mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
Merge filesystem workspace access fix into local main
Bring in the selected-workspace filesystem access fix from the detached investigation worktree so Windows projects outside the user home can use desktop @ file search. Constraint: Preserve local main history while landing the detached worktree fix Confidence: high Scope-risk: moderate Tested: bun test src/server/__tests__/filesystem.test.ts Tested: bun run check:server Not-tested: Windows packaged desktop smoke after merge
This commit is contained in:
commit
3f384a1d5f
@ -5,6 +5,8 @@ import * as fsp from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { handleFilesystemRoute } from '../api/filesystem.js'
|
||||
import { clearFilesystemAccessRootsForTests } from '../services/filesystemAccessRoots.js'
|
||||
import { getRepositoryContext } from '../services/repositoryLaunchService.js'
|
||||
|
||||
const cleanupDirs = new Set<string>()
|
||||
|
||||
@ -21,6 +23,7 @@ afterEach(async () => {
|
||||
await fsp.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
cleanupDirs.clear()
|
||||
clearFilesystemAccessRootsForTests()
|
||||
})
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
@ -30,6 +33,34 @@ function git(cwd: string, ...args: string[]): string {
|
||||
})
|
||||
}
|
||||
|
||||
function isWithinPath(targetPath: string, rootPath: string): boolean {
|
||||
const target = path.resolve(targetPath)
|
||||
const root = path.resolve(rootPath)
|
||||
return target === root || target.startsWith(`${root}${path.sep}`)
|
||||
}
|
||||
|
||||
async function makeExternalFixtureDir(): Promise<string | null> {
|
||||
const candidates = ['/var/tmp', '/private/var/tmp', '/Users/Shared']
|
||||
|
||||
for (const baseDir of candidates) {
|
||||
try {
|
||||
const stat = await fsp.stat(baseDir)
|
||||
if (!stat.isDirectory()) continue
|
||||
const fixtureDir = await fsp.mkdtemp(path.join(baseDir, 'claude-filesystem-test-'))
|
||||
const isDefaultAllowed =
|
||||
isWithinPath(fixtureDir, os.homedir()) ||
|
||||
isWithinPath(fixtureDir, '/tmp') ||
|
||||
(process.platform === 'darwin' && isWithinPath(fixtureDir, '/private/tmp'))
|
||||
if (!isDefaultAllowed) return fixtureDir
|
||||
await fsp.rm(fixtureDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Try the next common writable system directory.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('filesystem API', () => {
|
||||
it('allows browsing a directory under the user home directory', async () => {
|
||||
const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-'))
|
||||
@ -49,6 +80,37 @@ describe('filesystem API', () => {
|
||||
expect(body.entries.some((entry) => entry.name === 'note.txt')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows browsing a selected workspace outside the default home/tmp roots', async () => {
|
||||
const externalFixtureDir = await makeExternalFixtureDir()
|
||||
if (!externalFixtureDir) return
|
||||
|
||||
cleanupDirs.add(externalFixtureDir)
|
||||
await fsp.writeFile(path.join(externalFixtureDir, 'note.txt'), 'hello')
|
||||
|
||||
const deniedRes = await handleFilesystemRoute(
|
||||
'/api/filesystem/browse',
|
||||
makeUrl('/api/filesystem/browse', {
|
||||
path: externalFixtureDir,
|
||||
includeFiles: 'true',
|
||||
}),
|
||||
)
|
||||
expect(deniedRes.status).toBe(403)
|
||||
|
||||
await getRepositoryContext(externalFixtureDir)
|
||||
|
||||
const res = await handleFilesystemRoute(
|
||||
'/api/filesystem/browse',
|
||||
makeUrl('/api/filesystem/browse', {
|
||||
path: externalFixtureDir,
|
||||
includeFiles: 'true',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { entries: Array<{ name: string }> }
|
||||
expect(body.entries.some((entry) => entry.name === 'note.txt')).toBe(true)
|
||||
})
|
||||
|
||||
it('fuzzy searches files and directories below the selected root', async () => {
|
||||
const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-'))
|
||||
cleanupDirs.add(homeFixtureDir)
|
||||
|
||||
@ -12,6 +12,7 @@ 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
|
||||
@ -57,6 +58,10 @@ function isAllowedFilesystemPath(targetPath: string): boolean {
|
||||
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
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
createSessionBranch,
|
||||
SessionBranchingError,
|
||||
} from '../../utils/sessionBranching.js'
|
||||
import { registerFilesystemAccessRoot } from '../services/filesystemAccessRoots.js'
|
||||
|
||||
const workspaceService = new WorkspaceService(
|
||||
async (sessionId) => (
|
||||
@ -314,7 +315,11 @@ async function getSessionRepositoryContext(url: URL): Promise<Response> {
|
||||
throw ApiError.badRequest('workDir query parameter is required')
|
||||
}
|
||||
|
||||
return Response.json(await getRepositoryContext(workDir))
|
||||
const context = await getRepositoryContext(workDir)
|
||||
registerFilesystemAccessRoot(workDir)
|
||||
registerFilesystemAccessRoot(context.workDir)
|
||||
registerFilesystemAccessRoot(context.repoRoot)
|
||||
return Response.json(context)
|
||||
}
|
||||
|
||||
async function requireSessionWorkspace(sessionId: string): Promise<string> {
|
||||
@ -633,6 +638,7 @@ async function getGitInfo(sessionId: string): Promise<Response> {
|
||||
if (!workDir) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
registerFilesystemAccessRoot(workDir)
|
||||
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
|
||||
const repository = launchInfo?.repository
|
||||
const worktreeSession = launchInfo?.worktreeSession
|
||||
|
||||
30
src/server/services/filesystemAccessRoots.ts
Normal file
30
src/server/services/filesystemAccessRoots.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import * as path from 'node:path'
|
||||
|
||||
const registeredRoots = new Set<string>()
|
||||
|
||||
function normalizeComparablePath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath)
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
||||
}
|
||||
|
||||
function isWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||
const target = normalizeComparablePath(targetPath)
|
||||
const root = normalizeComparablePath(rootPath)
|
||||
return target === root || target.startsWith(`${root}${path.sep}`)
|
||||
}
|
||||
|
||||
export function registerFilesystemAccessRoot(rootPath: string | null | undefined): void {
|
||||
if (!rootPath) return
|
||||
registeredRoots.add(path.resolve(rootPath))
|
||||
}
|
||||
|
||||
export function isWithinRegisteredFilesystemRoot(targetPath: string): boolean {
|
||||
for (const rootPath of registeredRoots) {
|
||||
if (isWithinRoot(targetPath, rootPath)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function clearFilesystemAccessRootsForTests(): void {
|
||||
registeredRoots.clear()
|
||||
}
|
||||
@ -4,6 +4,7 @@ import * as path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { findCanonicalGitRoot, findGitRoot } from '../../utils/git.js'
|
||||
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
|
||||
import {
|
||||
ensureWorktreesDirExcluded,
|
||||
performPostCreationSetup,
|
||||
@ -326,6 +327,8 @@ export async function getRepositoryContext(workDir: string): Promise<RepositoryC
|
||||
let absWorkDir: string
|
||||
try {
|
||||
absWorkDir = await resolveDirectory(workDir)
|
||||
registerFilesystemAccessRoot(workDir)
|
||||
registerFilesystemAccessRoot(absWorkDir)
|
||||
} catch (error) {
|
||||
return {
|
||||
state: 'missing_workdir',
|
||||
@ -358,6 +361,7 @@ export async function getRepositoryContext(workDir: string): Promise<RepositoryC
|
||||
|
||||
try {
|
||||
const repoRoot = findCanonicalGitRoot(gitRoot) ?? gitRoot
|
||||
registerFilesystemAccessRoot(repoRoot)
|
||||
const [branchResult, defaultBranch, statusResult, worktreeResult] = await Promise.all([
|
||||
runGit(gitRoot, ['branch', '--show-current']),
|
||||
getDefaultBranch(gitRoot),
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
type CreateSessionRepositoryOptions,
|
||||
type PreparedSessionWorkspace,
|
||||
} from './repositoryLaunchService.js'
|
||||
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
|
||||
import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js'
|
||||
|
||||
// ============================================================================
|
||||
@ -1419,6 +1420,7 @@ export class SessionService {
|
||||
sessionId,
|
||||
)
|
||||
const absWorkDir = preparedWorkspace.workDir
|
||||
registerFilesystemAccessRoot(absWorkDir)
|
||||
console.log(
|
||||
`[SessionService] createSession: requested workDir=${JSON.stringify(
|
||||
workDir,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user