fix(file-explorer): open symlink files when stat fails (#11670)

* fix(file-explorer): open symlink files when stat fails

* fix(file-explorer): grant symlink targets path access on activation

Following a symlink out of the workspace was denied by the main-process
path allow-list, so both the stat and the file read failed. Activating the
row is explicit intent, so authorize the target the way terminal links and
Quick Open already do.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Mark Xian
2026-08-10 00:53:54 -07:00
committed by GitHub
co-authored by Brennan Benson
parent c5023fa0ab
commit 80f23e31cb
5 changed files with 154 additions and 7 deletions
@@ -504,6 +504,7 @@ function FileExplorerFiles(): React.JSX.Element {
toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir,
loadDir,
statPath,
authorizeExternalPath: window.api.fs.authorizeExternalPath,
markPathAsDirectory,
setSelectedPath: setSingleSelectedPath,
scrollRef
@@ -32,6 +32,7 @@ function renderHandlers(toggleDir: (worktreeId: string, dirPath: string) => void
toggleDir,
loadDir: vi.fn().mockResolvedValue(true),
statPath: vi.fn().mockResolvedValue({ isDirectory: true }),
authorizeExternalPath: vi.fn(),
markPathAsDirectory: vi.fn(),
setSelectedPath: vi.fn(),
scrollRef: createRef<HTMLDivElement>()
@@ -120,6 +120,7 @@ function HandlersProbe({ scrollRef }: { scrollRef: React.RefObject<HTMLDivElemen
toggleDir: vi.fn(),
loadDir: vi.fn(),
statPath: vi.fn(),
authorizeExternalPath: vi.fn(),
markPathAsDirectory: vi.fn(),
setSelectedPath: vi.fn(),
scrollRef
@@ -37,6 +37,7 @@ describe('activateFileExplorerNode', () => {
canToggleDirectories: false,
loadDir: vi.fn(),
statPath: vi.fn(),
authorizeExternalPath: vi.fn(),
markPathAsDirectory: vi.fn(),
setSelectedPath
})
@@ -58,6 +59,7 @@ describe('activateFileExplorerNode', () => {
toggleDir,
loadDir,
statPath: vi.fn().mockResolvedValue({ isDirectory: true }),
authorizeExternalPath: vi.fn(),
markPathAsDirectory,
setSelectedPath: vi.fn()
})
@@ -95,6 +97,7 @@ describe('activateFileExplorerNode', () => {
toggleDir: vi.fn(),
loadDir: vi.fn(),
statPath: vi.fn().mockResolvedValue({ isDirectory: false }),
authorizeExternalPath: vi.fn(),
markPathAsDirectory: vi.fn(),
setSelectedPath: vi.fn()
})
@@ -112,6 +115,139 @@ describe('activateFileExplorerNode', () => {
)
})
it('opens a symlink as a file when target stat fails', async () => {
const openFile = vi.fn()
useAppStore.setState({
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo',
hostId: 'runtime:runtime-env-1'
} as never
]
}
})
await activateFileExplorerNode({
node: symlinkNode,
activeWorktreeId: 'wt-1',
runtimeEnvironmentId: 'runtime-env-1',
openFile,
toggleDir: vi.fn(),
loadDir: vi.fn(),
statPath: vi.fn().mockRejectedValue(new Error('stat failed')),
authorizeExternalPath: vi.fn(),
markPathAsDirectory: vi.fn(),
setSelectedPath: vi.fn()
})
expect(openFile).toHaveBeenCalledWith(
{
filePath: '/repo/linked-docs',
relativePath: 'linked-docs',
worktreeId: 'wt-1',
runtimeEnvironmentId: 'runtime-env-1',
language: expect.any(String),
mode: 'edit'
},
{ preview: true, focusEditor: true, suppressActiveRuntimeFallback: false }
)
})
it('grants the symlink target local path access before resolving it', async () => {
const order: string[] = []
const authorizeExternalPath = vi.fn(async () => {
order.push('authorize')
})
const statPath = vi.fn(async () => {
order.push('stat')
return { isDirectory: false }
})
const openFile = vi.fn()
useAppStore.setState({
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' } as never]
}
})
await activateFileExplorerNode({
node: { ...symlinkNode, operationOwner: { kind: 'local' } },
activeWorktreeId: 'wt-1',
runtimeEnvironmentId: null,
openFile,
toggleDir: vi.fn(),
loadDir: vi.fn(),
statPath,
authorizeExternalPath,
markPathAsDirectory: vi.fn(),
setSelectedPath: vi.fn()
})
// Why: the grant has to land before the stat, or the allow-list denies the
// target and the row can never open.
expect(order).toEqual(['authorize', 'stat'])
expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: '/repo/linked-docs' })
expect(openFile).toHaveBeenCalledTimes(1)
})
it('still opens the symlink when the path grant itself fails', async () => {
const openFile = vi.fn()
useAppStore.setState({
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' } as never]
}
})
await activateFileExplorerNode({
node: { ...symlinkNode, operationOwner: { kind: 'local' } },
activeWorktreeId: 'wt-1',
runtimeEnvironmentId: null,
openFile,
toggleDir: vi.fn(),
loadDir: vi.fn(),
statPath: vi.fn().mockResolvedValue({ isDirectory: false }),
authorizeExternalPath: vi.fn().mockRejectedValue(new Error('ipc unavailable')),
markPathAsDirectory: vi.fn(),
setSelectedPath: vi.fn()
})
// Why: a rejected grant must degrade to the editor's real error, not a dead click.
expect(openFile).toHaveBeenCalledTimes(1)
})
it('leaves symlink authorization to the host for a remote-owned workspace', async () => {
const authorizeExternalPath = vi.fn()
useAppStore.setState({
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo',
hostId: 'runtime:runtime-env-1'
} as never
]
}
})
await activateFileExplorerNode({
node: symlinkNode,
activeWorktreeId: 'wt-1',
runtimeEnvironmentId: 'runtime-env-1',
openFile: vi.fn(),
toggleDir: vi.fn(),
loadDir: vi.fn(),
statPath: vi.fn().mockResolvedValue({ isDirectory: false }),
authorizeExternalPath,
markPathAsDirectory: vi.fn(),
setSelectedPath: vi.fn()
})
expect(authorizeExternalPath).not.toHaveBeenCalled()
})
it('opens local files without runtime fallback when no runtime owner is set', async () => {
const fileNode: TreeNode = {
name: 'README.md',
@@ -136,6 +272,7 @@ describe('activateFileExplorerNode', () => {
toggleDir: vi.fn(),
loadDir: vi.fn(),
statPath: vi.fn(),
authorizeExternalPath: vi.fn(),
markPathAsDirectory: vi.fn(),
setSelectedPath: vi.fn()
})
@@ -40,6 +40,7 @@ type UseFileExplorerHandlersParams = {
options?: { force?: boolean; failOnError?: boolean }
) => Promise<boolean>
statPath: (path: string) => Promise<{ isDirectory: boolean }>
authorizeExternalPath: (args: { targetPath: string }) => Promise<void>
markPathAsDirectory: (path: string) => void
setSelectedPath: (path: string) => void
scrollRef: RefObject<HTMLDivElement | null>
@@ -64,6 +65,7 @@ export async function activateFileExplorerNode(args: {
canToggleDirectories?: boolean
loadDir: UseFileExplorerHandlersParams['loadDir']
statPath: UseFileExplorerHandlersParams['statPath']
authorizeExternalPath: UseFileExplorerHandlersParams['authorizeExternalPath']
markPathAsDirectory: (path: string) => void
setSelectedPath: (path: string) => void
}): Promise<void> {
@@ -75,6 +77,7 @@ export async function activateFileExplorerNode(args: {
canToggleDirectories = true,
loadDir,
statPath,
authorizeExternalPath,
markPathAsDirectory,
setSelectedPath
} = args
@@ -94,15 +97,16 @@ export async function activateFileExplorerNode(args: {
// them only after the user explicitly activates the row.
let targetIsDirectory = false
try {
// Why: activation is explicit intent to follow the link, so grant its target the
// access a terminal-link click already grants. Remote owners skip it — the
// relay/runtime is their security boundary.
if (node.operationOwner?.kind === 'local') {
await authorizeExternalPath({ targetPath: node.path })
}
targetIsDirectory = (await statPath(node.path)).isDirectory
} catch {
toast.error(
translate(
'auto.components.right.sidebar.useFileExplorerHandlers.32cd9fd991',
'Cannot open symlink target'
)
)
return
// Why: an unresolvable target can't be proven to be a directory; fall through so
// the editor reports the real error instead of the click dead-ending here.
}
if (targetIsDirectory) {
const loadedAsDirectory = await loadDir(node.path, node.depth, {
@@ -163,6 +167,7 @@ export function useFileExplorerHandlers({
canToggleDirectories = true,
loadDir,
statPath,
authorizeExternalPath,
markPathAsDirectory,
setSelectedPath,
scrollRef
@@ -228,6 +233,7 @@ export function useFileExplorerHandlers({
canToggleDirectories,
loadDir,
statPath,
authorizeExternalPath,
markPathAsDirectory,
setSelectedPath
})
@@ -241,6 +247,7 @@ export function useFileExplorerHandlers({
markPathAsDirectory,
openFile,
statPath,
authorizeExternalPath,
toggleDir,
setSelectedPath
]