Fix Codex automatic resume across account switches (#9696)

* fix(codex): preserve session home on automatic resume

* fix(codex): keep automatic resume homes launch-authoritative

* fix(codex): resume compressed rollout representations

* fix(codex): keep resume migration best effort

* fix(codex): reject stale automatic resume provenance

* fix(codex): enforce real home on automatic resume

* fix(codex): keep automatic resume home authoritative
This commit is contained in:
Brennan Benson
2026-07-21 00:01:47 -07:00
parent fea0004a01
commit a7bf398f3d
26 changed files with 919 additions and 38 deletions
@@ -9,7 +9,7 @@ import {
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { basename, join } from 'node:path'
import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume'
describe('prepareLegacySharedCodexSessionResume', () => {
@@ -47,6 +47,16 @@ describe('prepareLegacySharedCodexSessionResume', () => {
expect(statSync(targetPath).ino).toBe(statSync(rolloutPath).ino)
})
it('materializes a compressed legacy rollout without changing its representation', async () => {
const compressedPath = `${rolloutPath}.zst`
rmSync(rolloutPath)
rolloutPath = compressedPath
writeFileSync(rolloutPath, 'compressed-rollout', 'utf-8')
await expect(prepare()).resolves.toEqual({ useRealCodexHome: true })
expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('compressed-rollout')
})
it('coalesces concurrent resumes of the same legacy rollout', async () => {
const [first, second] = await Promise.all([prepare(), prepare()])
@@ -129,14 +139,7 @@ describe('prepareLegacySharedCodexSessionResume', () => {
}
function targetRolloutPath(): string {
return join(
systemHome,
'sessions',
'2026',
'07',
'20',
'rollout-2026-07-20T12-00-00-session.jsonl'
)
return join(systemHome, 'sessions', '2026', '07', '20', basename(rolloutPath))
}
})
@@ -162,7 +162,7 @@ function isLegacyRolloutRelativePath(relativePath: string): boolean {
/^\d{4}$/.test(parts[0] ?? '') &&
/^\d{2}$/.test(parts[1] ?? '') &&
/^\d{2}$/.test(parts[2] ?? '') &&
/^rollout-.+\.jsonl$/.test(parts[3] ?? '')
/^rollout-.+\.jsonl(?:\.zst)?$/.test(parts[3] ?? '')
)
}
+29 -1
View File
@@ -58,6 +58,34 @@ export async function* listCodexSessionJsonlFilesIncrementally(
rootPath: string,
options: CodexSessionBridgeIncrementalOptions,
onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise<void>
): AsyncGenerator<string> {
yield* listCodexSessionFilesIncrementally(
rootPath,
options,
(fileName) => fileName.endsWith('.jsonl'),
onDirectoryError
)
}
/** Yields both physical representations that current Codex can resume. */
export async function* listCodexSessionRolloutFilesIncrementally(
rootPath: string,
options: CodexSessionBridgeIncrementalOptions,
onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise<void>
): AsyncGenerator<string> {
yield* listCodexSessionFilesIncrementally(
rootPath,
options,
(fileName) => fileName.endsWith('.jsonl') || fileName.endsWith('.jsonl.zst'),
onDirectoryError
)
}
async function* listCodexSessionFilesIncrementally(
rootPath: string,
options: CodexSessionBridgeIncrementalOptions,
isSessionFile: (fileName: string) => boolean,
onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise<void>
): AsyncGenerator<string> {
const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE)
const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS)
@@ -75,7 +103,7 @@ export async function* listCodexSessionJsonlFilesIncrementally(
const childPath = join(currentDirectory, entry.name)
if (entry.isDirectory()) {
pendingDirectories.push(childPath)
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
} else if (entry.isFile() && isSessionFile(entry.name)) {
yield childPath
}
entriesSinceYield += 1
@@ -0,0 +1,226 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
findTrustedCodexSessionResume,
resolveTrustedCodexSessionResumeHome
} from './codex-session-resume-home'
const tempRoots: string[] = []
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
describe('resolveTrustedCodexSessionResumeHome', () => {
it('returns the trusted home containing a persisted rollout', () => {
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl',
trustedCodexHomes: ['/managed/account/home', '/Users/example/.codex'],
fileIsRegular: () => true
})
).toBe('/Users/example/.codex')
})
it('accepts Windows paths case-insensitively', () => {
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: 'C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl',
trustedCodexHomes: ['c:\\users\\example\\.codex'],
fileIsRegular: () => true
})
).toBe('c:\\users\\example\\.codex')
})
it('rejects paths outside trusted homes or outside the rollout layout', () => {
const fileIsRegular = vi.fn((): boolean => true)
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: '/tmp/sessions/2026/07/20/rollout-a.jsonl',
trustedCodexHomes: ['/Users/example/.codex'],
fileIsRegular
})
).toBeNull()
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: '/Users/example/.codex/sessions/index.jsonl',
trustedCodexHomes: ['/Users/example/.codex'],
fileIsRegular
})
).toBeNull()
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath:
'/Users/example/.codex/sessions/2026/07/20/rollout-a/../../../../outside.jsonl',
trustedCodexHomes: ['/Users/example/.codex'],
fileIsRegular
})
).toBeNull()
expect(fileIsRegular).not.toHaveBeenCalled()
})
it('rejects a trusted-looking path when the rollout no longer exists', () => {
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl',
trustedCodexHomes: ['/Users/example/.codex'],
fileIsRegular: () => false
})
).toBeNull()
})
it('requires the transcript provenance to name a regular rollout file', () => {
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-'))
tempRoots.push(homePath)
const rolloutDirectory = join(homePath, 'sessions', '2026', '07', '20', 'rollout-a.jsonl')
mkdirSync(rolloutDirectory, { recursive: true })
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: rolloutDirectory,
trustedCodexHomes: [homePath]
})
).toBeNull()
const rolloutFile = join(homePath, 'sessions', '2026', '07', '20', 'rollout-b.jsonl')
writeFileSync(rolloutFile, '{}\n')
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: rolloutFile,
trustedCodexHomes: [homePath]
})
).toBe(homePath)
})
it('follows Codex when a persisted plain rollout was compressed in place', async () => {
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-'))
tempRoots.push(homePath)
const plainPath = join(
homePath,
'sessions',
'2026',
'07',
'20',
'rollout-2026-07-20T12-00-00-session.jsonl'
)
const compressedPath = `${plainPath}.zst`
mkdirSync(join(plainPath, '..'), { recursive: true })
writeFileSync(compressedPath, 'compressed-rollout')
await expect(
findTrustedCodexSessionResume({
sessionId: 'session-a',
transcriptPath: plainPath,
trustedCodexHomes: [homePath]
})
).resolves.toEqual({ homePath, transcriptPath: compressedPath })
writeFileSync(plainPath, 'active-rollout')
await expect(
findTrustedCodexSessionResume({
sessionId: 'session-a',
transcriptPath: compressedPath,
trustedCodexHomes: [homePath]
})
).resolves.toEqual({ homePath, transcriptPath: plainPath })
})
it('finds compressed rollouts for legacy records without transcript provenance', async () => {
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-'))
tempRoots.push(homePath)
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
const compressedPath = join(
homePath,
'sessions',
'2026',
'07',
'20',
`rollout-2026-07-20T12-00-00-${sessionId}.jsonl.zst`
)
mkdirSync(join(compressedPath, '..'), { recursive: true })
writeFileSync(compressedPath, 'compressed-rollout')
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: undefined,
trustedCodexHomes: [homePath]
})
).resolves.toEqual({ homePath, transcriptPath: compressedPath })
})
it('finds older saved sessions by id when transcript provenance is absent', async () => {
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
const rolloutPath = `/managed/account/home/sessions/2026/07/20/rollout-2026-07-20T15-50-19-${sessionId}.jsonl`
const listSessionFiles = async function* (sessionsRoot: string): AsyncIterable<string> {
if (sessionsRoot === '/managed/account/home/sessions') {
yield `/managed/account/home/sessions/misplaced-${sessionId}.jsonl`
yield rolloutPath
}
}
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: undefined,
trustedCodexHomes: ['/Users/example/.codex', '/managed/account/home'],
listSessionFiles
})
).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath: rolloutPath })
})
it('does not scan session trees when exact transcript provenance is valid', async () => {
const transcriptPath =
'/managed/account/home/sessions/2026/07/20/rollout-2026-07-20-session.jsonl'
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
throw new Error('must not scan')
})
await expect(
findTrustedCodexSessionResume({
sessionId: 'session-a',
transcriptPath,
trustedCodexHomes: ['/managed/account/home'],
fileIsRegular: () => true,
listSessionFiles
})
).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath })
expect(listSessionFiles).not.toHaveBeenCalled()
})
it('does not replace rejected transcript provenance with a same-id rollout from another home', async () => {
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
throw new Error('must not scan')
})
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: `/managed/origin/home/sessions/2026/07/20/rollout-${sessionId}.jsonl`,
trustedCodexHomes: ['/managed/origin/home', '/managed/other/home'],
fileIsRegular: () => false,
listSessionFiles
})
).resolves.toBeNull()
expect(listSessionFiles).not.toHaveBeenCalled()
})
it('does not scan homes for an untrusted legacy session id shape', async () => {
const listSessionFiles = (): AsyncIterable<string> => {
throw new Error('must not scan')
}
await expect(
findTrustedCodexSessionResume({
sessionId: '../session',
transcriptPath: undefined,
trustedCodexHomes: ['/Users/example/.codex'],
listSessionFiles
})
).resolves.toBeNull()
})
})
+135
View File
@@ -0,0 +1,135 @@
import { existsSync, lstatSync } from 'node:fs'
import { join } from 'node:path'
import {
getRuntimePathBasename,
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../shared/cross-platform-path'
import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing'
// Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials.
const ROLLOUT_RELATIVE_PATH = /^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl(?:\.zst)?$/
function isCodexRolloutInsideSessionsRoot(sessionsRoot: string, filePath: string): boolean {
const relativePath = relativePathInsideRoot(sessionsRoot, filePath)
return Boolean(relativePath && ROLLOUT_RELATIVE_PATH.test(relativePath.replace(/\\/g, '/')))
}
function isRegularFile(filePath: string): boolean {
try {
return lstatSync(filePath).isFile()
} catch {
return false
}
}
function resolveExistingRolloutPath(
transcriptPath: string,
fileIsRegular: (filePath: string) => boolean
): string | null {
const plainPath = transcriptPath.endsWith('.jsonl.zst')
? transcriptPath.slice(0, -'.zst'.length)
: transcriptPath.endsWith('.jsonl')
? transcriptPath
: null
if (!plainPath) {
return fileIsRegular(transcriptPath) ? transcriptPath : null
}
if (fileIsRegular(plainPath)) {
return plainPath
}
const compressedPath = `${plainPath}.zst`
return fileIsRegular(compressedPath) ? compressedPath : null
}
function resolveTrustedCodexSessionResume(args: {
transcriptPath: string | undefined
trustedCodexHomes: readonly string[]
fileIsRegular?: (filePath: string) => boolean
}): { homePath: string; transcriptPath: string } | null {
const persistedPath = args.transcriptPath?.trim()
if (!persistedPath) {
return null
}
for (const homePath of args.trustedCodexHomes) {
const sessionsRoot = join(homePath, 'sessions')
if (!isCodexRolloutInsideSessionsRoot(sessionsRoot, persistedPath)) {
continue
}
const transcriptPath = resolveExistingRolloutPath(
persistedPath,
args.fileIsRegular ?? isRegularFile
)
if (transcriptPath) {
return { homePath, transcriptPath }
}
}
return null
}
export function resolveTrustedCodexSessionResumeHome(args: {
transcriptPath: string | undefined
trustedCodexHomes: readonly string[]
fileIsRegular?: (filePath: string) => boolean
}): string | null {
return resolveTrustedCodexSessionResume(args)?.homePath ?? null
}
export async function findTrustedCodexSessionResume(args: {
sessionId: string
transcriptPath: string | undefined
trustedCodexHomes: readonly string[]
fileIsRegular?: (filePath: string) => boolean
listSessionFiles?: (sessionsRoot: string) => AsyncIterable<string>
}): Promise<{ homePath: string; transcriptPath: string } | null> {
const directSession = resolveTrustedCodexSessionResume(args)
if (directSession) {
return directSession
}
if (args.transcriptPath?.trim()) {
// Why: stale/rejected provenance must not select a same-id rollout under different account credentials; scanning is legacy-only.
return null
}
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(args.sessionId)) {
return null
}
const listSessionFiles =
args.listSessionFiles ??
((sessionsRoot: string) =>
listCodexSessionRolloutFilesIncrementally(sessionsRoot, { batchSize: 64, yieldMs: 0 }))
const expectedSuffix = `-${args.sessionId}.jsonl`.toLowerCase()
const seenHomes = new Set<string>()
for (const homePath of args.trustedCodexHomes) {
const comparisonHome = normalizeRuntimePathForComparison(homePath)
if (seenHomes.has(comparisonHome)) {
continue
}
seenHomes.add(comparisonHome)
const sessionsRoot = join(homePath, 'sessions')
if (!args.listSessionFiles && !existsSync(sessionsRoot)) {
continue
}
for await (const filePath of listSessionFiles(sessionsRoot)) {
const plainFilePath = filePath.endsWith('.jsonl.zst')
? filePath.slice(0, -'.zst'.length)
: filePath
// Why: the directory entry already proves the compressed file exists; only probe its preferred plain sibling.
const preferredFilePath =
plainFilePath !== filePath && (args.fileIsRegular ?? isRegularFile)(plainFilePath)
? plainFilePath
: filePath
const plainFileName = getRuntimePathBasename(preferredFilePath)
.toLowerCase()
.replace(/\.zst$/, '')
if (
isCodexRolloutInsideSessionsRoot(sessionsRoot, preferredFilePath) &&
plainFileName.endsWith(expectedSuffix)
) {
return { homePath, transcriptPath: preferredFilePath }
}
}
}
return null
}
+85 -1
View File
@@ -147,6 +147,10 @@ import { startCodexSessionIndexHealInBackground } from './codex/codex-session-in
import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler'
import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume'
import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home'
import { findTrustedCodexSessionResume } from './codex/codex-session-resume-home'
import { getSystemCodexHomePath } from './codex/codex-home-paths'
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume'
import { getDefaultWslDistro } from './wsl'
import { ClaudeAccountService } from './claude-accounts/service'
import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service'
@@ -784,6 +788,84 @@ function prepareCodexRuntimeHomeForLaunch(
return runtimeHomePath
}
async function prepareCodexSessionResumeForLaunch(args: {
providerSession: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
workspacePath?: string
}): Promise<{ codexHomePath: string | null } | null> {
if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) {
return null
}
const systemHomePath = getSystemCodexHomePath()
// Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth.
const trustedHomes = [
systemHomePath,
...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery()
]
const sessionSource = await findTrustedCodexSessionResume({
sessionId: args.providerSession.id,
transcriptPath: args.providerSession.transcriptPath,
trustedCodexHomes: trustedHomes
})
if (!sessionSource) {
if (args.providerSession.transcriptPath) {
throw new Error(
'Orca could not verify the originating Codex session file, so automatic resume was stopped to avoid using a different account.'
)
}
return null
}
let migrated = { useRealCodexHome: false }
try {
migrated = await prepareLegacySharedCodexSessionResume(
{
agent: 'codex',
executionHostId: 'local',
filePath: sessionSource.transcriptPath,
codexHome: sessionSource.homePath
},
{
isHostSystemDefaultRealHome: () => codexRuntimeHome!.isHostSystemDefaultRealHome(),
systemCodexHomePath: systemHomePath
}
)
} catch (error) {
// Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home.
console.warn(
'[codex-session-resume] Legacy rollout migration failed; using origin home:',
error
)
}
const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath
if (args.workspacePath) {
try {
markCodexProjectTrusted(args.workspacePath)
} catch (error) {
console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error)
}
}
const isSystemHome =
normalizeRuntimePathForComparison(resumeHome) ===
normalizeRuntimePathForComparison(systemHomePath)
const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings())
try {
if (isSystemHome) {
ensureRealHomeCodexHookState({ hooksEnabled, userDataPath: app.getPath('userData') })
} else if (hooksEnabled) {
codexHookService.install(resumeHome)
} else {
codexHookService.refreshRuntimeUserHooks(resumeHome)
}
} catch (error) {
// Why: hook repair is best-effort; session provenance must still win over the currently selected home.
console.warn('[codex-hook-service] failed to prepare automatic resume home:', error)
}
return { codexHomePath: resumeHome }
}
// Why: restore the window the close handler may have hidden to tray, or reopen it (dock-reactivation style) if fully torn down.
function showMainWindowFromTray(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -1050,6 +1132,7 @@ function openMainWindow(): BrowserWindow {
prepareCodexRuntimeHomeForLaunch,
(target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target),
{
prepareCodexSessionResume: prepareCodexSessionResumeForLaunch,
awaitLocalPtyStartup: () => localPtyStartupReady,
awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady,
onBeforeRendererReload: ({ ignoreCache, webContentsId }) => {
@@ -2195,7 +2278,8 @@ app.whenReady().then(async () => {
prepareCodexRuntimeHomeForLaunch,
() => store!.getSettings(),
(target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target),
store
store,
prepareCodexSessionResumeForLaunch
)
// Why: headless servers can't mount <webview> panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest.
if (headlessBrowserDisplayAvailable) {
+202
View File
@@ -931,6 +931,111 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('resumes an automatic Codex session from its prepared originating home', async () => {
const selectedHome = vi.fn(() => '/managed/current/home')
const prepareResume = vi.fn(async () => ({ codexHomePath: '/managed/origin/home' }))
registerPtyHandlers(
mainWindow as never,
undefined,
selectedHome,
undefined,
undefined,
undefined,
{ prepareCodexSessionResume: prepareResume }
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'codex resume session-a',
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl'
}
})
const env = spawnMock.mock.calls.at(-1)![2].env as Record<string, string>
expect(prepareResume).toHaveBeenCalledWith(
expect.objectContaining({
providerSession: expect.objectContaining({ id: 'session-a' }),
target: { runtime: 'host' }
})
)
expect(selectedHome).not.toHaveBeenCalled()
expect(env.CODEX_HOME).toBe('/managed/origin/home')
expect(env.ORCA_CODEX_HOME).toBe('/managed/origin/home')
})
it('overrides an unmarked custom home when the resumed session originated in real home', async () => {
const selectedHome = vi.fn(() => '/managed/current/home')
const systemHome = '/Users/example/.codex'
registerPtyHandlers(
mainWindow as never,
undefined,
selectedHome,
undefined,
undefined,
undefined,
{ prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) }
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'codex resume session-a',
env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'],
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl'
}
})
const env = spawnMock.mock.calls.at(-1)![2].env as Record<string, string>
expect(selectedHome).not.toHaveBeenCalled()
expect(env.CODEX_HOME).toBe(systemHome)
expect(env.ORCA_CODEX_HOME).toBe(systemHome)
expect(env.REMOVE_ME).toBeUndefined()
})
it('does not fall back to the selected account when automatic resume provenance is rejected', async () => {
const selectedHome = vi.fn(() => '/managed/current/home')
registerPtyHandlers(
mainWindow as never,
undefined,
selectedHome,
undefined,
undefined,
undefined,
{
prepareCodexSessionResume: async () => {
throw new Error('origin unavailable')
}
}
)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'codex resume session-a',
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl'
}
})
).rejects.toThrow('origin unavailable')
expect(selectedHome).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
})
it('prepares Codex launch state for the workspace before spawning an interactive tab', async () => {
const workspacePath = '/repo/worktrees/new-feature'
const resolveHome = vi.fn(
@@ -1729,6 +1834,103 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('overrides an unmarked custom home for an authoritative daemon resume', async () => {
const daemonSpawn = setupDaemonAdapter()
const selectedHome = vi.fn(() => '/managed/current/home')
const systemHome = '/Users/example/.codex'
handlers.clear()
registerPtyHandlers(
mainWindow as never,
undefined,
selectedHome,
undefined,
undefined,
undefined,
{ prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) }
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'codex resume session-a',
env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'],
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: `${systemHome}/sessions/2026/07/20/rollout-a.jsonl`
}
})
const env = daemonSpawn.mock.calls.at(-1)![0].env
expect(selectedHome).not.toHaveBeenCalled()
expect(env.CODEX_HOME).toBe(systemHome)
expect(env.ORCA_CODEX_HOME).toBe(systemHome)
expect(env.REMOVE_ME).toBeUndefined()
})
it('keeps the authoritative home for runtime-created daemon resumes', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
command: string
env: Record<string, string>
envToDelete: string[]
launchAgent: 'codex'
resumeProviderSession: {
key: 'session_id'
id: string
transcriptPath: string
}
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
const systemHome = '/Users/example/.codex'
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{ prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) }
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
command: 'codex resume session-a',
env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' },
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'],
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: `${systemHome}/sessions/2026/07/20/rollout-a.jsonl`
}
})
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.CODEX_HOME).toBe(systemHome)
expect(spawnOptions.env.ORCA_CODEX_HOME).toBe(systemHome)
expect(spawnOptions.env.REMOVE_ME).toBeUndefined()
expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME')
expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME')
expect(spawnOptions.envToDelete).toContain('REMOVE_ME')
})
it('prepares Codex project trust before a daemon-backed interactive launch', async () => {
const workspacePath = '/repo/worktrees/new-feature'
const resolveHome = vi.fn(
+95 -16
View File
@@ -32,7 +32,11 @@ import {
} from '../../shared/pty-delivery-diagnostics'
import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
import { isTuiAgent } from '../../shared/tui-agent-config'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import {
normalizeAgentProviderSession,
type AgentProviderSessionMetadata,
type SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import {
isWslShellName,
@@ -624,6 +628,12 @@ export type GetSelectedCodexHomePath = (
launchEnv?: NodeJS.ProcessEnv,
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
) => string | null
export type PrepareCodexSessionResume = (args: {
providerSession: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
workspacePath?: string
}) => Promise<{ codexHomePath: string | null } | null>
type PrepareClaudeAuth = (
target?: ClaudeAccountSelectionTarget
) => Promise<ClaudeRuntimeAuthPreparation>
@@ -747,6 +757,12 @@ function mergePtyEnvDeletions(
return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys]))
}
function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined {
// Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account.
const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME')
return filtered?.length ? filtered : undefined
}
function getInheritedAgentHookEnvKeysToDelete(
spawnEnv: Record<string, string> | undefined
): string[] {
@@ -1381,6 +1397,7 @@ export function registerPtyHandlers(
prepareClaudeAuth?: PrepareClaudeAuth,
store?: Store,
options?: {
prepareCodexSessionResume?: PrepareCodexSessionResume
awaitLocalPtyStartup?: () => Promise<void>
awaitLocalPtyProviderStartup?: () => Promise<void>
// Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787).
@@ -1453,10 +1470,12 @@ export function registerPtyHandlers(
: { runtime: 'host' }
const selectedCodexHomePath = getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: ctx?.cwd,
launchAgent: ctx?.launchAgent
}) ?? null
ctx?.codexHomePathOverride
? ctx.codexHomePathOverride.value
: (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: ctx?.cwd,
launchAgent: ctx?.launchAgent
}) ?? null)
)
const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath
const env = buildPtyHostEnv(id, baseEnv, {
@@ -2699,6 +2718,29 @@ export function registerPtyHandlers(
}
}
const prepareCodexResumeHome = (args: {
connectionId?: string | null
launchAgent?: TuiAgent
providerSession?: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
workspacePath?: string
}): Promise<{ codexHomePath: string | null } | null> | null => {
if (args.connectionId || args.launchAgent !== 'codex' || !options?.prepareCodexSessionResume) {
return null
}
const providerSession = normalizeAgentProviderSession(args.providerSession)
if (!providerSession) {
return null
}
return options.prepareCodexSessionResume({
providerSession,
target: args.target,
launchEnv: args.launchEnv,
workspacePath: args.workspacePath
})
}
// Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them.
runtime?.setPtyController({
spawn: async (args) => {
@@ -2729,6 +2771,15 @@ export function registerPtyHandlers(
cwd,
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
)
const codexResumePreparation = prepareCodexResumeHome({
connectionId: args.connectionId,
launchAgent: args.launchAgent,
providerSession: args.resumeProviderSession,
target: codexSelectionTarget,
launchEnv: args.env,
workspacePath: cwd
})
const codexResumeHome = codexResumePreparation ? await codexResumePreparation : null
const claudeAuth =
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
@@ -2798,10 +2849,12 @@ export function registerPtyHandlers(
const selectedCodexHomePath = isDaemonHostSpawn
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
workspacePath: cwd,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
}) ?? null
codexResumeHome
? codexResumeHome.codexHomePath
: (getSelectedCodexHomePath?.(codexSelectionTarget, env, {
workspacePath: cwd,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
}) ?? null)
)
: null
const skipCodexHomeEnv =
@@ -2849,6 +2902,9 @@ export function registerPtyHandlers(
env,
...(isMintedSessionId ? { isNewSession: true } : {})
}
if (!isDaemonHostSpawn && codexResumeHome) {
spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath }
}
spawnOptions.envToDelete = mergePtyEnvDeletions(
mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []),
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : []
@@ -2865,6 +2921,9 @@ export function registerPtyHandlers(
'ORCA_CODEX_HOME'
])
}
if (codexResumeHome?.codexHomePath) {
spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete)
}
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
if (args.command !== undefined) {
@@ -3466,6 +3525,7 @@ export function registerPtyHandlers(
command?: string
commandDelivery?: 'renderer' | 'provider'
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
@@ -3686,13 +3746,24 @@ export function registerPtyHandlers(
cwd,
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
)
const codexResumePreparation = prepareCodexResumeHome({
connectionId: args.connectionId,
launchAgent: args.launchAgent,
providerSession: args.resumeProviderSession,
target: codexSelectionTarget,
launchEnv: baseEnv,
workspacePath: cwd
})
const codexResumeHome = codexResumePreparation ? await codexResumePreparation : null
const selectedCodexHomePath = isDaemonHostSpawn
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: cwd,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
}) ?? null
codexResumeHome
? codexResumeHome.codexHomePath
: (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: cwd,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined
}) ?? null)
)
: null
const skipCodexHomeEnv =
@@ -3754,7 +3825,7 @@ export function registerPtyHandlers(
const envToDelete = claudeAuth?.stripAuthEnv
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined
const combinedEnvToDelete = mergePtyEnvDeletions(
let combinedEnvToDelete = mergePtyEnvDeletions(
mergePtyEnvDeletions(
mergePtyEnvDeletions(
mergePtyEnvDeletions(
@@ -3769,6 +3840,9 @@ export function registerPtyHandlers(
// main cannot safely decide ownership for a process it may not parent.
stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : []
)
if (codexResumeHome?.codexHomePath) {
combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete)
}
deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete)
promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath)
const spawnOptions: PtySpawnOptions = {
@@ -3778,6 +3852,9 @@ export function registerPtyHandlers(
env: spawnEnv,
...(isMintedSessionId ? { isNewSession: true } : {})
}
if (!isDaemonHostSpawn && codexResumeHome) {
spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath }
}
if (combinedEnvToDelete) {
spawnOptions.envToDelete = combinedEnvToDelete
}
@@ -4888,7 +4965,8 @@ export function registerHeadlessPtyRuntime(
getSelectedCodexHomePath?: GetSelectedCodexHomePath,
getSettings?: () => GlobalSettings,
prepareClaudeAuth?: PrepareClaudeAuth,
store?: Store
store?: Store,
prepareCodexSessionResume?: PrepareCodexSessionResume
): void {
// Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals.
const headlessWindow = {
@@ -4905,7 +4983,8 @@ export function registerHeadlessPtyRuntime(
getSelectedCodexHomePath,
getSettings,
prepareClaudeAuth,
store
store,
{ prepareCodexSessionResume }
)
}
+2
View File
@@ -471,6 +471,7 @@ export type LocalPtyProviderOptions = {
ctx?: {
command?: string
launchAgent?: PtySpawnOptions['launchAgent']
codexHomePathOverride?: PtySpawnOptions['codexHomePathOverride']
cwd?: string
shellPath?: string
isWsl?: boolean
@@ -673,6 +674,7 @@ export class LocalPtyProvider implements IPtyProvider {
? this.opts.buildSpawnEnv(id, spawnEnv, {
command: args.command,
launchAgent: args.launchAgent,
codexHomePathOverride: args.codexHomePathOverride,
cwd,
shellPath,
isWsl: isWslShell,
+2
View File
@@ -56,6 +56,8 @@ export type PtySpawnOptions = {
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
/** Main-validated home provenance for an automatic Codex session resume. */
codexHomePathOverride?: { value: string | null }
command?: string
commandDelivery?: 'renderer' | 'provider'
startupCommandDelivery?: StartupCommandDelivery
+10 -1
View File
@@ -151,7 +151,10 @@ import {
parseExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../shared/agent-session-resume'
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events'
import type { SshConnectionState } from '../../shared/ssh-types'
@@ -1075,6 +1078,7 @@ type TerminalCreateOptions = {
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: WorktreeStartupLaunch['launchConfig']
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
@@ -1268,6 +1272,7 @@ type RuntimePtyController = {
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
env?: Record<string, string>
envToDelete?: string[]
resumeProviderSession?: AgentProviderSessionMetadata
telemetry?: WorktreeStartupLaunch['telemetry']
connectionId?: string | null
worktreeId?: string
@@ -19205,6 +19210,7 @@ export class OrcaRuntimeService {
launchOpts.envToDelete,
agentTeamsPlan?.envToDelete
),
resumeProviderSession: launchOpts.resumeProviderSession,
telemetry: launchOpts.telemetry,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
@@ -19348,6 +19354,9 @@ export class OrcaRuntimeService {
cwd,
...(launchOpts.env ? { env: launchOpts.env } : {}),
...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}),
...(launchOpts.resumeProviderSession
? { resumeProviderSession: launchOpts.resumeProviderSession }
: {}),
...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
+10
View File
@@ -913,6 +913,13 @@ const TerminalCreateParams = z.object({
agentEnv: z.record(z.string(), z.string())
})
.optional(),
resumeProviderSession: z
.object({
key: z.enum(['session_id', 'conversation_id']),
id: z.string().min(1).max(512),
transcriptPath: z.string().min(1).max(32_768).optional()
})
.optional(),
launchToken: OptionalString,
launchAgent: z.string().refine(isTuiAgent).optional(),
title: OptionalString,
@@ -1348,6 +1355,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
env: params.env,
envToDelete: params.envToDelete,
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.resumeProviderSession
? { resumeProviderSession: params.resumeProviderSession }
: {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
title: params.title,
@@ -12,7 +12,12 @@ import type {
import { registerRepoHandlers } from '../ipc/repos'
import { registerWorktreeHandlers } from '../ipc/worktrees'
import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup'
import { getLocalPtyProvider, registerPtyHandlers, type GetSelectedCodexHomePath } from '../ipc/pty'
import {
getLocalPtyProvider,
registerPtyHandlers,
type GetSelectedCodexHomePath,
type PrepareCodexSessionResume
} from '../ipc/pty'
import { registerDaemonManagementHandlers } from '../ipc/pty-management'
import { registerSshHandlers } from '../ipc/ssh'
import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace'
@@ -70,6 +75,7 @@ export function attachMainWindowServices(
target?: ClaudeAccountSelectionTarget
) => Promise<ClaudeRuntimeAuthPreparation>,
options?: {
prepareCodexSessionResume?: PrepareCodexSessionResume
awaitLocalPtyStartup?: () => Promise<void>
awaitLocalPtyProviderStartup?: () => Promise<void>
onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void
@@ -93,6 +99,7 @@ export function attachMainWindowServices(
prepareClaudeAuth,
store,
{
prepareCodexSessionResume: options?.prepareCodexSessionResume,
awaitLocalPtyStartup: options?.awaitLocalPtyStartup,
awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup,
isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight
+6 -1
View File
@@ -63,7 +63,10 @@ import type { TaskSourceContext } from '../shared/task-source-context'
import type { LinearIssueAttributeFilter } from '../shared/linear-issue-attribute-filter'
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
import type { StartupCommandDelivery } from '../shared/codex-startup-delivery'
import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../shared/agent-session-resume'
import type {
LocalhostWorktreeLabelResult,
LocalhostWorktreeLabelRoute
@@ -1290,6 +1293,7 @@ export type PreloadApi = {
envToDelete?: string[]
command?: string
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
@@ -2878,6 +2882,7 @@ export type PreloadApi = {
cwd?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
+7 -1
View File
@@ -14,7 +14,10 @@ import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
import type { StartupCommandDelivery } from '../shared/codex-startup-delivery'
import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../shared/agent-session-resume'
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
import type {
@@ -797,6 +800,7 @@ const api = {
envToDelete?: string[]
command?: string
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
@@ -3487,6 +3491,7 @@ const api = {
cwd?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
@@ -3510,6 +3515,7 @@ const api = {
cwd?: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
@@ -6,7 +6,10 @@ import type { EventProps } from '../../../../shared/telemetry-events'
import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol'
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
export type PtyConnectionDeps = {
@@ -22,6 +25,7 @@ export type PtyConnectionDeps = {
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
draftPrompt?: string
@@ -7444,7 +7444,11 @@ describe('connectPanePty', () => {
updatedAt: 1,
stateStartedAt: 1,
stateHistory: [],
providerSession: { key: 'session_id', id: 'codex-session-1' }
providerSession: {
key: 'session_id',
id: 'codex-session-1',
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl'
}
}
}
} as StoreState
@@ -7470,6 +7474,11 @@ describe('connectPanePty', () => {
expect.objectContaining({
sessionId: 'lost-pty',
command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'",
resumeProviderSession: {
key: 'session_id',
id: 'codex-session-1',
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl'
},
env: expect.objectContaining({
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: 'tab-1',
@@ -235,6 +235,7 @@ import {
agentProviderSessionsEqual,
isResumableTuiAgent,
normalizeAgentProviderSession,
type AgentProviderSessionMetadata,
type ResumableTuiAgent,
type SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
@@ -481,6 +482,7 @@ type FreshSpawnOptions = {
type ColdRestoreAgentResumeStartup = PendingStartupCommand & {
agent: ResumableTuiAgent
resumeProviderSession: AgentProviderSessionMetadata
launchConfig: NonNullable<ReturnType<typeof buildAgentResumeStartupPlan>>['launchConfig']
launchToken: string
useLiveEntry: boolean
@@ -3348,6 +3350,9 @@ export function connectPanePty(
...(projectRuntime ? { projectRuntime } : {}),
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
...(paneStartup?.launchConfig ? { launchConfig: paneStartup.launchConfig } : {}),
...(paneStartup?.resumeProviderSession
? { resumeProviderSession: paneStartup.resumeProviderSession }
: {}),
...(launchToken ? { launchToken } : {}),
...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}),
...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}),
@@ -4487,6 +4492,7 @@ export function connectPanePty(
ORCA_AGENT_LAUNCH_TOKEN: coldRestoreLaunchToken
},
launchConfig: startupPlan.launchConfig,
resumeProviderSession: providerSession,
launchToken: coldRestoreLaunchToken,
useLiveEntry: Boolean(useLiveEntry),
hasSleepingRecord: Boolean(sleepingRecord),
@@ -4700,6 +4706,9 @@ export function connectPanePty(
? { env: mergeStartupEnvWithPaneIdentity(startupOverride.env) }
: {}),
...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}),
...(coldRestoreOverride
? { resumeProviderSession: coldRestoreOverride.resumeProviderSession }
: {}),
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
@@ -7552,6 +7561,9 @@ export function connectPanePty(
...(coldRestoreStartup?.launchConfig
? { launchConfig: coldRestoreStartup.launchConfig }
: {}),
...(coldRestoreStartup?.resumeProviderSession
? { resumeProviderSession: coldRestoreStartup.resumeProviderSession }
: {}),
...(coldRestoreStartup?.launchToken
? { launchToken: coldRestoreStartup.launchToken }
: {}),
@@ -7763,6 +7775,9 @@ export function connectPanePty(
...(coldRestoreStartup?.launchConfig
? { launchConfig: coldRestoreStartup.launchConfig }
: {}),
...(coldRestoreStartup?.resumeProviderSession
? { resumeProviderSession: coldRestoreStartup.resumeProviderSession }
: {}),
...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}),
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
@@ -1,5 +1,8 @@
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime'
import type { EventProps } from '../../../../shared/telemetry-events'
@@ -86,6 +89,7 @@ export type PtyTransport = {
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
@@ -144,6 +148,7 @@ export type IpcPtyTransportOptions = {
envToDelete?: string[]
command?: string
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
@@ -93,6 +93,21 @@ describe('createIpcPtyTransport', () => {
)
})
it('forwards automatic resume provenance to the PTY spawn', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
const resumeProviderSession = {
key: 'session_id' as const,
id: 'session-a',
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl'
}
const transport = createIpcPtyTransport({ resumeProviderSession })
await transport.connect({ url: '', callbacks: {} })
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ resumeProviderSession }))
})
it('leaves the transport silently unbound after a failed connect — sendInput drops with no write IPC (frozen-terminal repro)', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
@@ -468,6 +468,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
envToDelete,
command,
launchConfig,
resumeProviderSession,
launchToken,
launchAgent,
startupCommandDelivery,
@@ -686,6 +687,11 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
...((options.launchConfig ?? launchConfig)
? { launchConfig: options.launchConfig ?? launchConfig }
: {}),
...((options.resumeProviderSession ?? resumeProviderSession)
? {
resumeProviderSession: options.resumeProviderSession ?? resumeProviderSession
}
: {}),
...((options.launchToken ?? launchToken)
? { launchToken: options.launchToken ?? launchToken }
: {}),
@@ -1180,6 +1180,11 @@ describe('createRemoteRuntimePtyTransport', () => {
},
launchToken: 'fresh-token',
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-1',
transcriptPath: '/home/example/.codex/sessions/2026/07/20/rollout-a.jsonl'
},
callbacks: {}
})
@@ -1195,7 +1200,12 @@ describe('createRemoteRuntimePtyTransport', () => {
agentEnv: { CODEX_PROFILE: 'captured' }
},
launchToken: 'fresh-token',
launchAgent: 'codex'
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-1',
transcriptPath: '/home/example/.codex/sessions/2026/07/20/rollout-a.jsonl'
}
})
})
)
@@ -68,6 +68,7 @@ export function createRemoteRuntimePtyTransport(
env,
envToDelete,
launchConfig,
resumeProviderSession,
launchToken,
launchAgent,
worktreeId,
@@ -778,6 +779,7 @@ export function createRemoteRuntimePtyTransport(
const envToSend = options.env ?? env
const envToDeleteToSend = options.envToDelete ?? envToDelete
const launchConfigToSend = options.launchConfig ?? launchConfig
const resumeProviderSessionToSend = options.resumeProviderSession ?? resumeProviderSession
const launchTokenToSend = options.launchToken ?? launchToken
const launchAgentToSend = options.launchAgent ?? launchAgent
const created = await callRuntime<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
@@ -789,6 +791,9 @@ export function createRemoteRuntimePtyTransport(
...(envToSend !== undefined ? { env: envToSend } : {}),
...(envToDeleteToSend !== undefined ? { envToDelete: envToDeleteToSend } : {}),
...(launchConfigToSend !== undefined ? { launchConfig: launchConfigToSend } : {}),
...(resumeProviderSessionToSend !== undefined
? { resumeProviderSession: resumeProviderSessionToSend }
: {}),
...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}),
...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}),
tabId,
@@ -50,7 +50,10 @@ import type {
import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry'
import type { EventProps } from '../../../../shared/telemetry-events'
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
import {
buildFontFamily,
@@ -209,7 +212,9 @@ type UseTerminalPaneLifecycleDeps = {
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
draftPrompt?: string
+5
View File
@@ -1387,6 +1387,7 @@ export function useIpcEvents(): void {
cwd,
env,
launchConfig,
resumeProviderSession,
launchToken,
launchAgent,
viewMode,
@@ -1572,6 +1573,7 @@ export function useIpcEvents(): void {
command,
...(env ? { env } : {}),
...(launchConfig ? { launchConfig } : {}),
...(resumeProviderSession ? { resumeProviderSession } : {}),
...(launchToken ? { launchToken } : {}),
...(launchAgent ? { launchAgent } : {})
})
@@ -1723,6 +1725,9 @@ export function useIpcEvents(): void {
...(data.env ? { env: data.env } : {}),
...(data.envToDelete ? { envToDelete: data.envToDelete } : {}),
...(data.launchConfig ? { launchConfig: data.launchConfig } : {}),
...(data.resumeProviderSession
? { resumeProviderSession: data.resumeProviderSession }
: {}),
...(data.launchToken ? { launchToken: data.launchToken } : {}),
...(data.launchAgent ? { launchAgent: data.launchAgent } : {}),
...(data.startupCommandDelivery
+5 -1
View File
@@ -32,7 +32,10 @@ import type {
} from './mobile-markdown-document'
import type { RuntimeCapability } from './protocol-version'
import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types'
import type { SleepingAgentLaunchConfig } from './agent-session-resume'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from './agent-session-resume'
import type { StartupCommandDelivery } from './codex-startup-delivery'
export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult }
@@ -501,6 +504,7 @@ type RuntimeTerminalCreateBaseRequestPayload = {
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'