fix: tmp 目录改用 os.tmpdir() + ripgrep 缺失时自动 fallback 系统 rg

1. Shell.ts / imagePaste.ts / filesystem.ts: Linux/macOS 默认 tmp 路径
   从硬编码 '/tmp' 改为 os.tmpdir(),自动适配 Termux/Android 等无 /tmp
   的环境;macOS 桌面零变化;CLAUDE_CODE_TMPDIR 仍优先级最高。

2. ripgrep.ts: builtin rg 二进制缺失时(Android/Termux、不完整安装)
   自动 fallback 到 PATH 上的系统 rg,通过 note 字段携带人读提示;
   /doctor 渲染 note;init 启动时写一行 stderr warning。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-15 16:15:25 +08:00
parent 2714bbf812
commit 9d6a98dd06
10 changed files with 786 additions and 9 deletions

View File

@@ -2,6 +2,7 @@ import { execFileSync, spawn } from 'child_process'
import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
import { type FileHandle, mkdir, open, realpath } from 'fs/promises'
import memoize from 'lodash-es/memoize.js'
import { tmpdir } from 'os'
import { isAbsolute, resolve } from 'path'
import { join as posixJoin } from 'path/posix'
import { logEvent } from 'src/services/analytics/index.js'
@@ -200,9 +201,10 @@ export async function exec(
.toString(16)
.padStart(4, '0')
// Sandbox temp directory - use per-user directory name to prevent multi-user permission conflicts
// Sandbox temp directory - use per-user directory name to prevent multi-user permission conflicts.
// tmpdir() honors $TMPDIR so non-/tmp environments (Termux/Android, containers) work out of the box.
const sandboxTmpDir = posixJoin(
process.env.CLAUDE_CODE_TMPDIR || '/tmp',
process.env.CLAUDE_CODE_TMPDIR || tmpdir(),
getClaudeTempDirName(),
)

View File

@@ -0,0 +1,75 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
import { mkdirSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
// Test the pure fallback function directly — no mock.module needed,
// so this test cannot pollute other tests in the same Bun process.
// See CLAUDE.md "Mock 使用规范" for why we avoid business-module mocking.
const { resolveBuiltinWithFallback } = await import('../ripgrep.js')
// Real temp dir with a real (or removed) fake rg binary to control existsSync.
const tmpDir = join(
globalThis.process.env.TMPDIR || '/tmp',
'ripgrep-config-test',
)
const vendorDir = join(
tmpDir,
'vendor',
'ripgrep',
`${process.arch}-${process.platform}`,
)
const rgPath = join(vendorDir, process.platform === 'win32' ? 'rg.exe' : 'rg')
describe('resolveBuiltinWithFallback', () => {
beforeAll(() => {
mkdirSync(vendorDir, { recursive: true })
writeFileSync(rgPath, '')
})
afterAll(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
test('builtin exists -> mode=builtin, no note', () => {
const result = resolveBuiltinWithFallback(rgPath)
expect(result.mode).toBe('builtin')
expect(result.command).toBe(rgPath)
expect(result.note).toBeUndefined()
})
test('builtin missing + system rg available -> mode=system, note set', () => {
rmSync(rgPath)
const result = resolveBuiltinWithFallback(
rgPath,
'/usr/local/bin/rg', // explicit system rg path
'testplatform',
)
expect(result.mode).toBe('system')
expect(result.command).toBe('rg')
expect(result.note).toContain('fallback')
expect(result.note).toContain('testplatform')
// Restore for subsequent tests
writeFileSync(rgPath, '')
})
test('builtin missing + system rg missing -> mode=builtin, note set', () => {
rmSync(rgPath)
const result = resolveBuiltinWithFallback(
rgPath,
null, // no system rg
'testplatform',
)
expect(result.mode).toBe('builtin')
expect(result.command).toBe(rgPath)
expect(result.note).toContain('no ripgrep available')
expect(result.note).toContain('testplatform')
writeFileSync(rgPath, '')
})
test('uses process.platform when platform param omitted', () => {
rmSync(rgPath)
const result = resolveBuiltinWithFallback(rgPath, null)
expect(result.note).toContain(process.platform)
writeFileSync(rgPath, '')
})
})

View File

@@ -67,6 +67,7 @@ export type DiagnosticInfo = {
working: boolean
mode: 'system' | 'builtin' | 'embedded'
systemPath: string | null
note: string | null
}
}
@@ -594,6 +595,7 @@ export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> {
mode: ripgrepStatusRaw.mode,
systemPath:
ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null,
note: ripgrepStatusRaw.note ?? null,
}
// Get package manager info if running from package manager

View File

@@ -1,6 +1,7 @@
import { feature } from 'bun:bundle'
import { randomBytes } from 'crypto'
import { execa } from 'execa'
import { tmpdir } from 'os'
import { basename, extname, isAbsolute, join } from 'path'
import {
IMAGE_MAX_HEIGHT,
@@ -32,10 +33,11 @@ function getClipboardCommands() {
const platform = process.platform as SupportedPlatform
// Platform-specific temporary file paths
// Use CLAUDE_CODE_TMPDIR if set, otherwise fall back to platform defaults
// Use CLAUDE_CODE_TMPDIR if set, otherwise fall back to platform defaults.
// tmpdir() honors $TMPDIR so non-/tmp environments (Termux/Android, containers) work out of the box.
const baseTmpDir =
process.env.CLAUDE_CODE_TMPDIR ||
(platform === 'win32' ? process.env.TEMP || 'C:\\Temp' : '/tmp')
(platform === 'win32' ? process.env.TEMP || 'C:\\Temp' : tmpdir())
const screenshotFilename = 'claude_cli_latest_screenshot.png'
const tempPaths: Record<SupportedPlatform, string> = {
darwin: join(baseTmpDir, screenshotFilename),

View File

@@ -329,9 +329,9 @@ export function getClaudeTempDirName(): string {
// and per-turn from BashTool prompt. Inputs (CLAUDE_CODE_TMPDIR env + platform) are
// fixed at startup, and the realpath of the system tmp dir does not change mid-session.
export const getClaudeTempDir = memoize(function getClaudeTempDir(): string {
const baseTmpDir =
process.env.CLAUDE_CODE_TMPDIR ||
(getPlatform() === 'windows' ? tmpdir() : '/tmp')
// tmpdir() honors $TMPDIR so non-/tmp environments (Termux/Android, containers)
// work out of the box; CLAUDE_CODE_TMPDIR still wins if explicitly set.
const baseTmpDir = process.env.CLAUDE_CODE_TMPDIR || tmpdir()
// Resolve symlinks in the base temp directory (e.g., /tmp -> /private/tmp on macOS)
// This ensures the path matches resolved paths in permission checks

View File

@@ -1,5 +1,6 @@
import type { ChildProcess, ExecFileException } from 'child_process'
import { execFile, spawn } from 'child_process'
import { existsSync } from 'fs'
import memoize from 'lodash-es/memoize.js'
import { homedir } from 'os'
import * as path from 'path'
@@ -24,9 +25,10 @@ type RipgrepConfig = {
command: string
args: string[]
argv0?: string
note?: string
}
const getRipgrepConfig = memoize((): RipgrepConfig => {
export const getRipgrepConfig = memoize((): RipgrepConfig => {
const userWantsSystemRipgrep = isEnvDefinedFalsy(
process.env.USE_BUILTIN_RIPGREP,
)
@@ -59,9 +61,61 @@ const getRipgrepConfig = memoize((): RipgrepConfig => {
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
return { mode: 'builtin', command, args: [] }
return resolveBuiltinWithFallback(command)
})
/**
* Pure function: decide what to do when the builtin rg binary may be missing.
* Extracted so it can be tested without any module mocking.
*
* @param builtinPath Path to the vendored rg binary.
* @param systemRgPath When omitted, calls `findExecutable('rg')` (production path).
* Pass a string to force a specific system path, or `null` to
* simulate "system rg not found".
* @param platform Override for `process.platform` (tests only).
*/
export function resolveBuiltinWithFallback(
builtinPath: string,
systemRgPath?: string | null,
platform?: string,
): {
mode: 'system' | 'builtin'
command: string
args: string[]
note?: string
} {
const p = platform ?? process.platform
// Builtin exists — use it, no note.
if (existsSync(builtinPath)) {
return { mode: 'builtin', command: builtinPath, args: [] }
}
// Builtin missing — check system rg.
// When systemRgPath is explicitly passed (including null), use it directly.
// When undefined, call findExecutable (production path).
const resolvedSystem =
systemRgPath === undefined
? findExecutable('rg', []).cmd
: (systemRgPath ?? 'rg')
if (resolvedSystem !== 'rg') {
return {
mode: 'system',
command: 'rg',
args: [],
note: `fallback: builtin rg unavailable on ${p}, using system rg`,
}
}
// Neither available.
return {
mode: 'builtin',
command: builtinPath,
args: [],
note: `no ripgrep available on ${p}; install via apt/pkg/brew or set USE_BUILTIN_RIPGREP=0`,
}
}
export function ripgrepCommand(): {
rgPath: string
rgArgs: string[]
@@ -524,6 +578,7 @@ let ripgrepStatus: {
working: boolean
lastTested: number
config: RipgrepConfig
note?: string
} | null = null
/**
@@ -534,12 +589,14 @@ export function getRipgrepStatus(): {
mode: 'system' | 'builtin' | 'embedded'
path: string
working: boolean | null // null if not yet tested
note?: string
} {
const config = getRipgrepConfig()
return {
mode: config.mode,
path: config.command,
working: ripgrepStatus?.working ?? null,
note: ripgrepStatus?.note ?? config.note,
}
}
@@ -593,6 +650,7 @@ const testRipgrepOnFirstUse = memoize(async (): Promise<void> => {
working,
lastTested: Date.now(),
config,
note: config.note,
}
logForDebugging(
@@ -609,6 +667,7 @@ const testRipgrepOnFirstUse = memoize(async (): Promise<void> => {
working: false,
lastTested: Date.now(),
config,
note: config.note,
}
logError(error)
}