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:
程序员阿江(Relakkes) 2026-05-22 19:48:15 +08:00
commit 3f384a1d5f
6 changed files with 110 additions and 1 deletions

View File

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

View File

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

View File

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

View 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()
}

View File

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

View File

@ -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,