Recover working local forge CLIs behind broken PATH launchers

Recover a working local forge CLI when an earlier PATH launcher is broken. Bound executable probes and reuse the verified selection for native operations without replaying authentication or user requests.

Fixes #22975

Co-authored-by: Aashish <145881415+aashish254@users.noreply.github.com>
This commit is contained in:
Aashish Mahato
2026-10-03 20:45:52 -07:00
committed by GitHub
parent 94f5669228
commit e5ba5975df
15 changed files with 1391 additions and 53 deletions
+2
View File
@@ -1168,6 +1168,8 @@ jobs:
src/main/runtime/unreadable-secret-store-preservation.win32.test.ts
src/main/ipc/pty-codex-account-attribution.test.ts
src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts
src/main/ipc/preflight-provider-command-selection.test.ts
src/main/ipc/preflight-runnable-local-cli.test.ts
src/relay/windows-port-scan.win32.test.ts
src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts
src/main/ssh/remote-node-runtime-store-windows.test.ts
+2
View File
@@ -357,6 +357,8 @@ const WINDOWS_PACKAGE_TESTS = [
'src/main/runtime/unreadable-secret-store-preservation.win32.test.ts',
'src/main/ipc/pty-codex-account-attribution.test.ts',
'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts',
'src/main/ipc/preflight-provider-command-selection.test.ts',
'src/main/ipc/preflight-runnable-local-cli.test.ts',
'src/relay/windows-port-scan.win32.test.ts',
'src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts',
'src/main/ssh/remote-node-runtime-store-windows.test.ts'
@@ -2,6 +2,7 @@ import { execFile, type ChildProcess, type ExecFileOptions } from 'node:child_pr
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
import { endSubprocessStdin } from '../../../shared/subprocess-stdin-write'
import { runProcess } from '../../../shared/child-process/run-process'
import { resolveSelectedLocalCommand } from '../../ipc/command-path-resolver'
import type { WslProcessGroupTermination } from '../wsl-process-group-termination'
import { createAbortError } from './abort-error'
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
@@ -30,7 +31,10 @@ export async function execFileCaptureToTermination(
// Spawn cost is reported by spawnProcess's observer, which runProcess goes
// through; recording it again here would double-count every capture.
const pending = runProcess({
program: command,
program: resolveSelectedLocalCommand(command, {
env: options.env,
cwd: typeof options.cwd === 'string' ? options.cwd : undefined
}),
args,
cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
env: options.env,
+127 -2
View File
@@ -1,8 +1,8 @@
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { access, chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { isCommandOnLocalPath } from './command-path-resolver'
import { isCommandOnLocalPath, listLocalCommandPaths } from './command-path-resolver'
describe('isCommandOnLocalPath', () => {
it('returns false for an empty command', async () => {
@@ -112,4 +112,129 @@ describe('isCommandOnLocalPath', () => {
).resolves.toBe(true)
})
})
// Why the full list exists (#22975): a dead version-manager shim passes the
// same executable check as the binary it shadows, so a winner-only lookup can
// only ever hand the caller the shim. The copies behind it are the answer.
describe.skipIf(process.platform === 'win32')('listLocalCommandPaths', () => {
let front = ''
let middle = ''
let back = ''
async function executable(dir: string, name: string, body: string): Promise<void> {
await writeFile(path.join(dir, name), body)
await chmod(path.join(dir, name), 0o755)
}
beforeAll(async () => {
front = await mkdtemp(path.join(tmpdir(), 'cmd-list-front-'))
middle = await mkdtemp(path.join(tmpdir(), 'cmd-list-middle-'))
back = await mkdtemp(path.join(tmpdir(), 'cmd-list-back-'))
await executable(front, 'gh', '#!/usr/bin/env bash\nexec /nope/asdf exec "gh" "$@"\n')
await executable(back, 'gh', "#!/bin/sh\nprintf 'gh version 2.98.0\\n'\n")
// Both a non-executable file and a directory in the middle: neither is a
// match, and neither may end the scan.
await writeFile(path.join(middle, 'gh'), 'not executable\n')
await chmod(path.join(middle, 'gh'), 0o644)
await mkdir(path.join(middle, 'glab'))
await executable(back, 'glab', '#!/bin/sh\n')
})
afterAll(async () => {
await rm(front, { recursive: true, force: true })
await rm(middle, { recursive: true, force: true })
await rm(back, { recursive: true, force: true })
})
it('returns every match in PATH order, shim first', async () => {
await expect(
listLocalCommandPaths('gh', {
platform: 'linux',
env: { PATH: `${front}:${middle}:${back}` }
})
).resolves.toEqual([path.posix.join(front, 'gh'), path.posix.join(back, 'gh')])
})
it('follows PATH order rather than which copy runs', async () => {
await expect(
listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: `${back}:${front}` } })
).resolves.toEqual([path.posix.join(back, 'gh'), path.posix.join(front, 'gh')])
})
it('skips a directory that matches the command name and keeps scanning', async () => {
await expect(
listLocalCommandPaths('glab', {
platform: 'linux',
env: { PATH: `${middle}:${front}:${back}` }
})
).resolves.toEqual([path.posix.join(back, 'glab')])
})
it('returns an empty list when PATH holds no absolute match', async () => {
await expect(
listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: '' } })
).resolves.toEqual([])
})
it('applies the absolute-only gate to the whole list, not just the winner', async () => {
await expect(
listLocalCommandPaths('gh', {
platform: 'linux',
env: { PATH: `.${path.delimiter}${front}` }
})
).resolves.toEqual([path.posix.join(front, 'gh')])
})
it('drops a relative match the filesystem would otherwise find', async () => {
// Why a second relative case: `path.posix.join('.', 'gh')` collapses to
// `gh`, so the case above is unaffected by deleting the gate. `fs.access`
// resolves a relative candidate against the process cwd, so only a PATH
// entry that really reaches a fixture proves the filter — and the `access`
// proves that reach first, making this fail loudly instead of going
// vacuous if the suite is ever run from a directory that hides it.
const relative = path.relative(process.cwd(), back)
await expect(access(path.join(relative, 'gh'))).resolves.toBeUndefined()
await expect(
listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: relative } })
).resolves.toEqual([])
})
it('lists a PATH entry that repeats only once', async () => {
// Why it matters: callers spawn each entry, and `/usr/local/bin` appearing
// twice in a real PATH is normal — a doomed shim must not be probed twice.
await expect(
listLocalCommandPaths('gh', {
platform: 'linux',
env: { PATH: `${back}:${front}:${back}` }
})
).resolves.toEqual([path.posix.join(back, 'gh'), path.posix.join(front, 'gh')])
})
it('returns an empty list for an empty command', async () => {
await expect(listLocalCommandPaths('')).resolves.toEqual([])
})
it('resolves an absolute command path directly', async () => {
await expect(
listLocalCommandPaths(path.join(front, 'gh'), { platform: 'linux', env: { PATH: '' } })
).resolves.toEqual([`${front}/gh`])
})
it('lists every PATHEXT permutation on win32, in resolver order', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'cmd-list-win32-'))
try {
await writeFile(path.join(dir, 'tool.CMD'), '@echo off\n')
await writeFile(path.join(dir, 'tool.EXE'), '')
await expect(
listLocalCommandPaths('tool', {
platform: 'win32',
env: { Path: dir, PATHEXT: '.CMD;.EXE' },
cwd: dir
})
).resolves.toEqual([`${dir}/tool.CMD`, `${dir}/tool.EXE`])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})
})
+138 -5
View File
@@ -1,4 +1,6 @@
import { access, constants as fsConstants, stat } from 'node:fs/promises'
import { statSync, type Stats } from 'node:fs'
import { homedir } from 'node:os'
import path from 'node:path'
export type ResolveCommandOptions = {
@@ -8,6 +10,8 @@ export type ResolveCommandOptions = {
env?: NodeJS.ProcessEnv
/** CWD used only for the win32 "search current directory first" rule. */
cwd?: string
/** Stop after this many matches; defaults to the complete list. */
maxResults?: number
}
// Why: Windows env keys are case-insensitive (PATH is usually stored as `Path`,
@@ -38,6 +42,109 @@ function getWindowsExtensions(env: NodeJS.ProcessEnv, command: string): string[]
return extensions
}
type LocalCommandSelection = {
scope: string
selected?: { binary: string; stamp: string; cwd?: string }
}
const localCommandSelections = new Map<string, LocalCommandSelection>()
function selectionScope(options: ResolveCommandOptions): string {
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
const isWin = platform === 'win32'
const pathValue = readEnvCaseInsensitive(env, 'PATH') ?? ''
const pathApi = isWin ? path.win32 : path.posix
const needsCwd = pathValue.split(isWin ? ';' : ':').some((dir) => !pathApi.isAbsolute(dir))
return JSON.stringify([
platform,
pathValue,
isWin ? readEnvCaseInsensitive(env, 'PATHEXT') : null,
env.HOME,
env.USERPROFILE,
homedir(),
needsCwd ? (options.cwd ?? process.cwd()) : null
])
}
function commandFileStamp(stats: Stats): string {
return [stats.dev, stats.ino, stats.size, stats.mtimeMs, stats.ctimeMs, stats.mode].join(':')
}
/** Publish only a successful version probe; a newer probe supersedes an older one. */
export function beginLocalCommandSelection(
command: string
): (binary: string | null) => Promise<void> {
if (command !== 'gh' && command !== 'glab') {
return async () => {}
}
const scope = selectionScope({})
const probeCwd = process.cwd()
const previous = localCommandSelections.get(command)
const selection: LocalCommandSelection = {
scope,
selected: previous?.scope === scope ? previous.selected : undefined
}
localCommandSelections.set(command, selection)
return async (binary) => {
if (localCommandSelections.get(command) !== selection) {
return
}
if (binary === null || !path.isAbsolute(binary)) {
delete selection.selected
return
}
try {
const stats = await stat(binary)
if (localCommandSelections.get(command) === selection) {
const cwd =
process.platform === 'win32' &&
path.win32.resolve(path.win32.dirname(binary)).toLowerCase() ===
path.win32.resolve(probeCwd).toLowerCase()
? probeCwd
: undefined
// Only a current-directory CLI needs to stay tied to the probe's folder.
selection.selected = stats.isFile()
? { binary, stamp: commandFileStamp(stats), cwd }
: undefined
}
} catch {
// A binary removed during the probe must not become the runtime selection.
if (localCommandSelections.get(command) === selection) {
delete selection.selected
}
}
}
}
/** Native execution reuses preflight's selection without probing or replaying the operation. */
export function resolveSelectedLocalCommand(
command: string,
options: ResolveCommandOptions = {}
): string {
const selection = localCommandSelections.get(command)
if (!selection?.selected || selection.scope !== selectionScope(options)) {
return command
}
if (
selection.selected.cwd &&
path.win32.resolve(options.cwd ?? process.cwd()).toLowerCase() !==
path.win32.resolve(selection.selected.cwd).toLowerCase()
) {
return command
}
try {
const stats = statSync(selection.selected.binary)
if (commandFileStamp(stats) === selection.selected.stamp) {
return selection.selected.binary
}
} catch {
// Missing or replaced binaries require a fresh version probe.
}
delete selection.selected
return command
}
async function isExecutableFile(candidate: string, isWin: boolean): Promise<boolean> {
try {
// Why: stat (not lstat) so symlinked CLIs resolve to their real target.
@@ -63,12 +170,15 @@ async function isExecutableFile(candidate: string, isWin: boolean): Promise<bool
* which(1)/where.exe lookup, including the current preflight quirk that only
* counts matches which resolve to an ABSOLUTE path (so relative PATH entries
* and relative command paths stay not-found, exactly as before).
*
* Stops at the first match; use {@link listLocalCommandPaths} when the rest of
* the PATH matters too.
*/
export async function isCommandOnLocalPath(
command: string,
options: ResolveCommandOptions = {}
): Promise<boolean> {
return (await resolveCommandOnLocalPath(command, options)) !== null
return (await findLocalCommandPaths(command, options, true)).length > 0
}
/** The absolute path `isCommandOnLocalPath` found, or null. */
@@ -76,8 +186,24 @@ export async function resolveCommandOnLocalPath(
command: string,
options: ResolveCommandOptions = {}
): Promise<string | null> {
return (await findLocalCommandPaths(command, options, true))[0] ?? null
}
/** Ordered, deduplicated candidates, including executable shims that may fail to run. */
export async function listLocalCommandPaths(
command: string,
options: ResolveCommandOptions = {}
): Promise<string[]> {
return findLocalCommandPaths(command, options, false)
}
async function findLocalCommandPaths(
command: string,
options: ResolveCommandOptions,
stopAtFirst: boolean
): Promise<string[]> {
if (!command) {
return null
return []
}
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
@@ -95,6 +221,8 @@ export async function resolveCommandOnLocalPath(
const searchDirs = hasPathSeparator ? [''] : isWin ? [cwd, ...pathDirs] : pathDirs
const extensions = isWin ? getWindowsExtensions(env, command) : ['']
const found: string[] = []
const seen = new Set<string>()
for (const dir of searchDirs) {
for (const ext of extensions) {
// Why: forward-slash joins so candidates are statable on every platform
@@ -102,13 +230,18 @@ export async function resolveCommandOnLocalPath(
const candidate = path.posix.join(dir, command) + ext
// Why: preserve the prior `.some(line => path.isAbsolute(line))` filter
// over where/which stdout — only absolute resolutions count.
if (!isAbsolute(candidate)) {
const candidateKey = isWin ? candidate.toLowerCase() : candidate
if (!isAbsolute(candidate) || seen.has(candidateKey)) {
continue
}
seen.add(candidateKey)
if (await isExecutableFile(candidate, isWin)) {
return candidate
found.push(candidate)
if (stopAtFirst || found.length >= (options.maxResults ?? Infinity)) {
return found
}
}
}
}
return null
return found
}
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -12,6 +13,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -26,6 +28,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
vi.mock('./command-path-resolver', () => ({
isCommandOnLocalPath: isCommandOnLocalPathMock
vi.mock('./command-path-resolver', async (importOriginal) => ({
...(await importOriginal<typeof LocalCommandResolver>()),
isCommandOnLocalPath: isCommandOnLocalPathMock,
listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -113,6 +118,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
+8 -2
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -12,6 +13,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -26,6 +28,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
vi.mock('./command-path-resolver', () => ({
isCommandOnLocalPath: isCommandOnLocalPathMock
vi.mock('./command-path-resolver', async (importOriginal) => ({
...(await importOriginal<typeof LocalCommandResolver>()),
isCommandOnLocalPath: isCommandOnLocalPathMock,
listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -109,6 +114,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
+251 -3
View File
@@ -1,15 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import path from 'node:path'
import type * as LocalCommandResolver from './command-path-resolver'
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
const { runPreflightCommandInWslMock } = vi.hoisted(() => ({
runPreflightCommandInWslMock: vi.fn()
const {
runPreflightCommandInWslMock,
execFileAsyncMock,
listLocalCommandPathsMock,
runProcessMock
} = vi.hoisted(() => ({
runPreflightCommandInWslMock: vi.fn(),
execFileAsyncMock: vi.fn(),
listLocalCommandPathsMock: vi.fn(),
runProcessMock: vi.fn()
}))
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
vi.mock('./preflight-local-env', () => ({ buildLocalPreflightEnv: () => undefined }))
vi.mock('./preflight-wsl-command', () => ({
runPreflightCommandInWsl: runPreflightCommandInWslMock
}))
import { isCommandOnPath } from './preflight-command-exec'
vi.mock('./command-path-resolver', async (importOriginal) => ({
...(await importOriginal<typeof LocalCommandResolver>()),
isCommandOnLocalPath: vi.fn(async () => false),
listLocalCommandPaths: listLocalCommandPathsMock
}))
// Why: `findRunnableLocalCommand` decides what to spawn next from the error
// `execFile` puts on a failed probe, so the shapes below have to be its own.
vi.mock('child_process', () => {
const execFileWithPromisify = Object.assign(vi.fn(), {
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
})
return { execFile: execFileWithPromisify, spawn: vi.fn() }
})
import { findRunnableLocalCommand, isCommandOnPath } from './preflight-command-exec'
describe('isCommandOnPath', () => {
const sentinel = '__ORCA_PREFLIGHT_COMMAND_PATH__'
@@ -59,3 +88,222 @@ describe('isCommandOnPath', () => {
await expect(isCommandOnPath('codex', { distro: 'Ubuntu' })).resolves.toBe(expected)
})
})
describe('findRunnableLocalCommand', () => {
const shim = '/Users/tester/.asdf/shims/gh'
const second = '/Users/tester/.volta/bin/gh'
const third = '/usr/local/bin/gh'
const spawnedCommands = () => execFileAsyncMock.mock.calls.map(([command]) => command)
let pathBefore = ''
beforeEach(() => {
pathBefore = process.env.PATH ?? ''
execFileAsyncMock.mockReset()
runProcessMock.mockReset()
listLocalCommandPathsMock.mockReset()
listLocalCommandPathsMock.mockResolvedValue([shim, second, third])
})
afterEach(() => {
vi.restoreAllMocks()
process.env.PATH = pathBefore
})
it('returns the first copy that runs and leaves the rest alone', async () => {
execFileAsyncMock.mockImplementation(async (command: string) => {
if (command === shim) {
throw Object.assign(new Error('cannot execute'), { code: 126 })
}
return { stdout: 'gh version 2.98.0\n', stderr: '' }
})
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: second
})
expect(spawnedCommands()).toEqual([shim, second])
})
it('keeps looking when a copy exits non-zero', async () => {
execFileAsyncMock.mockImplementation(async (command: string) => {
if (command === third) {
return { stdout: 'gh version 2.98.0\n', stderr: '' }
}
throw Object.assign(new Error('Command failed'), { code: 1 })
})
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: third
})
})
it('stops at a timed-out copy instead of paying the timeout once per copy', async () => {
execFileAsyncMock.mockImplementation(async (command: string) => {
if (command === second) {
throw Object.assign(new Error('Timed out'), { killed: true, code: null })
}
throw Object.assign(new Error('cannot execute'), { code: 126 })
})
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'timeout',
binary: second
})
expect(spawnedCommands()).toEqual([shim, second])
})
it('stops on the ETIMEDOUT shape as well as the killed shape', async () => {
execFileAsyncMock.mockImplementation((command: string) =>
command === second
? Promise.reject(Object.assign(new Error('Timed out'), { code: 'ETIMEDOUT' }))
: Promise.reject(Object.assign(new Error('cannot execute'), { code: 126 }))
)
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'timeout',
binary: second
})
expect(spawnedCommands()).toEqual([shim, second])
})
it('reads a rejection that is not an object as an ordinary failure', async () => {
execFileAsyncMock.mockImplementation((command: string) =>
command === third
? Promise.resolve({ stdout: 'gh version 2.98.0\n', stderr: '' })
: Promise.reject('not an error object')
)
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: third
})
expect(spawnedCommands()).toEqual([shim, second, third])
})
it('probes the bare command name when fs finds no candidate', async () => {
listLocalCommandPathsMock.mockResolvedValue([])
execFileAsyncMock.mockResolvedValue({ stdout: 'gh version 2.98.0\n', stderr: '' })
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: 'gh'
})
expect(spawnedCommands()).toEqual(['gh'])
})
it('probes a relative PATH entry as the absolute directory cwd gives it', async () => {
const relativeDir = path.join('.', 'tools')
process.env.PATH = [shim.replace('/gh', ''), relativeDir].join(path.delimiter)
const absoluteDir = path.resolve(relativeDir)
const hidden = `${absoluteDir}/gh`
const absolutePath = [shim.replace('/gh', ''), absoluteDir].join(path.delimiter)
listLocalCommandPathsMock.mockImplementation(
async (_command: string, options?: { env?: NodeJS.ProcessEnv }) =>
options?.env?.PATH === absolutePath ? [shim, hidden] : []
)
execFileAsyncMock.mockImplementation(async (command: string) => {
if (command === hidden) {
return { stdout: 'gh version 2.98.0\n', stderr: '' }
}
throw Object.assign(new Error('cannot execute'), { code: 126 })
})
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: hidden
})
expect(spawnedCommands()).toEqual([shim, hidden])
})
it('does not pay for the bare name when every PATH entry is absolute', async () => {
process.env.PATH = ['/Users/tester/.asdf/shims', '/usr/local/bin'].join(path.delimiter)
execFileAsyncMock.mockRejectedValue(Object.assign(new Error('cannot execute'), { code: 126 }))
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'exec_failed',
binary: third
})
expect(spawnedCommands()).not.toContain('gh')
})
it('runs an explicitly selected Windows cmd shim through the shared runner', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const command = path.win32.join('C:\\tools', 'gh.cmd')
runProcessMock.mockResolvedValue({
code: 0,
stdout: 'gh version fixture',
stderr: '',
timedOut: false
})
await expect(findRunnableLocalCommand(command)).resolves.toEqual({
status: 'available',
binary: command
})
expect(runProcessMock).toHaveBeenCalledWith({
program: command,
args: ['--version'],
env: undefined,
timeoutMs: expect.any(Number)
})
expect(execFileAsyncMock).not.toHaveBeenCalled()
expect(listLocalCommandPathsMock).not.toHaveBeenCalled()
})
it('keeps searching Windows PATH after a broken cmd shim', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const command = path.win32.join('C:\\tools', 'gh.cmd')
const working = path.win32.join('C:\\working', 'gh.exe')
listLocalCommandPathsMock.mockResolvedValue([command, working])
runProcessMock.mockResolvedValue({
code: 126,
stdout: '',
stderr: 'broken shim',
timedOut: false
})
execFileAsyncMock.mockResolvedValue({ stdout: 'gh version fixture', stderr: '' })
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'available',
binary: working
})
expect(runProcessMock).toHaveBeenCalledOnce()
expect(spawnedCommands()).toEqual([working])
})
it('stops on a shared runner timeout', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const command = path.win32.join('C:\\tools', 'gh.cmd')
listLocalCommandPathsMock.mockResolvedValue([command, third])
runProcessMock.mockResolvedValue({ code: null, stdout: '', stderr: '', timedOut: true })
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'timeout',
binary: command
})
expect(execFileAsyncMock).not.toHaveBeenCalled()
})
it('shares the five second timeout across failed candidates', async () => {
let now = 1000
vi.spyOn(Date, 'now').mockImplementation(() => now)
execFileAsyncMock.mockImplementation(async (command: string) => {
if (command === shim) {
now += 4000
throw Object.assign(new Error('cannot execute'), { code: 126 })
}
throw Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })
})
await expect(findRunnableLocalCommand('gh')).resolves.toEqual({
status: 'timeout',
binary: second
})
expect(execFileAsyncMock.mock.calls.map(([, , options]) => options.timeout)).toEqual([
5000, 1000
])
})
})
+131 -11
View File
@@ -1,8 +1,15 @@
import { execFile } from 'node:child_process'
import path from 'node:path'
import { homedir } from 'node:os'
import { promisify } from 'node:util'
import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup'
import { isCommandOnLocalPath } from './command-path-resolver'
import { getSystemCliInstallDirectories } from '../../shared/system-cli-install-dirs'
import { runProcess } from '../../shared/child-process/run-process'
import {
beginLocalCommandSelection,
isCommandOnLocalPath,
listLocalCommandPaths
} from './command-path-resolver'
import { buildLocalPreflightEnv } from './preflight-local-env'
import { runPreflightCommandInWsl } from './preflight-wsl-command'
import type { WslPreflightTarget } from './preflight-wsl-agent-detection'
@@ -17,7 +24,11 @@ export function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
async function withPreflightTimeout<T>(command: string, commandPromise: Promise<T>): Promise<T> {
async function withPreflightTimeout<T>(
command: string,
commandPromise: Promise<T>,
timeoutMs = PREFLIGHT_COMMAND_TIMEOUT_MS
): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
@@ -28,7 +39,7 @@ async function withPreflightTimeout<T>(command: string, commandPromise: Promise<
code: 'ETIMEDOUT'
})
reject(error)
}, PREFLIGHT_COMMAND_TIMEOUT_MS)
}, timeoutMs)
if (typeof timeout.unref === 'function') {
timeout.unref()
}
@@ -47,19 +58,36 @@ async function withPreflightTimeout<T>(command: string, commandPromise: Promise<
* docs/reference/wsl-probe-failure-semantics.md before doing so. */
export async function execLocalPreflightCommandOrThrow(
command: string,
args: string[]
args: string[],
options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}
): Promise<PreflightCommandResult> {
const env = buildLocalPreflightEnv()
const env = options.env ?? buildLocalPreflightEnv()
const timeoutMs = options.timeoutMs ?? PREFLIGHT_COMMAND_TIMEOUT_MS
// Node cannot execFile a batch shim; the shared runner handles its argv safely.
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command)) {
const result = await withPreflightTimeout(
command,
runProcess({ program: command, args, env, timeoutMs }),
timeoutMs
)
if (result.timedOut || result.code !== 0) {
throw Object.assign(new Error(`Failed running ${command}`), {
...result,
code: result.timedOut ? 'ETIMEDOUT' : result.code
})
}
return { stdout: result.stdout, stderr: result.stderr }
}
const commandPromise = execFileAsync(command, args, {
encoding: 'utf-8',
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
timeout: timeoutMs,
// Preflight probes console-subsystem binaries (git, gh, node); without this
// each one flashes a console and steals foreground on Windows (#10488).
windowsHide: true,
...(env ? { env } : {})
}) as Promise<PreflightCommandResult>
})
return withPreflightTimeout(command, commandPromise)
return withPreflightTimeout(command, commandPromise, timeoutMs)
}
// Throws on any failure — a distro that is booting/unreachable throws the
@@ -76,14 +104,106 @@ export async function execCommandInWslOrThrow(
return withPreflightTimeout('wsl command', commandPromise)
}
const PREFLIGHT_LOCAL_PROBE_LIMIT = 4
export type LocalCommandProbe =
| { status: 'available'; binary: string }
| { status: 'absent' }
| { status: 'exec_failed' | 'timeout' | 'limit_reached'; binary: string }
function probeTimedOut(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false
}
return (
('killed' in error && error.killed === true) || ('code' in error && error.code === 'ETIMEDOUT')
)
}
async function localProbeCandidates(
command: string,
env: NodeJS.ProcessEnv | undefined
): Promise<string[]> {
const isWin = process.platform === 'win32'
const probeEnv = env ?? process.env
// Keep relative PATH entries in their original position, as execFile does.
const absoluteEnv = isWin
? probeEnv
: {
...probeEnv,
PATH: (probeEnv.PATH ?? '')
.split(path.delimiter)
.map((dir) => path.resolve(dir))
.join(path.delimiter)
}
const maxResults = PREFLIGHT_LOCAL_PROBE_LIMIT + 1
const paths = await listLocalCommandPaths(command, { env: absoluteEnv, maxResults })
const installPaths =
isWin || paths.length >= maxResults
? []
: await listLocalCommandPaths(command, {
env: {
PATH: getSystemCliInstallDirectories(process.platform, homedir()).join(path.delimiter)
},
maxResults
})
return [...new Set([...paths, ...installPaths])]
}
/** Try only version probes; authentication must stay on the selected binary. */
export async function findRunnableLocalCommand(command: string): Promise<LocalCommandProbe> {
const publishSelection = beginLocalCommandSelection(command)
const result = await probeRunnableLocalCommand(command)
await publishSelection(result.status === 'available' ? result.binary : null)
return result
}
async function probeRunnableLocalCommand(command: string): Promise<LocalCommandProbe> {
const env = buildLocalPreflightEnv()
const explicit = command.includes('/') || (process.platform === 'win32' && command.includes('\\'))
// An explicit path is the user's selection, even when it cannot run.
const candidates = explicit ? [] : await localProbeCandidates(command, env)
const probes = candidates.length ? candidates.slice(0, PREFLIGHT_LOCAL_PROBE_LIMIT) : [command]
const deadline = Date.now() + PREFLIGHT_COMMAND_TIMEOUT_MS
for (const binary of probes) {
const timeoutMs = deadline - Date.now()
if (timeoutMs <= 0) {
return { status: 'timeout', binary }
}
try {
await execLocalPreflightCommandOrThrow(binary, ['--version'], { env, timeoutMs })
return { status: 'available', binary }
} catch (error) {
if (probeTimedOut(error)) {
return { status: 'timeout', binary }
}
if (
candidates.length === 0 &&
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ENOENT' &&
!(await isCommandOnLocalPath(explicit ? path.resolve(command) : command, { env }))
) {
return { status: 'absent' }
}
}
}
return {
status: candidates.length > PREFLIGHT_LOCAL_PROBE_LIMIT ? 'limit_reached' : 'exec_failed',
binary: probes.at(-1) ?? command
}
}
export async function isCommandAvailable(
command: string,
wslTarget?: WslPreflightTarget
): Promise<boolean> {
if (!wslTarget) {
return (await findRunnableLocalCommand(command)).status === 'available'
}
try {
await (wslTarget
? execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`)
: execLocalPreflightCommandOrThrow(command, ['--version']))
await execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`)
return true
} catch {
return false
+110 -10
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -13,6 +14,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -28,6 +30,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
vi.mock('./command-path-resolver', () => ({
isCommandOnLocalPath: isCommandOnLocalPathMock
vi.mock('./command-path-resolver', async (importOriginal) => ({
...(await importOriginal<typeof LocalCommandResolver>()),
isCommandOnLocalPath: isCommandOnLocalPathMock,
listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -115,6 +120,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
@@ -162,17 +168,29 @@ describe('preflight', () => {
})
})
it('treats gh as unauthenticated when gh auth status fails without auth markers', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' })
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
it.each(['gh', 'glab'])('does not switch %s copies after authentication fails', async (cli) => {
const first = `/test/first/${cli}`
const second = `/test/second/${cli}`
listLocalCommandPathsMock.mockImplementation(async (command: string) =>
command === cli ? [first, second] : []
)
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
if (command === first && args[0] === 'auth') {
throw Object.assign(new Error('not authenticated'), { code: 1, stderr: 'not logged in' })
}
return { stdout: 'fixture success', stderr: '' }
})
const status = await runPreflightCheck()
expect(status.gh).toEqual({ installed: true, authenticated: false })
expect(cli === 'gh' ? status.gh : status.glab).toEqual({
installed: true,
authenticated: false
})
expect(
execFileAsyncMock.mock.calls.filter(([command]) => command === first).map(([, args]) => args)
).toEqual([['--version'], ['auth', 'status']])
expect(execFileAsyncMock.mock.calls.some(([command]) => command === second)).toBe(false)
})
it('keeps older gh stderr success output from showing a false auth warning', async () => {
@@ -188,6 +206,88 @@ describe('preflight', () => {
expect(status.gh).toEqual({ installed: true, authenticated: true })
})
// Why (#22975): these two cases key the spawn mock on (command, args) instead
// of call order, because the fallback probes exactly which copy runs is the
// behaviour under test — and `gh auth status` runs in parallel with
// `glab auth status`, so a fixed sequence would not be the real one.
it('authenticates the gh copy that actually ran, not the shim PATH chose', async () => {
const shim = '/Users/octocat/.asdf/shims/gh'
const working = '/opt/homebrew/bin/gh'
const glabShim = '/Users/octocat/.asdf/shims/glab'
const glabWorking = '/usr/local/bin/glab'
const shims = new Set([shim, glabShim])
listLocalCommandPathsMock.mockImplementation(async (command: string) => {
if (command === 'gh') {
return [shim, working]
}
return command === 'glab' ? [glabShim, glabWorking] : []
})
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
if (shims.has(command)) {
throw Object.assign(new Error('spawn failed'), { code: 126 })
}
const auth = args[0] === 'auth'
if (command === working) {
return { stdout: auth ? 'github.com\n - Active account: true\n' : 'gh version 2.6.0\n' }
}
if (command === glabWorking) {
return { stdout: auth ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n' }
}
if (command === 'git') {
return { stdout: 'git version 2.0.0\n' }
}
throw new Error(`unexpected command ${command}`)
})
const status = await runPreflightCheck()
expect(status).toMatchObject({
gh: { installed: true, authenticated: true },
glab: { installed: true, authenticated: true }
})
for (const [cli, copy] of [
['gh', working],
['glab', glabWorking]
]) {
expect(execFileAsyncMock).toHaveBeenCalledWith(copy, ['auth', 'status'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true
})
expect(execFileAsyncMock).not.toHaveBeenCalledWith(cli, ['auth', 'status'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true
})
}
})
it('marks gh not installed when no copy PATH offers can run', async () => {
const doomed = ['/Users/octocat/.asdf/shims/gh', '/Users/octocat/.local/bin/gh']
listLocalCommandPathsMock.mockImplementation(async (command: string) =>
command === 'gh' ? doomed : []
)
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
if (doomed.includes(command)) {
throw Object.assign(new Error('spawn failed'), { code: 126 })
}
if (command === 'git') {
return { stdout: 'git version 2.0.0\n' }
}
if (command === 'glab') {
return {
stdout: args[0] === 'auth' ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n'
}
}
throw new Error(`unexpected command ${command}`)
})
const status = await runPreflightCheck()
expect(status.gh).toEqual({ installed: false, authenticated: false })
expect(execFileAsyncMock).toHaveBeenCalledTimes(5)
})
it('marks glab as not installed when `glab --version` fails', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
@@ -0,0 +1,321 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { removeTree } from '../../shared/windows-transient-lock-removal'
import { ghExecFileAsync } from '../git/command-runner/gh-exec-file'
import { glabExecFileAsync } from '../git/command-runner/glab-exec-file'
import { execFileCaptureToTermination } from '../git/command-runner/exec-file-capture'
import { beginLocalCommandSelection, resolveSelectedLocalCommand } from './command-path-resolver'
import {
execLocalPreflightCommandOrThrow,
findRunnableLocalCommand,
shellQuote
} from './preflight-command-exec'
vi.mock('../../shared/system-cli-install-dirs', () => ({
getSystemCliInstallDirectories: (_platform: NodeJS.Platform, home: string) => [
path.join(home, '.nix-profile', 'bin')
]
}))
let root = ''
const CLIS = ['gh', 'glab'] as const
async function fixtureCli(
cli: string,
label: string,
body = `case "$1" in\n--version) echo fixture-version;;\nauth) echo 'Logged in fixture';;\napi) echo '${label}-api';;\nesac\n`
): Promise<string> {
const dir = path.join(root, label)
await mkdir(dir, { recursive: true })
const binary = path.join(dir, cli)
await writeFile(
binary,
`#!/bin/sh\nprintf '%s\\n' "$*" >> ${shellQuote(`${binary}-calls`)}\n${body}`,
{ mode: 0o755 }
)
return binary
}
async function calls(binary: string): Promise<string[]> {
try {
return (await readFile(`${binary}-calls`, 'utf8')).trim().split('\n')
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
return []
}
throw error
}
}
function providerCommand(cli: 'gh' | 'glab', env?: NodeJS.ProcessEnv, cwd?: string) {
const execute = cli === 'gh' ? ghExecFileAsync : glabExecFileAsync
return execute(['api', 'user'], { timeout: 1000, idempotent: false, env, cwd })
}
describe.skipIf(process.platform === 'win32')(
'preflight selection in native provider commands',
() => {
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-provider-'))
vi.stubEnv('HOME', root)
})
afterEach(async () => {
vi.unstubAllEnvs()
await removeTree(root)
})
it.each(CLIS)(
'uses the same runnable %s for version, auth and provider operations',
async (cli) => {
const shim = await fixtureCli(cli, 'shim', 'echo broken-shim >&2\nexit 126\n')
const good = await fixtureCli(cli, 'good')
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
const selected = await findRunnableLocalCommand(cli)
expect(selected).toEqual({ status: 'available', binary: good })
if (selected.status !== 'available') {
throw new Error('Expected the working fixture to be selected')
}
await expect(
execLocalPreflightCommandOrThrow(selected.binary, ['auth', 'status'])
).resolves.toMatchObject({ stdout: 'Logged in fixture\n' })
await expect(providerCommand(cli, undefined, root)).resolves.toMatchObject({
stdout: 'good-api\n'
})
expect(await calls(shim)).toEqual(['--version'])
expect(await calls(good)).toEqual(['--version', 'auth status', 'api user'])
}
)
it.each(CLIS)(
'keeps the proven %s usable while its version selection refreshes',
async (cli) => {
const shim = await fixtureCli(cli, 'shim', 'echo broken-shim >&2\nexit 126\n')
const good = await fixtureCli(cli, 'good')
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
await findRunnableLocalCommand(cli)
const refreshing = findRunnableLocalCommand(cli)
await expect(providerCommand(cli)).resolves.toMatchObject({ stdout: 'good-api\n' })
await expect(refreshing).resolves.toEqual({ status: 'available', binary: good })
expect(await calls(shim)).toEqual(['--version', '--version'])
}
)
it('clears the previous selection only after the newest version probe fails', async () => {
const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
const good = await fixtureCli(
'gh',
'good',
'if [ "$1" = --version ] && [ "$ORCA_22975_VERSION_DISABLED" = 1 ]; then exit 126; fi\necho good-api\n'
)
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
await findRunnableLocalCommand('gh')
vi.stubEnv('ORCA_22975_VERSION_DISABLED', '1')
const refreshing = findRunnableLocalCommand('gh')
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'good-api\n' })
await expect(refreshing).resolves.toMatchObject({ status: 'exec_failed' })
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
})
it.each(CLIS)('also uses the recovered %s from a known install directory', async (cli) => {
const shim = await fixtureCli(cli, 'shim', 'exit 126\n')
const good = await fixtureCli(cli, path.join('.nix-profile', 'bin'))
vi.stubEnv('PATH', path.dirname(shim))
await expect(findRunnableLocalCommand(cli)).resolves.toEqual({
status: 'available',
binary: good
})
await expect(providerCommand(cli)).resolves.toMatchObject({
stdout: `${path.join('.nix-profile', 'bin')}-api\n`
})
expect(await calls(shim)).toEqual(['--version'])
})
it.each(CLIS)('keeps %s selected when authentication fails', async (cli) => {
const first = await fixtureCli(
cli,
'first',
'if [ "$1" = auth ]; then echo unauthenticated >&2; exit 1; fi\necho first-result\n'
)
const other = await fixtureCli(cli, 'other')
vi.stubEnv('PATH', [path.dirname(first), path.dirname(other)].join(path.delimiter))
await findRunnableLocalCommand(cli)
await expect(
execLocalPreflightCommandOrThrow(first, ['auth', 'status'])
).rejects.toMatchObject({
code: 1
})
await expect(providerCommand(cli)).resolves.toMatchObject({ stdout: 'first-result\n' })
expect(await calls(other)).toEqual([])
})
it.each(CLIS)('never retries a failed %s operation on another binary', async (cli) => {
const first = await fixtureCli(
cli,
'first',
'if [ "$1" = api ]; then echo operation-failed >&2; exit 126; fi\necho fixture-version\n'
)
const other = await fixtureCli(cli, 'other')
vi.stubEnv('PATH', [path.dirname(first), path.dirname(other)].join(path.delimiter))
await findRunnableLocalCommand(cli)
await expect(providerCommand(cli)).rejects.toMatchObject({
code: 126,
stderr: 'operation-failed\n'
})
expect(await calls(first)).toEqual(['--version', 'api user'])
expect(await calls(other)).toEqual([])
})
it('respects a caller PATH and explicit binary instead of the global selection', async () => {
const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
const good = await fixtureCli('gh', 'good')
const custom = await fixtureCli('gh', 'custom')
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
await findRunnableLocalCommand('gh')
const refreshing = findRunnableLocalCommand('gh')
await expect(
providerCommand('gh', { ...process.env, PATH: path.dirname(custom) })
).resolves.toMatchObject({ stdout: 'custom-api\n' })
await expect(
execFileCaptureToTermination(custom, ['api', 'user'], { encoding: 'utf8', timeout: 1000 })
).resolves.toMatchObject({ stdout: 'custom-api\n' })
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'good-api\n' })
await expect(refreshing).resolves.toEqual({ status: 'available', binary: good })
})
it('bypasses a selection after PATH or relative-PATH cwd changes', async () => {
const good = await fixtureCli('gh', 'good')
const custom = await fixtureCli('gh', 'custom')
vi.stubEnv('PATH', path.dirname(good))
await findRunnableLocalCommand('gh')
const refreshing = findRunnableLocalCommand('gh')
vi.stubEnv('PATH', path.dirname(custom))
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'custom-api\n' })
await refreshing
await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'custom-api\n' })
vi.stubEnv('PATH', path.relative(process.cwd(), path.dirname(good)))
await findRunnableLocalCommand('gh')
expect(resolveSelectedLocalCommand('gh', { cwd: root })).toBe('gh')
})
it('discards a replaced binary and clears a selection when the next probe fails', async () => {
const shim = await fixtureCli('gh', 'shim', 'exit 126\n')
const good = await fixtureCli('gh', 'good')
vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter))
await findRunnableLocalCommand('gh')
await writeFile(good, '#!/bin/sh\necho changed >&2\nexit 126\n')
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
await expect(findRunnableLocalCommand('gh')).resolves.toMatchObject({ status: 'exec_failed' })
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
await fixtureCli('gh', 'good')
await findRunnableLocalCommand('gh')
expect(resolveSelectedLocalCommand('gh')).toBe(good)
await rm(good)
expect(resolveSelectedLocalCommand('gh')).toBe('gh')
})
it('keeps an older concurrent probe from replacing the newer selection', async () => {
const first = await fixtureCli('gh', 'first')
const second = await fixtureCli('gh', 'second')
const older = beginLocalCommandSelection('gh')
const newer = beginLocalCommandSelection('gh')
await newer(second)
await older(first)
await older(null)
expect(resolveSelectedLocalCommand('gh')).toBe(second)
})
}
)
describe('Windows selection across working folders', () => {
it('keeps an absolute PATH selection in a different provider cwd', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-22975-cwd-'))
const binary = path.join(directory, 'gh.CMD')
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
await writeFile(binary, '@echo off\r\nexit /b 0\r\n')
try {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.stubEnv('PATH', directory)
const publish = beginLocalCommandSelection('gh')
await publish(binary)
expect(resolveSelectedLocalCommand('gh', { cwd: path.join(directory, 'project') })).toBe(
binary
)
} finally {
vi.unstubAllEnvs()
if (descriptor) {
Object.defineProperty(process, 'platform', descriptor)
}
await removeTree(directory)
}
})
it('does not carry a current-directory CLI into another folder', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-22975-local-cwd-'))
const binary = path.join(directory, 'gh.CMD')
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')
await writeFile(binary, '@echo off\r\nexit /b 0\r\n')
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(directory)
try {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.stubEnv('PATH', path.join(directory, 'other'))
const publish = beginLocalCommandSelection('gh')
await publish(binary)
expect(resolveSelectedLocalCommand('gh')).toBe(binary)
expect(resolveSelectedLocalCommand('gh', { cwd: path.join(directory, 'project') })).toBe('gh')
} finally {
cwd.mockRestore()
vi.unstubAllEnvs()
if (descriptor) {
Object.defineProperty(process, 'platform', descriptor)
}
await removeTree(directory)
}
})
})
describe.runIf(process.platform === 'win32')('native provider batch selection', () => {
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-provider-cmd-'))
})
afterEach(async () => {
vi.unstubAllEnvs()
await removeTree(root)
})
it.each(CLIS)('passes the selected %s.cmd through the native runner', async (cli) => {
const shim = path.join(root, 'shim')
const good = path.join(root, 'good')
await mkdir(shim)
await mkdir(good)
await writeFile(path.join(shim, `${cli}.CMD`), '@echo off\r\nexit /b 126\r\n')
await writeFile(
path.join(good, `${cli}.CMD`),
'@echo off\r\nif "%~1"=="api" (echo good-api) else (echo fixture-version)\r\nexit /b 0\r\n'
)
vi.stubEnv('PATH', [shim, good].join(path.delimiter))
vi.stubEnv('Path', [shim, good].join(path.delimiter))
vi.stubEnv('PATHEXT', '.CMD')
await expect(findRunnableLocalCommand(cli)).resolves.toEqual({
status: 'available',
binary: path.posix.join(good, `${cli}.CMD`)
})
await expect(providerCommand(cli, undefined, root)).resolves.toMatchObject({
stdout: expect.stringContaining('good-api')
})
})
})
+8 -2
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as LocalCommandResolver from './command-path-resolver'
const {
handleMock,
@@ -12,6 +13,7 @@ const {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = vi.hoisted(() => ({
@@ -26,6 +28,7 @@ const {
getGiteaAuthStatusMock: vi.fn(),
resolveCliCommandsMock: vi.fn(),
isCommandOnLocalPathMock: vi.fn(),
listLocalCommandPathsMock: vi.fn(),
mergePersistedWindowsPathAsyncMock: vi.fn(),
mergePersistedWindowsPathMock: vi.fn()
}))
@@ -58,8 +61,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
// These tests express "which commands are on PATH" via the where/which mock,
// so route the resolver through that same mock to preserve their intent.
vi.mock('./command-path-resolver', () => ({
isCommandOnLocalPath: isCommandOnLocalPathMock
vi.mock('./command-path-resolver', async (importOriginal) => ({
...(await importOriginal<typeof LocalCommandResolver>()),
isCommandOnLocalPath: isCommandOnLocalPathMock,
listLocalCommandPaths: listLocalCommandPathsMock
}))
vi.mock('../pty/windows-environment-path', () => ({
@@ -106,6 +111,7 @@ describe('preflight', () => {
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
},
@@ -0,0 +1,249 @@
import { chmod, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { removeTree } from '../../shared/windows-transient-lock-removal'
import {
execLocalPreflightCommandOrThrow,
findRunnableLocalCommand,
isCommandAvailable,
isCommandOnPath,
shellQuote
} from './preflight-command-exec'
const COMMAND = 'orca-22975-gh'
const BROKEN_SHIM = 'echo broken-shim >&2\nexit 126\n'
let root = ''
async function cliInDirectory(label: string, body = 'echo gh-version-fixture\n'): Promise<string> {
const dir = path.join(root, label)
await mkdir(dir, { recursive: true })
await writeFile(
path.join(dir, COMMAND),
`#!/bin/sh\nprintf '%s\\n' "$@" >> ${shellQuote(path.join(dir, 'argv.txt'))}\n${body}`,
{ mode: 0o755 }
)
return dir
}
async function probedArgs(dir: string): Promise<string[]> {
try {
return (await readFile(path.join(dir, 'argv.txt'), 'utf8')).split('\n').filter(Boolean)
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
return []
}
throw error
}
}
describe.skipIf(process.platform === 'win32')(
'local CLI version probes with real processes',
() => {
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-case-'))
vi.stubEnv('HOME', root)
})
afterEach(async () => {
vi.unstubAllEnvs()
await removeTree(root)
})
it('detects a runnable copy behind an executable shim that exits 126', async () => {
const shim = await cliInDirectory('shim', BROKEN_SHIM)
const good = await cliInDirectory('good')
vi.stubEnv('PATH', [shim, good].join(path.delimiter))
await expect(isCommandOnPath(COMMAND)).resolves.toBe(true)
await expect(
execLocalPreflightCommandOrThrow(path.join(shim, COMMAND), ['--version'])
).rejects.toMatchObject({ code: 126, stderr: 'broken-shim\n' })
await expect(
execLocalPreflightCommandOrThrow(path.join(good, COMMAND), ['--version'])
).resolves.toMatchObject({ stdout: 'gh-version-fixture\n' })
await expect(isCommandAvailable(COMMAND)).resolves.toBe(true)
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(good, COMMAND)
})
})
it('keeps PATH order and leaves later copies unprobed when the first works', async () => {
const first = await cliInDirectory('first')
const second = await cliInDirectory('second')
vi.stubEnv('PATH', [first, second].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(first, COMMAND)
})
expect(await probedArgs(first)).toEqual(['--version'])
expect(await probedArgs(second)).toEqual([])
})
it('distinguishes exhausted failing copies from an absent command', async () => {
const first = await cliInDirectory('first', BROKEN_SHIM)
const second = await cliInDirectory('second', BROKEN_SHIM)
vi.stubEnv('PATH', [first, second].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'exec_failed',
binary: path.join(second, COMMAND)
})
await expect(findRunnableLocalCommand('orca-22975-absent-cli')).resolves.toEqual({
status: 'absent'
})
expect(await probedArgs(first)).toEqual(['--version'])
expect(await probedArgs(second)).toEqual(['--version'])
})
it('skips directories and non-executable files before a working copy', async () => {
const directory = path.join(root, 'directory')
await mkdir(path.join(directory, COMMAND), { recursive: true })
const plain = await cliInDirectory('plain')
await chmod(path.join(plain, COMMAND), 0o644)
const good = await cliInDirectory('good')
vi.stubEnv('PATH', [directory, plain, good].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(good, COMMAND)
})
expect(await probedArgs(plain)).toEqual([])
})
it('never substitutes another binary for an explicit failing path', async () => {
const shim = await cliInDirectory('shim', BROKEN_SHIM)
const good = await cliInDirectory('good')
vi.stubEnv('PATH', good)
const selected = path.join(shim, COMMAND)
await expect(findRunnableLocalCommand(selected)).resolves.toEqual({
status: 'exec_failed',
binary: selected
})
expect(await probedArgs(good)).toEqual([])
})
it('executes relative explicit paths and reports an explicit missing path', async () => {
const good = await cliInDirectory('good')
const selected = path.relative(process.cwd(), path.join(good, COMMAND))
vi.stubEnv('PATH', '')
await expect(findRunnableLocalCommand(selected)).resolves.toEqual({
status: 'available',
binary: selected
})
await expect(findRunnableLocalCommand(path.join(root, 'missing', COMMAND))).resolves.toEqual({
status: 'absent'
})
})
it.each([true, false])(
'preserves relative PATH order with shim first: %s',
async (shimFirst) => {
const good = await cliInDirectory('good')
const shim = await cliInDirectory('shim', BROKEN_SHIM)
const relative = path.relative(process.cwd(), good)
vi.stubEnv('PATH', (shimFirst ? [shim, relative] : [relative, shim]).join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(good, COMMAND)
})
expect(await probedArgs(good)).toEqual(['--version'])
expect(await probedArgs(shim)).toEqual(shimFirst ? ['--version'] : [])
}
)
it('deduplicates PATH entries before spending the probe limit', async () => {
const shim = await cliInDirectory('shim', BROKEN_SHIM)
const good = await cliInDirectory('good')
vi.stubEnv('PATH', [shim, shim, shim, shim, good].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(good, COMMAND)
})
expect(await probedArgs(shim)).toEqual(['--version'])
})
it('caps execution at four distinct candidates and reports the limit', async () => {
const doomed: string[] = []
for (const index of [0, 1, 2, 3]) {
doomed.push(await cliInDirectory(`doomed-${index}`, BROKEN_SHIM))
}
const good = await cliInDirectory('past-cap')
vi.stubEnv('PATH', [...doomed, good].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'limit_reached',
binary: path.join(doomed[3], COMMAND)
})
for (const dir of doomed) {
expect(await probedArgs(dir)).toEqual(['--version'])
}
expect(await probedArgs(good)).toEqual([])
})
it('stops at a real timeout and leaves later copies unprobed', async () => {
const hung = await cliInDirectory('hung', 'exec /bin/sleep 30\n')
const good = await cliInDirectory('good')
vi.stubEnv('PATH', [hung, good].join(path.delimiter))
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'timeout',
binary: path.join(hung, COMMAND)
})
expect(await probedArgs(good)).toEqual([])
}, 10_000)
it('uses the known Nix install directory after PATH copies fail', async () => {
const shim = await cliInDirectory('shim', BROKEN_SHIM)
const profile = await cliInDirectory(path.join('.nix-profile', 'bin'))
vi.stubEnv('PATH', shim)
await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({
status: 'available',
binary: path.join(profile, COMMAND)
})
})
}
)
describe.runIf(process.platform === 'win32')('Windows preflight batch shims', () => {
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'orca-22975-cmd-'))
})
afterEach(async () => {
vi.unstubAllEnvs()
await removeTree(root)
})
it('runs the next cmd shim after an earlier copy exits 126', async () => {
const first = path.join(root, 'first')
const second = path.join(root, 'second')
for (const dir of [first, second]) {
await mkdir(dir)
}
const name = `${COMMAND}.CMD`
await writeFile(path.join(first, name), '@echo off\r\nexit /b 126\r\n')
await writeFile(
path.join(second, name),
'@echo off\r\necho gh-version-fixture\r\nexit /b 0\r\n'
)
vi.stubEnv('PATH', [first, second].join(path.delimiter))
vi.stubEnv('Path', [first, second].join(path.delimiter))
const result = await findRunnableLocalCommand(COMMAND)
expect(result).toEqual({ status: 'available', binary: path.posix.join(second, name) })
if (result.status === 'available') {
await expect(
execLocalPreflightCommandOrThrow(result.binary, ['auth', 'status'])
).resolves.toMatchObject({ stdout: expect.stringContaining('gh-version-fixture') })
}
})
})
+7
View File
@@ -16,6 +16,7 @@ export type PreflightMocks = {
getGiteaAuthStatusMock: Mock
resolveCliCommandsMock: Mock
isCommandOnLocalPathMock: Mock
listLocalCommandPathsMock: Mock
mergePersistedWindowsPathAsyncMock: Mock
mergePersistedWindowsPathMock: Mock
}
@@ -52,6 +53,7 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap)
getGiteaAuthStatusMock,
resolveCliCommandsMock,
isCommandOnLocalPathMock,
listLocalCommandPathsMock,
mergePersistedWindowsPathAsyncMock,
mergePersistedWindowsPathMock
} = mocks
@@ -63,6 +65,11 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap)
hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' })
mergePathSegmentsMock.mockReset()
getActiveMultiplexerMock.mockReset()
// Why empty by default: with no fs candidates the local probe keeps its
// historical bare-name spawn, so cases that stub only where/which and execFile
// still assert on the command names they were written against.
listLocalCommandPathsMock.mockReset()
listLocalCommandPathsMock.mockResolvedValue([])
getBitbucketAuthStatusMock.mockReset()
getAzureDevOpsAuthStatusMock.mockReset()
getGiteaAuthStatusMock.mockReset()
+24 -15
View File
@@ -31,6 +31,7 @@ import { hydrateShellPathForAgentDetection } from '../ipc/agent-detection-shell-
import {
execCommandInWslOrThrow,
execLocalPreflightCommandOrThrow,
findRunnableLocalCommand,
isCommandAvailable,
isCommandOnPath,
shellQuote
@@ -126,20 +127,24 @@ function uniqueAgentIds(ids: Iterable<string>): string[] {
return [...new Set(ids)]
}
/** A CLI verdict and, on the local path, the exact copy that produced it. */
type CommandRuntime = { installed: boolean; wslTarget?: WslPreflightTarget; binary?: string }
async function detectCommandRuntime(
command: string,
context?: PreflightRuntimeContext
): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> {
): Promise<CommandRuntime> {
const wslTarget = getPreflightWslTarget(context)
if (wslTarget) {
return (await isCommandAvailable(command, wslTarget))
? { installed: true, wslTarget }
: { installed: false }
}
if (await isCommandAvailable(command)) {
return { installed: true }
}
return { installed: false }
// Pin auth to the copy that passed --version, so PATH cannot select the dead shim again.
const probe = await findRunnableLocalCommand(command)
return probe.status === 'available'
? { installed: true, binary: probe.binary }
: { installed: false }
}
export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise<string[]> {
@@ -253,11 +258,15 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis
return uniqueAgentIds(result.agents)
}
async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise<boolean> {
// Why the probe object rather than the bare command name: on the local path
// `binary` is the copy that just passed `--version`, which on a shim-shadowed
// host is not what PATH would resolve (#22975). WSL has no `binary` — the guest
// resolves the name inside the distro, where Orca's PATH ordering cannot apply.
async function isGhAuthenticated(probe: CommandRuntime): Promise<boolean> {
try {
await (wslTarget
? execCommandInWslOrThrow(wslTarget, `${shellQuote('gh')} auth status`)
: execLocalPreflightCommandOrThrow('gh', ['auth', 'status']))
await (probe.wslTarget
? execCommandInWslOrThrow(probe.wslTarget, `${shellQuote('gh')} auth status`)
: execLocalPreflightCommandOrThrow(probe.binary ?? 'gh', ['auth', 'status']))
// Why: for plain-text `gh auth status`, exit 0 means gh did not detect any
// authentication issues for the checked hosts/accounts.
return true
@@ -274,11 +283,11 @@ async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise<boolea
// Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth
// status to stderr in some versions and stdout in others; check both.
async function isGlabAuthenticated(wslTarget?: WslPreflightTarget): Promise<boolean> {
async function isGlabAuthenticated(probe: CommandRuntime): Promise<boolean> {
try {
await (wslTarget
? execCommandInWslOrThrow(wslTarget, `${shellQuote('glab')} auth status`)
: execLocalPreflightCommandOrThrow('glab', ['auth', 'status']))
await (probe.wslTarget
? execCommandInWslOrThrow(probe.wslTarget, `${shellQuote('glab')} auth status`)
: execLocalPreflightCommandOrThrow(probe.binary ?? 'glab', ['auth', 'status']))
return true
} catch (error) {
const stdout = (error as { stdout?: string }).stdout ?? ''
@@ -380,8 +389,8 @@ async function executePreflightCheck(
])
const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([
ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false),
glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false),
ghProbe.installed ? isGhAuthenticated(ghProbe) : Promise.resolve(false),
glabProbe.installed ? isGlabAuthenticated(glabProbe) : Promise.resolve(false),
getBitbucketAuthStatus(),
getAzureDevOpsAuthStatus(),
getGiteaAuthStatus()