fix(terminal): derive Codex startup marker lifetime from resolved launch

This commit is contained in:
Brennan Benson
2026-09-16 21:32:17 -07:00
parent dce80b1def
commit 3310201848
11 changed files with 116 additions and 64 deletions
@@ -1,4 +1,6 @@
// Native Windows shell launch: PowerShell implementations, cmd.exe and Git Bash.
import { writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type * as LocalPtyUtils from '../providers/local-pty-utils'
@@ -348,6 +350,34 @@ describe('createPtySubprocess', () => {
)
})
it('abandons the default-home marker when Git Bash wrapper materialization fails', async () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
const userDataPath = process.env.ORCA_USER_DATA_PATH!
// A file occupying the wrapper directory deterministically prevents materialization.
mkdirSync(userDataPath, { recursive: true })
writeFileSync(join(userDataPath, 'shell-wrappers'), 'occupied')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
await createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
cwd: 'C:\\Users\\jin\\repo',
shellOverride: 'C:\\PortableGit\\bin\\bash.exe',
env: { [ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]: '1' }
})
} finally {
Object.defineProperty(process, 'platform', platform)
}
expect(spawnMock).toHaveBeenCalledTimes(1)
const [shell, args, options] = spawnMock.mock.calls[0]
expect(shell).toBe('C:\\PortableGit\\bin\\bash.exe')
expect(args).toEqual(['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'])
expect(options.env[ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]).toBeUndefined()
})
it('keeps the Git Bash wrapper without a managed Codex preflight', async () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
@@ -5,7 +5,7 @@ import {
} from '../../providers/macos-tcc-login-shell'
import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain'
import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job'
import { scrubCodexDefaultHomeMarkerForWindowsShell } from '../../pty/codex-default-home-shell-startup'
import { scrubCodexDefaultHomeMarkerForLaunch } from '../../pty/codex-default-home-shell-startup'
export type SpawnedDaemonPty = {
process: pty.IPty
@@ -62,7 +62,7 @@ export function spawnNativeDaemonPty(args: {
}
for (const attempt of args.windowsFallbackAttempts.slice(1)) {
try {
scrubCodexDefaultHomeMarkerForWindowsShell(args.env, attempt.shellPath)
scrubCodexDefaultHomeMarkerForLaunch(args.env, attempt.supportsCodexDefaultHomeAfterProfile)
const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd)
const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr)
console.warn(
@@ -36,7 +36,7 @@ import {
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query'
import {
ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV,
scrubCodexDefaultHomeMarkerForWindowsShell
scrubCodexDefaultHomeMarkerForLaunch
} from '../../pty/codex-default-home-shell-startup'
import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell'
import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready'
@@ -72,8 +72,6 @@ export function createPtyShellLaunchPlan(
let validationCwd = spawnCwd
if (process.platform === 'win32') {
// Why: execution-host reconciliation may preserve a daemon-only custom home
// by dropping the reset, but that must not also disable shell integration.
const useGitBashShellReadyWrapper =
opts.env?.[ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV] !== undefined
const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase()
@@ -117,8 +115,11 @@ export function createPtyShellLaunchPlan(
wslContext: resolvedWslContext,
startupCommand: opts.command
})
let supportsCodexDefaultHomeAfterProfile = false
const primaryAttempt = windowsFallbackAttempts[0]
if (primaryAttempt) {
supportsCodexDefaultHomeAfterProfile =
primaryAttempt.supportsCodexDefaultHomeAfterProfile === true
shellPath = primaryAttempt.shellPath
shellArgs = primaryAttempt.shellArgs
spawnCwd = primaryAttempt.effectiveCwd
@@ -134,6 +135,7 @@ export function createPtyShellLaunchPlan(
env.ORCA_CODEX_LAUNCH_PREFLIGHT,
useGitBashShellReadyWrapper
)
supportsCodexDefaultHomeAfterProfile = resolved.supportsCodexDefaultHomeAfterProfile === true
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
@@ -163,6 +165,8 @@ export function createPtyShellLaunchPlan(
env.ORCA_CODEX_LAUNCH_PREFLIGHT,
useGitBashShellReadyWrapper
)
supportsCodexDefaultHomeAfterProfile =
resolved.supportsCodexDefaultHomeAfterProfile === true
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
@@ -189,7 +193,7 @@ export function createPtyShellLaunchPlan(
if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') {
addOrcaWslInteropEnv(env)
}
scrubCodexDefaultHomeMarkerForWindowsShell(env, shellPath)
scrubCodexDefaultHomeMarkerForLaunch(env, supportsCodexDefaultHomeAfterProfile)
} else {
rescrubDaemonPtyEnvironment(env, opts)
const preferredShellPath = shellPath
@@ -45,6 +45,7 @@ export type LocalPtyLaunchPlan = {
shellArgs: string[]
effectiveCwd: string
validationCwd: string
supportsCodexDefaultHomeAfterProfile?: boolean
startupCommandDeliveredInShellArgs: boolean
windowsFallbackAttempts: ReturnType<typeof buildWindowsPowerShellSpawnAttempts>
shellReadyLaunch: ReturnType<typeof getShellLaunchConfig> | null
@@ -72,6 +73,7 @@ function finalizeLocalPtyLaunchPlan(
shellArgs: string[]
effectiveCwd: string
validationCwd: string
supportsCodexDefaultHomeAfterProfile?: boolean
startupCommandDeliveredInShellArgs?: boolean
windowsFallbackAttempts?: ReturnType<typeof buildWindowsPowerShellSpawnAttempts>
}
@@ -91,6 +93,7 @@ function finalizeLocalPtyLaunchPlan(
preferredWslContext: seed.preferredWslContext,
launchWslContext: seed.launchWslContext,
shellPath: shell.shellPath,
supportsCodexDefaultHomeAfterProfile: shell.supportsCodexDefaultHomeAfterProfile,
shellArgs: shell.shellArgs,
effectiveCwd: shell.effectiveCwd,
validationCwd: shell.validationCwd,
@@ -162,6 +165,7 @@ function createWindowsLocalPtyLaunchPlan(
if (primaryAttempt) {
return finalizeLocalPtyLaunchPlan(seed, {
shellPath: primaryAttempt.shellPath,
supportsCodexDefaultHomeAfterProfile: primaryAttempt.supportsCodexDefaultHomeAfterProfile,
shellArgs: primaryAttempt.shellArgs,
effectiveCwd: primaryAttempt.effectiveCwd,
validationCwd: primaryAttempt.validationCwd,
@@ -178,6 +182,7 @@ function createWindowsLocalPtyLaunchPlan(
)
return finalizeLocalPtyLaunchPlan(seed, {
shellPath,
supportsCodexDefaultHomeAfterProfile: resolved.supportsCodexDefaultHomeAfterProfile,
shellArgs: resolved.shellArgs,
effectiveCwd: resolved.effectiveCwd,
validationCwd: resolved.validationCwd,
@@ -229,6 +234,7 @@ export function createLocalPtyLaunchPlan(
const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd)
return finalizeLocalPtyLaunchPlan(seed, {
shellPath,
supportsCodexDefaultHomeAfterProfile: resolved.supportsCodexDefaultHomeAfterProfile,
shellArgs: resolved.shellArgs,
effectiveCwd: resolved.effectiveCwd,
validationCwd: resolved.validationCwd
@@ -632,6 +632,40 @@ describe('LocalPtyProvider', () => {
)
})
it('abandons the default-home marker when Git Bash wrapper writing fails', async () => {
spawnMock.mockClear()
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
statSyncMock.mockImplementation((path: string) => {
if (String(path).includes('shell-wrappers')) {
throw new Error('ENOENT')
}
return { isDirectory: () => true, mode: 0o755, size: 1 }
})
writeFileSyncMock.mockImplementation(() => {
throw new Error('ENOSPC')
})
provider.configure({
buildSpawnEnv: (_id, env) => ({
...env,
[ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]: '1'
})
})
await provider.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\Users\\jin\\repo',
shellOverride: 'C:\\PortableGit\\bin\\bash.exe'
})
expect(writeFileSyncMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledTimes(1)
const [shell, args, options] = spawnMock.mock.calls[0]
expect(shell).toBe('C:\\PortableGit\\bin\\bash.exe')
expect(args).toEqual(['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'])
expect(options.env[ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]).toBeUndefined()
})
it('keeps the Git Bash wrapper without a managed Codex preflight', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const originalProgramFiles = process.env.ProgramFiles
+4 -13
View File
@@ -1,3 +1,5 @@
import type { WindowsShellSpawnAttempt } from './windows-shell-fallback-chain'
export type { WindowsShellSpawnAttempt } from './windows-shell-fallback-chain'
import { basename, isAbsolute, join } from 'node:path'
import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs'
import type * as pty from 'node-pty'
@@ -7,7 +9,7 @@ import {
wrapShellSpawnForMacosTccAttribution
} from './macos-tcc-login-shell'
import { formatLocalPtyEnvironmentDiag } from './working-directory-validation'
import { scrubCodexDefaultHomeMarkerForWindowsShell } from '../pty/codex-default-home-shell-startup'
import { scrubCodexDefaultHomeMarkerForLaunch } from '../pty/codex-default-home-shell-startup'
export {
formatLocalPtyEnvironmentDiag,
@@ -111,17 +113,6 @@ export function ensureNodePtySpawnHelperExecutable(): void {
}
}
/** A pre-resolved Windows shell attempt: an absolute executable plus the launch
* args + cwd computed for it. Used to walk the PowerShell -> Windows PowerShell
* -> cmd.exe fallback chain when ConPTY rejects the primary shell. */
export type WindowsShellSpawnAttempt = {
shellPath: string
shellArgs: string[]
effectiveCwd: string
validationCwd: string
startupCommandDeliveredInShellArgs: boolean
}
export type ShellSpawnParams = {
shellPath: string
shellArgs: string[]
@@ -185,7 +176,7 @@ function spawnWindowsFallbackChain(
// Skip the first entry: it is the primary that already failed above.
for (const attempt of attempts.slice(1)) {
try {
scrubCodexDefaultHomeMarkerForWindowsShell(env, attempt.shellPath)
scrubCodexDefaultHomeMarkerForLaunch(env, attempt.supportsCodexDefaultHomeAfterProfile)
const proc = ptySpawn(attempt.shellPath, attempt.shellArgs, {
name: termName,
cols,
@@ -6,7 +6,7 @@ import { parseWslPath } from '../wsl'
import { isWindowsGitBashShellPath } from '../git-bash'
import {
ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV,
scrubCodexDefaultHomeMarkerForWindowsShell
scrubCodexDefaultHomeMarkerForLaunch
} from '../pty/codex-default-home-shell-startup'
import type { LocalPtyLaunchPlan } from './local-pty-launch-plan'
import {
@@ -41,6 +41,7 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: {
distro: codexHomeWslInfo.distro
}
)
plan.supportsCodexDefaultHomeAfterProfile = resolved.supportsCodexDefaultHomeAfterProfile
plan.shellArgs = resolved.shellArgs
plan.effectiveCwd = resolved.effectiveCwd
plan.validationCwd = resolved.validationCwd
@@ -72,7 +73,6 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: {
const shellBasename = pathWin32.basename(plan.shellPath).toLowerCase()
const codexLaunchPreflightCommand = env.ORCA_CODEX_LAUNCH_PREFLIGHT
const useGitBashShellReadyWrapper = env[ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV] !== undefined
scrubCodexDefaultHomeMarkerForWindowsShell(env, plan.shellPath)
if (
(codexLaunchPreflightCommand || useGitBashShellReadyWrapper) &&
((shellBasename === 'cmd.exe' && Boolean(codexLaunchPreflightCommand)) ||
@@ -91,9 +91,11 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: {
codexLaunchPreflightCommand,
useGitBashShellReadyWrapper
)
plan.supportsCodexDefaultHomeAfterProfile = resolved.supportsCodexDefaultHomeAfterProfile
plan.shellArgs = resolved.shellArgs
plan.effectiveCwd = resolved.effectiveCwd
plan.validationCwd = resolved.validationCwd
plan.startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true
}
scrubCodexDefaultHomeMarkerForLaunch(env, plan.supportsCodexDefaultHomeAfterProfile)
}
+13 -8
View File
@@ -32,20 +32,23 @@ const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH
function getGitBashLaunchCommand(
codexLaunchPreflightCommand?: string,
useGitBashShellReadyWrapper = false
): string {
): { command: string; supportsCodexDefaultHomeAfterProfile: boolean } {
if (!codexLaunchPreflightCommand && !useGitBashShellReadyWrapper) {
return GIT_BASH_UTF8_LOGIN_COMMAND
return { command: GIT_BASH_UTF8_LOGIN_COMMAND, supportsCodexDefaultHomeAfterProfile: false }
}
ensureShellReadyWrappersAt()
const wrapperArgs = getBashWrapperLaunchArgs()
if (!wrapperArgs) {
return GIT_BASH_UTF8_LOGIN_COMMAND
return { command: GIT_BASH_UTF8_LOGIN_COMMAND, supportsCodexDefaultHomeAfterProfile: false }
}
const bashArgs = [...wrapperArgs, '-i']
.map((arg) => (arg.startsWith('-') ? arg : quotePosixShell(arg.replace(/\\/g, '/'))))
.join(' ')
return `chcp.com 65001 >/dev/null 2>&1; exec "$BASH" ${bashArgs}`
return {
command: `chcp.com 65001 >/dev/null 2>&1; exec "$BASH" ${bashArgs}`,
supportsCodexDefaultHomeAfterProfile: true
}
}
/** Result of resolving a Windows shell to its launch args + effective cwd.
@@ -57,6 +60,8 @@ function getGitBashLaunchCommand(
* shellOverride never reached the daemon's shell-args branches. Sharing the
* decision here keeps both paths honest. */
export type WindowsShellLaunchArgs = {
/** Set only when the resolved launch actually installs the post-profile consumer. */
supportsCodexDefaultHomeAfterProfile?: boolean
shellArgs: string[]
/** True when the startup command was embedded in shellArgs and must not be
* written again through stdin. */
@@ -210,6 +215,7 @@ export function resolveWindowsShellLaunchArgs(
// Why base64 and not -Command: see powershell-osc133-bootstrap.ts (MDE review).
return {
shellArgs: ['-NoLogo', '-NoExit', '-EncodedCommand', powerShellCommand.encodedCommand],
supportsCodexDefaultHomeAfterProfile: true,
...(powerShellCommand.startupCommandDeliveredInShellArgs
? { startupCommandDeliveredInShellArgs: true }
: {}),
@@ -219,11 +225,10 @@ export function resolveWindowsShellLaunchArgs(
}
if (isWindowsGitBashShellPath(shellPath)) {
const launch = getGitBashLaunchCommand(codexLaunchPreflightCommand, useGitBashShellReadyWrapper)
return {
shellArgs: [
'-c',
getGitBashLaunchCommand(codexLaunchPreflightCommand, useGitBashShellReadyWrapper)
],
shellArgs: ['-c', launch.command],
supportsCodexDefaultHomeAfterProfile: launch.supportsCodexDefaultHomeAfterProfile,
effectiveCwd: nativeCwd,
validationCwd: nativeCwd
}
@@ -9,6 +9,7 @@ import {
/** A single attempt in the Windows shell-spawn fallback chain: the absolute
* executable plus the launch args + cwd computed for it. */
export type WindowsShellSpawnAttempt = {
supportsCodexDefaultHomeAfterProfile?: boolean
shellPath: string
shellArgs: string[]
effectiveCwd: string
@@ -32,6 +33,7 @@ function toAttempt(
)
return {
shellPath,
supportsCodexDefaultHomeAfterProfile: resolved.supportsCodexDefaultHomeAfterProfile,
shellArgs: resolved.shellArgs,
effectiveCwd: resolved.effectiveCwd,
validationCwd: resolved.validationCwd,
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV,
reconcileDaemonCodexDefaultHomeMarker,
scrubCodexDefaultHomeMarkerForWindowsShell
scrubCodexDefaultHomeMarkerForLaunch
} from './codex-default-home-shell-startup'
describe('reconcileDaemonCodexDefaultHomeMarker', () => {
@@ -63,29 +63,15 @@ describe('reconcileDaemonCodexDefaultHomeMarker', () => {
})
})
describe('scrubCodexDefaultHomeMarkerForWindowsShell', () => {
it.each([
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
'C:\\Program Files\\Git\\bin\\bash.exe'
])('keeps the one-shot marker for a consuming shell: %s', (shellPath) => {
const env = { [ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]: '1' }
scrubCodexDefaultHomeMarkerForWindowsShell(env, shellPath)
expect(env[ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]).toBe('1')
})
it.each(['cmd.exe', 'wsl.exe', 'C:\\msys64\\usr\\bin\\bash.exe'])(
'removes the marker before an unwrapped shell: %s',
(shellPath) => {
const env: Record<string, string> = {
[ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]: '1'
}
scrubCodexDefaultHomeMarkerForWindowsShell(env, shellPath)
expect(env[ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]).toBeUndefined()
describe('scrubCodexDefaultHomeMarkerForLaunch', () => {
it.each([true, false, undefined])(
'retains the marker only with a consumer: %s',
(supportsConsumer) => {
const env = { [ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]: '1' }
scrubCodexDefaultHomeMarkerForLaunch(env, supportsConsumer)
expect(env[ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]).toBe(
supportsConsumer ? '1' : undefined
)
}
)
})
@@ -1,6 +1,3 @@
import { win32 as pathWin32 } from 'node:path'
import { isWindowsGitBashShellPath } from '../git-bash'
export const ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV = 'ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE'
export const ORCA_CODEX_DEFAULT_HOME_UNSET_AFTER_PROFILE = '1'
@@ -24,16 +21,11 @@ export function reconcileDaemonCodexDefaultHomeMarker(
}
/** Drops the one-shot marker from shells whose startup path cannot consume it. */
export function scrubCodexDefaultHomeMarkerForWindowsShell(
export function scrubCodexDefaultHomeMarkerForLaunch(
env: Record<string, string>,
shellPath: string
supportsCodexDefaultHomeAfterProfile: boolean | undefined
): void {
const shellName = pathWin32.basename(shellPath).toLowerCase()
if (
shellName !== 'powershell.exe' &&
shellName !== 'pwsh.exe' &&
!isWindowsGitBashShellPath(shellPath)
) {
if (!supportsCodexDefaultHomeAfterProfile) {
delete env[ORCA_CODEX_DEFAULT_HOME_AFTER_PROFILE_ENV]
}
}