mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user