diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 9e22f01ac5d..8db2f1d0be1 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -504,6 +504,7 @@ function FileExplorerFiles(): React.JSX.Element { toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir, loadDir, statPath, + authorizeExternalPath: window.api.fs.authorizeExternalPath, markPathAsDirectory, setSelectedPath: setSingleSelectedPath, scrollRef diff --git a/src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts index 5ce6a0bba1b..4a717ed24f5 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-deferred-dir-toggle.test.ts @@ -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() diff --git a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx index 3e490c7a9a5..7516546735d 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx @@ -120,6 +120,7 @@ function HandlersProbe({ scrollRef }: { scrollRef: React.RefObject { 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() }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts index b83d60123c7..e13b4a5329c 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts @@ -40,6 +40,7 @@ type UseFileExplorerHandlersParams = { options?: { force?: boolean; failOnError?: boolean } ) => Promise statPath: (path: string) => Promise<{ isDirectory: boolean }> + authorizeExternalPath: (args: { targetPath: string }) => Promise markPathAsDirectory: (path: string) => void setSelectedPath: (path: string) => void scrollRef: RefObject @@ -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 { @@ -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 ]