diff --git a/src/main/ipc/command-path-resolver.test.ts b/src/main/ipc/command-path-resolver.test.ts index e9f11a06e3f..359d56306be 100644 --- a/src/main/ipc/command-path-resolver.test.ts +++ b/src/main/ipc/command-path-resolver.test.ts @@ -312,3 +312,41 @@ describe('findCommandsOnLocalPath', () => { }) }) }) + +// An execute-only directory cannot be listed, but stat on known names still works. +describe.skipIf(process.platform === 'win32')('findCommandsOnLocalPath unreadable PATH entry', () => { + let locked = '' + beforeAll(async () => { + locked = await mkdtemp(path.join(tmpdir(), 'cmd-batch-locked-')) + await writeFile(path.join(locked, 'runme'), '#!/bin/sh\n') + await chmod(path.join(locked, 'runme'), 0o755) + await chmod(locked, 0o111) + }) + afterAll(async () => { + await chmod(locked, 0o755) + await rm(locked, { recursive: true, force: true }) + }) + + it('probes known names directly when readdir is denied, like stat-per-candidate', async () => { + await expect( + findCommandsOnLocalPath(['runme'], { platform: 'linux', env: { PATH: locked } }) + ).resolves.toEqual(new Set(['runme'])) + }) +}) + +describe('findCommandsOnLocalPath shared candidate names', () => { + it('finds both a bare command and its dotted form when they resolve to the same file', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'cmd-batch-shared-')) + try { + await writeFile(path.join(dir, 'tool.CMD'), '@echo off\n') + await expect( + findCommandsOnLocalPath(['tool', 'tool.CMD'], { + platform: 'win32', + env: { Path: dir, PATHEXT: '.CMD;.EXE' } + }) + ).resolves.toEqual(new Set(['tool', 'tool.CMD'])) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ipc/command-path-resolver.ts b/src/main/ipc/command-path-resolver.ts index d566c4db055..84ee5622266 100644 --- a/src/main/ipc/command-path-resolver.ts +++ b/src/main/ipc/command-path-resolver.ts @@ -219,27 +219,38 @@ export async function findCommandsOnLocalPath( ? new Map(names.map((name) => [name.toLowerCase(), name] as const)) : new Set(names) } - } catch { - // Missing or unreadable directories hold no candidates; stat-per-candidate agreed. + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code + if (code === 'EACCES' || code === 'EPERM') { + // A searchable-but-unreadable directory still admits stat on known names; + // the per-candidate lookup found commands there, so probe them directly. + return { dir, names: null } + } + // Missing or dangling directories hold no candidates; stat-per-candidate agreed. return null } }) ) - const seenCandidates = new Set() for (const command of bareCommands) { const extensions = isWin ? getWindowsExtensions(env, command) : [''] + // Deduplicate candidates per command: two commands (for example `tool` and + // `tool.CMD`) may resolve to the same on-disk file, and each must still be found. + const seenCandidates = new Set() for (const listing of directoryListings) { if (!listing) { continue } for (const ext of extensions) { const wanted = command + ext - const onDisk = isWin - ? (listing.names as Map).get(wanted.toLowerCase()) - : (listing.names as Set).has(wanted) + const onDisk = + listing.names === null ? wanted - : undefined + : isWin + ? (listing.names as Map).get(wanted.toLowerCase()) + : (listing.names as Set).has(wanted) + ? wanted + : undefined if (onDisk === undefined) { continue } diff --git a/src/main/preflight/agent-command-detection.test.ts b/src/main/preflight/agent-command-detection.test.ts index 98c9c7e464f..b460ff216c0 100644 --- a/src/main/preflight/agent-command-detection.test.ts +++ b/src/main/preflight/agent-command-detection.test.ts @@ -47,7 +47,7 @@ describe('detectAgentCommandsOnHost install-dir fallback', () => { } finally { process.env.PATH = original } - expect(detectInstallDirs).toHaveBeenCalledWith(['codex'], '/usr/bin') + expect(detectInstallDirs).toHaveBeenCalledWith(['codex'], '/usr/bin', { skipPathSearch: true }) } ) }) diff --git a/src/main/preflight/agent-command-detection.ts b/src/main/preflight/agent-command-detection.ts index 57fbcd55216..eb2fca4b280 100644 --- a/src/main/preflight/agent-command-detection.ts +++ b/src/main/preflight/agent-command-detection.ts @@ -28,9 +28,13 @@ export async function detectAgentCommandsOnHost( const missedCommands = commands.filter((cmd) => !onPathCommands.has(cmd)) // Resolve cold-GUI install dirs once; use the same WSL-filtered PATH while retaining process PATH elsewhere. const guestMounts = readGuestWindowsDriveMounts() + // The batch already proved these commands absent on the PATH it searched, and that + // PATH is a superset of the fallback's (merged persisted PATH on Windows, the same + // filtered PATH on a WSL host), so the fallback scans install directories only. const installDirCommands = detectCommandsInInstallDirs( missedCommands, - guestMounts.length > 0 ? buildLocalPreflightEnv(guestMounts)?.PATH : undefined + guestMounts.length > 0 ? buildLocalPreflightEnv(guestMounts)?.PATH : undefined, + { skipPathSearch: true } ) const foundCommands = new Set( commands.filter((cmd) => onPathCommands.has(cmd) || installDirCommands.has(cmd)) diff --git a/src/shared/local-agent-install-dir-detection.ts b/src/shared/local-agent-install-dir-detection.ts index dd7f15d5321..b3c9328f7f3 100644 --- a/src/shared/local-agent-install-dir-detection.ts +++ b/src/shared/local-agent-install-dir-detection.ts @@ -5,16 +5,17 @@ import { resolveCliCommands } from './node-cli-command-resolution' export function detectCommandsInInstallDirs( commands: readonly string[], /** The PATH detection searched; defaults to this process's own. */ - pathEnv?: string + pathEnv?: string, + options?: { skipPathSearch?: boolean } ): Set { if (commands.length === 0) { return new Set() } try { - const resolvedCommands = - pathEnv === undefined - ? resolveCliCommands(commands) - : resolveCliCommands(commands, { pathEnv }) + const resolvedCommands = resolveCliCommands(commands, { + ...(pathEnv === undefined ? {} : { pathEnv }), + ...(options?.skipPathSearch ? { skipPathSearch: true } : {}) + }) return new Set( commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command)) ) diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts index 595d0941368..085ce861693 100644 --- a/src/shared/node-cli-command-resolution.ts +++ b/src/shared/node-cli-command-resolution.ts @@ -9,6 +9,8 @@ type ResolveCommandOptions = { pathEnv?: string | null platform?: NodeJS.Platform homePath?: string + /** Skip the PATH scan; the caller already established absence there (agent detection batches it). */ + skipPathSearch?: boolean } function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] { @@ -288,7 +290,9 @@ export function resolveCliCommands( for (const commandName of new Set(commandNames)) { const executableNames = getExecutableNames(platform, commandName) - const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames) + const pathCandidate = options.skipPathSearch + ? undefined + : findFirstExecutable(platform, pathDirectories, executableNames) const installCandidate = pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames) resolved.set(commandName, installCandidate ?? commandName)