fix(desktop): resolve CLI tools from shell PATH (#15368)

* fix(desktop): resolve CLI tools from shell PATH

* fix(desktop): validate resolved CLI with fallback PATH
This commit is contained in:
qybaihe
2026-06-02 11:29:57 +08:00
committed by GitHub
parent 857aaf4766
commit 66c9339e98
2 changed files with 143 additions and 19 deletions
@@ -181,5 +181,46 @@ describe('cliAgentDetectors', () => {
expect(execMock).not.toHaveBeenCalled();
expect(execFileMock).toHaveBeenCalledTimes(2);
});
it('falls back to the login shell PATH for tools installed by shell setup', async () => {
const originalPath = process.env.PATH;
const originalShell = process.env.SHELL;
process.env.PATH = '/usr/bin:/bin';
process.env.SHELL = '/bin/zsh';
try {
callExecFileError(new Error('not found'));
callExecFile('/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin');
callExecFile('/Users/Hanam/.local/share/mise/shims/gemini\n');
callExecFile('gemini 0.2.0');
const { geminiCliDetector } = await import('../cliAgentDetectors');
const status = await geminiCliDetector.detect();
expect(status.available).toBe(true);
expect(status.path).toBe('/Users/Hanam/.local/share/mise/shims/gemini');
expect(status.version).toBe('gemini 0.2.0');
expect(execFileMock).toHaveBeenCalledTimes(4);
expect(execFileMock.mock.calls[0]![0]).toBe('which');
expect(execFileMock.mock.calls[1]![0]).toBe('/bin/zsh');
expect(execFileMock.mock.calls[1]![1]).toEqual(['-ilc', 'printf "%s" "$PATH"']);
expect(execFileMock.mock.calls[2]![0]).toBe('which');
expect(execFileMock.mock.calls[2]![2]).toMatchObject({
env: {
PATH: '/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin',
},
});
expect(execFileMock.mock.calls[3]![0]).toBe('/Users/Hanam/.local/share/mise/shims/gemini');
expect(execFileMock.mock.calls[3]![2]).toMatchObject({
env: {
PATH: '/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin',
},
});
} finally {
process.env.PATH = originalPath;
process.env.SHELL = originalShell;
}
});
});
});
@@ -19,7 +19,13 @@ interface ValidatedDetectorOptions {
validateKeywords: string[];
}
interface ResolvedCommand {
env?: NodeJS.ProcessEnv;
path: string;
}
const isWindows = () => platform() === 'win32';
let shellPathPromise: Promise<string | undefined> | undefined;
// Reject anything that could break out of the `cmd /c "<path>" --version`
// shell line we build for Windows .cmd shims (see `detectValidatedCommand`).
@@ -40,36 +46,109 @@ const pickWindowsRunnable = (lines: string[]): string | undefined => {
return undefined;
};
const resolveCommandPath = async (command: string): Promise<string | undefined> => {
const trimmedCommand = command.trim();
if (!trimmedCommand) return;
const getLoginShellPath = async (): Promise<string | undefined> => {
if (isWindows()) return undefined;
if (path.isAbsolute(trimmedCommand) || trimmedCommand.includes(path.sep)) {
return trimmedCommand;
}
const whichCommand = isWindows() ? 'where' : 'which';
const shell = process.env.SHELL;
if (!shell || !path.isAbsolute(shell)) return undefined;
try {
const { stdout } = await execFilePromise(whichCommand, [trimmedCommand], { timeout: 3000 });
const { stdout } = await execFilePromise(shell, ['-ilc', 'printf "%s" "$PATH"'], {
timeout: 3000,
windowsHide: true,
});
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.reverse()
.find((line) => line.includes(path.delimiter));
} catch {
return undefined;
}
};
const getCachedLoginShellPath = async (): Promise<string | undefined> => {
shellPathPromise ??= getLoginShellPath();
return shellPathPromise;
};
const mergePathValues = (...values: Array<string | undefined>): string | undefined => {
const seen = new Set<string>();
const segments = values
.flatMap((value) => value?.split(path.delimiter) ?? [])
.map((segment) => segment.trim())
.filter((segment) => {
if (!segment || seen.has(segment)) return false;
seen.add(segment);
return true;
});
return segments.length > 0 ? segments.join(path.delimiter) : undefined;
};
const getCommandPathLines = async (
whichCommand: 'where' | 'which',
command: string,
env?: NodeJS.ProcessEnv,
): Promise<string[] | undefined> => {
try {
const { stdout } = await execFilePromise(whichCommand, [command], {
env,
timeout: 3000,
windowsHide: true,
});
const lines = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) return undefined;
// Windows `where` lists every PATHEXT match (e.g. for `codex` npm ships
// a Unix shell wrapper alongside `codex.cmd` and `codex.ps1`). Picking
// the first line can land us on something we can't execute, so prefer a
// runnable extension and bail otherwise.
if (isWindows()) return pickWindowsRunnable(lines);
return lines[0];
return lines.length > 0 ? lines : undefined;
} catch {
return undefined;
}
};
const resolveCommandPath = async (command: string): Promise<ResolvedCommand | undefined> => {
const trimmedCommand = command.trim();
if (!trimmedCommand) return;
if (path.isAbsolute(trimmedCommand) || trimmedCommand.includes(path.sep)) {
return { path: trimmedCommand };
}
const whichCommand = isWindows() ? 'where' : 'which';
let lines = await getCommandPathLines(whichCommand, trimmedCommand);
let lookupEnv: NodeJS.ProcessEnv | undefined;
if (!lines && !isWindows()) {
const shellPath = await getCachedLoginShellPath();
const lookupPath = mergePathValues(shellPath, process.env.PATH);
if (lookupPath && lookupPath !== process.env.PATH) {
const fallbackEnv = {
...process.env,
PATH: lookupPath,
};
lines = await getCommandPathLines(whichCommand, trimmedCommand, fallbackEnv);
if (lines) lookupEnv = fallbackEnv;
}
}
if (!lines) return undefined;
// Windows `where` lists every PATHEXT match (e.g. for `codex` npm ships
// a Unix shell wrapper alongside `codex.cmd` and `codex.ps1`). Picking
// the first line can land us on something we can't execute, so prefer a
// runnable extension and bail otherwise.
if (isWindows()) {
const runnablePath = pickWindowsRunnable(lines);
return runnablePath ? { path: runnablePath } : undefined;
}
return { env: lookupEnv, path: lines[0] };
};
const detectValidatedCommand = async (
command: string,
options: Pick<ValidatedDetectorOptions, 'validateFlag' | 'validateKeywords'>,
@@ -83,17 +162,21 @@ const detectValidatedCommand = async (
// Resolve via where/which BEFORE invoking. On Windows this is what discovers
// npm-installed shims like `claude.cmd` under %APPDATA%\npm — `execFile`
// alone won't apply PATHEXT and can't run .cmd files directly.
const resolvedPath = await resolveCommandPath(trimmedCommand);
if (!resolvedPath) return { available: false };
const resolvedCommand = await resolveCommandPath(trimmedCommand);
if (!resolvedCommand) return { available: false };
const { env, path: resolvedPath } = resolvedCommand;
try {
const needsShell = isWindows() && /\.(?:cmd|bat)$/i.test(resolvedPath);
const { stderr, stdout } = needsShell
? await execPromise(`"${resolvedPath}" ${validateFlag}`, {
env,
timeout: 5000,
windowsHide: true,
})
: await execFilePromise(resolvedPath, [validateFlag], {
env,
timeout: 5000,
windowsHide: true,
});