Fix Windows attribution shim ordering

This commit is contained in:
Neil 2026-05-29 07:09:43 -07:00
parent d1b9392b49
commit 0d3b38af1d
4 changed files with 137 additions and 22 deletions

View File

@ -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<string, string> = { 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<string, string> = { 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(

View File

@ -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<string, string>,
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<string, string>): void {
function clearTerminalAttributionEnv(
baseEnv: Record<string, string>,
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
}

View File

@ -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?.()
})

View File

@ -162,7 +162,7 @@ export type LocalPtyProviderOptions = {
buildSpawnEnv?: (
id: string,
baseEnv: Record<string, string>,
ctx?: { command?: string; isWsl?: boolean; wslDistro?: string | null }
ctx?: { command?: string; shellPath?: string; isWsl?: boolean; wslDistro?: string | null }
) => Record<string, string>
/** 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
})