From f86eedb3b5844806b4efc8fcd2a6f3af9599aa86 Mon Sep 17 00:00:00 2001 From: Relakkes Yang Date: Mon, 27 Apr 2026 10:58:38 +0800 Subject: [PATCH] fix: handle unavailable ripgrep paths --- desktop/src-tauri/src/lib.rs | 47 +++++---- src/utils/__tests__/ripgrep.test.ts | 36 +++++++ src/utils/bash/ShellSnapshot.ts | 64 ++++++------ src/utils/ripgrep.ts | 150 +++++++++++++++++++++++++--- 4 files changed, 226 insertions(+), 71 deletions(-) create mode 100644 src/utils/__tests__/ripgrep.test.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index eecc9a39..d25d8614 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -603,19 +603,22 @@ fn start_server_sidecar(app: &AppHandle) -> Result { let app_root_arg = app_root.to_string_lossy().to_string(); // 单一合并 sidecar:第一个参数选 server / cli / adapters 模式。 - let sidecar = app + let mut sidecar = app .shell() .sidecar("claude-sidecar") - .map_err(|err| format!("resolve sidecar: {err}"))? - .args([ - "server", - "--app-root", - &app_root_arg, - "--host", - host, - "--port", - &port.to_string(), - ]); + .map_err(|err| format!("resolve sidecar: {err}"))?; + for (key, value) in terminal_environment(&default_shell()) { + sidecar = sidecar.env(key, value); + } + let sidecar = sidecar.args([ + "server", + "--app-root", + &app_root_arg, + "--host", + host, + "--port", + &port.to_string(), + ]); let startup_logs = Arc::new(Mutex::new(VecDeque::new())); let logs_for_task = Arc::clone(&startup_logs); @@ -707,18 +710,20 @@ fn start_adapters_sidecar(app: &AppHandle) -> Result { server_http_url.clone() }; - let sidecar = app + let mut sidecar = app .shell() .sidecar("claude-sidecar") - .map_err(|err| format!("resolve sidecar: {err}"))? - .env("ADAPTER_SERVER_URL", &server_ws_url) - .args([ - "adapters", - "--app-root", - &app_root_arg, - "--feishu", - "--telegram", - ]); + .map_err(|err| format!("resolve sidecar: {err}"))?; + for (key, value) in terminal_environment(&default_shell()) { + sidecar = sidecar.env(key, value); + } + let sidecar = sidecar.env("ADAPTER_SERVER_URL", &server_ws_url).args([ + "adapters", + "--app-root", + &app_root_arg, + "--feishu", + "--telegram", + ]); let (mut rx, child) = sidecar .spawn() diff --git a/src/utils/__tests__/ripgrep.test.ts b/src/utils/__tests__/ripgrep.test.ts new file mode 100644 index 00000000..dc5c147d --- /dev/null +++ b/src/utils/__tests__/ripgrep.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { isUsableBuiltinRipgrepPath } from '../ripgrep.js' + +const tempFiles: string[] = [] + +afterEach(async () => { + await Promise.all(tempFiles.splice(0).map(path => rm(path, { force: true }))) +}) + +describe('isUsableBuiltinRipgrepPath', () => { + test('rejects Bun virtual filesystem paths', () => { + expect( + isUsableBuiltinRipgrepPath('B:\\~BUN\\root\\vendor\\ripgrep\\x64-win32\\rg.exe'), + ).toBe(false) + expect( + isUsableBuiltinRipgrepPath('/$bunfs/root/vendor/ripgrep/arm64-darwin/rg'), + ).toBe(false) + }) + + test('rejects missing paths', () => { + expect( + isUsableBuiltinRipgrepPath(join(tmpdir(), 'missing-cc-haha-rg')), + ).toBe(false) + }) + + test('accepts real filesystem paths', async () => { + const filePath = join(tmpdir(), `cc-haha-rg-${Date.now()}`) + await writeFile(filePath, '') + tempFiles.push(filePath) + + expect(isUsableBuiltinRipgrepPath(filePath)).toBe(true) + }) +}) diff --git a/src/utils/bash/ShellSnapshot.ts b/src/utils/bash/ShellSnapshot.ts index d26f052c..a8d90261 100644 --- a/src/utils/bash/ShellSnapshot.ts +++ b/src/utils/bash/ShellSnapshot.ts @@ -1,5 +1,4 @@ import { execFile } from 'child_process' -import { execa } from 'execa' import { mkdir, stat } from 'fs/promises' import * as os from 'os' import { join } from 'path' @@ -15,7 +14,6 @@ import { getClaudeConfigHomeDir } from '../envUtils.js' import { pathExists } from '../file.js' import { getFsImplementation } from '../fsOperations.js' import { logError } from '../log.js' -import { getPlatform } from '../platform.js' import { ripgrepCommand } from '../ripgrep.js' import { subprocessEnv } from '../subprocessEnv.js' import { quote } from './shellQuote.js' @@ -65,8 +63,11 @@ function createArgv0ShellFunction( export function createRipgrepShellIntegration(): { type: 'alias' | 'function' snippet: string -} { +} | null { const rgCommand = ripgrepCommand() + if (!rgCommand.rgPath) { + return null + } // For embedded ripgrep (bun-internal), we need a shell function that sets argv0 if (rgCommand.argv0) { @@ -80,6 +81,12 @@ export function createRipgrepShellIntegration(): { } } + // If the selected command is the system rg from PATH, the snapshot should not + // create a fallback alias. Once PATH is restored below, command -v rg is enough. + if (rgCommand.rgPath === 'rg') { + return null + } + // For regular ripgrep, use a simple alias target const quotedPath = quote([rgCommand.rgPath]) const quotedArgs = rgCommand.rgArgs.map(arg => quote([arg])) @@ -267,52 +274,48 @@ function getUserSnapshotContent(configFile: string): string { * This content is always included regardless of user configuration */ async function getClaudeCodeSnapshotContent(): Promise { - // Get the appropriate PATH based on platform - let pathValue = process.env.PATH - if (getPlatform() === 'windows') { - // On Windows with git-bash, read the Cygwin PATH - const cygwinResult = await execa('echo $PATH', { - shell: true, - reject: false, - }) - if (cygwinResult.exitCode === 0 && cygwinResult.stdout) { - pathValue = cygwinResult.stdout.trim() - } - // Fall back to process.env.PATH if we can't get Cygwin PATH - } - const rgIntegration = createRipgrepShellIntegration() let content = '' + // Capture PATH from the shell after user config has been sourced. This keeps + // macOS GUI launches from overwriting Homebrew paths with the app's minimal PATH. + content += ` + # Add PATH to the file + echo "# PATH" >> "$SNAPSHOT_FILE" + printf 'export PATH=%q\\n' "$PATH" >> "$SNAPSHOT_FILE" + ` + // Check if rg is available, if not create an alias/function to bundled ripgrep // We use a subshell to unalias rg before checking, so that user aliases like // `alias rg='rg --smart-case'` don't shadow the real binary check. The subshell // ensures we don't modify the user's aliases in the parent shell. - content += ` + if (rgIntegration !== null) { + content += ` # Check for rg availability echo "# Check for rg availability" >> "$SNAPSHOT_FILE" echo "if ! (unalias rg 2>/dev/null; command -v rg) >/dev/null 2>&1; then" >> "$SNAPSHOT_FILE" ` - if (rgIntegration.type === 'function') { - // For embedded ripgrep, write the function definition using heredoc - content += ` + if (rgIntegration.type === 'function') { + // For embedded ripgrep, write the function definition using heredoc + content += ` cat >> "$SNAPSHOT_FILE" << 'RIPGREP_FUNC_END' ${rgIntegration.snippet} RIPGREP_FUNC_END ` - } else { - // For regular ripgrep, write a simple alias - const escapedSnippet = rgIntegration.snippet.replace(/'/g, "'\\''") - content += ` + } else { + // For regular ripgrep, write a simple alias + const escapedSnippet = rgIntegration.snippet.replace(/'/g, "'\\''") + content += ` echo ' alias rg='"'${escapedSnippet}'" >> "$SNAPSHOT_FILE" ` - } + } - content += ` + content += ` echo "fi" >> "$SNAPSHOT_FILE" ` + } // For ant-native builds, shadow find/grep with bfs/ugrep embedded in the bun // binary. Unlike rg (which only activates if system rg is absent), we always @@ -329,13 +332,6 @@ FIND_GREP_FUNC_END ` } - // Add PATH to the file - content += ` - - # Add PATH to the file - echo "export PATH=${quote([pathValue || ''])}" >> "$SNAPSHOT_FILE" - ` - return content } diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index 683da051..8128aae7 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -1,5 +1,6 @@ import type { ChildProcess, ExecFileException } from 'child_process' -import { execFile, spawn } from 'child_process' +import { execFile, spawn, spawnSync } from 'child_process' +import { existsSync } from 'fs' import memoize from 'lodash-es/memoize.js' import { homedir } from 'os' import * as path from 'path' @@ -21,13 +22,97 @@ const __dirname = path.join( process.env.NODE_ENV === 'test' ? '../../../' : '../', ) +const BUN_VIRTUAL_PATH_MARKERS = ['$bunfs', '~BUN'] + type RipgrepConfig = { - mode: 'system' | 'builtin' | 'embedded' + mode: 'system' | 'builtin' | 'embedded' | 'unavailable' command: string args: string[] argv0?: string } +function isBunVirtualPath(candidatePath: string): boolean { + const normalized = candidatePath.replace(/\\/g, '/') + return BUN_VIRTUAL_PATH_MARKERS.some(marker => normalized.includes(marker)) +} + +export function isUsableBuiltinRipgrepPath(candidatePath: string): boolean { + return !isBunVirtualPath(candidatePath) && existsSync(candidatePath) +} + +function systemRipgrepConfig(): RipgrepConfig | null { + const systemPath = findUsableSystemRipgrep() + if (systemPath === null) { + return null + } + + return { mode: 'system', command: systemPath, args: [] } +} + +function findUsableSystemRipgrep(): string | null { + for (const candidate of systemRipgrepCandidates()) { + if (!existsSync(candidate)) continue + const result = spawnSync(candidate, ['--version'], { + encoding: 'utf8', + timeout: 5000, + windowsHide: true, + }) + if ( + result.status === 0 && + typeof result.stdout === 'string' && + result.stdout.startsWith('ripgrep ') + ) { + return candidate + } + } + return null +} + +function systemRipgrepCandidates(): string[] { + const candidates: string[] = [] + const seen = new Set() + const addCandidate = (candidate: string | null | undefined) => { + if (!candidate || candidate === 'rg') return + const key = + process.platform === 'win32' ? candidate.toLowerCase() : candidate + if (seen.has(key)) return + seen.add(key) + candidates.push(candidate) + } + + addCandidate(findExecutable('rg', []).cmd) + + const pathEntries = (process.env.PATH ?? '').split(path.delimiter) + const extensions = + process.platform === 'win32' + ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD') + .split(';') + .filter(Boolean) + : [''] + + for (const dir of pathEntries) { + if (!dir) continue + for (const ext of extensions) { + addCandidate(path.join(dir, `rg${ext.toLowerCase()}`)) + if (process.platform === 'win32') { + addCandidate(path.join(dir, `rg${ext.toUpperCase()}`)) + } + } + } + + return candidates +} + +function builtinRipgrepConfig(): RipgrepConfig { + const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep') + const command = + process.platform === 'win32' + ? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe') + : path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg') + + return { mode: 'builtin', command, args: [] } +} + const getRipgrepConfig = memoize((): RipgrepConfig => { const userWantsSystemRipgrep = isEnvDefinedFalsy( process.env.USE_BUILTIN_RIPGREP, @@ -35,13 +120,13 @@ const getRipgrepConfig = memoize((): RipgrepConfig => { // Try system ripgrep if user wants it if (userWantsSystemRipgrep) { - const { cmd: systemPath } = findExecutable('rg', []) - if (systemPath !== 'rg') { - // SECURITY: Use command name 'rg' instead of systemPath to prevent PATH hijacking - // If we used systemPath, a malicious ./rg.exe in current directory could be executed - // Using just 'rg' lets the OS resolve it safely with NoDefaultCurrentDirectoryInExePath protection - return { mode: 'system', command: 'rg', args: [] } - } + return ( + systemRipgrepConfig() ?? { + mode: 'unavailable', + command: '', + args: [], + } + ) } // In bundled (native) mode, ripgrep is statically compiled into bun-internal @@ -55,13 +140,18 @@ const getRipgrepConfig = memoize((): RipgrepConfig => { } } - const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep') - const command = - process.platform === 'win32' - ? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe') - : path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg') + const builtinConfig = builtinRipgrepConfig() + if (isUsableBuiltinRipgrepPath(builtinConfig.command)) { + return builtinConfig + } - return { mode: 'builtin', command, args: [] } + return ( + systemRipgrepConfig() ?? { + mode: 'unavailable', + command: '', + args: [], + } + ) }) export function ripgrepCommand(): { @@ -121,6 +211,11 @@ function ripGrepRaw( // pattern is provided const { rgPath, rgArgs, argv0 } = ripgrepCommand() + if (!rgPath) { + throw new Error( + 'ripgrep is not available. Install ripgrep and ensure rg --version works in this environment.', + ) + } // Use single-threaded mode only if explicitly requested for this call's retry const threadArgs = singleThread ? ['-j', '1'] : [] @@ -250,6 +345,11 @@ async function ripGrepFileCount( ): Promise { await codesignRipgrepIfNecessary() const { rgPath, rgArgs, argv0 } = ripgrepCommand() + if (!rgPath) { + throw new Error( + 'ripgrep is not available. Install ripgrep and ensure rg --version works in this environment.', + ) + } return new Promise((resolve, reject) => { const child = spawn(rgPath, [...rgArgs, ...args, target], { @@ -300,6 +400,11 @@ export async function ripGrepStream( ): Promise { await codesignRipgrepIfNecessary() const { rgPath, rgArgs, argv0 } = ripgrepCommand() + if (!rgPath) { + throw new Error( + 'ripgrep is not available. Install ripgrep and ensure rg --version works in this environment.', + ) + } return new Promise((resolve, reject) => { const child = spawn(rgPath, [...rgArgs, ...args, target], { @@ -533,7 +638,7 @@ let ripgrepStatus: { * Returns current configuration immediately, with working status if available */ export function getRipgrepStatus(): { - mode: 'system' | 'builtin' | 'embedded' + mode: 'system' | 'builtin' | 'embedded' | 'unavailable' path: string working: boolean | null // null if not yet tested } { @@ -555,6 +660,19 @@ const testRipgrepOnFirstUse = memoize(async (): Promise => { } const config = getRipgrepConfig() + if (config.mode === 'unavailable') { + ripgrepStatus = { + working: false, + lastTested: Date.now(), + config, + } + logForDebugging('Ripgrep first use test: FAILED (mode=unavailable)') + logEvent('tengu_ripgrep_availability', { + working: 0, + using_system: 0, + }) + return + } try { let test: { code: number; stdout: string }