mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
4dc705db3c
Desktop file mentions should follow the workspace the user opened, but the filesystem browser only trusted the home and temp roots. Register workspace roots after repository context, session creation, and session git-info resolution so Windows projects on another drive can be searched without turning the browse API into arbitrary disk access. Constraint: Windows users can open repositories outside C:\\Users, such as D:\\workspace\\code\\cc-haha Constraint: Filesystem browse must not become an unbounded local disk reader Rejected: Add a global user-configurable filesystem whitelist | broader product and persistence surface than this bug needs Rejected: Allow every requested browse path | would bypass the intended filesystem boundary Confidence: high Scope-risk: moderate Directive: Register only workspace roots that the server has already resolved through session or repository flows Tested: bun test src/server/__tests__/filesystem.test.ts Tested: bun run check:server Not-tested: Windows packaged desktop smoke
453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
/**
|
|
* Filesystem browser & search API — supports directory browsing and file search
|
|
* for the DirectoryPicker component and @-triggered file search popup.
|
|
*/
|
|
|
|
import * as path from 'path'
|
|
import * as fs from 'fs'
|
|
import * as os from 'os'
|
|
import ignore from 'ignore'
|
|
import { getGlobalConfig } from '../../utils/config.js'
|
|
import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js'
|
|
import { findGitRoot, gitExe } from '../../utils/git.js'
|
|
import { ripGrep } from '../../utils/ripgrep.js'
|
|
import { getInitialSettings } from '../../utils/settings/settings.js'
|
|
import { isWithinRegisteredFilesystemRoot } from '../services/filesystemAccessRoots.js'
|
|
|
|
type FilesystemEntry = {
|
|
name: string
|
|
path: string
|
|
isDirectory: boolean
|
|
relativePath?: string
|
|
}
|
|
|
|
type ScoredFilesystemEntry = FilesystemEntry & {
|
|
score: number
|
|
}
|
|
|
|
const FILE_SEARCH_TIMEOUT_MS = 10_000
|
|
|
|
const IMAGE_MIME_TYPES: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
'.svg': 'image/svg+xml',
|
|
'.bmp': 'image/bmp',
|
|
'.ico': 'image/x-icon',
|
|
'.avif': 'image/avif',
|
|
}
|
|
|
|
function isWithinRoot(targetPath: string, rootPath: string): boolean {
|
|
const target = normalizeComparablePath(targetPath)
|
|
const root = normalizeComparablePath(rootPath)
|
|
return target === root || target.startsWith(`${root}${path.sep}`)
|
|
}
|
|
|
|
function normalizeComparablePath(filePath: string): string {
|
|
const resolved = path.resolve(filePath)
|
|
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
}
|
|
|
|
function isAllowedFilesystemPath(targetPath: string): boolean {
|
|
const resolvedPath = path.resolve(targetPath)
|
|
const homeDir = path.resolve(os.homedir())
|
|
|
|
if (isWithinRoot(resolvedPath, homeDir) || isWithinRoot(resolvedPath, '/tmp')) {
|
|
return true
|
|
}
|
|
|
|
if (isWithinRegisteredFilesystemRoot(resolvedPath)) {
|
|
return true
|
|
}
|
|
|
|
// macOS reports /tmp as /private/tmp via native folder pickers and realpath().
|
|
if (process.platform === 'darwin' && isWithinRoot(resolvedPath, '/private/tmp')) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export async function handleFilesystemRoute(pathname: string, url: URL): Promise<Response> {
|
|
if (pathname === '/api/filesystem/browse') {
|
|
return handleBrowse(url)
|
|
}
|
|
|
|
if (pathname === '/api/filesystem/file') {
|
|
return handleServeFile(url)
|
|
}
|
|
|
|
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
|
|
}
|
|
|
|
async function handleServeFile(url: URL): Promise<Response> {
|
|
const filePath = url.searchParams.get('path')
|
|
if (!filePath) {
|
|
return json({ error: 'Missing path parameter' }, 400)
|
|
}
|
|
|
|
const resolvedPath = path.resolve(filePath)
|
|
|
|
if (!isAllowedFilesystemPath(resolvedPath)) {
|
|
return json({ error: 'Access denied: path outside allowed directory' }, 403)
|
|
}
|
|
|
|
const ext = path.extname(resolvedPath).toLowerCase()
|
|
const mimeType = IMAGE_MIME_TYPES[ext]
|
|
|
|
if (!mimeType) {
|
|
return json({ error: 'Unsupported file type' }, 400)
|
|
}
|
|
|
|
try {
|
|
const stat = fs.statSync(resolvedPath)
|
|
if (!stat.isFile()) {
|
|
return json({ error: 'Not a file' }, 400)
|
|
}
|
|
// Limit to 50MB
|
|
if (stat.size > 50 * 1024 * 1024) {
|
|
return json({ error: 'File too large' }, 400)
|
|
}
|
|
|
|
const data = fs.readFileSync(resolvedPath)
|
|
return new Response(data, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': mimeType,
|
|
'Content-Length': String(stat.size),
|
|
'Cache-Control': 'private, max-age=3600',
|
|
},
|
|
})
|
|
} catch {
|
|
return json({ error: 'File not found' }, 404)
|
|
}
|
|
}
|
|
|
|
async function handleBrowse(url: URL): Promise<Response> {
|
|
const targetPath = url.searchParams.get('path') || os.homedir() || '/'
|
|
const resolvedPath = path.resolve(targetPath)
|
|
|
|
if (!isAllowedFilesystemPath(resolvedPath)) {
|
|
return json({ error: 'Access denied: path outside allowed directory' }, 403)
|
|
}
|
|
|
|
const searchQuery = url.searchParams.get('search') || ''
|
|
const includeFiles = url.searchParams.get('includeFiles') === 'true'
|
|
const maxResults = Math.min(parseInt(url.searchParams.get('maxResults') || '200', 10), 200)
|
|
|
|
try {
|
|
const stat = fs.statSync(resolvedPath)
|
|
if (!stat.isDirectory()) {
|
|
return json({ error: 'Not a directory', path: resolvedPath }, 400)
|
|
}
|
|
|
|
if (searchQuery) {
|
|
const results = await searchFilesystemEntries(resolvedPath, searchQuery, {
|
|
includeFiles,
|
|
maxResults,
|
|
})
|
|
|
|
return json({
|
|
currentPath: resolvedPath,
|
|
parentPath: path.dirname(resolvedPath),
|
|
entries: results,
|
|
query: searchQuery,
|
|
})
|
|
}
|
|
|
|
const entries = fs.readdirSync(resolvedPath, { withFileTypes: true })
|
|
|
|
// Browse mode: show all directories (and optionally files)
|
|
const filtered = entries.filter((e) => {
|
|
if (e.name.startsWith('.')) return false
|
|
if (e.isDirectory()) return true
|
|
return includeFiles
|
|
})
|
|
|
|
const entries_list = filtered
|
|
.map((e) => ({
|
|
name: e.name,
|
|
path: path.join(resolvedPath, e.name),
|
|
isDirectory: e.isDirectory(),
|
|
relativePath: e.name,
|
|
}))
|
|
.sort((a, b) => {
|
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
|
|
return json({
|
|
currentPath: resolvedPath,
|
|
parentPath: path.dirname(resolvedPath),
|
|
entries: entries_list,
|
|
})
|
|
} catch (err) {
|
|
return json({ error: `Cannot read directory: ${err}`, path: resolvedPath }, 500)
|
|
}
|
|
}
|
|
|
|
async function searchFilesystemEntries(
|
|
rootPath: string,
|
|
searchQuery: string,
|
|
options: { includeFiles: boolean; maxResults: number },
|
|
): Promise<FilesystemEntry[]> {
|
|
const normalizedQuery = normalizeSearchText(searchQuery)
|
|
if (!normalizedQuery) return []
|
|
|
|
const candidates = await getSearchCandidates(rootPath, options.includeFiles)
|
|
const results = candidates
|
|
.map((entry): ScoredFilesystemEntry | null => {
|
|
const relativePath = entry.relativePath ?? entry.name
|
|
const score = scoreFilesystemEntry(entry.name, relativePath, normalizedQuery, entry.isDirectory)
|
|
return score > 0 ? { ...entry, score } : null
|
|
})
|
|
.filter((entry): entry is ScoredFilesystemEntry => entry !== null)
|
|
|
|
return results
|
|
.sort((a, b) => {
|
|
if (a.score !== b.score) return b.score - a.score
|
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
|
|
const aPath = a.relativePath ?? a.name
|
|
const bPath = b.relativePath ?? b.name
|
|
const aDepth = pathDepth(aPath)
|
|
const bDepth = pathDepth(bPath)
|
|
if (aDepth !== bDepth) return aDepth - bDepth
|
|
return (a.relativePath ?? a.name).localeCompare(b.relativePath ?? b.name)
|
|
})
|
|
.slice(0, options.maxResults)
|
|
.map(({ score: _score, ...entry }) => entry)
|
|
}
|
|
|
|
async function getSearchCandidates(rootPath: string, includeFiles: boolean): Promise<FilesystemEntry[]> {
|
|
const files = await getProjectSearchFiles(rootPath)
|
|
const entries = new Map<string, FilesystemEntry>()
|
|
|
|
for (const filePath of files) {
|
|
const normalizedFile = normalizeRelativePath(filePath)
|
|
if (!normalizedFile || !isRelativeInsideRoot(normalizedFile)) continue
|
|
|
|
let currentDir = path.posix.dirname(normalizedFile)
|
|
while (currentDir !== '.') {
|
|
addCandidate(entries, rootPath, currentDir, true)
|
|
const parent = path.posix.dirname(currentDir)
|
|
if (parent === currentDir) break
|
|
currentDir = parent
|
|
}
|
|
|
|
if (includeFiles) {
|
|
addCandidate(entries, rootPath, normalizedFile, false)
|
|
}
|
|
}
|
|
|
|
return [...entries.values()]
|
|
}
|
|
|
|
function addCandidate(entries: Map<string, FilesystemEntry>, rootPath: string, relativePath: string, isDirectory: boolean): void {
|
|
if (entries.has(relativePath)) return
|
|
entries.set(relativePath, {
|
|
name: path.posix.basename(relativePath),
|
|
path: path.join(rootPath, ...relativePath.split('/')),
|
|
isDirectory,
|
|
relativePath,
|
|
})
|
|
}
|
|
|
|
async function getProjectSearchFiles(rootPath: string): Promise<string[]> {
|
|
const respectGitignore = shouldRespectGitignore()
|
|
const gitFiles = await getFilesUsingGit(rootPath, respectGitignore)
|
|
if (gitFiles !== null) {
|
|
return gitFiles
|
|
}
|
|
|
|
return getFilesUsingRipgrep(rootPath, respectGitignore)
|
|
}
|
|
|
|
function shouldRespectGitignore(): boolean {
|
|
const projectSettings = getInitialSettings()
|
|
const globalConfig = getGlobalConfig()
|
|
return projectSettings.respectGitignore ?? globalConfig.respectGitignore ?? true
|
|
}
|
|
|
|
async function getFilesUsingGit(rootPath: string, respectGitignore: boolean): Promise<string[] | null> {
|
|
const repoRoot = findGitRoot(rootPath)
|
|
if (!repoRoot) return null
|
|
|
|
const trackedResult = await execFileNoThrowWithCwd(
|
|
gitExe(),
|
|
['-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
|
|
{ timeout: FILE_SEARCH_TIMEOUT_MS, cwd: repoRoot },
|
|
)
|
|
if (trackedResult.code !== 0) return null
|
|
|
|
const untrackedArgs = respectGitignore
|
|
? ['-c', 'core.quotepath=false', 'ls-files', '--others', '--exclude-standard']
|
|
: ['-c', 'core.quotepath=false', 'ls-files', '--others']
|
|
const untrackedResult = await execFileNoThrowWithCwd(gitExe(), untrackedArgs, {
|
|
timeout: FILE_SEARCH_TIMEOUT_MS,
|
|
cwd: repoRoot,
|
|
})
|
|
|
|
const files = [
|
|
...lines(trackedResult.stdout),
|
|
...(untrackedResult.code === 0 ? lines(untrackedResult.stdout) : []),
|
|
]
|
|
let normalized = files
|
|
.map(filePath => normalizeGitPath(filePath, repoRoot, rootPath))
|
|
.filter((filePath): filePath is string => filePath !== null)
|
|
|
|
const ignorePatterns = loadSearchIgnorePatterns(repoRoot, rootPath, false)
|
|
if (ignorePatterns) {
|
|
normalized = ignorePatterns.filter(normalized)
|
|
}
|
|
|
|
return [...new Set(normalized)]
|
|
}
|
|
|
|
async function getFilesUsingRipgrep(rootPath: string, respectGitignore: boolean): Promise<string[]> {
|
|
const rgArgs = [
|
|
'--files',
|
|
'--follow',
|
|
'--hidden',
|
|
'--glob',
|
|
'!.git/',
|
|
'--glob',
|
|
'!.svn/',
|
|
'--glob',
|
|
'!.hg/',
|
|
'--glob',
|
|
'!.bzr/',
|
|
'--glob',
|
|
'!.jj/',
|
|
'--glob',
|
|
'!.sl/',
|
|
]
|
|
if (!respectGitignore) {
|
|
rgArgs.push('--no-ignore-vcs')
|
|
}
|
|
|
|
const files = await ripGrep(rgArgs, rootPath, AbortSignal.timeout(FILE_SEARCH_TIMEOUT_MS))
|
|
let normalized = files
|
|
.map(filePath => normalizeRipgrepPath(filePath, rootPath))
|
|
.filter((filePath): filePath is string => filePath !== null)
|
|
|
|
const ignorePatterns = loadSearchIgnorePatterns(rootPath, rootPath, true)
|
|
if (ignorePatterns) {
|
|
normalized = ignorePatterns.filter(normalized)
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
function normalizeGitPath(filePath: string, repoRoot: string, rootPath: string): string | null {
|
|
const relativePath = path.relative(rootPath, path.join(repoRoot, filePath))
|
|
const normalized = normalizeRelativePath(relativePath)
|
|
return isRelativeInsideRoot(normalized) ? normalized : null
|
|
}
|
|
|
|
function normalizeRipgrepPath(filePath: string, rootPath: string): string | null {
|
|
const relativePath = path.isAbsolute(filePath) ? path.relative(rootPath, filePath) : filePath
|
|
const normalized = normalizeRelativePath(relativePath)
|
|
return isRelativeInsideRoot(normalized) ? normalized : null
|
|
}
|
|
|
|
function normalizeRelativePath(filePath: string): string {
|
|
return filePath.replace(/\\/g, '/').replace(/^\.\/+/, '')
|
|
}
|
|
|
|
function isRelativeInsideRoot(filePath: string): boolean {
|
|
return !!filePath && filePath !== '.' && !filePath.startsWith('../') && !path.isAbsolute(filePath)
|
|
}
|
|
|
|
function lines(output: string): string[] {
|
|
return output.trim().split('\n').map(line => line.trim()).filter(Boolean)
|
|
}
|
|
|
|
function loadSearchIgnorePatterns(repoRoot: string, rootPath: string, includeGitignore: boolean): ReturnType<typeof ignore> | null {
|
|
const ig = ignore()
|
|
let hasPatterns = false
|
|
const ignoreFiles = includeGitignore ? ['.gitignore', '.ignore', '.rgignore'] : ['.ignore', '.rgignore']
|
|
const paths = [...new Set([repoRoot, rootPath])].flatMap(dir => ignoreFiles.map(fileName => path.join(dir, fileName)))
|
|
|
|
for (const ignorePath of paths) {
|
|
try {
|
|
ig.add(fs.readFileSync(ignorePath, 'utf8'))
|
|
hasPatterns = true
|
|
} catch {
|
|
// Missing or unreadable ignore files should not break suggestions.
|
|
}
|
|
}
|
|
|
|
return hasPatterns ? ig : null
|
|
}
|
|
|
|
function normalizeSearchText(value: string): string {
|
|
return value
|
|
.trim()
|
|
.replace(/\\/g, '/')
|
|
.replace(/^@+/, '')
|
|
.replace(/^\.\/+/, '')
|
|
.replace(/\/+$/, '')
|
|
.toLowerCase()
|
|
}
|
|
|
|
function scoreFilesystemEntry(name: string, relativePath: string, query: string, isDirectory: boolean): number {
|
|
const normalizedName = normalizeSearchText(name)
|
|
const normalizedPath = normalizeSearchText(relativePath)
|
|
const pathNoExtension = normalizedPath.replace(/\.[^/.]+$/, '')
|
|
const pathPrefix = `${query}/`
|
|
const baseBoost = isDirectory ? 4 : 0
|
|
const depthPenalty = Math.min(relativePath.split('/').length - 1, 8) * 2
|
|
|
|
if (normalizedPath === query) return 150 + baseBoost - depthPenalty
|
|
if (pathNoExtension === query) return 144 + baseBoost - depthPenalty
|
|
if (normalizedPath.startsWith(pathPrefix)) return 136 + baseBoost - depthPenalty
|
|
if (normalizedPath.startsWith(query)) return 112 + baseBoost - depthPenalty
|
|
if (normalizedName === query) return 96 + baseBoost - depthPenalty
|
|
if (normalizedName.startsWith(query)) return 88 + baseBoost - depthPenalty
|
|
if (normalizedName.includes(query)) return 72 + baseBoost - depthPenalty
|
|
if (normalizedPath.includes(query)) return 60 + baseBoost - depthPenalty
|
|
|
|
const nameFuzzy = fuzzyScore(normalizedName, query)
|
|
if (nameFuzzy > 0) return 44 + nameFuzzy + baseBoost - depthPenalty
|
|
|
|
const pathFuzzy = fuzzyScore(normalizedPath, query)
|
|
if (pathFuzzy > 0) return 28 + pathFuzzy + baseBoost - depthPenalty
|
|
|
|
return 0
|
|
}
|
|
|
|
function pathDepth(relativePath: string): number {
|
|
return relativePath.split('/').length
|
|
}
|
|
|
|
function fuzzyScore(value: string, query: string): number {
|
|
let queryIndex = 0
|
|
let runLength = 0
|
|
let score = 0
|
|
|
|
for (let valueIndex = 0; valueIndex < value.length && queryIndex < query.length; valueIndex += 1) {
|
|
if (value[valueIndex] !== query[queryIndex]) {
|
|
runLength = 0
|
|
continue
|
|
}
|
|
|
|
const boundaryBoost = valueIndex === 0 || ['/', '-', '_', '.', ' '].includes(value[valueIndex - 1] ?? '')
|
|
? 3
|
|
: 0
|
|
runLength += 1
|
|
score += 2 + boundaryBoost + Math.min(runLength, 4)
|
|
queryIndex += 1
|
|
}
|
|
|
|
return queryIndex === query.length ? score : 0
|
|
}
|
|
|
|
function json(data: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(data), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
}
|