diff --git a/src/main/attribution/terminal-attribution.test.ts b/src/main/attribution/terminal-attribution.test.ts index ef99f3f2b..1686cbe0e 100644 --- a/src/main/attribution/terminal-attribution.test.ts +++ b/src/main/attribution/terminal-attribution.test.ts @@ -14,7 +14,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { applyTerminalAttributionEnv } from './terminal-attribution' +import { applyTerminalAttributionEnv, resolveAttributionShellFamily } from './terminal-attribution' describe('applyTerminalAttributionEnv', () => { let tmpRoot: string | null = null @@ -61,6 +61,26 @@ describe('applyTerminalAttributionEnv', () => { }) } + it('classifies Windows native and POSIX shell families for attribution shims', () => { + expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'powershell.exe' })).toBe( + 'native-windows' + ) + expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'cmd.exe' })).toBe( + 'native-windows' + ) + expect( + resolveAttributionShellFamily({ + platform: 'win32', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe' + }) + ).toBe('posix') + expect(resolveAttributionShellFamily({ platform: 'win32', shellPath: 'wsl.exe' })).toBe('posix') + expect(resolveAttributionShellFamily({ platform: 'win32', isWsl: true })).toBe('posix') + expect(resolveAttributionShellFamily({ platform: 'darwin', shellPath: '/bin/zsh' })).toBe( + undefined + ) + }) + it('does not amend HEAD when git commit --dry-run exits successfully', () => { const root = makeTmpRoot() const repo = join(root, 'repo') @@ -730,6 +750,50 @@ exit 1 expect(new Set(shimEntries).size).toBe(shimEntries.length) }) + it('puts only Windows shims on PATH for native Windows shells', () => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const baseEnv: Record = { PATH: 'C:\\Git\\cmd;C:\\Windows\\System32' } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'win32', + shellFamily: 'native-windows', + userDataPath + }) + + const posixDir = join(userDataPath, 'orca-terminal-attribution', 'posix') + const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') + const pathEntries = baseEnv.PATH.split(';') + + expect(pathEntries[0]).toBe(win32Dir) + expect(pathEntries).not.toContain(posixDir) + expect(baseEnv.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + expect(existsSync(join(win32Dir, 'git.cmd'))).toBe(true) + }) + + it('keeps POSIX shims first for Windows Git Bash and WSL shells', () => { + const root = makeTmpRoot() + const userDataPath = join(root, 'user-data') + const baseEnv: Record = { PATH: 'C:\\Program Files\\Git\\cmd;C:\\Windows' } + + applyTerminalAttributionEnv(baseEnv, { + enabled: true, + platform: 'win32', + shellFamily: 'posix', + userDataPath + }) + + const posixDir = join(userDataPath, 'orca-terminal-attribution', 'posix') + const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') + const pathEntries = baseEnv.PATH.split(';') + + expect(pathEntries[0]).toBe(posixDir) + expect(pathEntries).not.toContain(win32Dir) + expect(baseEnv.ORCA_ATTRIBUTION_SHIM_DIR).toBe(posixDir) + expect(existsSync(join(posixDir, 'git'))).toBe(true) + }) + it('writes PowerShell wrappers without raw-template backslash escapes', () => { const root = makeTmpRoot() applyTerminalAttributionEnv( diff --git a/src/main/attribution/terminal-attribution.ts b/src/main/attribution/terminal-attribution.ts index 4162e15e3..252ac589b 100644 --- a/src/main/attribution/terminal-attribution.ts +++ b/src/main/attribution/terminal-attribution.ts @@ -3,7 +3,7 @@ scripts for both POSIX shells and Windows shells. Keeping the scripts adjacent to the env injection code makes the attribution behavior auditable as one unit instead of scattering generated shell fragments across files. */ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' -import { join } from 'path' +import { join, win32 as pathWin32 } from 'path' import { ORCA_GIT_COMMIT_TRAILER } from '../../shared/orca-attribution' const ATTRIBUTION_ROOT_DIR = 'orca-terminal-attribution' @@ -29,12 +29,39 @@ type AttributionShimPaths = { win32Dir: string } +export type AttributionShellFamily = 'native-windows' | 'posix' + +export function resolveAttributionShellFamily(options: { + platform?: NodeJS.Platform + shellPath?: string + isWsl?: boolean +}): AttributionShellFamily | undefined { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return undefined + } + const shellName = options.shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase() + if (options.isWsl || shellName === 'wsl.exe' || shellName === 'wsl') { + return 'posix' + } + if (shellName === 'bash.exe' || shellName === 'sh.exe' || shellName === 'zsh.exe') { + return 'posix' + } + return 'native-windows' +} + export function applyTerminalAttributionEnv( baseEnv: Record, - options: { enabled: boolean; userDataPath: string } + options: { + enabled: boolean + userDataPath: string + platform?: NodeJS.Platform + shellFamily?: AttributionShellFamily + } ): void { + const platform = options.platform ?? process.platform if (!options.enabled) { - clearTerminalAttributionEnv(baseEnv) + clearTerminalAttributionEnv(baseEnv, platform) return } @@ -45,19 +72,20 @@ export function applyTerminalAttributionEnv( return } - const pathDelimiter = process.platform === 'win32' ? ';' : ':' + const pathDelimiter = platform === 'win32' ? ';' : ':' const basePath = baseEnv.PATH ?? process.env.PATH ?? '' // Why: resolve real Windows commands before prepending shims so cmd wrappers // cannot recursively point ORCA_REAL_* at themselves. - const resolvedGit = - process.platform === 'win32' ? resolveWindowsExecutable('git', basePath) : null - const resolvedGh = process.platform === 'win32' ? resolveWindowsExecutable('gh', basePath) : null + const resolvedGit = platform === 'win32' ? resolveWindowsExecutable('git', basePath) : null + const resolvedGh = platform === 'win32' ? resolveWindowsExecutable('gh', basePath) : null const { posixDir, win32Dir } = shimPaths - // Why: Windows terminals may be cmd/PowerShell or Git Bash. Include both shim - // families; native shells ignore extensionless POSIX files, Git Bash can use them. - const prependDirs = process.platform === 'win32' ? [posixDir, win32Dir] : [posixDir] + const shellFamily = options.shellFamily ?? (platform === 'win32' ? 'native-windows' : 'posix') + // Why: Windows native shells can try to open extensionless POSIX shims before + // PATHEXT reaches git.cmd, which surfaces an "Open With" dialog. + const prependDirs = + platform === 'win32' && shellFamily === 'native-windows' ? [win32Dir] : [posixDir] const prependDirKeys = new Set( - prependDirs.map((dir) => (process.platform === 'win32' ? dir.toLowerCase() : dir)) + prependDirs.map((dir) => (platform === 'win32' ? dir.toLowerCase() : dir)) ) const cleanedBasePath = stripAttributionPathEntries(basePath, pathDelimiter) .split(pathDelimiter) @@ -65,7 +93,7 @@ export function applyTerminalAttributionEnv( if (!entry) { return false } - const key = process.platform === 'win32' ? entry.toLowerCase() : entry + const key = platform === 'win32' ? entry.toLowerCase() : entry return !prependDirKeys.has(key) }) .join(pathDelimiter) @@ -79,9 +107,13 @@ export function applyTerminalAttributionEnv( baseEnv.ORCA_GIT_COMMIT_TRAILER = ORCA_GIT_COMMIT_TRAILER baseEnv.ORCA_GH_PR_FOOTER = ORCA_GH_FOOTER baseEnv.ORCA_GH_ISSUE_FOOTER = ORCA_GH_FOOTER - baseEnv.ORCA_ATTRIBUTION_SHIM_DIR = posixDir + if (shellFamily === 'posix') { + baseEnv.ORCA_ATTRIBUTION_SHIM_DIR = posixDir + } else { + delete baseEnv.ORCA_ATTRIBUTION_SHIM_DIR + } - if (process.platform === 'win32') { + if (platform === 'win32') { if (resolvedGit) { baseEnv.ORCA_REAL_GIT = resolvedGit } @@ -91,11 +123,14 @@ export function applyTerminalAttributionEnv( } } -function clearTerminalAttributionEnv(baseEnv: Record): void { +function clearTerminalAttributionEnv( + baseEnv: Record, + platform: NodeJS.Platform +): void { for (const key of ATTRIBUTION_ENV_KEYS) { delete baseEnv[key] } - const pathDelimiter = process.platform === 'win32' ? ';' : ':' + const pathDelimiter = platform === 'win32' ? ';' : ':' const cleanedPath = stripAttributionPathEntries(baseEnv.PATH ?? '', pathDelimiter) if (cleanedPath) { baseEnv.PATH = cleanedPath @@ -167,12 +202,12 @@ function resolveWindowsExecutable(command: string, pathValue: string): string | for (const dir of searchDirs) { for (const ext of pathExt) { - const candidate = join(dir, `${command}${ext}`) + const candidate = pathWin32.join(dir, `${command}${ext}`) if (existsSync(candidate)) { return candidate } } - const bareCandidate = join(dir, command) + const bareCandidate = pathWin32.join(dir, command) if (existsSync(bareCandidate)) { return bareCandidate } diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index cbca0931d..f5a917822 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -31,7 +31,10 @@ import { markClaudePtyExited, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' -import { applyTerminalAttributionEnv } from '../attribution/terminal-attribution' +import { + applyTerminalAttributionEnv, + resolveAttributionShellFamily +} from '../attribution/terminal-attribution' import { registerPty, unregisterPty } from '../memory/pty-registry' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import { track } from '../telemetry/client' @@ -303,6 +306,8 @@ export type BuildPtyHostEnvOptions = { * resolve to Pi for back-compat. NEVER infer from disk presence; that's * the bug this option fixes (cross-agent shadowing when both dirs exist). */ launchCommand?: string + shellPath?: string + isWsl?: boolean agentStatusHooksEnabled: boolean networkProxySettings?: NetworkProxySettings } @@ -672,7 +677,11 @@ export function buildPtyHostEnv( } applyTerminalAttributionEnv(baseEnv, { enabled: opts.githubAttributionEnabled, - userDataPath: opts.userDataPath + userDataPath: opts.userDataPath, + shellFamily: resolveAttributionShellFamily({ + shellPath: opts.shellPath, + isWsl: opts.isWsl + }) }) return baseEnv @@ -922,6 +931,8 @@ export function registerPtyHandlers( skipCodexHomeEnv: ctx?.isWsl === true && !selectedCodexHomePath, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: ctx?.command, + shellPath: ctx?.shellPath, + isWsl: ctx?.isWsl, agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), networkProxySettings: getSettings?.() }) @@ -1453,6 +1464,8 @@ export function registerPtyHandlers( skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, + shellPath: daemonShellOverride ?? process.env.COMSPEC, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd), agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), networkProxySettings: getSettings?.() }) @@ -1898,6 +1911,8 @@ export function registerPtyHandlers( skipCodexHomeEnv, githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, launchCommand: args.command, + shellPath: effectiveShellOverride ?? process.env.COMSPEC, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd), agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()), networkProxySettings: getSettings?.() }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index adddc0dd9..cf7739e09 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -162,7 +162,7 @@ export type LocalPtyProviderOptions = { buildSpawnEnv?: ( id: string, baseEnv: Record, - ctx?: { command?: string; isWsl?: boolean; wslDistro?: string | null } + ctx?: { command?: string; shellPath?: string; isWsl?: boolean; wslDistro?: string | null } ) => Record /** Whether worktree-scoped shell history is enabled. When true (or absent) * and a worktreeId is provided, HISTFILE is scoped per-worktree. */ @@ -332,6 +332,7 @@ export class LocalPtyProvider implements IPtyProvider { const finalEnv = this.opts.buildSpawnEnv ? this.opts.buildSpawnEnv(id, spawnEnv, { command: args.command, + shellPath, isWsl: isWslShell, wslDistro: launchWslDistro })