fix: handle unavailable ripgrep paths

This commit is contained in:
Relakkes Yang 2026-04-27 10:58:38 +08:00
parent 869aeb0e77
commit f86eedb3b5
4 changed files with 226 additions and 71 deletions

View File

@ -603,19 +603,22 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
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<CommandChild, String> {
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()

View File

@ -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)
})
})

View File

@ -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<string> {
// 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
}

View File

@ -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<string>()
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<number> {
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<number>((resolve, reject) => {
const child = spawn(rgPath, [...rgArgs, ...args, target], {
@ -300,6 +400,11 @@ export async function ripGrepStream(
): Promise<void> {
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<void>((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<void> => {
}
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 }