Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/main/cmder.smoke.node-pty.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as pty from 'node-pty'
import { describe, expect, it } from 'vitest'
import { applyCmderSpawnEnvironment, resolveCmderRoot } from './cmder'
import { resolveWindowsShellLaunchArgs } from './providers/windows-shell-args'

// Real ConPTY launch against a locally installed Cmder; skipped when none is found.
describe.runIf(process.platform === 'win32' && resolveCmderRoot() !== null)('Cmder launch', () => {
it('runs init.bat and keeps the requested cwd', async () => {
const root = resolveCmderRoot()!
const cwd = process.cwd()
const launch = resolveWindowsShellLaunchArgs(
'cmd.exe',
cwd,
cwd,
undefined,
// Why `call`: /K expands %VAR% before init.bat runs; `call` re-expands afterwards.
'call echo MARK[%^CMDER_ROOT%][%^CMDER_CONFIGURED%][%^CD%]',
undefined,
true
)
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && key !== 'CMDER_ROOT') {
env[key] = value
}
}
applyCmderSpawnEnvironment(env, root)
const proc = pty.spawn('cmd.exe', launch.shellArgs, {
cwd: launch.effectiveCwd,
env,
cols: 200,
rows: 40
})
let out = ''
proc.onData((data) => {
out += data
})
const exited = new Promise<void>((resolve) => proc.onExit(() => resolve()))
const deadline = Date.now() + 30_000
while (!out.includes('MARK[C:') && !out.includes(`MARK[${root}`) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 200))
}
proc.write('exit\r')
await exited
expect(out).toContain(`MARK[${root}][1][${cwd}]`)
}, 60_000)
})
82 changes: 82 additions & 0 deletions src/main/cmder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import {
applyCmderSpawnEnvironment,
applyConfiguredCmderRootEnv,
setConfiguredCmderRoot,
getCmderRootCandidates,
resolveCmderRoot,
resolveWindowsCmderShellRoot
} from './cmder'

describe('Cmder discovery', () => {
it('prefers CMDER_ROOT and strips quotes and trailing separators', () => {
const candidates = getCmderRootCandidates({
CMDER_ROOT: '"C:\\programs\\cmder\\"',
USERPROFILE: 'C:\\Users\\alice'
})
expect(candidates[0]).toBe('C:\\programs\\cmder')
expect(candidates).toContain('C:\\Users\\alice\\scoop\\apps\\cmder\\current')
})

it('resolves the first root whose vendor init.bat exists', () => {
expect(
resolveCmderRoot({
platform: 'win32',
env: { CMDER_ROOT: 'D:\\missing', USERPROFILE: 'C:\\Users\\alice' },
exists: (path) => path === 'C:\\Users\\alice\\cmder\\vendor\\init.bat'
})
).toBe('C:\\Users\\alice\\cmder')
})

it('never resolves off Windows', () => {
expect(
resolveCmderRoot({ platform: 'linux', env: { CMDER_ROOT: 'C:\\cmder' }, exists: () => true })
).toBeNull()
})

it('only maps the cmder sentinel', () => {
const options = {
platform: 'win32' as const,
env: { CMDER_ROOT: 'C:\\cmder' },
exists: () => true
}
expect(resolveWindowsCmderShellRoot('Cmder', options)).toBe('C:\\cmder')
expect(resolveWindowsCmderShellRoot('cmd.exe', options)).toBeNull()
})

it('sets CMDER_ROOT and the init path/quote pair, dropping inherited init state', () => {
const env: Record<string, string> = {
CMDER_CONFIGURED: '2',
CMDER_INIT_START: '1:00',
CMDER_INIT_END: '1:01'
}
applyCmderSpawnEnvironment(env, 'C:\\Program Files\\cmder')
expect(env).toEqual({
CMDER_ROOT: 'C:\\Program Files\\cmder',
ORCA_CMDER_INIT: 'C:\\Program Files\\cmder\\vendor\\init.bat',
ORCA_CMDER_INIT_QUOTE: '"'
})
})

it('ranks the spawn-carried setting over CMDER_ROOT, and the main-process setting next', () => {
expect(
getCmderRootCandidates({ ORCA_CMDER_ROOT: 'D:\\cmder', CMDER_ROOT: 'C:\\cmder' }).slice(0, 2)
).toEqual(['D:\\cmder', 'C:\\cmder'])
setConfiguredCmderRoot(' E:\\apps\\cmder ')
try {
expect(getCmderRootCandidates({ CMDER_ROOT: 'C:\\cmder' })[0]).toBe('E:\\apps\\cmder')
} finally {
setConfiguredCmderRoot(null)
}
})

it('carries only a non-empty setting into spawn env', () => {
const options: { env?: Record<string, string> } = { env: { PATH: 'x' } }
applyConfiguredCmderRootEnv(options, ' ')
expect(options.env).toEqual({ PATH: 'x' })
applyConfiguredCmderRootEnv(options, 'D:\\cmder')
expect(options.env).toEqual({ PATH: 'x', ORCA_CMDER_ROOT: 'D:\\cmder' })
applyConfiguredCmderRootEnv(options, '')
expect(options.env).toEqual({ PATH: 'x' })
})
})
136 changes: 136 additions & 0 deletions src/main/cmder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { existsSync } from 'node:fs'
import { win32 as pathWin32 } from 'node:path'
import { WINDOWS_CMDER_SHELL } from '../shared/windows-terminal-shell'

export const ORCA_CMDER_INIT_ENV = 'ORCA_CMDER_INIT'
export const ORCA_CMDER_INIT_QUOTE_ENV = 'ORCA_CMDER_INIT_QUOTE'
export const ORCA_CMDER_ROOT_ENV = 'ORCA_CMDER_ROOT'

let configuredCmderRoot: string | null = null

/** Carries the Cmder setting into the spawn env so the out-of-process daemon resolves the same root. */
export function applyConfiguredCmderRootEnv(
spawnOptions: { env?: Record<string, string> },
configuredRoot: string | undefined
): void {
const root = configuredRoot?.trim()
// Why drop first: a carried key from an earlier setting would outrank CMDER_ROOT after the setting is cleared.
const { [ORCA_CMDER_ROOT_ENV]: _stale, ...env } = spawnOptions.env ?? {}
if (root) {
spawnOptions.env = { ...env, [ORCA_CMDER_ROOT_ENV]: root }
} else if (spawnOptions.env) {
spawnOptions.env = env
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Settings-provided Cmder folder; it outranks CMDER_ROOT and guessed install dirs. */
export function setConfiguredCmderRoot(root: string | null | undefined): void {
configuredCmderRoot = root?.trim() || null
}

type CmderRootOptions = {
env?: NodeJS.ProcessEnv
exists?: (path: string) => boolean
platform?: NodeJS.Platform
}

function readEnv(env: NodeJS.ProcessEnv, names: string[]): string | undefined {
for (const name of names) {
const value = env[name]?.trim()
if (value) {
return value
}
}
return undefined
}

function stripQuotes(value: string): string {
return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value
}

export function getCmderInitScriptPath(root: string): string {
return pathWin32.join(root, 'vendor', 'init.bat')
}

export function getCmderRootCandidates(env: NodeJS.ProcessEnv = process.env): string[] {
const candidates: string[] = []
const seen = new Set<string>()
const push = (candidate: string | undefined): void => {
if (!candidate) {
return
}
const normalized = pathWin32.normalize(stripQuotes(candidate)).replace(/\\+$/, '')
const key = normalized.toLowerCase()
if (!seen.has(key)) {
seen.add(key)
candidates.push(normalized)
}
}

// Why: spawn paths inject the setting as ORCA_CMDER_ROOT so the separate daemon process sees it too.
push(readEnv(env, [ORCA_CMDER_ROOT_ENV]) ?? configuredCmderRoot ?? undefined)
// Why: Cmder's installer and docs both publish CMDER_ROOT; it wins over guessed locations.
push(readEnv(env, ['CMDER_ROOT', 'cmder_root']))
const userProfile = readEnv(env, ['USERPROFILE', 'UserProfile'])
const systemDrive = readEnv(env, ['SystemDrive', 'SYSTEMDRIVE']) ?? 'C:'
const programRoots = [
readEnv(env, ['ProgramFiles', 'PROGRAMFILES']),
readEnv(env, ['ProgramFiles(x86)', 'PROGRAMFILES(X86)']),
readEnv(env, ['LOCALAPPDATA', 'LocalAppData'])
]
for (const root of programRoots) {
if (root) {
push(pathWin32.join(root, 'cmder'))
}
}
if (userProfile) {
push(pathWin32.join(userProfile, 'cmder'))
push(pathWin32.join(userProfile, 'scoop', 'apps', 'cmder', 'current'))
push(pathWin32.join(userProfile, 'scoop', 'apps', 'cmder-full', 'current'))
}
push(pathWin32.join(`${systemDrive}\\`, 'tools', 'cmder'))
push(pathWin32.join(`${systemDrive}\\`, 'cmder'))
return candidates
}

export function resolveCmderRoot(options: CmderRootOptions = {}): string | null {
const platform = options.platform ?? process.platform
if (platform !== 'win32') {
return null
}
const exists = options.exists ?? existsSync
for (const candidate of getCmderRootCandidates(options.env ?? process.env)) {
if (exists(getCmderInitScriptPath(candidate))) {
return candidate
}
}
return null
}

export function isCmderAvailable(): boolean {
return resolveCmderRoot() !== null
}

/** Orca launched from a Cmder console inherits CMDER_CONFIGURED, which makes init.bat skip setup. */
export function stripInheritedCmderState(env: Record<string, string>): void {
delete env[ORCA_CMDER_ROOT_ENV]
delete env.CMDER_CONFIGURED
delete env.CMDER_INIT_START
delete env.CMDER_INIT_END
}

/** Env Cmder's init.bat expects, plus the path/quote pair the cmd `/K` chain expands. */
export function applyCmderSpawnEnvironment(env: Record<string, string>, cmderRoot: string): void {
stripInheritedCmderState(env)
env.CMDER_ROOT = cmderRoot
env[ORCA_CMDER_INIT_ENV] = getCmderInitScriptPath(cmderRoot)
env[ORCA_CMDER_INIT_QUOTE_ENV] = '"'
}

/** Cmder root for a requested shell, or null when the request is not Cmder (or Cmder is missing). */
export function resolveWindowsCmderShellRoot(
shell: string | undefined,
options: CmderRootOptions = {}
): string | null {
return shell?.trim().toLowerCase() === WINDOWS_CMDER_SHELL ? resolveCmderRoot(options) : null
}
13 changes: 11 additions & 2 deletions src/main/daemon/pty-subprocess/shell-launch-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startu
import { win32 as pathWin32 } from 'node:path'
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash'
import { isPwshAvailable } from '../../pwsh'
import { applyCmderSpawnEnvironment, resolveWindowsCmderShellRoot } from '../../cmder'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../pty/codex-home-wsl-env'
import { addOrcaWslInteropEnv } from '../../pty/wsl-orca-env'
import {
Expand Down Expand Up @@ -34,7 +35,7 @@ import {
type RecognizedAgentProcess
} from '../../../shared/agent-process-recognition'
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query'
import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell'
import { WINDOWS_CMDER_SHELL, WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell'
import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready'
import { resolveWslSessionContext } from '../wsl-session-context'
import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment'
Expand Down Expand Up @@ -70,6 +71,7 @@ export function createPtyShellLaunchPlan(
if (process.platform === 'win32') {
const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase()
const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath)
const cmderRoot = resolveWindowsCmderShellRoot(shellPath, { env })
const resolvedShellFamily: WindowsPowerShellShellFamily =
normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe'
? normalizedShellFamily
Expand All @@ -87,6 +89,9 @@ export function createPtyShellLaunchPlan(
shellPath = resolvedGitBashPath
} else if (shellPath === WINDOWS_GIT_BASH_SHELL) {
shellPath = 'powershell.exe'
} else if (shellPath.toLowerCase() === WINDOWS_CMDER_SHELL) {
// Why: Cmder is cmd.exe plus init.bat; a missing install degrades to plain cmd, not a spawn failure.
shellPath = 'cmd.exe'
} else {
shellPath = shouldResolvePowerShellFamily
? (resolveEffectiveWindowsPowerShell({
Expand All @@ -102,6 +107,9 @@ export function createPtyShellLaunchPlan(
) {
env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"'
}
if (cmderRoot) {
applyCmderSpawnEnvironment(env, cmderRoot)
}
windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({
shellPath,
cwd: spawnCwd,
Expand All @@ -123,7 +131,8 @@ export function createPtyShellLaunchPlan(
resolveSafePtyDefaultCwd(),
resolvedWslContext,
opts.command,
env.ORCA_CODEX_LAUNCH_PREFLIGHT
env.ORCA_CODEX_LAUNCH_PREFLIGHT,
cmderRoot !== null
)
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
Expand Down
2 changes: 2 additions & 0 deletions src/main/ipc/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getDevInstanceIdentity } from '../startup/dev-instance-identity'
import { isPwshAvailableAsync } from '../pwsh'
import { isWslAvailableAsync, listWslDistrosAsync } from '../wsl'
import { isGitBashAvailable } from '../git-bash'
import { isCmderAvailable } from '../cmder'
import { setUnreadDockBadgeCount } from '../dock/unread-badge'
import { destroySystemTray } from '../tray/system-tray'
import { authorizeExternalPath } from './filesystem-auth'
Expand Down Expand Up @@ -279,6 +280,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
ipcMain.handle('wsl:listDistros', (): Promise<string[]> => listWslDistrosAsync())
ipcMain.handle('pwsh:isAvailable', (): Promise<boolean> => isPwshAvailableAsync())
ipcMain.handle('gitBash:isAvailable', (): boolean => isGitBashAvailable())
ipcMain.handle('cmder:isAvailable', (): boolean => isCmderAvailable())

// The selected IME identity must win over its US-shaped backing keyboard layout.
ipcMain.handle('app:getKeyboardInputSourceId', async (): Promise<string | null> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export type RemoteWindowsTerminalCapabilities = {
wslDistros: string[]
pwshAvailable: boolean
gitBashAvailable: boolean
// Why optional: relays predating Cmder omit it, which means no Cmder.
cmderAvailable?: boolean
hostPlatform: NodeJS.Platform | null
}

Expand Down
5 changes: 5 additions & 0 deletions src/main/ipc/pty/ipc/spawn-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-te
import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command'
import type { PtyIpcSpawnState } from './spawn-state'
import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn'
import { applyConfiguredCmderRootEnv } from '../../../cmder'

/** Carries deletions to provider-owned environments, including persistent older daemons. */
export async function buildPtyIpcSpawnOptions(
Expand Down Expand Up @@ -160,6 +161,10 @@ export async function buildPtyIpcSpawnOptions(
})
}
if (process.platform === 'win32' && !args.connectionId) {
applyConfiguredCmderRootEnv(
ctx.spawnOptions,
ctx.deps.getSettings?.()?.terminalWindowsCmderPath
)
// Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable.
ctx.spawnOptions.terminalWindowsWslDistro = ctx.expectedWslDistro
ctx.spawnOptions.terminalWindowsPowerShellImplementation = ctx.deps.getSettings
Expand Down
7 changes: 7 additions & 0 deletions src/main/ipc/pty/runtime/spawn-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from '../pane/spawn-reservation'
import type { RuntimePtySpawnState } from './spawn-state'
import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn'
import { applyConfiguredCmderRootEnv } from '../../../cmder'

/** Headless spawns need the same host-side environment isolation as desktop spawns. */
export async function buildRuntimePtySpawnOptions(
Expand Down Expand Up @@ -189,6 +190,12 @@ export async function buildRuntimePtySpawnOptions(
settings: ctx.deps.getSettings?.()
})
ctx.spawnOptions.terminalWindowsWslDistro = ctx.expectedWslDistro
if (process.platform === 'win32') {
applyConfiguredCmderRootEnv(
ctx.spawnOptions,
ctx.deps.getSettings?.()?.terminalWindowsCmderPath
)
}
ctx.spawnOptions.terminalWindowsPowerShellImplementation = ctx.deps.getSettings
? (ctx.deps.getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
: undefined
Expand Down
Loading