mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
co-authored by
Brennan Benson
parent
c5023fa0ab
commit
80f23e31cb
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user