From 19fdcf6f5f607f20e2a7cc1a73e0df39a81f2c3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:51:11 -0700 Subject: [PATCH 01/19] fix(editor): read files outside projects without a grant a restart loses A file opened from outside every project (e.g. ~/notes.txt from the floating workspace) read through an in-memory grant. After a restart the restored tab only renewed that grant when it stored a full path, so a tab saved relative to the floating workspace folder failed with "Access denied" and Retry repeated it. Single-file reads (read, stat, exists) and open-editor-tab saves now resolve a path outside every project in place. Paths inside a project keep the full containment check, so a project's symlinks still cannot escape it, and every other write stays inside projects. --- src/main/ipc/filesystem-auth.ts | 44 ++++- .../ipc/filesystem-user-named-path.test.ts | 153 ++++++++++++++++++ .../filesystem/filesystem-read-handlers.ts | 11 +- .../filesystem/filesystem-write-handlers.ts | 19 ++- src/preload/api/fs-bridge.ts | 1 + .../editor-autosave-conflict-flow.test.ts | 9 +- .../editor/editor-autosave-controller.test.ts | 9 +- .../components/editor/editor-save-queue.ts | 4 +- ...stored-editor-owner-save-lifecycle.test.ts | 1 + .../editor/useEditorPanelFileContentLoader.ts | 4 +- .../runtime/mobile-markdown-bridge.test.ts | 3 +- ...ime-file-client-mutation-ownership.test.ts | 13 ++ .../runtime/runtime-file-mutation-client.ts | 13 +- 13 files changed, 263 insertions(+), 21 deletions(-) create mode 100644 src/main/ipc/filesystem-user-named-path.test.ts diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 620baa9b5950..a864a4a8e10f 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -1,4 +1,4 @@ -import { resolve, dirname, basename } from 'node:path' +import { resolve, dirname, basename, isAbsolute } from 'node:path' import { realpathSync } from 'node:fs' import { realpath } from 'node:fs/promises' import type { Store } from '../persistence' @@ -139,6 +139,48 @@ export async function resolveAuthorizedPath( } } +/** + * Resolves a local path the user named directly, such as an editor tab's file. + * + * A path inside a project keeps the full containment check, so a project's symlinks still cannot + * escape it. A path outside every project is the user's own file and resolves in place, with no + * in-memory grant that a restart would lose. + */ +export async function resolveUserNamedPath(targetPath: string, store: Store): Promise { + if (!isAbsolute(targetPath)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + try { + return await resolveAuthorizedPath(targetPath, store) + } catch (error) { + if (!(error instanceof Error && error.message === PATH_ACCESS_DENIED_MESSAGE)) { + throw error + } + const resolvedTarget = resolve(targetPath) + if (await isInsideAllowedRoot(resolvedTarget, store)) { + throw error + } + return resolvedTarget + } +} + +async function isInsideAllowedRoot(resolvedTarget: string, store: Store): Promise { + if (await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store)) { + return true + } + let canonicalParent: string + try { + canonicalParent = resolve(await realpath(dirname(resolvedTarget))) + } catch { + return false + } + // Why: macOS /var→/private/var; a project registered by its canonical path must still count. + return isPathAllowedIncludingRegisteredWorktrees( + resolve(canonicalParent, basename(resolvedTarget)), + store + ) +} + async function resolveAuthorizedMissingPath( resolvedTarget: string, store: Store, diff --git a/src/main/ipc/filesystem-user-named-path.test.ts b/src/main/ipc/filesystem-user-named-path.test.ts new file mode 100644 index 000000000000..5821fca81ee3 --- /dev/null +++ b/src/main/ipc/filesystem-user-named-path.test.ts @@ -0,0 +1,153 @@ +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + handlers, + store, + REPO_PATH, + readFileMock, + writeFileMock, + statMock, + realpathMock, + resetFilesystemIpcMocks +} from './filesystem-test-harness' + +vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) +vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock) +vi.mock( + '../wsl-unc-delete', + async () => (await import('./filesystem-test-harness')).wslUncDeleteMock +) +vi.mock( + '../crash-reporting/crash-breadcrumb-store', + async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock +) +vi.mock( + '../local-downloaded-folder-promotion', + async () => (await import('./filesystem-test-harness')).folderPromotionMock +) +vi.mock( + '../git/status', + async () => (await import('./filesystem-test-harness')).gitStatusModuleMock +) +vi.mock( + '../git/check-ignored-paths', + async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock +) +vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../providers/ssh-filesystem-dispatch', + async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock +) +vi.mock( + '../providers/ssh-git-dispatch', + async () => (await import('./filesystem-test-harness')).sshGitDispatchMock +) +vi.mock( + '../text-generation/commit-message-text-generation', + async () => (await import('./filesystem-test-harness')).textGenerationModuleMock +) +vi.mock( + '../text-generation/pull-request-context', + async () => (await import('./filesystem-test-harness')).pullRequestContextMock +) +vi.mock( + '../source-control/pull-request-template', + async () => (await import('./filesystem-test-harness')).pullRequestTemplateMock +) +vi.mock( + '../source-control/pull-request-linked-issue', + async () => (await import('./filesystem-test-harness')).pullRequestLinkedIssueMock +) + +import { registerFilesystemHandlers } from './filesystem' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' + +const OUTSIDE_FILE = path.resolve('/Users/me/notes.txt') +const PATH_ACCESS_DENIED = 'Access denied: path resolves outside allowed directories' + +describe('user-named paths outside every project', () => { + beforeEach(() => { + resetFilesystemIpcMocks() + invalidateAuthorizedRootsCache() + readFileMock.mockResolvedValue(Buffer.from('hello')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared harness store implements only the repo and settings reads these handlers make. + registerFilesystemHandlers(store as never) + }) + + it('reads a file outside every project without a grant', async () => { + await expect(handlers.get('fs:readFile')!(null, { filePath: OUTSIDE_FILE })).resolves.toEqual({ + content: 'hello', + isBinary: false + }) + expect(readFileMock).toHaveBeenCalledWith(OUTSIDE_FILE) + }) + + it('stats a file outside every project and reports a missing one as absent', async () => { + await expect(handlers.get('fs:stat')!(null, { filePath: OUTSIDE_FILE })).resolves.toEqual({ + size: 10, + isDirectory: false, + mtime: 123 + }) + + statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + await expect(handlers.get('fs:pathExists')!(null, { filePath: OUTSIDE_FILE })).resolves.toBe( + false + ) + }) + + it('rejects a relative path instead of resolving it against the app directory', async () => { + await expect(handlers.get('fs:readFile')!(null, { filePath: 'notes.txt' })).rejects.toThrow( + PATH_ACCESS_DENIED + ) + expect(readFileMock).not.toHaveBeenCalled() + }) + + it('still rejects a project symlink reached through an alias of the project folder', async () => { + const aliasRepoPath = path.resolve('/alias/repo') + const aliasLinkPath = path.join(aliasRepoPath, 'link.txt') + realpathMock.mockImplementation(async (targetPath: string) => { + if (targetPath === aliasRepoPath) { + return REPO_PATH + } + if (targetPath === aliasLinkPath) { + return path.resolve('/private/secret.txt') + } + return targetPath + }) + + await expect(handlers.get('fs:readFile')!(null, { filePath: aliasLinkPath })).rejects.toThrow( + PATH_ACCESS_DENIED + ) + expect(readFileMock).not.toHaveBeenCalled() + }) + + it('saves an open editor file outside every project but keeps other writes inside projects', async () => { + await expect( + handlers.get('fs:writeFile')!(null, { filePath: OUTSIDE_FILE, content: 'other write' }) + ).rejects.toThrow(PATH_ACCESS_DENIED) + expect(writeFileMock).not.toHaveBeenCalled() + + await handlers.get('fs:writeFile')!(null, { + filePath: OUTSIDE_FILE, + content: 'editor save', + savesOpenEditorFile: true + }) + expect(writeFileMock).toHaveBeenCalledWith(OUTSIDE_FILE, 'editor save', 'utf-8') + }) + + it('does not let an editor save write through a project symlink that escapes it', async () => { + const linkPath = path.join(REPO_PATH, 'link.txt') + realpathMock.mockImplementation(async (targetPath: string) => + targetPath === linkPath ? path.resolve('/private/secret.txt') : targetPath + ) + + await expect( + handlers.get('fs:writeFile')!(null, { + filePath: linkPath, + content: 'editor save', + savesOpenEditorFile: true + }) + ).rejects.toThrow(PATH_ACCESS_DENIED) + expect(writeFileMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 1d9fe681fee6..fdfa6acb10cf 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -10,7 +10,7 @@ import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entr import { sortDirEntries } from '../../../shared/file-name-sort' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' -import { resolveAuthorizedPath } from '../filesystem-auth' +import { resolveAuthorizedPath, resolveUserNamedPath } from '../filesystem-auth' import { isENOENT } from '../filesystem-path-containment' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' @@ -83,7 +83,8 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext const provider = requireSshFilesystemProvider(args.connectionId) return provider.readFile(args.filePath) } - const filePath = await resolveAuthorizedPath(args.filePath, store) + // Why: a single-file read names its target directly, so it needs no grant outside projects. + const filePath = await resolveUserNamedPath(args.filePath, store) if (args.includeLocalLogMetadata === true) { return readLocalLogSnapshot(filePath) } @@ -150,7 +151,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext const result = await provider.stat(args.filePath) return { size: result.size, isDirectory: result.type === 'directory', mtime: result.mtime } } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveUserNamedPath(args.filePath, store) const stats = await stat(filePath) return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } } @@ -173,7 +174,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext try { await (provider ? provider.stat(filePath) - : stat(await resolveAuthorizedPath(filePath, store))) + : stat(await resolveUserNamedPath(filePath, store))) return true } catch (error) { if (isENOENT(error)) { @@ -196,7 +197,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext await provider.stat(args.filePath) return true } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveUserNamedPath(args.filePath, store) await stat(filePath) return true } catch (error) { diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 0cc5a921b451..d1f6f1a80d57 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -5,7 +5,11 @@ import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generatio import { runSshProviderContinuation } from '../../ssh/ssh-provider-continuations' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' -import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' +import { + authorizeExternalPath, + resolveAuthorizedPath, + resolveUserNamedPath +} from '../filesystem-auth' import { isENOENT } from '../filesystem-path-containment' import { registerFilesystemMutationHandlers } from '../filesystem-mutations' import type { FilesystemHandlerContext } from './filesystem-handler-context' @@ -17,7 +21,12 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex 'fs:writeFile', async ( _event, - args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation + args: { + filePath: string + content: string + connectionId?: string + savesOpenEditorFile?: boolean + } & SshMutationExpectation ): Promise => { assertSshMutationExpectation( args.connectionId, @@ -31,7 +40,11 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex provider.writeFile(args.filePath, args.content) ) } - const filePath = await resolveAuthorizedPath(args.filePath, store) + // Why: saving an open editor tab writes the file the user opened, so it needs no grant outside + // projects; every other write stays inside them. + const filePath = args.savesOpenEditorFile + ? await resolveUserNamedPath(args.filePath, store) + : await resolveAuthorizedPath(args.filePath, store) try { const fileStats = await lstat(filePath) if (fileStats.isDirectory()) { diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index d58dd17ff8f9..f4576212d98e 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -91,6 +91,7 @@ export const fsApi = { filePath: string content: string connectionId?: string + savesOpenEditorFile?: boolean } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:writeFile', args), createFile: ( diff --git a/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts b/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts index 72f2735f6a2a..310c7aa5f276 100644 --- a/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts @@ -131,7 +131,8 @@ describe('editor autosave changed-on-disk conflict flow', () => { filePath: '/repo/file.ts', content: 'user edit', connectionId: undefined, - expectedExecutionHostId: 'local' + expectedExecutionHostId: 'local', + savesOpenEditorFile: true }) } finally { cleanup() @@ -164,7 +165,8 @@ describe('editor autosave changed-on-disk conflict flow', () => { filePath: '/repo/file.ts', content: 'restored draft', connectionId: undefined, - expectedExecutionHostId: 'local' + expectedExecutionHostId: 'local', + savesOpenEditorFile: true }) } finally { cleanup() @@ -185,7 +187,8 @@ describe('editor autosave changed-on-disk conflict flow', () => { filePath: '/repo/file.ts', content: 'user version', connectionId: undefined, - expectedExecutionHostId: 'local' + expectedExecutionHostId: 'local', + savesOpenEditorFile: true }) const file = store.getState().openFiles[0] expect(file?.isDirty).toBe(false) diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index 78e83706e520..fd4e98e43ec0 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -189,7 +189,8 @@ describe('attachEditorAutosaveController', () => { filePath: '/repo/file.ts', content: 'edited', connectionId: undefined, - expectedExecutionHostId: 'local' + expectedExecutionHostId: 'local', + savesOpenEditorFile: true }) expect(store.getState().openFiles[0]?.isDirty).toBe(false) expect(store.getState().editorDrafts).toEqual({}) @@ -460,7 +461,8 @@ describe('attachEditorAutosaveController', () => { filePath: '/repo/file.md', content: 'pending rich edit', connectionId: undefined, - expectedExecutionHostId: 'local' + expectedExecutionHostId: 'local', + savesOpenEditorFile: true }) expect(store.getState().openFiles[0]?.isDirty).toBe(false) expect(store.getState().editorDrafts).toEqual({}) @@ -744,7 +746,8 @@ describe('attachEditorAutosaveController', () => { filePath: '/repo/file.md', content: 'after save', connectionId: undefined, - expectedExecutionHostId: 'local' + expectedExecutionHostId: 'local', + savesOpenEditorFile: true }) expect(store.getState().openFiles[0]?.isDirty).toBe(false) } finally { diff --git a/src/renderer/src/components/editor/editor-save-queue.ts b/src/renderer/src/components/editor/editor-save-queue.ts index b5a0281470d3..69d751326ed0 100644 --- a/src/renderer/src/components/editor/editor-save-queue.ts +++ b/src/renderer/src/components/editor/editor-save-queue.ts @@ -110,7 +110,9 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue { : undefined ) try { - await writeRuntimeFile(fileContext, liveFile.filePath, contentToSave) + await writeRuntimeFile(fileContext, liveFile.filePath, contentToSave, { + savesOpenEditorFile: true + }) } catch (error) { // Why: the self-write stamp is only valid after a real write; clear on failure so it can't suppress a real update. clearSelfWrite(liveFile.filePath, liveFile.runtimeEnvironmentId) diff --git a/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts b/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts index 1d5ccf262b3f..43aada0c5966 100644 --- a/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts +++ b/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts @@ -120,6 +120,7 @@ describe('restored editor owner save lifecycle', () => { await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(3)) expect(mocks.writeRuntimeFile.mock.calls[2]?.[0]).toMatchObject({ worktreeId: TARGET }) expect(mocks.writeRuntimeFile.mock.calls[2]?.[2]).toBe('autosave destination') + expect(mocks.writeRuntimeFile.mock.calls[2]?.[3]).toEqual({ savesOpenEditorFile: true }) }) it('rejects a concurrent migration without clearing the active migration gate', async () => { diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index e7fdfc88754e..95b93bb50a8d 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -155,8 +155,8 @@ export function useEditorPanelFileContentLoader({ throw new Error('External local files are not available for remote workspaces.') } if (!externalSshOwnerId) { - // Why: client-local external tabs need their main-process path grant - // refreshed because that authorization is only held in memory. + // Why: plain reads and saves no longer need it, but other file features + // (e.g. running a notebook) still check this in-memory grant. await window.api.fs.authorizeExternalPath({ targetPath: filePath }) // Why: that grant covers the client path, so this read must stay off the // worktree's SSH host. diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts index e039cc515ebb..c98d728ced3b 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts @@ -117,7 +117,8 @@ describe('mobile markdown bridge', () => { filePath: '/repo/README.md', content: 'mobile edit', connectionId: undefined, - expectedExecutionHostId: 'local' + expectedExecutionHostId: 'local', + savesOpenEditorFile: true }) expect(response).toMatchObject({ id: 'save-2', diff --git a/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts b/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts index 688e2e574171..687113708752 100644 --- a/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts @@ -110,6 +110,19 @@ describe('runtime file client', () => { }) }) + it('marks a local write as an open editor file save only when the caller asks', async () => { + const context = { settings: null, worktreeId: 'wt-1', worktreePath: '/repo' } + + await writeRuntimeFile(context, '/Users/me/notes.txt', 'saved', { savesOpenEditorFile: true }) + await writeRuntimeFile(context, '/repo/src/index.ts', 'written') + + expect(fsWriteFile).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ filePath: '/Users/me/notes.txt', savesOpenEditorFile: true }) + ) + expect(fsWriteFile.mock.calls[1]?.[0]).not.toHaveProperty('savesOpenEditorFile') + }) + it('refuses HUB-local mutations before RPC when the HUB lacks ownership support', async () => { runtimeEnvironmentTransportCall.mockImplementation((args: { method: string }) => { if (args.method === 'status.get') { diff --git a/src/renderer/src/runtime/runtime-file-mutation-client.ts b/src/renderer/src/runtime/runtime-file-mutation-client.ts index b2a358a5200f..fe1df8994246 100644 --- a/src/renderer/src/runtime/runtime-file-mutation-client.ts +++ b/src/renderer/src/runtime/runtime-file-mutation-client.ts @@ -32,13 +32,22 @@ export async function readRuntimeDirectory( export async function writeRuntimeFile( context: RuntimeFileOperationArgs, filePath: string, - content: string + content: string, + options?: { savesOpenEditorFile?: boolean } ): Promise { const remoteArgs = getRemoteFileArgs(context, filePath) if (!remoteArgs) { assertLocalFilesystemFallbackAllowed(context) await window.api.fs.writeFile( - withSshMutationExpectation(context, { filePath, content, connectionId: context.connectionId }) + withSshMutationExpectation(context, { + filePath, + content, + connectionId: context.connectionId, + // Why: only a local write consults the flag; an SSH host owns its own file access. + ...(options?.savesOpenEditorFile && !context.connectionId + ? { savesOpenEditorFile: true } + : {}) + }) ) return } From 9e47d99a38d2c51d807be6de55acecb821e8c065 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:50:40 -0700 Subject: [PATCH 02/19] fix(editor): re-grant restored floating-workspace tabs by owner, not path shape Problem: a file opened from the floating workspace (e.g. ~/notes.txt via Cmd-click in the floating terminal, the floating markdown picker, or a .md opened from the OS) loads until restart, then shows "Access denied: path resolves outside allowed directories". Main's external-path grants live only in memory. On restore the editor re-granted only tabs that stored an absolute path, but floating tabs store a path relative to the floating root (~ by default), which is deliberately not an authorized root, so they were never re-granted. Restored floating notebooks also failed to start a kernel. The previous commit on this branch let main read and save any path outside a project without a grant. That widened fs:readFile/stat/pathExists for every caller, including automatic reads of untrusted content (markdown preview images), which opened a Windows UNC credential leak and a /dev/zero main-process memory blowup. This reverts that model entirely. Fix: one helper decides which client-local path a tab needs re-granted by ownership: a floating-workspace tab, or a tab stored outside its own project. It never grants paths a local project root covers (a grant would also authorize a project symlink's outside target), and skips SSH-owned, runtime-owned and not-yet-hydrated owners. Every reader that can touch a restored tab before or without the editor loader uses it: the loader, the restored dirty-tab conflict scan, and the paired-mobile markdown bridge. --- src/main/ipc/filesystem-auth.ts | 44 +---- .../ipc/filesystem-user-named-path.test.ts | 153 ---------------- .../filesystem/filesystem-read-handlers.ts | 11 +- .../filesystem/filesystem-write-handlers.ts | 19 +- src/preload/api/fs-bridge.ts | 1 - .../editor-autosave-conflict-flow.test.ts | 9 +- .../editor/editor-autosave-controller.test.ts | 9 +- .../editor-restored-tab-conflict-scan.test.ts | 67 ++++++- .../editor-restored-tab-conflict-scan.ts | 6 + .../components/editor/editor-save-queue.ts | 4 +- ...stored-editor-owner-save-lifecycle.test.ts | 1 - .../editor/useEditorPanelFileContentLoader.ts | 14 +- ...ditorPanelFloatingTabContentState.test.tsx | 158 ++++++++++++++++ .../editor-tab-external-path-grant.test.ts | 169 ++++++++++++++++++ .../src/lib/editor-tab-external-path-grant.ts | 58 ++++++ .../mobile-markdown-bridge-test-harness.ts | 7 +- .../runtime/mobile-markdown-bridge.test.ts | 36 +++- .../src/runtime/mobile-markdown-bridge.ts | 6 + ...ime-file-client-mutation-ownership.test.ts | 13 -- .../runtime/runtime-file-mutation-client.ts | 13 +- 20 files changed, 529 insertions(+), 269 deletions(-) delete mode 100644 src/main/ipc/filesystem-user-named-path.test.ts create mode 100644 src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx create mode 100644 src/renderer/src/lib/editor-tab-external-path-grant.test.ts create mode 100644 src/renderer/src/lib/editor-tab-external-path-grant.ts diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index a864a4a8e10f..620baa9b5950 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -1,4 +1,4 @@ -import { resolve, dirname, basename, isAbsolute } from 'node:path' +import { resolve, dirname, basename } from 'node:path' import { realpathSync } from 'node:fs' import { realpath } from 'node:fs/promises' import type { Store } from '../persistence' @@ -139,48 +139,6 @@ export async function resolveAuthorizedPath( } } -/** - * Resolves a local path the user named directly, such as an editor tab's file. - * - * A path inside a project keeps the full containment check, so a project's symlinks still cannot - * escape it. A path outside every project is the user's own file and resolves in place, with no - * in-memory grant that a restart would lose. - */ -export async function resolveUserNamedPath(targetPath: string, store: Store): Promise { - if (!isAbsolute(targetPath)) { - throw new Error(PATH_ACCESS_DENIED_MESSAGE) - } - try { - return await resolveAuthorizedPath(targetPath, store) - } catch (error) { - if (!(error instanceof Error && error.message === PATH_ACCESS_DENIED_MESSAGE)) { - throw error - } - const resolvedTarget = resolve(targetPath) - if (await isInsideAllowedRoot(resolvedTarget, store)) { - throw error - } - return resolvedTarget - } -} - -async function isInsideAllowedRoot(resolvedTarget: string, store: Store): Promise { - if (await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store)) { - return true - } - let canonicalParent: string - try { - canonicalParent = resolve(await realpath(dirname(resolvedTarget))) - } catch { - return false - } - // Why: macOS /var→/private/var; a project registered by its canonical path must still count. - return isPathAllowedIncludingRegisteredWorktrees( - resolve(canonicalParent, basename(resolvedTarget)), - store - ) -} - async function resolveAuthorizedMissingPath( resolvedTarget: string, store: Store, diff --git a/src/main/ipc/filesystem-user-named-path.test.ts b/src/main/ipc/filesystem-user-named-path.test.ts deleted file mode 100644 index 5821fca81ee3..000000000000 --- a/src/main/ipc/filesystem-user-named-path.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import path from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - handlers, - store, - REPO_PATH, - readFileMock, - writeFileMock, - statMock, - realpathMock, - resetFilesystemIpcMocks -} from './filesystem-test-harness' - -vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) -vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock) -vi.mock( - '../wsl-unc-delete', - async () => (await import('./filesystem-test-harness')).wslUncDeleteMock -) -vi.mock( - '../crash-reporting/crash-breadcrumb-store', - async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock -) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) -vi.mock( - '../git/status', - async () => (await import('./filesystem-test-harness')).gitStatusModuleMock -) -vi.mock( - '../git/check-ignored-paths', - async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock -) -vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock -) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) -vi.mock( - '../text-generation/commit-message-text-generation', - async () => (await import('./filesystem-test-harness')).textGenerationModuleMock -) -vi.mock( - '../text-generation/pull-request-context', - async () => (await import('./filesystem-test-harness')).pullRequestContextMock -) -vi.mock( - '../source-control/pull-request-template', - async () => (await import('./filesystem-test-harness')).pullRequestTemplateMock -) -vi.mock( - '../source-control/pull-request-linked-issue', - async () => (await import('./filesystem-test-harness')).pullRequestLinkedIssueMock -) - -import { registerFilesystemHandlers } from './filesystem' -import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' - -const OUTSIDE_FILE = path.resolve('/Users/me/notes.txt') -const PATH_ACCESS_DENIED = 'Access denied: path resolves outside allowed directories' - -describe('user-named paths outside every project', () => { - beforeEach(() => { - resetFilesystemIpcMocks() - invalidateAuthorizedRootsCache() - readFileMock.mockResolvedValue(Buffer.from('hello')) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared harness store implements only the repo and settings reads these handlers make. - registerFilesystemHandlers(store as never) - }) - - it('reads a file outside every project without a grant', async () => { - await expect(handlers.get('fs:readFile')!(null, { filePath: OUTSIDE_FILE })).resolves.toEqual({ - content: 'hello', - isBinary: false - }) - expect(readFileMock).toHaveBeenCalledWith(OUTSIDE_FILE) - }) - - it('stats a file outside every project and reports a missing one as absent', async () => { - await expect(handlers.get('fs:stat')!(null, { filePath: OUTSIDE_FILE })).resolves.toEqual({ - size: 10, - isDirectory: false, - mtime: 123 - }) - - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - await expect(handlers.get('fs:pathExists')!(null, { filePath: OUTSIDE_FILE })).resolves.toBe( - false - ) - }) - - it('rejects a relative path instead of resolving it against the app directory', async () => { - await expect(handlers.get('fs:readFile')!(null, { filePath: 'notes.txt' })).rejects.toThrow( - PATH_ACCESS_DENIED - ) - expect(readFileMock).not.toHaveBeenCalled() - }) - - it('still rejects a project symlink reached through an alias of the project folder', async () => { - const aliasRepoPath = path.resolve('/alias/repo') - const aliasLinkPath = path.join(aliasRepoPath, 'link.txt') - realpathMock.mockImplementation(async (targetPath: string) => { - if (targetPath === aliasRepoPath) { - return REPO_PATH - } - if (targetPath === aliasLinkPath) { - return path.resolve('/private/secret.txt') - } - return targetPath - }) - - await expect(handlers.get('fs:readFile')!(null, { filePath: aliasLinkPath })).rejects.toThrow( - PATH_ACCESS_DENIED - ) - expect(readFileMock).not.toHaveBeenCalled() - }) - - it('saves an open editor file outside every project but keeps other writes inside projects', async () => { - await expect( - handlers.get('fs:writeFile')!(null, { filePath: OUTSIDE_FILE, content: 'other write' }) - ).rejects.toThrow(PATH_ACCESS_DENIED) - expect(writeFileMock).not.toHaveBeenCalled() - - await handlers.get('fs:writeFile')!(null, { - filePath: OUTSIDE_FILE, - content: 'editor save', - savesOpenEditorFile: true - }) - expect(writeFileMock).toHaveBeenCalledWith(OUTSIDE_FILE, 'editor save', 'utf-8') - }) - - it('does not let an editor save write through a project symlink that escapes it', async () => { - const linkPath = path.join(REPO_PATH, 'link.txt') - realpathMock.mockImplementation(async (targetPath: string) => - targetPath === linkPath ? path.resolve('/private/secret.txt') : targetPath - ) - - await expect( - handlers.get('fs:writeFile')!(null, { - filePath: linkPath, - content: 'editor save', - savesOpenEditorFile: true - }) - ).rejects.toThrow(PATH_ACCESS_DENIED) - expect(writeFileMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index fdfa6acb10cf..1d9fe681fee6 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -10,7 +10,7 @@ import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entr import { sortDirEntries } from '../../../shared/file-name-sort' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' -import { resolveAuthorizedPath, resolveUserNamedPath } from '../filesystem-auth' +import { resolveAuthorizedPath } from '../filesystem-auth' import { isENOENT } from '../filesystem-path-containment' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' @@ -83,8 +83,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext const provider = requireSshFilesystemProvider(args.connectionId) return provider.readFile(args.filePath) } - // Why: a single-file read names its target directly, so it needs no grant outside projects. - const filePath = await resolveUserNamedPath(args.filePath, store) + const filePath = await resolveAuthorizedPath(args.filePath, store) if (args.includeLocalLogMetadata === true) { return readLocalLogSnapshot(filePath) } @@ -151,7 +150,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext const result = await provider.stat(args.filePath) return { size: result.size, isDirectory: result.type === 'directory', mtime: result.mtime } } - const filePath = await resolveUserNamedPath(args.filePath, store) + const filePath = await resolveAuthorizedPath(args.filePath, store) const stats = await stat(filePath) return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } } @@ -174,7 +173,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext try { await (provider ? provider.stat(filePath) - : stat(await resolveUserNamedPath(filePath, store))) + : stat(await resolveAuthorizedPath(filePath, store))) return true } catch (error) { if (isENOENT(error)) { @@ -197,7 +196,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext await provider.stat(args.filePath) return true } - const filePath = await resolveUserNamedPath(args.filePath, store) + const filePath = await resolveAuthorizedPath(args.filePath, store) await stat(filePath) return true } catch (error) { diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index d1f6f1a80d57..0cc5a921b451 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -5,11 +5,7 @@ import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generatio import { runSshProviderContinuation } from '../../ssh/ssh-provider-continuations' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' -import { - authorizeExternalPath, - resolveAuthorizedPath, - resolveUserNamedPath -} from '../filesystem-auth' +import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' import { isENOENT } from '../filesystem-path-containment' import { registerFilesystemMutationHandlers } from '../filesystem-mutations' import type { FilesystemHandlerContext } from './filesystem-handler-context' @@ -21,12 +17,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex 'fs:writeFile', async ( _event, - args: { - filePath: string - content: string - connectionId?: string - savesOpenEditorFile?: boolean - } & SshMutationExpectation + args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation ): Promise => { assertSshMutationExpectation( args.connectionId, @@ -40,11 +31,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex provider.writeFile(args.filePath, args.content) ) } - // Why: saving an open editor tab writes the file the user opened, so it needs no grant outside - // projects; every other write stays inside them. - const filePath = args.savesOpenEditorFile - ? await resolveUserNamedPath(args.filePath, store) - : await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveAuthorizedPath(args.filePath, store) try { const fileStats = await lstat(filePath) if (fileStats.isDirectory()) { diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index f4576212d98e..d58dd17ff8f9 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -91,7 +91,6 @@ export const fsApi = { filePath: string content: string connectionId?: string - savesOpenEditorFile?: boolean } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:writeFile', args), createFile: ( diff --git a/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts b/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts index 310c7aa5f276..72f2735f6a2a 100644 --- a/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-conflict-flow.test.ts @@ -131,8 +131,7 @@ describe('editor autosave changed-on-disk conflict flow', () => { filePath: '/repo/file.ts', content: 'user edit', connectionId: undefined, - expectedExecutionHostId: 'local', - savesOpenEditorFile: true + expectedExecutionHostId: 'local' }) } finally { cleanup() @@ -165,8 +164,7 @@ describe('editor autosave changed-on-disk conflict flow', () => { filePath: '/repo/file.ts', content: 'restored draft', connectionId: undefined, - expectedExecutionHostId: 'local', - savesOpenEditorFile: true + expectedExecutionHostId: 'local' }) } finally { cleanup() @@ -187,8 +185,7 @@ describe('editor autosave changed-on-disk conflict flow', () => { filePath: '/repo/file.ts', content: 'user version', connectionId: undefined, - expectedExecutionHostId: 'local', - savesOpenEditorFile: true + expectedExecutionHostId: 'local' }) const file = store.getState().openFiles[0] expect(file?.isDirty).toBe(false) diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index fd4e98e43ec0..78e83706e520 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -189,8 +189,7 @@ describe('attachEditorAutosaveController', () => { filePath: '/repo/file.ts', content: 'edited', connectionId: undefined, - expectedExecutionHostId: 'local', - savesOpenEditorFile: true + expectedExecutionHostId: 'local' }) expect(store.getState().openFiles[0]?.isDirty).toBe(false) expect(store.getState().editorDrafts).toEqual({}) @@ -461,8 +460,7 @@ describe('attachEditorAutosaveController', () => { filePath: '/repo/file.md', content: 'pending rich edit', connectionId: undefined, - expectedExecutionHostId: 'local', - savesOpenEditorFile: true + expectedExecutionHostId: 'local' }) expect(store.getState().openFiles[0]?.isDirty).toBe(false) expect(store.getState().editorDrafts).toEqual({}) @@ -746,8 +744,7 @@ describe('attachEditorAutosaveController', () => { filePath: '/repo/file.md', content: 'after save', connectionId: undefined, - expectedExecutionHostId: 'local', - savesOpenEditorFile: true + expectedExecutionHostId: 'local' }) expect(store.getState().openFiles[0]?.isDirty).toBe(false) } finally { diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 8e25daabad70..45a833b1f2ec 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createStore, type StoreApi } from 'zustand/vanilla' import { createEditorSlice } from '@/store/slices/editor' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { AppState } from '@/store' import { attachRestoredTabConflictScan } from './editor-restored-tab-conflict-scan' import { getDiskBaselineSignature } from './diff-content-signature' @@ -8,7 +9,8 @@ import { getDiskBaselineSignature } from './diff-content-signature' const mocks = vi.hoisted(() => ({ readRuntimeFileContent: vi.fn(), getConnectionIdForFile: vi.fn(), - pathExists: vi.fn() + pathExists: vi.fn(), + authorizeExternalPath: vi.fn() })) vi.mock('@/runtime/runtime-file-client', () => ({ @@ -18,13 +20,16 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ settingsForRuntimeOwner: () => null })) vi.mock('@/lib/connection-context', () => ({ - getConnectionIdForFile: mocks.getConnectionIdForFile + getConnectionIdForFile: mocks.getConnectionIdForFile, + isWorktreeConnectionResolved: () => true })) function createEditorStore(): StoreApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any return createStore()((...args: any[]) => ({ settings: {}, + worktreesByRepo: {}, + folderWorkspaces: [], ...createEditorSlice(...(args as Parameters)) })) as unknown as StoreApi } @@ -62,7 +67,13 @@ describe('attachRestoredTabConflictScan', () => { mocks.getConnectionIdForFile.mockReturnValue(undefined) mocks.pathExists.mockReset() mocks.pathExists.mockResolvedValue(true) - vi.stubGlobal('window', { api: { fs: { pathExists: mocks.pathExists } } }) + mocks.authorizeExternalPath.mockReset() + mocks.authorizeExternalPath.mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + fs: { pathExists: mocks.pathExists, authorizeExternalPath: mocks.authorizeExternalPath } + } + }) }) afterEach(() => { @@ -250,6 +261,56 @@ describe('attachRestoredTabConflictScan', () => { } }) + it('grants a restored dirty floating-workspace tab before verifying it', async () => { + // Why: without the grant the read is denied forever and the tab's autosave never resumes. + mocks.readRuntimeFileContent.mockResolvedValue({ + content: 'original baseline', + isBinary: false + }) + mocks.getConnectionIdForFile.mockReturnValue(null) + const store = createEditorStore() + openRestoredDirtyTab(store, '/Users/me/notes.txt', 'original baseline') + store.setState({ + openFiles: store.getState().openFiles.map((file) => ({ + ...file, + relativePath: 'notes.txt', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + })) + } as never) + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + expect(mocks.authorizeExternalPath).toHaveBeenCalledWith({ + targetPath: '/Users/me/notes.txt' + }) + expect(mocks.authorizeExternalPath.mock.invocationCallOrder[0]).toBeLessThan( + mocks.readRuntimeFileContent.mock.invocationCallOrder[0] + ) + expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBeUndefined() + } finally { + detach() + } + }) + + it('does not grant a restored dirty project tab', async () => { + mocks.readRuntimeFileContent.mockResolvedValue({ + content: 'original baseline', + isBinary: false + }) + const store = createEditorStore() + openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1) + expect(mocks.authorizeExternalPath).not.toHaveBeenCalled() + } finally { + detach() + } + }) + it('does not verify an external SSH file through a replacement target', async () => { const store = createEditorStore() openRestoredDirtyTab(store, '/tmp/external.ts', 'original baseline') diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index 92fde37b51e5..13fdb8a41efd 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -3,6 +3,7 @@ import type { StoreApi } from 'zustand' import type { AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { getConnectionIdForFile } from '@/lib/connection-context' +import { refreshEditorTabExternalPathGrant } from '@/lib/editor-tab-external-path-grant' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { canAutoSaveOpenFile } from './editor-autosave' @@ -60,6 +61,11 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { let retryScheduled = false try { const state = store.getState() + // Why: this runs at startup for unopened tabs too, before the loader re-grants their path. + const pathGrant = refreshEditorTabExternalPathGrant(state, file) + if (pathGrant) { + await pathGrant + } const result = await readRuntimeFileContent({ settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), filePath: file.filePath, diff --git a/src/renderer/src/components/editor/editor-save-queue.ts b/src/renderer/src/components/editor/editor-save-queue.ts index 69d751326ed0..b5a0281470d3 100644 --- a/src/renderer/src/components/editor/editor-save-queue.ts +++ b/src/renderer/src/components/editor/editor-save-queue.ts @@ -110,9 +110,7 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue { : undefined ) try { - await writeRuntimeFile(fileContext, liveFile.filePath, contentToSave, { - savesOpenEditorFile: true - }) + await writeRuntimeFile(fileContext, liveFile.filePath, contentToSave) } catch (error) { // Why: the self-write stamp is only valid after a real write; clear on failure so it can't suppress a real update. clearSelfWrite(liveFile.filePath, liveFile.runtimeEnvironmentId) diff --git a/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts b/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts index 43aada0c5966..1d5ccf262b3f 100644 --- a/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts +++ b/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts @@ -120,7 +120,6 @@ describe('restored editor owner save lifecycle', () => { await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(3)) expect(mocks.writeRuntimeFile.mock.calls[2]?.[0]).toMatchObject({ worktreeId: TARGET }) expect(mocks.writeRuntimeFile.mock.calls[2]?.[2]).toBe('autosave destination') - expect(mocks.writeRuntimeFile.mock.calls[2]?.[3]).toEqual({ savesOpenEditorFile: true }) }) it('rejects a concurrent migration without clearing the active migration gate', async () => { diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index 95b93bb50a8d..998e5e3ca0d8 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -19,6 +19,7 @@ import { } from './editor-panel-content-types' import type { EditorPanelContentLoadOptions } from './useEditorPanelExternalContentEvents' import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner' +import { refreshEditorTabExternalPathGrant } from '@/lib/editor-tab-external-path-grant' const inFlightFileReads = new Map>() @@ -155,14 +156,21 @@ export function useEditorPanelFileContentLoader({ throw new Error('External local files are not available for remote workspaces.') } if (!externalSshOwnerId) { - // Why: plain reads and saves no longer need it, but other file features - // (e.g. running a notebook) still check this in-memory grant. - await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + await refreshEditorTabExternalPathGrant(currentState, restoredOpenFile) // Why: that grant covers the client path, so this read must stay off the // worktree's SSH host. readConnectionId = undefined } } + } else if (restoredOpenFile?.filePath === filePath) { + // Why: a floating-workspace tab stores a root-relative path, but its root isn't authorized. + const pathGrant = refreshEditorTabExternalPathGrant( + useAppStore.getState(), + restoredOpenFile + ) + if (pathGrant) { + await pathGrant + } } const readScope = getRuntimeFileReadScope(readSettings, readConnectionId) const key = inFlightReadKey(readScope, filePath) diff --git a/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx new file mode 100644 index 000000000000..e296c86b9dca --- /dev/null +++ b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { FileContent } from './editor-panel-content-types' + +const mocks = vi.hoisted(() => ({ + readRuntimeFileContent: vi.fn(), + findWorkspaceFileRoute: vi.fn(), + getState: vi.fn() +})) + +vi.mock('@/runtime/runtime-file-client', () => ({ + getRuntimeFileReadScope: vi.fn( + ( + settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined, + connectionId?: string + ) => connectionId ?? settings?.activeRuntimeEnvironmentId ?? null + ), + readRuntimeFileContent: mocks.readRuntimeFileContent, + subscribeRuntimeFileChanges: vi.fn() +})) + +vi.mock('@/runtime/runtime-git-client', () => ({ + getRuntimeGitBranchDiff: vi.fn(), + getRuntimeGitCommitDiff: vi.fn(), + getRuntimeGitDiff: vi.fn(), + getRuntimeGitScope: vi.fn(() => null) +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(() => null), + getConnectionIdForFile: vi.fn(() => null), + isWorktreeConnectionResolved: vi.fn(() => true) +})) + +vi.mock('@/lib/worktree-host-connection-phase', () => import('./local-host-test-fixture')) + +vi.mock('@/lib/runtime-workspace-file-route', () => ({ + findWorkspaceFileRoute: mocks.findWorkspaceFileRoute +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) + +vi.mock('./useEditorPanelExternalContentEvents', () => ({ + useEditorPanelExternalContentEvents: vi.fn(), + usePruneClosedEditorContent: vi.fn() +})) + +vi.mock('./useEditorPanelFileLoadRetry', () => ({ useEditorPanelFileLoadRetry: vi.fn() })) +vi.mock('./useLocalLogTail', () => ({ useLocalLogTail: vi.fn() })) + +import { useEditorPanelContentState } from './useEditorPanelContentState' + +const authorizeExternalPath = vi.fn() +let latestFileContents: Record = {} + +function createFloatingFile(filePath: string, overrides: Partial = {}): OpenFile { + return { + id: filePath, + filePath, + // Why: floating tabs store a path relative to the floating root (~ by default). + relativePath: filePath.slice('/Users/me/'.length), + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: 'markdown', + isDirty: false, + mode: 'edit', + ...overrides + } +} + +function HookProbe({ activeFile }: { activeFile: OpenFile }): null { + latestFileContents = useEditorPanelContentState({ + activeFile, + isChangesMode: false, + openFiles: [activeFile], + gitStatusEntries: undefined, + editorViewMode: {} + }).fileContents + return null +} + +describe('restored floating-workspace editor tabs', () => { + let container: HTMLDivElement | null = null + let root: Root | null = null + + beforeEach(() => { + latestFileContents = {} + authorizeExternalPath.mockReset() + authorizeExternalPath.mockResolvedValue(undefined) + ;(window as unknown as { api: unknown }).api = { fs: { authorizeExternalPath } } + mocks.readRuntimeFileContent.mockReset() + mocks.readRuntimeFileContent.mockResolvedValue({ content: '# local', isBinary: false }) + mocks.findWorkspaceFileRoute.mockReset() + mocks.findWorkspaceFileRoute.mockReturnValue(null) + mocks.getState.mockReset() + mocks.getState.mockReturnValue({ + settings: null, + openFiles: [], + setLastKnownDiskSignature: vi.fn() + }) + container = document.body.appendChild(document.createElement('div')) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + }) + + // The notebook kernel and environment handlers authorize the same filePath. + it.each(['/Users/me/notes.txt', '/Users/me/analysis.ipynb'])( + 're-grants %s before reading it after a restart', + async (filePath) => { + const activeFile = createFloatingFile(filePath) + + await act(async () => root?.render()) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) + expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: filePath }) + expect(authorizeExternalPath.mock.invocationCallOrder[0]).toBeLessThan( + mocks.readRuntimeFileContent.mock.invocationCallOrder[0] + ) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ filePath, connectionId: undefined }) + ) + } + ) + + it('does not grant a floating tab whose file lies inside a project', async () => { + const activeFile = createFloatingFile('/Users/me/project/src/link.md') + mocks.findWorkspaceFileRoute.mockReturnValue({ + worktreeId: 'repo::/Users/me/project', + relativePath: 'src/link.md', + executionHostId: 'local' + }) + + await act(async () => root?.render()) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) + expect(authorizeExternalPath).not.toHaveBeenCalled() + }) + + it('does not grant a project tab that its authorized root already covers', async () => { + const activeFile = createFloatingFile('/Users/me/project/README.md', { + relativePath: 'README.md', + worktreeId: 'repo::/Users/me/project' + }) + + await act(async () => root?.render()) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) + expect(authorizeExternalPath).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/editor-tab-external-path-grant.test.ts b/src/renderer/src/lib/editor-tab-external-path-grant.test.ts new file mode 100644 index 000000000000..8d3fc8b7e73d --- /dev/null +++ b/src/renderer/src/lib/editor-tab-external-path-grant.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import type { Worktree } from '../../../shared/worktree/types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { useAppStore } from '@/store' +import { + getEditorTabExternalPathGrantTarget, + refreshEditorTabExternalPathGrant +} from './editor-tab-external-path-grant' + +const initialState = useAppStore.getInitialState() + +function makeRepo(overrides: Partial & { id: string; path: string }): Repo { + return { displayName: 'repo', badgeColor: '#000', addedAt: 0, ...overrides } +} + +function makeWorktree(overrides: Partial & { id: string; repoId: string }): Worktree { + return { + path: '/Users/me/project', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'project', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +const localWorktreeId = 'repo-local::/Users/me/project' +const sshWorktreeId = 'repo-ssh::/work/project' + +function grantTarget(file: { + filePath: string + relativePath: string + worktreeId: string + runtimeEnvironmentId?: string | null + externalSshTargetId?: string +}): string | null { + return getEditorTabExternalPathGrantTarget(useAppStore.getState(), file) +} + +describe('getEditorTabExternalPathGrantTarget', () => { + beforeEach(() => { + useAppStore.setState({ + repos: [ + makeRepo({ id: 'repo-local', path: '/Users/me/project' }), + makeRepo({ id: 'repo-ssh', path: '/work/project', connectionId: 'ssh-1' }) + ], + worktreesByRepo: { + 'repo-local': [makeWorktree({ id: localWorktreeId, repoId: 'repo-local' })] + } + }) + }) + + afterEach(() => { + useAppStore.setState(initialState, true) + vi.unstubAllGlobals() + }) + + it('grants a restored floating-workspace tab that stores a root-relative path', () => { + expect( + grantTarget({ + filePath: '/Users/me/notes.txt', + relativePath: 'notes.txt', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }) + ).toBe('/Users/me/notes.txt') + }) + + it('grants a client-local tab stored outside its own project', () => { + expect( + grantTarget({ + filePath: '/Users/me/notes/audit.md', + relativePath: '/Users/me/notes/audit.md', + worktreeId: localWorktreeId + }) + ).toBe('/Users/me/notes/audit.md') + }) + + it('does not grant a project tab, which its authorized root already covers', () => { + expect( + grantTarget({ + filePath: '/Users/me/project/src/link.ts', + relativePath: 'src/link.ts', + worktreeId: localWorktreeId + }) + ).toBeNull() + }) + + it('does not grant a floating tab whose file lies inside a project', () => { + // A grant would also authorize the outside target of a project symlink. + expect( + grantTarget({ + filePath: '/Users/me/project/src/link.ts', + relativePath: 'project/src/link.ts', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }) + ).toBeNull() + }) + + it('keeps SSH-owned external tabs off the local grant', () => { + expect( + grantTarget({ + filePath: '/work/reports/audit.md', + relativePath: '/work/reports/audit.md', + worktreeId: sshWorktreeId + }) + ).toBeNull() + expect( + grantTarget({ + filePath: '/work/reports/audit.md', + relativePath: '/work/reports/audit.md', + worktreeId: localWorktreeId, + externalSshTargetId: 'ssh-1' + }) + ).toBeNull() + }) + + it('does not grant an external tab whose owner has not hydrated yet', () => { + expect( + grantTarget({ + filePath: '/work/reports/audit.md', + relativePath: '/work/reports/audit.md', + worktreeId: 'repo-missing::/work/other' + }) + ).toBeNull() + }) + + it('does not grant a runtime-owned tab, which the runtime reads remotely', () => { + expect( + grantTarget({ + filePath: '/Users/me/notes.txt', + relativePath: 'notes.txt', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + runtimeEnvironmentId: 'runtime-1' + }) + ).toBeNull() + }) + + it('skips the grant call entirely when none is needed', async () => { + const authorizeExternalPath = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { fs: { authorizeExternalPath } } }) + const state = useAppStore.getState() + + expect( + refreshEditorTabExternalPathGrant(state, { + filePath: '/Users/me/project/a.ts', + relativePath: 'a.ts', + worktreeId: localWorktreeId + }) + ).toBeNull() + await refreshEditorTabExternalPathGrant(state, { + filePath: '/Users/me/notes.txt', + relativePath: 'notes.txt', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }) + expect(authorizeExternalPath).toHaveBeenCalledTimes(1) + expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: '/Users/me/notes.txt' }) + }) +}) diff --git a/src/renderer/src/lib/editor-tab-external-path-grant.ts b/src/renderer/src/lib/editor-tab-external-path-grant.ts new file mode 100644 index 000000000000..4db5696e24cc --- /dev/null +++ b/src/renderer/src/lib/editor-tab-external-path-grant.ts @@ -0,0 +1,58 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { settingsForRuntimeOwner } from '@/runtime/runtime-client-target' +import type { OpenFile } from '@/store/slices/editor' +import type { AppState } from '@/store/types' +import { getConnectionIdForFile, isWorktreeConnectionResolved } from './connection-context' +import { findWorkspaceFileRoute } from './runtime-workspace-file-route' + +type EditorTabPathGrantFile = Pick< + OpenFile, + 'filePath' | 'relativePath' | 'worktreeId' | 'runtimeEnvironmentId' | 'externalSshTargetId' +> + +/** + * The client-local path an editor tab needs re-granted before main will read or + * write it, or null when an authorized project root (or a remote host) owns it. + */ +export function getEditorTabExternalPathGrantTarget( + state: AppState, + file: EditorTabPathGrantFile +): string | null { + const runtimeOwner = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) + if (file.externalSshTargetId?.trim() || runtimeOwner?.activeRuntimeEnvironmentId?.trim()) { + return null + } + // Why: the floating workspace root (`~` by default) is deliberately not an authorized root, + // so its tabs need a grant even though they store a root-relative path. + if (file.worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { + if (file.relativePath !== file.filePath) { + return null + } + const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) + // Why: an SSH owner reads remotely, and an unhydrated owner can't be told apart from one. + if ( + connectionId || + (connectionId === undefined && !isWorktreeConnectionResolved(file.worktreeId)) + ) { + return null + } + } + // Why: a project root already authorizes this path; a grant would also authorize a project symlink's outside target. + if (findWorkspaceFileRoute(state, LOCAL_EXECUTION_HOST_ID, file.filePath)) { + return null + } + return file.filePath +} + +/** + * Main's external-path grants live only in memory, so every reader of a restored tab re-derives + * its own. Null when no grant is needed, so callers can keep their read on the same tick. + */ +export function refreshEditorTabExternalPathGrant( + state: AppState, + file: EditorTabPathGrantFile +): Promise | null { + const targetPath = getEditorTabExternalPathGrantTarget(state, file) + return targetPath ? window.api.fs.authorizeExternalPath({ targetPath }) : null +} diff --git a/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts b/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts index 1d7b41f9105c..6f4d674da591 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts @@ -18,6 +18,7 @@ type WindowStub = { fs: { readFile: ReturnType writeFile: ReturnType + authorizeExternalPath: ReturnType } } } @@ -26,10 +27,12 @@ let mobileMarkdownHandler: ((request: RuntimeMobileMarkdownRequest) => void) | n export function setupWindow({ readFile, - writeFile = vi.fn().mockResolvedValue(undefined) + writeFile = vi.fn().mockResolvedValue(undefined), + authorizeExternalPath = vi.fn().mockResolvedValue(undefined) }: { readFile: ReturnType writeFile?: ReturnType + authorizeExternalPath?: ReturnType }): { responses: unknown[] } { const eventTarget = new EventTarget() const responses: unknown[] = [] @@ -50,7 +53,7 @@ export function setupWindow({ }, respondMobileMarkdownRequest: vi.fn((response) => responses.push(response)) }, - fs: { readFile, writeFile } + fs: { readFile, writeFile, authorizeExternalPath } } } satisfies WindowStub) return { responses } diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts index c98d728ced3b..3fe13ee413ef 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts @@ -7,6 +7,7 @@ import { import { attachEditorAutosaveController } from '../components/editor/editor-autosave-controller' import { registerPendingEditorFlush } from '../components/editor/editor-pending-flush' import { useAppStore } from '../store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { attachMobileMarkdownBridge } from './mobile-markdown-bridge' import { cleanupMobileMarkdownBridgeHarness, @@ -63,6 +64,38 @@ describe('mobile markdown bridge', () => { } }) + it('grants a restored floating-workspace tab before reading it for mobile', async () => { + useAppStore.getState().openFile({ + filePath: '/Users/me/notes.md', + relativePath: 'notes.md', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: 'markdown', + mode: 'edit' + }) + const tabId = useAppStore.getState().openFiles[0]!.id + const readFile = vi.fn().mockResolvedValue({ content: '# notes', isBinary: false }) + const authorizeExternalPath = vi.fn().mockResolvedValue(undefined) + setupWindow({ readFile, authorizeExternalPath }) + const detach = attachMobileMarkdownBridge() + + try { + const response = await sendRequest({ + id: 'read-floating', + operation: 'read', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + tabId + }) + + expect(response).toMatchObject({ ok: true, result: { content: '# notes', source: 'file' } }) + expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: '/Users/me/notes.md' }) + expect(authorizeExternalPath.mock.invocationCallOrder[0]).toBeLessThan( + readFile.mock.invocationCallOrder[0] + ) + } finally { + detach() + } + }) + it('rejects save when a clean file changed after mobile read', async () => { openMarkdownFile() const writeFile = vi.fn().mockResolvedValue(undefined) @@ -117,8 +150,7 @@ describe('mobile markdown bridge', () => { filePath: '/repo/README.md', content: 'mobile edit', connectionId: undefined, - expectedExecutionHostId: 'local', - savesOpenEditorFile: true + expectedExecutionHostId: 'local' }) expect(response).toMatchObject({ id: 'save-2', diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index c51a76568432..3b3b6361957a 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -7,6 +7,7 @@ import { } from '@/components/editor/editor-autosave' import { flushPendingEditorChange } from '@/components/editor/editor-pending-flush' import { getConnectionIdForFile } from '@/lib/connection-context' +import { refreshEditorTabExternalPathGrant } from '@/lib/editor-tab-external-path-grant' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { readRuntimeFileContent } from './runtime-file-client' @@ -243,6 +244,11 @@ async function readCurrentContent( async function readFileContent(file: OpenFile): Promise { const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined const state = useAppStore.getState() + // Why: a paired client can read a restored tab the desktop editor never loaded. + const pathGrant = refreshEditorTabExternalPathGrant(state, file) + if (pathGrant) { + await pathGrant + } const result = (await readRuntimeFileContent({ settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), filePath: file.filePath, diff --git a/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts b/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts index 687113708752..688e2e574171 100644 --- a/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts @@ -110,19 +110,6 @@ describe('runtime file client', () => { }) }) - it('marks a local write as an open editor file save only when the caller asks', async () => { - const context = { settings: null, worktreeId: 'wt-1', worktreePath: '/repo' } - - await writeRuntimeFile(context, '/Users/me/notes.txt', 'saved', { savesOpenEditorFile: true }) - await writeRuntimeFile(context, '/repo/src/index.ts', 'written') - - expect(fsWriteFile).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ filePath: '/Users/me/notes.txt', savesOpenEditorFile: true }) - ) - expect(fsWriteFile.mock.calls[1]?.[0]).not.toHaveProperty('savesOpenEditorFile') - }) - it('refuses HUB-local mutations before RPC when the HUB lacks ownership support', async () => { runtimeEnvironmentTransportCall.mockImplementation((args: { method: string }) => { if (args.method === 'status.get') { diff --git a/src/renderer/src/runtime/runtime-file-mutation-client.ts b/src/renderer/src/runtime/runtime-file-mutation-client.ts index fe1df8994246..b2a358a5200f 100644 --- a/src/renderer/src/runtime/runtime-file-mutation-client.ts +++ b/src/renderer/src/runtime/runtime-file-mutation-client.ts @@ -32,22 +32,13 @@ export async function readRuntimeDirectory( export async function writeRuntimeFile( context: RuntimeFileOperationArgs, filePath: string, - content: string, - options?: { savesOpenEditorFile?: boolean } + content: string ): Promise { const remoteArgs = getRemoteFileArgs(context, filePath) if (!remoteArgs) { assertLocalFilesystemFallbackAllowed(context) await window.api.fs.writeFile( - withSshMutationExpectation(context, { - filePath, - content, - connectionId: context.connectionId, - // Why: only a local write consults the flag; an SSH host owns its own file access. - ...(options?.savesOpenEditorFile && !context.connectionId - ? { savesOpenEditorFile: true } - : {}) - }) + withSshMutationExpectation(context, { filePath, content, connectionId: context.connectionId }) ) return } From 303b7abae8ccbe089b9bdfec0047ff953165bc54 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:22:44 -0700 Subject: [PATCH 03/19] fix(editor): let main decide which restored-tab paths a project already covers Problem: the restore re-grant helper decided "already inside a project" in the renderer from its worktree list. At startup that list only holds repos the session references, so a floating tab inside an unlisted repo was granted (including a symlink's outside target), and the renderer's path matcher disagrees with main's on WSL \\wsl$ vs \\wsl.localhost, which stranded a folder-workspace tab with "Access denied" after restart. The helper also treated a folder workspace with a missing or ambiguous host as local. Fix: the renderer now decides only by owner (a floating-workspace tab, or a tab stored outside a project whose owner is explicitly local) and asks main with `skipIfInsideAllowedRoots`. Main checks the path against its own allowed and registered roots, in both the named and canonical-parent spelling against both root spellings: a path a project covers gets no grant, an alias spelling of a project path gets only that spelling, and a project symlink's outside target is never granted. Explicit-open grants (Cmd-click, drag, explorer) are unchanged. Tests now prove each reader waits for the grant before reading. --- src/main/ipc/filesystem-auth.ts | 51 ++++++++ .../ipc/filesystem-restored-tab-grant.test.ts | 109 ++++++++++++++++++ .../filesystem/filesystem-write-handlers.ts | 22 +++- src/preload/api/filesystem-api.ts | 6 +- src/preload/api/fs-bridge.ts | 6 +- .../editor-restored-tab-conflict-scan.test.ts | 16 ++- .../useEditorPanelContentState.test.tsx | 22 ---- ...ditorPanelFloatingTabContentState.test.tsx | 41 ++++--- .../editor-tab-external-path-grant.test.ts | 29 +++-- .../src/lib/editor-tab-external-path-grant.ts | 33 +++--- .../runtime/mobile-markdown-bridge.test.ts | 23 ++-- 11 files changed, 273 insertions(+), 85 deletions(-) create mode 100644 src/main/ipc/filesystem-restored-tab-grant.test.ts diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 620baa9b5950..68dcb16db2bf 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -44,6 +44,57 @@ export function authorizeExternalPath(targetPath: string): void { } catch {} } +/** + * Re-derives a restored editor tab's grant without widening any project: a path a project root + * already covers gets none, and an alias spelling of a project path gets only that spelling, never + * its realpath — so a project symlink's outside target is never granted. + */ +export async function authorizeExternalPathOutsideAllowedRoots( + targetPath: string, + store: Store +): Promise { + const resolvedTarget = resolve(targetPath) + await ensureAuthorizedRootsCache(store) + const roots = getAllowedRoots(store) + const isInsideRoot = (path: string, candidateRoots: readonly string[]): boolean => + candidateRoots.some((root) => isDescendantOrEqual(path, root)) || + isRegisteredWorktreePath(path, store) + if (isInsideRoot(resolvedTarget, roots)) { + return + } + const canonicalRoots = await Promise.all( + roots.map((root) => normalizeExistingPath(root).catch(() => root)) + ) + const canonicalTarget = await canonicalizeParentKeepingLeaf(resolvedTarget) + if ( + isInsideRoot(resolvedTarget, canonicalRoots) || + (canonicalTarget !== null && + (isInsideRoot(canonicalTarget, roots) || isInsideRoot(canonicalTarget, canonicalRoots))) + ) { + rememberAuthorizedExternalPath(resolvedTarget) + return + } + authorizeExternalPath(resolvedTarget) +} + +// Why keep the leaf: a project symlink must still read as inside its project, not as its target. +async function canonicalizeParentKeepingLeaf(resolvedTarget: string): Promise { + const missingSegments = [basename(resolvedTarget)] + let ancestor = dirname(resolvedTarget) + while (true) { + try { + return resolve(await realpath(ancestor), ...missingSegments) + } catch (error) { + const parent = dirname(ancestor) + if (!isENOENT(error) || parent === ancestor) { + return null + } + missingSegments.unshift(basename(ancestor)) + ancestor = parent + } + } +} + /** * One allowed-root list shared by every check in a single authorization. * diff --git a/src/main/ipc/filesystem-restored-tab-grant.test.ts b/src/main/ipc/filesystem-restored-tab-grant.test.ts new file mode 100644 index 000000000000..2277b45c8f5b --- /dev/null +++ b/src/main/ipc/filesystem-restored-tab-grant.test.ts @@ -0,0 +1,109 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as RepoWorktrees from '../repo-worktrees' +import type { Repo } from '../../shared/repo-types' +import { authorizeExternalPathOutsideAllowedRoots, resolveAuthorizedPath } from './filesystem-auth' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' + +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } +}) + +function makeStore(repoPath: string): Store { + const repo = { + id: 'repo-local', + path: repoPath, + displayName: 'project', + badgeColor: '#000', + addedAt: 0 + } satisfies Repo + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. + return { + getRepos: () => [{ ...repo }], + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) + } as unknown as Store +} + +// Why: creating a symlink on Windows needs elevation or Developer Mode. +describe.skipIf(process.platform === 'win32')('authorizeExternalPathOutsideAllowedRoots', () => { + let base: string + let project: string + let alias: string + let outside: string + let secret: string + + beforeEach(async () => { + invalidateAuthorizedRootsCache() + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-restored-grant-')) + project = join(base, 'real', 'project') + alias = join(base, 'alias-project') + outside = join(base, 'outside') + await mkdir(project, { recursive: true }) + await mkdir(outside) + await symlink(project, alias) + secret = join(outside, 'secret.txt') + await writeFile(secret, 'secret\n') + await writeFile(join(project, 'notes.md'), 'notes\n') + await symlink(secret, join(project, 'escape.txt')) + await symlink(outside, join(project, 'dir-link')) + }) + + afterEach(async () => { + await rm(base, { recursive: true, force: true }) + }) + + it('grants a restored floating-workspace file outside every project', async () => { + const store = makeStore(project) + const notes = join(base, 'home-notes.txt') + await writeFile(notes, 'home\n') + await expect(resolveAuthorizedPath(notes, store)).rejects.toThrow('Access denied') + + await authorizeExternalPathOutsideAllowedRoots(notes, store) + + await expect(resolveAuthorizedPath(notes, store)).resolves.toBe(notes) + }) + + // The project is in main's store even before the renderer has listed its worktrees. + it.each([ + ['a leaf symlink', 'escape.txt'], + ['a directory symlink', join('dir-link', 'secret.txt')] + ])('never grants the outside target of %s inside a project', async (_label, relativePath) => { + const store = makeStore(project) + + await authorizeExternalPathOutsideAllowedRoots(join(project, relativePath), store) + + await expect(resolveAuthorizedPath(join(project, relativePath), store)).rejects.toThrow( + 'Access denied' + ) + await expect(resolveAuthorizedPath(secret, store)).rejects.toThrow('Access denied') + }) + + it.each([ + ['registered through an alias, named canonically', 'alias', 'project'], + ['registered canonically, named through an alias', 'project', 'alias'] + ])( + 'treats a project %s as inside it: symlink targets stay denied, files stay readable', + async (_label, registeredAs, namedAs) => { + const store = makeStore(registeredAs === 'alias' ? alias : project) + const namedRoot = namedAs === 'alias' ? alias : project + + await authorizeExternalPathOutsideAllowedRoots(join(namedRoot, 'escape.txt'), store) + await authorizeExternalPathOutsideAllowedRoots(join(namedRoot, 'notes.md'), store) + + await expect(resolveAuthorizedPath(join(namedRoot, 'escape.txt'), store)).rejects.toThrow( + 'Access denied' + ) + await expect(resolveAuthorizedPath(secret, store)).rejects.toThrow('Access denied') + await expect(resolveAuthorizedPath(join(namedRoot, 'notes.md'), store)).resolves.toBe( + join(project, 'notes.md') + ) + } + ) +}) diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 0cc5a921b451..edb770321b3b 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -5,7 +5,11 @@ import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generatio import { runSshProviderContinuation } from '../../ssh/ssh-provider-continuations' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' -import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' +import { + authorizeExternalPath, + authorizeExternalPathOutsideAllowedRoots, + resolveAuthorizedPath +} from '../filesystem-auth' import { isENOENT } from '../filesystem-path-containment' import { registerFilesystemMutationHandlers } from '../filesystem-mutations' import type { FilesystemHandlerContext } from './filesystem-handler-context' @@ -90,7 +94,17 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex registerFilesystemMutationHandlers(store) - ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { - authorizeExternalPath(args.targetPath) - }) + ipcMain.handle( + 'fs:authorizeExternalPath', + async ( + _event, + args: { targetPath: string; skipIfInsideAllowedRoots?: boolean } + ): Promise => { + if (args.skipIfInsideAllowedRoots === true) { + await authorizeExternalPathOutsideAllowedRoots(args.targetPath, store) + return + } + authorizeExternalPath(args.targetPath) + } + ) } diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index d8b74cd93043..54d0f7f914a7 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -120,7 +120,11 @@ export type FilesystemApi = { recursive?: boolean } & SshMutationExpectation ) => Promise - authorizeExternalPath: (args: { targetPath: string }) => Promise + authorizeExternalPath: (args: { + targetPath: string + /** Restored editor tabs: no grant inside a project, and never a project symlink's target. */ + skipIfInsideAllowedRoots?: boolean + }) => Promise stat: (args: { filePath: string connectionId?: string diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index d58dd17ff8f9..5e9cfc22eaca 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -116,8 +116,10 @@ export const fsApi = { recursive?: boolean } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:deletePath', args), - authorizeExternalPath: (args: { targetPath: string }): Promise => - ipcRenderer.invoke('fs:authorizeExternalPath', args), + authorizeExternalPath: (args: { + targetPath: string + skipIfInsideAllowedRoots?: boolean + }): Promise => ipcRenderer.invoke('fs:authorizeExternalPath', args), stat: (args: { filePath: string connectionId?: string diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 45a833b1f2ec..2c54c6ababf9 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -278,15 +278,23 @@ describe('attachRestoredTabConflictScan', () => { })) } as never) + let finishGrant: () => void = () => {} + mocks.authorizeExternalPath.mockImplementation( + () => new Promise((resolve) => (finishGrant = resolve)) + ) + const detach = attachRestoredTabConflictScan(store) try { await vi.advanceTimersByTimeAsync(10) expect(mocks.authorizeExternalPath).toHaveBeenCalledWith({ - targetPath: '/Users/me/notes.txt' + targetPath: '/Users/me/notes.txt', + skipIfInsideAllowedRoots: true }) - expect(mocks.authorizeExternalPath.mock.invocationCallOrder[0]).toBeLessThan( - mocks.readRuntimeFileContent.mock.invocationCallOrder[0] - ) + expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() + + finishGrant() + await vi.advanceTimersByTimeAsync(10) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1) expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBeUndefined() } finally { detach() diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 6df420f6abca..2ec22004b832 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -294,28 +294,6 @@ describe('useEditorPanelContentState', () => { ) }) - it('re-authorizes a client-local external tab before reading it', async () => { - const activeFile = createOpenFile({ - id: '/Users/me/notes/audit.md', - filePath: '/Users/me/notes/audit.md', - relativePath: '/Users/me/notes/audit.md', - worktreeId: 'repo-local::/Users/me/project' - }) - mocks.getConnectionIdForFile.mockReturnValue(undefined) - mocks.readRuntimeFileContent.mockResolvedValue({ content: '# local', isBinary: false }) - - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - await act(async () => { - root?.render() - }) - - await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) - expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: '/Users/me/notes/audit.md' }) - }) - it('rejects an unstamped external tab in a remote runtime workspace', async () => { const activeFile = createOpenFile({ id: '/work/reports/audit.md', diff --git a/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx index e296c86b9dca..cd5fa2dec64d 100644 --- a/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx @@ -83,7 +83,7 @@ function HookProbe({ activeFile }: { activeFile: OpenFile }): null { return null } -describe('restored floating-workspace editor tabs', () => { +describe('restored client-local editor tabs', () => { let container: HTMLDivElement | null = null let root: Root | null = null @@ -113,35 +113,50 @@ describe('restored floating-workspace editor tabs', () => { // The notebook kernel and environment handlers authorize the same filePath. it.each(['/Users/me/notes.txt', '/Users/me/analysis.ipynb'])( - 're-grants %s before reading it after a restart', + 're-grants %s and waits for the grant before reading it after a restart', async (filePath) => { + let finishGrant: () => void = () => {} + authorizeExternalPath.mockImplementation( + () => new Promise((resolve) => (finishGrant = resolve)) + ) const activeFile = createFloatingFile(filePath) await act(async () => root?.render()) - await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) - expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: filePath }) - expect(authorizeExternalPath.mock.invocationCallOrder[0]).toBeLessThan( - mocks.readRuntimeFileContent.mock.invocationCallOrder[0] + await vi.waitFor(() => + expect(authorizeExternalPath).toHaveBeenCalledWith({ + targetPath: filePath, + skipIfInsideAllowedRoots: true + }) ) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() + + await act(async () => finishGrant()) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( expect.objectContaining({ filePath, connectionId: undefined }) ) } ) - it('does not grant a floating tab whose file lies inside a project', async () => { - const activeFile = createFloatingFile('/Users/me/project/src/link.md') - mocks.findWorkspaceFileRoute.mockReturnValue({ - worktreeId: 'repo::/Users/me/project', - relativePath: 'src/link.md', - executionHostId: 'local' + it('re-authorizes a client-local tab stored outside its own project', async () => { + const filePath = '/Users/me/notes/audit.md' + const activeFile = createFloatingFile(filePath, { + relativePath: filePath, + worktreeId: 'repo-local::/Users/me/project' }) await act(async () => root?.render()) await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) - expect(authorizeExternalPath).not.toHaveBeenCalled() + expect(authorizeExternalPath).toHaveBeenCalledWith({ + targetPath: filePath, + skipIfInsideAllowedRoots: true + }) }) it('does not grant a project tab that its authorized root already covers', async () => { diff --git a/src/renderer/src/lib/editor-tab-external-path-grant.test.ts b/src/renderer/src/lib/editor-tab-external-path-grant.test.ts index 8d3fc8b7e73d..d4497b67d860 100644 --- a/src/renderer/src/lib/editor-tab-external-path-grant.test.ts +++ b/src/renderer/src/lib/editor-tab-external-path-grant.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { useAppStore } from '@/store' import { getEditorTabExternalPathGrantTarget, @@ -96,17 +97,6 @@ describe('getEditorTabExternalPathGrantTarget', () => { ).toBeNull() }) - it('does not grant a floating tab whose file lies inside a project', () => { - // A grant would also authorize the outside target of a project symlink. - expect( - grantTarget({ - filePath: '/Users/me/project/src/link.ts', - relativePath: 'project/src/link.ts', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID - }) - ).toBeNull() - }) - it('keeps SSH-owned external tabs off the local grant', () => { expect( grantTarget({ @@ -135,6 +125,17 @@ describe('getEditorTabExternalPathGrantTarget', () => { ).toBeNull() }) + it('does not grant an external tab in a folder workspace whose host is unknown', () => { + // Why: a missing or ambiguous folder host may be SSH; only an explicitly local owner is granted. + expect( + grantTarget({ + filePath: '/home/remote-user/notes.md', + relativePath: '/home/remote-user/notes.md', + worktreeId: folderWorkspaceKey('fw-missing') + }) + ).toBeNull() + }) + it('does not grant a runtime-owned tab, which the runtime reads remotely', () => { expect( grantTarget({ @@ -164,6 +165,10 @@ describe('getEditorTabExternalPathGrantTarget', () => { worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) expect(authorizeExternalPath).toHaveBeenCalledTimes(1) - expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: '/Users/me/notes.txt' }) + // Main, not the renderer, decides whether a project root already covers the path. + expect(authorizeExternalPath).toHaveBeenCalledWith({ + targetPath: '/Users/me/notes.txt', + skipIfInsideAllowedRoots: true + }) }) }) diff --git a/src/renderer/src/lib/editor-tab-external-path-grant.ts b/src/renderer/src/lib/editor-tab-external-path-grant.ts index 4db5696e24cc..56418947b767 100644 --- a/src/renderer/src/lib/editor-tab-external-path-grant.ts +++ b/src/renderer/src/lib/editor-tab-external-path-grant.ts @@ -1,10 +1,8 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { settingsForRuntimeOwner } from '@/runtime/runtime-client-target' import type { OpenFile } from '@/store/slices/editor' import type { AppState } from '@/store/types' -import { getConnectionIdForFile, isWorktreeConnectionResolved } from './connection-context' -import { findWorkspaceFileRoute } from './runtime-workspace-file-route' +import { getConnectionIdForFile } from './connection-context' type EditorTabPathGrantFile = Pick< OpenFile, @@ -12,8 +10,8 @@ type EditorTabPathGrantFile = Pick< > /** - * The client-local path an editor tab needs re-granted before main will read or - * write it, or null when an authorized project root (or a remote host) owns it. + * The client-local path a restored editor tab may need re-granted, decided by who owns the tab. + * Main decides whether a project root already covers it. */ export function getEditorTabExternalPathGrantTarget( state: AppState, @@ -25,21 +23,14 @@ export function getEditorTabExternalPathGrantTarget( } // Why: the floating workspace root (`~` by default) is deliberately not an authorized root, // so its tabs need a grant even though they store a root-relative path. - if (file.worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { - if (file.relativePath !== file.filePath) { - return null - } - const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) - // Why: an SSH owner reads remotely, and an unhydrated owner can't be told apart from one. - if ( - connectionId || - (connectionId === undefined && !isWorktreeConnectionResolved(file.worktreeId)) - ) { - return null - } + if (file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return file.filePath } - // Why: a project root already authorizes this path; a grant would also authorize a project symlink's outside target. - if (findWorkspaceFileRoute(state, LOCAL_EXECUTION_HOST_ID, file.filePath)) { + // Why null only: an SSH, ambiguous or not-yet-loaded owner may mean the path lives on another host. + if ( + file.relativePath !== file.filePath || + getConnectionIdForFile(file.worktreeId, file.filePath) !== null + ) { return null } return file.filePath @@ -54,5 +45,7 @@ export function refreshEditorTabExternalPathGrant( file: EditorTabPathGrantFile ): Promise | null { const targetPath = getEditorTabExternalPathGrantTarget(state, file) - return targetPath ? window.api.fs.authorizeExternalPath({ targetPath }) : null + return targetPath + ? window.api.fs.authorizeExternalPath({ targetPath, skipIfInsideAllowedRoots: true }) + : null } diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts index 3fe13ee413ef..a7fec4a14d57 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts @@ -74,23 +74,32 @@ describe('mobile markdown bridge', () => { }) const tabId = useAppStore.getState().openFiles[0]!.id const readFile = vi.fn().mockResolvedValue({ content: '# notes', isBinary: false }) - const authorizeExternalPath = vi.fn().mockResolvedValue(undefined) + let finishGrant: () => void = () => {} + const authorizeExternalPath = vi.fn( + () => new Promise((resolve) => (finishGrant = resolve)) + ) setupWindow({ readFile, authorizeExternalPath }) const detach = attachMobileMarkdownBridge() try { - const response = await sendRequest({ + const response = sendRequest({ id: 'read-floating', operation: 'read', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, tabId }) + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(authorizeExternalPath).toHaveBeenCalledWith({ + targetPath: '/Users/me/notes.md', + skipIfInsideAllowedRoots: true + }) + expect(readFile).not.toHaveBeenCalled() - expect(response).toMatchObject({ ok: true, result: { content: '# notes', source: 'file' } }) - expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: '/Users/me/notes.md' }) - expect(authorizeExternalPath.mock.invocationCallOrder[0]).toBeLessThan( - readFile.mock.invocationCallOrder[0] - ) + finishGrant() + expect(await response).toMatchObject({ + ok: true, + result: { content: '# notes', source: 'file' } + }) } finally { detach() } From 7c8350cc4df3a54a055472cb7f5cecdb729a370a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:43:27 -0700 Subject: [PATCH 04/19] fix(fs): decide a restored tab's project membership from every ancestor's real path Problem: the restore re-grant decided "inside a project" from the named path and the real path of its parent only. When a tab path crossed a project directory symlink and named the project through a spelling that was neither the registered root nor its realpath (a second alias, a `..` segment, a case variant on a case-insensitive disk, a /var-style alias of an ancestor), no check matched, the path took the full grant, and the symlink's outside target became readable and writable. Fix: a path is inside a project when the named path is inside a root, or the real path of any ancestor folder is inside a root in its registered or real spelling. Such a path gets at most its named spelling, never its realpath. An ancestor that fails to resolve for any reason other than "missing" now fails closed to the named-spelling grant instead of falling through to the full one. Tests cover each spelling; the non-symlink case also runs on Windows. --- src/main/ipc/filesystem-auth.ts | 50 +++-- .../ipc/filesystem-restored-tab-grant.test.ts | 189 ++++++++++++------ 2 files changed, 153 insertions(+), 86 deletions(-) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 68dcb16db2bf..7105386efe94 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -45,9 +45,9 @@ export function authorizeExternalPath(targetPath: string): void { } /** - * Re-derives a restored editor tab's grant without widening any project: a path a project root - * already covers gets none, and an alias spelling of a project path gets only that spelling, never - * its realpath — so a project symlink's outside target is never granted. + * Re-derives a restored editor tab's grant without widening any project. A path inside a project in + * any spelling — as named, or through the real path of any ancestor folder — gets at most its named + * spelling, never its realpath, so a project symlink's outside target is never granted. */ export async function authorizeExternalPathOutsideAllowedRoots( targetPath: string, @@ -62,37 +62,33 @@ export async function authorizeExternalPathOutsideAllowedRoots( if (isInsideRoot(resolvedTarget, roots)) { return } - const canonicalRoots = await Promise.all( + const realRoots = await Promise.all( roots.map((root) => normalizeExistingPath(root).catch(() => root)) ) - const canonicalTarget = await canonicalizeParentKeepingLeaf(resolvedTarget) - if ( - isInsideRoot(resolvedTarget, canonicalRoots) || - (canonicalTarget !== null && - (isInsideRoot(canonicalTarget, roots) || isInsideRoot(canonicalTarget, canonicalRoots))) - ) { - rememberAuthorizedExternalPath(resolvedTarget) - return - } - authorizeExternalPath(resolvedTarget) -} - -// Why keep the leaf: a project symlink must still read as inside its project, not as its target. -async function canonicalizeParentKeepingLeaf(resolvedTarget: string): Promise { - const missingSegments = [basename(resolvedTarget)] - let ancestor = dirname(resolvedTarget) - while (true) { + const rootSpellings = [...roots, ...realRoots] + // Why every ancestor: past an escaping directory symlink the real path has left the project, so + // only a higher ancestor's real path shows the named path entered it. The leaf is skipped: a file + // that links into a project is not a project path. + for (let ancestor = dirname(resolvedTarget); ; ancestor = dirname(ancestor)) { + let realAncestor: string | null = null try { - return resolve(await realpath(ancestor), ...missingSegments) + realAncestor = resolve(await realpath(ancestor)) } catch (error) { - const parent = dirname(ancestor) - if (!isENOENT(error) || parent === ancestor) { - return null + if (!isENOENT(error)) { + // Why fail closed: an unresolvable ancestor can't prove the path stays outside every project. + rememberAuthorizedExternalPath(resolvedTarget) + return } - missingSegments.unshift(basename(ancestor)) - ancestor = parent + } + if (realAncestor !== null && isInsideRoot(realAncestor, rootSpellings)) { + rememberAuthorizedExternalPath(resolvedTarget) + return + } + if (dirname(ancestor) === ancestor) { + break } } + authorizeExternalPath(resolvedTarget) } /** diff --git a/src/main/ipc/filesystem-restored-tab-grant.test.ts b/src/main/ipc/filesystem-restored-tab-grant.test.ts index 2277b45c8f5b..6fbe8bb9aef6 100644 --- a/src/main/ipc/filesystem-restored-tab-grant.test.ts +++ b/src/main/ipc/filesystem-restored-tab-grant.test.ts @@ -1,10 +1,9 @@ -import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type * as RepoWorktrees from '../repo-worktrees' -import type { Repo } from '../../shared/repo-types' import { authorizeExternalPathOutsideAllowedRoots, resolveAuthorizedPath } from './filesystem-auth' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' @@ -13,97 +12,169 @@ vi.mock('../repo-worktrees', async () => { return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } }) -function makeStore(repoPath: string): Store { - const repo = { - id: 'repo-local', - path: repoPath, - displayName: 'project', - badgeColor: '#000', - addedAt: 0 - } satisfies Repo +type Registration = { repoPath?: string; folderPath?: string } + +function makeStore({ repoPath, folderPath }: Registration): Store { + const repos = repoPath + ? [{ id: 'repo-local', path: repoPath, displayName: 'project', badgeColor: '#000', addedAt: 0 }] + : [] + const folders = folderPath ? [{ id: 'folder', folderPath, projectGroupId: 'none' }] : [] // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. return { - getRepos: () => [{ ...repo }], + getRepos: () => repos.map((repo) => ({ ...repo })), getProjects: () => [], getProjectGroups: () => [], - getFolderWorkspaces: () => [], + getFolderWorkspaces: () => folders.map((folder) => ({ ...folder })), getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) } as unknown as Store } +async function isReadable(path: string, store: Store): Promise { + return resolveAuthorizedPath(path, store).then( + () => true, + () => false + ) +} + +let base: string + +beforeEach(async () => { + invalidateAuthorizedRootsCache() + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-restored-grant-')) +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +describe('authorizeExternalPathOutsideAllowedRoots', () => { + it('grants a restored floating-workspace file outside every project', async () => { + const project = join(base, 'project') + await mkdir(project) + const store = makeStore({ repoPath: project }) + const notes = join(base, 'home-notes.txt') + await writeFile(notes, 'home\n') + expect(await isReadable(notes, store)).toBe(false) + + await authorizeExternalPathOutsideAllowedRoots(notes, store) + + expect(await isReadable(notes, store)).toBe(true) + }) +}) + // Why: creating a symlink on Windows needs elevation or Developer Mode. -describe.skipIf(process.platform === 'win32')('authorizeExternalPathOutsideAllowedRoots', () => { - let base: string +describe.skipIf(process.platform === 'win32')('restore grants and project symlinks', () => { let project: string let alias: string - let outside: string + let alias2: string + let privateBase: string + let varAlias: string let secret: string beforeEach(async () => { - invalidateAuthorizedRootsCache() - base = await mkdtemp(join(await realpath(tmpdir()), 'orca-restored-grant-')) - project = join(base, 'real', 'project') + // Mirrors macOS /var -> /private/var: an alias of an ancestor above the project root. + privateBase = join(base, 'private') + varAlias = join(base, 'var') + project = join(privateBase, 'real', 'project') alias = join(base, 'alias-project') - outside = join(base, 'outside') - await mkdir(project, { recursive: true }) + alias2 = join(base, 'alias2-project') + const outside = join(base, 'outside') + await mkdir(join(project, 'sub'), { recursive: true }) await mkdir(outside) + await symlink(privateBase, varAlias) await symlink(project, alias) + await symlink(project, alias2) secret = join(outside, 'secret.txt') await writeFile(secret, 'secret\n') await writeFile(join(project, 'notes.md'), 'notes\n') await symlink(secret, join(project, 'escape.txt')) await symlink(outside, join(project, 'dir-link')) + await symlink(outside, join(project, 'sub', 'deep-link')) }) - afterEach(async () => { - await rm(base, { recursive: true, force: true }) - }) + const viaDirLink = (root: string): string => join(root, 'dir-link', 'secret.txt') - it('grants a restored floating-workspace file outside every project', async () => { - const store = makeStore(project) - const notes = join(base, 'home-notes.txt') - await writeFile(notes, 'home\n') - await expect(resolveAuthorizedPath(notes, store)).rejects.toThrow('Access denied') + // Each spelling must still read as inside the project, or the full grant exposes the target. + it.each<[string, () => Registration, () => string]>([ + [ + 'a leaf symlink named as registered', + () => ({ repoPath: project }), + () => join(project, 'escape.txt') + ], + [ + 'a directory symlink named as registered', + () => ({ repoPath: project }), + () => viaDirLink(project) + ], + [ + 'a directory symlink named through an alias', + () => ({ repoPath: project }), + () => viaDirLink(alias) + ], + [ + 'a directory symlink, registered and named through two aliases', + () => ({ repoPath: alias }), + () => viaDirLink(alias2) + ], + [ + 'a directory symlink, registered via alias and named canonically', + () => ({ folderPath: alias }), + () => viaDirLink(project) + ], + [ + 'a deep directory symlink in a folder workspace', + () => ({ folderPath: project }), + () => join(alias, 'sub', 'deep-link', 'secret.txt') + ], + [ + 'a directory symlink named with `..`', + () => ({ repoPath: project }), + () => join(alias, 'sub', '..', 'dir-link', 'secret.txt') + ], + [ + 'a directory symlink named through an ancestor alias', + () => ({ repoPath: project }), + () => viaDirLink(project.replace(privateBase, varAlias)) + ], + [ + 'a leaf symlink named through an ancestor alias', + () => ({ repoPath: project }), + () => join(project.replace(privateBase, varAlias), 'escape.txt') + ] + ])('never grants the outside target of %s', async (_label, registration, namedPath) => { + const store = makeStore(registration()) - await authorizeExternalPathOutsideAllowedRoots(notes, store) + await authorizeExternalPathOutsideAllowedRoots(namedPath(), store) - await expect(resolveAuthorizedPath(notes, store)).resolves.toBe(notes) + expect(await isReadable(secret, store)).toBe(false) + expect(await isReadable(namedPath(), store)).toBe(false) }) - // The project is in main's store even before the renderer has listed its worktrees. - it.each([ - ['a leaf symlink', 'escape.txt'], - ['a directory symlink', join('dir-link', 'secret.txt')] - ])('never grants the outside target of %s inside a project', async (_label, relativePath) => { - const store = makeStore(project) + it('never grants the outside target of a case-variant spelling on a case-insensitive disk', async (ctx) => { + const caseVariant = project.replace(`${privateBase}/real`, `${privateBase}/REAL`) + const caseInsensitive = await stat(caseVariant).then( + () => true, + () => false + ) + if (!caseInsensitive) { + ctx.skip() + } + const store = makeStore({ repoPath: project }) - await authorizeExternalPathOutsideAllowedRoots(join(project, relativePath), store) + await authorizeExternalPathOutsideAllowedRoots(viaDirLink(caseVariant), store) - await expect(resolveAuthorizedPath(join(project, relativePath), store)).rejects.toThrow( - 'Access denied' - ) - await expect(resolveAuthorizedPath(secret, store)).rejects.toThrow('Access denied') + expect(await isReadable(secret, store)).toBe(false) }) it.each([ ['registered through an alias, named canonically', 'alias', 'project'], ['registered canonically, named through an alias', 'project', 'alias'] - ])( - 'treats a project %s as inside it: symlink targets stay denied, files stay readable', - async (_label, registeredAs, namedAs) => { - const store = makeStore(registeredAs === 'alias' ? alias : project) - const namedRoot = namedAs === 'alias' ? alias : project - - await authorizeExternalPathOutsideAllowedRoots(join(namedRoot, 'escape.txt'), store) - await authorizeExternalPathOutsideAllowedRoots(join(namedRoot, 'notes.md'), store) - - await expect(resolveAuthorizedPath(join(namedRoot, 'escape.txt'), store)).rejects.toThrow( - 'Access denied' - ) - await expect(resolveAuthorizedPath(secret, store)).rejects.toThrow('Access denied') - await expect(resolveAuthorizedPath(join(namedRoot, 'notes.md'), store)).resolves.toBe( - join(project, 'notes.md') - ) - } - ) + ])('keeps a project file %s readable', async (_label, registeredAs, namedAs) => { + const store = makeStore({ folderPath: registeredAs === 'alias' ? alias : project }) + const named = join(namedAs === 'alias' ? alias : project, 'notes.md') + + await authorizeExternalPathOutsideAllowedRoots(named, store) + + await expect(resolveAuthorizedPath(named, store)).resolves.toBe(join(project, 'notes.md')) + }) }) From 3046896b4ef5c36a27cb47c1ee2a3f34e2888143 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:12:41 -0700 Subject: [PATCH 05/19] fix(fs): read local files as regular files only, from one bounded handle Problem: fs:readFile stat'ed a path and then read it to EOF. A character device such as /dev/zero reports size 0, passes the size limit and never ends, so the main process buffers until memory runs out; a FIFO hangs the open. Writes could also target an existing device or FIFO. Fix: every local fs:readFile (editor and log snapshot) opens the path once, non-blocking, refuses anything but a regular file, and reads the size check, binary probe and content from that same handle, capped at the limit even if the file lies about its size. The AI Vault log tail opens non-blocking too, and fs:writeFile refuses an existing non-regular target. --- src/main/ai-vault/local-log-tail-reader.ts | 3 +- src/main/ipc/filesystem-test-harness.ts | 40 +++++++--- src/main/ipc/filesystem.test.ts | 45 ++++------- .../filesystem-file-content-inspection.ts | 78 ++++++++++++------- .../filesystem/filesystem-read-handlers.ts | 57 ++------------ .../filesystem/filesystem-write-handlers.ts | 2 + .../local-regular-file-read.test.ts | 78 +++++++++++++++++++ .../ipc/filesystem/local-regular-file-read.ts | 78 +++++++++++++++++++ 8 files changed, 266 insertions(+), 115 deletions(-) create mode 100644 src/main/ipc/filesystem/local-regular-file-read.test.ts create mode 100644 src/main/ipc/filesystem/local-regular-file-read.ts diff --git a/src/main/ai-vault/local-log-tail-reader.ts b/src/main/ai-vault/local-log-tail-reader.ts index 3045981e5c57..9044a64e8332 100644 --- a/src/main/ai-vault/local-log-tail-reader.ts +++ b/src/main/ai-vault/local-log-tail-reader.ts @@ -1,4 +1,5 @@ import { open } from 'node:fs/promises' +import { LOCAL_READ_OPEN_FLAGS } from '../ipc/filesystem/local-regular-file-read' import { LOCAL_LOG_TAIL_CHUNK_BYTES, type LocalLogTailReadResult @@ -23,7 +24,7 @@ export async function readLocalLogTailRange( throw new Error('Invalid local log tail byte offset') } - const handle = await open(filePath, 'r') + const handle = await open(filePath, LOCAL_READ_OPEN_FLAGS) try { const initialStats = await handle.stat() if (!initialStats.isFile()) { diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index a3a06b95e85e..6aabd4bf3740 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -237,6 +237,36 @@ const ALL_MOCKS = [ pullRequestLinkedIssueMock ].flatMap(collectMocks) +/** A FileHandle double over `content`: positional reads copy from it, and stat reports its size. */ +export function localFileHandleMock( + content: Buffer, + { isFile = true, size = content.byteLength }: { isFile?: boolean; size?: number } = {} +): Record { + return { + stat: vi.fn(async () => ({ + size, + isFile: () => isFile, + isDirectory: () => false, + mtimeMs: 123, + dev: 1, + ino: 2, + birthtimeMs: 3 + })), + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = content.copy( + buffer, + offset, + position, + Math.min(position + length, content.length) + ) + return { bytesRead, buffer } + }), + write: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + close: vi.fn() + } +} + /** Resets every filesystem IPC mock and reinstalls the defaults every suite starts from. */ export function resetFilesystemIpcMocks(): void { handlers.clear() @@ -272,14 +302,6 @@ export function resetFilesystemIpcMocks(): void { statMock.mockResolvedValue({ size: 10, isDirectory: () => false, mtimeMs: 123 }) renameMock.mockResolvedValue(undefined) rmMock.mockResolvedValue(undefined) - openMock.mockResolvedValue({ - read: vi.fn(async (buffer: Buffer) => { - buffer.fill(0x61) - return { bytesRead: buffer.length, buffer } - }), - write: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined), - close: vi.fn() - }) + openMock.mockResolvedValue(localFileHandleMock(Buffer.from('a'.repeat(10)))) lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 68946d56cff5..4c2ea4330e10 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -18,7 +18,8 @@ import { getSshFilesystemProviderMock, tryDeleteWslUncPathMock, recordCrashBreadcrumbMock, - resetFilesystemIpcMocks + resetFilesystemIpcMocks, + localFileHandleMock } from './filesystem-test-harness' vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) @@ -361,8 +362,7 @@ describe('registerFilesystemHandlers', () => { } ])('returns base64 content for supported $ext binaries', async ({ ext, mime, data }) => { const buf = Buffer.from(data) - statMock.mockResolvedValue({ size: buf.length, isDirectory: () => false, mtimeMs: 123 }) - readFileMock.mockResolvedValue(buf) + openMock.mockResolvedValue(localFileHandleMock(buf)) registerFilesystemHandlers(store as never) await expect( handlers.get('fs:readFile')!(null, { filePath: path.resolve(`/workspace/repo/file.${ext}`) }) @@ -376,8 +376,7 @@ describe('registerFilesystemHandlers', () => { it('opens text files larger than the old 5MB guard', async () => { const content = 'a'.repeat(6 * 1024 * 1024) - statMock.mockResolvedValue({ size: content.length, isDirectory: () => false, mtimeMs: 123 }) - readFileMock.mockResolvedValue(Buffer.from(content)) + openMock.mockResolvedValue(localFileHandleMock(Buffer.from(content))) registerFilesystemHandlers(store as never) @@ -391,17 +390,8 @@ describe('registerFilesystemHandlers', () => { it('returns stable byte metadata only for opted-in local log snapshots', async () => { const content = Buffer.from('first\npartial') - const close = vi.fn() - openMock.mockResolvedValue({ - stat: vi.fn().mockResolvedValue({ - size: content.byteLength, - dev: 1, - ino: 2, - birthtimeMs: 3 - }), - readFile: vi.fn().mockResolvedValue(content), - close - }) + const handle = localFileHandleMock(content) + openMock.mockResolvedValue(handle) registerFilesystemHandlers(store as never) await expect( @@ -414,12 +404,13 @@ describe('registerFilesystemHandlers', () => { isBinary: false, fileIdentity: '1:2:3' }) - expect(close).toHaveBeenCalledTimes(1) + expect(handle.close).toHaveBeenCalledTimes(1) expect(readFileMock).not.toHaveBeenCalled() }) it('rejects text files beyond the editor read budget', async () => { - statMock.mockResolvedValue({ size: 51 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 }) + const handle = localFileHandleMock(Buffer.alloc(0), { size: 51 * 1024 * 1024 }) + openMock.mockResolvedValue(handle) registerFilesystemHandlers(store as never) @@ -427,18 +418,13 @@ describe('registerFilesystemHandlers', () => { handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/huge.json') }) ).rejects.toThrow('exceeds 50MB limit') - expect(readFileMock).not.toHaveBeenCalled() + expect(handle.read).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalled() }) it('probes large unknown binaries without reading the full file', async () => { - statMock.mockResolvedValue({ size: 6 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 }) - openMock.mockResolvedValue({ - read: vi.fn(async (buffer: Buffer) => { - buffer[0] = 0x00 - return { bytesRead: 1, buffer } - }), - close: vi.fn() - }) + const handle = localFileHandleMock(Buffer.alloc(6 * 1024 * 1024)) + openMock.mockResolvedValue(handle) registerFilesystemHandlers(store as never) @@ -449,7 +435,7 @@ describe('registerFilesystemHandlers', () => { isBinary: true }) - expect(readFileMock).not.toHaveBeenCalled() + expect(handle.read).toHaveBeenCalledTimes(1) }) it('moves files to trash', async () => { @@ -519,8 +505,7 @@ describe('registerFilesystemHandlers', () => { }) it('keeps non-image binaries hidden from the editor payload', async () => { - statMock.mockResolvedValue({ size: 4, isDirectory: () => false, mtimeMs: 123 }) - readFileMock.mockResolvedValue(Buffer.from([0x00, 0x01, 0x02])) + openMock.mockResolvedValue(localFileHandleMock(Buffer.from([0x00, 0x01, 0x02]))) registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts index b140532c3727..ff406bae7eb9 100644 --- a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -1,6 +1,11 @@ -import { open } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' +import { extname } from 'node:path' import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' +import { + fileTooLargeError, + openLocalRegularFile, + readLocalFileBounded, + readLocalFilePrefix +} from './local-regular-file-read' // Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB @@ -19,25 +24,57 @@ export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.pdf': 'application/pdf' } -export async function readLocalLogSnapshot(filePath: string): Promise<{ +export type LocalFileContent = { content: string isBinary: boolean + isImage?: boolean + mimeType?: string fileIdentity?: string -}> { - const handle = await open(filePath, 'r') +} + +/** One open, one handle: the size check, binary probe and read all see the same regular file. */ +export async function readLocalFileContent(filePath: string): Promise { + const { handle, stats } = await openLocalRegularFile(filePath) try { - const stats = await handle.stat() - if (stats.size > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) + const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] + const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (stats.size > sizeLimit) { + throw fileTooLargeError(stats.size, sizeLimit) + } + if (mimeType) { + const buffer = await readLocalFileBounded(handle, sizeLimit) + return { + content: buffer.toString('base64'), + isBinary: true, + // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. + isImage: true, + mimeType + } + } + // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. + if ( + stats.size > BINARY_PROBE_BYTES && + isBinaryBuffer(await readLocalFilePrefix(handle, BINARY_PROBE_BYTES)) + ) { + return { content: '', isBinary: true } } - const buffer = await handle.readFile() - if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) + const buffer = await readLocalFileBounded(handle, sizeLimit) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } + } finally { + await handle.close() + } +} + +export async function readLocalLogSnapshot(filePath: string): Promise { + const { handle, stats } = await openLocalRegularFile(filePath) + try { + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw fileTooLargeError(stats.size, MAX_TEXT_FILE_SIZE) } + const buffer = await readLocalFileBounded(handle, MAX_TEXT_FILE_SIZE) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } @@ -62,17 +99,6 @@ export function isBinaryBuffer(buffer: Buffer): boolean { return false } -export async function isBinaryFilePrefix(filePath: string): Promise { - const handle: FileHandle = await open(filePath, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - return isBinaryBuffer(probe.subarray(0, bytesRead)) - } finally { - await handle.close() - } -} - export function isDirectoryEntry(entry: { isDirectory(): boolean isSymbolicLink(): boolean diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 1d9fe681fee6..15a8b5e19938 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -4,8 +4,7 @@ import { type PathExistenceResult } from '../../../shared/path-existence-batch' import { ipcMain } from 'electron' -import { readdir, readFile, stat } from 'node:fs/promises' -import { extname } from 'node:path' +import { readdir, stat } from 'node:fs/promises' import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entry-types' import { sortDirEntries } from '../../../shared/file-name-sort' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' @@ -18,14 +17,10 @@ import { recordCrashBreadcrumb } from '../../crash-reporting/crash-breadcrumb-st import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from '../readdir-error-diagnostics' import type { FilesystemHandlerContext } from './filesystem-handler-context' import { - BINARY_PROBE_BYTES, - isBinaryBuffer, - isBinaryFilePrefix, isDirectoryEntry, - MAX_PREVIEWABLE_BINARY_SIZE, - MAX_TEXT_FILE_SIZE, - PREVIEWABLE_BINARY_MIME_TYPES, - readLocalLogSnapshot + readLocalFileContent, + readLocalLogSnapshot, + type LocalFileContent } from './filesystem-file-content-inspection' export function registerFilesystemReadHandlers(context: FilesystemHandlerContext): void { @@ -72,51 +67,15 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext async ( _event, args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } - ): Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> => { + ): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.readFile(args.filePath) } const filePath = await resolveAuthorizedPath(args.filePath, store) - if (args.includeLocalLogMetadata === true) { - return readLocalLogSnapshot(filePath) - } - const stats = await stat(filePath) - const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - - if (mimeType) { - const buffer = await readFile(filePath) - return { - content: buffer.toString('base64'), - isBinary: true, - // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. - isImage: true, - mimeType - } - } - - // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. - if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { - return { content: '', isBinary: true } - } - - const buffer = await readFile(filePath) - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { content: buffer.toString('utf-8'), isBinary: false } + return args.includeLocalLogMetadata === true + ? readLocalLogSnapshot(filePath) + : readLocalFileContent(filePath) } ) diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index edb770321b3b..8ce423128c94 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -13,6 +13,7 @@ import { import { isENOENT } from '../filesystem-path-containment' import { registerFilesystemMutationHandlers } from '../filesystem-mutations' import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read' export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void { const { store } = context @@ -46,6 +47,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex throw error } } + await assertLocalWriteTargetIsRegularFile(filePath) await writeFile(filePath, args.content, 'utf-8') } ) diff --git a/src/main/ipc/filesystem/local-regular-file-read.test.ts b/src/main/ipc/filesystem/local-regular-file-read.test.ts new file mode 100644 index 000000000000..0913b8359740 --- /dev/null +++ b/src/main/ipc/filesystem/local-regular-file-read.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readLocalFileContent } from './filesystem-file-content-inspection' +import { + assertLocalWriteTargetIsRegularFile, + NOT_A_REGULAR_FILE_MESSAGE, + openLocalRegularFile, + readLocalFileBounded +} from './local-regular-file-read' + +let base: string + +beforeEach(async () => { + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-regular-file-read-')) +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +describe('local regular-file reads', () => { + it('reads a regular file', async () => { + const filePath = join(base, 'notes.txt') + await writeFile(filePath, 'hello\n') + + await expect(readLocalFileContent(filePath)).resolves.toEqual({ + content: 'hello\n', + isBinary: false + }) + }) + + it('refuses a directory', async () => { + await expect(readLocalFileContent(base)).rejects.toThrow() + }) + + it('caps a read at the limit even when the file is bigger than its handle claimed', async () => { + const filePath = join(base, 'big.txt') + await writeFile(filePath, 'a'.repeat(2048)) + const { handle } = await openLocalRegularFile(filePath) + try { + await expect(readLocalFileBounded(handle, 1024)).rejects.toThrow('File too large') + } finally { + await handle.close() + } + }) + + // Why: device files are POSIX-only. + describe.skipIf(process.platform === 'win32')('non-regular files', () => { + it.each(['/dev/zero', '/dev/urandom'])( + 'refuses %s before reading any of it', + async (device) => { + await expect(readLocalFileContent(device)).rejects.toThrow(NOT_A_REGULAR_FILE_MESSAGE) + } + ) + + it('refuses a symlink to a device', async () => { + const link = join(base, 'zero.png') + await symlink('/dev/zero', link) + + await expect(readLocalFileContent(link)).rejects.toThrow(NOT_A_REGULAR_FILE_MESSAGE) + }) + + it('refuses writing over a device but allows a regular or missing file', async () => { + const filePath = join(base, 'notes.txt') + await writeFile(filePath, 'x') + + await expect(assertLocalWriteTargetIsRegularFile('/dev/null')).rejects.toThrow( + NOT_A_REGULAR_FILE_MESSAGE + ) + await expect(assertLocalWriteTargetIsRegularFile(filePath)).resolves.toBeUndefined() + await expect( + assertLocalWriteTargetIsRegularFile(join(base, 'missing.txt')) + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/src/main/ipc/filesystem/local-regular-file-read.ts b/src/main/ipc/filesystem/local-regular-file-read.ts new file mode 100644 index 000000000000..b4adcacf7fd4 --- /dev/null +++ b/src/main/ipc/filesystem/local-regular-file-read.ts @@ -0,0 +1,78 @@ +import { constants, type Stats } from 'node:fs' +import { open, type FileHandle } from 'node:fs/promises' +import { isENOENT } from '../filesystem-path-containment' + +export const NOT_A_REGULAR_FILE_MESSAGE = 'Not a regular file' + +// Why O_NONBLOCK: opening a FIFO would otherwise wait for a writer forever; regular files ignore +// the flag and Windows has none. +export const LOCAL_READ_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) +const LOCAL_WRITE_PROBE_FLAGS = constants.O_WRONLY | (constants.O_NONBLOCK ?? 0) +const READ_CHUNK_BYTES = 1024 * 1024 + +export function fileTooLargeError(size: number, limit: number): Error { + return new Error( + `File too large: ${(size / 1024 / 1024).toFixed(1)}MB exceeds ${limit / 1024 / 1024}MB limit` + ) +} + +/** Opens a path for reading, refusing anything but a regular file (FIFO, device, socket, directory). */ +export async function openLocalRegularFile( + filePath: string +): Promise<{ handle: FileHandle; stats: Stats }> { + const handle = await open(filePath, LOCAL_READ_OPEN_FLAGS) + try { + const stats = await handle.stat() + if (!stats.isFile()) { + throw new Error(NOT_A_REGULAR_FILE_MESSAGE) + } + return { handle, stats } + } catch (error) { + await handle.close() + throw error + } +} + +/** Reads from the start of the handle; the cap holds even when a file reports a false size. */ +export async function readLocalFileBounded(handle: FileHandle, limit: number): Promise { + const chunks: Buffer[] = [] + let total = 0 + while (true) { + const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, limit + 1 - total)) + const { bytesRead } = await handle.read(chunk, 0, chunk.length, total) + if (bytesRead === 0) { + return Buffer.concat(chunks, total) + } + chunks.push(chunk.subarray(0, bytesRead)) + total += bytesRead + if (total > limit) { + throw fileTooLargeError(total, limit) + } + } +} + +export async function readLocalFilePrefix(handle: FileHandle, bytes: number): Promise { + const probe = Buffer.alloc(bytes) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return probe.subarray(0, bytesRead) +} + +/** Refuses writing over an existing non-regular file, e.g. a device or FIFO. */ +export async function assertLocalWriteTargetIsRegularFile(filePath: string): Promise { + let handle: FileHandle + try { + handle = await open(filePath, LOCAL_WRITE_PROBE_FLAGS) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + try { + if (!(await handle.stat()).isFile()) { + throw new Error(NOT_A_REGULAR_FILE_MESSAGE) + } + } finally { + await handle.close() + } +} From 8c064cbe72ff8ce22f6c01ac43f1d88e1ad76018 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:26:09 -0700 Subject: [PATCH 06/19] feat(fs): let desktop file requests declare their shape Problem: main decided every local file request against one allow-list plus a set of in-memory grants the renderer had to recreate after every restart, so a file the user opened outside a project (for example from the floating workspace) was denied once Orca restarted. This adds the request shape the common pattern uses, alongside the grants for now: - no shape (the default): the path must be inside a project root main recognises, symlinks included. Desktop requests also accept the app-owned floating-workspace folder; paired-client RPC never does. - user-file: a single file the user named by absolute path, used in place. Only fs:readFile/stat/pathExists and saving (fs:writeFile) accept it. - document-resource: an image or PDF a document references, limited to every project root when the document is in one, else to the document's folder, and refused by path text before any disk or network access. Notebook kernels and AI Vault log tails check their open file as user-named. --- src/main/ipc/filesystem-auth.ts | 11 +- src/main/ipc/filesystem-import-ssh.test.ts | 5 +- src/main/ipc/filesystem-import.test.ts | 1 + src/main/ipc/filesystem-mutations.test.ts | 1 + src/main/ipc/filesystem-mutations.ts | 20 +- .../filesystem-provider-continuations.test.ts | 15 +- ...esystem-request-shape-network-path.test.ts | 94 ++++++ src/main/ipc/filesystem-request-shape.test.ts | 280 ++++++++++++++++++ src/main/ipc/filesystem-request-shape.ts | 138 +++++++++ .../ipc/filesystem-search-rg-timeout.test.ts | 1 + src/main/ipc/filesystem-test-harness.ts | 1 + src/main/ipc/filesystem.ts | 2 +- .../filesystem/filesystem-read-handlers.ts | 30 +- .../filesystem/filesystem-search-handlers.ts | 4 +- .../filesystem/filesystem-write-handlers.ts | 20 +- src/main/ipc/floating-workspace-directory.ts | 7 +- src/main/ipc/local-log-tail-lifetime.test.ts | 5 +- src/main/ipc/local-log-tail.test.ts | 8 +- src/main/ipc/local-log-tail.ts | 17 +- src/main/ipc/notebook.test.ts | 31 +- src/main/ipc/notebook.ts | 15 +- .../window/clipboard-ipc-handlers.test.ts | 2 +- src/main/window/clipboard-ipc-handlers.ts | 5 +- src/preload/api/filesystem-api.ts | 10 +- src/preload/api/fs-bridge.ts | 11 +- src/shared/local-file-access.ts | 9 + 26 files changed, 680 insertions(+), 63 deletions(-) create mode 100644 src/main/ipc/filesystem-request-shape-network-path.test.ts create mode 100644 src/main/ipc/filesystem-request-shape.test.ts create mode 100644 src/main/ipc/filesystem-request-shape.ts create mode 100644 src/shared/local-file-access.ts diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 7105386efe94..93335424d2b6 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -99,9 +99,12 @@ export async function authorizeExternalPathOutsideAllowedRoots( */ type AllowedRootsSnapshot = { get: () => readonly string[] } -function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot { +function createAllowedRootsSnapshot( + store: Store, + extraRoots: readonly string[] = [] +): AllowedRootsSnapshot { let roots: readonly string[] | undefined - return { get: () => (roots ??= getAllowedRoots(store)) } + return { get: () => (roots ??= [...getAllowedRoots(store), ...extraRoots]) } } export function isPathAllowed( @@ -128,6 +131,8 @@ export type ResolveAuthorizedPathOptions = { * Canonicalize the parent but preserve the leaf so delete/rename target the symlink itself, not its destination (which may live outside allowed roots). */ preserveSymlink?: boolean + /** Roots only the desktop window may use (never runtime RPC), checked like any other root. */ + extraRoots?: readonly string[] } export async function resolveAuthorizedPath( @@ -138,7 +143,7 @@ export async function resolveAuthorizedPath( const resolvedTarget = resolve(targetPath) // Why: the roots depend only on store state, not on the candidate path, so one snapshot serves // every authorization below; each candidate is still checked against it in full. - const allowedRoots = createAllowedRootsSnapshot(store) + const allowedRoots = createAllowedRootsSnapshot(store, options.extraRoots) if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index 6a5d502b14c7..3c18eea6981c 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -27,7 +27,10 @@ const { getConnMgrMock: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, + ipcMain: { handle: handleMock } +})) vi.mock('fs/promises', () => ({ lstat: lstatMock, mkdir: mkdirMock, diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index 8047c19f62d9..c2df10fd70b1 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -29,6 +29,7 @@ const { })) vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, ipcMain: { handle: handleMock } })) diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 18d05ab5c96c..36db8630dc59 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -14,6 +14,7 @@ const { handleMock, copyFileMock, lstatMock, mkdirMock, renameMock, writeFileMoc })) vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, ipcMain: { handle: handleMock } })) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index ad1308f0c307..753c2fcd5518 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -3,7 +3,7 @@ import { constants } from 'node:fs' import { copyFile, mkdir, writeFile } from 'node:fs/promises' import { basename, dirname } from 'node:path' import type { Store } from '../persistence' -import { resolveAuthorizedPath } from './filesystem-auth' +import { resolveDesktopAuthorizedPath } from './filesystem-request-shape' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' import { importExternalPathsSsh } from './filesystem-import-ssh' @@ -49,7 +49,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { const provider = requireSshFilesystemProvider(args.connectionId) return provider.createFile(args.filePath) } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveDesktopAuthorizedPath(args.filePath, store) await mkdir(dirname(filePath), { recursive: true }) try { // Use the 'wx' flag for atomic create-if-not-exists, avoiding TOCTOU races @@ -76,7 +76,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { const provider = requireSshFilesystemProvider(args.connectionId) return provider.createDir(args.dirPath) } - const dirPath = await resolveAuthorizedPath(args.dirPath, store) + const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store) await assertNotExists(dirPath) await mkdir(dirPath, { recursive: true }) } @@ -107,8 +107,12 @@ export function registerFilesystemMutationHandlers(store: Store): void { // target file (potentially elsewhere in the worktree) and leave the // symlink dangling. newPath must also preserve its leaf so we don't // accidentally write into a symlinked destination name. - const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true }) - const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true }) + const oldPath = await resolveDesktopAuthorizedPath(args.oldPath, store, { + preserveSymlink: true + }) + const newPath = await resolveDesktopAuthorizedPath(args.newPath, store, { + preserveSymlink: true + }) await renameLocalPathSerializedByDestination(oldPath, newPath) } ) @@ -133,10 +137,10 @@ export function registerFilesystemMutationHandlers(store: Store): void { const provider = requireSshFilesystemProvider(args.connectionId) return provider.copy(args.sourcePath, args.destinationPath) } - const sourcePath = await resolveAuthorizedPath(args.sourcePath, store, { + const sourcePath = await resolveDesktopAuthorizedPath(args.sourcePath, store, { preserveSymlink: true }) - const destinationPath = await resolveAuthorizedPath(args.destinationPath, store, { + const destinationPath = await resolveDesktopAuthorizedPath(args.destinationPath, store, { preserveSymlink: true }) await mkdir(dirname(destinationPath), { recursive: true }) @@ -180,7 +184,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { // destination is outside allowed roots, the entire import fails. // This only applies to local imports — remote paths are authorized by // the SSH connection boundary (see importExternalPathsSsh). - const resolvedDest = await resolveAuthorizedPath(args.destDir, store) + const resolvedDest = await resolveDesktopAuthorizedPath(args.destDir, store) const results: ImportItemResult[] = [] const reservedNames = new Set() diff --git a/src/main/ipc/filesystem-provider-continuations.test.ts b/src/main/ipc/filesystem-provider-continuations.test.ts index c585d7ac6f67..04abc927dd27 100644 --- a/src/main/ipc/filesystem-provider-continuations.test.ts +++ b/src/main/ipc/filesystem-provider-continuations.test.ts @@ -22,8 +22,19 @@ const mocks = vi.hoisted(() => ({ resolveAuthorizedPath: vi.fn(), tryDeleteWslUncPath: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle }, shell: mocks })) -vi.mock('node:fs/promises', () => ({ lstat: mocks.lstat, writeFile: mocks.writeFile })) +vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, + ipcMain: { handle: mocks.handle }, + shell: mocks +})) +vi.mock('node:fs/promises', () => ({ + lstat: mocks.lstat, + writeFile: mocks.writeFile, + // Why: the write target doesn't exist yet, so the regular-file probe has nothing to refuse. + open: vi.fn(async () => { + throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }) + }) +})) vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: vi.fn(), resolveAuthorizedPath: mocks.resolveAuthorizedPath diff --git a/src/main/ipc/filesystem-request-shape-network-path.test.ts b/src/main/ipc/filesystem-request-shape-network-path.test.ts new file mode 100644 index 000000000000..2746c2651e7d --- /dev/null +++ b/src/main/ipc/filesystem-request-shape-network-path.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import type * as NodePath from 'node:path' +import type { Store } from '../persistence' + +// Why win32 paths on every host: a UNC image in a document is a Windows credential leak, and the +// guarantee is that its path text is refused before any filesystem call can reach the network. +vi.mock('node:path', async () => { + const actual = await vi.importActual('node:path') + return { ...actual.win32, default: actual.win32 } +}) + +const { fsCalls } = vi.hoisted(() => ({ fsCalls: [] as string[] })) + +vi.mock('node:fs/promises', () => { + const record = (name: string) => + vi.fn(async (target: unknown) => { + fsCalls.push(`${name} ${String(target)}`) + return name === 'realpath' ? target : { isFile: () => true, isDirectory: () => false } + }) + return { realpath: record('realpath'), stat: record('stat'), open: record('open') } +}) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + statSync: vi.fn((target: unknown) => { + fsCalls.push(`statSync ${String(target)}`) + throw new Error('statSync is not expected') + }) + } +}) +vi.mock('electron', () => ({ app: { getPath: () => 'C:\\Users\\me\\AppData\\Roaming\\Orca' } })) +vi.mock('../repo-worktrees', () => ({ listRepoWorktreeGraph: vi.fn(async () => []) })) + +import { resolveLocalFileRequestPath } from './filesystem-request-shape' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. +const store = { + getRepos: () => [ + { id: 'repo', path: 'C:\\repo', displayName: 'repo', badgeColor: '#000', addedAt: 0 } + ], + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) +} as unknown as Store + +const networkTargets = [ + '\\\\attacker.example\\share\\x.png', + '//attacker.example/share/x.png', + '\\\\?\\UNC\\attacker.example\\share\\x.png' +] + +describe('document images on a network share', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + it.each(networkTargets)( + 'refuses %s from a project document without touching it', + async (target) => { + await expect( + resolveLocalFileRequestPath( + target, + { kind: 'document-resource', documentPath: 'C:\\repo\\README.md' }, + store + ) + ).rejects.toThrow('Access denied') + expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) + } + ) + + it.each(networkTargets)( + 'refuses %s from a document outside every project without touching it', + async (target) => { + await expect( + resolveLocalFileRequestPath( + target, + { kind: 'document-resource', documentPath: 'C:\\Users\\me\\notes\\todo.md' }, + store + ) + ).rejects.toThrow('Access denied') + expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) + } + ) + + it('refuses it with no shape too', async () => { + await expect(resolveLocalFileRequestPath(networkTargets[0], undefined, store)).rejects.toThrow( + 'Access denied' + ) + expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) + }) +}) diff --git a/src/main/ipc/filesystem-request-shape.test.ts b/src/main/ipc/filesystem-request-shape.test.ts new file mode 100644 index 000000000000..f5729fc472ac --- /dev/null +++ b/src/main/ipc/filesystem-request-shape.test.ts @@ -0,0 +1,280 @@ +import { mkdir, mkdtemp, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as RepoWorktrees from '../repo-worktrees' +import { resolveAuthorizedPath } from './filesystem-auth' +import { + resolveDesktopAuthorizedPath, + resolveLocalFileRequestPath, + resolveLocalWriteRequestPath +} from './filesystem-request-shape' +import { readLocalFileContent } from './filesystem/filesystem-file-content-inspection' +import { + assertLocalWriteTargetIsRegularFile, + NOT_A_REGULAR_FILE_MESSAGE +} from './filesystem/local-regular-file-read' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' + +const { userData } = vi.hoisted(() => ({ userData: { path: '' } })) + +vi.mock('electron', () => ({ app: { getPath: () => userData.path } })) +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } +}) + +type Registration = { repoPaths?: string[]; folderPath?: string } + +function makeStore({ repoPaths = [], folderPath }: Registration): Store { + const repos = repoPaths.map((path, index) => ({ + id: `repo-${index}`, + path, + displayName: 'project', + badgeColor: '#000', + addedAt: 0 + })) + const folders = folderPath ? [{ id: 'folder', folderPath, projectGroupId: 'none' }] : [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. + return { + getRepos: () => repos.map((repo) => ({ ...repo })), + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => folders.map((folder) => ({ ...folder })), + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) + } as unknown as Store +} + +const USER_FILE = { kind: 'user-file' } as const +const documentResource = (documentPath: string) => + ({ kind: 'document-resource', documentPath }) as const + +async function settles(promise: Promise): Promise<'ok' | 'denied'> { + return promise.then( + () => 'ok', + () => 'denied' + ) +} + +let base: string +let project: string +let outside: string + +beforeEach(async () => { + invalidateAuthorizedRootsCache() + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-request-shape-')) + project = join(base, 'project') + outside = join(base, 'outside') + userData.path = join(base, 'user-data') + await mkdir(project) + await mkdir(outside) + await mkdir(join(userData.path, 'floating-workspace'), { recursive: true }) + await writeFile(join(outside, 'notes.txt'), 'outside notes\n') +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +describe('user-file requests', () => { + it('read a regular file outside every project in place', async () => { + const store = makeStore({}) + const filePath = await resolveLocalFileRequestPath(join(outside, 'notes.txt'), USER_FILE, store) + + await expect(readLocalFileContent(filePath)).resolves.toEqual({ + content: 'outside notes\n', + isBinary: false + }) + }) + + it.each(['notes.txt', 'C:notes.txt'])('refuse the non-absolute path %s', async (relative) => { + await expect(resolveLocalFileRequestPath(relative, USER_FILE, makeStore({}))).rejects.toThrow( + 'absolute path' + ) + }) + + it('stat a directory but never read one', async () => { + const dirPath = await resolveLocalFileRequestPath(outside, USER_FILE, makeStore({})) + + expect((await stat(dirPath)).isDirectory()).toBe(true) + await expect(readLocalFileContent(dirPath)).rejects.toThrow() + }) + + it('save an outside file, but never onto a device', async () => { + const store = makeStore({}) + const filePath = join(outside, 'notes.txt') + + await expect(resolveLocalWriteRequestPath(filePath, USER_FILE, store)).resolves.toBe(filePath) + await expect(resolveLocalWriteRequestPath(filePath, undefined, store)).rejects.toThrow( + 'Access denied' + ) + if (process.platform !== 'win32') { + await expect(assertLocalWriteTargetIsRegularFile('/dev/null')).rejects.toThrow( + NOT_A_REGULAR_FILE_MESSAGE + ) + } + }) + + it.skipIf(process.platform === 'win32')('refuse /dev/zero promptly', async () => { + const filePath = await resolveLocalFileRequestPath('/dev/zero', USER_FILE, makeStore({})) + + await expect(readLocalFileContent(filePath)).rejects.toThrow(NOT_A_REGULAR_FILE_MESSAGE) + }) +}) + +describe('requests with no shape (roots only)', () => { + it('refuse a file outside every project, even one a user-file request may read', async () => { + const store = makeStore({ repoPaths: [project] }) + + await expect( + resolveLocalFileRequestPath(join(outside, 'notes.txt'), undefined, store) + ).rejects.toThrow('Access denied') + await expect( + resolveLocalFileRequestPath(join(outside, 'notes.txt'), { kind: 'grant-everything' }, store) + ).rejects.toThrow('Access denied') + }) + + it('allow the app-owned floating-workspace folder on the desktop only', async () => { + const store = makeStore({ repoPaths: [project] }) + const untitled = join(userData.path, 'floating-workspace', 'untitled.md') + + await expect(resolveDesktopAuthorizedPath(untitled, store)).resolves.toBe(untitled) + await expect(resolveAuthorizedPath(untitled, store)).rejects.toThrow('Access denied') + }) + + it('refuse the home folder, which the floating workspace starts in', async () => { + await expect( + resolveDesktopAuthorizedPath(homedir(), makeStore({ repoPaths: [project] })) + ).rejects.toThrow('Access denied') + }) +}) + +// Why: creating a symlink on Windows needs elevation or Developer Mode. +describe.skipIf(process.platform === 'win32')('project symlinks under every spelling', () => { + let real: string + let alias: string + let alias2: string + let privateBase: string + let varAlias: string + let secret: string + + beforeEach(async () => { + // Mirrors macOS /var -> /private/var: an alias of an ancestor above the project root. + privateBase = join(base, 'private') + varAlias = join(base, 'var') + real = join(privateBase, 'real', 'project') + alias = join(base, 'alias-project') + alias2 = join(base, 'alias2-project') + await mkdir(join(real, 'sub'), { recursive: true }) + await symlink(privateBase, varAlias) + await symlink(real, alias) + await symlink(real, alias2) + secret = join(outside, 'secret.txt') + await writeFile(secret, 'secret\n') + await writeFile(join(real, 'notes.md'), 'notes\n') + await symlink(secret, join(real, 'escape.txt')) + await symlink(outside, join(real, 'dir-link')) + await symlink(outside, join(real, 'sub', 'deep-link')) + }) + + const viaDirLink = (root: string): string => join(root, 'dir-link', 'secret.txt') + + it.each<[string, () => Registration, () => string]>([ + ['a leaf symlink', () => ({ repoPaths: [real] }), () => join(real, 'escape.txt')], + ['a directory symlink', () => ({ repoPaths: [real] }), () => viaDirLink(real)], + ['a directory symlink via an alias', () => ({ repoPaths: [real] }), () => viaDirLink(alias)], + [ + 'a directory symlink, registered and named via two aliases', + () => ({ repoPaths: [alias] }), + () => viaDirLink(alias2) + ], + [ + 'a directory symlink, registered via alias and named canonically', + () => ({ folderPath: alias }), + () => viaDirLink(real) + ], + [ + 'a deep directory symlink in a folder workspace', + () => ({ folderPath: real }), + () => join(alias, 'sub', 'deep-link', 'secret.txt') + ], + [ + 'a directory symlink named with `..`', + () => ({ repoPaths: [real] }), + () => join(alias, 'sub', '..', 'dir-link', 'secret.txt') + ], + [ + 'a directory symlink via an ancestor alias', + () => ({ repoPaths: [real] }), + () => viaDirLink(real.replace(privateBase, varAlias)) + ], + [ + 'a leaf symlink via an ancestor alias', + () => ({ repoPaths: [real] }), + () => join(real.replace(privateBase, varAlias), 'escape.txt') + ] + ])('never read or write through %s out of the project', async (_label, register, named) => { + const store = makeStore(register()) + + expect(await settles(resolveLocalFileRequestPath(named(), undefined, store))).toBe('denied') + expect(await settles(resolveLocalWriteRequestPath(named(), undefined, store))).toBe('denied') + expect(await settles(resolveLocalFileRequestPath(secret, undefined, store))).toBe('denied') + }) +}) + +describe('document-resource requests', () => { + let otherProject: string + + beforeEach(async () => { + otherProject = join(base, 'other-project') + await mkdir(join(project, 'docs'), { recursive: true }) + await mkdir(otherProject) + await writeFile(join(project, 'docs', 'README.md'), '# doc\n') + await writeFile(join(project, 'logo.png'), 'png') + await writeFile(join(otherProject, 'shared.png'), 'png') + await writeFile(join(project, 'notes.txt'), 'text') + await writeFile(join(outside, 'outside.png'), 'png') + await mkdir(join(outside, 'doc-folder', 'img'), { recursive: true }) + await writeFile(join(outside, 'doc-folder', 'note.md'), '# note\n') + await writeFile(join(outside, 'doc-folder', 'img', 'nested.png'), 'png') + await writeFile(join(outside, 'doc-folder', 'sibling.png'), 'png') + }) + + it('limit a project document to every project root, images and PDFs only', async () => { + const store = makeStore({ repoPaths: [project, otherProject] }) + const access = documentResource(join(project, 'docs', 'README.md')) + const outcome = (path: string) => settles(resolveLocalFileRequestPath(path, access, store)) + + expect(await outcome(join(project, 'logo.png'))).toBe('ok') + expect(await outcome(join(otherProject, 'shared.png'))).toBe('ok') + expect(await outcome(join(outside, 'outside.png'))).toBe('denied') + expect(await outcome(join(project, 'notes.txt'))).toBe('denied') + }) + + it('limit a document outside every project to its own folder', async () => { + const store = makeStore({ repoPaths: [project] }) + const access = documentResource(join(outside, 'doc-folder', 'note.md')) + const outcome = (path: string) => settles(resolveLocalFileRequestPath(path, access, store)) + + expect(await outcome(join(outside, 'doc-folder', 'sibling.png'))).toBe('ok') + expect(await outcome(join(outside, 'doc-folder', 'img', 'nested.png'))).toBe('ok') + expect(await outcome(join(outside, 'outside.png'))).toBe('denied') + expect(await outcome('/dev/zero')).toBe('denied') + }) + + it.skipIf(process.platform === 'win32')( + 'refuse a symlink in the document folder that leads out of it', + async () => { + const store = makeStore({}) + await symlink(join(outside, 'outside.png'), join(outside, 'doc-folder', 'escape.png')) + const access = documentResource(join(outside, 'doc-folder', 'note.md')) + + expect( + await settles( + resolveLocalFileRequestPath(join(outside, 'doc-folder', 'escape.png'), access, store) + ) + ).toBe('denied') + } + ) +}) diff --git a/src/main/ipc/filesystem-request-shape.ts b/src/main/ipc/filesystem-request-shape.ts new file mode 100644 index 000000000000..8806b49dc177 --- /dev/null +++ b/src/main/ipc/filesystem-request-shape.ts @@ -0,0 +1,138 @@ +import { dirname, extname, isAbsolute, resolve } from 'node:path' +import { realpath, stat } from 'node:fs/promises' +import type { Store } from '../persistence' +import type { LocalFileAccess } from '../../shared/local-file-access' +import { + PATH_ACCESS_DENIED_MESSAGE, + resolveAuthorizedPath, + type ResolveAuthorizedPathOptions +} from './filesystem-auth' +import { isDescendantOrEqual } from './filesystem-path-containment' +import { PREVIEWABLE_BINARY_MIME_TYPES } from './filesystem/filesystem-file-content-inspection' +import { getDefaultFloatingWorkspacePath } from './floating-workspace-directory' +import { NOT_A_REGULAR_FILE_MESSAGE } from './filesystem/local-regular-file-read' + +export const USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE = + 'Access denied: a file opened by name needs an absolute path.' +export const DOCUMENT_RESOURCE_TYPE_MESSAGE = + 'Access denied: a document can only load images and PDFs it references.' + +/** Desktop IPC's root check: the project roots plus the app-owned floating-workspace folder. */ +export function resolveDesktopAuthorizedPath( + targetPath: string, + store: Store, + options: ResolveAuthorizedPathOptions = {} +): Promise { + return resolveAuthorizedPath(targetPath, store, { + ...options, + extraRoots: [getDefaultFloatingWorkspacePath()] + }) +} + +/** A file the user named is used where it is; no root applies, and nothing is remembered. */ +export function resolveUserNamedLocalPath(targetPath: string): string { + // Why isAbsolute on the raw input: resolve() would anchor `notes.txt` or `C:notes` to main's cwd. + if (typeof targetPath !== 'string' || !isAbsolute(targetPath)) { + throw new Error(USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE) + } + return resolve(targetPath) +} + +/** A user-named path that must be an existing regular file, e.g. a notebook or a log being tailed. */ +export async function resolveUserNamedRegularFile(targetPath: string): Promise { + const filePath = resolveUserNamedLocalPath(targetPath) + if (!(await stat(filePath)).isFile()) { + throw new Error(NOT_A_REGULAR_FILE_MESSAGE) + } + return filePath +} + +async function isInsideDesktopRoots(documentPath: string, store: Store): Promise { + try { + await resolveDesktopAuthorizedPath(documentPath, store) + return true + } catch { + return false + } +} + +/** + * An image or PDF a document references: limited to every project root when the document is in + * one, else to the document's own folder. Out-of-scope targets are refused by path text before any + * disk or network access, so a referenced network share is never contacted. + */ +export async function resolveDocumentResourcePath( + targetPath: string, + documentPath: string, + store: Store +): Promise { + if ( + typeof targetPath !== 'string' || + !isAbsolute(targetPath) || + typeof documentPath !== 'string' || + !isAbsolute(documentPath) + ) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const resolvedTarget = resolve(targetPath) + if (!PREVIEWABLE_BINARY_MIME_TYPES[extname(resolvedTarget).toLowerCase()]) { + throw new Error(DOCUMENT_RESOURCE_TYPE_MESSAGE) + } + if (await isInsideDesktopRoots(documentPath, store)) { + return resolveDesktopAuthorizedPath(resolvedTarget, store) + } + const documentFolder = dirname(resolve(documentPath)) + if (!isDescendantOrEqual(resolvedTarget, documentFolder)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const realTarget = resolve(await realpath(resolvedTarget)) + if (!isDescendantOrEqual(realTarget, resolve(await realpath(documentFolder)))) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + return realTarget +} + +// Why parse: IPC input is untyped, and an unrecognised shape must fall back to roots only. +function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined { + if (typeof access !== 'object' || access === null || !('kind' in access)) { + return undefined + } + if (access.kind === 'user-file') { + return { kind: 'user-file' } + } + if ( + access.kind === 'document-resource' && + 'documentPath' in access && + typeof access.documentPath === 'string' + ) { + return { kind: 'document-resource', documentPath: access.documentPath } + } + return undefined +} + +/** Resolves a desktop read/stat request by its declared shape; no shape means roots only. */ +export async function resolveLocalFileRequestPath( + targetPath: string, + access: unknown, + store: Store +): Promise { + const shape = parseLocalFileAccess(access) + if (shape?.kind === 'user-file') { + return resolveUserNamedLocalPath(targetPath) + } + if (shape?.kind === 'document-resource') { + return resolveDocumentResourcePath(targetPath, shape.documentPath, store) + } + return resolveDesktopAuthorizedPath(targetPath, store) +} + +/** Writes accept only the user-file shape: saving the open file the user named. */ +export async function resolveLocalWriteRequestPath( + targetPath: string, + access: unknown, + store: Store +): Promise { + return parseLocalFileAccess(access)?.kind === 'user-file' + ? resolveUserNamedLocalPath(targetPath) + : resolveDesktopAuthorizedPath(targetPath, store) +} diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 9c37bacb20a7..fe8dcda09ba6 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -24,6 +24,7 @@ const { const handlers = new Map unknown>() vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, ipcMain: { handle: handleMock }, diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index 6aabd4bf3740..069f4c10d839 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -60,6 +60,7 @@ export const recordCrashBreadcrumbMock: IpcMock = vi.fn() export const promoteLocalDownloadedFolderMock: IpcMock = vi.fn() export const electronMock = { + app: { getPath: () => '/orca-test-user-data' }, BrowserWindow: { fromWebContents: fromWebContentsMock }, dialog: { showSaveDialog: showSaveDialogMock, showOpenDialog: showOpenDialogMock }, ipcMain: { handle: handleMock }, diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index e517a834d5e3..86aca9a7a410 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -44,5 +44,5 @@ export function registerFilesystemHandlers( registerFilesystemGitDiffHandlers(context) registerFilesystemGitIndexHandlers(context) registerFilesystemGitUrlHandlers(context) - registerLocalLogTailHandlers(store) + registerLocalLogTailHandlers() } diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 15a8b5e19938..5c14af1673c2 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -9,7 +9,11 @@ import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entr import { sortDirEntries } from '../../../shared/file-name-sort' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' -import { resolveAuthorizedPath } from '../filesystem-auth' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import { + resolveDesktopAuthorizedPath, + resolveLocalFileRequestPath +} from '../filesystem-request-shape' import { isENOENT } from '../filesystem-path-containment' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' @@ -38,7 +42,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext // Why: re-sort locally — the remote relay may be an older build with lexicographic ordering. return sortDirEntries(await provider.readDir(args.dirPath)) } - const dirPath = await resolveAuthorizedPath(args.dirPath, store) + const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store) throwSite = 'readdir' const entries = await readdir(dirPath, { withFileTypes: true }) const mapped = entries.map((entry) => ({ @@ -66,13 +70,18 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext 'fs:readFile', async ( _event, - args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } + args: { + filePath: string + connectionId?: string + includeLocalLogMetadata?: boolean + access?: LocalFileAccess + } ): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.readFile(args.filePath) } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) return args.includeLocalLogMetadata === true ? readLocalLogSnapshot(filePath) : readLocalFileContent(filePath) @@ -102,14 +111,14 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext 'fs:stat', async ( _event, - args: { filePath: string; connectionId?: string } + args: { filePath: string; connectionId?: string; access?: LocalFileAccess } ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) const result = await provider.stat(args.filePath) return { size: result.size, isDirectory: result.type === 'directory', mtime: result.mtime } } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) const stats = await stat(filePath) return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } } @@ -132,7 +141,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext try { await (provider ? provider.stat(filePath) - : stat(await resolveAuthorizedPath(filePath, store))) + : stat(await resolveDesktopAuthorizedPath(filePath, store))) return true } catch (error) { if (isENOENT(error)) { @@ -148,14 +157,17 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext ipcMain.handle( 'fs:pathExists', - async (_event, args: { filePath: string; connectionId?: string }): Promise => { + async ( + _event, + args: { filePath: string; connectionId?: string; access?: LocalFileAccess } + ): Promise => { try { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) await provider.stat(args.filePath) return true } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) await stat(filePath) return true } catch (error) { diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index 9212200e151f..908ded3d9c36 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -25,7 +25,7 @@ import { getSshFilesystemProvider, requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { resolveAuthorizedPath } from '../filesystem-auth' +import { resolveDesktopAuthorizedPath } from '../filesystem-request-shape' import { listQuickOpenFiles } from '../filesystem-list-files' import { isFileNameFilterQueryTooLarge, @@ -51,7 +51,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte const provider = requireSshFilesystemProvider(args.connectionId) return provider.search(args) } - const rootPath = await resolveAuthorizedPath(args.rootPath, store) + const rootPath = await resolveDesktopAuthorizedPath(args.rootPath, store) const localGitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.rootPath, diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 8ce423128c94..c02debba2ef5 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -5,11 +5,12 @@ import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generatio import { runSshProviderContinuation } from '../../ssh/ssh-provider-continuations' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import { authorizeExternalPath, authorizeExternalPathOutsideAllowedRoots } from '../filesystem-auth' import { - authorizeExternalPath, - authorizeExternalPathOutsideAllowedRoots, - resolveAuthorizedPath -} from '../filesystem-auth' + resolveDesktopAuthorizedPath, + resolveLocalWriteRequestPath +} from '../filesystem-request-shape' import { isENOENT } from '../filesystem-path-containment' import { registerFilesystemMutationHandlers } from '../filesystem-mutations' import type { FilesystemHandlerContext } from './filesystem-handler-context' @@ -22,7 +23,12 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex 'fs:writeFile', async ( _event, - args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation + args: { + filePath: string + content: string + connectionId?: string + access?: LocalFileAccess + } & SshMutationExpectation ): Promise => { assertSshMutationExpectation( args.connectionId, @@ -36,7 +42,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex provider.writeFile(args.filePath, args.content) ) } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveLocalWriteRequestPath(args.filePath, args.access, store) try { const fileStats = await lstat(filePath) if (fileStats.isDirectory()) { @@ -75,7 +81,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) } // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). - const targetPath = await resolveAuthorizedPath(args.targetPath, store, { + const targetPath = await resolveDesktopAuthorizedPath(args.targetPath, store, { preserveSymlink: true }) // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index 9e5f7d34cb74..9e74ef1a78ef 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -68,8 +68,13 @@ function isTrustedFloatingWorkspaceDirectory( return getTrustedFloatingWorkspaceDirectories(settings).has(path.resolve(canonicalDirPath)) } +/** The app-owned folder floating markdown documents are created in; a desktop-only root. */ +export function getDefaultFloatingWorkspacePath(): string { + return path.join(app.getPath('userData'), FLOATING_WORKSPACE_DIRNAME) +} + export async function ensureDefaultFloatingWorkspacePath(): Promise { - const cwd = path.join(app.getPath('userData'), FLOATING_WORKSPACE_DIRNAME) + const cwd = getDefaultFloatingWorkspacePath() await mkdir(cwd, { recursive: true }) // Why: the default floating workspace lives outside repo roots by design; // authorize only this app-owned directory instead of widening access to ~. diff --git a/src/main/ipc/local-log-tail-lifetime.test.ts b/src/main/ipc/local-log-tail-lifetime.test.ts index 4abf283b9ecf..a2dd78042a9d 100644 --- a/src/main/ipc/local-log-tail-lifetime.test.ts +++ b/src/main/ipc/local-log-tail-lifetime.test.ts @@ -13,7 +13,7 @@ vi.mock('electron', () => ({ handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler) } })) -vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: authorize })) +vi.mock('./filesystem-request-shape', () => ({ resolveUserNamedRegularFile: authorize })) import { closeAllLocalLogTailWatchers, getActiveLocalLogTailWatcherCount, @@ -41,8 +41,7 @@ beforeEach(async () => { filePath = join(directory, 'fixture.log') await writeFile(filePath, 'test\n') authorize.mockReset().mockResolvedValue(filePath) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization is mocked; the handler never reads Store in this isolated fixture. - registerLocalLogTailHandlers({} as never) + registerLocalLogTailHandlers() }) afterEach(async () => { closeAllLocalLogTailWatchers() diff --git a/src/main/ipc/local-log-tail.test.ts b/src/main/ipc/local-log-tail.test.ts index 5f7f46ab0f52..e0b84376264b 100644 --- a/src/main/ipc/local-log-tail.test.ts +++ b/src/main/ipc/local-log-tail.test.ts @@ -18,7 +18,9 @@ vi.mock('electron', () => ({ vi.mock('node:fs', () => ({ watch: watchMock })) -vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock })) +vi.mock('./filesystem-request-shape', () => ({ + resolveUserNamedRegularFile: resolveAuthorizedPathMock +})) vi.mock('../ai-vault/local-log-tail-reader', () => ({ readLocalLogTailRange: readRangeMock @@ -64,7 +66,7 @@ beforeEach(() => { watchMock.mockReset() resolveAuthorizedPathMock.mockReset().mockImplementation(async (path: string) => path) readRangeMock.mockReset() - registerLocalLogTailHandlers({} as never) + registerLocalLogTailHandlers() }) afterEach(() => { @@ -87,7 +89,7 @@ describe('local log tail IPC', () => { ) emitChange?.('change') - expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.anything()) + expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl') expect(watchMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.any(Function)) expect(sender.send).toHaveBeenCalledWith('fs:localLogTailChanged', { subscriptionId: 'tail-1', diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts index 0892665ad50d..d89d62b256de 100644 --- a/src/main/ipc/local-log-tail.ts +++ b/src/main/ipc/local-log-tail.ts @@ -1,6 +1,5 @@ import { ipcMain, type WebContents } from 'electron' import { watch, type FSWatcher } from 'node:fs' -import type { Store } from '../persistence' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -8,7 +7,7 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' -import { resolveAuthorizedPath } from './filesystem-auth' +import { resolveUserNamedRegularFile } from './filesystem-request-shape' import { abortWhenRendererGone } from './renderer-lifetime-abort' type TailSenderOwner = { @@ -95,11 +94,7 @@ function validateSubscriptionId(value: unknown): string { return value } -async function startWatch( - sender: WebContents, - args: LocalLogTailWatchArgs, - store: Store -): Promise { +async function startWatch(sender: WebContents, args: LocalLogTailWatchArgs): Promise { const subscriptionId = validateSubscriptionId(args.subscriptionId) if (sender.isDestroyed()) { return @@ -109,7 +104,7 @@ async function startWatch( const pending = Symbol(subscriptionId) owner.pending.set(key, pending) try { - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveUserNamedRegularFile(args.filePath) if ( sender.isDestroyed() || owner.signal.aborted || @@ -143,17 +138,17 @@ async function startWatch( } } -export function registerLocalLogTailHandlers(store: Store): void { +export function registerLocalLogTailHandlers(): void { ipcMain.handle( 'fs:readLocalLogTail', async (_event, args: LocalLogTailReadArgs): Promise => { - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveUserNamedRegularFile(args.filePath) return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity) } ) ipcMain.handle('fs:startLocalLogTail', (event, args: LocalLogTailWatchArgs): Promise => - startWatch(event.sender, args, store) + startWatch(event.sender, args) ) ipcMain.handle('fs:stopLocalLogTail', (event, args: { subscriptionId: string }): void => { diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index 9201793592a2..83acf5354cbe 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -1,4 +1,7 @@ import { EventEmitter } from 'node:events' +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { KernelFrame } from '../../shared/notebook-kernel-types' @@ -14,11 +17,15 @@ vi.mock('electron', () => ({ handlers.set(channel, handler) } })) -vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock })) +vi.mock('./filesystem-request-shape', () => ({ + resolveUserNamedRegularFile: resolveAuthorizedPathMock, + resolveDesktopAuthorizedPath: resolveAuthorizedPathMock +})) vi.mock('../notebook/notebook-kernel', () => ({ startNotebookKernel: startNotebookKernelMock })) import { registerNotebookHandlers } from './notebook' import type { Store } from '../persistence' +import type * as RequestShape from './filesystem-request-shape' function fakeKernel() { let onFrame: (frame: KernelFrame) => void = () => {} @@ -104,4 +111,26 @@ describe('notebook IPC', () => { expect(second.kernel.execute).toHaveBeenCalledWith('x') expect(first.kernel.execute).not.toHaveBeenCalled() }) + + it('starts a kernel for a notebook outside every project, and refuses a relative path', async () => { + const actual = await vi.importActual('./filesystem-request-shape') + resolveAuthorizedPathMock.mockImplementation(actual.resolveUserNamedRegularFile) + const folder = await mkdtemp(join(await realpath(tmpdir()), 'orca-notebook-')) + try { + const notebook = join(folder, 'analysis.ipynb') + await writeFile(notebook, '{}') + fakeKernel() + const start = handlers.get('notebook:startKernel')! + + await expect( + start({ sender: fakeOwner() }, { filePath: notebook, python: '/py' }) + ).resolves.toEqual({ status: 'ready' }) + expect(startNotebookKernelMock).toHaveBeenCalledWith(expect.objectContaining({ cwd: folder })) + await expect( + start({ sender: fakeOwner() }, { filePath: 'analysis.ipynb', python: '/py' }) + ).rejects.toThrow('absolute path') + } finally { + await rm(folder, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index 70f8224011b6..3721b15f917b 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -1,7 +1,10 @@ import { dirname } from 'node:path' import { ipcMain, type WebContents } from 'electron' import type { Store } from '../persistence' -import { resolveAuthorizedPath } from './filesystem-auth' +import { + resolveDesktopAuthorizedPath, + resolveUserNamedRegularFile +} from './filesystem-request-shape' import { startNotebookKernel, type NotebookKernel } from '../notebook/notebook-kernel' import { createNotebookVenv, @@ -44,6 +47,8 @@ function kernelsOf(owner: WebContents): Map { return kernels } +// Why the notebook path is user-named, not root-checked: it is an open tab, and it only picks the +// kernel's cwd and the venv folder. export function registerNotebookHandlers(store: Store): void { ipcMain.handle( 'notebook:listPythonEnvironments', @@ -51,7 +56,7 @@ export function registerNotebookHandlers(store: Store): void { _event, args: { filePath: string; rootPath: string | null; runWorkspaceInterpreters: boolean } ): Promise => { - await resolveAuthorizedPath(args.filePath, store) + await resolveUserNamedRegularFile(args.filePath) // Why the unresolved path: rootPath is in the same (possibly symlinked) form, e.g. /tmp. return listPythonEnvironments(args.filePath, args.rootPath, { runWorkspaceInterpreters: args.runWorkspaceInterpreters === true @@ -68,7 +73,7 @@ export function registerNotebookHandlers(store: Store): void { 'notebook:startKernel', async (event, args: { filePath: string; python: string }): Promise => { // Why: run from the notebook's folder so relative imports and data paths resolve as on disk. - const cwd = dirname(await resolveAuthorizedPath(args.filePath, store)) + const cwd = dirname(await resolveUserNamedRegularFile(args.filePath)) const owner = event.sender const kernels = kernelsOf(owner) kernels.get(args.filePath)?.shutdown() @@ -106,9 +111,9 @@ export function registerNotebookHandlers(store: Store): void { _event, args: { filePath: string; rootPath: string | null; python: string } ): Promise => { - await resolveAuthorizedPath(args.filePath, store) + await resolveUserNamedRegularFile(args.filePath) if (args.rootPath) { - await resolveAuthorizedPath(args.rootPath, store) + await resolveDesktopAuthorizedPath(args.rootPath, store) } return createNotebookVenv(args.python, notebookVenvParent(args.filePath, args.rootPath)) } diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 1bcf1749c30c..7b63f381c1c3 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -321,7 +321,7 @@ describe('registerClipboardHandlers', () => { ).resolves.toEqual({ ok: true }) expect(fsStatMock).toHaveBeenCalledWith('/tmp/copied-file.txt') - expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/tmp/copied-file.txt', {}) + expect(resolveAuthorizedPathMock.mock.calls[0]?.[0]).toBe('/tmp/copied-file.txt') if (process.platform === 'darwin') { expect(clipboardWriteBufferMock).toHaveBeenCalledWith( 'public.file-url', diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index e81140d734a6..e8a5c1c13c6d 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -9,7 +9,8 @@ import { import { spawn } from 'node:child_process' import { open, stat } from 'node:fs/promises' import type { Store } from '../persistence' -import { PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { PATH_ACCESS_DENIED_MESSAGE } from '../ipc/filesystem-auth' +import { resolveDesktopAuthorizedPath } from '../ipc/filesystem-request-shape' import { isENOENT } from '../ipc/filesystem-path-containment' import { assertClipboardTextWriteWithinLimitWithYield, @@ -170,7 +171,7 @@ export function registerClipboardHandlers(store: Store): void { } const deps = makeClipboardFileDeps(async (path) => { try { - const authorizedPath = await resolveAuthorizedPath(path, store) + const authorizedPath = await resolveDesktopAuthorizedPath(path, store) await stat(authorizedPath) return { ok: true, path: authorizedPath } } catch (error) { diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 54d0f7f914a7..7039fd5a7a8f 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -17,6 +17,7 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { LocalFileAccess } from '../../shared/local-file-access' import type { CreateVenvResult, KernelFrameEvent, @@ -42,6 +43,7 @@ export type FilesystemApi = { filePath: string connectionId?: string includeLocalLogMetadata?: boolean + access?: LocalFileAccess }) => Promise<{ content: string isBinary: boolean @@ -88,6 +90,7 @@ export type FilesystemApi = { filePath: string content: string connectionId?: string + access?: LocalFileAccess } & SshMutationExpectation ) => Promise createFile: ( @@ -128,12 +131,17 @@ export type FilesystemApi = { stat: (args: { filePath: string connectionId?: string + access?: LocalFileAccess }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> pathsExist?: (args: { filePaths: string[] connectionId?: string }) => Promise - pathExists: (args: { filePath: string; connectionId?: string }) => Promise + pathExists: (args: { + filePath: string + connectionId?: string + access?: LocalFileAccess + }) => Promise listFiles: (args: { rootPath: string connectionId?: string diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 5e9cfc22eaca..6e2a038501ae 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,6 +1,7 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' import { ipcRenderer } from 'electron' import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { LocalFileAccess } from '../../shared/local-file-access' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' import type { SearchResult } from '../../shared/code-search-types' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' @@ -27,6 +28,7 @@ export const fsApi = { filePath: string connectionId?: string includeLocalLogMetadata?: boolean + access?: LocalFileAccess }): Promise<{ content: string isBinary: boolean @@ -91,6 +93,7 @@ export const fsApi = { filePath: string content: string connectionId?: string + access?: LocalFileAccess } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:writeFile', args), createFile: ( @@ -123,14 +126,18 @@ export const fsApi = { stat: (args: { filePath: string connectionId?: string + access?: LocalFileAccess }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => ipcRenderer.invoke('fs:stat', args), pathsExist: (args: { filePaths: string[] connectionId?: string }): Promise => ipcRenderer.invoke('fs:pathsExist', args), - pathExists: (args: { filePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:pathExists', args), + pathExists: (args: { + filePath: string + connectionId?: string + access?: LocalFileAccess + }): Promise => ipcRenderer.invoke('fs:pathExists', args), listFiles: (args: { rootPath: string connectionId?: string diff --git a/src/shared/local-file-access.ts b/src/shared/local-file-access.ts new file mode 100644 index 000000000000..99d41d5fd684 --- /dev/null +++ b/src/shared/local-file-access.ts @@ -0,0 +1,9 @@ +/** + * The shape of a desktop file request, which decides what main checks. Absent means the path must + * be inside a project root main recognises. + */ +export type LocalFileAccess = + /** A single file the user named (gesture or persisted tab): read in place, regular files only. */ + | { kind: 'user-file' } + /** A resource a document's content references (images): limited to that document's roots or folder. */ + | { kind: 'document-resource'; documentPath: string } From 9a44e6a73dd60539158a5af93a6d25cec65e4c45 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:35:46 -0700 Subject: [PATCH 07/19] feat(editor): send each local file request's shape from the renderer Problem: after a restart, a tab opened outside every project (a floating workspace file, a file opened by absolute path, an OS-opened markdown) could only be read if the renderer first re-granted its path, and readers that ran before the editor loaded the tab had no grant at all. The renderer now says what kind of request it is making, and main checks that: - A persisted tab opened outside its owner's root (floating workspace, or an absolute stored path) whose owner is explicitly local reads and saves as a user-named file, from every reader: the editor loader, the restored-tab conflict scan, the change banner and compare dialog, the paired-phone markdown bridge and the save queue. Project tabs stay inside their root. - Clicks, drops, typed paths and browser-opened notebooks stat as user-named. - Markdown preview and rich-editor images are document resources, limited to the document's roots or folder. Images the user pasted or attached into a chat show as user-named; agent images stay inside the project. - The image cache keys on the shape, so one shape's image never answers another's request. A ratchet test lists every renderer file allowed to create a user-named request. --- .../describe-page/browser-artifact-upload.ts | 11 +- .../navigate/navigate-browser-page-url.ts | 3 +- .../editor/ExternalFileChangeBanner.tsx | 4 +- .../ExternalFileChangeCompareDialog.tsx | 8 +- .../editor-restored-tab-conflict-scan.test.ts | 4 +- .../editor-restored-tab-conflict-scan.ts | 9 +- .../components/editor/editor-save-queue.ts | 8 +- .../editor/editor-save-user-named-tab.test.ts | 109 +++++++++++ .../editor/local-image-src-reader.ts | 12 +- .../editor/markdown-preview-local-images.ts | 7 +- .../editor/rich-markdown-extensions.ts | 36 ++-- .../editor/rich-markdown-local-image.test.ts | 3 +- .../use-markdown-preview-components.tsx | 11 +- .../editor/useEditorPanelFileContentLoader.ts | 10 +- ...ditorPanelFloatingTabContentState.test.tsx | 6 +- .../editor/useLocalImageSrc.test.ts | 18 ++ .../src/components/editor/useLocalImageSrc.ts | 48 +++-- .../NativeChatImageAttachmentPreview.test.tsx | 4 +- .../NativeChatImageAttachmentPreview.tsx | 5 +- .../native-chat/NativeChatMessageRow.tsx | 2 + .../NativeChatTranscriptChrome.test.tsx | 23 +++ .../NativeChatTranscriptChrome.tsx | 21 +- .../sidebar/useSidebarProjectDrop.ts | 6 +- .../tab-bar/tab-create-entry-absolute-file.ts | 3 +- .../tab-bar/tab-create-entry-action.test.ts | 6 +- .../terminal-file-open-routing.ts | 3 +- .../terminal-link-click-fallback.test.ts | 4 +- .../terminal-link-wsl-path-mapping.test.ts | 6 +- .../composer-state/attachment-drop-state.ts | 3 +- src/renderer/src/hooks/useGlobalFileDrop.ts | 3 +- .../src/lib/local-file-access.test.ts | 179 ++++++++++++++++++ src/renderer/src/lib/local-file-access.ts | 55 ++++++ .../runtime/mobile-markdown-bridge.test.ts | 3 + .../src/runtime/mobile-markdown-bridge.ts | 4 +- .../src/runtime/runtime-file-client-types.ts | 3 + .../runtime/runtime-file-metadata-client.ts | 8 +- .../runtime/runtime-file-mutation-client.ts | 12 +- .../src/runtime/runtime-file-read-client.ts | 33 +++- 38 files changed, 612 insertions(+), 81 deletions(-) create mode 100644 src/renderer/src/components/editor/editor-save-user-named-tab.test.ts create mode 100644 src/renderer/src/lib/local-file-access.test.ts create mode 100644 src/renderer/src/lib/local-file-access.ts diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.ts b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.ts index 8288e2d306a3..85a6ca22b0f2 100644 --- a/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.ts +++ b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.ts @@ -2,6 +2,7 @@ import type { ArtifactWriteRequest } from '../../../../../shared/artifacts' import { ARTIFACT_MAX_CONTENT_BYTES } from '../../../../../shared/artifacts' import { getRuntimePathBasename } from '../../../../../shared/cross-platform-path' import { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow' +import { userNamedFileAccess } from '@/lib/local-file-access' export type ShareableBrowserArtifactFile = { fileName: string @@ -44,14 +45,20 @@ export async function readBrowserHtmlArtifactRequest(url: string): Promise ARTIFACT_MAX_CONTENT_BYTES) { throw new ArtifactPublishPreparationError('too-large') } - const result = await window.api.fs.readFile({ filePath: file.filePath }) + const result = await window.api.fs.readFile({ + filePath: file.filePath, + access: userNamedFileAccess() + }) if (result.isBinary) { throw new ArtifactPublishPreparationError('binary') } diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts index 9627437b256c..541443e05dc2 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts @@ -25,6 +25,7 @@ import type { BrowserTabPageState } from '../describe-page/browser-page-types' import type { MutableRefObject } from 'react' +import { userNamedFileAccess } from '@/lib/local-file-access' export type NavigateBrowserPageToUrlArgs = { url: string @@ -110,7 +111,7 @@ export function navigateBrowserPageToUrl({ if (!isRemoteRuntimeFileOperation(fileContext, notebookPath)) { await window.api.fs.authorizeExternalPath({ targetPath: notebookPath }) } - const stat = await statRuntimePath(fileContext, notebookPath) + const stat = await statRuntimePath(fileContext, notebookPath, userNamedFileAccess()) if (stat.isDirectory) { navigateBrowserUrl(url) return diff --git a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx index dda2fd2d5477..71a11eaa7757 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx @@ -3,6 +3,7 @@ import { TriangleAlert } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { getConnectionIdForFile } from '@/lib/connection-context' +import { editorTabFileAccess } from '@/lib/local-file-access' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' @@ -92,7 +93,8 @@ export function keepTabEditsOverExternalChange(file: OpenFile): void { relativePath: file.relativePath, worktreeId: file.worktreeId, connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined, - expectedExternalSshTargetId: file.externalSshTargetId + expectedExternalSshTargetId: file.externalSshTargetId, + access: editorTabFileAccess(state, file) }) .then((result) => { if (result.isBinary) { diff --git a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx index cb8230c33443..26cd9fbe95f7 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/dialog' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { getConnectionIdForFile } from '@/lib/connection-context' +import { editorTabFileAccess } from '@/lib/local-file-access' import { detectLanguage } from '@/lib/language-detect' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' @@ -46,6 +47,7 @@ export function ExternalFileChangeCompareDialog({ onKeepEdits: () => void }): React.JSX.Element { const [diskState, setDiskState] = useState({ kind: 'loading' }) + const access = editorTabFileAccess(useAppStore.getState(), file) useEffect(() => { if (!open) { @@ -61,7 +63,8 @@ export function ExternalFileChangeCompareDialog({ relativePath: file.relativePath, worktreeId: file.worktreeId, connectionId: getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined, - expectedExternalSshTargetId: file.externalSshTargetId + expectedExternalSshTargetId: file.externalSshTargetId, + access }) .then((result) => { if (cancelled) { @@ -89,7 +92,8 @@ export function ExternalFileChangeCompareDialog({ file.relativePath, file.worktreeId, file.runtimeEnvironmentId, - file.externalSshTargetId + file.externalSshTargetId, + access ]) const language = detectLanguage(file.relativePath) diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 2c54c6ababf9..8fe04dc23086 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -294,7 +294,9 @@ describe('attachRestoredTabConflictScan', () => { finishGrant() await vi.advanceTimersByTimeAsync(10) - expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/Users/me/notes.txt', access: { kind: 'user-file' } }) + ) expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBeUndefined() } finally { detach() diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index 13fdb8a41efd..acd5126460cb 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -3,6 +3,7 @@ import type { StoreApi } from 'zustand' import type { AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { getConnectionIdForFile } from '@/lib/connection-context' +import { editorTabFileAccess } from '@/lib/local-file-access' import { refreshEditorTabExternalPathGrant } from '@/lib/editor-tab-external-path-grant' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' @@ -45,9 +46,12 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { return false } try { + const connectionId = getFileConnectionId(file) + const access = connectionId ? undefined : editorTabFileAccess(store.getState(), file) const exists = await globalThis.window?.api?.fs?.pathExists?.({ filePath: file.filePath, - connectionId: getFileConnectionId(file) + connectionId, + ...(access ? { access } : {}) }) return exists === false } catch { @@ -72,7 +76,8 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { relativePath: file.relativePath, worktreeId: file.worktreeId, connectionId: getFileConnectionId(file), - expectedExternalSshTargetId: file.externalSshTargetId + expectedExternalSshTargetId: file.externalSshTargetId, + access: editorTabFileAccess(state, file) }) if (disposed) { return diff --git a/src/renderer/src/components/editor/editor-save-queue.ts b/src/renderer/src/components/editor/editor-save-queue.ts index b5a0281470d3..508edc4165b9 100644 --- a/src/renderer/src/components/editor/editor-save-queue.ts +++ b/src/renderer/src/components/editor/editor-save-queue.ts @@ -19,6 +19,7 @@ import { } from './editor-self-write-registry' import { getDiskBaselineSignature } from './diff-content-signature' import { trackExternalChangeConflictAction } from './editor-external-change-telemetry' +import { editorTabFileAccess } from '@/lib/local-file-access' export type AppStoreApi = Pick, 'getState' | 'subscribe'> @@ -110,7 +111,12 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue { : undefined ) try { - await writeRuntimeFile(fileContext, liveFile.filePath, contentToSave) + await writeRuntimeFile( + fileContext, + liveFile.filePath, + contentToSave, + editorTabFileAccess(state, liveFile) + ) } catch (error) { // Why: the self-write stamp is only valid after a real write; clear on failure so it can't suppress a real update. clearSelfWrite(liveFile.filePath, liveFile.runtimeEnvironmentId) diff --git a/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts b/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts new file mode 100644 index 000000000000..b6efaa834661 --- /dev/null +++ b/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createStore, type StoreApi } from 'zustand/vanilla' +import { createEditorSlice } from '@/store/slices/editor' +import type { AppState } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT } from '../../../../shared/editor-save-events' +import { attachEditorAutosaveController } from './editor-autosave-controller' +import { __clearSelfWriteRegistryForTests } from './editor-self-write-registry' + +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null })) + +function createEditorStore(): StoreApi { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return createStore()((...args: any[]) => ({ + settings: { editorAutoSave: false }, + repos: [], + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' }] + }, + detectedWorktreesByRepo: {}, + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: true, + removedRuntimeEnvironmentIds: new Set(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi +} + +async function saveDirtyFiles(): Promise { + await new Promise((resolve, reject) => { + window.dispatchEvent( + new CustomEvent(ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT, { + detail: { + claim: () => {}, + resolve, + reject: (message: string) => reject(new Error(message)) + } + }) + ) + }) +} + +describe('saving a restored tab', () => { + let writeFile: ReturnType + + beforeEach(() => { + writeFile = vi.fn().mockResolvedValue(undefined) + const eventTarget = new EventTarget() + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { fs: { writeFile } } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + __clearSelfWriteRegistryForTests() + }) + + it.each([ + ['a floating-workspace tab', 'notes.txt', FLOATING_TERMINAL_WORKTREE_ID, '/Users/me/notes.txt'], + ['a tab stored by absolute path', '/tmp/audit.md', 'wt-1', '/tmp/audit.md'] + ])('saves %s as the file the user named', async (_label, relativePath, worktreeId, filePath) => { + const store = createEditorStore() + // Hydration restores the tab exactly as persisted; nothing is re-granted first. + store + .getState() + .openFile({ filePath, relativePath, worktreeId, language: 'markdown', mode: 'edit' }) + store.getState().setEditorDraft(filePath, 'edited') + store.getState().markFileDirty(filePath, true) + const detach = attachEditorAutosaveController(store) + try { + await saveDirtyFiles() + + expect(writeFile).toHaveBeenCalledWith( + expect.objectContaining({ filePath, content: 'edited', access: { kind: 'user-file' } }) + ) + } finally { + detach() + } + }) + + it('saves a project tab inside its root, with no shape', async () => { + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/a.ts', + relativePath: 'a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + store.getState().setEditorDraft('/repo/a.ts', 'edited') + store.getState().markFileDirty('/repo/a.ts', true) + const detach = attachEditorAutosaveController(store) + try { + await saveDirtyFiles() + + expect(writeFile).toHaveBeenCalledTimes(1) + expect(writeFile.mock.calls[0]?.[0]).not.toHaveProperty('access') + } finally { + detach() + } + }) +}) diff --git a/src/renderer/src/components/editor/local-image-src-reader.ts b/src/renderer/src/components/editor/local-image-src-reader.ts index 5e845031bd03..acef96fc54a4 100644 --- a/src/renderer/src/components/editor/local-image-src-reader.ts +++ b/src/renderer/src/components/editor/local-image-src-reader.ts @@ -1,21 +1,27 @@ +import type { LocalFileAccess } from '../../../../shared/local-file-access' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { readRuntimeFilePreview } from '@/runtime/runtime-file-client' export function readLocalImagePreview( absolutePath: string, connectionId?: string | null, - runtimeContext?: Omit & { connectionId?: string | null } + runtimeContext?: Omit & { + connectionId?: string | null + }, + access?: LocalFileAccess ) { try { if (!runtimeContext) { return window.api.fs.readFile({ filePath: absolutePath, - connectionId: connectionId ?? undefined + connectionId: connectionId ?? undefined, + ...(access && !connectionId ? { access } : {}) }) } return readRuntimeFilePreview( { ...runtimeContext, connectionId: runtimeContext.connectionId ?? connectionId ?? undefined }, - absolutePath + absolutePath, + access ) } catch (error) { return Promise.reject(error) diff --git a/src/renderer/src/components/editor/markdown-preview-local-images.ts b/src/renderer/src/components/editor/markdown-preview-local-images.ts index 57d37f09d9ea..bd14a540f517 100644 --- a/src/renderer/src/components/editor/markdown-preview-local-images.ts +++ b/src/renderer/src/components/editor/markdown-preview-local-images.ts @@ -5,6 +5,7 @@ import { unified } from 'unified' import { resolveImageAbsolutePath } from './markdown-preview-links' import { getLocalImageCacheKey, loadLocalImageAbsolutePath } from './useLocalImageSrc' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { documentResourceAccess } from '@/lib/local-file-access' export const MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT = 64 export const MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_CONCURRENCY = 4 @@ -97,7 +98,8 @@ export function extractMarkdownPreviewLocalImageCandidates( const cacheKey = getLocalImageCacheKey( absolutePath, options.connectionId, - options.runtimeContext + options.runtimeContext, + documentResourceAccess(filePath) ) if (seenCacheKeys.has(cacheKey)) { return @@ -143,7 +145,8 @@ export function prewarmMarkdownPreviewLocalImages( loadLocalImageAbsolutePath( candidate.absolutePath, options.connectionId, - options.runtimeContext + options.runtimeContext, + documentResourceAccess(filePath) )) let cancelled = false let nextIndex = 0 diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index c7e69917c253..961a00685ce9 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -43,6 +43,7 @@ import { RichMarkdownParagraph } from './rich-markdown-paragraph' import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight' import { RichMarkdownTaskList } from './rich-markdown-task-list' import { createCachedLowlight } from './rich-markdown-lowlight-cache' +import { documentResourceAccess } from '@/lib/local-file-access' const lowlight = createCachedLowlight(createLowlight(common)) @@ -146,20 +147,29 @@ export function createRichMarkdownExtensions({ | undefined const contextVersionAtLoad = getImageContextVersion(this.storage) if (src && fp) { - releaseImageLease = acquireLocalImageSrcLease(src, fp, undefined, runtimeContext) - void loadLocalImageSrc(src, fp, undefined, runtimeContext).then((resolved) => { - if (currentSrc !== src || currentContextVersion !== contextVersionAtLoad) { - return + const access = documentResourceAccess(fp) + releaseImageLease = acquireLocalImageSrcLease( + src, + fp, + undefined, + runtimeContext, + access + ) + void loadLocalImageSrc(src, fp, undefined, runtimeContext, access).then( + (resolved) => { + if (currentSrc !== src || currentContextVersion !== contextVersionAtLoad) { + return + } + if (resolved) { + img.src = resolved + return + } + // Why: local image paths must stay behind IPC/runtime + // authorization; a failed load should render missing, not + // hand the raw path back to Chromium. + img.removeAttribute('src') } - if (resolved) { - img.src = resolved - return - } - // Why: local image paths must stay behind IPC/runtime - // authorization; a failed load should render missing, not - // hand the raw path back to Chromium. - img.removeAttribute('src') - }) + ) } else if (src) { img.src = src } else { diff --git a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts index 056fbdd8d33d..d6c74e235a34 100644 --- a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts @@ -58,7 +58,8 @@ describe('rich markdown local images', () => { expect(window.api.fs.readFile).toHaveBeenCalledWith({ filePath: '/repo/docs/diagram.png', - connectionId: undefined + connectionId: undefined, + access: { kind: 'document-resource', documentPath: '/repo/docs/readme.md' } }) expect(host.querySelector('img')?.src).toBe('blob:rich-local-image') } finally { diff --git a/src/renderer/src/components/editor/use-markdown-preview-components.tsx b/src/renderer/src/components/editor/use-markdown-preview-components.tsx index 6f5f0b0d63ef..1b7d21b2dee7 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-components.tsx +++ b/src/renderer/src/components/editor/use-markdown-preview-components.tsx @@ -21,6 +21,7 @@ import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundatio import type { MarkdownPreviewReviewActions } from './use-markdown-preview-review-actions' import type { MarkdownPreviewViewport } from './use-markdown-preview-viewport' import { useLocalImageSrc } from './useLocalImageSrc' +import { documentResourceAccess } from '@/lib/local-file-access' export function useMarkdownPreviewComponents({ foundation, @@ -64,6 +65,8 @@ export function useMarkdownPreviewComponents({ const { renderAnnotationControls, wrapAnnotatedBlock } = annotationRenderers return useMemo(() => { + // Why: preview images come from document text, so main limits them to the document's roots. + const imageAccess = documentResourceAccess(filePath) const linkContext = { isMac, sourceOwner, @@ -130,7 +133,13 @@ export function useMarkdownPreviewComponents({ ) }, img: function MarkdownImg({ src, alt, ...props }) { - const resolvedSrc = useLocalImageSrc(src, filePath, undefined, imageRuntimeContext) + const resolvedSrc = useLocalImageSrc( + src, + filePath, + undefined, + imageRuntimeContext, + imageAccess + ) const handleImageClick = (event: React.MouseEvent): void => { if (!isMarkdownPreviewOpenModifier(event, isMac)) { return diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index 998e5e3ca0d8..bbdb1e106f29 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -20,6 +20,7 @@ import { import type { EditorPanelContentLoadOptions } from './useEditorPanelExternalContentEvents' import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner' import { refreshEditorTabExternalPathGrant } from '@/lib/editor-tab-external-path-grant' +import { editorTabFileAccess } from '@/lib/local-file-access' const inFlightFileReads = new Map>() @@ -173,7 +174,11 @@ export function useEditorPanelFileContentLoader({ } } const readScope = getRuntimeFileReadScope(readSettings, readConnectionId) - const key = inFlightReadKey(readScope, filePath) + const access = restoredOpenFile + ? editorTabFileAccess(useAppStore.getState(), restoredOpenFile) + : undefined + // Why the shape in the key: a contained tab must not share a read made as a user-named one. + const key = `${inFlightReadKey(readScope, filePath)}::${access?.kind ?? ''}` const registeredRead = inFlightFileReads.get(key) if ( options?.force && @@ -193,7 +198,8 @@ export function useEditorPanelFileContentLoader({ worktreeId: readWorktreeId, connectionId: readConnectionId, expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId, - includeLocalLogMetadata: isLiveTailLogTab + includeLocalLogMetadata: isLiveTailLogTab, + access }) as Promise pending = { externalEventGeneration: options?.externalEventGeneration, promise } inFlightFileReads.set(key, pending) diff --git a/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx index cd5fa2dec64d..0a5b81a2011e 100644 --- a/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx @@ -138,7 +138,11 @@ describe('restored client-local editor tabs', () => { await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( - expect.objectContaining({ filePath, connectionId: undefined }) + expect.objectContaining({ + filePath, + connectionId: undefined, + access: { kind: 'user-file' } + }) ) } ) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index 1c93693ff1ce..f1e7fabc79d5 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -418,4 +418,22 @@ describe('loadLocalImageSrc', () => { expect(renders).toEqual([undefined]) }) + + it('never shares a cached image across request shapes', () => { + const userFile = getLocalImageCacheKey('/tmp/a.png', undefined, undefined, { + kind: 'user-file' + }) + const fromDocA = getLocalImageCacheKey('/tmp/a.png', undefined, undefined, { + kind: 'document-resource', + documentPath: '/tmp/a.md' + }) + const fromDocB = getLocalImageCacheKey('/tmp/a.png', undefined, undefined, { + kind: 'document-resource', + documentPath: '/other/b.md' + }) + + expect(new Set([userFile, fromDocA, fromDocB, getLocalImageCacheKey('/tmp/a.png')]).size).toBe( + 4 + ) + }) }) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 38339ee36eac..55365775c4e3 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { resolveImageAbsolutePath } from './markdown-preview-links' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { readLocalImagePreview } from './local-image-src-reader' +import type { LocalFileAccess } from '../../../../shared/local-file-access' import { blobUrlCache, cacheLocalImageBlob, @@ -20,7 +21,10 @@ import { export function getLocalImageCacheKey( absolutePath: string, connectionId?: string | null, - runtimeContext?: Omit & { connectionId?: string | null } + runtimeContext?: Omit & { + connectionId?: string | null + }, + access?: LocalFileAccess ): string { const runtimeEnvironmentId = runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client' @@ -33,6 +37,9 @@ export function getLocalImageCacheKey( runtimeContext?.expectedExternalSshTargetId ?? '', runtimeContext?.worktreeId ?? 'unknown-worktree', runtimeContext?.worktreePath ?? '', + // Why: an image read under one shape must never answer a request made under another. + access?.kind ?? 'roots', + access?.kind === 'document-resource' ? access.documentPath : '', absolutePath ].join('\0') } @@ -67,13 +74,14 @@ export function useLocalImageSrc( connectionId?: string | null, runtimeContext?: | (Omit & { connectionId?: string | null }) - | null + | null, + access?: LocalFileAccess ): string | undefined { const [generation, setGeneration] = useState(getLocalImageCacheGeneration()) useEffect(() => { - return acquireLocalImageSrcLease(rawSrc, filePath, connectionId, runtimeContext) - }, [rawSrc, filePath, connectionId, runtimeContext]) + return acquireLocalImageSrcLease(rawSrc, filePath, connectionId, runtimeContext, access) + }, [rawSrc, filePath, connectionId, runtimeContext, access]) useEffect(() => { return onImageCacheInvalidated(() => setGeneration(getLocalImageCacheGeneration())) @@ -88,7 +96,7 @@ export function useLocalImageSrc( } const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) if (absolutePath) { - const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) if (blobUrlCache.has(cacheKey)) { return blobUrlCache.get(cacheKey) } @@ -113,7 +121,7 @@ export function useLocalImageSrc( return } - const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) if (blobUrlCache.has(cacheKey)) { setDisplaySrc(blobUrlCache.get(cacheKey)) return @@ -121,7 +129,7 @@ export function useLocalImageSrc( let cancelled = false const effectGeneration = generation - loadLocalImageAbsolutePath(absolutePath, connectionId, runtimeContext) + loadLocalImageAbsolutePath(absolutePath, connectionId, runtimeContext, access) .then((url) => { if (cancelled) { return @@ -137,7 +145,7 @@ export function useLocalImageSrc( return () => { cancelled = true } - }, [rawSrc, filePath, generation, connectionId, runtimeContext]) + }, [rawSrc, filePath, generation, connectionId, runtimeContext, access]) return displaySrc } @@ -153,7 +161,8 @@ export async function loadLocalImageSrc( connectionId?: string | null, runtimeContext?: | (Omit & { connectionId?: string | null }) - | null + | null, + access?: LocalFileAccess ): Promise { if (isExternalUrl(rawSrc)) { return rawSrc @@ -167,13 +176,13 @@ export async function loadLocalImageSrc( return null } - const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) const cached = blobUrlCache.get(cacheKey) if (cached) { return cached } - return loadLocalImageAbsolutePath(absolutePath, connectionId, runtimeContext) + return loadLocalImageAbsolutePath(absolutePath, connectionId, runtimeContext, access) } export function loadLocalImageAbsolutePath( @@ -181,12 +190,13 @@ export function loadLocalImageAbsolutePath( connectionId?: string | null, runtimeContext?: | (Omit & { connectionId?: string | null }) - | null + | null, + access?: LocalFileAccess ): Promise { if (runtimeContext === null) { return Promise.resolve(null) } - const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) const cached = blobUrlCache.get(cacheKey) if (cached) { return Promise.resolve(cached) @@ -199,7 +209,7 @@ export function loadLocalImageAbsolutePath( const readGeneration = getLocalImageCacheGeneration() const readLeaseVersion = getLocalImageCacheKeyVersion(cacheKey) - const loadPromise = readLocalImagePreview(absolutePath, connectionId, runtimeContext) + const loadPromise = readLocalImagePreview(absolutePath, connectionId, runtimeContext, access) .then((result) => { if ( !result.isBinary || @@ -240,7 +250,8 @@ export function acquireLocalImageSrcLease( connectionId?: string | null, runtimeContext?: | (Omit & { connectionId?: string | null }) - | null + | null, + access?: LocalFileAccess ): (() => void) | undefined { if (!rawSrc || isExternalUrl(rawSrc) || runtimeContext === null) { return undefined @@ -249,7 +260,7 @@ export function acquireLocalImageSrcLease( if (!absolutePath) { return undefined } - const key = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const key = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) pinLocalImageCache(key) return () => unpinLocalImageCache(key) } @@ -261,7 +272,8 @@ export function releaseLocalImageSrc( connectionId?: string | null, runtimeContext?: | (Omit & { connectionId?: string | null }) - | null + | null, + access?: LocalFileAccess ): void { if (!rawSrc || isExternalUrl(rawSrc) || runtimeContext === null) { return @@ -270,6 +282,6 @@ export function releaseLocalImageSrc( if (!absolutePath) { return } - const key = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const key = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext, access) releaseLocalImageBlob(key) } diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index 2b5924e98260..84f0068fc039 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -50,6 +50,8 @@ describe('NativeChatImageAttachmentPreview', () => { mocks.useLocalImageSrc.mockReturnValue(undefined) renderPreview({ id: 'a1', path: '', previewUrl: 'blob:clipboard-1', pending: true }) - expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(undefined, '', undefined) + expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(undefined, '', undefined, undefined, { + kind: 'user-file' + }) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 52e9a5f7c3a1..802550d9fe25 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -6,6 +6,7 @@ import { basename } from '@/lib/path' import { useLocalImageSrc } from '@/components/editor/useLocalImageSrc' import { isNativeChatPastedImagePath } from './native-chat-image-paste' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { userNamedFileAccess } from '@/lib/local-file-access' type Props = { attachment: NativeChatComposerImageAttachment @@ -45,7 +46,9 @@ export function NativeChatImageAttachmentPreview({ const localSrc = useLocalImageSrc( !isPending && (isNearViewport || isOpen) ? attachment.path : undefined, attachment.path, - attachment.connectionId + attachment.connectionId, + undefined, + userNamedFileAccess() ) // The clipboard thumbnail is already in this process, so it renders with no // round-trip; the on-disk file only wins for the full-size dialog. diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 234934664f4a..ebdf7b8b3cb6 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -154,6 +154,7 @@ export const MessageRow = memo(function MessageRow({ blocks={prose} runtimeContext={runtimeContext} enablePreview={runtimeContext !== undefined} + attachedByUser /> )} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx index 3992bbce1f57..0b4012db4661 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx @@ -206,4 +206,27 @@ describe('NativeChatImageAttachments', () => { expect(container.firstElementChild).toBe(observedElement) root.unmount() }) + + it.each([ + ['pasted or attached by the user', true, { kind: 'user-file' }], + ['produced by the agent', false, undefined] + ])('reads an image %s with the matching shape', async (_label, attachedByUser, access) => { + const container = document.createElement('div') + const root = createRoot(container) + await act(async () => { + root.render( + createElement(NativeChatImageAttachments, { + blocks: [{ type: 'image-ref' as const, path: '/tmp/orca-paste-1.png' }], + runtimeContext: runtimeContext('wt-1'), + attachedByUser + }) + ) + await flushPromises() + }) + + const request = vi.mocked(window.api.fs.readFile).mock.calls[0]?.[0] + expect(request?.filePath).toBe('/tmp/orca-paste-1.png') + expect(request?.access).toEqual(access) + root.unmount() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index 4dea3e2d6177..5e559fc87843 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -15,6 +15,8 @@ import { } from '@/components/editor/useLocalImageSrc' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import type { LocalFileAccess } from '../../../../shared/local-file-access' +import { userNamedFileAccess } from '@/lib/local-file-access' type VisibilityListener = (isVisible: boolean) => void @@ -72,10 +74,12 @@ function transcriptImageIdentity( function TranscriptImagePreview({ block, - runtimeContext + runtimeContext, + access }: { block: Extract runtimeContext: RuntimeFileOperationArgs | null | undefined + access: LocalFileAccess | undefined }): React.JSX.Element { const [open, setOpen] = useState(false) const [near, setNear] = useState(false) @@ -90,7 +94,8 @@ function TranscriptImagePreview({ leaseActive && !external && runtimeContext !== undefined ? source : undefined, filePath, runtimeContext?.connectionId, - runtimeContext + runtimeContext, + access ) const displaySrc = external && leaseActive ? source : localSrc const label = @@ -124,10 +129,10 @@ function TranscriptImagePreview({ return } if (!leaseActive) { - releaseLocalImageSrc(source, filePath, context.connectionId, context) + releaseLocalImageSrc(source, filePath, context.connectionId, context, access) } - return () => releaseLocalImageSrc(source, filePath, context.connectionId, context) - }, [external, filePath, leaseActive, runtimeContext, source]) + return () => releaseLocalImageSrc(source, filePath, context.connectionId, context, access) + }, [access, external, filePath, leaseActive, runtimeContext, source]) const showPreview = leaseActive && @@ -183,12 +188,15 @@ function TranscriptImagePreview({ export function NativeChatImageAttachments({ blocks, runtimeContext, - enablePreview = runtimeContext !== undefined + enablePreview = runtimeContext !== undefined, + attachedByUser = false }: { blocks: NativeChatBlock[] runtimeContext?: RuntimeFileOperationArgs | null /** Keep legacy terminal chips unchanged until that lane opts into previews. */ enablePreview?: boolean + /** The user pasted or attached these, so they show wherever they live; agent images stay in the project. */ + attachedByUser?: boolean }): React.JSX.Element | null { const images = blocks.filter((block) => block.type === 'image-ref') if (images.length === 0) { @@ -236,6 +244,7 @@ export function NativeChatImageAttachments({ key={`${imageKeyBase}-${identity}-${occurrence}`} block={image} runtimeContext={runtimeContext} + access={attachedByUser ? userNamedFileAccess() : undefined} /> ) })} diff --git a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts index f44048aea87d..356b7f6d07ee 100644 --- a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts +++ b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts @@ -12,6 +12,7 @@ import { resolveSidebarProjectDropPath } from './sidebar-project-drop' import { translate } from '@/i18n/i18n' +import { userNamedFileAccess } from '@/lib/local-file-access' type SidebarProjectDropHandlers = { onDragEnter: (event: React.DragEvent) => void @@ -80,7 +81,10 @@ export function useSidebarProjectDrop(): { setIsHandlingDrop(true) try { await window.api.fs.authorizeExternalPath({ targetPath: pathResolution.path }) - const stat = await window.api.fs.stat({ filePath: pathResolution.path }) + const stat = await window.api.fs.stat({ + filePath: pathResolution.path, + access: userNamedFileAccess() + }) if (!mountedRef.current) { return } diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts b/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts index 2b60d1d25068..b620a55b2e0c 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts @@ -6,6 +6,7 @@ import { validateNewTabEntryAbsolutePath, type TabEntryLocalPlatform } from './tab-create-entry-path-validation' +import { userNamedFileAccess } from '@/lib/local-file-access' type AbsoluteFileOperations = { assertAbsolutePathAllowed: () => void @@ -32,7 +33,7 @@ export async function openAbsoluteTabEntryFile(args: { args.operations.assertAbsolutePathAllowed() let stat: Awaited> try { - stat = await args.operations.statRuntimePath(args.context, filePath) + stat = await args.operations.statRuntimePath(args.context, filePath, userNamedFileAccess()) } catch { throw new Error(`File not found: ${filePath}`) } diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts index 880bb561db42..79211e6dd489 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts @@ -267,7 +267,8 @@ describe('openTabEntryWithOperations', () => { expect(operations.authorizeExternalPath).toHaveBeenCalledWith({ targetPath: '/tmp/notes.md' }) expect(operations.statRuntimePath).toHaveBeenCalledWith( baseArgs.runtimeContext, - '/tmp/notes.md' + '/tmp/notes.md', + { kind: 'user-file' } ) expect(operations.openFile).toHaveBeenCalledWith( expect.objectContaining({ @@ -351,7 +352,8 @@ describe('openTabEntryWithOperations', () => { }) expect(operations.statRuntimePath).toHaveBeenCalledWith( baseArgs.runtimeContext, - 'C:/tmp/notes.md' + 'C:/tmp/notes.md', + { kind: 'user-file' } ) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index 21bb89d39aa5..ba64abdb2b8e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -23,6 +23,7 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { userNamedFileAccess } from '@/lib/local-file-access' export type FileOpenFailure = { /** `missing` is a verified absence; `unverifiable` means the host could not answer (dropped SSH, timeout, denied path). */ @@ -177,7 +178,7 @@ export function openDetectedFilePath( if (canOpenWithSystemDefault) { await window.api.fs.authorizeExternalPath({ targetPath: mappedFilePath }) } - statResult = await statRuntimePath(fileContext, mappedFilePath) + statResult = await statRuntimePath(fileContext, mappedFilePath, userNamedFileAccess()) } catch (error) { if (requestId === latestOpenDetectedFilePathRequestId && deps.onOpenFailure) { // Why: loss of contact with the host is not evidence the file is gone. diff --git a/src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts index f7c92a460bc8..7ded98221b3e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts @@ -106,7 +106,9 @@ describe('createFilePathLinkProvider range bounds', () => { expect(opened).toBe(true) expect(statMock).toHaveBeenCalledWith({ - filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md' + filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md', + connectionId: undefined, + access: { kind: 'user-file' } }) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts index 5ef937f9ed2b..03c5eb7835f5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts @@ -70,7 +70,11 @@ describe('createFilePathLinkProvider range bounds', () => { await flushAsyncWork() await flushDoubleRaf() - expect(statMock).toHaveBeenCalledWith({ filePath: mappedPath }) + expect(statMock).toHaveBeenCalledWith({ + filePath: mappedPath, + connectionId: undefined, + access: { kind: 'user-file' } + }) expect(openFileMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: mappedPath }), { forceContentReload: true }) diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts index 03d1f15dc246..88cca731b876 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts @@ -30,6 +30,7 @@ import { import { applyComposerNativeFileDrop } from '../composer-native-file-drop' import { useMountedRef } from '../useMountedRef' import { useComposerDropListener } from './composer-drop-listener' +import { userNamedFileAccess } from '@/lib/local-file-access' // Local drops bypass the runtime importer's skip classification. function localDropFailure(detail: string | undefined): ComposerDropFailure { @@ -224,7 +225,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { if (!mountedRef.current) { return } - const stat = await window.api.fs.stat({ filePath }) + const stat = await window.api.fs.stat({ filePath, access: userNamedFileAccess() }) results.push({ status: 'imported', destPath: filePath, diff --git a/src/renderer/src/hooks/useGlobalFileDrop.ts b/src/renderer/src/hooks/useGlobalFileDrop.ts index e201893088bd..c012b7bb8921 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.ts @@ -21,6 +21,7 @@ import { } from '../../../shared/native-file-drop' import { captureWorktreeSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { describeDropTempCopyFailure } from '@/lib/drop-temp-copy-failure-copy' +import { userNamedFileAccess } from '@/lib/local-file-access' export function getEditorFileDropSettingsForWorktree( store: WorktreeRuntimeOwnerState, @@ -165,7 +166,7 @@ export function useGlobalFileDrop(): void { if (!connectionId && !isRemoteRuntimePath) { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) } - const stat = await statRuntimePath(fileContext, filePath) + const stat = await statRuntimePath(fileContext, filePath, userNamedFileAccess()) if (stat.isDirectory) { return } diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts new file mode 100644 index 000000000000..ebef7ffe1354 --- /dev/null +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -0,0 +1,179 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import type { Worktree } from '../../../shared/worktree/types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { useAppStore } from '@/store' +import { editorTabFileAccess } from './local-file-access' + +const initialState = useAppStore.getInitialState() + +function makeRepo(overrides: Partial & { id: string; path: string }): Repo { + return { displayName: 'repo', badgeColor: '#000', addedAt: 0, ...overrides } +} + +function makeWorktree(overrides: Partial & { id: string; repoId: string }): Worktree { + return { + path: '/Users/me/project', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'project', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +const localWorktreeId = 'repo-local::/Users/me/project' + +type TabShape = Parameters[1] + +function accessKind(file: TabShape): string | undefined { + return editorTabFileAccess(useAppStore.getState(), file)?.kind +} + +describe('editorTabFileAccess', () => { + beforeEach(() => { + useAppStore.setState({ + repos: [ + makeRepo({ id: 'repo-local', path: '/Users/me/project' }), + makeRepo({ id: 'repo-ssh', path: '/work/project', connectionId: 'ssh-1' }) + ], + worktreesByRepo: { + 'repo-local': [makeWorktree({ id: localWorktreeId, repoId: 'repo-local' })] + } + }) + }) + + afterEach(() => { + useAppStore.setState(initialState, true) + }) + + it.each<[string, TabShape, string | undefined]>([ + [ + 'a floating-workspace tab stored relative to ~', + { + filePath: '/Users/me/notes.txt', + relativePath: 'notes.txt', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }, + 'user-file' + ], + [ + 'a local tab stored by absolute path', + { filePath: '/tmp/audit.md', relativePath: '/tmp/audit.md', worktreeId: localWorktreeId }, + 'user-file' + ], + [ + 'an AI Vault log tab in an SSH workspace', + { + filePath: '/Users/me/.codex/session.jsonl', + relativePath: '/Users/me/.codex/session.jsonl', + worktreeId: 'repo-ssh::/work/project', + readOnly: true, + liveTail: true + }, + 'user-file' + ], + [ + 'a project tab, which stays inside its root', + { filePath: '/Users/me/project/a.ts', relativePath: 'a.ts', worktreeId: localWorktreeId }, + undefined + ], + [ + 'an absolute tab owned by an SSH workspace', + { filePath: '/work/x.md', relativePath: '/work/x.md', worktreeId: 'repo-ssh::/work/project' }, + undefined + ], + [ + 'an absolute tab pinned to an SSH host', + { + filePath: '/work/x.md', + relativePath: '/work/x.md', + worktreeId: localWorktreeId, + externalSshTargetId: 'ssh-1' + }, + undefined + ], + [ + 'a floating tab owned by a remote runtime', + { + filePath: '/Users/me/notes.txt', + relativePath: 'notes.txt', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + runtimeEnvironmentId: 'runtime-1' + }, + undefined + ], + [ + 'an absolute tab whose owner has not loaded', + { + filePath: '/work/x.md', + relativePath: '/work/x.md', + worktreeId: 'repo-missing::/work/other' + }, + undefined + ], + [ + 'an absolute tab in a folder workspace with an unknown host', + { + filePath: '/home/remote/notes.md', + relativePath: '/home/remote/notes.md', + worktreeId: folderWorkspaceKey('fw-missing') + }, + undefined + ] + ])('%s', (_label, file, expected) => { + expect(accessKind(file)).toBe(expected) + }) +}) + +// Why a ratchet: a content-driven reader adopting user-file would bring back the round-1 leak, so +// every new importer must be a deliberate, reviewed addition to this list. +const USER_NAMED_ACCESS_IMPORTERS = [ + 'components/browser-pane/describe-page/browser-artifact-upload.ts', + 'components/browser-pane/navigate/navigate-browser-page-url.ts', + 'components/native-chat/NativeChatImageAttachmentPreview.tsx', + 'components/native-chat/NativeChatTranscriptChrome.tsx', + 'components/sidebar/useSidebarProjectDrop.ts', + 'components/tab-bar/tab-create-entry-absolute-file.ts', + 'components/terminal-pane/terminal-file-open-routing.ts', + 'hooks/composer-state/attachment-drop-state.ts', + 'hooks/useGlobalFileDrop.ts', + 'lib/local-file-access.ts' +] + +function collectSourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const full = join(dir, entry) + if (statSync(full).isDirectory()) { + return collectSourceFiles(full) + } + return /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry) ? [full] : [] + }) +} + +describe('user-named file access ratchet', () => { + it('is constructed only by gesture sites and the editor-tab rule', () => { + const rendererRoot = resolve(__dirname, '..') + const importers = collectSourceFiles(rendererRoot) + .filter((file) => + /\buserNamedFileAccess\b|kind: 'user-file'/.test(readFileSync(file, 'utf8')) + ) + .map((file) => relative(rendererRoot, file).split('\\').join('/')) + .sort() + + expect(importers).toEqual(USER_NAMED_ACCESS_IMPORTERS) + }) +}) diff --git a/src/renderer/src/lib/local-file-access.ts b/src/renderer/src/lib/local-file-access.ts new file mode 100644 index 000000000000..6f56b43c5183 --- /dev/null +++ b/src/renderer/src/lib/local-file-access.ts @@ -0,0 +1,55 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import { settingsForRuntimeOwner } from '@/runtime/runtime-client-target' +import type { OpenFile } from '@/store/slices/editor' +import type { AppState } from '@/store/types' +import { getConnectionIdForFile } from './connection-context' + +const USER_FILE_ACCESS: LocalFileAccess = { kind: 'user-file' } + +/** For a path the user named by a gesture (click, drop, typed path); never for document content. */ +export function userNamedFileAccess(): LocalFileAccess { + return USER_FILE_ACCESS +} + +/** For an image or PDF a document's content references; main limits it to the document's roots. */ +export function documentResourceAccess(documentPath: string): LocalFileAccess { + return { kind: 'document-resource', documentPath } +} + +type EditorTabAccessFile = Pick< + OpenFile, + | 'filePath' + | 'relativePath' + | 'worktreeId' + | 'runtimeEnvironmentId' + | 'externalSshTargetId' + | 'readOnly' + | 'liveTail' +> + +/** + * The shape a persisted editor tab reads and saves with. A tab the user opened outside its owner's + * root (a floating-workspace tab, or one stored with an absolute path) is user-named, so it reads + * the same before and after a restart; every other tab stays inside its project root. + */ +export function editorTabFileAccess( + state: Pick, + file: EditorTabAccessFile +): LocalFileAccess | undefined { + // Why: AI Vault logs are client-local files the user opened, whatever the worktree's host. + if (file.readOnly === true && file.liveTail === true) { + return USER_FILE_ACCESS + } + const runtimeOwner = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) + if (file.externalSshTargetId?.trim() || runtimeOwner?.activeRuntimeEnvironmentId?.trim()) { + return undefined + } + const outsideOwnerRoot = + file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID || file.relativePath === file.filePath + // Why null only: an SSH, ambiguous or not-yet-loaded owner may mean the path lives on another host. + if (!outsideOwnerRoot || getConnectionIdForFile(file.worktreeId, file.filePath) !== null) { + return undefined + } + return USER_FILE_ACCESS +} diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts index a7fec4a14d57..505143bfee0a 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts @@ -100,6 +100,9 @@ describe('mobile markdown bridge', () => { ok: true, result: { content: '# notes', source: 'file' } }) + expect(readFile).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/Users/me/notes.md', access: { kind: 'user-file' } }) + ) } finally { detach() } diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index 3b3b6361957a..baf5ef02775c 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -7,6 +7,7 @@ import { } from '@/components/editor/editor-autosave' import { flushPendingEditorChange } from '@/components/editor/editor-pending-flush' import { getConnectionIdForFile } from '@/lib/connection-context' +import { editorTabFileAccess } from '@/lib/local-file-access' import { refreshEditorTabExternalPathGrant } from '@/lib/editor-tab-external-path-grant' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' @@ -255,7 +256,8 @@ async function readFileContent(file: OpenFile): Promise { relativePath: file.relativePath, worktreeId: file.worktreeId, connectionId, - expectedExternalSshTargetId: file.externalSshTargetId + expectedExternalSshTargetId: file.externalSshTargetId, + access: editorTabFileAccess(state, file) })) as FileContent if (result.isBinary) { throw new Error('binary_file') diff --git a/src/renderer/src/runtime/runtime-file-client-types.ts b/src/renderer/src/runtime/runtime-file-client-types.ts index 6525fc7e3573..7f66f42b864a 100644 --- a/src/renderer/src/runtime/runtime-file-client-types.ts +++ b/src/renderer/src/runtime/runtime-file-client-types.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { LocalFileAccess } from '../../../shared/local-file-access' export type RuntimeReadableFileContent = { content: string @@ -16,6 +17,8 @@ export type RuntimeFileReadArgs = { connectionId?: string expectedExternalSshTargetId?: string includeLocalLogMetadata?: boolean + /** Shape of the local fallback read; remote reads stay root-relative. */ + access?: LocalFileAccess } export type RuntimeFileOperationArgs = { diff --git a/src/renderer/src/runtime/runtime-file-metadata-client.ts b/src/renderer/src/runtime/runtime-file-metadata-client.ts index e9d2e867b77d..e89c2c1e299f 100644 --- a/src/renderer/src/runtime/runtime-file-metadata-client.ts +++ b/src/renderer/src/runtime/runtime-file-metadata-client.ts @@ -3,6 +3,8 @@ import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { assertLocalFilesystemFallbackAllowed, getRemoteFileArgs } from './runtime-file-routing' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import { localAccess } from './runtime-file-read-client' export async function listRuntimeMarkdownDocuments( context: RuntimeFileOperationArgs, @@ -25,14 +27,16 @@ export async function listRuntimeMarkdownDocuments( export async function statRuntimePath( context: RuntimeFileOperationArgs, - absolutePath: string + absolutePath: string, + access?: LocalFileAccess ): Promise<{ size: number; isDirectory: boolean; mtime: number }> { const remoteArgs = getRemoteFileArgs(context, absolutePath) if (!remoteArgs) { assertLocalFilesystemFallbackAllowed(context) return window.api.fs.stat({ filePath: absolutePath, - connectionId: context.connectionId + connectionId: context.connectionId, + ...localAccess(context.connectionId, access) }) } return callRuntimeRpc<{ size: number; isDirectory: boolean; mtime: number }>( diff --git a/src/renderer/src/runtime/runtime-file-mutation-client.ts b/src/renderer/src/runtime/runtime-file-mutation-client.ts index b2a358a5200f..0e270bdfc198 100644 --- a/src/renderer/src/runtime/runtime-file-mutation-client.ts +++ b/src/renderer/src/runtime/runtime-file-mutation-client.ts @@ -11,6 +11,8 @@ import { } from './runtime-file-routing' import { callRuntimeFileMutation } from './runtime-file-mutation-rpc' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import { localAccess } from './runtime-file-read-client' export async function readRuntimeDirectory( context: RuntimeFileOperationArgs, @@ -32,13 +34,19 @@ export async function readRuntimeDirectory( export async function writeRuntimeFile( context: RuntimeFileOperationArgs, filePath: string, - content: string + content: string, + access?: LocalFileAccess ): Promise { const remoteArgs = getRemoteFileArgs(context, filePath) if (!remoteArgs) { assertLocalFilesystemFallbackAllowed(context) await window.api.fs.writeFile( - withSshMutationExpectation(context, { filePath, content, connectionId: context.connectionId }) + withSshMutationExpectation(context, { + filePath, + content, + connectionId: context.connectionId, + ...localAccess(context.connectionId, access) + }) ) return } diff --git a/src/renderer/src/runtime/runtime-file-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts index 40b74611c676..f05599752912 100644 --- a/src/renderer/src/runtime/runtime-file-read-client.ts +++ b/src/renderer/src/runtime/runtime-file-read-client.ts @@ -17,6 +17,7 @@ import { hasRemoteRuntimeOwner } from './runtime-file-routing' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import type { LocalFileAccess } from '../../../shared/local-file-access' const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024 const REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE = @@ -24,6 +25,14 @@ const REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE = type RemoteFileDownloadArgs = NonNullable> +// Why local only: an SSH read is the remote host's to check, and its relay never sees this field. +export function localAccess( + connectionId: string | undefined, + access: LocalFileAccess | undefined +): { access?: LocalFileAccess } { + return access && !connectionId ? { access } : {} +} + export async function readRuntimeFileContent({ settings, filePath, @@ -31,15 +40,18 @@ export async function readRuntimeFileContent({ worktreeId, connectionId, expectedExternalSshTargetId, - includeLocalLogMetadata + includeLocalLogMetadata, + access }: RuntimeFileReadArgs): Promise { assertExternalSshReadOwnership(settings, connectionId, expectedExternalSshTargetId) const target = getActiveRuntimeTarget(settings) - if (target.kind !== 'environment') { - return window.api.fs.readFile({ filePath, connectionId, includeLocalLogMetadata }) - } - if (!worktreeId) { - return window.api.fs.readFile({ filePath, connectionId, includeLocalLogMetadata }) + if (target.kind !== 'environment' || !worktreeId) { + return window.api.fs.readFile({ + filePath, + connectionId, + includeLocalLogMetadata, + ...localAccess(connectionId, access) + }) } if (!canReadRelativeRuntimeFile(relativePath)) { throw new Error('Remote file is outside the owning runtime worktree') @@ -78,7 +90,8 @@ export async function readRuntimeFileContent({ export async function readRuntimeFilePreview( context: RuntimeFileOperationArgs, - filePath: string + filePath: string, + access?: LocalFileAccess ): Promise { assertExternalSshReadOwnership( context.settings, @@ -90,7 +103,11 @@ export async function readRuntimeFilePreview( if (hasRemoteRuntimeOwner(context)) { throw new Error('Remote file is outside the owning runtime worktree') } - return window.api.fs.readFile({ filePath, connectionId: context.connectionId }) + return window.api.fs.readFile({ + filePath, + connectionId: context.connectionId, + ...localAccess(context.connectionId, access) + }) } return callRuntimeRpc( remoteArgs.target, From 01efba5c7e48872b80bd995f967fe9356dc37f82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:54:46 -0700 Subject: [PATCH 08/19] refactor(fs): delete the in-memory path grant system Problem: main kept a set of paths the renderer had asked it to allow (fs:authorizeExternalPath). The set lived only in memory, so a file the user opened outside every project could be read until Orca restarted and was then denied, and every new reader of a restored tab had to remember to recreate the grant first. Three rounds of re-deriving grants at restore each found another reader or path spelling it missed. Now that every desktop request declares its shape, nothing needs a grant: - delete the grant set, authorizeExternalPath, the restore re-grant from the earlier commits on this branch, the fs:authorizeExternalPath channel and its preload and web-client entries; - delete every renderer grant call (terminal and markdown link clicks, drops, typed paths, the file explorer, AI Vault logs, chat attachments, browser notebooks) and every main one (floating markdown picker and folder, OS-opened markdown, keybindings.json, pasted images, import and upload sources); - the floating workspace's picker-approved folders stay a terminal-cwd allowlist only. Main now holds no per-path permission, so a restart can't change any answer. --- src/main/ipc/app.test.ts | 10 +- src/main/ipc/app.ts | 10 +- src/main/ipc/filesystem-allowed-roots.test.ts | 21 +- src/main/ipc/filesystem-auth.test.ts | 46 +---- src/main/ipc/filesystem-auth.ts | 89 +-------- .../filesystem-import-local-rollback.test.ts | 1 - src/main/ipc/filesystem-import-local.ts | 9 +- .../filesystem-import-ssh-path-safety.test.ts | 3 - src/main/ipc/filesystem-import-ssh.ts | 3 - .../filesystem-provider-continuations.test.ts | 1 - .../ipc/filesystem-restored-tab-grant.test.ts | 180 ------------------ .../filesystem-runtime-upload-staging.test.ts | 1 - .../ipc/filesystem-runtime-upload-staging.ts | 5 - .../ipc/filesystem-search-rg-timeout.test.ts | 1 - src/main/ipc/filesystem.test.ts | 7 + .../filesystem/filesystem-write-handlers.ts | 15 -- .../ipc/floating-workspace-directory.test.ts | 43 ++--- src/main/ipc/floating-workspace-directory.ts | 11 +- src/main/ipc/keybindings.test.ts | 34 +--- src/main/ipc/keybindings.ts | 6 - .../ipc/runtime-upload-file-stream.test.ts | 1 - src/main/ipc/runtime-upload-file-stream.ts | 6 +- .../runtime-upload-slice-boundaries.test.ts | 1 - .../startup/os-opened-markdown-files.test.ts | 13 +- src/main/startup/os-opened-markdown-files.ts | 6 +- .../window/clipboard-image-temp-file.test.ts | 24 +-- src/main/window/clipboard-image-temp-file.ts | 4 - .../window/clipboard-ipc-handlers.test.ts | 5 +- .../clipboard-runtime-owned-ssh-paste.test.ts | 3 +- src/main/worktree-root-preparation.test.ts | 20 +- src/preload/api/filesystem-api.ts | 5 - src/preload/api/fs-bridge.ts | 4 - .../navigate/navigate-browser-page-url.ts | 9 +- .../editor-restored-tab-conflict-scan.test.ts | 48 ++--- .../editor-restored-tab-conflict-scan.ts | 6 - .../useEditorPanelContentState.test.tsx | 16 +- .../editor/useEditorPanelFileContentLoader.ts | 16 +- ...ditorPanelFloatingTabContentState.test.tsx | 43 ++--- ...orPanelHostUnresolvedContentState.test.tsx | 2 +- ...torPanelRemoteSiblingContentState.test.tsx | 13 +- .../native-chat-composer-drop-scope.test.tsx | 34 ++-- .../use-native-chat-composer-attachments.ts | 2 +- ...-native-chat-external-attachments.test.tsx | 65 ++++--- .../use-native-chat-external-attachments.ts | 11 +- .../ai-vault-session-log-open.test.ts | 48 +---- .../ai-vault-session-log-open.ts | 152 ++++++--------- .../file-explorer-drag-scroll-marker.test.tsx | 1 - .../file-explorer-instant-dir-toggle.test.ts | 1 - .../use-file-explorer-tree-pane-state.ts | 1 - .../useFileExplorerHandlers.test.ts | 96 ---------- .../right-sidebar/useFileExplorerHandlers.ts | 12 -- .../sidebar/useSidebarProjectDrop.ts | 1 - .../tab-bar/tab-create-entry-absolute-file.ts | 3 - .../tab-bar/tab-create-entry-action.test.ts | 30 ++- .../tab-bar/tab-create-entry-action.ts | 2 - .../terminal-file-open-routing.ts | 4 - .../terminal-link-handlers-test-fixtures.ts | 3 - .../terminal-link-handlers-test-harness.ts | 2 - .../terminal-link-missing-file-open.test.ts | 15 +- .../terminal-link-osc-file-targets.test.ts | 85 +++++---- ...minal-link-remote-runtime-ssh-open.test.ts | 4 - ...inal-link-worktree-root-activation.test.ts | 15 +- .../terminal-link-wsl-path-mapping.test.ts | 18 +- .../attachment-drop-failure.test.tsx | 1 - .../attachment-drop-lifetime.test.tsx | 133 +++++-------- .../composer-state/attachment-drop-state.ts | 4 - src/renderer/src/hooks/useGlobalFileDrop.ts | 8 +- .../editor-tab-external-path-grant.test.ts | 174 ----------------- .../src/lib/editor-tab-external-path-grant.ts | 51 ----- .../src/lib/local-file-access.test.ts | 1 + .../mobile-markdown-bridge-test-harness.ts | 7 +- .../runtime/mobile-markdown-bridge.test.ts | 44 +++-- .../src/runtime/mobile-markdown-bridge.ts | 6 - .../editor-markdown-link-activation.test.ts | 7 +- .../editor/actions/markdown-link-action.ts | 2 - .../src/web/preload-api/web-filesystem-api.ts | 1 - 76 files changed, 385 insertions(+), 1400 deletions(-) delete mode 100644 src/main/ipc/filesystem-restored-tab-grant.test.ts delete mode 100644 src/renderer/src/lib/editor-tab-external-path-grant.test.ts delete mode 100644 src/renderer/src/lib/editor-tab-external-path-grant.ts diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index e52668e1ce9e..3f72f5b8131b 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -10,7 +10,7 @@ const { destroySystemTrayMock, relaunchAppMock, showOpenDialogMock, - grantFloatingWorkspaceDirectoryMock, + trustFloatingWorkspaceDirectoryMock, registerRendererShutdownCheckpointHandlerMock, registerMacKeyboardLayoutChangeNotificationsMock } = vi.hoisted(() => ({ @@ -22,7 +22,7 @@ const { destroySystemTrayMock: vi.fn(), relaunchAppMock: vi.fn(), showOpenDialogMock: vi.fn(), - grantFloatingWorkspaceDirectoryMock: vi.fn(), + trustFloatingWorkspaceDirectoryMock: vi.fn(), registerRendererShutdownCheckpointHandlerMock: vi.fn(), registerMacKeyboardLayoutChangeNotificationsMock: vi.fn() })) @@ -103,7 +103,7 @@ vi.mock('../app-relaunch', () => ({ vi.mock('./floating-workspace-directory', () => ({ ensureDefaultFloatingWorkspacePath: vi.fn(), - grantFloatingWorkspaceDirectory: grantFloatingWorkspaceDirectoryMock, + trustFloatingWorkspaceDirectory: trustFloatingWorkspaceDirectoryMock, resolveFloatingTerminalCwd: vi.fn() })) @@ -155,7 +155,7 @@ describe('registerAppHandlers', () => { relaunchAppMock.mockReset() relaunchAppMock.mockImplementation(() => appRelaunchMock()) showOpenDialogMock.mockReset() - grantFloatingWorkspaceDirectoryMock.mockReset() + trustFloatingWorkspaceDirectoryMock.mockReset() registerRendererShutdownCheckpointHandlerMock.mockReset() registerMacKeyboardLayoutChangeNotificationsMock.mockReset() for (const probe of Object.values(windowsProbes)) { @@ -411,7 +411,7 @@ describe('registerAppHandlers', () => { expect(showOpenDialogMock).toHaveBeenCalledWith({ properties: ['openDirectory'] }) - expect(grantFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes') + expect(trustFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes') }) // Why: the renderer reads these on every Windows capability refresh; the sync probes diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 17b7663e11be..1ef982fe86f5 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -15,10 +15,9 @@ import { isWslAvailableAsync, listWslDistrosAsync } from '../wsl' import { isGitBashAvailable } from '../git-bash' import { setUnreadDockBadgeCount } from '../dock/unread-badge' import { destroySystemTray } from '../tray/system-tray' -import { authorizeExternalPath } from './filesystem-auth' import { ensureDefaultFloatingWorkspacePath, - grantFloatingWorkspaceDirectory, + trustFloatingWorkspaceDirectory, resolveFloatingTerminalCwd } from './floating-workspace-directory' import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown-documents' @@ -60,7 +59,6 @@ async function pickFloatingMarkdownDocument( if (!isMarkdownDocumentName(filePath)) { throw new Error('Selected file is not a markdown document.') } - authorizeExternalPath(filePath) return markdownDocumentFromFilePath(cwd, filePath, { outsideRootRelativePath: 'basename' }) } @@ -70,7 +68,7 @@ async function pickFloatingWorkspaceDirectory( ): Promise { const parentWindow = BrowserWindow.fromWebContents(event.sender) const options = { - // Why: this picker only grants access to an existing directory; creation belongs to explicit file actions. + // Why: this picker only chooses an existing directory; creation belongs to explicit file actions. properties: ['openDirectory'] } satisfies Electron.OpenDialogOptions const result = parentWindow @@ -80,8 +78,8 @@ async function pickFloatingWorkspaceDirectory( return null } const selectedDir = result.filePaths[0] - // Why: a user-approved picker selection is a trust grant for later markdown creation, unlike typed settings text. - await grantFloatingWorkspaceDirectory(store, selectedDir) + // Why: only a user-approved picker selection may become the floating terminal's cwd, unlike typed settings text. + await trustFloatingWorkspaceDirectory(store, selectedDir) return selectedDir } diff --git a/src/main/ipc/filesystem-allowed-roots.test.ts b/src/main/ipc/filesystem-allowed-roots.test.ts index ce4a30c62fa5..7d965f218d72 100644 --- a/src/main/ipc/filesystem-allowed-roots.test.ts +++ b/src/main/ipc/filesystem-allowed-roots.test.ts @@ -15,7 +15,7 @@ import type { ProjectGroup } from '../../shared/project-group-types' import type { Project } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' import { getAllowedRoots } from './filesystem-allowed-roots' -import { authorizeExternalPath, resolveAuthorizedPath } from './filesystem-auth' +import { resolveAuthorizedPath } from './filesystem-auth' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' @@ -358,25 +358,6 @@ describe('resolveAuthorizedPath allowed-root reuse', () => { } ) - it('builds no allowed-root list at all for a granted external path', async () => { - const external = join(outsideRoot, 'external.md') - await writeFile(external, 'notes\n') - authorizeExternalPath(external) - counts.getRepos = 0 - counts.getProjects = 0 - counts.getFolderWorkspaces = 0 - - for (let index = 0; index < 5; index += 1) { - await expect(resolveAuthorizedPath(external, store)).resolves.toBe(external) - } - - // The grant answers on its own; hoisting the snapshot must not turn zero builds into one per read. - expect.soft(counts.getRepos).toBe(0) - expect.soft(counts.getProjects).toBe(0) - expect.soft(counts.getFolderWorkspaces).toBe(0) - expect.soft(vi.mocked(buildProjectGroupChildIndex)).not.toHaveBeenCalled() - }) - it.skipIf(process.platform === 'win32')( 'still refuses a directory symlink that escapes every allowed root', async () => { diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index fa82b7a652c7..12a9995eecf5 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -10,12 +10,7 @@ import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' -import { - AUTHORIZED_EXTERNAL_PATHS_MAX, - authorizeExternalPath, - isPathAllowed, - resolveAuthorizedPath -} from './filesystem-auth' +import { resolveAuthorizedPath } from './filesystem-auth' import { isDescendantOrEqual, validateGitRelativeFilePath } from './filesystem-path-containment' import { __resetCreatedWorktreeRootsForTests, @@ -419,42 +414,3 @@ describe('filesystem-auth path containment', () => { } }) }) - -describe('filesystem-auth authorized external path bound', () => { - // Empty allow-list store, so a path is allowed only if it (or an ancestor) is - // in the session-authorized external-path set. - const emptyStore = makeStore([]) - const flood = (n: number): string => - resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`) - - it('bounds the authorized external path set with LRU eviction', () => { - const keep = resolve('/leak-audit-ext/keep') - authorizeExternalPath(keep) - - // Flood past the cap with distinct external paths, re-authorizing `keep` - // periodically so LRU keeps it hot. - const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200 - for (let i = 0; i < total; i += 1) { - authorizeExternalPath(flood(i)) - if (i % 250 === 0) { - authorizeExternalPath(keep) - } - } - - // The oldest never-re-touched entries fell out of the bounded set... - expect(isPathAllowed(flood(0), emptyStore)).toBe(false) - // ...while the periodically re-authorized path and the most recent survive. - expect(isPathAllowed(keep, emptyStore)).toBe(true) - expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true) - }) - - it('re-authorizes an evicted path on next use (self-healing)', () => { - const path = resolve('/leak-audit-ext/evicted-then-reused') - for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) { - authorizeExternalPath(flood(100_000 + i)) - } - expect(isPathAllowed(path, emptyStore)).toBe(false) - authorizeExternalPath(path) - expect(isPathAllowed(path, emptyStore)).toBe(true) - }) -}) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 93335424d2b6..2b5cc976212d 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -1,5 +1,4 @@ import { resolve, dirname, basename } from 'node:path' -import { realpathSync } from 'node:fs' import { realpath } from 'node:fs/promises' import type { Store } from '../persistence' import { getAllowedRoots } from './filesystem-allowed-roots' @@ -18,85 +17,7 @@ export { isENOENT } from './filesystem-path-containment' export const PATH_ACCESS_DENIED_MESSAGE = 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' -// Why: authorized external paths accumulate all session; LRU-bound the set. Safe to evict because every caller re-authorizes before operating. -export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096 -const authorizedExternalPaths = new Set() - -function rememberAuthorizedExternalPath(path: string): void { - // Delete-then-add makes re-authorized paths most-recent so LRU eviction sheds only the oldest untouched entries. - authorizedExternalPaths.delete(path) - authorizedExternalPaths.add(path) - while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) { - const oldest = authorizedExternalPaths.keys().next().value - if (oldest === undefined) { - break - } - authorizedExternalPaths.delete(oldest) - } -} - -export function authorizeExternalPath(targetPath: string): void { - const resolvedTarget = resolve(targetPath) - rememberAuthorizedExternalPath(resolvedTarget) - try { - // Why: macOS canonicalizes /tmp to /private/tmp during read authorization. - rememberAuthorizedExternalPath(realpathSync(resolvedTarget)) - } catch {} -} - -/** - * Re-derives a restored editor tab's grant without widening any project. A path inside a project in - * any spelling — as named, or through the real path of any ancestor folder — gets at most its named - * spelling, never its realpath, so a project symlink's outside target is never granted. - */ -export async function authorizeExternalPathOutsideAllowedRoots( - targetPath: string, - store: Store -): Promise { - const resolvedTarget = resolve(targetPath) - await ensureAuthorizedRootsCache(store) - const roots = getAllowedRoots(store) - const isInsideRoot = (path: string, candidateRoots: readonly string[]): boolean => - candidateRoots.some((root) => isDescendantOrEqual(path, root)) || - isRegisteredWorktreePath(path, store) - if (isInsideRoot(resolvedTarget, roots)) { - return - } - const realRoots = await Promise.all( - roots.map((root) => normalizeExistingPath(root).catch(() => root)) - ) - const rootSpellings = [...roots, ...realRoots] - // Why every ancestor: past an escaping directory symlink the real path has left the project, so - // only a higher ancestor's real path shows the named path entered it. The leaf is skipped: a file - // that links into a project is not a project path. - for (let ancestor = dirname(resolvedTarget); ; ancestor = dirname(ancestor)) { - let realAncestor: string | null = null - try { - realAncestor = resolve(await realpath(ancestor)) - } catch (error) { - if (!isENOENT(error)) { - // Why fail closed: an unresolvable ancestor can't prove the path stays outside every project. - rememberAuthorizedExternalPath(resolvedTarget) - return - } - } - if (realAncestor !== null && isInsideRoot(realAncestor, rootSpellings)) { - rememberAuthorizedExternalPath(resolvedTarget) - return - } - if (dirname(ancestor) === ancestor) { - break - } - } - authorizeExternalPath(resolvedTarget) -} - -/** - * One allowed-root list shared by every check in a single authorization. - * - * Lazy so a path already covered by an external grant still builds nothing at all, the way it did - * before the list was hoisted out of the individual checks. - */ +/** One allowed-root list shared by every check in a single authorization, built on first use. */ type AllowedRootsSnapshot = { get: () => readonly string[] } function createAllowedRootsSnapshot( @@ -113,14 +34,6 @@ export function isPathAllowed( allowedRoots?: AllowedRootsSnapshot ): boolean { const resolvedTarget = resolve(targetPath) - if (authorizedExternalPaths.has(resolvedTarget)) { - return true - } - for (const authorizedPath of authorizedExternalPaths) { - if (isDescendantOrEqual(resolvedTarget, authorizedPath)) { - return true - } - } return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) => isDescendantOrEqual(resolvedTarget, root) ) diff --git a/src/main/ipc/filesystem-import-local-rollback.test.ts b/src/main/ipc/filesystem-import-local-rollback.test.ts index 7e196d700dcd..b76fe597b352 100644 --- a/src/main/ipc/filesystem-import-local-rollback.test.ts +++ b/src/main/ipc/filesystem-import-local-rollback.test.ts @@ -12,7 +12,6 @@ const { lstatMock, mkdirMock, openMock, readdirMock, rmMock, unlinkMock } = vi.h unlinkMock: vi.fn() })) -vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: vi.fn() })) vi.mock('node:fs/promises', () => ({ lstat: lstatMock, mkdir: mkdirMock, diff --git a/src/main/ipc/filesystem-import-local.ts b/src/main/ipc/filesystem-import-local.ts index c77d841a53d7..d7960a2beebe 100644 --- a/src/main/ipc/filesystem-import-local.ts +++ b/src/main/ipc/filesystem-import-local.ts @@ -1,6 +1,5 @@ import { lstat } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' -import { authorizeExternalPath } from './filesystem-auth' import { isENOENT } from './filesystem-path-containment' import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { @@ -10,8 +9,8 @@ import { } from './filesystem-import-local-tree-copy' /** - * Import a single top-level source into destDir, handling authorization, - * validation, pre-scan, deconfliction, and copy. + * Import a single top-level source into destDir, handling validation, pre-scan, + * deconfliction, and copy. */ export async function importOneSource( sourcePath: string, @@ -20,10 +19,6 @@ export async function importOneSource( ): Promise { const resolvedSource = resolve(sourcePath) - // Why: authorize the external source path so downstream filesystem - // operations (lstat, readdir, copyFile) are permitted by Electron. - authorizeExternalPath(resolvedSource) - // Why: validate source using lstat on the unresolved path *before* // canonicalization so top-level symlinks are rejected instead of being // silently dereferenced by realpath. diff --git a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts index 297dcb220458..3a6b0f2f86c7 100644 --- a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts +++ b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts @@ -15,9 +15,6 @@ vi.mock('node:fs/promises', () => ({ readdir: readdirMock, realpath: realpathMock })) -vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: vi.fn() -})) vi.mock('./filesystem-path-containment', () => ({ isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT' })) diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index 39b2350bb44e..2e0dc51753a5 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -1,6 +1,5 @@ import { lstat } from 'node:fs/promises' import { basename, posix, resolve } from 'node:path' -import { authorizeExternalPath } from './filesystem-auth' import { isENOENT } from './filesystem-path-containment' import { getSshConnectionManager } from './ssh' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' @@ -105,8 +104,6 @@ async function importOneSourceSsh( ): Promise { const resolvedSource = resolve(sourcePath) - authorizeExternalPath(resolvedSource) - const originalName = basename(resolvedSource) try { assertSafeRemotePathSegment(originalName, remotePathFlavor) diff --git a/src/main/ipc/filesystem-provider-continuations.test.ts b/src/main/ipc/filesystem-provider-continuations.test.ts index 04abc927dd27..4aece9bc2764 100644 --- a/src/main/ipc/filesystem-provider-continuations.test.ts +++ b/src/main/ipc/filesystem-provider-continuations.test.ts @@ -36,7 +36,6 @@ vi.mock('node:fs/promises', () => ({ }) })) vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: vi.fn(), resolveAuthorizedPath: mocks.resolveAuthorizedPath })) vi.mock('./filesystem-mutations', () => ({ registerFilesystemMutationHandlers: vi.fn() })) diff --git a/src/main/ipc/filesystem-restored-tab-grant.test.ts b/src/main/ipc/filesystem-restored-tab-grant.test.ts deleted file mode 100644 index 6fbe8bb9aef6..000000000000 --- a/src/main/ipc/filesystem-restored-tab-grant.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { mkdir, mkdtemp, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Store } from '../persistence' -import type * as RepoWorktrees from '../repo-worktrees' -import { authorizeExternalPathOutsideAllowedRoots, resolveAuthorizedPath } from './filesystem-auth' -import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' - -vi.mock('../repo-worktrees', async () => { - const actual = await vi.importActual('../repo-worktrees') - return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } -}) - -type Registration = { repoPath?: string; folderPath?: string } - -function makeStore({ repoPath, folderPath }: Registration): Store { - const repos = repoPath - ? [{ id: 'repo-local', path: repoPath, displayName: 'project', badgeColor: '#000', addedAt: 0 }] - : [] - const folders = folderPath ? [{ id: 'folder', folderPath, projectGroupId: 'none' }] : [] - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. - return { - getRepos: () => repos.map((repo) => ({ ...repo })), - getProjects: () => [], - getProjectGroups: () => [], - getFolderWorkspaces: () => folders.map((folder) => ({ ...folder })), - getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) - } as unknown as Store -} - -async function isReadable(path: string, store: Store): Promise { - return resolveAuthorizedPath(path, store).then( - () => true, - () => false - ) -} - -let base: string - -beforeEach(async () => { - invalidateAuthorizedRootsCache() - base = await mkdtemp(join(await realpath(tmpdir()), 'orca-restored-grant-')) -}) - -afterEach(async () => { - await rm(base, { recursive: true, force: true }) -}) - -describe('authorizeExternalPathOutsideAllowedRoots', () => { - it('grants a restored floating-workspace file outside every project', async () => { - const project = join(base, 'project') - await mkdir(project) - const store = makeStore({ repoPath: project }) - const notes = join(base, 'home-notes.txt') - await writeFile(notes, 'home\n') - expect(await isReadable(notes, store)).toBe(false) - - await authorizeExternalPathOutsideAllowedRoots(notes, store) - - expect(await isReadable(notes, store)).toBe(true) - }) -}) - -// Why: creating a symlink on Windows needs elevation or Developer Mode. -describe.skipIf(process.platform === 'win32')('restore grants and project symlinks', () => { - let project: string - let alias: string - let alias2: string - let privateBase: string - let varAlias: string - let secret: string - - beforeEach(async () => { - // Mirrors macOS /var -> /private/var: an alias of an ancestor above the project root. - privateBase = join(base, 'private') - varAlias = join(base, 'var') - project = join(privateBase, 'real', 'project') - alias = join(base, 'alias-project') - alias2 = join(base, 'alias2-project') - const outside = join(base, 'outside') - await mkdir(join(project, 'sub'), { recursive: true }) - await mkdir(outside) - await symlink(privateBase, varAlias) - await symlink(project, alias) - await symlink(project, alias2) - secret = join(outside, 'secret.txt') - await writeFile(secret, 'secret\n') - await writeFile(join(project, 'notes.md'), 'notes\n') - await symlink(secret, join(project, 'escape.txt')) - await symlink(outside, join(project, 'dir-link')) - await symlink(outside, join(project, 'sub', 'deep-link')) - }) - - const viaDirLink = (root: string): string => join(root, 'dir-link', 'secret.txt') - - // Each spelling must still read as inside the project, or the full grant exposes the target. - it.each<[string, () => Registration, () => string]>([ - [ - 'a leaf symlink named as registered', - () => ({ repoPath: project }), - () => join(project, 'escape.txt') - ], - [ - 'a directory symlink named as registered', - () => ({ repoPath: project }), - () => viaDirLink(project) - ], - [ - 'a directory symlink named through an alias', - () => ({ repoPath: project }), - () => viaDirLink(alias) - ], - [ - 'a directory symlink, registered and named through two aliases', - () => ({ repoPath: alias }), - () => viaDirLink(alias2) - ], - [ - 'a directory symlink, registered via alias and named canonically', - () => ({ folderPath: alias }), - () => viaDirLink(project) - ], - [ - 'a deep directory symlink in a folder workspace', - () => ({ folderPath: project }), - () => join(alias, 'sub', 'deep-link', 'secret.txt') - ], - [ - 'a directory symlink named with `..`', - () => ({ repoPath: project }), - () => join(alias, 'sub', '..', 'dir-link', 'secret.txt') - ], - [ - 'a directory symlink named through an ancestor alias', - () => ({ repoPath: project }), - () => viaDirLink(project.replace(privateBase, varAlias)) - ], - [ - 'a leaf symlink named through an ancestor alias', - () => ({ repoPath: project }), - () => join(project.replace(privateBase, varAlias), 'escape.txt') - ] - ])('never grants the outside target of %s', async (_label, registration, namedPath) => { - const store = makeStore(registration()) - - await authorizeExternalPathOutsideAllowedRoots(namedPath(), store) - - expect(await isReadable(secret, store)).toBe(false) - expect(await isReadable(namedPath(), store)).toBe(false) - }) - - it('never grants the outside target of a case-variant spelling on a case-insensitive disk', async (ctx) => { - const caseVariant = project.replace(`${privateBase}/real`, `${privateBase}/REAL`) - const caseInsensitive = await stat(caseVariant).then( - () => true, - () => false - ) - if (!caseInsensitive) { - ctx.skip() - } - const store = makeStore({ repoPath: project }) - - await authorizeExternalPathOutsideAllowedRoots(viaDirLink(caseVariant), store) - - expect(await isReadable(secret, store)).toBe(false) - }) - - it.each([ - ['registered through an alias, named canonically', 'alias', 'project'], - ['registered canonically, named through an alias', 'project', 'alias'] - ])('keeps a project file %s readable', async (_label, registeredAs, namedAs) => { - const store = makeStore({ folderPath: registeredAs === 'alias' ? alias : project }) - const named = join(namedAs === 'alias' ? alias : project, 'notes.md') - - await authorizeExternalPathOutsideAllowedRoots(named, store) - - await expect(resolveAuthorizedPath(named, store)).resolves.toBe(join(project, 'notes.md')) - }) -}) diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts index de32bd8ebb4c..7f6b4d96b175 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.test.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -6,7 +6,6 @@ import type * as RuntimeImportLimits from './runtime-import-limits' type RuntimeImportLimitsModule = typeof RuntimeImportLimits -vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) // Why: real ceilings are gigabytes, and truncate() is not sparse on NTFS, so a // literal over-limit fixture would allocate that much on Windows CI. vi.mock('./runtime-import-limits', async (importOriginal) => ({ diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index 6531f8a499a1..9d5d8467e3ee 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -6,7 +6,6 @@ import { import { constants } from 'node:fs' import { lstat, open, readdir, realpath } from 'node:fs/promises' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' -import { authorizeExternalPath } from './filesystem-auth' import { isENOENT } from './filesystem-path-containment' import type { StagedExternalImportEntry, @@ -36,10 +35,6 @@ export async function stageOneSourceForRuntimeUpload( ): Promise { const resolvedSource = resolve(sourcePath) - // Why: runtime uploads read client-local paths in the client main process; - // authorize before lstat just like local copy imports. - authorizeExternalPath(resolvedSource) - let sourceStat: Awaited> try { sourceStat = await lstat(resolvedSource) diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index fe8dcda09ba6..1eb3572acc03 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -44,7 +44,6 @@ vi.mock('../wsl', () => ({ })) vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: vi.fn(async (value: string) => value), resolveAuthorizedPath: resolveAuthorizedPathMock })) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 4c2ea4330e10..8582a3b00acf 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -84,6 +84,13 @@ describe('registerFilesystemHandlers', () => { invalidateAuthorizedRootsCache() }) + it('registers no channel that remembers a path grant', () => { + registerFilesystemHandlers(store as never) + + expect(handlers.has('fs:readFile')).toBe(true) + expect(handlers.has('fs:authorizeExternalPath')).toBe(false) + }) + it('re-sorts SSH provider listings directories-first in natural order', async () => { // Why: the remote relay may be an older build that still sorts lexicographically. getSshFilesystemProviderMock.mockReturnValueOnce({ diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index c02debba2ef5..1e27ff34247d 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -6,7 +6,6 @@ import { runSshProviderContinuation } from '../../ssh/ssh-provider-continuations import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' import type { LocalFileAccess } from '../../../shared/local-file-access' -import { authorizeExternalPath, authorizeExternalPathOutsideAllowedRoots } from '../filesystem-auth' import { resolveDesktopAuthorizedPath, resolveLocalWriteRequestPath @@ -101,18 +100,4 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) registerFilesystemMutationHandlers(store) - - ipcMain.handle( - 'fs:authorizeExternalPath', - async ( - _event, - args: { targetPath: string; skipIfInsideAllowedRoots?: boolean } - ): Promise => { - if (args.skipIfInsideAllowedRoots === true) { - await authorizeExternalPathOutsideAllowedRoots(args.targetPath, store) - return - } - authorizeExternalPath(args.targetPath) - } - ) } diff --git a/src/main/ipc/floating-workspace-directory.test.ts b/src/main/ipc/floating-workspace-directory.test.ts index 2b372586cf12..010a0ce40bf9 100644 --- a/src/main/ipc/floating-workspace-directory.test.ts +++ b/src/main/ipc/floating-workspace-directory.test.ts @@ -4,9 +4,8 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' -const { appGetPathMock, authorizeExternalPathMock } = vi.hoisted(() => ({ - appGetPathMock: vi.fn(), - authorizeExternalPathMock: vi.fn() +const { appGetPathMock } = vi.hoisted(() => ({ + appGetPathMock: vi.fn() })) vi.mock('electron', () => ({ @@ -15,13 +14,9 @@ vi.mock('electron', () => ({ } })) -vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: authorizeExternalPathMock -})) - import { ensureDefaultFloatingWorkspacePath, - grantFloatingWorkspaceDirectory, + trustFloatingWorkspaceDirectory, resolveFloatingTerminalCwd, sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory' @@ -48,7 +43,7 @@ function createStore(settings: Partial = {}): TestStore { return store } -describe('floating workspace directory authorization', () => { +describe('floating workspace directory', () => { let tempRoot: string let homeDir: string let userDataDir: string @@ -67,7 +62,6 @@ describe('floating workspace directory authorization', () => { } throw new Error(`unexpected app path: ${name}`) }) - authorizeExternalPathMock.mockClear() }) afterEach(async () => { @@ -78,46 +72,37 @@ describe('floating workspace directory authorization', () => { await symlink(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir') } - it('defaults terminal cwd to home without authorizing home for markdown writes', async () => { + it('defaults terminal cwd to home', async () => { const store = createStore() await expect(resolveFloatingTerminalCwd(store as never)).resolves.toBe(homeDir) - - expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(homeDir) }) it('keeps the app-owned directory for floating markdown notes', async () => { await expect(ensureDefaultFloatingWorkspacePath()).resolves.toBe( path.join(userDataDir, 'floating-workspace') ) - - expect(authorizeExternalPathMock).toHaveBeenCalledWith( - path.join(userDataDir, 'floating-workspace') - ) }) - it('persists picker-approved directories and reauthorizes them on resolution', async () => { + it('persists picker-approved directories and resolves them as the cwd', async () => { const store = createStore() const selectedDir = path.join(tempRoot, 'notes') await mkdir(selectedDir) const canonicalSelectedDir = await realpath(selectedDir) - await grantFloatingWorkspaceDirectory(store as never, selectedDir) + await trustFloatingWorkspaceDirectory(store as never, selectedDir) expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalSelectedDir]) - expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalSelectedDir) - authorizeExternalPathMock.mockClear() await expect( resolveFloatingTerminalCwd(store as never, { path: selectedDir, requireTrusted: true }) ).resolves.toBe(canonicalSelectedDir) - expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalSelectedDir) }) - it('stores symlink grants as canonical targets and rejects the link after retargeting', async () => { + it('stores a symlinked choice as its canonical target and rejects the link after retargeting', async () => { const store = createStore() const originalTarget = path.join(tempRoot, 'original-target') const retargetedTarget = path.join(tempRoot, 'retargeted-target') @@ -127,16 +112,12 @@ describe('floating workspace directory authorization', () => { await symlinkDirectory(originalTarget, selectedLink) const canonicalOriginalTarget = await realpath(originalTarget) - await grantFloatingWorkspaceDirectory(store as never, selectedLink) + await trustFloatingWorkspaceDirectory(store as never, selectedLink) expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalOriginalTarget]) - expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalOriginalTarget) await unlink(selectedLink) await symlinkDirectory(retargetedTarget, selectedLink) - const canonicalRetargetedTarget = await realpath(retargetedTarget) - - authorizeExternalPathMock.mockClear() await expect( resolveFloatingTerminalCwd(store as never, { path: selectedLink, @@ -146,10 +127,9 @@ describe('floating workspace directory authorization', () => { await expect( sanitizeFloatingWorkspaceDirectorySetting(store as never, selectedLink) ).resolves.toBe('') - expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(canonicalRetargetedTarget) }) - it('keeps temporarily inaccessible trusted directories when adding a new grant', async () => { + it('keeps temporarily inaccessible trusted directories when adding a new one', async () => { const missingTrustedDir = path.join(tempRoot, 'offline-drive', 'notes') const selectedDir = path.join(tempRoot, 'new-notes') await mkdir(selectedDir) @@ -158,7 +138,7 @@ describe('floating workspace directory authorization', () => { floatingTerminalTrustedCwds: [missingTrustedDir] }) - await grantFloatingWorkspaceDirectory(store as never, selectedDir) + await trustFloatingWorkspaceDirectory(store as never, selectedDir) expect(store.settings.floatingTerminalTrustedCwds).toEqual([ missingTrustedDir, @@ -200,6 +180,5 @@ describe('floating workspace directory authorization', () => { await expect(resolveFloatingTerminalCwd(store as never, { path: arbitraryDir })).resolves.toBe( arbitraryDir ) - expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(arbitraryDir) }) }) diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index 9e74ef1a78ef..134d4c73d50e 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -5,7 +5,6 @@ import { app } from 'electron' import type { GlobalSettings } from '../../shared/global-settings-types' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import type { Store } from '../persistence' -import { authorizeExternalPath } from './filesystem-auth' const FLOATING_WORKSPACE_DIRNAME = 'floating-workspace' @@ -76,9 +75,6 @@ export function getDefaultFloatingWorkspacePath(): string { export async function ensureDefaultFloatingWorkspacePath(): Promise { const cwd = getDefaultFloatingWorkspacePath() await mkdir(cwd, { recursive: true }) - // Why: the default floating workspace lives outside repo roots by design; - // authorize only this app-owned directory instead of widening access to ~. - authorizeExternalPath(cwd) return cwd } @@ -99,17 +95,15 @@ export async function resolveFloatingTerminalCwd( return ensureDefaultFloatingWorkspacePath() } + // Why: only picker-approved directories may become the cwd, so arbitrary settings text can't. if (isTrustedFloatingWorkspaceDirectory(canonicalCwd, store.getSettings())) { - // Why: picker-approved directories are persisted as explicit grants, so a - // restart can restore file creation access without trusting arbitrary text. - authorizeExternalPath(canonicalCwd) return canonicalCwd } return args?.requireTrusted === true ? ensureDefaultFloatingWorkspacePath() : cwd } -export async function grantFloatingWorkspaceDirectory( +export async function trustFloatingWorkspaceDirectory( store: Store, dirPath: string ): Promise { @@ -118,7 +112,6 @@ export async function grantFloatingWorkspaceDirectory( if (!canonicalDir) { return } - authorizeExternalPath(canonicalDir) const trustedDirectories = await getPreservedTrustedFloatingWorkspaceDirectories( store.getSettings() ) diff --git a/src/main/ipc/keybindings.test.ts b/src/main/ipc/keybindings.test.ts index ec8016287d52..7199d39a0a40 100644 --- a/src/main/ipc/keybindings.test.ts +++ b/src/main/ipc/keybindings.test.ts @@ -1,21 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { KeybindingFileSnapshot } from '../../shared/keybindings' -const { - authorizeExternalPathMock, - getAllWindowsMock, - handleMock, - openPathMock, - rebuildAppMenuMock, - showItemInFolderMock -} = vi.hoisted(() => ({ - authorizeExternalPathMock: vi.fn(), - getAllWindowsMock: vi.fn(() => []), - handleMock: vi.fn(), - openPathMock: vi.fn(), - rebuildAppMenuMock: vi.fn(), - showItemInFolderMock: vi.fn() -})) +const { getAllWindowsMock, handleMock, openPathMock, rebuildAppMenuMock, showItemInFolderMock } = + vi.hoisted(() => ({ + getAllWindowsMock: vi.fn(() => []), + handleMock: vi.fn(), + openPathMock: vi.fn(), + rebuildAppMenuMock: vi.fn(), + showItemInFolderMock: vi.fn() + })) vi.mock('electron', () => ({ BrowserWindow: { @@ -30,10 +23,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: authorizeExternalPathMock -})) - vi.mock('../menu/register-app-menu', () => ({ rebuildAppMenu: rebuildAppMenuMock })) @@ -60,7 +49,6 @@ function getHandler(channel: string): (...args: unknown[]) => unknown { describe('registerKeybindingHandlers', () => { beforeEach(() => { - authorizeExternalPathMock.mockReset() getAllWindowsMock.mockReturnValue([]) handleMock.mockReset() openPathMock.mockReset() @@ -68,11 +56,10 @@ describe('registerKeybindingHandlers', () => { showItemInFolderMock.mockReset() }) - it('authorizes the keybindings file for in-app editing when ensuring it exists', () => { + it('returns the keybindings file when ensuring it exists', () => { registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never) expect(getHandler('keybindings:ensureFile')()).toBe(snapshot) - expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path) }) it('reconciles plugin command conflicts after a shortcut edit', () => { @@ -92,12 +79,11 @@ describe('registerKeybindingHandlers', () => { expect(onChanged).toHaveBeenCalledOnce() }) - it('authorizes the keybindings file before opening it outside Orca', async () => { + it('opens the keybindings file outside Orca', async () => { openPathMock.mockResolvedValue('') registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never) await expect(getHandler('keybindings:openFile')()).resolves.toBe(snapshot) - expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path) expect(openPathMock).toHaveBeenCalledWith(snapshot.path) }) }) diff --git a/src/main/ipc/keybindings.ts b/src/main/ipc/keybindings.ts index 7a71f3116c00..6e137bf6ca69 100644 --- a/src/main/ipc/keybindings.ts +++ b/src/main/ipc/keybindings.ts @@ -2,7 +2,6 @@ import { BrowserWindow, ipcMain, shell } from 'electron' import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings' import type { KeybindingService } from '../keybindings/keybinding-service' import { rebuildAppMenu } from '../menu/register-app-menu' -import { authorizeExternalPath } from './filesystem-auth' function broadcastKeybindingsChanged(snapshot: KeybindingFileSnapshot): void { for (const window of BrowserWindow.getAllWindows()) { @@ -21,9 +20,6 @@ export function registerKeybindingHandlers( ipcMain.handle('keybindings:ensureFile', () => { const snapshot = service.ensureFile() - // Why: keybindings.json lives in Orca's app config directory, not inside a - // workspace. Opening it in the editor still needs normal fs IPC access. - authorizeExternalPath(snapshot.path) broadcastKeybindingsChanged(snapshot) onChanged?.() return snapshot @@ -48,7 +44,6 @@ export function registerKeybindingHandlers( ipcMain.handle('keybindings:openFile', async () => { const snapshot = service.ensureFile() - authorizeExternalPath(snapshot.path) const error = await shell.openPath(snapshot.path) if (error) { throw new Error(error) @@ -58,7 +53,6 @@ export function registerKeybindingHandlers( ipcMain.handle('keybindings:revealFile', () => { const snapshot = service.ensureFile() - authorizeExternalPath(snapshot.path) shell.showItemInFolder(snapshot.path) return snapshot }) diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts index 4d11bc186529..87d2c66c61a4 100644 --- a/src/main/ipc/runtime-upload-file-stream.test.ts +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -35,7 +35,6 @@ vi.mock('./runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: (...args: Parameters) => callRuntimeEnvironment(...args) })) -vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) // Why: see filesystem-runtime-upload-staging.test.ts — a real over-limit fixture // would allocate gigabytes on Windows. vi.mock('./runtime-import-limits', async (importOriginal) => ({ diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index 28271774ce09..079549e47111 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -5,7 +5,6 @@ import type { RuntimeUploadFileStreamRequest, StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract' -import { authorizeExternalPath } from './filesystem-auth' import { formatByteCeiling, REMOTE_IMPORT_MAX_FILE_BYTES } from './runtime-import-limits' import { isRuntimeEnvironmentManuallyDisconnected, @@ -39,9 +38,6 @@ export async function streamExternalFileToRuntime( ): Promise<{ byteLength: number }> { const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath) - // Why: parity with staging — an OS drop authorizes the paths it hands over. - authorizeExternalPath(sourcePath) - // Why: relativePath is the hidden .orca-upload- temp destination, so a // dropped file names its source instead of a path the user never chose. const displayPath = args.entryRelativePath || basename(args.sourceRootPath) @@ -189,7 +185,7 @@ async function sendChunk( } function resolveEntrySourcePath(sourceRootPath: string, entryRelativePath: string): string { - // Why: staging resolves before authorizing, so the streamer has to agree on + // Why: staging resolves the source first, so the streamer has to agree on // the same absolute path or the two checks can disagree. const root = resolve(sourceRootPath) return entryRelativePath ? join(root, entryRelativePath) : root diff --git a/src/main/ipc/runtime-upload-slice-boundaries.test.ts b/src/main/ipc/runtime-upload-slice-boundaries.test.ts index 0c2bd457b1f6..6a7b580e086f 100644 --- a/src/main/ipc/runtime-upload-slice-boundaries.test.ts +++ b/src/main/ipc/runtime-upload-slice-boundaries.test.ts @@ -16,7 +16,6 @@ import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-uploa // Why: real limits, real host write flags ('wx' then 'a') and the real chunk // schema — the slice loop is exercised exactly at the boundaries it must respect. -vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) type ChunkParams = { relativePath: string; contentBase64: string; append: boolean } type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } diff --git a/src/main/startup/os-opened-markdown-files.test.ts b/src/main/startup/os-opened-markdown-files.test.ts index c980b6258d20..855422a5ba39 100644 --- a/src/main/startup/os-opened-markdown-files.test.ts +++ b/src/main/startup/os-opened-markdown-files.test.ts @@ -10,14 +10,10 @@ import { resolveOpenedMarkdownDocuments } from './os-opened-markdown-files' -vi.mock('../ipc/filesystem-auth', () => ({ - authorizeExternalPath: vi.fn() -})) vi.mock('../ipc/floating-workspace-directory', () => ({ ensureDefaultFloatingWorkspacePath: vi.fn() })) -const { authorizeExternalPath } = await import('../ipc/filesystem-auth') const { ensureDefaultFloatingWorkspacePath } = await import('../ipc/floating-workspace-directory') describe('markdownPathsFromArguments', () => { @@ -248,7 +244,6 @@ describe('resolveOpenedMarkdownDocuments', () => { let fileRoot: string beforeEach(async () => { - vi.mocked(authorizeExternalPath).mockClear() vi.mocked(ensureDefaultFloatingWorkspacePath).mockClear() floatingRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-root-')) fileRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-files-')) @@ -274,7 +269,6 @@ describe('resolveOpenedMarkdownDocuments', () => { name: 'design notes' } ]) - expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) }) it('drops a directory that merely looks like a markdown file', async () => { @@ -286,22 +280,17 @@ describe('resolveOpenedMarkdownDocuments', () => { const documents = await resolveOpenedMarkdownDocuments([bundlePath, filePath]) expect(documents.map((document) => document.filePath)).toEqual([filePath]) - // Security contract: a path we never validated must never be authorized for renderer reads. - expect(authorizeExternalPath).toHaveBeenCalledTimes(1) - expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) }) - it('drops a path that no longer exists without authorizing it', async () => { + it('drops a path that no longer exists', async () => { const missingPath = join(fileRoot, 'gone.md') expect(await resolveOpenedMarkdownDocuments([missingPath])).toEqual([]) - expect(authorizeExternalPath).not.toHaveBeenCalled() }) it('returns nothing for an empty input without touching the filesystem', async () => { expect(await resolveOpenedMarkdownDocuments([])).toEqual([]) expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled() - expect(authorizeExternalPath).not.toHaveBeenCalled() }) }) diff --git a/src/main/startup/os-opened-markdown-files.ts b/src/main/startup/os-opened-markdown-files.ts index 144f59fe5780..be373190ec4d 100644 --- a/src/main/startup/os-opened-markdown-files.ts +++ b/src/main/startup/os-opened-markdown-files.ts @@ -2,7 +2,6 @@ import { stat } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { MarkdownDocument } from '../../shared/filesystem-entry-types' -import { authorizeExternalPath } from '../ipc/filesystem-auth' import { ensureDefaultFloatingWorkspacePath } from '../ipc/floating-workspace-directory' import { isMarkdownDocumentName, markdownDocumentFromFilePath } from '../ipc/markdown-documents' @@ -130,7 +129,7 @@ export class OsOpenedMarkdownFileState { /** * Turns OS-handed paths into the same `MarkdownDocument` shape the floating workspace's own - * file picker produces, authorizing each one for the renderer's later read. + * file picker produces; the floating tab then reads each one as a user-named file. */ export async function resolveOpenedMarkdownDocuments( filePaths: readonly string[] @@ -143,14 +142,13 @@ export async function resolveOpenedMarkdownDocuments( for (const filePath of filePaths) { try { // Why: the shell can hand over a bundle directory named `*.md`, or a path already - // deleted by the time we resolve. Authorize only something that is really a file. + // deleted by the time we resolve. Open only something that is really a file. if (!(await stat(filePath)).isFile()) { continue } } catch { continue } - authorizeExternalPath(filePath) documents.push( markdownDocumentFromFilePath(floatingRoot, filePath, { outsideRootRelativePath: 'basename' diff --git a/src/main/window/clipboard-image-temp-file.test.ts b/src/main/window/clipboard-image-temp-file.test.ts index 71ca0804c0ef..e6fdd9f900b6 100644 --- a/src/main/window/clipboard-image-temp-file.test.ts +++ b/src/main/window/clipboard-image-temp-file.test.ts @@ -1,13 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { authorizeExternalPathMock, writeFileMock, getPathMock, writeFileBase64Mock } = vi.hoisted( - () => ({ - authorizeExternalPathMock: vi.fn(), - writeFileMock: vi.fn(), - getPathMock: vi.fn(() => '/var/folders/ab/T'), - writeFileBase64Mock: vi.fn() - }) -) +const { writeFileMock, getPathMock, writeFileBase64Mock } = vi.hoisted(() => ({ + writeFileMock: vi.fn(), + getPathMock: vi.fn(() => '/var/folders/ab/T'), + writeFileBase64Mock: vi.fn() +})) vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock } })) vi.mock('node:crypto', () => ({ randomUUID: () => 'uuid-1' })) @@ -20,7 +17,6 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ writeFileBase64: writeFileBase64Mock }) })) -vi.mock('../ipc/filesystem-auth', () => ({ authorizeExternalPath: authorizeExternalPathMock })) import { saveClipboardImageBufferAsTempFile } from './clipboard-image-temp-file' @@ -29,22 +25,20 @@ beforeEach(() => { }) describe('saveClipboardImageBufferAsTempFile', () => { - it('authorizes the local temp file so the composer can preview what it just wrote', async () => { + it('writes the pasted image to the local temp folder', async () => { const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3])) + expect(savedPath.startsWith('/var/folders/ab/T')).toBe(true) expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3])) - // The OS temp dir is outside every allowed root, so an unauthorized path - // makes fs:readFile deny the preview read of Orca's own file. - expect(authorizeExternalPathMock).toHaveBeenCalledWith(savedPath) }) - it('does not authorize a local path for an SSH save', async () => { + it('writes an SSH paste to the remote temp folder', async () => { const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1]), { connectionId: 'conn-1' }) expect(savedPath.startsWith('/remote/tmp/')).toBe(true) expect(writeFileBase64Mock).toHaveBeenCalled() - expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(writeFileMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 0024c4b6d1f8..cadbd6776504 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -6,7 +6,6 @@ import { getAppEnvironment } from '../../shared/app-environment' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' -import { authorizeExternalPath } from '../ipc/filesystem-auth' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null @@ -42,8 +41,5 @@ export async function saveClipboardImageBufferAsTempFile( const tempPath = path.join(getAppEnvironment().getPath('temp'), fileName) await fs.writeFile(tempPath, buffer) - // Why: the OS temp dir is outside every allowed root, so without this the - // composer's own thumbnail/preview read of the file it just wrote is denied. - authorizeExternalPath(tempPath) return tempPath } diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 7b63f381c1c3..79f9eabc066c 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -13,7 +13,6 @@ const { spawnMock, childStdinEndMock, resolveAuthorizedPathMock, - authorizeExternalPathMock, fsAccessMock, fsLstatMock, fsMkdirMock, @@ -50,7 +49,6 @@ const { return child }), resolveAuthorizedPathMock: vi.fn(), - authorizeExternalPathMock: vi.fn(), fsAccessMock: vi.fn(), fsLstatMock: vi.fn(), fsMkdirMock: vi.fn(), @@ -94,8 +92,7 @@ vi.mock('node:fs/promises', () => ({ vi.mock('../ipc/filesystem-auth', () => ({ PATH_ACCESS_DENIED_MESSAGE: 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.', - resolveAuthorizedPath: resolveAuthorizedPathMock, - authorizeExternalPath: authorizeExternalPathMock + resolveAuthorizedPath: resolveAuthorizedPathMock })) vi.mock('node:crypto', () => ({ diff --git a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts index 6e09eb5fd3e6..979c5b7d413e 100644 --- a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts +++ b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts @@ -43,8 +43,7 @@ vi.mock('node:fs/promises', () => ({ })) vi.mock('../ipc/filesystem-auth', () => ({ PATH_ACCESS_DENIED_MESSAGE: 'denied', - resolveAuthorizedPath: vi.fn(), - authorizeExternalPath: vi.fn() + resolveAuthorizedPath: vi.fn() })) vi.mock('../ipc/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: callRuntimeEnvironmentMock diff --git a/src/main/worktree-root-preparation.test.ts b/src/main/worktree-root-preparation.test.ts index c5f25eca502f..eada5ebc261d 100644 --- a/src/main/worktree-root-preparation.test.ts +++ b/src/main/worktree-root-preparation.test.ts @@ -2,23 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as WslModule from './wsl' import type { Repo } from '../shared/repo-types' -const { mkdirMock, authorizeExternalPathMock, getWslHomeMock, getWslHomeAsyncMock } = vi.hoisted( - () => ({ - mkdirMock: vi.fn(), - authorizeExternalPathMock: vi.fn(), - getWslHomeMock: vi.fn(), - getWslHomeAsyncMock: vi.fn() - }) -) +const { mkdirMock, getWslHomeMock, getWslHomeAsyncMock } = vi.hoisted(() => ({ + mkdirMock: vi.fn(), + getWslHomeMock: vi.fn(), + getWslHomeAsyncMock: vi.fn() +})) vi.mock('fs/promises', () => ({ mkdir: mkdirMock })) -vi.mock('./ipc/filesystem-auth', () => ({ - authorizeExternalPath: authorizeExternalPathMock -})) - vi.mock('./wsl', async (importOriginal) => ({ ...(await importOriginal()), getWslHome: getWslHomeMock, @@ -43,7 +36,6 @@ const store = { describe('prepareLocalWorktreeRootForRepo', () => { beforeEach(() => { mkdirMock.mockReset().mockResolvedValue(undefined) - authorizeExternalPathMock.mockReset() getWslHomeMock.mockReset().mockImplementation(() => { throw new Error('synchronous wsl.exe home probe must not run on the main thread') }) @@ -114,13 +106,11 @@ describe('prepareLocalWorktreeRootForRepo', () => { await prepareLocalWorktreeRootForRepo(store as never, { ...repo, kind: 'folder' }) expect(mkdirMock).not.toHaveBeenCalled() - expect(authorizeExternalPathMock).not.toHaveBeenCalled() }) it('does not fail repo setup when root preparation fails', async () => { mkdirMock.mockRejectedValueOnce(new Error('permission denied')) await expect(prepareLocalWorktreeRootForRepo(store as never, repo)).resolves.toBeUndefined() - expect(authorizeExternalPathMock).not.toHaveBeenCalled() }) }) diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 7039fd5a7a8f..d3f81a1b51c1 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -123,11 +123,6 @@ export type FilesystemApi = { recursive?: boolean } & SshMutationExpectation ) => Promise - authorizeExternalPath: (args: { - targetPath: string - /** Restored editor tabs: no grant inside a project, and never a project symlink's target. */ - skipIfInsideAllowedRoots?: boolean - }) => Promise stat: (args: { filePath: string connectionId?: string diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 6e2a038501ae..be0ce3f7ac91 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -119,10 +119,6 @@ export const fsApi = { recursive?: boolean } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:deletePath', args), - authorizeExternalPath: (args: { - targetPath: string - skipIfInsideAllowedRoots?: boolean - }): Promise => ipcRenderer.invoke('fs:authorizeExternalPath', args), stat: (args: { filePath: string connectionId?: string diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts index 541443e05dc2..23a468cce15f 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts @@ -2,11 +2,7 @@ import { detectLanguage } from '@/lib/language-detect' import { getConnectionId } from '@/lib/connection-context' import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' import { useAppStore } from '@/store' -import { - isRemoteRuntimeFileOperation, - statRuntimePath, - type RuntimeFileOperationArgs -} from '@/runtime/runtime-file-client' +import { statRuntimePath, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { normalizeBrowserNavigationUrl, redactKagiSessionToken @@ -108,9 +104,6 @@ export function navigateBrowserPageToUrl({ worktreePath: activeWorktree?.path, connectionId: undefined } - if (!isRemoteRuntimeFileOperation(fileContext, notebookPath)) { - await window.api.fs.authorizeExternalPath({ targetPath: notebookPath }) - } const stat = await statRuntimePath(fileContext, notebookPath, userNamedFileAccess()) if (stat.isDirectory) { navigateBrowserUrl(url) diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 8fe04dc23086..231fcd358daf 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -9,8 +9,7 @@ import { getDiskBaselineSignature } from './diff-content-signature' const mocks = vi.hoisted(() => ({ readRuntimeFileContent: vi.fn(), getConnectionIdForFile: vi.fn(), - pathExists: vi.fn(), - authorizeExternalPath: vi.fn() + pathExists: vi.fn() })) vi.mock('@/runtime/runtime-file-client', () => ({ @@ -20,16 +19,13 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ settingsForRuntimeOwner: () => null })) vi.mock('@/lib/connection-context', () => ({ - getConnectionIdForFile: mocks.getConnectionIdForFile, - isWorktreeConnectionResolved: () => true + getConnectionIdForFile: mocks.getConnectionIdForFile })) function createEditorStore(): StoreApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any return createStore()((...args: any[]) => ({ settings: {}, - worktreesByRepo: {}, - folderWorkspaces: [], ...createEditorSlice(...(args as Parameters)) })) as unknown as StoreApi } @@ -67,13 +63,7 @@ describe('attachRestoredTabConflictScan', () => { mocks.getConnectionIdForFile.mockReturnValue(undefined) mocks.pathExists.mockReset() mocks.pathExists.mockResolvedValue(true) - mocks.authorizeExternalPath.mockReset() - mocks.authorizeExternalPath.mockResolvedValue(undefined) - vi.stubGlobal('window', { - api: { - fs: { pathExists: mocks.pathExists, authorizeExternalPath: mocks.authorizeExternalPath } - } - }) + vi.stubGlobal('window', { api: { fs: { pathExists: mocks.pathExists } } }) }) afterEach(() => { @@ -261,12 +251,13 @@ describe('attachRestoredTabConflictScan', () => { } }) - it('grants a restored dirty floating-workspace tab before verifying it', async () => { - // Why: without the grant the read is denied forever and the tab's autosave never resumes. + it('verifies a restored dirty floating-workspace tab as the file the user named', async () => { + mocks.readRuntimeFileContent.mockRejectedValueOnce(new Error('ENOENT')) mocks.readRuntimeFileContent.mockResolvedValue({ content: 'original baseline', isBinary: false }) + mocks.pathExists.mockResolvedValue(true) mocks.getConnectionIdForFile.mockReturnValue(null) const store = createEditorStore() openRestoredDirtyTab(store, '/Users/me/notes.txt', 'original baseline') @@ -278,23 +269,14 @@ describe('attachRestoredTabConflictScan', () => { })) } as never) - let finishGrant: () => void = () => {} - mocks.authorizeExternalPath.mockImplementation( - () => new Promise((resolve) => (finishGrant = resolve)) - ) - const detach = attachRestoredTabConflictScan(store) try { await vi.advanceTimersByTimeAsync(10) - expect(mocks.authorizeExternalPath).toHaveBeenCalledWith({ - targetPath: '/Users/me/notes.txt', - skipIfInsideAllowedRoots: true - }) - expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() - - finishGrant() - await vi.advanceTimersByTimeAsync(10) - expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect(mocks.pathExists).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/Users/me/notes.txt', access: { kind: 'user-file' } }) + ) + await vi.advanceTimersByTimeAsync(2_100) + expect(mocks.readRuntimeFileContent).toHaveBeenLastCalledWith( expect.objectContaining({ filePath: '/Users/me/notes.txt', access: { kind: 'user-file' } }) ) expect(store.getState().openFiles[0]?.pendingDiskBaselineVerification).toBeUndefined() @@ -303,19 +285,21 @@ describe('attachRestoredTabConflictScan', () => { } }) - it('does not grant a restored dirty project tab', async () => { + it('verifies a restored dirty project tab inside its root', async () => { mocks.readRuntimeFileContent.mockResolvedValue({ content: 'original baseline', isBinary: false }) + mocks.getConnectionIdForFile.mockReturnValue(null) const store = createEditorStore() openRestoredDirtyTab(store, '/repo/file.ts', 'original baseline') const detach = attachRestoredTabConflictScan(store) try { await vi.advanceTimersByTimeAsync(10) - expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1) - expect(mocks.authorizeExternalPath).not.toHaveBeenCalled() + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/repo/file.ts', access: undefined }) + ) } finally { detach() } diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index acd5126460cb..72d44520981e 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -4,7 +4,6 @@ import type { AppState } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { getConnectionIdForFile } from '@/lib/connection-context' import { editorTabFileAccess } from '@/lib/local-file-access' -import { refreshEditorTabExternalPathGrant } from '@/lib/editor-tab-external-path-grant' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { canAutoSaveOpenFile } from './editor-autosave' @@ -65,11 +64,6 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { let retryScheduled = false try { const state = store.getState() - // Why: this runs at startup for unopened tabs too, before the loader re-grants their path. - const pathGrant = refreshEditorTabExternalPathGrant(state, file) - if (pathGrant) { - await pathGrant - } const result = await readRuntimeFileContent({ settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), filePath: file.filePath, diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 2ec22004b832..3ce87d778fc8 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -93,10 +93,9 @@ type ProbeProps = { gitStatusByWorktree?: Record } -const authorizeExternalPath = vi.fn() // Why: opening any liveTail tab arms useLocalLogTail's change subscription. const onLocalLogTailChanged = vi.fn(() => () => {}) -const fsApi = { authorizeExternalPath, onLocalLogTailChanged } +const fsApi = { onLocalLogTailChanged } let latestFileContents: Record = {} let latestDiffContents: Record = {} let latestReloadContent: (file: OpenFile) => void = () => {} @@ -140,8 +139,6 @@ describe('useEditorPanelContentState', () => { beforeEach(() => { latestFileContents = {} latestDiffContents = {} - authorizeExternalPath.mockReset() - authorizeExternalPath.mockResolvedValue(undefined) onLocalLogTailChanged.mockClear() ;(window as unknown as { api: unknown }).api = { fs: fsApi } mocks.readRuntimeFileContent.mockReset() @@ -259,8 +256,6 @@ describe('useEditorPanelContentState', () => { }) await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# remote')) - // Why: the client-local grant must not be requested for a remote-owned path. - expect(authorizeExternalPath).not.toHaveBeenCalled() expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/work/reports/audit.md', @@ -287,10 +282,13 @@ describe('useEditorPanelContentState', () => { await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('log line')) // Why: AI Vault only surfaces client-local logs, so the worktree's SSH target must - // not capture this read — it stays a granted client-local path. - expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: logPath }) + // not capture this read — it stays a user-named client-local path. expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( - expect.objectContaining({ connectionId: undefined, includeLocalLogMetadata: true }) + expect.objectContaining({ + connectionId: undefined, + includeLocalLogMetadata: true, + access: { kind: 'user-file' } + }) ) }) diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index bbdb1e106f29..863d90220898 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -19,7 +19,6 @@ import { } from './editor-panel-content-types' import type { EditorPanelContentLoadOptions } from './useEditorPanelExternalContentEvents' import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner' -import { refreshEditorTabExternalPathGrant } from '@/lib/editor-tab-external-path-grant' import { editorTabFileAccess } from '@/lib/local-file-access' const inFlightFileReads = new Map>() @@ -122,7 +121,6 @@ export function useEditorPanelFileContentLoader({ ? undefined : readSettings?.activeRuntimeEnvironmentId?.trim() if (isLiveTailLogTab) { - await window.api.fs.authorizeExternalPath({ targetPath: filePath }) readConnectionId = undefined } else { const currentState = useAppStore.getState() @@ -157,21 +155,11 @@ export function useEditorPanelFileContentLoader({ throw new Error('External local files are not available for remote workspaces.') } if (!externalSshOwnerId) { - await refreshEditorTabExternalPathGrant(currentState, restoredOpenFile) - // Why: that grant covers the client path, so this read must stay off the - // worktree's SSH host. + // Why: a client-local external tab names a client path, so this read must stay off + // the worktree's SSH host. readConnectionId = undefined } } - } else if (restoredOpenFile?.filePath === filePath) { - // Why: a floating-workspace tab stores a root-relative path, but its root isn't authorized. - const pathGrant = refreshEditorTabExternalPathGrant( - useAppStore.getState(), - restoredOpenFile - ) - if (pathGrant) { - await pathGrant - } } const readScope = getRuntimeFileReadScope(readSettings, readConnectionId) const access = restoredOpenFile diff --git a/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx index 0a5b81a2011e..10864beb5a9d 100644 --- a/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx @@ -55,7 +55,6 @@ vi.mock('./useLocalLogTail', () => ({ useLocalLogTail: vi.fn() })) import { useEditorPanelContentState } from './useEditorPanelContentState' -const authorizeExternalPath = vi.fn() let latestFileContents: Record = {} function createFloatingFile(filePath: string, overrides: Partial = {}): OpenFile { @@ -89,9 +88,8 @@ describe('restored client-local editor tabs', () => { beforeEach(() => { latestFileContents = {} - authorizeExternalPath.mockReset() - authorizeExternalPath.mockResolvedValue(undefined) - ;(window as unknown as { api: unknown }).api = { fs: { authorizeExternalPath } } + // Why an empty fs API: restoring must read with nothing re-granted or prepared first. + ;(window as unknown as { api: unknown }).api = { fs: {} } mocks.readRuntimeFileContent.mockReset() mocks.readRuntimeFileContent.mockResolvedValue({ content: '# local', isBinary: false }) mocks.findWorkspaceFileRoute.mockReset() @@ -111,32 +109,16 @@ describe('restored client-local editor tabs', () => { container?.remove() }) - // The notebook kernel and environment handlers authorize the same filePath. + // The notebook kernel and environment handlers check the same file path as user-named. it.each(['/Users/me/notes.txt', '/Users/me/analysis.ipynb'])( - 're-grants %s and waits for the grant before reading it after a restart', + 'reads %s as the file the user named after a restart', async (filePath) => { - let finishGrant: () => void = () => {} - authorizeExternalPath.mockImplementation( - () => new Promise((resolve) => (finishGrant = resolve)) - ) const activeFile = createFloatingFile(filePath) await act(async () => root?.render()) - await vi.waitFor(() => - expect(authorizeExternalPath).toHaveBeenCalledWith({ - targetPath: filePath, - skipIfInsideAllowedRoots: true - }) - ) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)) - }) - expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() - - await act(async () => finishGrant()) - await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1) expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( expect.objectContaining({ filePath, @@ -147,7 +129,7 @@ describe('restored client-local editor tabs', () => { } ) - it('re-authorizes a client-local tab stored outside its own project', async () => { + it('reads a local tab stored outside its own project as user-named', async () => { const filePath = '/Users/me/notes/audit.md' const activeFile = createFloatingFile(filePath, { relativePath: filePath, @@ -157,13 +139,12 @@ describe('restored client-local editor tabs', () => { await act(async () => root?.render()) await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) - expect(authorizeExternalPath).toHaveBeenCalledWith({ - targetPath: filePath, - skipIfInsideAllowedRoots: true - }) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ filePath, access: { kind: 'user-file' } }) + ) }) - it('does not grant a project tab that its authorized root already covers', async () => { + it('keeps a project tab inside its root', async () => { const activeFile = createFloatingFile('/Users/me/project/README.md', { relativePath: 'README.md', worktreeId: 'repo::/Users/me/project' @@ -172,6 +153,8 @@ describe('restored client-local editor tabs', () => { await act(async () => root?.render()) await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) - expect(authorizeExternalPath).not.toHaveBeenCalled() + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ access: undefined }) + ) }) }) diff --git a/src/renderer/src/components/editor/useEditorPanelHostUnresolvedContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelHostUnresolvedContentState.test.tsx index c6ff09f8c97a..600b79f8adf3 100644 --- a/src/renderer/src/components/editor/useEditorPanelHostUnresolvedContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelHostUnresolvedContentState.test.tsx @@ -98,7 +98,7 @@ describe('useEditorPanelContentState — host cannot resolve a mirrored file (#2 latestFileContents = {} // Why: opening any tab arms useLocalLogTail's change subscription on window.api. vi.stubGlobal('api', { - fs: { authorizeExternalPath: vi.fn(), onLocalLogTailChanged: vi.fn(() => () => {}) } + fs: { onLocalLogTailChanged: vi.fn(() => () => {}) } }) mocks.readRuntimeFileContent.mockReset() mocks.getState.mockReset() diff --git a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx index 08fcc4df79a3..43fedf1fa68b 100644 --- a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx @@ -63,7 +63,6 @@ vi.mock('./useLocalLogTail', () => ({ useLocalLogTail: vi.fn() })) import { useEditorPanelContentState } from './useEditorPanelContentState' -const authorizeExternalPath = vi.fn() let latestFileContents: Record = {} function createOpenFile(overrides: Partial): OpenFile { @@ -96,9 +95,7 @@ describe('remote sibling editor content routing', () => { beforeEach(() => { latestFileContents = {} - authorizeExternalPath.mockReset() - authorizeExternalPath.mockResolvedValue(undefined) - ;(window as unknown as { api: unknown }).api = { fs: { authorizeExternalPath } } + ;(window as unknown as { api: unknown }).api = { fs: {} } mocks.readRuntimeFileContent.mockReset() mocks.findWorkspaceFileRoute.mockReset() mocks.findWorkspaceFileRoute.mockReturnValue(null) @@ -135,9 +132,12 @@ describe('remote sibling editor content routing', () => { await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('log line')) expect(mocks.findWorkspaceFileRoute).not.toHaveBeenCalled() - expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: logPath }) expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( - expect.objectContaining({ connectionId: undefined, includeLocalLogMetadata: true }) + expect.objectContaining({ + connectionId: undefined, + includeLocalLogMetadata: true, + access: { kind: 'user-file' } + }) ) }) @@ -164,7 +164,6 @@ describe('remote sibling editor content routing', () => { 'runtime-1' ) ) - expect(authorizeExternalPath).not.toHaveBeenCalled() expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index 508a7c20e5ea..a4dde5f72dde 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -28,7 +28,7 @@ const electron = vi.hoisted(() => ({ const intake = vi.hoisted(() => ({ owner: { kind: 'local' } as { kind: string; connectionId?: string }, - authorizeExternalPath: vi.fn(), + stat: vi.fn(), readFile: vi.fn(), upload: vi.fn() })) @@ -153,7 +153,7 @@ describe('native chat composer drop scoping', () => { beforeEach(() => { intake.owner = { kind: 'local' } electron.getPathForFile.mockReset().mockImplementation((file: File) => `/repro/${file.name}`) - intake.authorizeExternalPath.mockReset().mockResolvedValue(undefined) + intake.stat.mockReset().mockResolvedValue(undefined) intake.readFile.mockReset().mockResolvedValue({ content: '', isBinary: false }) intake.upload.mockReset() vi.stubGlobal('IntersectionObserver', undefined) @@ -184,8 +184,8 @@ describe('native chat composer drop scoping', () => { expect(readNativeChatAttachmentCache('chat-a')).toEqual([]) }) - it('notices an OS drop whose every path fails authorization', async () => { - intake.authorizeExternalPath.mockRejectedValue(new Error('denied')) + it('notices an OS drop whose every path is unreadable', async () => { + intake.stat.mockRejectedValue(new Error('denied')) const view = render() await dropTwoImages(view.container.querySelector('[data-pane="chat-a"] .ProseMirror')!) @@ -197,8 +197,8 @@ describe('native chat composer drop scoping', () => { expect(readNativeChatAttachmentCache('chat-a')).toEqual([]) }) - it('notices an OS drop whose owner changes during authorization', async () => { - intake.authorizeExternalPath.mockImplementation(async () => { + it('notices an OS drop whose owner changes while checking the files', async () => { + intake.stat.mockImplementation(async () => { intake.owner = { kind: 'ssh', connectionId: 'conn-1' } }) const view = render() @@ -289,15 +289,9 @@ describe('native chat composer drop scoping', () => { expect(readNativeChatAttachmentCache('chat-b')).toEqual([]) }) - it('authorizes only dropped files before preview reads and leaves the other pane untouched', async () => { - const authorized = new Set() - intake.authorizeExternalPath.mockImplementation( - async ({ targetPath }: { targetPath: string }) => { - authorized.add(targetPath) - } - ) - intake.readFile.mockImplementation(async ({ filePath }: { filePath: string }) => { - if (!authorized.has(filePath)) { + it('previews dropped files as user-named and leaves the other pane untouched', async () => { + intake.readFile.mockImplementation(async ({ access }: { access?: { kind: string } }) => { + if (access?.kind !== 'user-file') { throw new Error('Access denied: path resolves outside allowed directories') } return { content: 'AA==', isBinary: true, mimeType: 'image/png' } @@ -317,9 +311,9 @@ describe('native chat composer drop scoping', () => { ) expect(await screen.findByRole('img', { name: 'first.png' })).toBeTruthy() expect(await screen.findByRole('img', { name: 'second.png' })).toBeTruthy() - expect(intake.authorizeExternalPath.mock.calls).toEqual([ - [{ targetPath: '/repro/first.png' }], - [{ targetPath: '/repro/second.png' }] + expect(intake.stat.mock.calls).toEqual([ + [{ filePath: '/repro/first.png', access: { kind: 'user-file' } }], + [{ filePath: '/repro/second.png', access: { kind: 'user-file' } }] ]) expect(intake.readFile).toHaveBeenCalledTimes(2) expect(intake.upload).not.toHaveBeenCalled() @@ -330,7 +324,7 @@ describe('native chat composer drop scoping', () => { ) }) - it('uploads once for the SSH drop owner without authorizing remote paths locally', async () => { + it('uploads once for the SSH drop owner without checking remote paths locally', async () => { intake.owner = { kind: 'ssh', connectionId: 'conn-1' } intake.upload.mockResolvedValue(['/remote/first.png', '/remote/second.png']) const view = render( @@ -344,7 +338,7 @@ describe('native chat composer drop scoping', () => { ['/repro/first.png', '/repro/second.png'], intake.owner ) - expect(intake.authorizeExternalPath).not.toHaveBeenCalled() + expect(intake.stat).not.toHaveBeenCalled() expect(readNativeChatAttachmentCache('chat-a').map(({ path }) => path)).toEqual([ '/remote/first.png', '/remote/second.png' diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 341e242901fa..088763d835d1 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -235,7 +235,7 @@ function writeNativeChatAttachmentCache( .filter((attachment) => !attachment.pending) // Preview URLs can retain the full clipboard Blob (or a large data URL) for // the lifetime of the scope cache. Settled attachments reload from their - // authorized path after a remount, so never retain the transient preview. + // on-disk path after a remount, so never retain the transient preview. .map(({ previewUrl: _previewUrl, ...attachment }) => attachment) if (attachments.length === 0) { attachmentCache.delete(scopeKey) diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 1d8fa94eeb2d..38ad346d5c39 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import type * as AttachmentUploadModule from './native-chat-attachment-upload' const mocks = vi.hoisted(() => ({ - authorizeExternalPath: vi.fn(), + stat: vi.fn(), resolveNativeChatAttachmentOwner: vi.fn(), resolveNativeChatAttachmentOwnerForWorktree: vi.fn(), uploadNativeChatAttachmentPaths: vi.fn() @@ -114,10 +114,10 @@ async function renderProbe(args: { } beforeEach(() => { - mocks.authorizeExternalPath.mockReset().mockResolvedValue(undefined) + mocks.stat.mockReset().mockResolvedValue(undefined) mocks.resolveNativeChatAttachmentOwnerForWorktree.mockReset().mockReturnValue({ kind: 'local' }) window.api = { - fs: { authorizeExternalPath: mocks.authorizeExternalPath } + fs: { stat: mocks.stat } } as unknown as Window['api'] }) @@ -135,52 +135,51 @@ describe('useNativeChatExternalAttachments', () => { await act(async () => { probe.latest().attachExternalPaths(['/local/a.txt']) }) - expect(mocks.authorizeExternalPath).toHaveBeenCalledExactlyOnceWith({ - targetPath: '/local/a.txt' + expect(mocks.stat).toHaveBeenCalledExactlyOnceWith({ + filePath: '/local/a.txt', + access: { kind: 'user-file' } }) expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt']) expect(mocks.uploadNativeChatAttachmentPaths).not.toHaveBeenCalled() }) - it('waits for local authorization and skips rejected paths without blocking other files', async () => { + it('waits for the local file check and skips rejected paths without blocking other files', async () => { mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' }) - const authorization = deferred() - mocks.authorizeExternalPath - .mockReturnValueOnce(authorization.promise) - .mockRejectedValueOnce(new Error('denied')) + const fileCheck = deferred() + mocks.stat.mockReturnValueOnce(fileCheck.promise).mockRejectedValueOnce(new Error('denied')) const attachResolvedPaths = vi.fn() const probe = await renderProbe({ attachResolvedPaths }) act(() => probe.latest().attachExternalPaths(['/external/a.png', '/external/b.png', '/external/c.png']) ) expect(attachResolvedPaths).not.toHaveBeenCalled() - expect(mocks.authorizeExternalPath).toHaveBeenCalledTimes(1) - await act(async () => authorization.resolve()) + expect(mocks.stat).toHaveBeenCalledTimes(1) + await act(async () => fileCheck.resolve()) expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith([ '/external/a.png', '/external/c.png' ]) - expect(mocks.authorizeExternalPath).toHaveBeenCalledTimes(3) + expect(mocks.stat).toHaveBeenCalledTimes(3) }) - it('does not attach local paths when disabled during authorization', async () => { + it('does not attach local paths when disabled during the file check', async () => { mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' }) - const authorization = deferred() - mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise) + const fileCheck = deferred() + mocks.stat.mockReturnValueOnce(fileCheck.promise) const attachResolvedPaths = vi.fn() const probe = await renderProbe({ attachResolvedPaths }) act(() => probe.latest().attachExternalPaths(['/external/a.png', '/external/b.png'])) await probe.setDisabled(true) - await act(async () => authorization.resolve()) + await act(async () => fileCheck.resolve()) expect(attachResolvedPaths).not.toHaveBeenCalled() - expect(mocks.authorizeExternalPath).toHaveBeenCalledTimes(1) + expect(mocks.stat).toHaveBeenCalledTimes(1) }) - it('does not attach local paths when the owner changes during authorization', async () => { - const authorization = deferred() + it('does not attach local paths when the owner changes during the file check', async () => { + const fileCheck = deferred() let owner: { kind: 'local' } | { kind: 'runtime' } = { kind: 'local' } mocks.resolveNativeChatAttachmentOwner.mockImplementation(() => owner) - mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise) + mocks.stat.mockReturnValueOnce(fileCheck.promise) const attachResolvedPaths = vi.fn() const notices: (string | null)[] = [] const probe = await renderProbe({ @@ -190,10 +189,10 @@ describe('useNativeChatExternalAttachments', () => { act(() => probe.latest().attachExternalPaths(['/external/a.png', '/external/b.png'])) owner = { kind: 'runtime' } - await act(async () => authorization.resolve()) + await act(async () => fileCheck.resolve()) expect(attachResolvedPaths).not.toHaveBeenCalled() - expect(mocks.authorizeExternalPath).toHaveBeenCalledTimes(1) + expect(mocks.stat).toHaveBeenCalledTimes(1) expect(notices.at(-1)).toBe( 'This workspace changed hosts while attaching — drop the files again.' ) @@ -202,11 +201,11 @@ describe('useNativeChatExternalAttachments', () => { // The owner flipping during the LAST path has no next iteration to catch it, // so the post-loop check is the only thing standing between a one-file drop // and a path attached to a host that no longer owns it. - it('reports a one-file drop whose owner changes during its authorization', async () => { - const authorization = deferred() + it('reports a one-file drop whose owner changes during its file check', async () => { + const fileCheck = deferred() let owner: { kind: 'local' } | { kind: 'runtime' } = { kind: 'local' } mocks.resolveNativeChatAttachmentOwner.mockImplementation(() => owner) - mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise) + mocks.stat.mockReturnValueOnce(fileCheck.promise) const attachResolvedPaths = vi.fn() const notices: (string | null)[] = [] const probe = await renderProbe({ @@ -216,7 +215,7 @@ describe('useNativeChatExternalAttachments', () => { act(() => probe.latest().attachExternalPaths(['/external/only.pdf'])) owner = { kind: 'runtime' } - await act(async () => authorization.resolve()) + await act(async () => fileCheck.resolve()) expect(attachResolvedPaths).not.toHaveBeenCalled() expect(notices.at(-1)).toBe( @@ -226,10 +225,10 @@ describe('useNativeChatExternalAttachments', () => { // Both workspaces answer `local`, so the owner alone cannot tell them apart: // only asking which workspace this composer serves now catches a tab that - // moved while the authorization was still in flight. - it('does not attach when the pane changes workspace during authorization', async () => { - const authorization = deferred() - mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise) + // moved while the file check was still in flight. + it('does not attach when the pane changes workspace during the file check', async () => { + const fileCheck = deferred() + mocks.stat.mockReturnValueOnce(fileCheck.promise) const attachResolvedPaths = vi.fn() const notices: (string | null)[] = [] const probe = await renderProbe({ @@ -240,7 +239,7 @@ describe('useNativeChatExternalAttachments', () => { act(() => probe.latest().attachExternalPaths(['/external/only.pdf'])) await probe.setStructuredWorktreeId('worktree-2') - await act(async () => authorization.resolve()) + await act(async () => fileCheck.resolve()) expect(attachResolvedPaths).not.toHaveBeenCalled() expect(notices.at(-1)).toBe( @@ -305,7 +304,7 @@ describe('useNativeChatExternalAttachments', () => { expectedSshConnectionGeneration: 4 }) expect(attachResolvedPaths).toHaveBeenCalledWith(['/remote/wt/.orca/drops/a.txt'], 'conn-1') - expect(mocks.authorizeExternalPath).not.toHaveBeenCalled() + expect(mocks.stat).not.toHaveBeenCalled() }) it('delivers concurrent SSH resolutions in order without deduplicating paths', async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index 98d3bc81446d..5e88f7938430 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -11,6 +11,7 @@ import { uploadNativeChatAttachmentPaths, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import { userNamedFileAccess } from '@/lib/local-file-access' export type UseNativeChatExternalAttachmentsArgs = { terminalTabId: string @@ -94,7 +95,7 @@ export function useNativeChatExternalAttachments({ nativeChatAttachmentOwnerUnchanged(owner, resolveAttachmentOwner()) if (owner.kind !== 'ssh') { void (async () => { - const authorizedPaths: string[] = [] + const readablePaths: string[] = [] for (const targetPath of paths) { if (disabledRef.current) { return @@ -104,8 +105,8 @@ export function useNativeChatExternalAttachments({ return } try { - await window.api.fs.authorizeExternalPath({ targetPath }) - authorizedPaths.push(targetPath) + await window.api.fs.stat({ filePath: targetPath, access: userNamedFileAccess() }) + readablePaths.push(targetPath) } catch { // Skip unreadable paths, matching workspace composer drops. } @@ -117,11 +118,11 @@ export function useNativeChatExternalAttachments({ setNotice(nativeChatAttachmentOwnerChangedNotice()) return } - if (authorizedPaths.length === 0) { + if (readablePaths.length === 0) { setNotice(nativeChatAttachmentUnreadableNotice()) return } - attachResolvedPaths(authorizedPaths) + attachResolvedPaths(readablePaths) })() return } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts index bde07f875406..6baa96675e6c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.test.ts @@ -44,11 +44,8 @@ function makeState(overrides: Partial = {}): FakeState { } } -let authorizeMock: ReturnType - beforeEach(() => { - authorizeMock = vi.fn().mockResolvedValue(undefined) - vi.stubGlobal('window', { api: { fs: { authorizeExternalPath: authorizeMock } } }) + vi.stubGlobal('window', { api: { fs: {} } }) }) afterEach(() => { @@ -57,13 +54,12 @@ afterEach(() => { }) describe('openAiVaultSessionLogInOrca', () => { - it('authorizes the exact path and opens a permanent read-only local tab', async () => { + it('opens the exact path as a permanent read-only local tab', async () => { const state = makeState() getStateMock.mockReturnValue(state) await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) - expect(authorizeMock).toHaveBeenCalledWith({ targetPath: LOG_PATH }) expect(state.openFile).toHaveBeenCalledTimes(1) const [file, options] = state.openFile.mock.calls[0] expect(file).toEqual({ @@ -85,7 +81,7 @@ describe('openAiVaultSessionLogInOrca', () => { expect(toastErrorMock).not.toHaveBeenCalled() }) - it('withholds blank, remote, and synthetic paths without authorizing', async () => { + it('withholds blank, remote, and synthetic paths', async () => { const state = makeState() getStateMock.mockReturnValue(state) @@ -96,30 +92,16 @@ describe('openAiVaultSessionLogInOrca', () => { executionHostId: 'local' }) - expect(authorizeMock).not.toHaveBeenCalled() expect(state.openFile).not.toHaveBeenCalled() }) - it('toasts and creates no tab when authorization rejects', async () => { - const state = makeState() + it('toasts and creates no tab when the workspace no longer exists', async () => { + const state = makeState({ worktreesByRepo: {}, folderWorkspaces: [] }) getStateMock.mockReturnValue(state) - authorizeMock.mockRejectedValue(new Error('denied')) - - await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) - - expect(state.openFile).not.toHaveBeenCalled() - expect(toastErrorMock).toHaveBeenCalledWith("Couldn't open log — path not authorized.") - }) - - it('toasts and creates no tab when the workspace vanishes after authorization', async () => { - const state = makeState() - const stateAfter = makeState({ worktreesByRepo: {}, folderWorkspaces: [] }) - getStateMock.mockReturnValueOnce(state).mockReturnValue(stateAfter) await openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) expect(state.openFile).not.toHaveBeenCalled() - expect(stateAfter.openFile).not.toHaveBeenCalled() expect(toastErrorMock).toHaveBeenCalledWith( "Couldn't open log — workspace is no longer available." ) @@ -145,24 +127,4 @@ describe('openAiVaultSessionLogInOrca', () => { expect(toastMock).toHaveBeenCalledWith('Log is already open for editing.') expect(toastErrorMock).not.toHaveBeenCalled() }) - - it('shares one in-flight open for concurrent clicks of the same path', async () => { - const state = makeState() - getStateMock.mockReturnValue(state) - let resolveAuth: (() => void) | undefined - authorizeMock.mockImplementation( - () => - new Promise((resolve) => { - resolveAuth = resolve - }) - ) - - const first = openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) - const second = openAiVaultSessionLogInOrca({ filePath: LOG_PATH, executionHostId: 'local' }) - resolveAuth?.() - await Promise.all([first, second]) - - expect(authorizeMock).toHaveBeenCalledTimes(1) - expect(state.openFile).toHaveBeenCalledTimes(1) - }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts index 2f4b6ff495cf..5b941719ad4a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-log-open.ts @@ -10,11 +10,6 @@ import { canOpenAiVaultSessionLogInOrca } from './ai-vault-session-path-actions' type AiVaultLogSession = Pick -// Why: rapid double-clicks of View Log during the authorize await must share one -// in-flight open (and toast-once on failure) so a slow FS grant can't spawn -// duplicate tabs or spam error toasts. Keyed by the exact requested path. -const inFlightOpenPaths = new Set() - function worktreeStillExists(state: AppState, worktreeId: string): boolean { if (findWorktreeById(state.worktreesByRepo ?? {}, worktreeId)) { return true @@ -44,8 +39,8 @@ function focusEditorContent(): void { /** * Open a local AI Vault session log inside Orca as a permanent, read-only editor * tab (or activate an existing tab without reducing its authority). Reuses - * Orca's external-file authorize + `openFile` pipeline; it never grants write - * capability by itself and never redirects the open to a remote host. + * Orca's external-file `openFile` pipeline; it never grants write capability by + * itself and never redirects the open to a remote host. */ export async function openAiVaultSessionLogInOrca(session: AiVaultLogSession): Promise { const filePath = session.filePath?.trim() @@ -54,98 +49,73 @@ export async function openAiVaultSessionLogInOrca(session: AiVaultLogSession): P if (!filePath || !canOpenAiVaultSessionLogInOrca(session)) { return } - if (inFlightOpenPaths.has(filePath)) { + const state = useAppStore.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + toast.error( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.workspaceGone', + "Couldn't open log — workspace is no longer available." + ) + ) return } - inFlightOpenPaths.add(filePath) - try { - const state = useAppStore.getState() - // Snapshot the invoking workspace/group before the authorization await so a - // delayed grant can't retarget the tab into a workspace the user moved to. - const worktreeId = state.activeWorktreeId - if (!worktreeId) { - toast.error( - translate( - 'auto.components.right.sidebar.aiVaultSessionLogOpen.workspaceGone', - "Couldn't open log — workspace is no longer available." - ) + const targetGroupId = state.activeGroupIdByWorktree?.[worktreeId] ?? undefined + // Why: an already-open *writable* tab must keep its edit authority — View Log + // only activates it and notifies. Local ownership only (runtimeEnvironmentId + // null) matches the tab this action would create/activate. + const existingWritableTab = state.openFiles.find( + (file) => + file.filePath === filePath && + file.mode === 'edit' && + file.worktreeId === worktreeId && + (file.runtimeEnvironmentId ?? null) === null && + file.readOnly !== true + ) + + if (!worktreeStillExists(state, worktreeId)) { + toast.error( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.workspaceGone', + "Couldn't open log — workspace is no longer available." ) - return - } - const targetGroupId = state.activeGroupIdByWorktree?.[worktreeId] ?? undefined - // Why: an already-open *writable* tab must keep its edit authority — View Log - // only activates it and notifies. Local ownership only (runtimeEnvironmentId - // null) matches the tab this action would create/activate. - const existingWritableTab = state.openFiles.find( - (file) => - file.filePath === filePath && - file.mode === 'edit' && - file.worktreeId === worktreeId && - (file.runtimeEnvironmentId ?? null) === null && - file.readOnly !== true ) + return + } - try { - // The exact scanned path is the authorization oracle; the user click is the - // trust gesture. Reuses Orca's existing external R/W open grant. - await window.api.fs.authorizeExternalPath({ targetPath: filePath }) - } catch { - toast.error( - translate( - 'auto.components.right.sidebar.aiVaultSessionLogOpen.notAuthorized', - "Couldn't open log — path not authorized." - ) - ) - return + state.openFile( + { + filePath, + // Why: keep relativePath === filePath so the external-file contract reads + // the exact named path, not a worktree-relative reinterpretation. + relativePath: filePath, + worktreeId, + // Why: the path was discovered on the client-local host — pin local + // ownership so an active runtime can't reinterpret it as a remote path. + runtimeEnvironmentId: null, + language: detectLanguage(filePath), + mode: 'edit', + readOnly: true, + liveTail: true + }, + { + preview: false, + // Why: a repeated View Log refreshes a non-dirty tab; the store skips the + // reload nonce for a dirty writable buffer (no buffer replacement). + forceContentReload: true, + suppressActiveRuntimeFallback: true, + targetGroupId } + ) - const stateAfterAuth = useAppStore.getState() - if (!worktreeStillExists(stateAfterAuth, worktreeId)) { - toast.error( - translate( - 'auto.components.right.sidebar.aiVaultSessionLogOpen.workspaceGone', - "Couldn't open log — workspace is no longer available." - ) + if (existingWritableTab) { + toast( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.alreadyEditable', + 'Log is already open for editing.' ) - return - } - - stateAfterAuth.openFile( - { - filePath, - // Why: keep relativePath === filePath so the external-file contract reads - // the exact authorized path, not a worktree-relative reinterpretation. - relativePath: filePath, - worktreeId, - // Why: the path was discovered on the client-local host — pin local - // ownership so an active runtime can't reinterpret it as a remote path. - runtimeEnvironmentId: null, - language: detectLanguage(filePath), - mode: 'edit', - readOnly: true, - liveTail: true - }, - { - preview: false, - // Why: a repeated View Log refreshes a non-dirty tab; the store skips the - // reload nonce for a dirty writable buffer (no buffer replacement). - forceContentReload: true, - suppressActiveRuntimeFallback: true, - targetGroupId - } ) - - if (existingWritableTab) { - toast( - translate( - 'auto.components.right.sidebar.aiVaultSessionLogOpen.alreadyEditable', - 'Log is already open for editing.' - ) - ) - } - - focusEditorContent() - } finally { - inFlightOpenPaths.delete(filePath) } + + focusEditorContent() } 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 d14caca306ad..039854bf0d83 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 @@ -267,7 +267,6 @@ function HandlersProbe({ scrollRef }: { scrollRef: React.RefObject 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/use-file-explorer-tree-pane-state.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts index 2eca309a9e3c..dfafbcfd0293 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts @@ -253,7 +253,6 @@ export function useFileExplorerTreePaneState({ toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir, loadDir, statPath, - authorizeExternalPath: window.api.fs.authorizeExternalPath, markPathAsDirectory, setSelectedPath: setSingleSelectedPath, scrollRef diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts index 0b55ffbc3a16..c5a2af368d59 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts @@ -37,7 +37,6 @@ describe('activateFileExplorerNode', () => { canToggleDirectories: false, loadDir: vi.fn(), statPath: vi.fn(), - authorizeExternalPath: vi.fn(), markPathAsDirectory: vi.fn(), setSelectedPath }) @@ -59,7 +58,6 @@ describe('activateFileExplorerNode', () => { toggleDir, loadDir, statPath: vi.fn().mockResolvedValue({ isDirectory: true }), - authorizeExternalPath: vi.fn(), markPathAsDirectory, setSelectedPath: vi.fn() }) @@ -97,7 +95,6 @@ describe('activateFileExplorerNode', () => { toggleDir: vi.fn(), loadDir: vi.fn(), statPath: vi.fn().mockRejectedValue(new Error('stat failed')), - authorizeExternalPath: vi.fn(), markPathAsDirectory: vi.fn(), setSelectedPath: vi.fn() }) @@ -115,98 +112,6 @@ describe('activateFileExplorerNode', () => { ) }) - 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', @@ -231,7 +136,6 @@ 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 5b91445639aa..cd7390f25b9e 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts @@ -39,7 +39,6 @@ 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 @@ -63,7 +62,6 @@ 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,7 +73,6 @@ export async function activateFileExplorerNode(args: { canToggleDirectories = true, loadDir, statPath, - authorizeExternalPath, markPathAsDirectory, setSelectedPath } = args @@ -95,12 +92,6 @@ 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 { // Why: an unresolvable target can't be proven to be a directory; fall through so @@ -165,7 +156,6 @@ export function useFileExplorerHandlers({ canToggleDirectories = true, loadDir, statPath, - authorizeExternalPath, markPathAsDirectory, setSelectedPath, scrollRef @@ -187,7 +177,6 @@ export function useFileExplorerHandlers({ canToggleDirectories, loadDir, statPath, - authorizeExternalPath, markPathAsDirectory, setSelectedPath }) @@ -200,7 +189,6 @@ export function useFileExplorerHandlers({ markPathAsDirectory, openFile, statPath, - authorizeExternalPath, toggleDir, setSelectedPath ] diff --git a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts index 356b7f6d07ee..90f40d0f8f28 100644 --- a/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts +++ b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts @@ -80,7 +80,6 @@ export function useSidebarProjectDrop(): { setIsHandlingDrop(true) try { - await window.api.fs.authorizeExternalPath({ targetPath: pathResolution.path }) const stat = await window.api.fs.stat({ filePath: pathResolution.path, access: userNamedFileAccess() diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts b/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts index b620a55b2e0c..6dfa05842db5 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts @@ -10,7 +10,6 @@ import { userNamedFileAccess } from '@/lib/local-file-access' type AbsoluteFileOperations = { assertAbsolutePathAllowed: () => void - authorizeExternalPath: (args: { targetPath: string }) => Promise openFile: ( file: Omit, options?: { preview?: boolean; targetGroupId?: string } @@ -29,8 +28,6 @@ export async function openAbsoluteTabEntryFile(args: { }): Promise { const filePath = validateNewTabEntryAbsolutePath(args.filePath, args.localPlatform) args.operations.assertAbsolutePathAllowed() - await args.operations.authorizeExternalPath({ targetPath: filePath }) - args.operations.assertAbsolutePathAllowed() let stat: Awaited> try { stat = await args.operations.statRuntimePath(args.context, filePath, userNamedFileAccess()) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts index 79211e6dd489..9826a4f8aef6 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts @@ -14,7 +14,6 @@ describe('openTabEntryWithOperations', () => { openWorkspaceBrowserTab: vi.fn().mockResolvedValue(undefined), openFile: vi.fn(), statRuntimePath: vi.fn().mockResolvedValue({ size: 1, isDirectory: false, mtime: 1 }), - authorizeExternalPath: vi.fn().mockResolvedValue(undefined), assertAbsolutePathAllowed: vi.fn(), ...overrides } @@ -254,7 +253,7 @@ describe('openTabEntryWithOperations', () => { }) }) - it('authorizes and opens absolute local files in the target group', async () => { + it('opens absolute local files as user-named in the target group', async () => { const operations = makeOperations() await openTabEntryWithOperations({ @@ -264,7 +263,6 @@ describe('openTabEntryWithOperations', () => { operations }) - expect(operations.authorizeExternalPath).toHaveBeenCalledWith({ targetPath: '/tmp/notes.md' }) expect(operations.statRuntimePath).toHaveBeenCalledWith( baseArgs.runtimeContext, '/tmp/notes.md', @@ -312,13 +310,12 @@ describe('openTabEntryWithOperations', () => { }) ).rejects.toThrow(TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE) - expect(operations.authorizeExternalPath).not.toHaveBeenCalled() expect(operations.statRuntimePath).not.toHaveBeenCalled() expect(operations.createRuntimePath).not.toHaveBeenCalled() expect(operations.openFile).not.toHaveBeenCalled() }) - it('rejects Windows path syntax before native POSIX authorization', async () => { + it('rejects Windows path syntax before touching a native POSIX path', async () => { const operations = makeOperations() await expect( @@ -330,7 +327,6 @@ describe('openTabEntryWithOperations', () => { }) ).rejects.toThrow('Enter an absolute path for this computer.') - expect(operations.authorizeExternalPath).not.toHaveBeenCalled() expect(operations.statRuntimePath).not.toHaveBeenCalled() expect(operations.openFile).not.toHaveBeenCalled() }) @@ -347,9 +343,6 @@ describe('openTabEntryWithOperations', () => { operations }) - expect(operations.authorizeExternalPath).toHaveBeenCalledWith({ - targetPath: 'C:/tmp/notes.md' - }) expect(operations.statRuntimePath).toHaveBeenCalledWith( baseArgs.runtimeContext, 'C:/tmp/notes.md', @@ -357,14 +350,16 @@ describe('openTabEntryWithOperations', () => { ) }) - it('stops after authorization when ownership becomes remote or ambiguous', async () => { - let releaseAuthorization: (() => void) | undefined - const authorization = new Promise((resolve) => { - releaseAuthorization = resolve - }) + it('stops after the file check when ownership becomes remote or ambiguous', async () => { + let releaseStat: (() => void) | undefined + const statResult = new Promise<{ size: number; isDirectory: boolean; mtime: number }>( + (resolve) => { + releaseStat = () => resolve({ size: 1, isDirectory: false, mtime: 1 }) + } + ) let allowed = true const operations = makeOperations({ - authorizeExternalPath: vi.fn(() => authorization), + statRuntimePath: vi.fn(() => statResult), assertAbsolutePathAllowed: vi.fn(() => { if (!allowed) { throw new Error(TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE) @@ -378,15 +373,14 @@ describe('openTabEntryWithOperations', () => { query: '/tmp/notes.md', operations }) - await vi.waitFor(() => expect(operations.authorizeExternalPath).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(operations.statRuntimePath).toHaveBeenCalledTimes(1)) const rejection = expect(opening).rejects.toThrow( TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE ) allowed = false - releaseAuthorization?.() + releaseStat?.() await rejection - expect(operations.statRuntimePath).not.toHaveBeenCalled() expect(operations.openFile).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts index 6c7ec6bbdb57..b0e1d4f7a641 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts @@ -66,7 +66,6 @@ export type TabEntryOperations = { options?: { preview?: boolean; targetGroupId?: string } ) => void statRuntimePath: typeof statRuntimePath - authorizeExternalPath: (args: { targetPath: string }) => Promise assertAbsolutePathAllowed: () => void } @@ -290,7 +289,6 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise { openWorkspaceBrowserTab, openFile: state.openFile, statRuntimePath, - authorizeExternalPath: window.api.fs.authorizeExternalPath, assertAbsolutePathAllowed: () => { if (!getTabEntryAllowAbsolutePaths(useAppStore.getState(), args.worktreeId)) { throw new Error(TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index ba64abdb2b8e..69a5762ba5dd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -174,10 +174,6 @@ export function openDetectedFilePath( } try { - // Why: remote paths don't need local auth — the relay/runtime is the security boundary. - if (canOpenWithSystemDefault) { - await window.api.fs.authorizeExternalPath({ targetPath: mappedFilePath }) - } statResult = await statRuntimePath(fileContext, mappedFilePath, userNamedFileAccess()) } catch (error) { if (requestId === latestOpenDetectedFilePathRequestId && deps.onOpenFailure) { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts index 12751c02373b..f609b05aaa69 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts @@ -24,7 +24,6 @@ export type TerminalLinkTestDoubles = { openFileUriMock: Mock openFilePathMock: Mock openFileMock: Mock - authorizeExternalPathMock: Mock statMock: Mock fsPathExistsMock: Mock runtimeEnvironmentCallMock: Mock @@ -43,7 +42,6 @@ export function createTerminalLinkTestDoubles(): TerminalLinkTestDoubles { const openFileUriMock = vi.fn() const openFilePathMock = vi.fn() const openFileMock = vi.fn() - const authorizeExternalPathMock = vi.fn() const statMock = vi.fn().mockResolvedValue({ isDirectory: false }) const fsPathExistsMock = vi.fn().mockResolvedValue(true) const runtimeEnvironmentCallMock = vi.fn() @@ -72,7 +70,6 @@ export function createTerminalLinkTestDoubles(): TerminalLinkTestDoubles { openFileUriMock, openFilePathMock, openFileMock, - authorizeExternalPathMock, statMock, fsPathExistsMock, runtimeEnvironmentCallMock, diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-harness.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-harness.ts index 5230fa00ed2b..43fd7a05a6d6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-harness.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-harness.ts @@ -35,7 +35,6 @@ export function installTerminalLinkTestEnvironment(doubles: TerminalLinkTestDoub openUrlMock, openFileUriMock, openFilePathMock, - authorizeExternalPathMock, statMock, fsPathExistsMock, runtimeEnvironmentCallMock, @@ -71,7 +70,6 @@ export function installTerminalLinkTestEnvironment(doubles: TerminalLinkTestDoub pathExists: vi.fn().mockResolvedValue(true) }, fs: { - authorizeExternalPath: authorizeExternalPathMock, pathExists: fsPathExistsMock, stat: statMock }, diff --git a/src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts index 500975a29b8e..dac1a35aaaa6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts @@ -9,7 +9,7 @@ import { } from './terminal-link-handlers-test-harness' const doubles = createTerminalLinkTestDoubles() -const { storeState, deps, openFileMock, statMock, authorizeExternalPathMock } = doubles +const { storeState, deps, openFileMock, statMock } = doubles vi.mock('@/store', () => ({ useAppStore: { @@ -58,19 +58,6 @@ describe('openDetectedFilePath on a path it cannot verify', () => { expect(onOpenFailure.mock.calls[0][0]).toEqual({ verdict: 'unverifiable', error }) }) - it('reports a refused path authorization as unverifiable', async () => { - setPlatform('Macintosh') - const error = new Error('Path is outside the allowed roots') - authorizeExternalPathMock.mockRejectedValueOnce(error) - const onOpenFailure = vi.fn() - - openDetectedFilePath('/tmp/src/denied.md', null, null, { ...deps, onOpenFailure }) - await flushAsyncWork() - - expect(statMock).not.toHaveBeenCalled() - expect(onOpenFailure.mock.calls[0][0]).toEqual({ verdict: 'unverifiable', error }) - }) - it('skips the callback when a later click superseded the failing one', async () => { setPlatform('Macintosh') let rejectFirstStat!: (error: Error) => void diff --git a/src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts index c537d57ba0f0..6270da6ada28 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts @@ -9,14 +9,8 @@ import { } from './terminal-link-handlers-test-harness' const doubles = createTerminalLinkTestDoubles() -const { - storeState, - deps, - authorizeExternalPathMock, - openFileMock, - openFilePathMock, - setPendingEditorRevealMock -} = doubles +const { storeState, deps, openFileMock, openFilePathMock, setPendingEditorRevealMock, statMock } = + doubles vi.mock('@/store', () => ({ useAppStore: { @@ -49,7 +43,6 @@ describe('handleOscLink', () => { await new Promise((resolve) => setTimeout(resolve, 0)) - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(openFileMock).not.toHaveBeenCalled() expect(openFilePathMock).not.toHaveBeenCalled() }) @@ -65,7 +58,9 @@ describe('handleOscLink', () => { // before asserting on positive behavior. await new Promise((resolve) => setTimeout(resolve, 0)) - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/test.txt' }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/test.txt', access: { kind: 'user-file' } }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/test.txt' }), { forceContentReload: true } @@ -88,9 +83,9 @@ describe('handleOscLink', () => { await flushAsyncWork() await flushDoubleRaf() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: 'C:/repo/src/index.ts' - }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: 'C:/repo/src/index.ts', access: { kind: 'user-file' } }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: 'C:/repo/src/index.ts' }), { forceContentReload: true } @@ -118,9 +113,12 @@ describe('handleOscLink', () => { ) await flushAsyncWork() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '//server/share/repo/test.txt' - }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '//server/share/repo/test.txt', + access: { kind: 'user-file' } + }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '//server/share/repo/test.txt' }), { forceContentReload: true } @@ -140,7 +138,6 @@ describe('handleOscLink', () => { ) await flushAsyncWork() - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(openFileMock).not.toHaveBeenCalled() }) @@ -151,7 +148,9 @@ describe('handleOscLink', () => { await flushAsyncWork() await flushDoubleRaf() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/test.txt' }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/test.txt', access: { kind: 'user-file' } }) + ) expect(openFilePathMock).not.toHaveBeenCalled() expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/test.txt' }), @@ -177,7 +176,9 @@ describe('handleOscLink', () => { ) await flushAsyncWork() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/test.txt' }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/test.txt', access: { kind: 'user-file' } }) + ) expect(openFilePathMock).toHaveBeenCalledWith('/tmp/test.txt') expect(openFileMock).not.toHaveBeenCalled() expect(setPendingEditorRevealMock).not.toHaveBeenCalled() @@ -195,7 +196,9 @@ describe('handleOscLink', () => { await flushAsyncWork() await flushDoubleRaf() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/test.txt' }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/test.txt', access: { kind: 'user-file' } }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/test.txt' }), { forceContentReload: true } @@ -224,9 +227,12 @@ describe('handleOscLink', () => { await flushAsyncWork() await flushDoubleRaf() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '//server/Share/Repo/src/app.ts' - }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '//server/Share/Repo/src/app.ts', + access: { kind: 'user-file' } + }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '//server/Share/Repo/src/app.ts', @@ -258,9 +264,12 @@ describe('handleOscLink', () => { await new Promise((resolve) => setTimeout(resolve, 0)) - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '/tmp/project/docs/README.md' - }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/tmp/project/docs/README.md', + access: { kind: 'user-file' } + }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/project/docs/README.md', @@ -287,9 +296,12 @@ describe('handleOscLink', () => { await flushAsyncWork() await flushDoubleRaf() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md' - }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md', + access: { kind: 'user-file' } + }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md' @@ -320,9 +332,12 @@ describe('handleOscLink', () => { await flushAsyncWork() await flushDoubleRaf() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md' - }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md', + access: { kind: 'user-file' } + }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md' @@ -353,9 +368,9 @@ describe('handleOscLink', () => { await new Promise((resolve) => setTimeout(resolve, 0)) - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '/home/alice/file.ts' - }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/home/alice/file.ts', access: { kind: 'user-file' } }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/home/alice/file.ts' diff --git a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts index 67562aec0529..2df62eb3e998 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts @@ -15,7 +15,6 @@ const doubles = createTerminalLinkTestDoubles() const { storeState, deps, - authorizeExternalPathMock, statMock, openFileMock, openFilePathMock, @@ -67,7 +66,6 @@ describe('handleOscLink', () => { openDetectedFilePath('/tmp/src/main.ts', null, null, deps) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(statMock).not.toHaveBeenCalled() await vi.waitFor(() => { expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({ @@ -130,7 +128,6 @@ describe('handleOscLink', () => { }) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(statMock).toHaveBeenCalledWith({ filePath: '/home/me/repo/src/main.ts', connectionId: 'ssh-1' @@ -172,7 +169,6 @@ describe('handleOscLink', () => { }) await flushAsyncWork() - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(statMock).toHaveBeenCalledWith({ filePath: '/tmp/ssh-preview.png', connectionId: 'ssh-1' diff --git a/src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts index 1920afc3643f..27eb4583b24f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts @@ -16,8 +16,7 @@ import { } from './terminal-link-handlers-test-harness' const doubles = createTerminalLinkTestDoubles() -const { storeState, deps, authorizeExternalPathMock, statMock, openFileMock, openFilePathMock } = - doubles +const { storeState, deps, statMock, openFileMock, openFilePathMock } = doubles vi.mock('@/store', () => ({ useAppStore: { @@ -55,7 +54,6 @@ describe('handleOscLink', () => { await flushAsyncWork() expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2') - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(statMock).not.toHaveBeenCalled() expect(openFilePathMock).not.toHaveBeenCalled() expect(openFileMock).not.toHaveBeenCalled() @@ -73,7 +71,6 @@ describe('handleOscLink', () => { expect(activateAndRevealWorktree).toHaveBeenCalledTimes(1) expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2') - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(statMock).not.toHaveBeenCalled() }) @@ -90,9 +87,9 @@ describe('handleOscLink', () => { }) await flushAsyncWork() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '/tmp/other-worktree' - }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/other-worktree', access: { kind: 'user-file' } }) + ) expect(statMock).toHaveBeenCalled() expect(openFilePathMock).toHaveBeenCalledWith('/tmp/other-worktree') expect(activateAndRevealWorktree).not.toHaveBeenCalled() @@ -113,7 +110,6 @@ describe('handleOscLink', () => { await flushAsyncWork() expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2') - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(statMock).not.toHaveBeenCalled() expect(openFilePathMock).not.toHaveBeenCalled() }) @@ -131,7 +127,6 @@ describe('handleOscLink', () => { await flushAsyncWork() expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-win') - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(statMock).not.toHaveBeenCalled() expect(openFilePathMock).not.toHaveBeenCalled() }) @@ -147,7 +142,6 @@ describe('handleOscLink', () => { await flushAsyncWork() expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2') - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(statMock).not.toHaveBeenCalled() expect(openFilePathMock).not.toHaveBeenCalled() expect(openFileMock).not.toHaveBeenCalled() @@ -254,7 +248,6 @@ describe('createFilePathLinkProvider range bounds', () => { expect(opened).toBe(true) expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2') - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(statMock).not.toHaveBeenCalled() expect(openFilePathMock).not.toHaveBeenCalled() expect(openFileMock).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts index 03c5eb7835f5..744cd12f9886 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-wsl-path-mapping.test.ts @@ -11,13 +11,7 @@ import { } from './terminal-link-handlers-test-harness' const doubles = createTerminalLinkTestDoubles() -const { - storeState, - authorizeExternalPathMock, - statMock, - openFileMock, - setPendingEditorRevealMock -} = doubles +const { storeState, statMock, openFileMock, setPendingEditorRevealMock } = doubles vi.mock('@/store', () => ({ useAppStore: { @@ -187,14 +181,16 @@ describe('createFilePathLinkProvider range bounds', () => { openDetectedFilePath('/home/alice/notes.md', null, null, { worktreeId: 'wt-1', - worktreePath: 'C:\\repo', + worktreePath: '/home/alice', wslDistro: 'Ubuntu', runtimeEnvironmentId: 'env-1' }) await flushAsyncWork() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '/home/alice/notes.md' - }) + // An unmapped POSIX path stays inside the runtime worktree and is asked of its host. + expect(JSON.stringify(doubles.runtimeEnvironmentTransportCallMock.mock.calls)).toContain( + '"relativePath":"notes.md"' + ) + expect(statMock).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx index 9b72a3d0dba0..31900f8202dc 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx +++ b/src/renderer/src/hooks/composer-state/attachment-drop-failure.test.tsx @@ -31,7 +31,6 @@ function installFsApi(): void { Object.assign(window, { api: { fs: { - authorizeExternalPath: vi.fn(async () => {}), stat: vi.fn(async ({ filePath }: { filePath: string }) => { if (FAILING_PATHS.has(filePath)) { throw new Error( diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx b/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx index 577d7c1fdf18..6709b5e76f34 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx +++ b/src/renderer/src/hooks/composer-state/attachment-drop-lifetime.test.tsx @@ -12,7 +12,6 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) vi.mock('@/runtime/runtime-file-client', () => ({ importExternalPathsToRuntime: vi.fn() })) const listeners = new Set<(data: NativeFileDropPayload) => void>() -const authorize = vi.fn(async (_input: { targetPath: string }) => {}) const stat = vi.fn(async (_input: { filePath: string }) => ({ isDirectory: false })) let originalApi: PropertyDescriptor | undefined @@ -45,13 +44,12 @@ function nativeDrop(paths: string[]): void { beforeEach(() => { vi.clearAllMocks() - authorize.mockReset().mockResolvedValue(undefined) stat.mockReset().mockResolvedValue({ isDirectory: false }) originalApi = Object.getOwnPropertyDescriptor(window, 'api') Object.defineProperty(window, 'api', { configurable: true, value: { - fs: { authorizeExternalPath: authorize, stat }, + fs: { stat }, ui: { onFileDrop: (listener: (data: NativeFileDropPayload) => void) => { listeners.add(listener) @@ -72,61 +70,45 @@ afterEach(() => { } }) +function holdStat(gate: Promise): void { + stat.mockImplementationOnce(async () => { + await gate + return { isDirectory: false } + }) +} + describe('local composer drop lifetime', () => { - it.each(['authorize', 'stat'] as const)( - 'stops a large batch after unmount during %s', - async (phase) => { - const gate = Promise.withResolvers() - if (phase === 'authorize') { - authorize.mockImplementationOnce(() => gate.promise) - } else { - stat.mockImplementationOnce(async () => { - await gate.promise - return { isDirectory: false } - }) - } - const hook = renderDrop() - const paths = Array.from({ length: 1000 }, (_, index) => `/drop/item-${index}`) - const pending = hook.result.current.applyLocalComposerDrop(paths) - await vi.waitFor(() => - expect(phase === 'authorize' ? authorize : stat).toHaveBeenCalledOnce() - ) - hook.unmount() - gate.resolve() - await pending + it('stops a large batch after unmount during a file check', async () => { + const gate = Promise.withResolvers() + holdStat(gate.promise) + const hook = renderDrop() + const paths = Array.from({ length: 1000 }, (_, index) => `/drop/item-${index}`) + const pending = hook.result.current.applyLocalComposerDrop(paths) + await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce()) + hook.unmount() + gate.resolve() + await pending - expect(authorize).toHaveBeenCalledOnce() - expect(stat).toHaveBeenCalledTimes(phase === 'authorize' ? 0 : 1) - expect(hook.attach).not.toHaveBeenCalled() - expect(hook.prompt).not.toHaveBeenCalled() - expect(mocks.toastError).not.toHaveBeenCalled() - } - ) + expect(stat).toHaveBeenCalledOnce() + expect(hook.attach).not.toHaveBeenCalled() + expect(hook.prompt).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + }) - it.each(['authorize', 'stat'] as const)( - 'stops silently when a held %s fails after unmount', - async (phase) => { - const gate = Promise.withResolvers() - if (phase === 'authorize') { - authorize.mockImplementationOnce(() => gate.promise) - } else { - stat.mockImplementationOnce(() => gate.promise) - } - const hook = renderDrop() - const pending = hook.result.current.applyLocalComposerDrop(['/drop/one', '/drop/two']) - await vi.waitFor(() => - expect(phase === 'authorize' ? authorize : stat).toHaveBeenCalledOnce() - ) - hook.unmount() - gate.reject(new Error('EACCES: no access')) - await pending + it('stops silently when a held file check fails after unmount', async () => { + const gate = Promise.withResolvers() + stat.mockImplementationOnce(() => gate.promise) + const hook = renderDrop() + const pending = hook.result.current.applyLocalComposerDrop(['/drop/one', '/drop/two']) + await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce()) + hook.unmount() + gate.reject(new Error('EACCES: no access')) + await pending - expect(authorize).toHaveBeenCalledOnce() - expect(stat).toHaveBeenCalledTimes(phase === 'authorize' ? 0 : 1) - expect(hook.attach).not.toHaveBeenCalled() - expect(mocks.toastError).not.toHaveBeenCalled() - } - ) + expect(stat).toHaveBeenCalledOnce() + expect(hook.attach).not.toHaveBeenCalled() + expect(mocks.toastError).not.toHaveBeenCalled() + }) it('does no work through a callback saved before unmount', async () => { const hook = renderDrop() @@ -134,18 +116,14 @@ describe('local composer drop lifetime', () => { hook.unmount() await applyDrop(['/drop/late']) - expect(authorize).not.toHaveBeenCalled() expect(stat).not.toHaveBeenCalled() expect(hook.attach).not.toHaveBeenCalled() }) - it('preserves mixed results, order, duplicate filtering and one failure report', async () => { + it('checks each dropped path as user-named, keeping results, order and one failure report', async () => { const order: string[] = [] - authorize.mockImplementation(async ({ targetPath }) => { - order.push(`authorize:${targetPath}`) - }) stat.mockImplementation(async ({ filePath }) => { - order.push(`stat:${filePath}`) + order.push(filePath) if (filePath === '/drop/missing') { throw new Error('ENOENT: missing') } @@ -155,7 +133,8 @@ describe('local composer drop lifetime', () => { const paths = ['/drop/one', '/drop/folder', '/drop/missing', '/drop/two', '/drop/one'] await hook.result.current.applyLocalComposerDrop(paths) - expect(order).toEqual(paths.flatMap((path) => [`authorize:${path}`, `stat:${path}`])) + expect(order).toEqual(paths) + expect(stat).toHaveBeenCalledWith({ filePath: '/drop/one', access: { kind: 'user-file' } }) expect(hook.attach).toHaveBeenCalledOnce() expect(hook.attach.mock.calls[0]?.[0](['/existing'])).toEqual([ '/existing', @@ -175,16 +154,15 @@ describe('native composer ownership during a local drop', () => { 'stops after actual listener cleanup (Strict Mode: %s)', async (strict) => { const gate = Promise.withResolvers() - authorize.mockImplementationOnce(() => gate.promise) + holdStat(gate.promise) const hook = renderDrop(strict) act(() => nativeDrop(['/drop/one', '/drop/two'])) - await vi.waitFor(() => expect(authorize).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce()) hook.unmount() expect(listeners.size).toBe(0) await act(async () => gate.resolve()) - expect(authorize).toHaveBeenCalledOnce() - expect(stat).not.toHaveBeenCalled() + expect(stat).toHaveBeenCalledOnce() expect(hook.attach).not.toHaveBeenCalled() } ) @@ -192,16 +170,14 @@ describe('native composer ownership during a local drop', () => { it('continues while temporarily covered and applies if ownership returns', async () => { const first = Promise.withResolvers() const second = Promise.withResolvers() - authorize - .mockImplementationOnce(() => first.promise) - .mockImplementationOnce(() => second.promise) + holdStat(first.promise) + holdStat(second.promise) const older = renderDrop() act(() => nativeDrop(['/drop/one', '/drop/two'])) - await vi.waitFor(() => expect(authorize).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce()) const newer = renderDrop() await act(async () => first.resolve()) - expect(authorize).toHaveBeenCalledTimes(2) - expect(stat).toHaveBeenCalledOnce() + expect(stat).toHaveBeenCalledTimes(2) expect(older.attach).not.toHaveBeenCalled() newer.unmount() await act(async () => second.resolve()) @@ -212,14 +188,13 @@ describe('native composer ownership during a local drop', () => { it('withholds a completed drop while a newer owner remains mounted', async () => { const gate = Promise.withResolvers() - authorize.mockImplementationOnce(() => gate.promise) + holdStat(gate.promise) const older = renderDrop() act(() => nativeDrop(['/drop/one', '/drop/two'])) - await vi.waitFor(() => expect(authorize).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce()) const newer = renderDrop() await act(async () => gate.resolve()) - expect(authorize).toHaveBeenCalledTimes(2) expect(stat).toHaveBeenCalledTimes(2) expect(older.attach).not.toHaveBeenCalled() expect(newer.attach).not.toHaveBeenCalled() @@ -227,10 +202,10 @@ describe('native composer ownership during a local drop', () => { it('does not revive an old batch when another composer mounts', async () => { const gate = Promise.withResolvers() - authorize.mockImplementationOnce(() => gate.promise) + holdStat(gate.promise) const older = renderDrop() act(() => nativeDrop(['/drop/old-one', '/drop/old-two'])) - await vi.waitFor(() => expect(authorize).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce()) older.unmount() const newer = renderDrop() await act(async () => { @@ -238,11 +213,7 @@ describe('native composer ownership during a local drop', () => { gate.resolve() }) - expect(authorize.mock.calls.map(([input]) => input.targetPath)).toEqual([ - '/drop/old-one', - '/drop/new' - ]) - expect(stat.mock.calls.map(([input]) => input.filePath)).toEqual(['/drop/new']) + expect(stat.mock.calls.map(([input]) => input.filePath)).toEqual(['/drop/old-one', '/drop/new']) expect(older.attach).not.toHaveBeenCalled() expect(newer.attach).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts index 88cca731b876..3358a7e028e1 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts @@ -221,10 +221,6 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { return } try { - await window.api.fs.authorizeExternalPath({ targetPath: filePath }) - if (!mountedRef.current) { - return - } const stat = await window.api.fs.stat({ filePath, access: userNamedFileAccess() }) results.push({ status: 'imported', diff --git a/src/renderer/src/hooks/useGlobalFileDrop.ts b/src/renderer/src/hooks/useGlobalFileDrop.ts index c012b7bb8921..5f808c99e7e3 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.ts @@ -8,7 +8,6 @@ import { joinPath } from '@/lib/path' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { importExternalPathsToRuntime, - isRemoteRuntimeFileOperation, statRuntimePath, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' @@ -161,11 +160,6 @@ export function useGlobalFileDrop(): void { for (const filePath of data.paths) { void (async () => { try { - const isRemoteRuntimePath = isRemoteRuntimeFileOperation(fileContext, filePath) - // Why: remote paths don't need local auth — the relay/runtime is the security boundary. - if (!connectionId && !isRemoteRuntimePath) { - await window.api.fs.authorizeExternalPath({ targetPath: filePath }) - } const stat = await statRuntimePath(fileContext, filePath, userNamedFileAccess()) if (stat.isDirectory) { return @@ -192,7 +186,7 @@ export function useGlobalFileDrop(): void { mode: 'edit' }) } catch { - // Ignore files that cannot be authorized or stat'd. + // Ignore files that cannot be stat'd. } })() } diff --git a/src/renderer/src/lib/editor-tab-external-path-grant.test.ts b/src/renderer/src/lib/editor-tab-external-path-grant.test.ts deleted file mode 100644 index d4497b67d860..000000000000 --- a/src/renderer/src/lib/editor-tab-external-path-grant.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../shared/repo-types' -import type { Worktree } from '../../../shared/worktree/types' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import { useAppStore } from '@/store' -import { - getEditorTabExternalPathGrantTarget, - refreshEditorTabExternalPathGrant -} from './editor-tab-external-path-grant' - -const initialState = useAppStore.getInitialState() - -function makeRepo(overrides: Partial & { id: string; path: string }): Repo { - return { displayName: 'repo', badgeColor: '#000', addedAt: 0, ...overrides } -} - -function makeWorktree(overrides: Partial & { id: string; repoId: string }): Worktree { - return { - path: '/Users/me/project', - head: 'abc123', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true, - displayName: 'project', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - ...overrides - } -} - -const localWorktreeId = 'repo-local::/Users/me/project' -const sshWorktreeId = 'repo-ssh::/work/project' - -function grantTarget(file: { - filePath: string - relativePath: string - worktreeId: string - runtimeEnvironmentId?: string | null - externalSshTargetId?: string -}): string | null { - return getEditorTabExternalPathGrantTarget(useAppStore.getState(), file) -} - -describe('getEditorTabExternalPathGrantTarget', () => { - beforeEach(() => { - useAppStore.setState({ - repos: [ - makeRepo({ id: 'repo-local', path: '/Users/me/project' }), - makeRepo({ id: 'repo-ssh', path: '/work/project', connectionId: 'ssh-1' }) - ], - worktreesByRepo: { - 'repo-local': [makeWorktree({ id: localWorktreeId, repoId: 'repo-local' })] - } - }) - }) - - afterEach(() => { - useAppStore.setState(initialState, true) - vi.unstubAllGlobals() - }) - - it('grants a restored floating-workspace tab that stores a root-relative path', () => { - expect( - grantTarget({ - filePath: '/Users/me/notes.txt', - relativePath: 'notes.txt', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID - }) - ).toBe('/Users/me/notes.txt') - }) - - it('grants a client-local tab stored outside its own project', () => { - expect( - grantTarget({ - filePath: '/Users/me/notes/audit.md', - relativePath: '/Users/me/notes/audit.md', - worktreeId: localWorktreeId - }) - ).toBe('/Users/me/notes/audit.md') - }) - - it('does not grant a project tab, which its authorized root already covers', () => { - expect( - grantTarget({ - filePath: '/Users/me/project/src/link.ts', - relativePath: 'src/link.ts', - worktreeId: localWorktreeId - }) - ).toBeNull() - }) - - it('keeps SSH-owned external tabs off the local grant', () => { - expect( - grantTarget({ - filePath: '/work/reports/audit.md', - relativePath: '/work/reports/audit.md', - worktreeId: sshWorktreeId - }) - ).toBeNull() - expect( - grantTarget({ - filePath: '/work/reports/audit.md', - relativePath: '/work/reports/audit.md', - worktreeId: localWorktreeId, - externalSshTargetId: 'ssh-1' - }) - ).toBeNull() - }) - - it('does not grant an external tab whose owner has not hydrated yet', () => { - expect( - grantTarget({ - filePath: '/work/reports/audit.md', - relativePath: '/work/reports/audit.md', - worktreeId: 'repo-missing::/work/other' - }) - ).toBeNull() - }) - - it('does not grant an external tab in a folder workspace whose host is unknown', () => { - // Why: a missing or ambiguous folder host may be SSH; only an explicitly local owner is granted. - expect( - grantTarget({ - filePath: '/home/remote-user/notes.md', - relativePath: '/home/remote-user/notes.md', - worktreeId: folderWorkspaceKey('fw-missing') - }) - ).toBeNull() - }) - - it('does not grant a runtime-owned tab, which the runtime reads remotely', () => { - expect( - grantTarget({ - filePath: '/Users/me/notes.txt', - relativePath: 'notes.txt', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - runtimeEnvironmentId: 'runtime-1' - }) - ).toBeNull() - }) - - it('skips the grant call entirely when none is needed', async () => { - const authorizeExternalPath = vi.fn().mockResolvedValue(undefined) - vi.stubGlobal('window', { api: { fs: { authorizeExternalPath } } }) - const state = useAppStore.getState() - - expect( - refreshEditorTabExternalPathGrant(state, { - filePath: '/Users/me/project/a.ts', - relativePath: 'a.ts', - worktreeId: localWorktreeId - }) - ).toBeNull() - await refreshEditorTabExternalPathGrant(state, { - filePath: '/Users/me/notes.txt', - relativePath: 'notes.txt', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID - }) - expect(authorizeExternalPath).toHaveBeenCalledTimes(1) - // Main, not the renderer, decides whether a project root already covers the path. - expect(authorizeExternalPath).toHaveBeenCalledWith({ - targetPath: '/Users/me/notes.txt', - skipIfInsideAllowedRoots: true - }) - }) -}) diff --git a/src/renderer/src/lib/editor-tab-external-path-grant.ts b/src/renderer/src/lib/editor-tab-external-path-grant.ts deleted file mode 100644 index 56418947b767..000000000000 --- a/src/renderer/src/lib/editor-tab-external-path-grant.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { settingsForRuntimeOwner } from '@/runtime/runtime-client-target' -import type { OpenFile } from '@/store/slices/editor' -import type { AppState } from '@/store/types' -import { getConnectionIdForFile } from './connection-context' - -type EditorTabPathGrantFile = Pick< - OpenFile, - 'filePath' | 'relativePath' | 'worktreeId' | 'runtimeEnvironmentId' | 'externalSshTargetId' -> - -/** - * The client-local path a restored editor tab may need re-granted, decided by who owns the tab. - * Main decides whether a project root already covers it. - */ -export function getEditorTabExternalPathGrantTarget( - state: AppState, - file: EditorTabPathGrantFile -): string | null { - const runtimeOwner = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) - if (file.externalSshTargetId?.trim() || runtimeOwner?.activeRuntimeEnvironmentId?.trim()) { - return null - } - // Why: the floating workspace root (`~` by default) is deliberately not an authorized root, - // so its tabs need a grant even though they store a root-relative path. - if (file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { - return file.filePath - } - // Why null only: an SSH, ambiguous or not-yet-loaded owner may mean the path lives on another host. - if ( - file.relativePath !== file.filePath || - getConnectionIdForFile(file.worktreeId, file.filePath) !== null - ) { - return null - } - return file.filePath -} - -/** - * Main's external-path grants live only in memory, so every reader of a restored tab re-derives - * its own. Null when no grant is needed, so callers can keep their read on the same tick. - */ -export function refreshEditorTabExternalPathGrant( - state: AppState, - file: EditorTabPathGrantFile -): Promise | null { - const targetPath = getEditorTabExternalPathGrantTarget(state, file) - return targetPath - ? window.api.fs.authorizeExternalPath({ targetPath, skipIfInsideAllowedRoots: true }) - : null -} diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index ebef7ffe1354..640b5e055370 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -146,6 +146,7 @@ const USER_NAMED_ACCESS_IMPORTERS = [ 'components/browser-pane/navigate/navigate-browser-page-url.ts', 'components/native-chat/NativeChatImageAttachmentPreview.tsx', 'components/native-chat/NativeChatTranscriptChrome.tsx', + 'components/native-chat/use-native-chat-external-attachments.ts', 'components/sidebar/useSidebarProjectDrop.ts', 'components/tab-bar/tab-create-entry-absolute-file.ts', 'components/terminal-pane/terminal-file-open-routing.ts', diff --git a/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts b/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts index 6f4d674da591..1d7b41f9105c 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts @@ -18,7 +18,6 @@ type WindowStub = { fs: { readFile: ReturnType writeFile: ReturnType - authorizeExternalPath: ReturnType } } } @@ -27,12 +26,10 @@ let mobileMarkdownHandler: ((request: RuntimeMobileMarkdownRequest) => void) | n export function setupWindow({ readFile, - writeFile = vi.fn().mockResolvedValue(undefined), - authorizeExternalPath = vi.fn().mockResolvedValue(undefined) + writeFile = vi.fn().mockResolvedValue(undefined) }: { readFile: ReturnType writeFile?: ReturnType - authorizeExternalPath?: ReturnType }): { responses: unknown[] } { const eventTarget = new EventTarget() const responses: unknown[] = [] @@ -53,7 +50,7 @@ export function setupWindow({ }, respondMobileMarkdownRequest: vi.fn((response) => responses.push(response)) }, - fs: { readFile, writeFile, authorizeExternalPath } + fs: { readFile, writeFile } } } satisfies WindowStub) return { responses } diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts index 505143bfee0a..8597316794fa 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts @@ -64,7 +64,7 @@ describe('mobile markdown bridge', () => { } }) - it('grants a restored floating-workspace tab before reading it for mobile', async () => { + it('reads a restored floating-workspace tab for a paired client as user-named', async () => { useAppStore.getState().openFile({ filePath: '/Users/me/notes.md', relativePath: 'notes.md', @@ -72,34 +72,20 @@ describe('mobile markdown bridge', () => { language: 'markdown', mode: 'edit' }) - const tabId = useAppStore.getState().openFiles[0]!.id + const tabId = useAppStore.getState().openFiles[0]?.id ?? '' const readFile = vi.fn().mockResolvedValue({ content: '# notes', isBinary: false }) - let finishGrant: () => void = () => {} - const authorizeExternalPath = vi.fn( - () => new Promise((resolve) => (finishGrant = resolve)) - ) - setupWindow({ readFile, authorizeExternalPath }) + setupWindow({ readFile }) const detach = attachMobileMarkdownBridge() try { - const response = sendRequest({ + const response = await sendRequest({ id: 'read-floating', operation: 'read', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, tabId }) - await new Promise((resolve) => setTimeout(resolve, 5)) - expect(authorizeExternalPath).toHaveBeenCalledWith({ - targetPath: '/Users/me/notes.md', - skipIfInsideAllowedRoots: true - }) - expect(readFile).not.toHaveBeenCalled() - finishGrant() - expect(await response).toMatchObject({ - ok: true, - result: { content: '# notes', source: 'file' } - }) + expect(response).toMatchObject({ ok: true, result: { content: '# notes', source: 'file' } }) expect(readFile).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/Users/me/notes.md', access: { kind: 'user-file' } }) ) @@ -108,6 +94,26 @@ describe('mobile markdown bridge', () => { } }) + it('reads a project tab for a paired client inside its root', async () => { + openMarkdownFile() + const readFile = vi.fn().mockResolvedValue({ content: 'disk', isBinary: false }) + setupWindow({ readFile }) + const detach = attachMobileMarkdownBridge() + + try { + await sendRequest({ + id: 'read-project', + operation: 'read', + worktreeId: 'wt-1', + tabId: 'tab-md' + }) + + expect(readFile.mock.calls[0]?.[0]).not.toHaveProperty('access') + } finally { + detach() + } + }) + it('rejects save when a clean file changed after mobile read', async () => { openMarkdownFile() const writeFile = vi.fn().mockResolvedValue(undefined) diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index baf5ef02775c..befacc31ac55 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -8,7 +8,6 @@ import { import { flushPendingEditorChange } from '@/components/editor/editor-pending-flush' import { getConnectionIdForFile } from '@/lib/connection-context' import { editorTabFileAccess } from '@/lib/local-file-access' -import { refreshEditorTabExternalPathGrant } from '@/lib/editor-tab-external-path-grant' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { readRuntimeFileContent } from './runtime-file-client' @@ -245,11 +244,6 @@ async function readCurrentContent( async function readFileContent(file: OpenFile): Promise { const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined const state = useAppStore.getState() - // Why: a paired client can read a restored tab the desktop editor never loaded. - const pathGrant = refreshEditorTabExternalPathGrant(state, file) - if (pathGrant) { - await pathGrant - } const result = (await readRuntimeFileContent({ settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), filePath: file.filePath, diff --git a/src/renderer/src/store/slices/editor-markdown-link-activation.test.ts b/src/renderer/src/store/slices/editor-markdown-link-activation.test.ts index 005ac4b99723..f5d005c8e13c 100644 --- a/src/renderer/src/store/slices/editor-markdown-link-activation.test.ts +++ b/src/renderer/src/store/slices/editor-markdown-link-activation.test.ts @@ -33,7 +33,6 @@ describe('createEditorSlice activateMarkdownLink', () => { const openUrlMock = vi.fn() const openFileUriMock = vi.fn() const pathExistsMock = vi.fn() - const authorizeExternalPathMock = vi.fn() const fsStatMock = vi.fn() const runtimeEnvironmentCallMock = vi.fn() const runtimeEnvironmentTransportCallMock = vi.fn() @@ -45,7 +44,6 @@ describe('createEditorSlice activateMarkdownLink', () => { openFileUriMock.mockReset() pathExistsMock.mockReset() pathExistsMock.mockResolvedValue(true) - authorizeExternalPathMock.mockReset() fsStatMock.mockReset() fsStatMock.mockImplementation(async ({ filePath }: { filePath: string }) => { const exists = await pathExistsMock(filePath) @@ -78,7 +76,6 @@ describe('createEditorSlice activateMarkdownLink', () => { pathExists: pathExistsMock }, fs: { - authorizeExternalPath: authorizeExternalPathMock, stat: fsStatMock }, runtimeEnvironments: { @@ -558,14 +555,13 @@ describe('createEditorSlice activateMarkdownLink', () => { expect(openFileUriMock).not.toHaveBeenCalled() }) - it('opens explicit file URLs outside the worktree in Orca after authorizing them', async () => { + it('opens explicit file URLs outside the worktree in Orca as user-named tabs', async () => { const store = createEditorStore() await store.getState().activateMarkdownLink('file:///tmp/image.png', { sourceFilePath: '/repo/docs/note.md', worktreeId: 'wt-1', worktreeRoot: '/repo' }) - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/image.png' }) expect(store.getState().openFiles).toEqual([ expect.objectContaining({ filePath: '/tmp/image.png', @@ -621,7 +617,6 @@ describe('createEditorSlice activateMarkdownLink', () => { worktreeRoot: '/repo' }) - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(store.getState().openFiles).toEqual([]) expect(toastErrorMock).toHaveBeenCalledWith( 'Opening remote paths in the local OS is not available.' diff --git a/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts b/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts index 17af9ca52114..435173e0e983 100644 --- a/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts +++ b/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts @@ -86,8 +86,6 @@ export function createMarkdownLinkAction( showLocalPathOpenBlockedToast() return } - // Why: markdown file:// links need the same user-gesture authorization terminal links get, so external paths (e.g. /tmp screenshots) can open in Orca. - await window.api.fs.authorizeExternalPath({ targetPath: target.absolutePath }) } else { let stats: { isDirectory: boolean } try { diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index 889e89b5103c..ab7062504a17 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -74,7 +74,6 @@ export function createFileApi(): NonNullable['fs']> { ...createWebFileMutationMethods({ captureSession: captureWebFileMutationSession }), - authorizeExternalPath: () => Promise.resolve(), stat: async ({ filePath }) => { const file = await resolveRuntimeFilePath(filePath) return callRuntimeResult('files.stat', { From 04c86c3471353cfcdaae0f56c9a8fdb501c389ae Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:07:40 -0700 Subject: [PATCH 09/19] feat(editor): open project links that lead outside the project as named files Problem: a file inside a project that is a symlink to something outside it opened fine from the file explorer or a terminal Cmd-click, then showed "Access denied" after a restart: its tab was stored as a project file, and a project request is refused when it resolves out of the project. A folder link out of the project expanded in the explorer until restart and then failed with a raw access error. Now the click decides and the tab keeps that decision. Both gestures stat the path inside the project first; if only the user-named check passes, the path leads out of the project: - a file opens by its absolute path, so it reads and saves as a file the user named, the same before and after a restart; - the explorer does not follow a folder link out of the project and says so ("This folder links outside the project, so it can't be opened here."). Paths that stay inside the project still open as contained project tabs. Also drops the AI Vault "path not authorized" message, which nothing shows now. --- src/main/ipc/filesystem-request-shape.test.ts | 12 +++ .../useFileExplorerHandlers.test.ts | 56 ++++++++++++ .../right-sidebar/useFileExplorerHandlers.ts | 24 +++-- .../right-sidebar/useFileExplorerTree.ts | 6 +- .../terminal-file-open-routing.ts | 9 +- .../terminal-link-escaping-file-open.test.ts | 74 ++++++++++++++++ .../terminal-link-missing-file-open.test.ts | 5 +- .../terminal-link-osc-file-targets.test.ts | 41 +++------ ...inal-link-worktree-root-activation.test.ts | 2 +- src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/fr.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- .../src/lib/local-file-access.test.ts | 13 ++- .../src/lib/user-opened-local-path.test.ts | 87 +++++++++++++++++++ .../src/lib/user-opened-local-path.ts | 47 ++++++++++ 18 files changed, 341 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-escaping-file-open.test.ts create mode 100644 src/renderer/src/lib/user-opened-local-path.test.ts create mode 100644 src/renderer/src/lib/user-opened-local-path.ts diff --git a/src/main/ipc/filesystem-request-shape.test.ts b/src/main/ipc/filesystem-request-shape.test.ts index f5729fc472ac..67dd47a5d73a 100644 --- a/src/main/ipc/filesystem-request-shape.test.ts +++ b/src/main/ipc/filesystem-request-shape.test.ts @@ -221,6 +221,18 @@ describe.skipIf(process.platform === 'win32')('project symlinks under every spel expect(await settles(resolveLocalWriteRequestPath(named(), undefined, store))).toBe('denied') expect(await settles(resolveLocalFileRequestPath(secret, undefined, store))).toBe('denied') }) + + it('reads a project link the user opened by name in place, while project requests stay refused', async () => { + const store = makeStore({ repoPaths: [real] }) + const link = join(real, 'escape.txt') + + expect(await settles(resolveLocalFileRequestPath(link, undefined, store))).toBe('denied') + const named = await resolveLocalFileRequestPath(link, USER_FILE, store) + await expect(readLocalFileContent(named)).resolves.toEqual({ + content: 'secret\n', + isBinary: false + }) + }) }) describe('document-resource requests', () => { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts index c5a2af368d59..efe5f4767449 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from 'vitest' + +const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: toastError } })) import { useAppStore } from '@/store' import type { TreeNode } from './file-explorer-types' import { activateFileExplorerNode } from './useFileExplorerHandlers' @@ -72,6 +75,59 @@ describe('activateFileExplorerNode', () => { expect(openFile).not.toHaveBeenCalled() }) + it('does not follow a folder link that leads out of the project', async () => { + const loadDir = vi.fn() + const openFile = vi.fn() + + await activateFileExplorerNode({ + node: { ...symlinkNode, operationOwner: { kind: 'local' } }, + activeWorktreeId: 'wt-1', + openFile, + toggleDir: vi.fn(), + loadDir, + statPath: vi.fn().mockResolvedValue({ isDirectory: true, escapesWorktree: true }), + markPathAsDirectory: vi.fn(), + setSelectedPath: vi.fn() + }) + + expect(loadDir).not.toHaveBeenCalled() + expect(openFile).not.toHaveBeenCalled() + expect(toastError).toHaveBeenCalledWith( + "This folder links outside the project, so it can't be opened here." + ) + }) + + it('opens a file link that leads out of the project by its absolute path', 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, escapesWorktree: true }), + markPathAsDirectory: vi.fn(), + setSelectedPath: vi.fn() + }) + + // The absolute relativePath marks the tab as user-named, which survives a restart. + expect(openFile).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/repo/linked-docs', + relativePath: '/repo/linked-docs', + worktreeId: 'wt-1' + }), + expect.anything() + ) + }) + it('opens a symlink as a file when target stat fails', async () => { const openFile = vi.fn() useAppStore.setState({ diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts index cd7390f25b9e..3464763cf556 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts @@ -38,7 +38,7 @@ type UseFileExplorerHandlersParams = { depth: number, options?: { force?: boolean; failOnError?: boolean } ) => Promise - statPath: (path: string) => Promise<{ isDirectory: boolean }> + statPath: (path: string) => Promise<{ isDirectory: boolean; escapesWorktree?: boolean }> markPathAsDirectory: (path: string) => void setSelectedPath: (path: string) => void scrollRef: RefObject @@ -87,17 +87,29 @@ export async function activateFileExplorerNode(args: { toggleDir(activeWorktreeId, node.path) return } + let escapesWorktree = false if (node.isSymlink) { // Why: symlink targets may live in macOS TCC-protected app data. Resolve // them only after the user explicitly activates the row. - let targetIsDirectory = false + let target: { isDirectory: boolean; escapesWorktree?: boolean } | null = null try { - targetIsDirectory = (await statPath(node.path)).isDirectory + target = await statPath(node.path) } catch { // 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) { + escapesWorktree = target?.escapesWorktree === true + if (target?.isDirectory && escapesWorktree) { + // Why: project listings stay inside the project, so a folder link out of it isn't followed. + toast.error( + translate( + 'auto.components.right.sidebar.useFileExplorerHandlers.folderLinksOutsideProject', + "This folder links outside the project, so it can't be opened here." + ) + ) + return + } + if (target?.isDirectory) { const loadedAsDirectory = await loadDir(node.path, node.depth, { force: true, failOnError: true @@ -129,7 +141,9 @@ export async function activateFileExplorerNode(args: { openFile( { filePath: node.path, - relativePath: node.relativePath, + // Why: a file link out of the project opens by its absolute path, as a file the user named, + // so it reads the same before and after a restart instead of being refused as a project file. + relativePath: escapesWorktree ? node.path : node.relativePath, worktreeId: activeWorktreeId, runtimeEnvironmentId: fileRuntimeEnvironmentId ?? undefined, language: detectLanguage(node.name), diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index ee186eac9656..fe1cea422a66 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -2,7 +2,6 @@ import type { Dispatch, SetStateAction } from 'react' import { useCallback, useRef, useState } from 'react' import type { DirCache, FileExplorerTreeRefreshOutcome } from './file-explorer-types' import { splitPathSegments } from './path-tree' -import { statRuntimePath } from '@/runtime/runtime-file-client' import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' import { getFileExplorerOperationOwner, @@ -22,6 +21,7 @@ import { markFileExplorerDirsLoading, withPendingFileExplorerDirCacheEntries } from './file-explorer-dir-load-state' +import { statUserOpenedPath, type UserOpenedPathStat } from '@/lib/user-opened-local-path' type UseFileExplorerTreeResult = { dirCache: Record @@ -37,7 +37,7 @@ type UseFileExplorerTreeResult = { depth: number, options?: { force?: boolean; failOnError?: boolean } ) => Promise - statPath: (path: string) => Promise<{ isDirectory: boolean }> + statPath: (path: string) => Promise markPathAsDirectory: (path: string) => void refreshTree: () => Promise refreshDir: (dirPath: string) => Promise @@ -182,7 +182,7 @@ export function useFileExplorerTree( if (!route) { throw new Error(getFileExplorerOwnerUnresolvedMessage()) } - return statRuntimePath( + return statUserOpenedPath( { settings: route.settings, worktreeId: activeWorktreeId, diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index 69a5762ba5dd..9680392f5eeb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -10,7 +10,6 @@ import { } from '@/lib/workspace-file-host-routing' import { isMissingRuntimePathError, - statRuntimePath, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' @@ -23,7 +22,7 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { userNamedFileAccess } from '@/lib/local-file-access' +import { statUserOpenedPath } from '@/lib/user-opened-local-path' export type FileOpenFailure = { /** `missing` is a verified absence; `unverifiable` means the host could not answer (dropped SSH, timeout, denied path). */ @@ -174,7 +173,7 @@ export function openDetectedFilePath( } try { - statResult = await statRuntimePath(fileContext, mappedFilePath, userNamedFileAccess()) + statResult = await statUserOpenedPath(fileContext, mappedFilePath) } catch (error) { if (requestId === latestOpenDetectedFilePathRequestId && deps.onOpenFailure) { // Why: loss of contact with the host is not evidence the file is gone. @@ -236,7 +235,9 @@ export function openDetectedFilePath( let relativePath = mappedFilePath if (worktreePath && isPathInsideWorktree(mappedFilePath, worktreePath)) { const maybeRelative = toWorktreeRelativePath(mappedFilePath, worktreePath) - if (maybeRelative !== null && maybeRelative.length > 0) { + // Why: a link out of the project keeps its absolute path, so the tab reads it as the file + // the user named, before and after a restart, instead of being refused as a project file. + if (maybeRelative !== null && maybeRelative.length > 0 && !statResult.escapesWorktree) { relativePath = maybeRelative } } else if ( diff --git a/src/renderer/src/components/terminal-pane/terminal-link-escaping-file-open.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-escaping-file-open.test.ts new file mode 100644 index 000000000000..6c5815564fb8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-escaping-file-open.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { openDetectedFilePath } from './terminal-link-handlers' +import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures' +import { + flushAsyncWork, + installTerminalLinkTestEnvironment, + setPlatform +} from './terminal-link-handlers-test-harness' + +const doubles = createTerminalLinkTestDoubles() +const { storeState, deps, openFileMock, statMock } = doubles + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => storeState + } +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: vi.fn(), + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(() => null) +})) + +installTerminalLinkTestEnvironment(doubles) + +/** Main's answer for a project link to outside: refused inside the root, readable as user-named. */ +function linkLeadsOutOfProject(): void { + statMock.mockImplementation(async ({ access }: { access?: { kind: string } }) => { + if (access?.kind !== 'user-file') { + throw new Error('Access denied: path resolves outside allowed directories') + } + return { size: 1, isDirectory: false, mtime: 1 } + }) +} + +describe('Cmd-click on a project file that links out of the project', () => { + it('opens it by its absolute path so it reads the same after a restart', async () => { + setPlatform('Macintosh') + linkLeadsOutOfProject() + + openDetectedFilePath('/tmp/docs/link.md', null, null, deps) + await flushAsyncWork() + + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/tmp/docs/link.md', + relativePath: '/tmp/docs/link.md', + worktreeId: 'wt-1' + }), + expect.anything() + ) + }) + + it('keeps an ordinary project file as a project tab', async () => { + setPlatform('Macintosh') + statMock.mockReset().mockResolvedValue({ size: 1, isDirectory: false, mtime: 1 }) + + openDetectedFilePath('/tmp/docs/plain.md', null, null, deps) + await flushAsyncWork() + + expect(statMock).toHaveBeenCalledExactlyOnceWith({ + filePath: '/tmp/docs/plain.md', + connectionId: undefined + }) + expect(openFileMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/tmp/docs/plain.md', relativePath: 'docs/plain.md' }), + expect.anything() + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts index dac1a35aaaa6..6f91f7286508 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-missing-file-open.test.ts @@ -34,7 +34,8 @@ describe('openDetectedFilePath on a path it cannot verify', () => { it('reports a verified miss', async () => { setPlatform('Macintosh') const error = new Error("Error invoking remote method 'fs:stat': Error: ENOENT: no such file") - statMock.mockRejectedValueOnce(error) + // A missing file fails both the project check and the user-named one. + statMock.mockRejectedValue(error) const onOpenFailure = vi.fn() openDetectedFilePath('/tmp/src/gone.md', null, null, { ...deps, onOpenFailure }) @@ -48,7 +49,7 @@ describe('openDetectedFilePath on a path it cannot verify', () => { it('reports a host that could not answer as unverifiable, not missing', async () => { setPlatform('Macintosh') const error = new Error('SSH connection closed') - statMock.mockRejectedValueOnce(error) + statMock.mockRejectedValue(error) const onOpenFailure = vi.fn() openDetectedFilePath('/tmp/src/present.md', null, null, { ...deps, onOpenFailure }) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts index 6270da6ada28..2c59fd08101b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-osc-file-targets.test.ts @@ -58,9 +58,7 @@ describe('handleOscLink', () => { // before asserting on positive behavior. await new Promise((resolve) => setTimeout(resolve, 0)) - expect(statMock).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '/tmp/test.txt', access: { kind: 'user-file' } }) - ) + expect(statMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/tmp/test.txt' })) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/test.txt' }), { forceContentReload: true } @@ -84,7 +82,7 @@ describe('handleOscLink', () => { await flushDoubleRaf() expect(statMock).toHaveBeenCalledWith( - expect.objectContaining({ filePath: 'C:/repo/src/index.ts', access: { kind: 'user-file' } }) + expect.objectContaining({ filePath: 'C:/repo/src/index.ts' }) ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: 'C:/repo/src/index.ts' }), @@ -114,10 +112,7 @@ describe('handleOscLink', () => { await flushAsyncWork() expect(statMock).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: '//server/share/repo/test.txt', - access: { kind: 'user-file' } - }) + expect.objectContaining({ filePath: '//server/share/repo/test.txt' }) ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '//server/share/repo/test.txt' }), @@ -148,9 +143,7 @@ describe('handleOscLink', () => { await flushAsyncWork() await flushDoubleRaf() - expect(statMock).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '/tmp/test.txt', access: { kind: 'user-file' } }) - ) + expect(statMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/tmp/test.txt' })) expect(openFilePathMock).not.toHaveBeenCalled() expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/test.txt' }), @@ -176,9 +169,7 @@ describe('handleOscLink', () => { ) await flushAsyncWork() - expect(statMock).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '/tmp/test.txt', access: { kind: 'user-file' } }) - ) + expect(statMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/tmp/test.txt' })) expect(openFilePathMock).toHaveBeenCalledWith('/tmp/test.txt') expect(openFileMock).not.toHaveBeenCalled() expect(setPendingEditorRevealMock).not.toHaveBeenCalled() @@ -196,9 +187,7 @@ describe('handleOscLink', () => { await flushAsyncWork() await flushDoubleRaf() - expect(statMock).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '/tmp/test.txt', access: { kind: 'user-file' } }) - ) + expect(statMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/tmp/test.txt' })) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/test.txt' }), { forceContentReload: true } @@ -228,10 +217,7 @@ describe('handleOscLink', () => { await flushDoubleRaf() expect(statMock).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: '//server/Share/Repo/src/app.ts', - access: { kind: 'user-file' } - }) + expect.objectContaining({ filePath: '//server/Share/Repo/src/app.ts' }) ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -265,10 +251,7 @@ describe('handleOscLink', () => { await new Promise((resolve) => setTimeout(resolve, 0)) expect(statMock).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: '/tmp/project/docs/README.md', - access: { kind: 'user-file' } - }) + expect.objectContaining({ filePath: '/tmp/project/docs/README.md' }) ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -298,8 +281,7 @@ describe('handleOscLink', () => { expect(statMock).toHaveBeenCalledWith( expect.objectContaining({ - filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md', - access: { kind: 'user-file' } + filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md' }) ) expect(openFileMock).toHaveBeenCalledWith( @@ -334,8 +316,7 @@ describe('handleOscLink', () => { expect(statMock).toHaveBeenCalledWith( expect.objectContaining({ - filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md', - access: { kind: 'user-file' } + filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md' }) ) expect(openFileMock).toHaveBeenCalledWith( @@ -369,7 +350,7 @@ describe('handleOscLink', () => { await new Promise((resolve) => setTimeout(resolve, 0)) expect(statMock).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '/home/alice/file.ts', access: { kind: 'user-file' } }) + expect.objectContaining({ filePath: '/home/alice/file.ts' }) ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts index 27eb4583b24f..66ece84e48cc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-worktree-root-activation.test.ts @@ -88,7 +88,7 @@ describe('handleOscLink', () => { await flushAsyncWork() expect(statMock).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '/tmp/other-worktree', access: { kind: 'user-file' } }) + expect.objectContaining({ filePath: '/tmp/other-worktree' }) ) expect(statMock).toHaveBeenCalled() expect(openFilePathMock).toHaveBeenCalledWith('/tmp/other-worktree') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 48f6eec3cb18..1b1e8c5d9d18 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13394,7 +13394,8 @@ "fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}." }, "useFileExplorerHandlers": { - "32cd9fd991": "Cannot open symlink target" + "32cd9fd991": "Cannot open symlink target", + "folderLinksOutsideProject": "This folder links outside the project, so it can't be opened here." }, "useFileExplorerImport": { "25919b2050": "Skipped {{value0}} {{value1}}.", @@ -13662,7 +13663,6 @@ }, "aiVaultSessionLogOpen": { "workspaceGone": "Couldn't open log — workspace is no longer available.", - "notAuthorized": "Couldn't open log — path not authorized.", "alreadyEditable": "Log is already open for editing." }, "github": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 84dffe050fdf..6ca2bcbd2a18 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11526,7 +11526,8 @@ "fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}." }, "useFileExplorerHandlers": { - "32cd9fd991": "No se puede abrir el destino del enlace simbólico" + "32cd9fd991": "No se puede abrir el destino del enlace simbólico", + "folderLinksOutsideProject": "Esta carpeta enlaza fuera del proyecto, así que no se puede abrir aquí." }, "useFileExplorerImport": { "25919b2050": "Se omitió {{value0}} {{value1}}.", @@ -11779,7 +11780,6 @@ }, "aiVaultSessionLogOpen": { "workspaceGone": "Couldn't open log — workspace is no longer available.", - "notAuthorized": "Couldn't open log — path not authorized.", "alreadyEditable": "Log is already open for editing." }, "github": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d8086183f219..ef04bd75e384 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -13301,7 +13301,8 @@ "fca915a67a": "Les éléments distants sont définitivement supprimés, sans retour arrière. Les éléments locaux sont déplacés dans {{value0}}." }, "useFileExplorerHandlers": { - "32cd9fd991": "Impossible d'ouvrir la cible du symlink" + "32cd9fd991": "Impossible d'ouvrir la cible du symlink", + "folderLinksOutsideProject": "Ce dossier pointe en dehors du projet ; il ne peut pas être ouvert ici." }, "useFileExplorerImport": { "25919b2050": "Ignoré : {{value0}} {{value1}}.", @@ -13569,7 +13570,6 @@ }, "aiVaultSessionLogOpen": { "workspaceGone": "Impossible d'ouvrir le log — l'espace de travail n'est plus disponible.", - "notAuthorized": "Impossible d'ouvrir le log — chemin non autorisé.", "alreadyEditable": "Le log est déjà ouvert en édition." }, "github": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0997b8b616bb..75446d9d195d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13198,7 +13198,8 @@ "fca915a67a": "リモートの項目は完全に削除され、取り消せません。ローカルの項目は {{value0}} に移動します。" }, "useFileExplorerHandlers": { - "32cd9fd991": "シンボリックリンクターゲットを開けません" + "32cd9fd991": "シンボリックリンクターゲットを開けません", + "folderLinksOutsideProject": "このフォルダーはプロジェクト外へのリンクのため、ここでは開けません。" }, "useFileExplorerImport": { "25919b2050": "{{value0}} {{value1}} をスキップしました。", @@ -13466,7 +13467,6 @@ }, "aiVaultSessionLogOpen": { "workspaceGone": "ログを開けませんでした。ワークスペースが利用できなくなっています。", - "notAuthorized": "ログを開けませんでした。このパスは許可されていません。", "alreadyEditable": "ログはすでに編集用に開かれています。" }, "github": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 29149cd5a652..64fd47bb3259 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13198,7 +13198,8 @@ "fca915a67a": "원격 항목은 영구적으로 삭제되며 되돌릴 수 없습니다. 로컬 항목은 {{value0}}(으)로 이동합니다." }, "useFileExplorerHandlers": { - "32cd9fd991": "심볼릭 링크 대상을 열 수 없습니다" + "32cd9fd991": "심볼릭 링크 대상을 열 수 없습니다", + "folderLinksOutsideProject": "이 폴더는 프로젝트 외부로 연결되어 있어 여기에서 열 수 없습니다." }, "useFileExplorerImport": { "25919b2050": "{{value0}} {{value1}}을(를) 건너뛰었습니다.", @@ -13466,7 +13467,6 @@ }, "aiVaultSessionLogOpen": { "workspaceGone": "로그를 열 수 없습니다 — 워크스페이스를 더 이상 사용할 수 없습니다.", - "notAuthorized": "로그를 열 수 없습니다 — 경로가 승인되지 않았습니다.", "alreadyEditable": "로그가 이미 편집용으로 열려 있습니다." }, "github": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d907237dea88..bf54817c9f5b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13198,7 +13198,8 @@ "fca915a67a": "远程项目将永久删除且无法撤销。本地项目将移至 {{value0}}。" }, "useFileExplorerHandlers": { - "32cd9fd991": "无法打开符号链接目标" + "32cd9fd991": "无法打开符号链接目标", + "folderLinksOutsideProject": "此文件夹链接到项目之外,无法在此打开。" }, "useFileExplorerImport": { "25919b2050": "跳过 {{value0}} {{value1}}。", @@ -13466,7 +13467,6 @@ }, "aiVaultSessionLogOpen": { "workspaceGone": "无法打开日志 — 工作区已不可用。", - "notAuthorized": "无法打开日志 — 路径未授权。", "alreadyEditable": "日志已处于编辑状态。" }, "github": { diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index 640b5e055370..5d0f8fe7ac0c 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -75,6 +75,15 @@ describe('editorTabFileAccess', () => { { filePath: '/tmp/audit.md', relativePath: '/tmp/audit.md', worktreeId: localWorktreeId }, 'user-file' ], + [ + 'a project link opened by its absolute path because it leads out of the project', + { + filePath: '/Users/me/project/docs/link.md', + relativePath: '/Users/me/project/docs/link.md', + worktreeId: localWorktreeId + }, + 'user-file' + ], [ 'an AI Vault log tab in an SSH workspace', { @@ -149,10 +158,10 @@ const USER_NAMED_ACCESS_IMPORTERS = [ 'components/native-chat/use-native-chat-external-attachments.ts', 'components/sidebar/useSidebarProjectDrop.ts', 'components/tab-bar/tab-create-entry-absolute-file.ts', - 'components/terminal-pane/terminal-file-open-routing.ts', 'hooks/composer-state/attachment-drop-state.ts', 'hooks/useGlobalFileDrop.ts', - 'lib/local-file-access.ts' + 'lib/local-file-access.ts', + 'lib/user-opened-local-path.ts' ] function collectSourceFiles(dir: string): string[] { diff --git a/src/renderer/src/lib/user-opened-local-path.test.ts b/src/renderer/src/lib/user-opened-local-path.test.ts new file mode 100644 index 000000000000..1219752d7f04 --- /dev/null +++ b/src/renderer/src/lib/user-opened-local-path.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' + +const mocks = vi.hoisted(() => ({ statRuntimePath: vi.fn() })) + +vi.mock('@/runtime/runtime-file-client', () => ({ + isRemoteRuntimeFileOperation: () => false, + statRuntimePath: mocks.statRuntimePath +})) + +import { statUserOpenedPath } from './user-opened-local-path' + +const project: RuntimeFileOperationArgs = { + settings: null, + worktreeId: 'repo::/repo', + worktreePath: '/repo' +} + +/** Main's answers: a contained (no-shape) stat only sees paths that stay in the project. */ +function answer(containedOk: boolean, userNamedOk: boolean, isDirectory = false): void { + mocks.statRuntimePath.mockImplementation( + async (_context: unknown, _path: string, access?: { kind: string }) => { + if (access ? !userNamedOk : !containedOk) { + throw new Error(access ? 'ENOENT: no such file' : 'Access denied') + } + return { size: 1, isDirectory, mtime: 1 } + } + ) +} + +describe('statUserOpenedPath', () => { + beforeEach(() => { + mocks.statRuntimePath.mockReset() + }) + + it('keeps a project file that stays in the project contained', async () => { + answer(true, true) + + await expect(statUserOpenedPath(project, '/repo/src/a.ts')).resolves.toEqual({ + isDirectory: false, + escapesWorktree: false + }) + expect(mocks.statRuntimePath).toHaveBeenCalledTimes(1) + expect(mocks.statRuntimePath).toHaveBeenCalledWith(project, '/repo/src/a.ts') + }) + + it.each([false, true])( + 'reports a project link that leads out of the project (directory: %s)', + async (isDirectory) => { + answer(false, true, isDirectory) + + await expect(statUserOpenedPath(project, '/repo/link')).resolves.toEqual({ + isDirectory, + escapesWorktree: true + }) + expect(mocks.statRuntimePath).toHaveBeenLastCalledWith(project, '/repo/link', { + kind: 'user-file' + }) + } + ) + + it('reports the project error when the path is missing everywhere', async () => { + answer(false, false) + + await expect(statUserOpenedPath(project, '/repo/gone.md')).rejects.toThrow('Access denied') + }) + + it.each([ + ['outside the project', project, '/tmp/notes.md'], + [ + 'in the floating workspace, whose folder is not a project', + { ...project, worktreeId: FLOATING_TERMINAL_WORKTREE_ID, worktreePath: '/Users/me' }, + '/Users/me/notes.md' + ] + ])('checks a path %s once, as user-named', async (_label, context, filePath) => { + answer(false, true) + + await expect(statUserOpenedPath(context, filePath)).resolves.toEqual({ + isDirectory: false, + escapesWorktree: false + }) + expect(mocks.statRuntimePath).toHaveBeenCalledExactlyOnceWith(context, filePath, { + kind: 'user-file' + }) + }) +}) diff --git a/src/renderer/src/lib/user-opened-local-path.ts b/src/renderer/src/lib/user-opened-local-path.ts new file mode 100644 index 000000000000..41840ed75977 --- /dev/null +++ b/src/renderer/src/lib/user-opened-local-path.ts @@ -0,0 +1,47 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + isRemoteRuntimeFileOperation, + statRuntimePath, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' +import { userNamedFileAccess } from './local-file-access' +import { isPathInsideWorktree } from './terminal-links' + +export type UserOpenedPathStat = { + isDirectory: boolean + /** The path sits in the project but resolves outside it, e.g. through a symlink. */ + escapesWorktree: boolean +} + +/** + * Stats a path the user opened by a gesture. A local path inside the worktree is checked against + * the project root first; if only the user-named check passes, the path leads out of the project, + * and the caller must open it as an external file rather than a project one. + */ +export async function statUserOpenedPath( + context: RuntimeFileOperationArgs, + filePath: string +): Promise { + // Why the floating workspace is skipped: its folder (~ by default) is not a project root. + const insideLocalWorktree = + context.worktreeId !== FLOATING_TERMINAL_WORKTREE_ID && + !context.connectionId && + !isRemoteRuntimeFileOperation(context, filePath) && + Boolean(context.worktreePath && isPathInsideWorktree(filePath, context.worktreePath)) + if (!insideLocalWorktree) { + const stat = await statRuntimePath(context, filePath, userNamedFileAccess()) + return { isDirectory: stat.isDirectory, escapesWorktree: false } + } + try { + const stat = await statRuntimePath(context, filePath) + return { isDirectory: stat.isDirectory, escapesWorktree: false } + } catch (containedError) { + let stat: Awaited> + try { + stat = await statRuntimePath(context, filePath, userNamedFileAccess()) + } catch { + throw containedError + } + return { isDirectory: stat.isDirectory, escapesWorktree: true } + } +} From f8b42ff2e2d3d8cb49dd1274f5464d0298fa00cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:16:36 -0700 Subject: [PATCH 10/19] chore: drop the casts the changed-code quality gate flags on this branch The FileContent casts in the editor loader and the paired-phone markdown bridge were never needed (the read result is already assignable). Tests stub window.api through vi.stubGlobal and pass narrow stores without casting; the one test store that still needs a cast states why. --- ...esystem-request-shape-network-path.test.ts | 5 ++++- .../ipc/floating-workspace-directory.test.ts | 6 +++--- src/main/ipc/floating-workspace-directory.ts | 2 +- .../editor-restored-tab-conflict-scan.test.ts | 21 +++++++++---------- .../editor/editor-save-user-named-tab.test.ts | 2 +- .../editor/useEditorPanelFileContentLoader.ts | 4 ++-- ...ditorPanelFloatingTabContentState.test.tsx | 2 +- ...torPanelRemoteSiblingContentState.test.tsx | 2 +- ...-native-chat-external-attachments.test.tsx | 4 +--- .../src/runtime/mobile-markdown-bridge.ts | 4 ++-- 10 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/main/ipc/filesystem-request-shape-network-path.test.ts b/src/main/ipc/filesystem-request-shape-network-path.test.ts index 2746c2651e7d..0d488f85305e 100644 --- a/src/main/ipc/filesystem-request-shape-network-path.test.ts +++ b/src/main/ipc/filesystem-request-shape-network-path.test.ts @@ -10,7 +10,10 @@ vi.mock('node:path', async () => { return { ...actual.win32, default: actual.win32 } }) -const { fsCalls } = vi.hoisted(() => ({ fsCalls: [] as string[] })) +const { fsCalls } = vi.hoisted(() => { + const calls: string[] = [] + return { fsCalls: calls } +}) vi.mock('node:fs/promises', () => { const record = (name: string) => diff --git a/src/main/ipc/floating-workspace-directory.test.ts b/src/main/ipc/floating-workspace-directory.test.ts index 010a0ce40bf9..55438efe5878 100644 --- a/src/main/ipc/floating-workspace-directory.test.ts +++ b/src/main/ipc/floating-workspace-directory.test.ts @@ -90,7 +90,7 @@ describe('floating workspace directory', () => { await mkdir(selectedDir) const canonicalSelectedDir = await realpath(selectedDir) - await trustFloatingWorkspaceDirectory(store as never, selectedDir) + await trustFloatingWorkspaceDirectory(store, selectedDir) expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalSelectedDir]) @@ -112,7 +112,7 @@ describe('floating workspace directory', () => { await symlinkDirectory(originalTarget, selectedLink) const canonicalOriginalTarget = await realpath(originalTarget) - await trustFloatingWorkspaceDirectory(store as never, selectedLink) + await trustFloatingWorkspaceDirectory(store, selectedLink) expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalOriginalTarget]) @@ -138,7 +138,7 @@ describe('floating workspace directory', () => { floatingTerminalTrustedCwds: [missingTrustedDir] }) - await trustFloatingWorkspaceDirectory(store as never, selectedDir) + await trustFloatingWorkspaceDirectory(store, selectedDir) expect(store.settings.floatingTerminalTrustedCwds).toEqual([ missingTrustedDir, diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index 134d4c73d50e..7c7a70cdd348 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -104,7 +104,7 @@ export async function resolveFloatingTerminalCwd( } export async function trustFloatingWorkspaceDirectory( - store: Store, + store: Pick, dirPath: string ): Promise { const resolvedDir = resolveFloatingWorkspaceInput(dirPath) diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 231fcd358daf..1e6bbe22779d 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -33,12 +33,15 @@ function createEditorStore(): StoreApi { function openRestoredDirtyTab( store: StoreApi, filePath: string, - baselineContent: string + baselineContent: string, + owner: { relativePath: string; worktreeId: string } = { + relativePath: filePath.slice(1), + worktreeId: 'wt-1' + } ): void { store.getState().openFile({ filePath, - relativePath: filePath.slice(1), - worktreeId: 'wt-1', + ...owner, language: 'typescript', mode: 'edit' }) @@ -260,14 +263,10 @@ describe('attachRestoredTabConflictScan', () => { mocks.pathExists.mockResolvedValue(true) mocks.getConnectionIdForFile.mockReturnValue(null) const store = createEditorStore() - openRestoredDirtyTab(store, '/Users/me/notes.txt', 'original baseline') - store.setState({ - openFiles: store.getState().openFiles.map((file) => ({ - ...file, - relativePath: 'notes.txt', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID - })) - } as never) + openRestoredDirtyTab(store, '/Users/me/notes.txt', 'original baseline', { + relativePath: 'notes.txt', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }) const detach = attachRestoredTabConflictScan(store) try { diff --git a/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts b/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts index b6efaa834661..5e1d8c1cfd02 100644 --- a/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts +++ b/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts @@ -10,7 +10,7 @@ import { __clearSelfWriteRegistryForTests } from './editor-self-write-registry' vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null })) function createEditorStore(): StoreApi { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-explicit-any -- SAFETY: the editor save path reads only the slice and fields built here. return createStore()((...args: any[]) => ({ settings: { editorAutoSave: false }, repos: [], diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index 863d90220898..c5406554b153 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -179,7 +179,7 @@ export function useEditorPanelFileContentLoader({ } let pending = inFlightFileReads.get(key) if (!pending) { - const promise = readRuntimeFileContent({ + const promise: Promise = readRuntimeFileContent({ settings: readSettings, filePath, relativePath: readRelativePath, @@ -188,7 +188,7 @@ export function useEditorPanelFileContentLoader({ expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId, includeLocalLogMetadata: isLiveTailLogTab, access - }) as Promise + }) pending = { externalEventGeneration: options?.externalEventGeneration, promise } inFlightFileReads.set(key, pending) queueMicrotask(() => { diff --git a/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx index 10864beb5a9d..457e437748d8 100644 --- a/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx @@ -89,7 +89,7 @@ describe('restored client-local editor tabs', () => { beforeEach(() => { latestFileContents = {} // Why an empty fs API: restoring must read with nothing re-granted or prepared first. - ;(window as unknown as { api: unknown }).api = { fs: {} } + vi.stubGlobal('api', { fs: {} }) mocks.readRuntimeFileContent.mockReset() mocks.readRuntimeFileContent.mockResolvedValue({ content: '# local', isBinary: false }) mocks.findWorkspaceFileRoute.mockReset() diff --git a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx index 43fedf1fa68b..2a35b1308f98 100644 --- a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx @@ -95,7 +95,7 @@ describe('remote sibling editor content routing', () => { beforeEach(() => { latestFileContents = {} - ;(window as unknown as { api: unknown }).api = { fs: {} } + vi.stubGlobal('api', { fs: {} }) mocks.readRuntimeFileContent.mockReset() mocks.findWorkspaceFileRoute.mockReset() mocks.findWorkspaceFileRoute.mockReturnValue(null) diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 38ad346d5c39..3c63879d198e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -116,9 +116,7 @@ async function renderProbe(args: { beforeEach(() => { mocks.stat.mockReset().mockResolvedValue(undefined) mocks.resolveNativeChatAttachmentOwnerForWorktree.mockReset().mockReturnValue({ kind: 'local' }) - window.api = { - fs: { stat: mocks.stat } - } as unknown as Window['api'] + vi.stubGlobal('api', { fs: { stat: mocks.stat } }) }) afterEach(() => { diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index befacc31ac55..ef26415f9b10 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -244,7 +244,7 @@ async function readCurrentContent( async function readFileContent(file: OpenFile): Promise { const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined const state = useAppStore.getState() - const result = (await readRuntimeFileContent({ + const result: FileContent = await readRuntimeFileContent({ settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), filePath: file.filePath, relativePath: file.relativePath, @@ -252,7 +252,7 @@ async function readFileContent(file: OpenFile): Promise { connectionId, expectedExternalSshTargetId: file.externalSshTargetId, access: editorTabFileAccess(state, file) - })) as FileContent + }) if (result.isBinary) { throw new Error('binary_file') } From bd9a0439312264a2c928d623c2e72485c8f04bca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:48:07 -0700 Subject: [PATCH 11/19] fix(fs): load chat images by type, and keep escaping project links readable Problems found in review: - Chat transcript images were trusted by message role: any user-role "[Image: source: ]" (an injected Claude record, `orca terminal send`, a paired client's image-ref) became an automatic user-named read as the row scrolled into view, of any file type, and on Windows a network-share path would have opened an SMB connection to that host. - A document image named like an image but linking to a text file (logo.png -> .env) was read as text. - Windows device names (NUL.png, COM1.jpg) passed the path-text check of the automatic image loads. - A project symlink leading out of the project, opened by a typed path, a tab-strip drop or a browser file:// notebook, was stored as a project tab and immediately refused. Fix: - New chat-image request shape for every transcript image and the composer preview, whoever's turn named it: an absolute local path whose requested and real targets are image files, a regular file, size-capped; network-share and device-namespace paths and Windows device names are refused by path text before any filesystem call. Pasted screenshots still show after a restart, and agent images outside the project now render. - Document resources check the real target's type too, and refuse Windows device names by path text. - Typed paths, tab-strip drops and browser notebooks stat through the same check as the explorer and terminal, and open an escaping link by its absolute path. - Tests pin the shape at the change banner, compare dialog, markdown preview and image prewarm; a second ratchet lists every file that can open a tab the tab rule reads as user-named, and its comment says what it can't see. - Stale grant wording removed. --- src/main/ipc/automatic-load-path-text.ts | 40 +++++++ ...esystem-request-shape-network-path.test.ts | 67 +++++++++++ src/main/ipc/filesystem-request-shape.test.ts | 73 ++++++++++++ src/main/ipc/filesystem-request-shape.ts | 73 ++++++++++-- ...avigate-browser-page-notebook-link.test.ts | 75 ++++++++++++ .../navigate/navigate-browser-page-url.ts | 13 +- .../editor/ExternalFileChangeBanner.test.tsx | 21 +++- .../ExternalFileChangeCompareDialog.test.tsx | 26 ++++ .../MarkdownPreview.image-access.test.tsx | 111 ++++++++++++++++++ .../components/editor/MarkdownPreviewBody.tsx | 2 +- .../markdown-preview-local-images.test.ts | 20 +++- .../editor/rich-markdown-extensions.ts | 6 +- .../NativeChatImageAttachmentPreview.test.tsx | 2 +- .../NativeChatImageAttachmentPreview.tsx | 5 +- .../native-chat/NativeChatMessageRow.tsx | 2 - .../NativeChatTranscriptChrome.test.tsx | 18 +-- .../NativeChatTranscriptChrome.tsx | 26 ++-- .../native-chat-composer-drop-scope.test.tsx | 4 +- .../settings/KeybindingsFileActions.tsx | 4 +- .../tab-bar/tab-create-entry-absolute-file.ts | 15 ++- .../tab-bar/tab-create-entry-action.test.ts | 44 +++++-- .../tab-bar/tab-create-entry-action.ts | 3 + .../useGlobalFileDrop.escaping-link.test.tsx | 87 ++++++++++++++ src/renderer/src/hooks/useGlobalFileDrop.ts | 13 +- .../src/lib/local-file-access.test.ts | 86 +++++++++++--- src/renderer/src/lib/local-file-access.ts | 9 ++ src/shared/local-file-access.ts | 2 + 27 files changed, 757 insertions(+), 90 deletions(-) create mode 100644 src/main/ipc/automatic-load-path-text.ts create mode 100644 src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts create mode 100644 src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx create mode 100644 src/renderer/src/hooks/useGlobalFileDrop.escaping-link.test.tsx diff --git a/src/main/ipc/automatic-load-path-text.ts b/src/main/ipc/automatic-load-path-text.ts new file mode 100644 index 000000000000..b48fd0679af3 --- /dev/null +++ b/src/main/ipc/automatic-load-path-text.ts @@ -0,0 +1,40 @@ +import { basename, sep } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' + +// Why the path flavour, not the OS: these are Windows path rules, and tests exercise them with +// path.win32 on any host. +function usesWindowsPaths(): boolean { + return sep === '\\' +} + +const WINDOWS_RESERVED_DEVICE_STEM = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i + +/** `NUL.png`, `com1 .jpg`, `Aux.` name a Windows device, not a file, whatever the extension. */ +export function isWindowsReservedDeviceName(filePath: string): boolean { + if (!usesWindowsPaths()) { + return false + } + const stem = + basename(filePath) + .replace(/[. ]+$/, '') + .split('.')[0] ?? '' + return WINDOWS_RESERVED_DEVICE_STEM.test(stem.replace(/ +$/, '')) +} + +/** + * A network share (`\\host\share`) or device namespace (`\\?\`, `\\.\`) path. WSL paths are UNC in + * form but stay on this machine, so they are not network paths. + */ +export function isNetworkOrDeviceNamespacePath(filePath: string): boolean { + if (!usesWindowsPaths()) { + return false + } + const normalized = filePath.replace(/\//g, '\\') + if (!normalized.startsWith('\\\\')) { + return false + } + if (/^\\\\[?.]\\/.test(normalized)) { + return true + } + return parseWslUncPath(normalized) === null +} diff --git a/src/main/ipc/filesystem-request-shape-network-path.test.ts b/src/main/ipc/filesystem-request-shape-network-path.test.ts index 0d488f85305e..fcc371daca78 100644 --- a/src/main/ipc/filesystem-request-shape-network-path.test.ts +++ b/src/main/ipc/filesystem-request-shape-network-path.test.ts @@ -95,3 +95,70 @@ describe('document images on a network share', () => { expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) }) }) + +const CHAT_IMAGE = { kind: 'chat-image' } as const + +describe('chat transcript images on Windows', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + it.each([...networkTargets, '\\\\.\\C:\\x.png', '//?/C:/x.png'])( + 'refuses %s without touching it', + async (target) => { + await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).rejects.toThrow( + 'Access denied' + ) + expect(fsCalls).toEqual([]) + } + ) + + it.each([ + 'C:\\Users\\me\\Pictures\\shot.png', + '\\\\wsl.localhost\\Ubuntu\\home\\me\\shot.png', + '\\\\wsl$\\Ubuntu\\tmp\\agent.webp' + ])('reads the local image %s in place', async (target) => { + await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).resolves.toBe(target) + }) +}) + +describe('Windows reserved device names in automatic image loads', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + const deviceTargets = [ + 'C:\\Users\\me\\notes\\NUL.png', + 'C:\\Users\\me\\notes\\com1.jpg', + 'C:\\Users\\me\\notes\\Lpt9 .gif', + 'C:\\Users\\me\\notes\\aux..png', + 'C:\\Users\\me\\notes\\CON.tar.png' + ] + + it.each(deviceTargets)('refuses %s as a chat image without touching it', async (target) => { + await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).rejects.toThrow( + 'Access denied' + ) + expect(fsCalls).toEqual([]) + }) + + it.each(deviceTargets)( + 'refuses %s from a document beside it without touching it', + async (target) => { + await expect( + resolveLocalFileRequestPath( + target, + { kind: 'document-resource', documentPath: 'C:\\Users\\me\\notes\\todo.md' }, + store + ) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + } + ) + + it('still reads an ordinary image whose name only starts like a device', async () => { + await expect( + resolveLocalFileRequestPath('C:\\Users\\me\\notes\\console.png', CHAT_IMAGE, store) + ).resolves.toBe('C:\\Users\\me\\notes\\console.png') + }) +}) diff --git a/src/main/ipc/filesystem-request-shape.test.ts b/src/main/ipc/filesystem-request-shape.test.ts index 67dd47a5d73a..3c47c8fc64d9 100644 --- a/src/main/ipc/filesystem-request-shape.test.ts +++ b/src/main/ipc/filesystem-request-shape.test.ts @@ -289,4 +289,77 @@ describe('document-resource requests', () => { ).toBe('denied') } ) + + it.skipIf(process.platform === 'win32')( + 'refuse an image-named link to a text file, in a project or beside the document', + async () => { + await writeFile(join(project, '.env'), 'PROJECT_KEY=secret\n') + await symlink(join(project, '.env'), join(project, 'logo-link.png')) + await writeFile(join(outside, 'doc-folder', 'notes.env'), 'API_KEY=secret\n') + await symlink(join(outside, 'doc-folder', 'notes.env'), join(outside, 'doc-folder', 'a.png')) + const store = makeStore({ repoPaths: [project] }) + + const inProject = documentResource(join(project, 'docs', 'README.md')) + const besideDoc = documentResource(join(outside, 'doc-folder', 'note.md')) + expect( + await settles(resolveLocalFileRequestPath(join(project, 'logo-link.png'), inProject, store)) + ).toBe('denied') + expect( + await settles( + resolveLocalFileRequestPath(join(outside, 'doc-folder', 'a.png'), besideDoc, store) + ) + ).toBe('denied') + } + ) +}) + +describe('chat-image requests', () => { + const CHAT_IMAGE = { kind: 'chat-image' } as const + + it('read any local image file in place, whatever turn named it', async () => { + const shot = join(outside, 'shot.png') + await writeFile(shot, 'png') + + const filePath = await resolveLocalFileRequestPath(shot, CHAT_IMAGE, makeStore({})) + + await expect(readLocalFileContent(filePath)).resolves.toMatchObject({ + isBinary: true, + mimeType: 'image/png' + }) + }) + + it.each(['notes.txt', 'missing.png'])('refuse %s', async (name) => { + expect( + await settles(resolveLocalFileRequestPath(join(outside, name), CHAT_IMAGE, makeStore({}))) + ).toBe('denied') + }) + + it('refuse a relative path, a device and a PDF', async () => { + const store = makeStore({}) + await writeFile(join(outside, 'doc.pdf'), '%PDF') + + expect(await settles(resolveLocalFileRequestPath('shot.png', CHAT_IMAGE, store))).toBe('denied') + expect(await settles(resolveLocalFileRequestPath('/dev/zero', CHAT_IMAGE, store))).toBe( + 'denied' + ) + expect( + await settles(resolveLocalFileRequestPath(join(outside, 'doc.pdf'), CHAT_IMAGE, store)) + ).toBe('denied') + }) + + it.skipIf(process.platform === 'win32')( + 'refuse an image-named link to a text file or a device', + async () => { + await symlink(join(outside, 'notes.txt'), join(outside, 'secret.png')) + await symlink('/dev/zero', join(outside, 'zero.png')) + const store = makeStore({}) + + expect( + await settles(resolveLocalFileRequestPath(join(outside, 'secret.png'), CHAT_IMAGE, store)) + ).toBe('denied') + expect( + await settles(resolveLocalFileRequestPath(join(outside, 'zero.png'), CHAT_IMAGE, store)) + ).toBe('denied') + } + ) }) diff --git a/src/main/ipc/filesystem-request-shape.ts b/src/main/ipc/filesystem-request-shape.ts index 8806b49dc177..4c976832dd08 100644 --- a/src/main/ipc/filesystem-request-shape.ts +++ b/src/main/ipc/filesystem-request-shape.ts @@ -10,12 +10,17 @@ import { import { isDescendantOrEqual } from './filesystem-path-containment' import { PREVIEWABLE_BINARY_MIME_TYPES } from './filesystem/filesystem-file-content-inspection' import { getDefaultFloatingWorkspacePath } from './floating-workspace-directory' +import { + isNetworkOrDeviceNamespacePath, + isWindowsReservedDeviceName +} from './automatic-load-path-text' import { NOT_A_REGULAR_FILE_MESSAGE } from './filesystem/local-regular-file-read' export const USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE = 'Access denied: a file opened by name needs an absolute path.' export const DOCUMENT_RESOURCE_TYPE_MESSAGE = 'Access denied: a document can only load images and PDFs it references.' +export const CHAT_IMAGE_TYPE_MESSAGE = 'Access denied: a chat can only show local image files.' /** Desktop IPC's root check: the project roots plus the app-owned floating-workspace folder. */ export function resolveDesktopAuthorizedPath( @@ -56,10 +61,18 @@ async function isInsideDesktopRoots(documentPath: string, store: Store): Promise } } +function isPreviewableBinary(filePath: string): boolean { + return Boolean(PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()]) +} + +function isChatImage(filePath: string): boolean { + return isPreviewableBinary(filePath) && extname(filePath).toLowerCase() !== '.pdf' +} + /** * An image or PDF a document references: limited to every project root when the document is in - * one, else to the document's own folder. Out-of-scope targets are refused by path text before any - * disk or network access, so a referenced network share is never contacted. + * one, else to the document's own folder. A target outside that scope is refused by its path text + * before any filesystem call; a symlink inside the scope is still resolved by the scope check. */ export async function resolveDocumentResourcePath( targetPath: string, @@ -70,25 +83,63 @@ export async function resolveDocumentResourcePath( typeof targetPath !== 'string' || !isAbsolute(targetPath) || typeof documentPath !== 'string' || - !isAbsolute(documentPath) + !isAbsolute(documentPath) || + isWindowsReservedDeviceName(targetPath) ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } const resolvedTarget = resolve(targetPath) - if (!PREVIEWABLE_BINARY_MIME_TYPES[extname(resolvedTarget).toLowerCase()]) { + if (!isPreviewableBinary(resolvedTarget)) { throw new Error(DOCUMENT_RESOURCE_TYPE_MESSAGE) } + let realTarget: string if (await isInsideDesktopRoots(documentPath, store)) { - return resolveDesktopAuthorizedPath(resolvedTarget, store) + realTarget = await resolveDesktopAuthorizedPath(resolvedTarget, store) + } else { + const documentFolder = dirname(resolve(documentPath)) + if (!isDescendantOrEqual(resolvedTarget, documentFolder)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + realTarget = resolve(await realpath(resolvedTarget)) + if (!isDescendantOrEqual(realTarget, resolve(await realpath(documentFolder)))) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + } + // Why the real target too: `logo.png -> .env` would otherwise return text. + if (!isPreviewableBinary(realTarget)) { + throw new Error(DOCUMENT_RESOURCE_TYPE_MESSAGE) } - const documentFolder = dirname(resolve(documentPath)) - if (!isDescendantOrEqual(resolvedTarget, documentFolder)) { + return realTarget +} + +/** + * An image shown in a chat transcript, whoever's turn named it: any absolute local image file, + * typed by its real target. Network shares and device paths are refused by path text before any + * filesystem call, since transcripts load as they scroll into view. + */ +export async function resolveChatImagePath(targetPath: string): Promise { + if ( + typeof targetPath !== 'string' || + !isAbsolute(targetPath) || + isWindowsReservedDeviceName(targetPath) + ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } + const resolvedTarget = resolve(targetPath) + if (isNetworkOrDeviceNamespacePath(resolvedTarget)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + if (!isChatImage(resolvedTarget)) { + throw new Error(CHAT_IMAGE_TYPE_MESSAGE) + } + // Why the real target too: `shot.png -> ~/.ssh/id_rsa` must not be read as an image. const realTarget = resolve(await realpath(resolvedTarget)) - if (!isDescendantOrEqual(realTarget, resolve(await realpath(documentFolder)))) { + if (isNetworkOrDeviceNamespacePath(realTarget) || isWindowsReservedDeviceName(realTarget)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } + if (!isChatImage(realTarget)) { + throw new Error(CHAT_IMAGE_TYPE_MESSAGE) + } return realTarget } @@ -100,6 +151,9 @@ function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined { if (access.kind === 'user-file') { return { kind: 'user-file' } } + if (access.kind === 'chat-image') { + return { kind: 'chat-image' } + } if ( access.kind === 'document-resource' && 'documentPath' in access && @@ -123,6 +177,9 @@ export async function resolveLocalFileRequestPath( if (shape?.kind === 'document-resource') { return resolveDocumentResourcePath(targetPath, shape.documentPath, store) } + if (shape?.kind === 'chat-image') { + return resolveChatImagePath(targetPath) + } return resolveDesktopAuthorizedPath(targetPath, store) } diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts new file mode 100644 index 000000000000..38a0326840d0 --- /dev/null +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ statUserOpenedPath: vi.fn(), openFile: vi.fn() })) + +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/lib/user-opened-local-path', () => ({ statUserOpenedPath: mocks.statUserOpenedPath })) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + settings: {}, + allWorktrees: () => [{ id: 'wt-1', path: '/repo' }], + setActiveTabType: vi.fn(), + ensureWorktreeRootGroup: () => 'group-1', + openFile: mocks.openFile + }) + } +})) + +import { navigateBrowserPageToUrl } from './navigate-browser-page-url' + +function ref(current: T): { current: T } { + return { current } +} + +function openNotebookUrl(url: string): void { + navigateBrowserPageToUrl({ + url, + browserTabId: 'tab-1', + worktreeId: 'wt-1', + activeLoadFailureRef: ref(null), + lastKnownWebviewUrlRef: ref(null), + trackNextLoadingEventRef: ref(false), + recoveryNavigationValidationRef: ref(null), + webviewRef: ref(null), + onSetUrlRef: ref(vi.fn()), + onUpdatePageStateRef: ref(vi.fn()), + setAddressBarValue: vi.fn(), + setResourceNotice: vi.fn(), + focusWebviewNow: () => true + }) +} + +describe('opening a file:// notebook from the browser', () => { + beforeEach(() => { + mocks.statUserOpenedPath.mockReset() + mocks.openFile.mockReset() + }) + + it('opens a project link that leads out of the project by its absolute path', async () => { + mocks.statUserOpenedPath.mockResolvedValue({ isDirectory: false, escapesWorktree: true }) + + openNotebookUrl('file:///repo/notebooks-link/analysis.ipynb') + await vi.waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) + + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/repo/notebooks-link/analysis.ipynb', + relativePath: '/repo/notebooks-link/analysis.ipynb' + }), + expect.anything() + ) + }) + + it('keeps an ordinary project notebook project-relative', async () => { + mocks.statUserOpenedPath.mockResolvedValue({ isDirectory: false, escapesWorktree: false }) + + openNotebookUrl('file:///repo/analysis.ipynb') + await vi.waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) + + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/repo/analysis.ipynb', relativePath: 'analysis.ipynb' }), + expect.anything() + ) + }) +}) diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts index 23a468cce15f..2f33e434982d 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts @@ -2,7 +2,7 @@ import { detectLanguage } from '@/lib/language-detect' import { getConnectionId } from '@/lib/connection-context' import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' import { useAppStore } from '@/store' -import { statRuntimePath, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { normalizeBrowserNavigationUrl, redactKagiSessionToken @@ -21,7 +21,7 @@ import type { BrowserTabPageState } from '../describe-page/browser-page-types' import type { MutableRefObject } from 'react' -import { userNamedFileAccess } from '@/lib/local-file-access' +import { statUserOpenedPath } from '@/lib/user-opened-local-path' export type NavigateBrowserPageToUrlArgs = { url: string @@ -104,14 +104,19 @@ export function navigateBrowserPageToUrl({ worktreePath: activeWorktree?.path, connectionId: undefined } - const stat = await statRuntimePath(fileContext, notebookPath, userNamedFileAccess()) + const stat = await statUserOpenedPath(fileContext, notebookPath) if (stat.isDirectory) { navigateBrowserUrl(url) return } let relativePath = notebookPath - if (activeWorktree?.path && isPathInsideWorktree(notebookPath, activeWorktree.path)) { + // Why: a project link out of the project keeps its absolute path, so it reads as user-named. + if ( + activeWorktree?.path && + !stat.escapesWorktree && + isPathInsideWorktree(notebookPath, activeWorktree.path) + ) { relativePath = toWorktreeRelativePath(notebookPath, activeWorktree.path) ?? notebookPath } diff --git a/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx index eae2909bef5c..a4c6598b3075 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx @@ -19,7 +19,9 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ settingsForRuntimeOwner: () => null })) vi.mock('@/lib/connection-context', () => ({ - getConnectionIdForFile: () => undefined + // Why: the floating workspace is always client-local; other owners are still loading. + getConnectionIdForFile: (worktreeId: string) => + worktreeId === 'global-floating-terminal' ? null : undefined })) import { @@ -228,4 +230,21 @@ describe('ExternalFileChangeBanner', () => { expect(setLastKnownDiskSignature).not.toHaveBeenCalled() }) + + it.each([ + [ + 'a floating-workspace tab, as the file the user named', + 'global-floating-terminal', + 'user-file' + ], + ['a project tab, inside its root', 'wt-1', undefined] + ])('keep-my-edits reads %s', async (_label, worktreeId, kind) => { + const tab: OpenFile = { ...file, worktreeId } + mockStoreState({}, [tab]) + + keepTabEditsOverExternalChange(tab) + await Promise.resolve() + + expect(readRuntimeFileContentMock.mock.calls[0]?.[0]?.access?.kind).toBe(kind) + }) }) diff --git a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx index 2a9060055555..799cf3d72854 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx @@ -139,4 +139,30 @@ describe('ExternalFileChangeCompareDialog', () => { }) expect(onKeepEdits).toHaveBeenCalledTimes(1) }) + + it.each([ + [ + 'a floating-workspace tab as the file the user named', + 'global-floating-terminal', + null, + 'user-file' + ], + ['a project tab inside its root', 'wt-1', null, undefined] + ])('reads %s', async (_label, worktreeId, connectionId, kind) => { + mocks.getConnectionIdForFile.mockReturnValue(connectionId) + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'disk version', isBinary: false }) + + await render( + + ) + + expect(mocks.readRuntimeFileContent.mock.calls[0]?.[0]?.access?.kind).toBe(kind) + }) }) diff --git a/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx new file mode 100644 index 000000000000..5689afe6358a --- /dev/null +++ b/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom +// +// Renders the real MarkdownPreview and pins the shape its images are read with: a resource of the +// document showing them, so main limits them to that document's roots or folder. + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const useLocalImageSrcSpy = vi.hoisted(() => vi.fn((src?: string, ..._context: unknown[]) => src)) + +const storeState = { + openFile: vi.fn(), + activateMarkdownLink: vi.fn(), + openMarkdownPreview: vi.fn(), + setMarkdownViewMode: vi.fn(), + markdownFrontmatterVisible: {}, + setPendingEditorReveal: vi.fn(), + addDiffComment: vi.fn(), + deleteDiffComment: vi.fn(), + updateDiffComment: vi.fn(), + clearDeliveredDiffComments: vi.fn(), + keybindings: {}, + worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], + openFiles: [], + activeFileIdByWorktree: {}, + settings: { openLinksInApp: true }, + editorFontZoomLevel: 0 +} + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (s: typeof storeState) => unknown) => selector(storeState), + { getState: () => storeState } + ) + return { useAppStore } +}) +vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + settingsForRuntimeOwner: (settings: unknown) => settings +})) +vi.mock('@/runtime/runtime-file-client', () => ({ + statRuntimePath: vi.fn(async () => ({ isDirectory: false })) +})) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null })) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => null +})) +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) +vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: useLocalImageSrcSpy })) +vi.mock('./markdown-preview-local-images', () => ({ + prewarmMarkdownPreviewLocalImages: () => ({ cancel: () => {}, done: Promise.resolve() }) +})) +vi.mock('./MermaidBlock', () => ({ default: () => null })) +vi.mock('./CodeBlockCopyButton', () => ({ + default: ({ children }: { children: React.ReactNode }) => children +})) +vi.mock('../diff-comments/DiffCommentCard', () => ({ DiffCommentCard: () => null })) +vi.mock('./NotesSendMenu', () => ({ NotesSendMenu: () => null })) +vi.mock('./MarkdownTableOfContentsPanel', () => ({ MarkdownTableOfContentsPanel: () => null })) + +import MarkdownPreview from './MarkdownPreview' + +describe('MarkdownPreview images', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.stubGlobal('api', { + shell: { openUrl: vi.fn(), openFileUri: vi.fn(), pathExists: vi.fn(async () => true) }, + ui: { writeClipboardText: vi.fn(async () => true) } + }) + useLocalImageSrcSpy.mockClear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.unstubAllGlobals() + }) + + it('reads each image as a resource of the document showing it', () => { + act(() => { + root.render( + + ) + }) + + expect(useLocalImageSrcSpy.mock.calls[0]?.[0]).toBe('./logo.png') + expect(useLocalImageSrcSpy.mock.calls[0]?.[4]).toEqual({ + kind: 'document-resource', + documentPath: '/notes/readme.md' + }) + }) +}) diff --git a/src/renderer/src/components/editor/MarkdownPreviewBody.tsx b/src/renderer/src/components/editor/MarkdownPreviewBody.tsx index 3125405127b1..8ad55a8557b3 100644 --- a/src/renderer/src/components/editor/MarkdownPreviewBody.tsx +++ b/src/renderer/src/components/editor/MarkdownPreviewBody.tsx @@ -19,7 +19,7 @@ const markdownPreviewSanitizeSchema = { tagNames: [...(defaultSchema.tagNames ?? []), 'details', 'summary', 'kbd', 'sub', 'sup', 'ins'], protocols: { ...defaultSchema.protocols, - // Why: keep file:// through sanitize so the click handler can authorize and open the target. + // Why: keep file:// through sanitize so the click handler can open the target. href: [...(defaultSchema.protocols?.href ?? []), 'file'], src: [...(defaultSchema.protocols?.src ?? []), 'file'] }, diff --git a/src/renderer/src/components/editor/markdown-preview-local-images.test.ts b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts index f7d60703fb38..3f4b8f8f4475 100644 --- a/src/renderer/src/components/editor/markdown-preview-local-images.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_CONCURRENCY, MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT, @@ -247,3 +247,21 @@ describe('prewarmMarkdownPreviewLocalImages', () => { expect(started).toHaveLength(2) }) }) + +describe('prewarming preview images', () => { + it('reads each image as a resource of the document that references it', async () => { + const readFile = vi.fn().mockResolvedValue({ content: '', isBinary: false }) + vi.stubGlobal('window', { api: { fs: { readFile } } }) + try { + await prewarmMarkdownPreviewLocalImages('![Logo](./logo.png)', '/notes/readme.md').done + + expect(readFile).toHaveBeenCalledWith({ + filePath: '/notes/logo.png', + connectionId: undefined, + access: { kind: 'document-resource', documentPath: '/notes/readme.md' } + }) + } finally { + vi.unstubAllGlobals() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 961a00685ce9..f7df5e880629 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -164,9 +164,9 @@ export function createRichMarkdownExtensions({ img.src = resolved return } - // Why: local image paths must stay behind IPC/runtime - // authorization; a failed load should render missing, not - // hand the raw path back to Chromium. + // Why: local image paths must go through main's file + // checks; a failed load should render missing, not hand + // the raw path back to Chromium. img.removeAttribute('src') } ) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index 84f0068fc039..068592d16c42 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -51,7 +51,7 @@ describe('NativeChatImageAttachmentPreview', () => { renderPreview({ id: 'a1', path: '', previewUrl: 'blob:clipboard-1', pending: true }) expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(undefined, '', undefined, undefined, { - kind: 'user-file' + kind: 'chat-image' }) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 802550d9fe25..819cfaeaf9e5 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -6,7 +6,7 @@ import { basename } from '@/lib/path' import { useLocalImageSrc } from '@/components/editor/useLocalImageSrc' import { isNativeChatPastedImagePath } from './native-chat-image-paste' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' -import { userNamedFileAccess } from '@/lib/local-file-access' +import { chatImageAccess } from '@/lib/local-file-access' type Props = { attachment: NativeChatComposerImageAttachment @@ -48,7 +48,8 @@ export function NativeChatImageAttachmentPreview({ attachment.path, attachment.connectionId, undefined, - userNamedFileAccess() + // Why chat-image: a draft handed off from the host queue may carry paths a paired client chose. + chatImageAccess() ) // The clipboard thumbnail is already in this process, so it renders with no // round-trip; the on-disk file only wins for the full-size dialog. diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index ebdf7b8b3cb6..234934664f4a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -154,7 +154,6 @@ export const MessageRow = memo(function MessageRow({ blocks={prose} runtimeContext={runtimeContext} enablePreview={runtimeContext !== undefined} - attachedByUser /> )} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx index 0b4012db4661..2ba15a08d4cc 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx @@ -208,25 +208,25 @@ describe('NativeChatImageAttachments', () => { }) it.each([ - ['pasted or attached by the user', true, { kind: 'user-file' }], - ['produced by the agent', false, undefined] - ])('reads an image %s with the matching shape', async (_label, attachedByUser, access) => { + ['a pasted screenshot in the temp folder', '/tmp/orca-paste-1.png'], + ['an agent image outside the project', '/Users/me/.codex/generated/plot.png'] + ])('reads %s as a chat image, whoever sent it', async (_label, path) => { const container = document.createElement('div') const root = createRoot(container) await act(async () => { root.render( createElement(NativeChatImageAttachments, { - blocks: [{ type: 'image-ref' as const, path: '/tmp/orca-paste-1.png' }], - runtimeContext: runtimeContext('wt-1'), - attachedByUser + blocks: [{ type: 'image-ref' as const, path }], + runtimeContext: runtimeContext('wt-1') }) ) await flushPromises() }) - const request = vi.mocked(window.api.fs.readFile).mock.calls[0]?.[0] - expect(request?.filePath).toBe('/tmp/orca-paste-1.png') - expect(request?.access).toEqual(access) + expect(vi.mocked(window.api.fs.readFile).mock.calls[0]?.[0]).toMatchObject({ + filePath: path, + access: { kind: 'chat-image' } + }) root.unmount() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index 5e559fc87843..0c637c33d5f3 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -15,8 +15,7 @@ import { } from '@/components/editor/useLocalImageSrc' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { isNativeChatPastedImagePath } from './native-chat-image-paste' -import type { LocalFileAccess } from '../../../../shared/local-file-access' -import { userNamedFileAccess } from '@/lib/local-file-access' +import { chatImageAccess } from '@/lib/local-file-access' type VisibilityListener = (isVisible: boolean) => void @@ -72,14 +71,16 @@ function transcriptImageIdentity( }` } +// Why one shape for every role: a turn's role says who sent it, not who chose the path, and these +// load on scroll with no click, so main only serves local image files by their real type. +const TRANSCRIPT_IMAGE_ACCESS = chatImageAccess() + function TranscriptImagePreview({ block, - runtimeContext, - access + runtimeContext }: { block: Extract runtimeContext: RuntimeFileOperationArgs | null | undefined - access: LocalFileAccess | undefined }): React.JSX.Element { const [open, setOpen] = useState(false) const [near, setNear] = useState(false) @@ -95,7 +96,7 @@ function TranscriptImagePreview({ filePath, runtimeContext?.connectionId, runtimeContext, - access + TRANSCRIPT_IMAGE_ACCESS ) const displaySrc = external && leaseActive ? source : localSrc const label = @@ -129,10 +130,11 @@ function TranscriptImagePreview({ return } if (!leaseActive) { - releaseLocalImageSrc(source, filePath, context.connectionId, context, access) + releaseLocalImageSrc(source, filePath, context.connectionId, context, TRANSCRIPT_IMAGE_ACCESS) } - return () => releaseLocalImageSrc(source, filePath, context.connectionId, context, access) - }, [access, external, filePath, leaseActive, runtimeContext, source]) + return () => + releaseLocalImageSrc(source, filePath, context.connectionId, context, TRANSCRIPT_IMAGE_ACCESS) + }, [external, filePath, leaseActive, runtimeContext, source]) const showPreview = leaseActive && @@ -188,15 +190,12 @@ function TranscriptImagePreview({ export function NativeChatImageAttachments({ blocks, runtimeContext, - enablePreview = runtimeContext !== undefined, - attachedByUser = false + enablePreview = runtimeContext !== undefined }: { blocks: NativeChatBlock[] runtimeContext?: RuntimeFileOperationArgs | null /** Keep legacy terminal chips unchanged until that lane opts into previews. */ enablePreview?: boolean - /** The user pasted or attached these, so they show wherever they live; agent images stay in the project. */ - attachedByUser?: boolean }): React.JSX.Element | null { const images = blocks.filter((block) => block.type === 'image-ref') if (images.length === 0) { @@ -244,7 +243,6 @@ export function NativeChatImageAttachments({ key={`${imageKeyBase}-${identity}-${occurrence}`} block={image} runtimeContext={runtimeContext} - access={attachedByUser ? userNamedFileAccess() : undefined} /> ) })} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index a4dde5f72dde..f25d5d863810 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -289,9 +289,9 @@ describe('native chat composer drop scoping', () => { expect(readNativeChatAttachmentCache('chat-b')).toEqual([]) }) - it('previews dropped files as user-named and leaves the other pane untouched', async () => { + it('previews dropped files as chat images and leaves the other pane untouched', async () => { intake.readFile.mockImplementation(async ({ access }: { access?: { kind: string } }) => { - if (access?.kind !== 'user-file') { + if (access?.kind !== 'chat-image') { throw new Error('Access denied: path resolves outside allowed directories') } return { content: 'AA==', isBinary: true, mimeType: 'image/png' } diff --git a/src/renderer/src/components/settings/KeybindingsFileActions.tsx b/src/renderer/src/components/settings/KeybindingsFileActions.tsx index 5d355b109493..f9b1288a73e0 100644 --- a/src/renderer/src/components/settings/KeybindingsFileActions.tsx +++ b/src/renderer/src/components/settings/KeybindingsFileActions.tsx @@ -83,8 +83,8 @@ export function KeybindingsFileActions(): React.JSX.Element { (file) => file.filePath === filePath && file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID ) if (existingFile && !existingFile.isDirty) { - // Why: a prior denied read can leave a focused error tab. Reopen a - // clean tab after authorization so the editor retries the file load. + // Why: a prior failed read can leave a focused error tab. Reopen a + // clean tab so the editor retries the file load. closeFile(existingFile.id) } openFile( diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts b/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts index 6dfa05842db5..7e201f7fc6cb 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-absolute-file.ts @@ -1,12 +1,12 @@ import { detectLanguage } from '@/lib/language-detect' import { toWorktreeRelativePath } from '@/lib/terminal-links' -import type { RuntimeFileOperationArgs, statRuntimePath } from '@/runtime/runtime-file-client' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import type { OpenFile } from '@/store/slices/editor' import { validateNewTabEntryAbsolutePath, type TabEntryLocalPlatform } from './tab-create-entry-path-validation' -import { userNamedFileAccess } from '@/lib/local-file-access' +import type { statUserOpenedPath, UserOpenedPathStat } from '@/lib/user-opened-local-path' type AbsoluteFileOperations = { assertAbsolutePathAllowed: () => void @@ -14,7 +14,7 @@ type AbsoluteFileOperations = { file: Omit, options?: { preview?: boolean; targetGroupId?: string } ) => void - statRuntimePath: typeof statRuntimePath + statUserOpenedPath: typeof statUserOpenedPath } export async function openAbsoluteTabEntryFile(args: { @@ -28,9 +28,9 @@ export async function openAbsoluteTabEntryFile(args: { }): Promise { const filePath = validateNewTabEntryAbsolutePath(args.filePath, args.localPlatform) args.operations.assertAbsolutePathAllowed() - let stat: Awaited> + let stat: UserOpenedPathStat try { - stat = await args.operations.statRuntimePath(args.context, filePath, userNamedFileAccess()) + stat = await args.operations.statUserOpenedPath(args.context, filePath) } catch { throw new Error(`File not found: ${filePath}`) } @@ -42,7 +42,10 @@ export async function openAbsoluteTabEntryFile(args: { args.operations.openFile( { filePath, - relativePath: toWorktreeRelativePath(filePath, args.worktreePath) || filePath, + // Why: a project link out of the project keeps its absolute path, so it reads as user-named. + relativePath: stat.escapesWorktree + ? filePath + : toWorktreeRelativePath(filePath, args.worktreePath) || filePath, worktreeId: args.worktreeId, language: detectLanguage(filePath), mode: 'edit' diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts index 9826a4f8aef6..93d007adf31d 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts @@ -14,6 +14,7 @@ describe('openTabEntryWithOperations', () => { openWorkspaceBrowserTab: vi.fn().mockResolvedValue(undefined), openFile: vi.fn(), statRuntimePath: vi.fn().mockResolvedValue({ size: 1, isDirectory: false, mtime: 1 }), + statUserOpenedPath: vi.fn().mockResolvedValue({ isDirectory: false, escapesWorktree: false }), assertAbsolutePathAllowed: vi.fn(), ...overrides } @@ -263,10 +264,9 @@ describe('openTabEntryWithOperations', () => { operations }) - expect(operations.statRuntimePath).toHaveBeenCalledWith( + expect(operations.statUserOpenedPath).toHaveBeenCalledWith( baseArgs.runtimeContext, - '/tmp/notes.md', - { kind: 'user-file' } + '/tmp/notes.md' ) expect(operations.openFile).toHaveBeenCalledWith( expect.objectContaining({ @@ -297,6 +297,27 @@ describe('openTabEntryWithOperations', () => { ) }) + it('opens a typed project link that leads out of the project by its absolute path', async () => { + const operations = makeOperations({ + statUserOpenedPath: vi.fn().mockResolvedValue({ isDirectory: false, escapesWorktree: true }) + }) + + await openTabEntryWithOperations({ + ...baseArgs, + classification: { kind: 'absolute-file', filePath: '/repo/docs-link/secret.md' }, + query: '/repo/docs-link/secret.md', + operations + }) + + expect(operations.openFile).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/repo/docs-link/secret.md', + relativePath: '/repo/docs-link/secret.md' + }), + { preview: false, targetGroupId: 'group-1' } + ) + }) + it('rejects absolute paths when remote workspaces disallow them', async () => { const operations = makeOperations() @@ -310,7 +331,7 @@ describe('openTabEntryWithOperations', () => { }) ).rejects.toThrow(TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE) - expect(operations.statRuntimePath).not.toHaveBeenCalled() + expect(operations.statUserOpenedPath).not.toHaveBeenCalled() expect(operations.createRuntimePath).not.toHaveBeenCalled() expect(operations.openFile).not.toHaveBeenCalled() }) @@ -327,7 +348,7 @@ describe('openTabEntryWithOperations', () => { }) ).rejects.toThrow('Enter an absolute path for this computer.') - expect(operations.statRuntimePath).not.toHaveBeenCalled() + expect(operations.statUserOpenedPath).not.toHaveBeenCalled() expect(operations.openFile).not.toHaveBeenCalled() }) @@ -343,23 +364,22 @@ describe('openTabEntryWithOperations', () => { operations }) - expect(operations.statRuntimePath).toHaveBeenCalledWith( + expect(operations.statUserOpenedPath).toHaveBeenCalledWith( baseArgs.runtimeContext, - 'C:/tmp/notes.md', - { kind: 'user-file' } + 'C:/tmp/notes.md' ) }) it('stops after the file check when ownership becomes remote or ambiguous', async () => { let releaseStat: (() => void) | undefined - const statResult = new Promise<{ size: number; isDirectory: boolean; mtime: number }>( + const statResult = new Promise<{ isDirectory: boolean; escapesWorktree: boolean }>( (resolve) => { - releaseStat = () => resolve({ size: 1, isDirectory: false, mtime: 1 }) + releaseStat = () => resolve({ isDirectory: false, escapesWorktree: false }) } ) let allowed = true const operations = makeOperations({ - statRuntimePath: vi.fn(() => statResult), + statUserOpenedPath: vi.fn(() => statResult), assertAbsolutePathAllowed: vi.fn(() => { if (!allowed) { throw new Error(TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE) @@ -373,7 +393,7 @@ describe('openTabEntryWithOperations', () => { query: '/tmp/notes.md', operations }) - await vi.waitFor(() => expect(operations.statRuntimePath).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(operations.statUserOpenedPath).toHaveBeenCalledTimes(1)) const rejection = expect(opening).rejects.toThrow( TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE ) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts index b0e1d4f7a641..4fedc19935b1 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.ts @@ -49,6 +49,7 @@ export { getTabEntryAllowAbsolutePaths, isTabEntryAbsolutePathAllowed } from './tab-create-entry-local-path' +import { statUserOpenedPath } from '@/lib/user-opened-local-path' export type TabCreateEntryArgs = { classification?: TabEntryActionClassification @@ -66,6 +67,7 @@ export type TabEntryOperations = { options?: { preview?: boolean; targetGroupId?: string } ) => void statRuntimePath: typeof statRuntimePath + statUserOpenedPath: typeof statUserOpenedPath assertAbsolutePathAllowed: () => void } @@ -289,6 +291,7 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise { openWorkspaceBrowserTab, openFile: state.openFile, statRuntimePath, + statUserOpenedPath, assertAbsolutePathAllowed: () => { if (!getTabEntryAllowAbsolutePaths(useAppStore.getState(), args.worktreeId)) { throw new Error(TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE) diff --git a/src/renderer/src/hooks/useGlobalFileDrop.escaping-link.test.tsx b/src/renderer/src/hooks/useGlobalFileDrop.escaping-link.test.tsx new file mode 100644 index 000000000000..c03f7a441465 --- /dev/null +++ b/src/renderer/src/hooks/useGlobalFileDrop.escaping-link.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeFileDropPayload } from '../../../shared/native-file-drop' + +const mocks = vi.hoisted(() => ({ + statUserOpenedPath: vi.fn(), + openFile: vi.fn(), + setActiveTabType: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/lib/ssh-mutation-expectation', () => ({ + captureWorktreeSshMutationExpectation: () => ({}) +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null +})) +vi.mock('@/lib/user-opened-local-path', () => ({ statUserOpenedPath: mocks.statUserOpenedPath })) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + settings: {}, + activeWorktreeId: 'wt-1', + getKnownWorktreeById: () => ({ id: 'wt-1', path: '/repo' }), + setActiveTabType: mocks.setActiveTabType, + openFile: mocks.openFile + }) + } +})) + +import { useGlobalFileDrop } from './useGlobalFileDrop' + +let dropListener: ((data: NativeFileDropPayload) => void) | null = null + +beforeEach(() => { + mocks.statUserOpenedPath.mockReset() + mocks.openFile.mockReset() + vi.stubGlobal('api', { + ui: { + onFileDrop: (listener: (data: NativeFileDropPayload) => void) => { + dropListener = listener + return () => { + dropListener = null + } + } + } + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +async function dropOnTabStrip(path: string): Promise { + renderHook(() => useGlobalFileDrop()) + dropListener?.({ target: 'editor', paths: [path] }) + await vi.waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) +} + +describe('dropping a project file on the tab strip', () => { + it('opens a link that leads out of the project by its absolute path', async () => { + mocks.statUserOpenedPath.mockResolvedValue({ isDirectory: false, escapesWorktree: true }) + + await dropOnTabStrip('/repo/docs-link/secret.md') + + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/repo/docs-link/secret.md', + relativePath: '/repo/docs-link/secret.md' + }) + ) + }) + + it('keeps an ordinary project file project-relative', async () => { + mocks.statUserOpenedPath.mockResolvedValue({ isDirectory: false, escapesWorktree: false }) + + await dropOnTabStrip('/repo/docs/plain.md') + + expect(mocks.openFile).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/repo/docs/plain.md', relativePath: 'docs/plain.md' }) + ) + }) +}) diff --git a/src/renderer/src/hooks/useGlobalFileDrop.ts b/src/renderer/src/hooks/useGlobalFileDrop.ts index 5f808c99e7e3..18f01ad0ff7c 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.ts @@ -8,7 +8,6 @@ import { joinPath } from '@/lib/path' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { importExternalPathsToRuntime, - statRuntimePath, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -20,7 +19,7 @@ import { } from '../../../shared/native-file-drop' import { captureWorktreeSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { describeDropTempCopyFailure } from '@/lib/drop-temp-copy-failure-copy' -import { userNamedFileAccess } from '@/lib/local-file-access' +import { statUserOpenedPath } from '@/lib/user-opened-local-path' export function getEditorFileDropSettingsForWorktree( store: WorktreeRuntimeOwnerState, @@ -160,13 +159,19 @@ export function useGlobalFileDrop(): void { for (const filePath of data.paths) { void (async () => { try { - const stat = await statRuntimePath(fileContext, filePath, userNamedFileAccess()) + const stat = await statUserOpenedPath(fileContext, filePath) if (stat.isDirectory) { return } let relativePath = filePath - if (worktreePath && isPathInsideWorktree(filePath, worktreePath)) { + // Why: a project link out of the project keeps its absolute path, so it reads as + // user-named instead of being refused as a project file. + if ( + worktreePath && + !stat.escapesWorktree && + isPathInsideWorktree(filePath, worktreePath) + ) { const maybeRelative = toWorktreeRelativePath(filePath, worktreePath) if (maybeRelative !== null && maybeRelative.length > 0) { relativePath = maybeRelative diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index 5d0f8fe7ac0c..ac5ba5509a74 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -148,42 +148,92 @@ describe('editorTabFileAccess', () => { }) }) -// Why a ratchet: a content-driven reader adopting user-file would bring back the round-1 leak, so -// every new importer must be a deliberate, reviewed addition to this list. +// Why a ratchet: a content-driven reader that adopted user-file would bring back the round-1 leak. +// These scans only see the two ways a renderer file can produce user-file today, by name and by +// pattern: building the shape directly, and opening a tab the tab rule reads as user-named. They +// cannot see a decision laundered through props or a helper, so they are a tripwire for review, not +// a proof; a branded user-named path type is the follow-up that would make it one. const USER_NAMED_ACCESS_IMPORTERS = [ 'components/browser-pane/describe-page/browser-artifact-upload.ts', - 'components/browser-pane/navigate/navigate-browser-page-url.ts', - 'components/native-chat/NativeChatImageAttachmentPreview.tsx', - 'components/native-chat/NativeChatTranscriptChrome.tsx', 'components/native-chat/use-native-chat-external-attachments.ts', 'components/sidebar/useSidebarProjectDrop.ts', - 'components/tab-bar/tab-create-entry-absolute-file.ts', 'hooks/composer-state/attachment-drop-state.ts', - 'hooks/useGlobalFileDrop.ts', 'lib/local-file-access.ts', 'lib/user-opened-local-path.ts' ] +// Files whose openFile call can store relativePath === filePath (read and saved as user-named by +// the tab rule) or open a floating-workspace tab. The scan matches by value, so a few listed files +// only look like it (their relativePath is genuinely relative); review each new entry by hand. +const USER_NAMED_TAB_OPENERS = [ + 'components/browser-pane/navigate/navigate-browser-page-url.ts', + 'components/editor/markdown-preview-link-actions.ts', + 'components/floating-terminal/use-floating-terminal-create-actions.ts', + 'components/right-sidebar/ai-vault-session-log-open.ts', + 'components/right-sidebar/source-control/notes/use-note-opening.ts', + 'components/right-sidebar/useFileExplorerHandlers.ts', + 'components/settings/KeybindingsFileActions.tsx', + 'components/tab-bar/tab-create-entry-absolute-file.ts', + 'components/terminal-pane/terminal-file-open-routing.ts', + 'hooks/useGlobalFileDrop.ts', + 'lib/floating-workspace-tab-creation.ts', + 'lib/open-markdown-in-floating-workspace.ts', + 'store/slices/editor/actions/markdown-link-action.ts' +] + function collectSourceFiles(dir: string): string[] { return readdirSync(dir).flatMap((entry) => { const full = join(dir, entry) if (statSync(full).isDirectory()) { return collectSourceFiles(full) } - return /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry) ? [full] : [] + return /\.tsx?$/.test(entry) && + !/\.test\.tsx?$/.test(entry) && + !/test-(harness|fixture)/.test(entry) + ? [full] + : [] }) } +function opensUserNamedTabs(source: string): boolean { + if (!/\bopenFile\(/.test(source)) { + return false + } + if (source.includes('FLOATING_TERMINAL_WORKTREE_ID')) { + return true + } + const filePathValues = [...source.matchAll(/\bfilePath:\s*([\w.]+)/g)] + .map((match) => match[1]) + .filter((value) => value !== 'string') + if (/\bfilePath,/.test(source)) { + filePathValues.push('filePath') + } + const relativePathValues = [ + ...source.matchAll(/\brelativePath(?::|\s*=)\s*([\s\S]*?)(?:,\s*\n|\n\s*[})])/g) + ].map((match) => match[1]) + return relativePathValues.some((value) => + filePathValues.some((filePath) => + new RegExp(`(^|[^\\w.])${filePath.replace(/\./g, '\\.')}($|[^\\w.])`).test(value) + ) + ) +} + +function rendererFilesMatching(test: (source: string) => boolean): string[] { + const rendererRoot = resolve(__dirname, '..') + return collectSourceFiles(rendererRoot) + .filter((file) => test(readFileSync(file, 'utf8'))) + .map((file) => relative(rendererRoot, file).split('\\').join('/')) + .sort() +} + describe('user-named file access ratchet', () => { - it('is constructed only by gesture sites and the editor-tab rule', () => { - const rendererRoot = resolve(__dirname, '..') - const importers = collectSourceFiles(rendererRoot) - .filter((file) => - /\buserNamedFileAccess\b|kind: 'user-file'/.test(readFileSync(file, 'utf8')) - ) - .map((file) => relative(rendererRoot, file).split('\\').join('/')) - .sort() - - expect(importers).toEqual(USER_NAMED_ACCESS_IMPORTERS) + it('lists every file that builds the user-file shape', () => { + expect( + rendererFilesMatching((source) => /\buserNamedFileAccess\b|kind: 'user-file'/.test(source)) + ).toEqual(USER_NAMED_ACCESS_IMPORTERS) + }) + + it('lists every file that can open a tab read as user-named', () => { + expect(rendererFilesMatching(opensUserNamedTabs)).toEqual(USER_NAMED_TAB_OPENERS) }) }) diff --git a/src/renderer/src/lib/local-file-access.ts b/src/renderer/src/lib/local-file-access.ts index 6f56b43c5183..d909cc517fa6 100644 --- a/src/renderer/src/lib/local-file-access.ts +++ b/src/renderer/src/lib/local-file-access.ts @@ -6,12 +6,21 @@ import type { AppState } from '@/store/types' import { getConnectionIdForFile } from './connection-context' const USER_FILE_ACCESS: LocalFileAccess = { kind: 'user-file' } +const CHAT_IMAGE_ACCESS: LocalFileAccess = { kind: 'chat-image' } /** For a path the user named by a gesture (click, drop, typed path); never for document content. */ export function userNamedFileAccess(): LocalFileAccess { return USER_FILE_ACCESS } +/** + * For an image a chat transcript or composer shows. Main reads any local image file in place, by + * its real type, and refuses network shares, so it is safe for content no one clicked. + */ +export function chatImageAccess(): LocalFileAccess { + return CHAT_IMAGE_ACCESS +} + /** For an image or PDF a document's content references; main limits it to the document's roots. */ export function documentResourceAccess(documentPath: string): LocalFileAccess { return { kind: 'document-resource', documentPath } diff --git a/src/shared/local-file-access.ts b/src/shared/local-file-access.ts index 99d41d5fd684..0fc0bfb47f9a 100644 --- a/src/shared/local-file-access.ts +++ b/src/shared/local-file-access.ts @@ -7,3 +7,5 @@ export type LocalFileAccess = | { kind: 'user-file' } /** A resource a document's content references (images): limited to that document's roots or folder. */ | { kind: 'document-resource'; documentPath: string } + /** An image a chat transcript or composer shows: any local image file, never a network share. */ + | { kind: 'chat-image' } From d925b8618fd93f2abc04d691db4b085f32f54c77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:29:23 -0700 Subject: [PATCH 12/19] fix(fs): tighten automatic image loads and the project-link check Problems found in review: - Two unit tests went red on this branch: the browser-share test still expected reads without a shape, and the rename test's electron mock had no app, which the desktop root check now needs. - The device-name check ran on the raw path, so `NUL.png\.` or `COM1.png\x\..` (reachable from markdown `![](NUL.png%2F.)`) reached the filesystem; a document image whose real target was a device name passed. - Chat images in a project that lives on a Windows network share no longer rendered, though the markdown preview showed them. - Any failed project check (a missing file, a dropped connection) was taken as "this link leads out of the project" and opened as an absolute tab. - Every local read allocated about 2 MiB, even for a tiny image. Fix: - Device names and device-namespace paths are checked on the resolved path and on the real target, for chat images and document resources alike. - A network-share path in an automatic load is read only inside a project root (the user chose that share when adding the project); anywhere else it is still refused by path text before any filesystem call. - Only main's "outside allowed directories" refusal marks a project path as leading out of the project; other errors surface as before. The message now lives in shared code so both sides agree on it. - Reads size their first buffer from fstat and confirm EOF with a 1-byte probe; a file that grows past its reported size is still read in bounded chunks up to the cap. - Fixed the two red tests. --- ...estination-serialized-local-rename.test.ts | 1 + src/main/ipc/automatic-load-path-text.ts | 27 +++--- src/main/ipc/filesystem-auth.ts | 4 +- ...esystem-request-shape-network-path.test.ts | 91 ++++++++++++++++++- src/main/ipc/filesystem-request-shape.ts | 57 ++++++++---- .../filesystem-file-content-inspection.ts | 6 +- .../local-regular-file-read.test.ts | 30 +++++- .../ipc/filesystem/local-regular-file-read.ts | 17 +++- .../browser-artifact-upload.test.ts | 11 ++- .../src/lib/user-opened-local-path.test.ts | 16 +++- .../src/lib/user-opened-local-path.ts | 5 + src/shared/local-file-access.ts | 9 ++ 12 files changed, 230 insertions(+), 44 deletions(-) diff --git a/src/main/destination-serialized-local-rename.test.ts b/src/main/destination-serialized-local-rename.test.ts index 7bdab6a0914a..6a969a6ee402 100644 --- a/src/main/destination-serialized-local-rename.test.ts +++ b/src/main/destination-serialized-local-rename.test.ts @@ -12,6 +12,7 @@ const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoi const handlers = new Map Promise>() vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, ipcMain: { handle: handleMock } })) diff --git a/src/main/ipc/automatic-load-path-text.ts b/src/main/ipc/automatic-load-path-text.ts index b48fd0679af3..744fab6448e0 100644 --- a/src/main/ipc/automatic-load-path-text.ts +++ b/src/main/ipc/automatic-load-path-text.ts @@ -21,20 +21,23 @@ export function isWindowsReservedDeviceName(filePath: string): boolean { return WINDOWS_RESERVED_DEVICE_STEM.test(stem.replace(/ +$/, '')) } +function toBackslashes(filePath: string): string { + return filePath.replace(/\//g, '\\') +} + +/** A Windows device-namespace path (`\\?\`, `\\.\`), which can name devices and shares alike. */ +export function isDeviceNamespacePath(filePath: string): boolean { + return usesWindowsPaths() && /^\\\\[?.]\\/.test(toBackslashes(filePath)) +} + /** - * A network share (`\\host\share`) or device namespace (`\\?\`, `\\.\`) path. WSL paths are UNC in - * form but stay on this machine, so they are not network paths. + * A network share (`\\host\share`). WSL paths are UNC in form but stay on this machine, so they + * are not network paths. */ -export function isNetworkOrDeviceNamespacePath(filePath: string): boolean { - if (!usesWindowsPaths()) { - return false - } - const normalized = filePath.replace(/\//g, '\\') - if (!normalized.startsWith('\\\\')) { +export function isNetworkSharePath(filePath: string): boolean { + if (!usesWindowsPaths() || isDeviceNamespacePath(filePath)) { return false } - if (/^\\\\[?.]\\/.test(normalized)) { - return true - } - return parseWslUncPath(normalized) === null + const normalized = toBackslashes(filePath) + return normalized.startsWith('\\\\') && parseWslUncPath(normalized) === null } diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 2b5cc976212d..e031c6a91e17 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -1,6 +1,7 @@ import { resolve, dirname, basename } from 'node:path' import { realpath } from 'node:fs/promises' import type { Store } from '../persistence' +import { PATH_OUTSIDE_ALLOWED_DIRECTORIES } from '../../shared/local-file-access' import { getAllowedRoots } from './filesystem-allowed-roots' import { isDescendantOrEqual, isENOENT, normalizeExistingPath } from './filesystem-path-containment' import { @@ -15,8 +16,7 @@ export { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cach export { invalidateAuthorizedRootsCacheForRepo } from './registered-worktree-roots-scoped-invalidation' export { isENOENT } from './filesystem-path-containment' -export const PATH_ACCESS_DENIED_MESSAGE = - 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' +export const PATH_ACCESS_DENIED_MESSAGE = `${PATH_OUTSIDE_ALLOWED_DIRECTORIES}. If this blocks a legitimate workflow, please file a GitHub issue.` /** One allowed-root list shared by every check in a single authorization, built on first use. */ type AllowedRootsSnapshot = { get: () => readonly string[] } diff --git a/src/main/ipc/filesystem-request-shape-network-path.test.ts b/src/main/ipc/filesystem-request-shape-network-path.test.ts index fcc371daca78..e74092badaa1 100644 --- a/src/main/ipc/filesystem-request-shape-network-path.test.ts +++ b/src/main/ipc/filesystem-request-shape-network-path.test.ts @@ -19,7 +19,11 @@ vi.mock('node:fs/promises', () => { const record = (name: string) => vi.fn(async (target: unknown) => { fsCalls.push(`${name} ${String(target)}`) - return name === 'realpath' ? target : { isFile: () => true, isDirectory: () => false } + if (name !== 'realpath') { + return { isFile: () => true, isDirectory: () => false } + } + // A project image that is really a link to a device name. + return String(target).endsWith('dev-link.png') ? 'C:\\repo\\CON.png' : target }) return { realpath: record('realpath'), stat: record('stat'), open: record('open') } }) @@ -162,3 +166,88 @@ describe('Windows reserved device names in automatic image loads', () => { ).resolves.toBe('C:\\Users\\me\\notes\\console.png') }) }) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. +const shareProjectStore = { + getRepos: () => [ + { + id: 'repo', + path: '\\\\server\\share\\repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + ], + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) +} as unknown as Store + +describe('automatic image loads and dot segments that resolve to a device name', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + it.each(['C:\\d\\NUL.png\\.', 'C:\\d\\COM1.png\\x\\..', 'C:/d/COM1.jpg/.'])( + 'refuses %s in both shapes without touching it', + async (target) => { + await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).rejects.toThrow( + 'Access denied' + ) + await expect( + resolveLocalFileRequestPath( + target, + { kind: 'document-resource', documentPath: 'C:\\d\\README.md' }, + store + ) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + } + ) + + it('refuses a project image whose real target is a device name', async () => { + await expect( + resolveLocalFileRequestPath( + 'C:\\repo\\dev-link.png', + { kind: 'document-resource', documentPath: 'C:\\repo\\README.md' }, + store + ) + ).rejects.toThrow('Access denied') + }) +}) + +describe('automatic image loads on a network share', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + it('reads a chat or document image inside a project the user added from the share', async () => { + const image = '\\\\server\\share\\repo\\out.png' + + await expect(resolveLocalFileRequestPath(image, CHAT_IMAGE, shareProjectStore)).resolves.toBe( + image + ) + await expect( + resolveLocalFileRequestPath( + image, + { kind: 'document-resource', documentPath: '\\\\server\\share\\repo\\README.md' }, + shareProjectStore + ) + ).resolves.toBe(image) + }) + + it('refuses a share image outside every project without touching the share', async () => { + await expect( + resolveLocalFileRequestPath('\\\\server\\share\\other\\x.png', CHAT_IMAGE, shareProjectStore) + ).rejects.toThrow('Access denied') + await expect( + resolveLocalFileRequestPath( + '\\\\nas\\notes\\x.png', + { kind: 'document-resource', documentPath: '\\\\nas\\notes\\todo.md' }, + shareProjectStore + ) + ).rejects.toThrow('Access denied') + expect(fsCalls.filter((call) => call.includes('other') || call.includes('nas'))).toEqual([]) + }) +}) diff --git a/src/main/ipc/filesystem-request-shape.ts b/src/main/ipc/filesystem-request-shape.ts index 4c976832dd08..79cb8284a3ca 100644 --- a/src/main/ipc/filesystem-request-shape.ts +++ b/src/main/ipc/filesystem-request-shape.ts @@ -11,7 +11,8 @@ import { isDescendantOrEqual } from './filesystem-path-containment' import { PREVIEWABLE_BINARY_MIME_TYPES } from './filesystem/filesystem-file-content-inspection' import { getDefaultFloatingWorkspacePath } from './floating-workspace-directory' import { - isNetworkOrDeviceNamespacePath, + isDeviceNamespacePath, + isNetworkSharePath, isWindowsReservedDeviceName } from './automatic-load-path-text' import { NOT_A_REGULAR_FILE_MESSAGE } from './filesystem/local-regular-file-read' @@ -69,10 +70,16 @@ function isChatImage(filePath: string): boolean { return isPreviewableBinary(filePath) && extname(filePath).toLowerCase() !== '.pdf' } +// Why the resolved path: `NUL.png\.` and `COM1.png\x\..` resolve to a device name. +function isRefusedAutomaticLoadPath(filePath: string): boolean { + return isDeviceNamespacePath(filePath) || isWindowsReservedDeviceName(filePath) +} + /** * An image or PDF a document references: limited to every project root when the document is in - * one, else to the document's own folder. A target outside that scope is refused by its path text - * before any filesystem call; a symlink inside the scope is still resolved by the scope check. + * one, else to the document's own folder. A target outside that scope, or a network share outside + * every project, is refused by its path text before any filesystem call; a symlink inside the scope + * is still resolved by the scope check. */ export async function resolveDocumentResourcePath( targetPath: string, @@ -83,12 +90,14 @@ export async function resolveDocumentResourcePath( typeof targetPath !== 'string' || !isAbsolute(targetPath) || typeof documentPath !== 'string' || - !isAbsolute(documentPath) || - isWindowsReservedDeviceName(targetPath) + !isAbsolute(documentPath) ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } const resolvedTarget = resolve(targetPath) + if (isRefusedAutomaticLoadPath(resolvedTarget)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } if (!isPreviewableBinary(resolvedTarget)) { throw new Error(DOCUMENT_RESOURCE_TYPE_MESSAGE) } @@ -97,7 +106,10 @@ export async function resolveDocumentResourcePath( realTarget = await resolveDesktopAuthorizedPath(resolvedTarget, store) } else { const documentFolder = dirname(resolve(documentPath)) - if (!isDescendantOrEqual(resolvedTarget, documentFolder)) { + if ( + isNetworkSharePath(resolvedTarget) || + !isDescendantOrEqual(resolvedTarget, documentFolder) + ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } realTarget = resolve(await realpath(resolvedTarget)) @@ -105,6 +117,9 @@ export async function resolveDocumentResourcePath( throw new Error(PATH_ACCESS_DENIED_MESSAGE) } } + if (isRefusedAutomaticLoadPath(realTarget)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } // Why the real target too: `logo.png -> .env` would otherwise return text. if (!isPreviewableBinary(realTarget)) { throw new Error(DOCUMENT_RESOURCE_TYPE_MESSAGE) @@ -114,29 +129,33 @@ export async function resolveDocumentResourcePath( /** * An image shown in a chat transcript, whoever's turn named it: any absolute local image file, - * typed by its real target. Network shares and device paths are refused by path text before any - * filesystem call, since transcripts load as they scroll into view. + * typed by its real target. Transcripts load as they scroll into view, so a network share is read + * only inside a project the user added from it; anywhere else its path text, like a device path, + * is refused before any filesystem call. */ -export async function resolveChatImagePath(targetPath: string): Promise { - if ( - typeof targetPath !== 'string' || - !isAbsolute(targetPath) || - isWindowsReservedDeviceName(targetPath) - ) { +export async function resolveChatImagePath(targetPath: string, store: Store): Promise { + if (typeof targetPath !== 'string' || !isAbsolute(targetPath)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } const resolvedTarget = resolve(targetPath) - if (isNetworkOrDeviceNamespacePath(resolvedTarget)) { + if (isRefusedAutomaticLoadPath(resolvedTarget)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } if (!isChatImage(resolvedTarget)) { throw new Error(CHAT_IMAGE_TYPE_MESSAGE) } - // Why the real target too: `shot.png -> ~/.ssh/id_rsa` must not be read as an image. - const realTarget = resolve(await realpath(resolvedTarget)) - if (isNetworkOrDeviceNamespacePath(realTarget) || isWindowsReservedDeviceName(realTarget)) { + // Why the roots check for a share: it refuses by path text first, so an outside share is never contacted. + const realTarget = isNetworkSharePath(resolvedTarget) + ? await resolveDesktopAuthorizedPath(resolvedTarget, store) + : resolve(await realpath(resolvedTarget)) + if (isRefusedAutomaticLoadPath(realTarget)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } + if (isNetworkSharePath(realTarget) && !isNetworkSharePath(resolvedTarget)) { + // Why: a local link may still lead onto a share; that is readable only inside a project. + await resolveDesktopAuthorizedPath(realTarget, store) + } + // Why the real target too: `shot.png -> ~/.ssh/id_rsa` must not be read as an image. if (!isChatImage(realTarget)) { throw new Error(CHAT_IMAGE_TYPE_MESSAGE) } @@ -178,7 +197,7 @@ export async function resolveLocalFileRequestPath( return resolveDocumentResourcePath(targetPath, shape.documentPath, store) } if (shape?.kind === 'chat-image') { - return resolveChatImagePath(targetPath) + return resolveChatImagePath(targetPath, store) } return resolveDesktopAuthorizedPath(targetPath, store) } diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts index ff406bae7eb9..c4f19987b062 100644 --- a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -42,7 +42,7 @@ export async function readLocalFileContent(filePath: string): Promise MAX_TEXT_FILE_SIZE) { throw fileTooLargeError(stats.size, MAX_TEXT_FILE_SIZE) } - const buffer = await readLocalFileBounded(handle, MAX_TEXT_FILE_SIZE) + const buffer = await readLocalFileBounded(handle, MAX_TEXT_FILE_SIZE, stats.size) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } diff --git a/src/main/ipc/filesystem/local-regular-file-read.test.ts b/src/main/ipc/filesystem/local-regular-file-read.test.ts index 0913b8359740..995e40ed09e4 100644 --- a/src/main/ipc/filesystem/local-regular-file-read.test.ts +++ b/src/main/ipc/filesystem/local-regular-file-read.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { readLocalFileContent } from './filesystem-file-content-inspection' import { assertLocalWriteTargetIsRegularFile, @@ -46,6 +46,34 @@ describe('local regular-file reads', () => { } }) + it('sizes the read from fstat, so a small file costs a small buffer', async () => { + const filePath = join(base, 'small.txt') + await writeFile(filePath, 'hello') + const { handle, stats } = await openLocalRegularFile(filePath) + const read = vi.spyOn(handle, 'read') + try { + await expect(readLocalFileBounded(handle, 1024 * 1024, stats.size)).resolves.toEqual( + Buffer.from('hello') + ) + // One read sized past the reported length, then a 1-byte probe confirming EOF. + expect(read.mock.calls.map((call) => call.at(2))).toEqual([6, 1]) + } finally { + await handle.close() + } + }) + + it('keeps reading a file that grew past its reported size, up to the cap', async () => { + const filePath = join(base, 'grew.txt') + await writeFile(filePath, 'a'.repeat(5000)) + const { handle } = await openLocalRegularFile(filePath) + try { + await expect(readLocalFileBounded(handle, 1024 * 1024, 10)).resolves.toHaveLength(5000) + await expect(readLocalFileBounded(handle, 4096, 10)).rejects.toThrow('File too large') + } finally { + await handle.close() + } + }) + // Why: device files are POSIX-only. describe.skipIf(process.platform === 'win32')('non-regular files', () => { it.each(['/dev/zero', '/dev/urandom'])( diff --git a/src/main/ipc/filesystem/local-regular-file-read.ts b/src/main/ipc/filesystem/local-regular-file-read.ts index b4adcacf7fd4..fe95ad5cf210 100644 --- a/src/main/ipc/filesystem/local-regular-file-read.ts +++ b/src/main/ipc/filesystem/local-regular-file-read.ts @@ -33,12 +33,21 @@ export async function openLocalRegularFile( } } -/** Reads from the start of the handle; the cap holds even when a file reports a false size. */ -export async function readLocalFileBounded(handle: FileHandle, limit: number): Promise { +/** + * Reads from the start of the handle, sized from its fstat so a small file costs a small buffer. + * The size is only a hint: a file that grows past it is read on in bounded chunks, and the cap holds + * even when a file reports a false size. + */ +export async function readLocalFileBounded( + handle: FileHandle, + limit: number, + expectedSize = 0 +): Promise { const chunks: Buffer[] = [] let total = 0 + let nextChunkBytes = Math.max(0, Math.min(expectedSize, limit)) + 1 while (true) { - const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, limit + 1 - total)) + const chunk = Buffer.allocUnsafe(Math.min(nextChunkBytes, limit + 1 - total)) const { bytesRead } = await handle.read(chunk, 0, chunk.length, total) if (bytesRead === 0) { return Buffer.concat(chunks, total) @@ -48,6 +57,8 @@ export async function readLocalFileBounded(handle: FileHandle, limit: number): P if (total > limit) { throw fileTooLargeError(total, limit) } + // Why a 1-byte probe after a short read: it confirms EOF without another full-size buffer. + nextChunkBytes = bytesRead < chunk.length ? 1 : READ_CHUNK_BYTES } } diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts index 5490e8de27aa..c525d22a7553 100644 --- a/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts +++ b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts @@ -43,8 +43,15 @@ describe('browser artifact upload', () => { contentType: 'text/html', fileName: 'report.html' }) - expect(stat).toHaveBeenCalledWith({ filePath: '/tmp/report.html' }) - expect(readFile).toHaveBeenCalledWith({ filePath: '/tmp/report.html' }) + // Why user-file: the user opened this page by URL, so it is shared from where it is. + expect(stat).toHaveBeenCalledWith({ + filePath: '/tmp/report.html', + access: { kind: 'user-file' } + }) + expect(readFile).toHaveBeenCalledWith({ + filePath: '/tmp/report.html', + access: { kind: 'user-file' } + }) }) it('rejects oversized and unreadable files before upload', async () => { diff --git a/src/renderer/src/lib/user-opened-local-path.test.ts b/src/renderer/src/lib/user-opened-local-path.test.ts index 1219752d7f04..86541d45fdee 100644 --- a/src/renderer/src/lib/user-opened-local-path.test.ts +++ b/src/renderer/src/lib/user-opened-local-path.test.ts @@ -22,7 +22,11 @@ function answer(containedOk: boolean, userNamedOk: boolean, isDirectory = false) mocks.statRuntimePath.mockImplementation( async (_context: unknown, _path: string, access?: { kind: string }) => { if (access ? !userNamedOk : !containedOk) { - throw new Error(access ? 'ENOENT: no such file' : 'Access denied') + throw new Error( + access + ? 'ENOENT: no such file' + : 'Access denied: path resolves outside allowed directories.' + ) } return { size: 1, isDirectory, mtime: 1 } } @@ -60,6 +64,16 @@ describe('statUserOpenedPath', () => { } ) + it.each(['ENOENT: no such file or directory', 'Remote connection dropped'])( + 'surfaces a project check that fails with %s instead of opening it as named', + async (message) => { + mocks.statRuntimePath.mockRejectedValue(new Error(message)) + + await expect(statUserOpenedPath(project, '/repo/gone.md')).rejects.toThrow(message) + expect(mocks.statRuntimePath).toHaveBeenCalledTimes(1) + } + ) + it('reports the project error when the path is missing everywhere', async () => { answer(false, false) diff --git a/src/renderer/src/lib/user-opened-local-path.ts b/src/renderer/src/lib/user-opened-local-path.ts index 41840ed75977..c6ef7d5632b5 100644 --- a/src/renderer/src/lib/user-opened-local-path.ts +++ b/src/renderer/src/lib/user-opened-local-path.ts @@ -1,4 +1,5 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { isPathOutsideAllowedDirectoriesError } from '../../../shared/local-file-access' import { isRemoteRuntimeFileOperation, statRuntimePath, @@ -36,6 +37,10 @@ export async function statUserOpenedPath( const stat = await statRuntimePath(context, filePath) return { isDirectory: stat.isDirectory, escapesWorktree: false } } catch (containedError) { + // Why only this refusal: a missing file or a dropped connection is not a link out of the project. + if (!isPathOutsideAllowedDirectoriesError(containedError)) { + throw containedError + } let stat: Awaited> try { stat = await statRuntimePath(context, filePath, userNamedFileAccess()) diff --git a/src/shared/local-file-access.ts b/src/shared/local-file-access.ts index 0fc0bfb47f9a..4059e18d9884 100644 --- a/src/shared/local-file-access.ts +++ b/src/shared/local-file-access.ts @@ -9,3 +9,12 @@ export type LocalFileAccess = | { kind: 'document-resource'; documentPath: string } /** An image a chat transcript or composer shows: any local image file, never a network share. */ | { kind: 'chat-image' } + +/** Main's refusal of a request that resolves outside every root it may serve. */ +export const PATH_OUTSIDE_ALLOWED_DIRECTORIES = + 'Access denied: path resolves outside allowed directories' + +// Why match the message: IPC errors cross to the renderer as plain messages, without their class. +export function isPathOutsideAllowedDirectoriesError(error: unknown): boolean { + return error instanceof Error && error.message.includes(PATH_OUTSIDE_ALLOWED_DIRECTORIES) +} From 1fae989bbfcdfbcccd9a0ab3b19b91000457b84f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:42:06 -0700 Subject: [PATCH 13/19] refactor(fs): name file access by its role, not its structure Problem: the static-analysis anti-slop check failed the PR because the new code named the request's file access a "shape" (`shape`, `RequestShape`, `TabShape`), which describes structure rather than the role. Rename the main-process module filesystem-request-shape.ts (and its tests) to local-file-access-resolution.ts, rename the symbols to fileAccess, FileAccessResolution and TabFileAccessFields, and say "file access" or "access kind" in the comments and test names. No behaviour change. --- src/main/ipc/filesystem-mutations.ts | 2 +- .../ipc/filesystem/filesystem-read-handlers.ts | 2 +- .../ipc/filesystem/filesystem-search-handlers.ts | 2 +- .../ipc/filesystem/filesystem-write-handlers.ts | 2 +- ...-file-access-resolution-network-path.test.ts} | 6 +++--- ...t.ts => local-file-access-resolution.test.ts} | 6 +++--- ...-shape.ts => local-file-access-resolution.ts} | 16 ++++++++-------- src/main/ipc/local-log-tail-lifetime.test.ts | 2 +- src/main/ipc/local-log-tail.test.ts | 2 +- src/main/ipc/local-log-tail.ts | 2 +- src/main/ipc/notebook.test.ts | 8 +++++--- src/main/ipc/notebook.ts | 2 +- src/main/window/clipboard-ipc-handlers.ts | 2 +- .../editor/MarkdownPreview.image-access.test.tsx | 2 +- .../editor/editor-save-user-named-tab.test.ts | 2 +- .../editor/useEditorPanelFileContentLoader.ts | 2 +- .../components/editor/useLocalImageSrc.test.ts | 2 +- .../src/components/editor/useLocalImageSrc.ts | 2 +- .../native-chat/NativeChatTranscriptChrome.tsx | 2 +- src/renderer/src/lib/local-file-access.test.ts | 10 +++++----- src/renderer/src/lib/local-file-access.ts | 2 +- .../src/lib/user-opened-local-path.test.ts | 2 +- .../src/runtime/runtime-file-client-types.ts | 2 +- src/shared/local-file-access.ts | 2 +- 24 files changed, 43 insertions(+), 41 deletions(-) rename src/main/ipc/{filesystem-request-shape-network-path.test.ts => local-file-access-resolution-network-path.test.ts} (97%) rename src/main/ipc/{filesystem-request-shape.test.ts => local-file-access-resolution.test.ts} (98%) rename src/main/ipc/{filesystem-request-shape.ts => local-file-access-resolution.ts} (93%) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 753c2fcd5518..bd9dfe206a58 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -3,7 +3,7 @@ import { constants } from 'node:fs' import { copyFile, mkdir, writeFile } from 'node:fs/promises' import { basename, dirname } from 'node:path' import type { Store } from '../persistence' -import { resolveDesktopAuthorizedPath } from './filesystem-request-shape' +import { resolveDesktopAuthorizedPath } from './local-file-access-resolution' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' import { importExternalPathsSsh } from './filesystem-import-ssh' diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 5c14af1673c2..7b51d3a54f4a 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -13,7 +13,7 @@ import type { LocalFileAccess } from '../../../shared/local-file-access' import { resolveDesktopAuthorizedPath, resolveLocalFileRequestPath -} from '../filesystem-request-shape' +} from '../local-file-access-resolution' import { isENOENT } from '../filesystem-path-containment' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index 908ded3d9c36..69034165bcd9 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -25,7 +25,7 @@ import { getSshFilesystemProvider, requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { resolveDesktopAuthorizedPath } from '../filesystem-request-shape' +import { resolveDesktopAuthorizedPath } from '../local-file-access-resolution' import { listQuickOpenFiles } from '../filesystem-list-files' import { isFileNameFilterQueryTooLarge, diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 1e27ff34247d..e1d0256fb9dd 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -9,7 +9,7 @@ import type { LocalFileAccess } from '../../../shared/local-file-access' import { resolveDesktopAuthorizedPath, resolveLocalWriteRequestPath -} from '../filesystem-request-shape' +} from '../local-file-access-resolution' import { isENOENT } from '../filesystem-path-containment' import { registerFilesystemMutationHandlers } from '../filesystem-mutations' import type { FilesystemHandlerContext } from './filesystem-handler-context' diff --git a/src/main/ipc/filesystem-request-shape-network-path.test.ts b/src/main/ipc/local-file-access-resolution-network-path.test.ts similarity index 97% rename from src/main/ipc/filesystem-request-shape-network-path.test.ts rename to src/main/ipc/local-file-access-resolution-network-path.test.ts index e74092badaa1..9fab42f753e1 100644 --- a/src/main/ipc/filesystem-request-shape-network-path.test.ts +++ b/src/main/ipc/local-file-access-resolution-network-path.test.ts @@ -40,7 +40,7 @@ vi.mock('node:fs', async (importOriginal) => { vi.mock('electron', () => ({ app: { getPath: () => 'C:\\Users\\me\\AppData\\Roaming\\Orca' } })) vi.mock('../repo-worktrees', () => ({ listRepoWorktreeGraph: vi.fn(async () => []) })) -import { resolveLocalFileRequestPath } from './filesystem-request-shape' +import { resolveLocalFileRequestPath } from './local-file-access-resolution' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. const store = { @@ -92,7 +92,7 @@ describe('document images on a network share', () => { } ) - it('refuses it with no shape too', async () => { + it('refuses it with no declared access too', async () => { await expect(resolveLocalFileRequestPath(networkTargets[0], undefined, store)).rejects.toThrow( 'Access denied' ) @@ -190,7 +190,7 @@ describe('automatic image loads and dot segments that resolve to a device name', }) it.each(['C:\\d\\NUL.png\\.', 'C:\\d\\COM1.png\\x\\..', 'C:/d/COM1.jpg/.'])( - 'refuses %s in both shapes without touching it', + 'refuses %s for both access kinds without touching it', async (target) => { await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).rejects.toThrow( 'Access denied' diff --git a/src/main/ipc/filesystem-request-shape.test.ts b/src/main/ipc/local-file-access-resolution.test.ts similarity index 98% rename from src/main/ipc/filesystem-request-shape.test.ts rename to src/main/ipc/local-file-access-resolution.test.ts index 3c47c8fc64d9..0c7496c5cbf2 100644 --- a/src/main/ipc/filesystem-request-shape.test.ts +++ b/src/main/ipc/local-file-access-resolution.test.ts @@ -9,7 +9,7 @@ import { resolveDesktopAuthorizedPath, resolveLocalFileRequestPath, resolveLocalWriteRequestPath -} from './filesystem-request-shape' +} from './local-file-access-resolution' import { readLocalFileContent } from './filesystem/filesystem-file-content-inspection' import { assertLocalWriteTargetIsRegularFile, @@ -63,7 +63,7 @@ let outside: string beforeEach(async () => { invalidateAuthorizedRootsCache() - base = await mkdtemp(join(await realpath(tmpdir()), 'orca-request-shape-')) + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-file-access-')) project = join(base, 'project') outside = join(base, 'outside') userData.path = join(base, 'user-data') @@ -123,7 +123,7 @@ describe('user-file requests', () => { }) }) -describe('requests with no shape (roots only)', () => { +describe('requests with no declared access (roots only)', () => { it('refuse a file outside every project, even one a user-file request may read', async () => { const store = makeStore({ repoPaths: [project] }) diff --git a/src/main/ipc/filesystem-request-shape.ts b/src/main/ipc/local-file-access-resolution.ts similarity index 93% rename from src/main/ipc/filesystem-request-shape.ts rename to src/main/ipc/local-file-access-resolution.ts index 79cb8284a3ca..d5e759d9a9b4 100644 --- a/src/main/ipc/filesystem-request-shape.ts +++ b/src/main/ipc/local-file-access-resolution.ts @@ -162,7 +162,7 @@ export async function resolveChatImagePath(targetPath: string, store: Store): Pr return realTarget } -// Why parse: IPC input is untyped, and an unrecognised shape must fall back to roots only. +// Why parse: IPC input is untyped, and an unrecognised access kind must fall back to roots only. function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined { if (typeof access !== 'object' || access === null || !('kind' in access)) { return undefined @@ -183,26 +183,26 @@ function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined { return undefined } -/** Resolves a desktop read/stat request by its declared shape; no shape means roots only. */ +/** Resolves a desktop read/stat request by its declared file access; none means roots only. */ export async function resolveLocalFileRequestPath( targetPath: string, access: unknown, store: Store ): Promise { - const shape = parseLocalFileAccess(access) - if (shape?.kind === 'user-file') { + const fileAccess = parseLocalFileAccess(access) + if (fileAccess?.kind === 'user-file') { return resolveUserNamedLocalPath(targetPath) } - if (shape?.kind === 'document-resource') { - return resolveDocumentResourcePath(targetPath, shape.documentPath, store) + if (fileAccess?.kind === 'document-resource') { + return resolveDocumentResourcePath(targetPath, fileAccess.documentPath, store) } - if (shape?.kind === 'chat-image') { + if (fileAccess?.kind === 'chat-image') { return resolveChatImagePath(targetPath, store) } return resolveDesktopAuthorizedPath(targetPath, store) } -/** Writes accept only the user-file shape: saving the open file the user named. */ +/** Writes accept only user-file access: saving the open file the user named. */ export async function resolveLocalWriteRequestPath( targetPath: string, access: unknown, diff --git a/src/main/ipc/local-log-tail-lifetime.test.ts b/src/main/ipc/local-log-tail-lifetime.test.ts index a2dd78042a9d..7a9a9e2c3505 100644 --- a/src/main/ipc/local-log-tail-lifetime.test.ts +++ b/src/main/ipc/local-log-tail-lifetime.test.ts @@ -13,7 +13,7 @@ vi.mock('electron', () => ({ handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler) } })) -vi.mock('./filesystem-request-shape', () => ({ resolveUserNamedRegularFile: authorize })) +vi.mock('./local-file-access-resolution', () => ({ resolveUserNamedRegularFile: authorize })) import { closeAllLocalLogTailWatchers, getActiveLocalLogTailWatcherCount, diff --git a/src/main/ipc/local-log-tail.test.ts b/src/main/ipc/local-log-tail.test.ts index e0b84376264b..847ea48e4248 100644 --- a/src/main/ipc/local-log-tail.test.ts +++ b/src/main/ipc/local-log-tail.test.ts @@ -18,7 +18,7 @@ vi.mock('electron', () => ({ vi.mock('node:fs', () => ({ watch: watchMock })) -vi.mock('./filesystem-request-shape', () => ({ +vi.mock('./local-file-access-resolution', () => ({ resolveUserNamedRegularFile: resolveAuthorizedPathMock })) diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts index d89d62b256de..7e63d665494d 100644 --- a/src/main/ipc/local-log-tail.ts +++ b/src/main/ipc/local-log-tail.ts @@ -7,7 +7,7 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' -import { resolveUserNamedRegularFile } from './filesystem-request-shape' +import { resolveUserNamedRegularFile } from './local-file-access-resolution' import { abortWhenRendererGone } from './renderer-lifetime-abort' type TailSenderOwner = { diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index 83acf5354cbe..536ba35788f9 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -17,7 +17,7 @@ vi.mock('electron', () => ({ handlers.set(channel, handler) } })) -vi.mock('./filesystem-request-shape', () => ({ +vi.mock('./local-file-access-resolution', () => ({ resolveUserNamedRegularFile: resolveAuthorizedPathMock, resolveDesktopAuthorizedPath: resolveAuthorizedPathMock })) @@ -25,7 +25,7 @@ vi.mock('../notebook/notebook-kernel', () => ({ startNotebookKernel: startNotebo import { registerNotebookHandlers } from './notebook' import type { Store } from '../persistence' -import type * as RequestShape from './filesystem-request-shape' +import type * as FileAccessResolution from './local-file-access-resolution' function fakeKernel() { let onFrame: (frame: KernelFrame) => void = () => {} @@ -113,7 +113,9 @@ describe('notebook IPC', () => { }) it('starts a kernel for a notebook outside every project, and refuses a relative path', async () => { - const actual = await vi.importActual('./filesystem-request-shape') + const actual = await vi.importActual( + './local-file-access-resolution' + ) resolveAuthorizedPathMock.mockImplementation(actual.resolveUserNamedRegularFile) const folder = await mkdtemp(join(await realpath(tmpdir()), 'orca-notebook-')) try { diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index 3721b15f917b..03c8e9133661 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -4,7 +4,7 @@ import type { Store } from '../persistence' import { resolveDesktopAuthorizedPath, resolveUserNamedRegularFile -} from './filesystem-request-shape' +} from './local-file-access-resolution' import { startNotebookKernel, type NotebookKernel } from '../notebook/notebook-kernel' import { createNotebookVenv, diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index e8a5c1c13c6d..7873316ef3d2 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -10,7 +10,7 @@ import { spawn } from 'node:child_process' import { open, stat } from 'node:fs/promises' import type { Store } from '../persistence' import { PATH_ACCESS_DENIED_MESSAGE } from '../ipc/filesystem-auth' -import { resolveDesktopAuthorizedPath } from '../ipc/filesystem-request-shape' +import { resolveDesktopAuthorizedPath } from '../ipc/local-file-access-resolution' import { isENOENT } from '../ipc/filesystem-path-containment' import { assertClipboardTextWriteWithinLimitWithYield, diff --git a/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx index 5689afe6358a..7e815d20f43f 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom // -// Renders the real MarkdownPreview and pins the shape its images are read with: a resource of the +// Renders the real MarkdownPreview and pins the file access its images are read with: a resource of the // document showing them, so main limits them to that document's roots or folder. import { act } from 'react' diff --git a/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts b/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts index 5e1d8c1cfd02..36818ef2d245 100644 --- a/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts +++ b/src/renderer/src/components/editor/editor-save-user-named-tab.test.ts @@ -85,7 +85,7 @@ describe('saving a restored tab', () => { } }) - it('saves a project tab inside its root, with no shape', async () => { + it('saves a project tab inside its root, with no declared access', async () => { const store = createEditorStore() store.getState().openFile({ filePath: '/repo/a.ts', diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index c5406554b153..25c0693f9dc4 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -165,7 +165,7 @@ export function useEditorPanelFileContentLoader({ const access = restoredOpenFile ? editorTabFileAccess(useAppStore.getState(), restoredOpenFile) : undefined - // Why the shape in the key: a contained tab must not share a read made as a user-named one. + // Why the access kind in the key: a contained tab must not share a read made as a user-named one. const key = `${inFlightReadKey(readScope, filePath)}::${access?.kind ?? ''}` const registeredRead = inFlightFileReads.get(key) if ( diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index f1e7fabc79d5..26ebe4de4837 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -419,7 +419,7 @@ describe('loadLocalImageSrc', () => { expect(renders).toEqual([undefined]) }) - it('never shares a cached image across request shapes', () => { + it('never shares a cached image across access kinds', () => { const userFile = getLocalImageCacheKey('/tmp/a.png', undefined, undefined, { kind: 'user-file' }) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 55365775c4e3..2e1224ecb97b 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -37,7 +37,7 @@ export function getLocalImageCacheKey( runtimeContext?.expectedExternalSshTargetId ?? '', runtimeContext?.worktreeId ?? 'unknown-worktree', runtimeContext?.worktreePath ?? '', - // Why: an image read under one shape must never answer a request made under another. + // Why: an image read under one access kind must never answer a request made under another. access?.kind ?? 'roots', access?.kind === 'document-resource' ? access.documentPath : '', absolutePath diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx index 0c637c33d5f3..402c82816a81 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -71,7 +71,7 @@ function transcriptImageIdentity( }` } -// Why one shape for every role: a turn's role says who sent it, not who chose the path, and these +// Why one access kind for every role: a turn's role says who sent it, not who chose the path, and these // load on scroll with no click, so main only serves local image files by their real type. const TRANSCRIPT_IMAGE_ACCESS = chatImageAccess() diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index ac5ba5509a74..8706a902f33e 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -37,9 +37,9 @@ function makeWorktree(overrides: Partial & { id: string; repoId: strin const localWorktreeId = 'repo-local::/Users/me/project' -type TabShape = Parameters[1] +type TabFileAccessFields = Parameters[1] -function accessKind(file: TabShape): string | undefined { +function accessKind(file: TabFileAccessFields): string | undefined { return editorTabFileAccess(useAppStore.getState(), file)?.kind } @@ -60,7 +60,7 @@ describe('editorTabFileAccess', () => { useAppStore.setState(initialState, true) }) - it.each<[string, TabShape, string | undefined]>([ + it.each<[string, TabFileAccessFields, string | undefined]>([ [ 'a floating-workspace tab stored relative to ~', { @@ -150,7 +150,7 @@ describe('editorTabFileAccess', () => { // Why a ratchet: a content-driven reader that adopted user-file would bring back the round-1 leak. // These scans only see the two ways a renderer file can produce user-file today, by name and by -// pattern: building the shape directly, and opening a tab the tab rule reads as user-named. They +// pattern: building user-file access directly, and opening a tab the tab rule reads as user-named. They // cannot see a decision laundered through props or a helper, so they are a tripwire for review, not // a proof; a branded user-named path type is the follow-up that would make it one. const USER_NAMED_ACCESS_IMPORTERS = [ @@ -227,7 +227,7 @@ function rendererFilesMatching(test: (source: string) => boolean): string[] { } describe('user-named file access ratchet', () => { - it('lists every file that builds the user-file shape', () => { + it('lists every file that builds user-file access', () => { expect( rendererFilesMatching((source) => /\buserNamedFileAccess\b|kind: 'user-file'/.test(source)) ).toEqual(USER_NAMED_ACCESS_IMPORTERS) diff --git a/src/renderer/src/lib/local-file-access.ts b/src/renderer/src/lib/local-file-access.ts index d909cc517fa6..7bb6f622766b 100644 --- a/src/renderer/src/lib/local-file-access.ts +++ b/src/renderer/src/lib/local-file-access.ts @@ -38,7 +38,7 @@ type EditorTabAccessFile = Pick< > /** - * The shape a persisted editor tab reads and saves with. A tab the user opened outside its owner's + * The file access a persisted editor tab reads and saves with. A tab the user opened outside its owner's * root (a floating-workspace tab, or one stored with an absolute path) is user-named, so it reads * the same before and after a restart; every other tab stays inside its project root. */ diff --git a/src/renderer/src/lib/user-opened-local-path.test.ts b/src/renderer/src/lib/user-opened-local-path.test.ts index 86541d45fdee..a00a0e37a94b 100644 --- a/src/renderer/src/lib/user-opened-local-path.test.ts +++ b/src/renderer/src/lib/user-opened-local-path.test.ts @@ -17,7 +17,7 @@ const project: RuntimeFileOperationArgs = { worktreePath: '/repo' } -/** Main's answers: a contained (no-shape) stat only sees paths that stay in the project. */ +/** Main's answers: a contained stat (no declared access) only sees paths that stay in the project. */ function answer(containedOk: boolean, userNamedOk: boolean, isDirectory = false): void { mocks.statRuntimePath.mockImplementation( async (_context: unknown, _path: string, access?: { kind: string }) => { diff --git a/src/renderer/src/runtime/runtime-file-client-types.ts b/src/renderer/src/runtime/runtime-file-client-types.ts index 7f66f42b864a..9fe3f342bfc5 100644 --- a/src/renderer/src/runtime/runtime-file-client-types.ts +++ b/src/renderer/src/runtime/runtime-file-client-types.ts @@ -17,7 +17,7 @@ export type RuntimeFileReadArgs = { connectionId?: string expectedExternalSshTargetId?: string includeLocalLogMetadata?: boolean - /** Shape of the local fallback read; remote reads stay root-relative. */ + /** File access of the local fallback read; remote reads stay root-relative. */ access?: LocalFileAccess } diff --git a/src/shared/local-file-access.ts b/src/shared/local-file-access.ts index 4059e18d9884..5c599778b170 100644 --- a/src/shared/local-file-access.ts +++ b/src/shared/local-file-access.ts @@ -1,5 +1,5 @@ /** - * The shape of a desktop file request, which decides what main checks. Absent means the path must + * The kind of file access a desktop request declares, which decides what main checks. Absent means the path must * be inside a project root main recognises. */ export type LocalFileAccess = From d62f1c91356cbd702b78e94a7b74a19ce4623ff9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:49:05 -0700 Subject: [PATCH 14/19] fix(fs): refuse every Windows device-name spelling in automatic image loads Problem: the device-name check split a file name only on '.', so names such as NUL:.png, COM1:.png, NUL:stream.png (an alternate data stream) slipped through, and CONIN$, CONOUT$, CLOCK$, COM0 and LPT0 were not listed. Those reached the filesystem from a document or chat image before being refused. Split on ':' as well, list the missing device names, and test each with Windows path rules and zero filesystem calls. Also cover the case of a local link that leads onto a network share outside every project (refused for chat images), and correct the shared comment on chat-image access. --- src/main/ipc/automatic-load-path-text.ts | 10 ++++++--- ...ile-access-resolution-network-path.test.ts | 21 ++++++++++++++++++- src/shared/local-file-access.ts | 2 +- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/automatic-load-path-text.ts b/src/main/ipc/automatic-load-path-text.ts index 744fab6448e0..2bc92ef3444f 100644 --- a/src/main/ipc/automatic-load-path-text.ts +++ b/src/main/ipc/automatic-load-path-text.ts @@ -7,9 +7,13 @@ function usesWindowsPaths(): boolean { return sep === '\\' } -const WINDOWS_RESERVED_DEVICE_STEM = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i +const WINDOWS_RESERVED_DEVICE_STEM = + /^(?:con|prn|aux|nul|conin\$|conout\$|clock\$|com[0-9¹²³]|lpt[0-9¹²³])$/i -/** `NUL.png`, `com1 .jpg`, `Aux.` name a Windows device, not a file, whatever the extension. */ +/** + * `NUL.png`, `com1 .jpg`, `Aux.`, `NUL:stream.png` name a Windows device, not a file, whatever the + * extension or alternate data stream (`:`). + */ export function isWindowsReservedDeviceName(filePath: string): boolean { if (!usesWindowsPaths()) { return false @@ -17,7 +21,7 @@ export function isWindowsReservedDeviceName(filePath: string): boolean { const stem = basename(filePath) .replace(/[. ]+$/, '') - .split('.')[0] ?? '' + .split(/[.:]/)[0] ?? '' return WINDOWS_RESERVED_DEVICE_STEM.test(stem.replace(/ +$/, '')) } diff --git a/src/main/ipc/local-file-access-resolution-network-path.test.ts b/src/main/ipc/local-file-access-resolution-network-path.test.ts index 9fab42f753e1..145aa83cc95d 100644 --- a/src/main/ipc/local-file-access-resolution-network-path.test.ts +++ b/src/main/ipc/local-file-access-resolution-network-path.test.ts @@ -23,6 +23,10 @@ vi.mock('node:fs/promises', () => { return { isFile: () => true, isDirectory: () => false } } // A project image that is really a link to a device name. + if (String(target).endsWith('share-link.png')) { + // A local image that is really a link onto a network share. + return '\\\\nas\\pics\\shot.png' + } return String(target).endsWith('dev-link.png') ? 'C:\\repo\\CON.png' : target }) return { realpath: record('realpath'), stat: record('stat'), open: record('open') } @@ -136,7 +140,16 @@ describe('Windows reserved device names in automatic image loads', () => { 'C:\\Users\\me\\notes\\com1.jpg', 'C:\\Users\\me\\notes\\Lpt9 .gif', 'C:\\Users\\me\\notes\\aux..png', - 'C:\\Users\\me\\notes\\CON.tar.png' + 'C:\\Users\\me\\notes\\CON.tar.png', + 'C:\\Users\\me\\notes\\NUL:.png', + 'C:\\Users\\me\\notes\\COM1:.png', + 'C:\\Users\\me\\notes\\NUL:stream.png', + 'C:\\Users\\me\\notes\\CONIN$.png', + 'C:\\Users\\me\\notes\\CONOUT$', + 'C:\\Users\\me\\notes\\clock$.jpg', + 'C:\\Users\\me\\notes\\COM0.png', + 'C:\\Users\\me\\notes\\LPT0.png', + 'C:\\Users\\me\\notes\\com¹.png' ] it.each(deviceTargets)('refuses %s as a chat image without touching it', async (target) => { @@ -237,6 +250,12 @@ describe('automatic image loads on a network share', () => { ).resolves.toBe(image) }) + it('refuses a local link that leads onto a share outside every project', async () => { + await expect( + resolveLocalFileRequestPath('C:\\Users\\me\\share-link.png', CHAT_IMAGE, store) + ).rejects.toThrow('Access denied') + }) + it('refuses a share image outside every project without touching the share', async () => { await expect( resolveLocalFileRequestPath('\\\\server\\share\\other\\x.png', CHAT_IMAGE, shareProjectStore) diff --git a/src/shared/local-file-access.ts b/src/shared/local-file-access.ts index 5c599778b170..9bdfd1754f28 100644 --- a/src/shared/local-file-access.ts +++ b/src/shared/local-file-access.ts @@ -7,7 +7,7 @@ export type LocalFileAccess = | { kind: 'user-file' } /** A resource a document's content references (images): limited to that document's roots or folder. */ | { kind: 'document-resource'; documentPath: string } - /** An image a chat transcript or composer shows: any local image file, never a network share. */ + /** An image a chat transcript or composer shows: any local image file; a network share only inside a project. */ | { kind: 'chat-image' } /** Main's refusal of a request that resolves outside every root it may serve. */ From 67bb9ea8b436f16f649ecc1ecd59ae74235694bf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:32:54 -0700 Subject: [PATCH 15/19] fix(editor): let users rename and insert images into files opened outside projects Renaming a file opened outside every project (tab double-click, editor header) and inserting an image into such a markdown document failed with "Access denied", even before a restart: both writes only passed the project-root check. Document resources and chat images were also limited by file type more strictly than users expect. - Add a "document-folder" access kind for writes beside a document the user opened: main allows renaming only that document, to a name inside its own folder, and importing new files only into that folder, checked by path text and again by real path, with Windows device names refused. The renderer sends it only for local user-named, writable tabs (rename, its undo/rollback, image insert); SSH and runtime requests never carry it. - Document resources: drop the image/PDF type allowlist; folder confinement, regular-file reads, the cap and path-text refusals remain. - Chat images: judge only the real target's type, against every previewable image type (AVIF added). --- .../filesystem-document-folder-writes.test.ts | 157 ++++++++++++++++++ src/main/ipc/filesystem-mutations.ts | 38 ++++- .../filesystem-file-content-inspection.ts | 1 + ...ile-access-resolution-network-path.test.ts | 27 ++- .../ipc/local-file-access-resolution.test.ts | 43 +++-- src/main/ipc/local-file-access-resolution.ts | 82 ++++++--- src/preload/api/filesystem-api.ts | 2 + src/preload/api/fs-bridge.ts | 8 +- .../editor/EditorPanelHeaderPath.test.tsx | 3 +- .../editor/editor-header-file-rename.ts | 6 +- ...h-markdown-image-insert-code-block.test.ts | 1 + .../editor/rich-markdown-image-insert.test.ts | 60 ++++++- .../editor/rich-markdown-image-insert.ts | 8 +- .../components/tab-bar/EditorFileTab.test.tsx | 3 +- .../src/components/tab-bar/EditorFileTab.tsx | 5 +- .../lib/execute-open-editor-path-move.test.ts | 70 +++++++- .../src/lib/execute-open-editor-path-move.ts | 17 +- .../src/lib/local-file-access.test.ts | 70 +++++++- src/renderer/src/lib/local-file-access.ts | 27 ++- src/renderer/src/lib/rename-file.ts | 11 +- .../src/runtime/runtime-file-import-client.ts | 12 +- .../runtime/runtime-file-mutation-client.ts | 10 +- src/shared/local-file-access.ts | 2 + 23 files changed, 597 insertions(+), 66 deletions(-) create mode 100644 src/main/ipc/filesystem-document-folder-writes.test.ts diff --git a/src/main/ipc/filesystem-document-folder-writes.test.ts b/src/main/ipc/filesystem-document-folder-writes.test.ts new file mode 100644 index 000000000000..e3ae3a49b073 --- /dev/null +++ b/src/main/ipc/filesystem-document-folder-writes.test.ts @@ -0,0 +1,157 @@ +import { mkdir, mkdtemp, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as RepoWorktrees from '../repo-worktrees' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' + +type Handler = (event: unknown, args: unknown) => Promise + +const { handlers, userData } = vi.hoisted(() => ({ + handlers: new Map(), + userData: { path: '' } +})) + +vi.mock('electron', () => ({ + app: { getPath: () => userData.path }, + ipcMain: { handle: (channel: string, handler: Handler) => handlers.set(channel, handler) } +})) +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } +}) + +import { registerFilesystemMutationHandlers } from './filesystem-mutations' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members; no project is registered. +const NO_PROJECTS = { + getRepos: () => [], + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) +} as unknown as Store + +const documentFolder = (documentPath: string) => ({ kind: 'document-folder', documentPath }) + +async function settles(promise: Promise): Promise<'ok' | 'denied'> { + return promise.then( + () => 'ok', + () => 'denied' + ) +} + +function call(channel: string, args: unknown): Promise { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`no handler for ${channel}`) + } + return handler(null, args) +} + +let base: string +let docFolder: string +let note: string + +beforeEach(async () => { + invalidateAuthorizedRootsCache() + handlers.clear() + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-document-folder-')) + userData.path = join(base, 'user-data') + docFolder = join(base, 'notes') + note = join(docFolder, 'note.md') + await mkdir(join(userData.path, 'floating-workspace'), { recursive: true }) + await mkdir(docFolder) + await writeFile(note, '# note\n') + await writeFile(join(base, 'shot.png'), 'png') + registerFilesystemMutationHandlers(NO_PROJECTS) +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +describe('renaming a document the user opened outside every project', () => { + it('renames it within its own folder', async () => { + const renamed = join(docFolder, 'renamed.md') + + await call('fs:rename', { oldPath: note, newPath: renamed, access: documentFolder(note) }) + + expect(await readdir(docFolder)).toEqual(['renamed.md']) + }) + + it('refuses a move out of its folder, another file, and a request with no access', async () => { + await writeFile(join(docFolder, 'other.md'), 'other') + + expect( + await settles( + call('fs:rename', { + oldPath: note, + newPath: join(base, 'note.md'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect( + await settles( + call('fs:rename', { + oldPath: join(docFolder, 'other.md'), + newPath: join(docFolder, 'moved.md'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect( + await settles(call('fs:rename', { oldPath: note, newPath: join(docFolder, 'plain.md') })) + ).toBe('denied') + expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'other.md']) + }) + + it.skipIf(process.platform === 'win32')( + 'refuses a new name through a linked subfolder that leads out', + async () => { + await mkdir(join(base, 'elsewhere')) + await symlink(join(base, 'elsewhere'), join(docFolder, 'linked')) + + expect( + await settles( + call('fs:rename', { + oldPath: note, + newPath: join(docFolder, 'linked', 'note.md'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect(await readdir(join(base, 'elsewhere'))).toEqual([]) + } + ) +}) + +describe('inserting an image into a document the user opened outside every project', () => { + it('copies the image into the document folder', async () => { + const outcome = await call('fs:importExternalPaths', { + sourcePaths: [join(base, 'shot.png')], + destDir: docFolder, + access: documentFolder(note) + }) + + expect(outcome).toMatchObject({ results: [{ status: 'imported' }] }) + expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'shot.png']) + }) + + it('refuses the parent folder, and any outside folder without access', async () => { + const importInto = (destDir: string, access?: unknown) => + settles( + call('fs:importExternalPaths', { + sourcePaths: [join(base, 'shot.png')], + destDir, + access + }) + ) + + expect(await importInto(base, documentFolder(note))).toBe('denied') + expect(await importInto(docFolder)).toBe('denied') + expect(await readdir(docFolder)).toEqual(['note.md']) + }) +}) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index bd9dfe206a58..eca6cf46a0e8 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -1,9 +1,15 @@ import { app, ipcMain } from 'electron' import { constants } from 'node:fs' import { copyFile, mkdir, writeFile } from 'node:fs/promises' -import { basename, dirname } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import type { Store } from '../persistence' -import { resolveDesktopAuthorizedPath } from './local-file-access-resolution' +import { + documentFolderAccessPath, + resolveDesktopAuthorizedPath, + resolveDocumentFolderPath +} from './local-file-access-resolution' +import { PATH_ACCESS_DENIED_MESSAGE } from './filesystem-auth' +import type { LocalFileAccess } from '../../shared/local-file-access' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' import { importExternalPathsSsh } from './filesystem-import-ssh' @@ -89,7 +95,12 @@ export function registerFilesystemMutationHandlers(store: Store): void { 'fs:rename', async ( _event, - args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation + args: { + oldPath: string + newPath: string + connectionId?: string + access?: LocalFileAccess + } & SshMutationExpectation ): Promise => { assertSshMutationExpectation( args.connectionId, @@ -107,6 +118,19 @@ export function registerFilesystemMutationHandlers(store: Store): void { // target file (potentially elsewhere in the worktree) and leave the // symlink dangling. newPath must also preserve its leaf so we don't // accidentally write into a symlinked destination name. + const documentPath = documentFolderAccessPath(args.access) + if (documentPath !== undefined) { + // Why: renaming the document the user opened works wherever it lives, but only that + // document, and only to a new name inside its own folder. + if (resolve(args.oldPath) !== resolve(documentPath)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + await renameLocalPathSerializedByDestination( + await resolveDocumentFolderPath(args.oldPath, documentPath, { preserveLeaf: true }), + await resolveDocumentFolderPath(args.newPath, documentPath, { preserveLeaf: true }) + ) + return + } const oldPath = await resolveDesktopAuthorizedPath(args.oldPath, store, { preserveSymlink: true }) @@ -159,6 +183,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { destDir: string connectionId?: string ensureDir?: boolean + access?: LocalFileAccess } & SshMutationExpectation ): Promise<{ results: ImportItemResult[] }> => { assertSshMutationExpectation( @@ -184,7 +209,12 @@ export function registerFilesystemMutationHandlers(store: Store): void { // destination is outside allowed roots, the entire import fails. // This only applies to local imports — remote paths are authorized by // the SSH connection boundary (see importExternalPathsSsh). - const resolvedDest = await resolveDesktopAuthorizedPath(args.destDir, store) + // An image inserted into a document the user opened lands in that document's own folder. + const documentPath = documentFolderAccessPath(args.access) + const resolvedDest = + documentPath === undefined + ? await resolveDesktopAuthorizedPath(args.destDir, store) + : await resolveDocumentFolderPath(args.destDir, documentPath) const results: ImportItemResult[] = [] const reservedNames = new Set() diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts index c4f19987b062..49473eb3f3f8 100644 --- a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -14,6 +14,7 @@ export const BINARY_PROBE_BYTES = 8192 export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.png': 'image/png', + '.avif': 'image/avif', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', diff --git a/src/main/ipc/local-file-access-resolution-network-path.test.ts b/src/main/ipc/local-file-access-resolution-network-path.test.ts index 145aa83cc95d..fefcef880df8 100644 --- a/src/main/ipc/local-file-access-resolution-network-path.test.ts +++ b/src/main/ipc/local-file-access-resolution-network-path.test.ts @@ -44,7 +44,10 @@ vi.mock('node:fs', async (importOriginal) => { vi.mock('electron', () => ({ app: { getPath: () => 'C:\\Users\\me\\AppData\\Roaming\\Orca' } })) vi.mock('../repo-worktrees', () => ({ listRepoWorktreeGraph: vi.fn(async () => []) })) -import { resolveLocalFileRequestPath } from './local-file-access-resolution' +import { + resolveDocumentFolderPath, + resolveLocalFileRequestPath +} from './local-file-access-resolution' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. const store = { @@ -173,6 +176,28 @@ describe('Windows reserved device names in automatic image loads', () => { } ) + it.each(deviceTargets)( + 'refuses %s as a new file beside an opened document without touching it', + async (target) => { + await expect( + resolveDocumentFolderPath(target, 'C:\\Users\\me\\notes\\todo.md', { + preserveLeaf: true + }) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + } + ) + + it.each([...networkTargets, 'C:\\Users\\me\\other\\shot.png'])( + 'refuses %s outside an opened document folder without touching it', + async (target) => { + await expect( + resolveDocumentFolderPath(target, 'C:\\Users\\me\\notes\\todo.md') + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + } + ) + it('still reads an ordinary image whose name only starts like a device', async () => { await expect( resolveLocalFileRequestPath('C:\\Users\\me\\notes\\console.png', CHAT_IMAGE, store) diff --git a/src/main/ipc/local-file-access-resolution.test.ts b/src/main/ipc/local-file-access-resolution.test.ts index 0c7496c5cbf2..47239284248c 100644 --- a/src/main/ipc/local-file-access-resolution.test.ts +++ b/src/main/ipc/local-file-access-resolution.test.ts @@ -253,15 +253,15 @@ describe('document-resource requests', () => { await writeFile(join(outside, 'doc-folder', 'sibling.png'), 'png') }) - it('limit a project document to every project root, images and PDFs only', async () => { + it('limit a project document to every project root, whatever the file type', async () => { const store = makeStore({ repoPaths: [project, otherProject] }) const access = documentResource(join(project, 'docs', 'README.md')) const outcome = (path: string) => settles(resolveLocalFileRequestPath(path, access, store)) expect(await outcome(join(project, 'logo.png'))).toBe('ok') expect(await outcome(join(otherProject, 'shared.png'))).toBe('ok') + expect(await outcome(join(project, 'notes.txt'))).toBe('ok') expect(await outcome(join(outside, 'outside.png'))).toBe('denied') - expect(await outcome(join(project, 'notes.txt'))).toBe('denied') }) it('limit a document outside every project to its own folder', async () => { @@ -291,24 +291,23 @@ describe('document-resource requests', () => { ) it.skipIf(process.platform === 'win32')( - 'refuse an image-named link to a text file, in a project or beside the document', + 'read a link of any name in a project or beside the document when its target stays there', async () => { - await writeFile(join(project, '.env'), 'PROJECT_KEY=secret\n') - await symlink(join(project, '.env'), join(project, 'logo-link.png')) - await writeFile(join(outside, 'doc-folder', 'notes.env'), 'API_KEY=secret\n') - await symlink(join(outside, 'doc-folder', 'notes.env'), join(outside, 'doc-folder', 'a.png')) + await writeFile(join(outside, 'doc-folder', 'diagram'), 'png') + await symlink(join(outside, 'doc-folder', 'diagram'), join(outside, 'doc-folder', 'a.png')) + await symlink(join(project, 'notes.txt'), join(project, 'notes-link')) const store = makeStore({ repoPaths: [project] }) const inProject = documentResource(join(project, 'docs', 'README.md')) const besideDoc = documentResource(join(outside, 'doc-folder', 'note.md')) expect( - await settles(resolveLocalFileRequestPath(join(project, 'logo-link.png'), inProject, store)) - ).toBe('denied') + await settles(resolveLocalFileRequestPath(join(project, 'notes-link'), inProject, store)) + ).toBe('ok') expect( await settles( resolveLocalFileRequestPath(join(outside, 'doc-folder', 'a.png'), besideDoc, store) ) - ).toBe('denied') + ).toBe('ok') } ) }) @@ -328,6 +327,30 @@ describe('chat-image requests', () => { }) }) + it.each(['shot.avif', 'shot.bmp', 'shot.ico', 'shot.svg', 'shot.webp'])( + 'read %s', + async (name) => { + await writeFile(join(outside, name), 'image') + expect( + await settles(resolveLocalFileRequestPath(join(outside, name), CHAT_IMAGE, makeStore({}))) + ).toBe('ok') + } + ) + + it.skipIf(process.platform === 'win32')( + 'judge a link by its target: an extensionless link to an image loads', + async () => { + await writeFile(join(outside, 'shot.png'), 'png') + await symlink(join(outside, 'shot.png'), join(outside, 'latest-screenshot')) + + expect( + await settles( + resolveLocalFileRequestPath(join(outside, 'latest-screenshot'), CHAT_IMAGE, makeStore({})) + ) + ).toBe('ok') + } + ) + it.each(['notes.txt', 'missing.png'])('refuse %s', async (name) => { expect( await settles(resolveLocalFileRequestPath(join(outside, name), CHAT_IMAGE, makeStore({}))) diff --git a/src/main/ipc/local-file-access-resolution.ts b/src/main/ipc/local-file-access-resolution.ts index d5e759d9a9b4..cf702a45f7fe 100644 --- a/src/main/ipc/local-file-access-resolution.ts +++ b/src/main/ipc/local-file-access-resolution.ts @@ -1,4 +1,4 @@ -import { dirname, extname, isAbsolute, resolve } from 'node:path' +import { basename, dirname, extname, isAbsolute, resolve } from 'node:path' import { realpath, stat } from 'node:fs/promises' import type { Store } from '../persistence' import type { LocalFileAccess } from '../../shared/local-file-access' @@ -19,8 +19,6 @@ import { NOT_A_REGULAR_FILE_MESSAGE } from './filesystem/local-regular-file-read export const USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE = 'Access denied: a file opened by name needs an absolute path.' -export const DOCUMENT_RESOURCE_TYPE_MESSAGE = - 'Access denied: a document can only load images and PDFs it references.' export const CHAT_IMAGE_TYPE_MESSAGE = 'Access denied: a chat can only show local image files.' /** Desktop IPC's root check: the project roots plus the app-owned floating-workspace folder. */ @@ -62,12 +60,10 @@ async function isInsideDesktopRoots(documentPath: string, store: Store): Promise } } -function isPreviewableBinary(filePath: string): boolean { - return Boolean(PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()]) -} - +// Why every previewable type but PDF: chat shows these in an , which renders no PDF. function isChatImage(filePath: string): boolean { - return isPreviewableBinary(filePath) && extname(filePath).toLowerCase() !== '.pdf' + const extension = extname(filePath).toLowerCase() + return Boolean(PREVIEWABLE_BINARY_MIME_TYPES[extension]) && extension !== '.pdf' } // Why the resolved path: `NUL.png\.` and `COM1.png\x\..` resolve to a device name. @@ -76,10 +72,10 @@ function isRefusedAutomaticLoadPath(filePath: string): boolean { } /** - * An image or PDF a document references: limited to every project root when the document is in - * one, else to the document's own folder. A target outside that scope, or a network share outside - * every project, is refused by its path text before any filesystem call; a symlink inside the scope - * is still resolved by the scope check. + * A file a document references (an image, typically): limited to every project root when the + * document is in one, else to the document's own folder, like the common markdown-preview rule. + * A target outside that scope, or a network share outside every project, is refused by its path + * text before any filesystem call; a symlink inside the scope is still resolved by the scope check. */ export async function resolveDocumentResourcePath( targetPath: string, @@ -98,9 +94,6 @@ export async function resolveDocumentResourcePath( if (isRefusedAutomaticLoadPath(resolvedTarget)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - if (!isPreviewableBinary(resolvedTarget)) { - throw new Error(DOCUMENT_RESOURCE_TYPE_MESSAGE) - } let realTarget: string if (await isInsideDesktopRoots(documentPath, store)) { realTarget = await resolveDesktopAuthorizedPath(resolvedTarget, store) @@ -120,10 +113,6 @@ export async function resolveDocumentResourcePath( if (isRefusedAutomaticLoadPath(realTarget)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - // Why the real target too: `logo.png -> .env` would otherwise return text. - if (!isPreviewableBinary(realTarget)) { - throw new Error(DOCUMENT_RESOURCE_TYPE_MESSAGE) - } return realTarget } @@ -141,9 +130,6 @@ export async function resolveChatImagePath(targetPath: string, store: Store): Pr if (isRefusedAutomaticLoadPath(resolvedTarget)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - if (!isChatImage(resolvedTarget)) { - throw new Error(CHAT_IMAGE_TYPE_MESSAGE) - } // Why the roots check for a share: it refuses by path text first, so an outside share is never contacted. const realTarget = isNetworkSharePath(resolvedTarget) ? await resolveDesktopAuthorizedPath(resolvedTarget, store) @@ -155,13 +141,56 @@ export async function resolveChatImagePath(targetPath: string, store: Store): Pr // Why: a local link may still lead onto a share; that is readable only inside a project. await resolveDesktopAuthorizedPath(realTarget, store) } - // Why the real target too: `shot.png -> ~/.ssh/id_rsa` must not be read as an image. + // Why the real target's type: `shot.png -> ~/.ssh/id_rsa` must not be read as an image. if (!isChatImage(realTarget)) { throw new Error(CHAT_IMAGE_TYPE_MESSAGE) } return realTarget } +/** + * A write beside a document the user opened: the target must stay inside the document's own + * folder, symlinks included. With `preserveLeaf`, a rename acts on the named entry, not its target. + */ +export async function resolveDocumentFolderPath( + targetPath: string, + documentPath: string, + { preserveLeaf = false }: { preserveLeaf?: boolean } = {} +): Promise { + if ( + typeof targetPath !== 'string' || + !isAbsolute(targetPath) || + typeof documentPath !== 'string' || + !isAbsolute(documentPath) + ) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const resolvedTarget = resolve(targetPath) + const documentFolder = dirname(resolve(documentPath)) + if ( + isRefusedAutomaticLoadPath(resolvedTarget) || + !isDescendantOrEqual(resolvedTarget, documentFolder) + ) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const realTarget = preserveLeaf + ? resolve(await realpath(dirname(resolvedTarget)), basename(resolvedTarget)) + : resolve(await realpath(resolvedTarget)) + if ( + isRefusedAutomaticLoadPath(realTarget) || + !isDescendantOrEqual(realTarget, resolve(await realpath(documentFolder))) + ) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + return realTarget +} + +/** The document a write is scoped to, when the request declares one. */ +export function documentFolderAccessPath(access: unknown): string | undefined { + const fileAccess = parseLocalFileAccess(access) + return fileAccess?.kind === 'document-folder' ? fileAccess.documentPath : undefined +} + // Why parse: IPC input is untyped, and an unrecognised access kind must fall back to roots only. function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined { if (typeof access !== 'object' || access === null || !('kind' in access)) { @@ -173,6 +202,13 @@ function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined { if (access.kind === 'chat-image') { return { kind: 'chat-image' } } + if ( + access.kind === 'document-folder' && + 'documentPath' in access && + typeof access.documentPath === 'string' + ) { + return { kind: 'document-folder', documentPath: access.documentPath } + } if ( access.kind === 'document-resource' && 'documentPath' in access && diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index d3f81a1b51c1..926d54d70131 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -107,6 +107,7 @@ export type FilesystemApi = { oldPath: string newPath: string connectionId?: string + access?: LocalFileAccess } & SshMutationExpectation ) => Promise copy: ( @@ -154,6 +155,7 @@ export type FilesystemApi = { destDir: string connectionId?: string ensureDir?: boolean + access?: LocalFileAccess } & SshMutationExpectation ) => Promise<{ results: ImportItemResult[] }> stageExternalPathsForRuntimeUpload: (args: { diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index be0ce3f7ac91..b0c9adf644f8 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -103,7 +103,12 @@ export const fsApi = { args: { dirPath: string; connectionId?: string } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:createDir', args), rename: ( - args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation + args: { + oldPath: string + newPath: string + connectionId?: string + access?: LocalFileAccess + } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:rename', args), copy: ( args: { @@ -162,6 +167,7 @@ export const fsApi = { destDir: string connectionId?: string ensureDir?: boolean + access?: LocalFileAccess } & SshMutationExpectation ): Promise<{ results: ImportItemResult[] }> => ipcRenderer.invoke('fs:importExternalPaths', args), stageExternalPathsForRuntimeUpload: (args: { diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx index 14fd66b266d3..04ca2e7264d7 100644 --- a/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx @@ -134,7 +134,8 @@ describe('EditorPanelHeaderPath inline rename', () => { oldPath: '/repo/notes.md', newName: 'renamed.mdx', worktreeId: 'wt-1', - worktreePath: '/repo' + worktreePath: '/repo', + documentScoped: false }) }) diff --git a/src/renderer/src/components/editor/editor-header-file-rename.ts b/src/renderer/src/components/editor/editor-header-file-rename.ts index 5dc67d803588..e5df74ac1419 100644 --- a/src/renderer/src/components/editor/editor-header-file-rename.ts +++ b/src/renderer/src/components/editor/editor-header-file-rename.ts @@ -5,6 +5,8 @@ import { useWorktreeById } from '@/store/selectors' import { basename } from '@/lib/path' import { renameFileOnDisk } from '@/lib/rename-file' import { getUntitledFileRoot } from './untitled-file-rename-path' +import { useAppStore } from '@/store' +import { editorTabDocumentFolderAccess } from '@/lib/local-file-access' type EditorHeaderFileRenameState = { canRename: boolean @@ -75,7 +77,9 @@ export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFil oldPath: activeFile.filePath, newName, worktreeId: activeFile.worktreeId, - worktreePath + worktreePath, + documentScoped: + editorTabDocumentFolderAccess(useAppStore.getState(), activeFile) !== undefined }) } diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts b/src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts index a83de8559f83..a128352beb07 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts @@ -23,6 +23,7 @@ vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn(() => ({ settings: { activeRuntimeEnvironmentId: null }, + openFiles: [], folderWorkspaces: [], worktreesByRepo: { repo1: [{ id: 'wt-1', path: '/repo' }] } })) diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts index d9fba2941244..4cda731ed903 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts @@ -7,13 +7,15 @@ import { insertRichMarkdownImageFromPath } from './rich-markdown-image-insert' import { createRichMarkdownExtensions } from './rich-markdown-extensions' import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { getConnectionIdForFile } from '@/lib/connection-context' vi.mock('@/runtime/runtime-file-client', () => ({ importExternalPathsToRuntime: vi.fn() })) vi.mock('@/lib/connection-context', () => ({ - getConnectionId: vi.fn(() => null) + getConnectionId: vi.fn(() => null), + getConnectionIdForFile: vi.fn(() => null) })) vi.mock('@/store', () => ({ @@ -56,17 +58,24 @@ function editorWithRunResult(runResult: boolean, markdown = 'hello world') { return { editor: { chain, schema, state }, chain, focus, insertContentAt, run } } +async function stubStoreState(state: Record): Promise { + const { useAppStore } = await import('@/store') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the insert path reads only these store members. + vi.mocked(useAppStore.getState).mockReturnValue(state as never) +} + describe('insertRichMarkdownImageFromPath', () => { beforeEach(async () => { vi.clearAllMocks() - const { useAppStore } = await import('@/store') - vi.mocked(useAppStore.getState).mockReturnValue({ + vi.mocked(getConnectionIdForFile).mockReturnValue(null) + await stubStoreState({ settings: { activeRuntimeEnvironmentId: null }, + openFiles: [], folderWorkspaces: [], worktreesByRepo: { repo1: [{ id: 'wt-1', path: '/repo' }] } - } as never) + }) vi.mocked(importExternalPathsToRuntime).mockResolvedValue({ results: [{ status: 'imported', destPath: '/repo/image.png' }] } as never) @@ -93,12 +102,12 @@ describe('insertRichMarkdownImageFromPath', () => { }) it('uses folder workspace paths for runtime-owned imports', async () => { - const { useAppStore } = await import('@/store') - vi.mocked(useAppStore.getState).mockReturnValue({ + await stubStoreState({ settings: { activeRuntimeEnvironmentId: 'env-1' }, + openFiles: [], folderWorkspaces: [{ id: 'folder-1', folderPath: '/folder-workspace' }], worktreesByRepo: {} - } as never) + }) const { editor } = editorWithRunResult(true) await insertRichMarkdownImageFromPath({ @@ -116,7 +125,8 @@ describe('insertRichMarkdownImageFromPath', () => { worktreePath: '/folder-workspace' }), ['/tmp/image.png'], - '/folder-workspace' + '/folder-workspace', + { access: undefined } ) }) @@ -147,6 +157,40 @@ describe('insertRichMarkdownImageFromPath', () => { }) }) + it.each<[string, string, string | null, unknown]>([ + [ + 'a local file opened outside every project', + '/Users/me/notes/note.md', + null, + { kind: 'document-folder', documentPath: '/Users/me/notes/note.md' } + ], + ['a project file', 'note.md', null, undefined], + ['an outside file on an SSH host', '/Users/me/notes/note.md', 'ssh-1', undefined] + ])( + 'declares document-folder access only for %s', + async (_label, relativePath, connectionId, access) => { + const filePath = relativePath === 'note.md' ? '/repo/note.md' : '/Users/me/notes/note.md' + await stubStoreState({ + settings: { activeRuntimeEnvironmentId: null }, + openFiles: [{ filePath, relativePath, worktreeId: 'wt-1' }], + folderWorkspaces: [], + worktreesByRepo: { repo1: [{ id: 'wt-1', path: '/repo' }] } + }) + vi.mocked(getConnectionIdForFile).mockReturnValue(connectionId) + const { editor } = editorWithRunResult(true) + + await insertRichMarkdownImageFromPath({ + editor: editor as never, + filePath, + sourcePath: '/tmp/image.png', + worktreeId: 'wt-1', + insertPos: 4 + }) + + expect(vi.mocked(importExternalPathsToRuntime).mock.calls[0]?.[3]).toEqual({ access }) + } + ) + it('skips editor mutation when the caller rejects the stale target after import', async () => { const { editor, chain } = editorWithRunResult(true) diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.ts index 3c1354a06b61..15ddce9f8553 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.ts @@ -5,6 +5,7 @@ import { getConnectionId } from '@/lib/connection-context' import { useAppStore } from '@/store' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' +import { editorTabDocumentFolderAccess } from '@/lib/local-file-access' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { translate } from '@/i18n/i18n' @@ -68,10 +69,15 @@ export async function insertRichMarkdownImageFromPath({ // Why: image bytes should live beside the note instead of inside markdown; // this keeps rich-mode size checks based on document text, not binary data. + // Why: a document opened outside every project still gets its image beside it. + const openDocument = state.openFiles.find( + (file) => file.filePath === filePath && file.worktreeId === worktreeId + ) const { results } = await importExternalPathsToRuntime( fileContext, [sourcePath], - dirname(filePath) + dirname(filePath), + { access: openDocument ? editorTabDocumentFolderAccess(state, openDocument) : undefined } ) const imported = results.find((result) => result.status === 'imported') if (!imported) { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 8f80a6ce6700..07dd4badb150 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -462,7 +462,8 @@ describe('EditorFileTab rename menu', () => { oldPath: '/repo/untitled-5.md', newName: '日本語.md', worktreeId: 'wt-1', - worktreePath: '/repo' + worktreePath: '/repo', + documentScoped: false }) }) diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index a9ee9eb37cd9..daa3bfa17a1a 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -32,6 +32,7 @@ import { TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripSlotProps } from './use-tab-strip-slot-props' import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { editorTabDocumentFolderAccess } from '@/lib/local-file-access' export default function EditorFileTab({ file, @@ -162,7 +163,9 @@ export default function EditorFileTab({ oldPath: file.filePath, newName, worktreeId: file.worktreeId, - worktreePath + worktreePath, + // Why: a file opened outside every project renames in its own folder, wherever it lives. + documentScoped: editorTabDocumentFolderAccess(useAppStore.getState(), file) !== undefined }) } diff --git a/src/renderer/src/lib/execute-open-editor-path-move.test.ts b/src/renderer/src/lib/execute-open-editor-path-move.test.ts index 76c5bbc1c7ec..3798a305445e 100644 --- a/src/renderer/src/lib/execute-open-editor-path-move.test.ts +++ b/src/renderer/src/lib/execute-open-editor-path-move.test.ts @@ -66,7 +66,12 @@ describe('executeOpenEditorPathMove', () => { worktreePath: '/repo' }) - expect(mocks.renameRuntimePath).toHaveBeenCalledWith(CONTEXT, '/repo/a.md', '/repo/sub/a.md') + expect(mocks.renameRuntimePath).toHaveBeenCalledWith( + CONTEXT, + '/repo/a.md', + '/repo/sub/a.md', + undefined + ) const moved = useAppStore.getState().openFiles[0]! expect(moved.filePath).toBe('/repo/sub/a.md') expect(moved.isDirty).toBe(true) @@ -79,6 +84,24 @@ describe('executeOpenEditorPathMove', () => { expect(__activeEditorPathMoveCountForTests()).toBe(0) }) + it('declares the moved file as the document for a document-scoped rename', async () => { + openDirtyTab() + + await executeOpenEditorPathMove({ + context: CONTEXT, + fromPath: '/repo/a.md', + toPath: '/repo/b.md', + worktreeId: 'wt-1', + worktreePath: '/repo', + documentScoped: true + }) + + expect(mocks.renameRuntimePath).toHaveBeenCalledWith(CONTEXT, '/repo/a.md', '/repo/b.md', { + kind: 'document-folder', + documentPath: '/repo/a.md' + }) + }) + it('resolves the verify gate proactively when no destination watcher event arrives', async () => { const DISK_CONTENT = 'the file as it exists on disk\n' const id = openDirtyTab() @@ -180,12 +203,53 @@ describe('executeOpenEditorPathMove', () => { ).rejects.toThrow(/retarget/) // Forward rename then inverse rename to undo the disk move. - expect(mocks.renameRuntimePath).toHaveBeenCalledWith(CONTEXT, '/repo/a.md', '/repo/sub/a.md') - expect(mocks.renameRuntimePath).toHaveBeenCalledWith(CONTEXT, '/repo/sub/a.md', '/repo/a.md') + expect(mocks.renameRuntimePath).toHaveBeenCalledWith( + CONTEXT, + '/repo/a.md', + '/repo/sub/a.md', + undefined + ) + expect(mocks.renameRuntimePath).toHaveBeenCalledWith( + CONTEXT, + '/repo/sub/a.md', + '/repo/a.md', + undefined + ) // The stranded source session is left intact (not rekeyed). expect(useAppStore.getState().openFiles.map((f) => f.id)).toEqual(before) }) + it('undoes a document-scoped rename with the renamed file as the document', async () => { + openDirtyTab() + useAppStore.getState().openFile( + { + filePath: '/repo/b.md', + relativePath: 'b.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + await expect( + executeOpenEditorPathMove({ + context: CONTEXT, + fromPath: '/repo/a.md', + toPath: '/repo/b.md', + worktreeId: 'wt-1', + worktreePath: '/repo', + documentScoped: true + }) + ).rejects.toThrow(/retarget/) + + expect(mocks.renameRuntimePath).toHaveBeenLastCalledWith(CONTEXT, '/repo/b.md', '/repo/a.md', { + kind: 'document-folder', + documentPath: '/repo/b.md' + }) + }) + it('leaves the store untouched and settles the transaction when the rename fails', async () => { openDirtyTab() const before = useAppStore.getState().openFiles.map((f) => ({ ...f })) diff --git a/src/renderer/src/lib/execute-open-editor-path-move.ts b/src/renderer/src/lib/execute-open-editor-path-move.ts index 09f891942961..7b12ac999c89 100644 --- a/src/renderer/src/lib/execute-open-editor-path-move.ts +++ b/src/renderer/src/lib/execute-open-editor-path-move.ts @@ -15,6 +15,7 @@ import { getExecutionHostIdForWorktree, getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { documentFolderAccess } from '@/lib/local-file-access' let moveOperationCounter = 0 @@ -31,6 +32,8 @@ export async function executeOpenEditorPathMove(args: { toPath: string worktreeId: string worktreePath: string + /** The moved file is a user-named document: main keeps the move inside its own folder. */ + documentScoped?: boolean }): Promise { const { context, fromPath, toPath, worktreeId, worktreePath } = args const operationId = `editor-move-${(moveOperationCounter += 1)}` @@ -73,7 +76,12 @@ export async function executeOpenEditorPathMove(args: { await Promise.all(affected.map((f) => requestEditorSaveQuiesce({ fileId: f.id }))) try { - await renameRuntimePath(context, fromPath, toPath) + await renameRuntimePath( + context, + fromPath, + toPath, + args.documentScoped ? documentFolderAccess(fromPath) : undefined + ) } catch (err) { // Rename never landed — release the source suppression immediately. for (const subOperationId of ownerSubOps) { @@ -107,7 +115,12 @@ export async function executeOpenEditorPathMove(args: { // still-open source session isn't stranded pointing at a vanished path. let rollbackError: unknown try { - await renameRuntimePath(context, toPath, fromPath) + await renameRuntimePath( + context, + toPath, + fromPath, + args.documentScoped ? documentFolderAccess(toPath) : undefined + ) } catch (err) { rollbackError = err } diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index 8706a902f33e..7c8706f9b6e1 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -6,7 +6,7 @@ import type { Worktree } from '../../../shared/worktree/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { useAppStore } from '@/store' -import { editorTabFileAccess } from './local-file-access' +import { editorTabDocumentFolderAccess, editorTabFileAccess } from './local-file-access' const initialState = useAppStore.getInitialState() @@ -148,6 +148,57 @@ describe('editorTabFileAccess', () => { }) }) +describe('editorTabDocumentFolderAccess', () => { + beforeEach(() => { + useAppStore.setState({ + repos: [ + makeRepo({ id: 'repo-local', path: '/Users/me/project' }), + makeRepo({ id: 'repo-ssh', path: '/work/project', connectionId: 'ssh-1' }) + ], + worktreesByRepo: { + 'repo-local': [makeWorktree({ id: localWorktreeId, repoId: 'repo-local' })] + } + }) + }) + + afterEach(() => { + useAppStore.setState(initialState, true) + }) + + it('scopes writes on a local user-named tab to that file and its own folder', () => { + expect( + editorTabDocumentFolderAccess(useAppStore.getState(), { + filePath: '/Users/me/notes.md', + relativePath: 'notes.md', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }) + ).toEqual({ kind: 'document-folder', documentPath: '/Users/me/notes.md' }) + }) + + it.each<[string, TabFileAccessFields]>([ + [ + 'a project tab', + { filePath: '/Users/me/project/a.md', relativePath: 'a.md', worktreeId: localWorktreeId } + ], + [ + 'an absolute tab owned by an SSH workspace', + { filePath: '/work/x.md', relativePath: '/work/x.md', worktreeId: 'repo-ssh::/work/project' } + ], + [ + 'an AI Vault log tab', + { + filePath: '/Users/me/.codex/session.jsonl', + relativePath: '/Users/me/.codex/session.jsonl', + worktreeId: 'repo-ssh::/work/project', + readOnly: true, + liveTail: true + } + ] + ])('grants no folder writes to %s', (_label, file) => { + expect(editorTabDocumentFolderAccess(useAppStore.getState(), file)).toBeUndefined() + }) +}) + // Why a ratchet: a content-driven reader that adopted user-file would bring back the round-1 leak. // These scans only see the two ways a renderer file can produce user-file today, by name and by // pattern: building user-file access directly, and opening a tab the tab rule reads as user-named. They @@ -165,6 +216,15 @@ const USER_NAMED_ACCESS_IMPORTERS = [ // Files whose openFile call can store relativePath === filePath (read and saved as user-named by // the tab rule) or open a floating-workspace tab. The scan matches by value, so a few listed files // only look like it (their relativePath is genuinely relative); review each new entry by hand. +// Files that build write access beside an opened document; each must derive it from the tab rule. +const DOCUMENT_FOLDER_ACCESS_BUILDERS = [ + 'components/editor/editor-header-file-rename.ts', + 'components/editor/rich-markdown-image-insert.ts', + 'components/tab-bar/EditorFileTab.tsx', + 'lib/execute-open-editor-path-move.ts', + 'lib/local-file-access.ts' +] + const USER_NAMED_TAB_OPENERS = [ 'components/browser-pane/navigate/navigate-browser-page-url.ts', 'components/editor/markdown-preview-link-actions.ts', @@ -233,6 +293,14 @@ describe('user-named file access ratchet', () => { ).toEqual(USER_NAMED_ACCESS_IMPORTERS) }) + it('lists every file that builds document-folder write access', () => { + expect( + rendererFilesMatching((source) => + /\b(editorTab)?[dD]ocumentFolderAccess\b|kind: 'document-folder'/.test(source) + ) + ).toEqual(DOCUMENT_FOLDER_ACCESS_BUILDERS) + }) + it('lists every file that can open a tab read as user-named', () => { expect(rendererFilesMatching(opensUserNamedTabs)).toEqual(USER_NAMED_TAB_OPENERS) }) diff --git a/src/renderer/src/lib/local-file-access.ts b/src/renderer/src/lib/local-file-access.ts index 7bb6f622766b..26307a71b88d 100644 --- a/src/renderer/src/lib/local-file-access.ts +++ b/src/renderer/src/lib/local-file-access.ts @@ -21,7 +21,15 @@ export function chatImageAccess(): LocalFileAccess { return CHAT_IMAGE_ACCESS } -/** For an image or PDF a document's content references; main limits it to the document's roots. */ +/** + * For a write beside a document the user opened (renaming it, inserting an image next to it). Main + * keeps it inside that document's own folder. + */ +export function documentFolderAccess(documentPath: string): LocalFileAccess { + return { kind: 'document-folder', documentPath } +} + +/** For a file a document's content references; main limits it to the document's roots or folder. */ export function documentResourceAccess(documentPath: string): LocalFileAccess { return { kind: 'document-resource', documentPath } } @@ -62,3 +70,20 @@ export function editorTabFileAccess( } return USER_FILE_ACCESS } + +/** + * The write access for acting on an open tab's file (rename, image insert): scoped to the file's own + * folder when the tab is user-named, else none, so project tabs keep their project checks. + */ +export function editorTabDocumentFolderAccess( + state: Pick, + file: EditorTabAccessFile +): LocalFileAccess | undefined { + // Why: a read-only tab (an AI Vault log, possibly in an SSH workspace) is never written. + if (file.readOnly === true) { + return undefined + } + return editorTabFileAccess(state, file)?.kind === 'user-file' + ? documentFolderAccess(file.filePath) + : undefined +} diff --git a/src/renderer/src/lib/rename-file.ts b/src/renderer/src/lib/rename-file.ts index 71161349e13c..27b8a7281e07 100644 --- a/src/renderer/src/lib/rename-file.ts +++ b/src/renderer/src/lib/rename-file.ts @@ -30,6 +30,8 @@ type RenameFileArgs = { operationOwner?: FileExplorerOperationOwner /** refresh the parent directory in the explorer tree, if caller tracks one */ refreshDir?: (dirPath: string) => Promise + /** The file is an open user-named document, so the rename stays in its own folder wherever it lives. */ + documentScoped?: boolean } /** @@ -76,7 +78,8 @@ export async function renameFileOnDisk(args: RenameFileArgs): Promise { fromPath: oldPath, toPath: newPath, worktreeId, - worktreePath + worktreePath, + documentScoped: args.documentScoped }) commitFileExplorerOp({ undo: async () => { @@ -86,7 +89,8 @@ export async function renameFileOnDisk(args: RenameFileArgs): Promise { fromPath: newPath, toPath: oldPath, worktreeId, - worktreePath + worktreePath, + documentScoped: args.documentScoped }) if (refreshDir) { await refreshDir(parentDir) @@ -99,7 +103,8 @@ export async function renameFileOnDisk(args: RenameFileArgs): Promise { fromPath: oldPath, toPath: newPath, worktreeId, - worktreePath + worktreePath, + documentScoped: args.documentScoped }) if (refreshDir) { await refreshDir(parentDir) diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 2aebfbcfa8c8..7bb142cc33aa 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -21,12 +21,19 @@ import { } from './runtime-file-upload-client' import { getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import { localAccess } from './runtime-file-read-client' export async function importExternalPathsToRuntime( context: RuntimeFileOperationArgs, sourcePaths: string[], destinationDir: string, - options?: { ensureDestinationDir?: boolean; assertCurrent?: () => void } + options?: { + ensureDestinationDir?: boolean + assertCurrent?: () => void + /** Local imports only; remote destinations stay root-relative. */ + access?: LocalFileAccess + } ): Promise<{ results: ImportItemResult[] }> { const target = getActiveRuntimeTarget(context.settings) if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { @@ -35,7 +42,8 @@ export async function importExternalPathsToRuntime( sourcePaths, destDir: destinationDir, connectionId: context.connectionId, - ensureDir: options?.ensureDestinationDir + ensureDir: options?.ensureDestinationDir, + ...localAccess(context.connectionId, options?.access) }) ) } diff --git a/src/renderer/src/runtime/runtime-file-mutation-client.ts b/src/renderer/src/runtime/runtime-file-mutation-client.ts index 0e270bdfc198..dbb0c55f7b96 100644 --- a/src/renderer/src/runtime/runtime-file-mutation-client.ts +++ b/src/renderer/src/runtime/runtime-file-mutation-client.ts @@ -96,14 +96,20 @@ export async function createRuntimePath( export async function renameRuntimePath( context: RuntimeFileOperationArgs, oldPath: string, - newPath: string + newPath: string, + access?: LocalFileAccess ): Promise { const oldRemoteArgs = getRemoteFileArgs(context, oldPath) const newRelativePath = getRelativePathInsideWorktree(context.worktreePath, newPath) if (!oldRemoteArgs || newRelativePath === null) { assertLocalFilesystemFallbackAllowed(context) await window.api.fs.rename( - withSshMutationExpectation(context, { oldPath, newPath, connectionId: context.connectionId }) + withSshMutationExpectation(context, { + oldPath, + newPath, + connectionId: context.connectionId, + ...localAccess(context.connectionId, access) + }) ) return } diff --git a/src/shared/local-file-access.ts b/src/shared/local-file-access.ts index 9bdfd1754f28..f60921334ba0 100644 --- a/src/shared/local-file-access.ts +++ b/src/shared/local-file-access.ts @@ -9,6 +9,8 @@ export type LocalFileAccess = | { kind: 'document-resource'; documentPath: string } /** An image a chat transcript or composer shows: any local image file; a network share only inside a project. */ | { kind: 'chat-image' } + /** A write beside a document the user opened: renaming it, or adding files, inside its own folder. */ + | { kind: 'document-folder'; documentPath: string } /** Main's refusal of a request that resolves outside every root it may serve. */ export const PATH_OUTSIDE_ALLOWED_DIRECTORIES = From d7c34be863d711ff1e198bc146027787cef7fee7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:52:29 -0700 Subject: [PATCH 16/19] fix(fs): a declared file-access kind never refuses what the project check allows A full-path tab for a file inside a project (for example a link that leads out, opened by its absolute path) was renamed under the document-folder rule, which limited the new name to the file's own folder, although the same rename with no declared access could move it anywhere in the project. Any declared kind could be stricter than the default in the same way. Every desktop local file request now goes through one resolver, resolveLocalRequestPath: it runs the default project check first (roots, Orca's floating folder, symlink containment, outside-root path text refused before any filesystem call) and only on a refusal applies the declared kind's rule, which adds paths outside projects. Reads, saves, rename source and target, and import destinations all use it, so a new kind gets the rule for free. Automatic loads (document and chat) still refuse Windows device paths and names by text first, even inside a project; a device is never a file to show. The document-resource rule no longer needs its own project branch, and chat images no longer re-run the roots check for shares. --- .../filesystem-document-folder-writes.test.ts | 39 +++- src/main/ipc/filesystem-mutations.ts | 39 ++-- ...ile-access-resolution-network-path.test.ts | 49 +++++- .../ipc/local-file-access-resolution.test.ts | 74 +++++++- src/main/ipc/local-file-access-resolution.ts | 166 ++++++++++++------ 5 files changed, 268 insertions(+), 99 deletions(-) diff --git a/src/main/ipc/filesystem-document-folder-writes.test.ts b/src/main/ipc/filesystem-document-folder-writes.test.ts index e3ae3a49b073..9f1f1b903d8e 100644 --- a/src/main/ipc/filesystem-document-folder-writes.test.ts +++ b/src/main/ipc/filesystem-document-folder-writes.test.ts @@ -24,9 +24,18 @@ vi.mock('../repo-worktrees', async () => { import { registerFilesystemMutationHandlers } from './filesystem-mutations' -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members; no project is registered. -const NO_PROJECTS = { - getRepos: () => [], +let projectPaths: string[] = [] + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. +const STORE = { + getRepos: () => + projectPaths.map((path, index) => ({ + id: `repo-${index}`, + path, + displayName: 'project', + badgeColor: '#000', + addedAt: 0 + })), getProjects: () => [], getProjectGroups: () => [], getFolderWorkspaces: () => [], @@ -65,7 +74,8 @@ beforeEach(async () => { await mkdir(docFolder) await writeFile(note, '# note\n') await writeFile(join(base, 'shot.png'), 'png') - registerFilesystemMutationHandlers(NO_PROJECTS) + projectPaths = [] + registerFilesystemMutationHandlers(STORE) }) afterEach(async () => { @@ -155,3 +165,24 @@ describe('inserting an image into a document the user opened outside every proje expect(await readdir(docFolder)).toEqual(['note.md']) }) }) + +describe('a project file opened by its full path', () => { + it('renames into another folder of the same project, as with no declared access', async () => { + const project = join(base, 'project') + await mkdir(join(project, 'docs'), { recursive: true }) + await mkdir(join(project, 'archive')) + await writeFile(join(project, 'docs', 'plan.md'), '# plan\n') + projectPaths = [project] + invalidateAuthorizedRootsCache() + const plan = join(project, 'docs', 'plan.md') + + await call('fs:rename', { + oldPath: plan, + newPath: join(project, 'archive', 'plan.md'), + access: documentFolder(plan) + }) + + expect(await readdir(join(project, 'archive'))).toEqual(['plan.md']) + expect(await readdir(join(project, 'docs'))).toEqual([]) + }) +}) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index eca6cf46a0e8..74634455d8ff 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -1,14 +1,12 @@ import { app, ipcMain } from 'electron' import { constants } from 'node:fs' import { copyFile, mkdir, writeFile } from 'node:fs/promises' -import { basename, dirname, resolve } from 'node:path' +import { basename, dirname } from 'node:path' import type { Store } from '../persistence' import { - documentFolderAccessPath, resolveDesktopAuthorizedPath, - resolveDocumentFolderPath + resolveLocalRequestPath } from './local-file-access-resolution' -import { PATH_ACCESS_DENIED_MESSAGE } from './filesystem-auth' import type { LocalFileAccess } from '../../shared/local-file-access' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' @@ -118,25 +116,9 @@ export function registerFilesystemMutationHandlers(store: Store): void { // target file (potentially elsewhere in the worktree) and leave the // symlink dangling. newPath must also preserve its leaf so we don't // accidentally write into a symlinked destination name. - const documentPath = documentFolderAccessPath(args.access) - if (documentPath !== undefined) { - // Why: renaming the document the user opened works wherever it lives, but only that - // document, and only to a new name inside its own folder. - if (resolve(args.oldPath) !== resolve(documentPath)) { - throw new Error(PATH_ACCESS_DENIED_MESSAGE) - } - await renameLocalPathSerializedByDestination( - await resolveDocumentFolderPath(args.oldPath, documentPath, { preserveLeaf: true }), - await resolveDocumentFolderPath(args.newPath, documentPath, { preserveLeaf: true }) - ) - return - } - const oldPath = await resolveDesktopAuthorizedPath(args.oldPath, store, { - preserveSymlink: true - }) - const newPath = await resolveDesktopAuthorizedPath(args.newPath, store, { - preserveSymlink: true - }) + // Outside every project, a document the user opened may still be renamed in its own folder. + const oldPath = await resolveLocalRequestPath(args.oldPath, args.access, store, 'rename-from') + const newPath = await resolveLocalRequestPath(args.newPath, args.access, store, 'rename-to') await renameLocalPathSerializedByDestination(oldPath, newPath) } ) @@ -210,11 +192,12 @@ export function registerFilesystemMutationHandlers(store: Store): void { // This only applies to local imports — remote paths are authorized by // the SSH connection boundary (see importExternalPathsSsh). // An image inserted into a document the user opened lands in that document's own folder. - const documentPath = documentFolderAccessPath(args.access) - const resolvedDest = - documentPath === undefined - ? await resolveDesktopAuthorizedPath(args.destDir, store) - : await resolveDocumentFolderPath(args.destDir, documentPath) + const resolvedDest = await resolveLocalRequestPath( + args.destDir, + args.access, + store, + 'import-into' + ) const results: ImportItemResult[] = [] const reservedNames = new Set() diff --git a/src/main/ipc/local-file-access-resolution-network-path.test.ts b/src/main/ipc/local-file-access-resolution-network-path.test.ts index fefcef880df8..e0000c5fc4c7 100644 --- a/src/main/ipc/local-file-access-resolution-network-path.test.ts +++ b/src/main/ipc/local-file-access-resolution-network-path.test.ts @@ -45,8 +45,8 @@ vi.mock('electron', () => ({ app: { getPath: () => 'C:\\Users\\me\\AppData\\Roam vi.mock('../repo-worktrees', () => ({ listRepoWorktreeGraph: vi.fn(async () => []) })) import { - resolveDocumentFolderPath, - resolveLocalFileRequestPath + resolveLocalFileRequestPath, + resolveLocalRequestPath } from './local-file-access-resolution' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. @@ -60,6 +60,8 @@ const store = { getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) } as unknown as Store +const besideTodo = { kind: 'document-folder', documentPath: 'C:\\Users\\me\\notes\\todo.md' } + const networkTargets = [ '\\\\attacker.example\\share\\x.png', '//attacker.example/share/x.png', @@ -179,11 +181,9 @@ describe('Windows reserved device names in automatic image loads', () => { it.each(deviceTargets)( 'refuses %s as a new file beside an opened document without touching it', async (target) => { - await expect( - resolveDocumentFolderPath(target, 'C:\\Users\\me\\notes\\todo.md', { - preserveLeaf: true - }) - ).rejects.toThrow('Access denied') + await expect(resolveLocalRequestPath(target, besideTodo, store, 'rename-to')).rejects.toThrow( + 'Access denied' + ) expect(fsCalls).toEqual([]) } ) @@ -192,7 +192,7 @@ describe('Windows reserved device names in automatic image loads', () => { 'refuses %s outside an opened document folder without touching it', async (target) => { await expect( - resolveDocumentFolderPath(target, 'C:\\Users\\me\\notes\\todo.md') + resolveLocalRequestPath(target, besideTodo, store, 'import-into') ).rejects.toThrow('Access denied') expect(fsCalls).toEqual([]) } @@ -244,6 +244,13 @@ describe('automatic image loads and dot segments that resolve to a device name', } ) + it('refuses a device name inside a project for automatic loads without touching it', async () => { + await expect( + resolveLocalFileRequestPath('C:\\repo\\NUL.png', CHAT_IMAGE, store) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + }) + it('refuses a project image whose real target is a device name', async () => { await expect( resolveLocalFileRequestPath( @@ -255,6 +262,32 @@ describe('automatic image loads and dot segments that resolve to a device name', }) }) +describe('the default check runs first for every declared kind', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + it.each([ + ['user-file', 'read', { kind: 'user-file' }], + [ + 'document-resource', + 'read', + { kind: 'document-resource', documentPath: 'C:\\repo\\README.md' } + ], + ['chat-image', 'read', CHAT_IMAGE], + ['document-folder', 'rename-to', besideTodo], + ['document-folder', 'import-into', besideTodo] + ] as const)( + 'never touches a share outside every project while resolving %s %s', + async (_kind, operation, access) => { + for (const target of networkTargets) { + await resolveLocalRequestPath(target, access, store, operation).catch(() => undefined) + } + expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) + } + ) +}) + describe('automatic image loads on a network share', () => { beforeEach(() => { fsCalls.length = 0 diff --git a/src/main/ipc/local-file-access-resolution.test.ts b/src/main/ipc/local-file-access-resolution.test.ts index 47239284248c..53735470f1fa 100644 --- a/src/main/ipc/local-file-access-resolution.test.ts +++ b/src/main/ipc/local-file-access-resolution.test.ts @@ -8,7 +8,9 @@ import { resolveAuthorizedPath } from './filesystem-auth' import { resolveDesktopAuthorizedPath, resolveLocalFileRequestPath, - resolveLocalWriteRequestPath + resolveLocalRequestPath, + resolveLocalWriteRequestPath, + type LocalRequestOperation } from './local-file-access-resolution' import { readLocalFileContent } from './filesystem/filesystem-file-content-inspection' import { @@ -123,6 +125,76 @@ describe('user-file requests', () => { }) }) +describe('a declared kind never refuses what the default project check allows', () => { + const outsideDocument = () => join(outside, 'doc-folder', 'note.md') + const declaredKinds = (): [string, unknown, LocalRequestOperation][] => [ + ['user-file read', USER_FILE, 'read'], + ['user-file write', USER_FILE, 'write'], + ['document-resource read', documentResource(outsideDocument()), 'read'], + ['chat-image read', { kind: 'chat-image' }, 'read'], + [ + 'document-folder rename-from', + { kind: 'document-folder', documentPath: outsideDocument() }, + 'rename-from' + ], + [ + 'document-folder rename-to', + { kind: 'document-folder', documentPath: outsideDocument() }, + 'rename-to' + ], + [ + 'document-folder import-into', + { kind: 'document-folder', documentPath: outsideDocument() }, + 'import-into' + ] + ] + + beforeEach(async () => { + await mkdir(join(project, 'src'), { recursive: true }) + await mkdir(join(outside, 'doc-folder'), { recursive: true }) + await writeFile(join(project, 'src', 'notes.txt'), 'text') + }) + + it('accepts every in-project path the default accepts, for every kind and operation', async () => { + const store = makeStore({ repoPaths: [project] }) + const targets = [ + join(project, 'src', 'notes.txt'), + join(project, 'src'), + join(project, 'src', 'new-name.txt'), + join(userData.path, 'floating-workspace', 'scratch.md') + ] + for (const [label, access, operation] of declaredKinds()) { + for (const target of targets) { + const byDefault = await resolveLocalRequestPath(target, undefined, store, operation) + await expect( + resolveLocalRequestPath(target, access, store, operation), + `${label} ${target}` + ).resolves.toBe(byDefault) + } + } + }) + + it.skipIf(process.platform === 'win32')( + 'accepts an in-project link for every kind exactly as the default does', + async () => { + await symlink(join(project, 'src', 'notes.txt'), join(project, 'notes-link')) + const store = makeStore({ repoPaths: [project] }) + for (const [label, access, operation] of declaredKinds()) { + const byDefault = await resolveLocalRequestPath( + join(project, 'notes-link'), + undefined, + store, + operation + ) + await expect( + resolveLocalRequestPath(join(project, 'notes-link'), access, store, operation), + label + ).resolves.toBe(byDefault) + } + } + ) +}) + describe('requests with no declared access (roots only)', () => { it('refuse a file outside every project, even one a user-file request may read', async () => { const store = makeStore({ repoPaths: [project] }) diff --git a/src/main/ipc/local-file-access-resolution.ts b/src/main/ipc/local-file-access-resolution.ts index cf702a45f7fe..5140b3f7bc20 100644 --- a/src/main/ipc/local-file-access-resolution.ts +++ b/src/main/ipc/local-file-access-resolution.ts @@ -51,15 +51,6 @@ export async function resolveUserNamedRegularFile(targetPath: string): Promise { - try { - await resolveDesktopAuthorizedPath(documentPath, store) - return true - } catch { - return false - } -} - // Why every previewable type but PDF: chat shows these in an , which renders no PDF. function isChatImage(filePath: string): boolean { const extension = extname(filePath).toLowerCase() @@ -72,15 +63,14 @@ function isRefusedAutomaticLoadPath(filePath: string): boolean { } /** - * A file a document references (an image, typically): limited to every project root when the - * document is in one, else to the document's own folder, like the common markdown-preview rule. - * A target outside that scope, or a network share outside every project, is refused by its path - * text before any filesystem call; a symlink inside the scope is still resolved by the scope check. + * A file a document references (an image, typically) beyond what the default check allows: one in + * the document's own folder, like the common markdown-preview rule. A target outside the folder, + * or a network share, is refused by its path text before any filesystem call; a symlink inside the + * folder is still resolved by the folder check. */ -export async function resolveDocumentResourcePath( +async function resolveDocumentResourcePath( targetPath: string, - documentPath: string, - store: Store + documentPath: string ): Promise { if ( typeof targetPath !== 'string' || @@ -94,21 +84,13 @@ export async function resolveDocumentResourcePath( if (isRefusedAutomaticLoadPath(resolvedTarget)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - let realTarget: string - if (await isInsideDesktopRoots(documentPath, store)) { - realTarget = await resolveDesktopAuthorizedPath(resolvedTarget, store) - } else { - const documentFolder = dirname(resolve(documentPath)) - if ( - isNetworkSharePath(resolvedTarget) || - !isDescendantOrEqual(resolvedTarget, documentFolder) - ) { - throw new Error(PATH_ACCESS_DENIED_MESSAGE) - } - realTarget = resolve(await realpath(resolvedTarget)) - if (!isDescendantOrEqual(realTarget, resolve(await realpath(documentFolder)))) { - throw new Error(PATH_ACCESS_DENIED_MESSAGE) - } + const documentFolder = dirname(resolve(documentPath)) + if (isNetworkSharePath(resolvedTarget) || !isDescendantOrEqual(resolvedTarget, documentFolder)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const realTarget = resolve(await realpath(resolvedTarget)) + if (!isDescendantOrEqual(realTarget, resolve(await realpath(documentFolder)))) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) } if (isRefusedAutomaticLoadPath(realTarget)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -119,10 +101,10 @@ export async function resolveDocumentResourcePath( /** * An image shown in a chat transcript, whoever's turn named it: any absolute local image file, * typed by its real target. Transcripts load as they scroll into view, so a network share is read - * only inside a project the user added from it; anywhere else its path text, like a device path, - * is refused before any filesystem call. + * only inside a project the user added from it (the default check); anywhere else its path text, + * like a device path, is refused before any filesystem call. */ -export async function resolveChatImagePath(targetPath: string, store: Store): Promise { +async function resolveChatImagePath(targetPath: string, store: Store): Promise { if (typeof targetPath !== 'string' || !isAbsolute(targetPath)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } @@ -130,10 +112,10 @@ export async function resolveChatImagePath(targetPath: string, store: Store): Pr if (isRefusedAutomaticLoadPath(resolvedTarget)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - // Why the roots check for a share: it refuses by path text first, so an outside share is never contacted. - const realTarget = isNetworkSharePath(resolvedTarget) - ? await resolveDesktopAuthorizedPath(resolvedTarget, store) - : resolve(await realpath(resolvedTarget)) + if (isNetworkSharePath(resolvedTarget)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const realTarget = resolve(await realpath(resolvedTarget)) if (isRefusedAutomaticLoadPath(realTarget)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } @@ -152,7 +134,7 @@ export async function resolveChatImagePath(targetPath: string, store: Store): Pr * A write beside a document the user opened: the target must stay inside the document's own * folder, symlinks included. With `preserveLeaf`, a rename acts on the named entry, not its target. */ -export async function resolveDocumentFolderPath( +async function resolveDocumentFolderPath( targetPath: string, documentPath: string, { preserveLeaf = false }: { preserveLeaf?: boolean } = {} @@ -185,10 +167,15 @@ export async function resolveDocumentFolderPath( return realTarget } -/** The document a write is scoped to, when the request declares one. */ -export function documentFolderAccessPath(access: unknown): string | undefined { - const fileAccess = parseLocalFileAccess(access) - return fileAccess?.kind === 'document-folder' ? fileAccess.documentPath : undefined +// Why only the document itself: a write beside it may rename that file, never a neighbour. +async function resolveDocumentRenameSource( + targetPath: string, + documentPath: string +): Promise { + if (typeof targetPath !== 'string' || resolve(targetPath) !== resolve(documentPath)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + return resolveDocumentFolderPath(targetPath, documentPath, { preserveLeaf: true }) } // Why parse: IPC input is untyped, and an unrecognised access kind must fall back to roots only. @@ -219,32 +206,95 @@ function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined { return undefined } -/** Resolves a desktop read/stat request by its declared file access; none means roots only. */ -export async function resolveLocalFileRequestPath( +/** What a desktop request does with its path; each declared kind adds access to only some. */ +export type LocalRequestOperation = 'read' | 'write' | 'rename-from' | 'rename-to' | 'import-into' + +type KindRule = (targetPath: string) => Promise + +function declaredKindRule( + fileAccess: LocalFileAccess, + operation: LocalRequestOperation, + store: Store +): KindRule | undefined { + switch (fileAccess.kind) { + case 'user-file': + return operation === 'read' || operation === 'write' + ? async (targetPath) => resolveUserNamedLocalPath(targetPath) + : undefined + case 'document-resource': + return operation === 'read' + ? (targetPath) => resolveDocumentResourcePath(targetPath, fileAccess.documentPath) + : undefined + case 'chat-image': + return operation === 'read' + ? (targetPath) => resolveChatImagePath(targetPath, store) + : undefined + case 'document-folder': + if (operation === 'rename-from') { + return (targetPath) => resolveDocumentRenameSource(targetPath, fileAccess.documentPath) + } + if (operation === 'rename-to' || operation === 'import-into') { + return (targetPath) => + resolveDocumentFolderPath(targetPath, fileAccess.documentPath, { + preserveLeaf: operation === 'rename-to' + }) + } + return undefined + } +} + +/** + * The one resolver for desktop local file requests. A declared kind never refuses what the default + * project check allows; its own rule only adds paths outside every project. + */ +export async function resolveLocalRequestPath( targetPath: string, access: unknown, - store: Store + store: Store, + operation: LocalRequestOperation ): Promise { + // Why the leaf is kept: a rename acts on a link itself, never on what it points to. + const options = { preserveSymlink: operation === 'rename-from' || operation === 'rename-to' } const fileAccess = parseLocalFileAccess(access) - if (fileAccess?.kind === 'user-file') { - return resolveUserNamedLocalPath(targetPath) + const kindRule = fileAccess && declaredKindRule(fileAccess, operation, store) + if (!fileAccess || !kindRule) { + return resolveDesktopAuthorizedPath(targetPath, store, options) } - if (fileAccess?.kind === 'document-resource') { - return resolveDocumentResourcePath(targetPath, fileAccess.documentPath, store) + if (typeof targetPath !== 'string') { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - if (fileAccess?.kind === 'chat-image') { - return resolveChatImagePath(targetPath, store) + // Why device text first: a device is never a file to load, even inside a project. + const automaticLoad = fileAccess.kind === 'document-resource' || fileAccess.kind === 'chat-image' + if (automaticLoad && isRefusedAutomaticLoadPath(resolve(targetPath))) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - return resolveDesktopAuthorizedPath(targetPath, store) + // Why the default check refuses an outside path by its text first: no share is contacted here. + const insideRoots = await resolveDesktopAuthorizedPath(targetPath, store, options).catch( + () => undefined + ) + if (insideRoots === undefined) { + return kindRule(targetPath) + } + if (automaticLoad && isRefusedAutomaticLoadPath(insideRoots)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + return insideRoots +} + +/** A desktop read/stat request; no declared access means roots only. */ +export function resolveLocalFileRequestPath( + targetPath: string, + access: unknown, + store: Store +): Promise { + return resolveLocalRequestPath(targetPath, access, store, 'read') } -/** Writes accept only user-file access: saving the open file the user named. */ -export async function resolveLocalWriteRequestPath( +/** A desktop save; user-file access adds the open file the user named. */ +export function resolveLocalWriteRequestPath( targetPath: string, access: unknown, store: Store ): Promise { - return parseLocalFileAccess(access)?.kind === 'user-file' - ? resolveUserNamedLocalPath(targetPath) - : resolveDesktopAuthorizedPath(targetPath, store) + return resolveLocalRequestPath(targetPath, access, store, 'write') } From be3f9bb5e00367de636a0dc21221d413bf0cf1f1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:26:24 -0700 Subject: [PATCH 17/19] fix(fs): symmetric outside-project renames, notebook real folder, same-share images - Renaming a file opened outside every project accepted a name in a subfolder (`archive/todo.md`), but the Undo and the rollback rename, declared from the moved file, were then refused and the file stayed moved. A rename under document-folder access must now land directly in the document's own folder (checked by path text before any filesystem call), so rename, Undo and rollback are symmetric. Image import still accepts the folder or a folder under it. Inside projects the default check still allows any in-project target. - A notebook opened through a link inside a project started its kernel in the link's folder instead of the real file's folder (main's behaviour), because notebook and AI Vault log-tail paths skipped the project check. Both now resolve through resolveLocalRequestPath (project check first, then the user-file rule). - A markdown file opened from a Windows share outside every project could not show the images beside it. Document images on a share are now allowed inside the document's own folder; the folder text check refuses every other host and share before any filesystem call. - resolveDesktopAuthorizedPath is async, so a synchronous failure in the default check rejects like any other refusal. --- .../filesystem-document-folder-writes.test.ts | 72 ++++++++++++++++ src/main/ipc/filesystem.ts | 2 +- ...ile-access-resolution-network-path.test.ts | 45 ++++++++-- src/main/ipc/local-file-access-resolution.ts | 51 ++++++++---- src/main/ipc/local-log-tail-lifetime.test.ts | 6 +- src/main/ipc/local-log-tail.test.ts | 8 +- src/main/ipc/local-log-tail.ts | 15 ++-- src/main/ipc/notebook-project-link.test.ts | 82 +++++++++++++++++++ src/main/ipc/notebook.ts | 10 +-- 9 files changed, 252 insertions(+), 39 deletions(-) create mode 100644 src/main/ipc/notebook-project-link.test.ts diff --git a/src/main/ipc/filesystem-document-folder-writes.test.ts b/src/main/ipc/filesystem-document-folder-writes.test.ts index 9f1f1b903d8e..54478ee612fc 100644 --- a/src/main/ipc/filesystem-document-folder-writes.test.ts +++ b/src/main/ipc/filesystem-document-folder-writes.test.ts @@ -118,6 +118,30 @@ describe('renaming a document the user opened outside every project', () => { expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'other.md']) }) + it('undoes a rename with the renamed file declared as the document', async () => { + const renamed = join(docFolder, 'renamed.md') + await call('fs:rename', { oldPath: note, newPath: renamed, access: documentFolder(note) }) + + await call('fs:rename', { oldPath: renamed, newPath: note, access: documentFolder(renamed) }) + + expect(await readdir(docFolder)).toEqual(['note.md']) + }) + + it('refuses a rename into a subfolder, whose Undo could not come back', async () => { + await mkdir(join(docFolder, 'archive')) + + expect( + await settles( + call('fs:rename', { + oldPath: note, + newPath: join(docFolder, 'archive', 'note.md'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect(await readdir(join(docFolder, 'archive'))).toEqual([]) + }) + it.skipIf(process.platform === 'win32')( 'refuses a new name through a linked subfolder that leads out', async () => { @@ -150,6 +174,37 @@ describe('inserting an image into a document the user opened outside every proje expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'shot.png']) }) + it('copies the image into a subfolder of the document folder', async () => { + await mkdir(join(docFolder, 'images')) + + await call('fs:importExternalPaths', { + sourcePaths: [join(base, 'shot.png')], + destDir: join(docFolder, 'images'), + access: documentFolder(note) + }) + + expect(await readdir(join(docFolder, 'images'))).toEqual(['shot.png']) + }) + + it.skipIf(process.platform === 'win32')( + 'refuses an import through a linked subfolder that leads out', + async () => { + await mkdir(join(base, 'elsewhere')) + await symlink(join(base, 'elsewhere'), join(docFolder, 'linked')) + + expect( + await settles( + call('fs:importExternalPaths', { + sourcePaths: [join(base, 'shot.png')], + destDir: join(docFolder, 'linked'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect(await readdir(join(base, 'elsewhere'))).toEqual([]) + } + ) + it('refuses the parent folder, and any outside folder without access', async () => { const importInto = (destDir: string, access?: unknown) => settles( @@ -167,6 +222,23 @@ describe('inserting an image into a document the user opened outside every proje }) describe('a project file opened by its full path', () => { + it('renames into a subfolder of the project, which the project check allows', async () => { + const project = join(base, 'project') + await mkdir(join(project, 'docs', 'old'), { recursive: true }) + await writeFile(join(project, 'docs', 'plan.md'), '# plan\n') + projectPaths = [project] + invalidateAuthorizedRootsCache() + const plan = join(project, 'docs', 'plan.md') + + await call('fs:rename', { + oldPath: plan, + newPath: join(project, 'docs', 'old', 'plan.md'), + access: documentFolder(plan) + }) + + expect(await readdir(join(project, 'docs', 'old'))).toEqual(['plan.md']) + }) + it('renames into another folder of the same project, as with no declared access', async () => { const project = join(base, 'project') await mkdir(join(project, 'docs'), { recursive: true }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 86aca9a7a410..e517a834d5e3 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -44,5 +44,5 @@ export function registerFilesystemHandlers( registerFilesystemGitDiffHandlers(context) registerFilesystemGitIndexHandlers(context) registerFilesystemGitUrlHandlers(context) - registerLocalLogTailHandlers() + registerLocalLogTailHandlers(store) } diff --git a/src/main/ipc/local-file-access-resolution-network-path.test.ts b/src/main/ipc/local-file-access-resolution-network-path.test.ts index e0000c5fc4c7..8ed5a7bf4742 100644 --- a/src/main/ipc/local-file-access-resolution-network-path.test.ts +++ b/src/main/ipc/local-file-access-resolution-network-path.test.ts @@ -198,6 +198,18 @@ describe('Windows reserved device names in automatic image loads', () => { } ) + it('refuses a rename into a subfolder of an opened document folder without touching it', async () => { + await expect( + resolveLocalRequestPath( + 'C:\\Users\\me\\notes\\archive\\todo.md', + besideTodo, + store, + 'rename-to' + ) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + }) + it('still reads an ordinary image whose name only starts like a device', async () => { await expect( resolveLocalFileRequestPath('C:\\Users\\me\\notes\\console.png', CHAT_IMAGE, store) @@ -288,6 +300,11 @@ describe('the default check runs first for every declared kind', () => { ) }) +const besideShareDocument = { + kind: 'document-resource', + documentPath: '\\\\nas\\notes\\todo.md' +} + describe('automatic image loads on a network share', () => { beforeEach(() => { fsCalls.length = 0 @@ -314,17 +331,31 @@ describe('automatic image loads on a network share', () => { ).rejects.toThrow('Access denied') }) - it('refuses a share image outside every project without touching the share', async () => { + it('refuses a chat share image outside every project without touching the share', async () => { await expect( resolveLocalFileRequestPath('\\\\server\\share\\other\\x.png', CHAT_IMAGE, shareProjectStore) ).rejects.toThrow('Access denied') + expect(fsCalls.filter((call) => call.includes('other'))).toEqual([]) + }) + + it.each(['\\\\nas\\notes\\x.png', '\\\\NAS\\Notes\\img\\y.png', '//nas/notes/z.png'])( + 'reads %s beside a document opened from that same share', + async (target) => { + await expect( + resolveLocalFileRequestPath(target, besideShareDocument, shareProjectStore) + ).resolves.toBeTruthy() + } + ) + + it.each([ + '\\\\nas\\other\\x.png', + '\\\\evil\\notes\\x.png', + '\\\\nas\\notes-evil\\x.png', + '\\\\attacker.example\\share\\x.png' + ])('refuses %s beside a share document without touching any share', async (target) => { await expect( - resolveLocalFileRequestPath( - '\\\\nas\\notes\\x.png', - { kind: 'document-resource', documentPath: '\\\\nas\\notes\\todo.md' }, - shareProjectStore - ) + resolveLocalFileRequestPath(target, besideShareDocument, shareProjectStore) ).rejects.toThrow('Access denied') - expect(fsCalls.filter((call) => call.includes('other') || call.includes('nas'))).toEqual([]) + expect(fsCalls).toEqual([]) }) }) diff --git a/src/main/ipc/local-file-access-resolution.ts b/src/main/ipc/local-file-access-resolution.ts index 5140b3f7bc20..bedeff0b9f91 100644 --- a/src/main/ipc/local-file-access-resolution.ts +++ b/src/main/ipc/local-file-access-resolution.ts @@ -19,10 +19,12 @@ import { NOT_A_REGULAR_FILE_MESSAGE } from './filesystem/local-regular-file-read export const USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE = 'Access denied: a file opened by name needs an absolute path.' +const USER_FILE_ACCESS: LocalFileAccess = { kind: 'user-file' } + export const CHAT_IMAGE_TYPE_MESSAGE = 'Access denied: a chat can only show local image files.' /** Desktop IPC's root check: the project roots plus the app-owned floating-workspace folder. */ -export function resolveDesktopAuthorizedPath( +export async function resolveDesktopAuthorizedPath( targetPath: string, store: Store, options: ResolveAuthorizedPathOptions = {} @@ -42,9 +44,15 @@ export function resolveUserNamedLocalPath(targetPath: string): string { return resolve(targetPath) } -/** A user-named path that must be an existing regular file, e.g. a notebook or a log being tailed. */ -export async function resolveUserNamedRegularFile(targetPath: string): Promise { - const filePath = resolveUserNamedLocalPath(targetPath) +/** + * A user-named path that must be an existing regular file, e.g. a notebook or a log being tailed. + * Inside a project it resolves as the default check does, to the real file. + */ +export async function resolveUserNamedRegularFile( + targetPath: string, + store: Store +): Promise { + const filePath = await resolveLocalRequestPath(targetPath, USER_FILE_ACCESS, store, 'read') if (!(await stat(filePath)).isFile()) { throw new Error(NOT_A_REGULAR_FILE_MESSAGE) } @@ -64,9 +72,9 @@ function isRefusedAutomaticLoadPath(filePath: string): boolean { /** * A file a document references (an image, typically) beyond what the default check allows: one in - * the document's own folder, like the common markdown-preview rule. A target outside the folder, - * or a network share, is refused by its path text before any filesystem call; a symlink inside the - * folder is still resolved by the folder check. + * the document's own folder, like the common markdown-preview rule, on a network share too when the + * document is on it. A target outside the folder is refused by its path text before any + * filesystem call; a symlink inside the folder is still resolved by the folder check. */ async function resolveDocumentResourcePath( targetPath: string, @@ -85,7 +93,9 @@ async function resolveDocumentResourcePath( throw new Error(PATH_ACCESS_DENIED_MESSAGE) } const documentFolder = dirname(resolve(documentPath)) - if (isNetworkSharePath(resolvedTarget) || !isDescendantOrEqual(resolvedTarget, documentFolder)) { + // Why the folder text check comes first: it refuses every host and share but the document's + // own (compared case-insensitively on Windows) before a filesystem call could reach one. + if (!isDescendantOrEqual(resolvedTarget, documentFolder)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } const realTarget = resolve(await realpath(resolvedTarget)) @@ -130,14 +140,19 @@ async function resolveChatImagePath(targetPath: string, store: Store): Promise { if ( typeof targetPath !== 'string' || @@ -151,17 +166,17 @@ async function resolveDocumentFolderPath( const documentFolder = dirname(resolve(documentPath)) if ( isRefusedAutomaticLoadPath(resolvedTarget) || - !isDescendantOrEqual(resolvedTarget, documentFolder) + !isDescendantOrEqual(resolvedTarget, documentFolder) || + // Why no real-path twin: a rename keeps its leaf, so its real parent is the folder's real path. + (rename && !isSameFolder(dirname(resolvedTarget), documentFolder)) ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - const realTarget = preserveLeaf + const realTarget = rename ? resolve(await realpath(dirname(resolvedTarget)), basename(resolvedTarget)) : resolve(await realpath(resolvedTarget)) - if ( - isRefusedAutomaticLoadPath(realTarget) || - !isDescendantOrEqual(realTarget, resolve(await realpath(documentFolder))) - ) { + const realFolder = resolve(await realpath(documentFolder)) + if (isRefusedAutomaticLoadPath(realTarget) || !isDescendantOrEqual(realTarget, realFolder)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } return realTarget @@ -175,7 +190,7 @@ async function resolveDocumentRenameSource( if (typeof targetPath !== 'string' || resolve(targetPath) !== resolve(documentPath)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - return resolveDocumentFolderPath(targetPath, documentPath, { preserveLeaf: true }) + return resolveDocumentFolderPath(targetPath, documentPath, { rename: true }) } // Why parse: IPC input is untyped, and an unrecognised access kind must fall back to roots only. @@ -236,7 +251,7 @@ function declaredKindRule( if (operation === 'rename-to' || operation === 'import-into') { return (targetPath) => resolveDocumentFolderPath(targetPath, fileAccess.documentPath, { - preserveLeaf: operation === 'rename-to' + rename: operation === 'rename-to' }) } return undefined diff --git a/src/main/ipc/local-log-tail-lifetime.test.ts b/src/main/ipc/local-log-tail-lifetime.test.ts index 7a9a9e2c3505..95d320164a8e 100644 --- a/src/main/ipc/local-log-tail-lifetime.test.ts +++ b/src/main/ipc/local-log-tail-lifetime.test.ts @@ -19,6 +19,10 @@ import { getActiveLocalLogTailWatcherCount, registerLocalLogTailHandlers } from './local-log-tail' +import type { Store } from '../persistence' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: path resolution is mocked, so the store is never read. +const NO_STORE = {} as Store class Sender extends EventEmitter { dead = false @@ -41,7 +45,7 @@ beforeEach(async () => { filePath = join(directory, 'fixture.log') await writeFile(filePath, 'test\n') authorize.mockReset().mockResolvedValue(filePath) - registerLocalLogTailHandlers() + registerLocalLogTailHandlers(NO_STORE) }) afterEach(async () => { closeAllLocalLogTailWatchers() diff --git a/src/main/ipc/local-log-tail.test.ts b/src/main/ipc/local-log-tail.test.ts index 847ea48e4248..ce88830304ed 100644 --- a/src/main/ipc/local-log-tail.test.ts +++ b/src/main/ipc/local-log-tail.test.ts @@ -31,6 +31,10 @@ import { getActiveLocalLogTailWatcherCount, registerLocalLogTailHandlers } from './local-log-tail' +import type { Store } from '../persistence' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: path resolution is mocked, so the store is never read. +const NO_STORE = {} as Store type FakeWatcher = { close: ReturnType @@ -66,7 +70,7 @@ beforeEach(() => { watchMock.mockReset() resolveAuthorizedPathMock.mockReset().mockImplementation(async (path: string) => path) readRangeMock.mockReset() - registerLocalLogTailHandlers() + registerLocalLogTailHandlers(NO_STORE) }) afterEach(() => { @@ -89,7 +93,7 @@ describe('local log tail IPC', () => { ) emitChange?.('change') - expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl') + expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl', NO_STORE) expect(watchMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.any(Function)) expect(sender.send).toHaveBeenCalledWith('fs:localLogTailChanged', { subscriptionId: 'tail-1', diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts index 7e63d665494d..85d402910dee 100644 --- a/src/main/ipc/local-log-tail.ts +++ b/src/main/ipc/local-log-tail.ts @@ -7,6 +7,7 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' +import type { Store } from '../persistence' import { resolveUserNamedRegularFile } from './local-file-access-resolution' import { abortWhenRendererGone } from './renderer-lifetime-abort' @@ -94,7 +95,11 @@ function validateSubscriptionId(value: unknown): string { return value } -async function startWatch(sender: WebContents, args: LocalLogTailWatchArgs): Promise { +async function startWatch( + sender: WebContents, + args: LocalLogTailWatchArgs, + store: Store +): Promise { const subscriptionId = validateSubscriptionId(args.subscriptionId) if (sender.isDestroyed()) { return @@ -104,7 +109,7 @@ async function startWatch(sender: WebContents, args: LocalLogTailWatchArgs): Pro const pending = Symbol(subscriptionId) owner.pending.set(key, pending) try { - const filePath = await resolveUserNamedRegularFile(args.filePath) + const filePath = await resolveUserNamedRegularFile(args.filePath, store) if ( sender.isDestroyed() || owner.signal.aborted || @@ -138,17 +143,17 @@ async function startWatch(sender: WebContents, args: LocalLogTailWatchArgs): Pro } } -export function registerLocalLogTailHandlers(): void { +export function registerLocalLogTailHandlers(store: Store): void { ipcMain.handle( 'fs:readLocalLogTail', async (_event, args: LocalLogTailReadArgs): Promise => { - const filePath = await resolveUserNamedRegularFile(args.filePath) + const filePath = await resolveUserNamedRegularFile(args.filePath, store) return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity) } ) ipcMain.handle('fs:startLocalLogTail', (event, args: LocalLogTailWatchArgs): Promise => - startWatch(event.sender, args) + startWatch(event.sender, args, store) ) ipcMain.handle('fs:stopLocalLogTail', (event, args: { subscriptionId: string }): void => { diff --git a/src/main/ipc/notebook-project-link.test.ts b/src/main/ipc/notebook-project-link.test.ts new file mode 100644 index 000000000000..193185d92271 --- /dev/null +++ b/src/main/ipc/notebook-project-link.test.ts @@ -0,0 +1,82 @@ +import { EventEmitter } from 'node:events' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as RepoWorktrees from '../repo-worktrees' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' + +type Handler = (event: unknown, args: unknown) => unknown + +const { handlers, startNotebookKernelMock, userData } = vi.hoisted(() => ({ + handlers: new Map(), + startNotebookKernelMock: vi.fn(), + userData: { path: '' } +})) + +vi.mock('electron', () => ({ + app: { getPath: () => userData.path }, + ipcMain: { handle: (channel: string, handler: Handler) => handlers.set(channel, handler) } +})) +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } +}) +vi.mock('../notebook/notebook-kernel', () => ({ startNotebookKernel: startNotebookKernelMock })) + +import { registerNotebookHandlers } from './notebook' + +let base: string +let project: string + +function storeWithProject(projectPath: string): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. + return { + getRepos: () => [ + { id: 'repo', path: projectPath, displayName: 'project', badgeColor: '#000', addedAt: 0 } + ], + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) + } as unknown as Store +} + +beforeEach(async () => { + invalidateAuthorizedRootsCache() + handlers.clear() + startNotebookKernelMock.mockReset().mockReturnValue({ + kernel: { execute: vi.fn(), interrupt: vi.fn(), shutdown: vi.fn() }, + ready: Promise.resolve({ status: 'ready' }), + exited: new Promise(() => {}) + }) + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-notebook-link-')) + project = join(base, 'project') + userData.path = join(base, 'user-data') + await mkdir(join(project, 'analysis'), { recursive: true }) + await mkdir(join(userData.path, 'floating-workspace'), { recursive: true }) + await writeFile(join(project, 'analysis', 'real.ipynb'), '{}') + registerNotebookHandlers(storeWithProject(project)) +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +it.skipIf(process.platform === 'win32')( + 'runs a notebook opened through a project link in its real folder, as the project check resolves it', + async () => { + await symlink(join(project, 'analysis', 'real.ipynb'), join(project, 'nb.ipynb')) + const owner = Object.assign(new EventEmitter(), { send: vi.fn(), isDestroyed: () => false }) + + await handlers.get('notebook:startKernel')!( + { sender: owner }, + { filePath: join(project, 'nb.ipynb'), python: '/py' } + ) + + expect(startNotebookKernelMock).toHaveBeenCalledWith( + expect.objectContaining({ cwd: join(project, 'analysis') }) + ) + } +) diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index 03c8e9133661..5b9d1856a2ca 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -47,8 +47,8 @@ function kernelsOf(owner: WebContents): Map { return kernels } -// Why the notebook path is user-named, not root-checked: it is an open tab, and it only picks the -// kernel's cwd and the venv folder. +// Why the notebook path is user-named: it is an open tab, and it only picks the kernel's cwd and +// the venv folder. Inside a project it resolves to the real file, so the cwd is its real folder. export function registerNotebookHandlers(store: Store): void { ipcMain.handle( 'notebook:listPythonEnvironments', @@ -56,7 +56,7 @@ export function registerNotebookHandlers(store: Store): void { _event, args: { filePath: string; rootPath: string | null; runWorkspaceInterpreters: boolean } ): Promise => { - await resolveUserNamedRegularFile(args.filePath) + await resolveUserNamedRegularFile(args.filePath, store) // Why the unresolved path: rootPath is in the same (possibly symlinked) form, e.g. /tmp. return listPythonEnvironments(args.filePath, args.rootPath, { runWorkspaceInterpreters: args.runWorkspaceInterpreters === true @@ -73,7 +73,7 @@ export function registerNotebookHandlers(store: Store): void { 'notebook:startKernel', async (event, args: { filePath: string; python: string }): Promise => { // Why: run from the notebook's folder so relative imports and data paths resolve as on disk. - const cwd = dirname(await resolveUserNamedRegularFile(args.filePath)) + const cwd = dirname(await resolveUserNamedRegularFile(args.filePath, store)) const owner = event.sender const kernels = kernelsOf(owner) kernels.get(args.filePath)?.shutdown() @@ -111,7 +111,7 @@ export function registerNotebookHandlers(store: Store): void { _event, args: { filePath: string; rootPath: string | null; python: string } ): Promise => { - await resolveUserNamedRegularFile(args.filePath) + await resolveUserNamedRegularFile(args.filePath, store) if (args.rootPath) { await resolveDesktopAuthorizedPath(args.rootPath, store) } From c2e253d74e380f8f19139368a5e4e0af11c7a9e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:58:59 -0700 Subject: [PATCH 18/19] fix(fs): refuse share images outside projects again; keep renames and kernels as on main - Reverts the same-share document image rule from the previous commit. Its folder check compared hosts case-insensitively, so a host spelled with U+212A KELVIN SIGN (or a decomposed accent) passed as the document's own share and was contacted, reopening the network credential leak. Document and chat images on a share outside every project are again refused by path text before any filesystem call; tests now cover the look-alike hosts with zero filesystem calls. - Renaming a file opened outside every project into a project folder passed the project check, but its Undo (declared from the new path) was refused and the file stayed moved. When the rename source is allowed only as the opened document, the new name must now land directly in the document's folder even if a project would accept it (resolveLocalRenamePaths). - A notebook opened through a link outside every project started its kernel in the link's folder; main used the real file's folder. The kernel cwd is now the real file's folder in every case. --- .../filesystem-document-folder-writes.test.ts | 23 +++++++++ src/main/ipc/filesystem-mutations.ts | 11 +++-- ...ile-access-resolution-network-path.test.ts | 44 +++++++++++++---- src/main/ipc/local-file-access-resolution.ts | 48 +++++++++++++++---- src/main/ipc/notebook-project-link.test.ts | 20 ++++++++ src/main/ipc/notebook.test.ts | 9 ++++ src/main/ipc/notebook.ts | 6 ++- 7 files changed, 138 insertions(+), 23 deletions(-) diff --git a/src/main/ipc/filesystem-document-folder-writes.test.ts b/src/main/ipc/filesystem-document-folder-writes.test.ts index 54478ee612fc..f5dfecb688db 100644 --- a/src/main/ipc/filesystem-document-folder-writes.test.ts +++ b/src/main/ipc/filesystem-document-folder-writes.test.ts @@ -142,6 +142,29 @@ describe('renaming a document the user opened outside every project', () => { expect(await readdir(join(docFolder, 'archive'))).toEqual([]) }) + it('refuses moving it into a project, where its Undo would be refused', async () => { + const project = join(docFolder, 'proj') + await mkdir(project) + projectPaths = [project] + invalidateAuthorizedRootsCache() + + expect( + await settles( + call('fs:rename', { + oldPath: note, + newPath: join(project, 'note.md'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect(await readdir(project)).toEqual([]) + + const renamed = join(docFolder, 'renamed.md') + await call('fs:rename', { oldPath: note, newPath: renamed, access: documentFolder(note) }) + await call('fs:rename', { oldPath: renamed, newPath: note, access: documentFolder(renamed) }) + expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'proj']) + }) + it.skipIf(process.platform === 'win32')( 'refuses a new name through a linked subfolder that leads out', async () => { diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 74634455d8ff..559dad4bb72d 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -5,6 +5,7 @@ import { basename, dirname } from 'node:path' import type { Store } from '../persistence' import { resolveDesktopAuthorizedPath, + resolveLocalRenamePaths, resolveLocalRequestPath } from './local-file-access-resolution' import type { LocalFileAccess } from '../../shared/local-file-access' @@ -117,9 +118,13 @@ export function registerFilesystemMutationHandlers(store: Store): void { // symlink dangling. newPath must also preserve its leaf so we don't // accidentally write into a symlinked destination name. // Outside every project, a document the user opened may still be renamed in its own folder. - const oldPath = await resolveLocalRequestPath(args.oldPath, args.access, store, 'rename-from') - const newPath = await resolveLocalRequestPath(args.newPath, args.access, store, 'rename-to') - await renameLocalPathSerializedByDestination(oldPath, newPath) + const { from, to } = await resolveLocalRenamePaths( + args.oldPath, + args.newPath, + args.access, + store + ) + await renameLocalPathSerializedByDestination(from, to) } ) diff --git a/src/main/ipc/local-file-access-resolution-network-path.test.ts b/src/main/ipc/local-file-access-resolution-network-path.test.ts index 8ed5a7bf4742..9c9aada91658 100644 --- a/src/main/ipc/local-file-access-resolution-network-path.test.ts +++ b/src/main/ipc/local-file-access-resolution-network-path.test.ts @@ -305,6 +305,11 @@ const besideShareDocument = { documentPath: '\\\\nas\\notes\\todo.md' } +const besideAccentedShareDocument = { + kind: 'document-resource', + documentPath: '\\\\n\u00e1s\\notes\\todo.md' +} + describe('automatic image loads on a network share', () => { beforeEach(() => { fsCalls.length = 0 @@ -338,16 +343,12 @@ describe('automatic image loads on a network share', () => { expect(fsCalls.filter((call) => call.includes('other'))).toEqual([]) }) - it.each(['\\\\nas\\notes\\x.png', '\\\\NAS\\Notes\\img\\y.png', '//nas/notes/z.png'])( - 'reads %s beside a document opened from that same share', - async (target) => { - await expect( - resolveLocalFileRequestPath(target, besideShareDocument, shareProjectStore) - ).resolves.toBeTruthy() - } - ) - + // Why beside a share document too: a host spelled with a look-alike (U+212A KELVIN SIGN, an NFD + // accent) can pass a case-folded folder comparison, so no share is loaded outside a project. it.each([ + '\\\\nas\\notes\\x.png', + '\\\\NAS\\Notes\\img\\y.png', + '//nas/notes/z.png', '\\\\nas\\other\\x.png', '\\\\evil\\notes\\x.png', '\\\\nas\\notes-evil\\x.png', @@ -358,4 +359,29 @@ describe('automatic image loads on a network share', () => { ).rejects.toThrow('Access denied') expect(fsCalls).toEqual([]) }) + + it.each(['\\\\bac\u212Aup\\notes\\x.png', '//bac\u212Aup/notes/x.png'])( + 'refuses the KELVIN SIGN host spelling %s beside a document on \\\\backup without touching it', + async (target) => { + await expect( + resolveLocalFileRequestPath( + target, + { kind: 'document-resource', documentPath: '\\\\backup\\notes\\todo.md' }, + shareProjectStore + ) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + } + ) + + it('refuses an NFD spelling of an accented share host beside a document on it, without touching it', async () => { + await expect( + resolveLocalFileRequestPath( + '\\\\na\u0301s\\notes\\x.png', + besideAccentedShareDocument, + shareProjectStore + ) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + }) }) diff --git a/src/main/ipc/local-file-access-resolution.ts b/src/main/ipc/local-file-access-resolution.ts index bedeff0b9f91..ff6f0956a82a 100644 --- a/src/main/ipc/local-file-access-resolution.ts +++ b/src/main/ipc/local-file-access-resolution.ts @@ -72,9 +72,9 @@ function isRefusedAutomaticLoadPath(filePath: string): boolean { /** * A file a document references (an image, typically) beyond what the default check allows: one in - * the document's own folder, like the common markdown-preview rule, on a network share too when the - * document is on it. A target outside the folder is refused by its path text before any - * filesystem call; a symlink inside the folder is still resolved by the folder check. + * the document's own folder, like the common markdown-preview rule. A target outside the folder, + * or a network share, is refused by its path text before any filesystem call; a symlink inside the + * folder is still resolved by the folder check. */ async function resolveDocumentResourcePath( targetPath: string, @@ -93,9 +93,7 @@ async function resolveDocumentResourcePath( throw new Error(PATH_ACCESS_DENIED_MESSAGE) } const documentFolder = dirname(resolve(documentPath)) - // Why the folder text check comes first: it refuses every host and share but the document's - // own (compared case-insensitively on Windows) before a filesystem call could reach one. - if (!isDescendantOrEqual(resolvedTarget, documentFolder)) { + if (isNetworkSharePath(resolvedTarget) || !isDescendantOrEqual(resolvedTarget, documentFolder)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } const realTarget = resolve(await realpath(resolvedTarget)) @@ -268,12 +266,44 @@ export async function resolveLocalRequestPath( store: Store, operation: LocalRequestOperation ): Promise { + return (await resolveLocalRequest(targetPath, access, store, operation)).path +} + +/** + * Both paths of a desktop rename. A source allowed only as the opened document (outside every + * project) gets its new name directly in that document's folder, even inside a project, so the + * Undo, declared from the new name, can move it back. + */ +export async function resolveLocalRenamePaths( + oldPath: string, + newPath: string, + access: unknown, + store: Store +): Promise<{ from: string; to: string }> { + const source = await resolveLocalRequest(oldPath, access, store, 'rename-from') + const fileAccess = parseLocalFileAccess(access) + const to = + source.byKindRule && fileAccess?.kind === 'document-folder' + ? await resolveDocumentFolderPath(newPath, fileAccess.documentPath, { rename: true }) + : await resolveLocalRequestPath(newPath, access, store, 'rename-to') + return { from: source.path, to } +} + +async function resolveLocalRequest( + targetPath: string, + access: unknown, + store: Store, + operation: LocalRequestOperation +): Promise<{ path: string; byKindRule: boolean }> { // Why the leaf is kept: a rename acts on a link itself, never on what it points to. const options = { preserveSymlink: operation === 'rename-from' || operation === 'rename-to' } const fileAccess = parseLocalFileAccess(access) const kindRule = fileAccess && declaredKindRule(fileAccess, operation, store) if (!fileAccess || !kindRule) { - return resolveDesktopAuthorizedPath(targetPath, store, options) + return { + path: await resolveDesktopAuthorizedPath(targetPath, store, options), + byKindRule: false + } } if (typeof targetPath !== 'string') { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -288,12 +318,12 @@ export async function resolveLocalRequestPath( () => undefined ) if (insideRoots === undefined) { - return kindRule(targetPath) + return { path: await kindRule(targetPath), byKindRule: true } } if (automaticLoad && isRefusedAutomaticLoadPath(insideRoots)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - return insideRoots + return { path: insideRoots, byKindRule: false } } /** A desktop read/stat request; no declared access means roots only. */ diff --git a/src/main/ipc/notebook-project-link.test.ts b/src/main/ipc/notebook-project-link.test.ts index 193185d92271..d0036d1d38f7 100644 --- a/src/main/ipc/notebook-project-link.test.ts +++ b/src/main/ipc/notebook-project-link.test.ts @@ -80,3 +80,23 @@ it.skipIf(process.platform === 'win32')( ) } ) + +it.skipIf(process.platform === 'win32')( + 'runs a linked notebook outside every project in its real folder, as before', + async () => { + const notes = join(base, 'notes') + await mkdir(join(notes, 'analysis'), { recursive: true }) + await writeFile(join(notes, 'analysis', 'real.ipynb'), '{}') + await symlink(join(notes, 'analysis', 'real.ipynb'), join(notes, 'nb.ipynb')) + const owner = Object.assign(new EventEmitter(), { send: vi.fn(), isDestroyed: () => false }) + + await handlers.get('notebook:startKernel')!( + { sender: owner }, + { filePath: join(notes, 'nb.ipynb'), python: '/py' } + ) + + expect(startNotebookKernelMock).toHaveBeenCalledWith( + expect.objectContaining({ cwd: join(notes, 'analysis') }) + ) + } +) diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index 536ba35788f9..05ea78fa9a49 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromises from 'node:fs/promises' import type { KernelFrame } from '../../shared/notebook-kernel-types' const handlers = new Map unknown>() @@ -22,6 +23,14 @@ vi.mock('./local-file-access-resolution', () => ({ resolveDesktopAuthorizedPath: resolveAuthorizedPathMock })) vi.mock('../notebook/notebook-kernel', () => ({ startNotebookKernel: startNotebookKernelMock })) +// Why: the mocked resolver returns made-up `/real/...` paths, which stand for real files already. +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + realpath: async (path: string) => (path.startsWith('/real/') ? path : actual.realpath(path)) + } +}) import { registerNotebookHandlers } from './notebook' import type { Store } from '../persistence' diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index 5b9d1856a2ca..47c58f6c3125 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -1,3 +1,4 @@ +import { realpath } from 'node:fs/promises' import { dirname } from 'node:path' import { ipcMain, type WebContents } from 'electron' import type { Store } from '../persistence' @@ -72,8 +73,9 @@ export function registerNotebookHandlers(store: Store): void { ipcMain.handle( 'notebook:startKernel', async (event, args: { filePath: string; python: string }): Promise => { - // Why: run from the notebook's folder so relative imports and data paths resolve as on disk. - const cwd = dirname(await resolveUserNamedRegularFile(args.filePath, store)) + // Why the real file's folder: relative imports and data paths resolve as on disk, even when + // the notebook was opened through a link. + const cwd = dirname(await realpath(await resolveUserNamedRegularFile(args.filePath, store))) const owner = event.sender const kernels = kernelsOf(owner) kernels.get(args.filePath)?.shutdown() From bbe1afe79244a4fc2028d1e75768348d5d0e3c2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:47:37 -0700 Subject: [PATCH 19/19] fix(fs): a file opened outside every project renames to any path, and keeps its access Renaming a document the user opened (floating workspace or full-path tab) now follows the user-file rule: the source must be the opened document, and the new path can be any absolute path, so a rename into another folder, a subfolder or a project works, and its Undo (declared from the moved file) comes back from there. Any other rename keeps the project check only. Remove the same-folder rename rule and its tests; image import stays in the document's own folder. After a move, a tab stored by its full path keeps its full path instead of being recomputed project-relative, so it keeps user-file access for save, the next rename, image insert and restore after restart. Folder moves go through the same remap. Also un-export unused resolver exports and avoid a copy for single-chunk reads. --- .../filesystem-document-folder-writes.test.ts | 147 +++++++---- src/main/ipc/filesystem-mutations.ts | 2 +- .../ipc/filesystem/local-regular-file-read.ts | 2 +- ...ile-access-resolution-network-path.test.ts | 27 +- .../ipc/local-file-access-resolution.test.ts | 29 ++- src/main/ipc/local-file-access-resolution.ts | 120 ++++----- .../src/components/tab-bar/EditorFileTab.tsx | 2 +- .../src/lib/execute-open-editor-path-move.ts | 2 +- .../src/lib/local-file-access.test.ts | 2 +- src/renderer/src/lib/local-file-access.ts | 8 +- .../remap-open-editor-tabs-for-path-change.ts | 4 + src/renderer/src/lib/rename-file.ts | 2 +- .../lib/rename-user-named-tab-access.test.ts | 232 ++++++++++++++++++ src/shared/local-file-access.ts | 2 +- 14 files changed, 417 insertions(+), 164 deletions(-) create mode 100644 src/renderer/src/lib/rename-user-named-tab-access.test.ts diff --git a/src/main/ipc/filesystem-document-folder-writes.test.ts b/src/main/ipc/filesystem-document-folder-writes.test.ts index f5dfecb688db..6e8e50ad2055 100644 --- a/src/main/ipc/filesystem-document-folder-writes.test.ts +++ b/src/main/ipc/filesystem-document-folder-writes.test.ts @@ -4,6 +4,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type * as RepoWorktrees from '../repo-worktrees' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' type Handler = (event: unknown, args: unknown) => Promise @@ -91,25 +95,21 @@ describe('renaming a document the user opened outside every project', () => { expect(await readdir(docFolder)).toEqual(['renamed.md']) }) - it('refuses a move out of its folder, another file, and a request with no access', async () => { + it('refuses another file, a relative new name, and a request with no access', async () => { await writeFile(join(docFolder, 'other.md'), 'other') expect( await settles( call('fs:rename', { - oldPath: note, - newPath: join(base, 'note.md'), + oldPath: join(docFolder, 'other.md'), + newPath: join(docFolder, 'moved.md'), access: documentFolder(note) }) ) ).toBe('denied') expect( await settles( - call('fs:rename', { - oldPath: join(docFolder, 'other.md'), - newPath: join(docFolder, 'moved.md'), - access: documentFolder(note) - }) + call('fs:rename', { oldPath: note, newPath: 'moved.md', access: documentFolder(note) }) ) ).toBe('denied') expect( @@ -127,62 +127,45 @@ describe('renaming a document the user opened outside every project', () => { expect(await readdir(docFolder)).toEqual(['note.md']) }) - it('refuses a rename into a subfolder, whose Undo could not come back', async () => { - await mkdir(join(docFolder, 'archive')) + // Why each destination is undone: the Undo declares the moved file, so it must come back from anywhere. + it('moves it into another outside folder, and Undo brings it back', async () => { + await mkdir(join(base, 'other')) + const moved = join(base, 'other', 'note.md') - expect( - await settles( - call('fs:rename', { - oldPath: note, - newPath: join(docFolder, 'archive', 'note.md'), - access: documentFolder(note) - }) - ) - ).toBe('denied') - expect(await readdir(join(docFolder, 'archive'))).toEqual([]) + await call('fs:rename', { oldPath: note, newPath: moved, access: documentFolder(note) }) + expect(await readdir(join(base, 'other'))).toEqual(['note.md']) + + await call('fs:rename', { oldPath: moved, newPath: note, access: documentFolder(moved) }) + expect(await readdir(join(base, 'other'))).toEqual([]) + expect(await readdir(docFolder)).toEqual(['note.md']) }) - it('refuses moving it into a project, where its Undo would be refused', async () => { - const project = join(docFolder, 'proj') + it('moves it into a project, and Undo brings it back out', async () => { + const project = join(base, 'proj') await mkdir(project) projectPaths = [project] invalidateAuthorizedRootsCache() + const moved = join(project, 'note.md') - expect( - await settles( - call('fs:rename', { - oldPath: note, - newPath: join(project, 'note.md'), - access: documentFolder(note) - }) - ) - ).toBe('denied') - expect(await readdir(project)).toEqual([]) + await call('fs:rename', { oldPath: note, newPath: moved, access: documentFolder(note) }) + expect(await readdir(project)).toEqual(['note.md']) - const renamed = join(docFolder, 'renamed.md') - await call('fs:rename', { oldPath: note, newPath: renamed, access: documentFolder(note) }) - await call('fs:rename', { oldPath: renamed, newPath: note, access: documentFolder(renamed) }) - expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'proj']) + await call('fs:rename', { oldPath: moved, newPath: note, access: documentFolder(moved) }) + expect(await readdir(project)).toEqual([]) + expect(await readdir(docFolder)).toEqual(['note.md']) }) - it.skipIf(process.platform === 'win32')( - 'refuses a new name through a linked subfolder that leads out', - async () => { - await mkdir(join(base, 'elsewhere')) - await symlink(join(base, 'elsewhere'), join(docFolder, 'linked')) + it('moves it into a subfolder, and Undo brings it back', async () => { + await mkdir(join(docFolder, 'archive')) + const moved = join(docFolder, 'archive', 'note.md') - expect( - await settles( - call('fs:rename', { - oldPath: note, - newPath: join(docFolder, 'linked', 'note.md'), - access: documentFolder(note) - }) - ) - ).toBe('denied') - expect(await readdir(join(base, 'elsewhere'))).toEqual([]) - } - ) + await call('fs:rename', { oldPath: note, newPath: moved, access: documentFolder(note) }) + expect(await readdir(join(docFolder, 'archive'))).toEqual(['note.md']) + + await call('fs:rename', { oldPath: moved, newPath: note, access: documentFolder(moved) }) + expect(await readdir(join(docFolder, 'archive'))).toEqual([]) + expect((await readdir(docFolder)).sort()).toEqual(['archive', 'note.md']) + }) }) describe('inserting an image into a document the user opened outside every project', () => { @@ -280,4 +263,62 @@ describe('a project file opened by its full path', () => { expect(await readdir(join(project, 'archive'))).toEqual(['plan.md']) expect(await readdir(join(project, 'docs'))).toEqual([]) }) + + it('moves out of the project, and Undo brings it back in', async () => { + const project = join(base, 'project') + await mkdir(join(project, 'docs'), { recursive: true }) + await writeFile(join(project, 'docs', 'plan.md'), '# plan\n') + projectPaths = [project] + invalidateAuthorizedRootsCache() + const plan = join(project, 'docs', 'plan.md') + const moved = join(docFolder, 'plan.md') + + await call('fs:rename', { oldPath: plan, newPath: moved, access: documentFolder(plan) }) + expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'plan.md']) + + await call('fs:rename', { oldPath: moved, newPath: plan, access: documentFolder(moved) }) + expect(await readdir(join(project, 'docs'))).toEqual(['plan.md']) + expect(await readdir(docFolder)).toEqual(['note.md']) + }) + + it('never moves another project file out by naming the opened document', async () => { + const project = join(base, 'project') + await mkdir(project) + await writeFile(join(project, 'secret.md'), 'secret') + projectPaths = [project] + invalidateAuthorizedRootsCache() + + expect( + await settles( + call('fs:rename', { + oldPath: join(project, 'secret.md'), + newPath: join(docFolder, 'secret.md'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect(await readdir(project)).toEqual(['secret.md']) + }) +}) + +describe('an SSH rename', () => { + it('goes to the remote host as before, whatever access it declares', async () => { + const renameNoClobber = vi.fn().mockResolvedValue(undefined) + registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) + try { + await call('fs:rename', { + oldPath: note, + newPath: join(base, 'note.md'), + access: documentFolder(note), + connectionId: 'ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 0 + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(renameNoClobber).toHaveBeenCalledWith(note, join(base, 'note.md')) + expect(await readdir(docFolder)).toEqual(['note.md']) + }) }) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 559dad4bb72d..e96650a7442b 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -117,7 +117,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { // target file (potentially elsewhere in the worktree) and leave the // symlink dangling. newPath must also preserve its leaf so we don't // accidentally write into a symlinked destination name. - // Outside every project, a document the user opened may still be renamed in its own folder. + // Outside every project, a document the user opened may still be renamed, to any path. const { from, to } = await resolveLocalRenamePaths( args.oldPath, args.newPath, diff --git a/src/main/ipc/filesystem/local-regular-file-read.ts b/src/main/ipc/filesystem/local-regular-file-read.ts index fe95ad5cf210..acffa8cb1098 100644 --- a/src/main/ipc/filesystem/local-regular-file-read.ts +++ b/src/main/ipc/filesystem/local-regular-file-read.ts @@ -50,7 +50,7 @@ export async function readLocalFileBounded( const chunk = Buffer.allocUnsafe(Math.min(nextChunkBytes, limit + 1 - total)) const { bytesRead } = await handle.read(chunk, 0, chunk.length, total) if (bytesRead === 0) { - return Buffer.concat(chunks, total) + return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, total) } chunks.push(chunk.subarray(0, bytesRead)) total += bytesRead diff --git a/src/main/ipc/local-file-access-resolution-network-path.test.ts b/src/main/ipc/local-file-access-resolution-network-path.test.ts index 9c9aada91658..b2340146526c 100644 --- a/src/main/ipc/local-file-access-resolution-network-path.test.ts +++ b/src/main/ipc/local-file-access-resolution-network-path.test.ts @@ -46,6 +46,7 @@ vi.mock('../repo-worktrees', () => ({ listRepoWorktreeGraph: vi.fn(async () => [ import { resolveLocalFileRequestPath, + resolveLocalRenamePaths, resolveLocalRequestPath } from './local-file-access-resolution' @@ -181,9 +182,9 @@ describe('Windows reserved device names in automatic image loads', () => { it.each(deviceTargets)( 'refuses %s as a new file beside an opened document without touching it', async (target) => { - await expect(resolveLocalRequestPath(target, besideTodo, store, 'rename-to')).rejects.toThrow( - 'Access denied' - ) + await expect( + resolveLocalRequestPath(target, besideTodo, store, 'import-into') + ).rejects.toThrow('Access denied') expect(fsCalls).toEqual([]) } ) @@ -198,18 +199,6 @@ describe('Windows reserved device names in automatic image loads', () => { } ) - it('refuses a rename into a subfolder of an opened document folder without touching it', async () => { - await expect( - resolveLocalRequestPath( - 'C:\\Users\\me\\notes\\archive\\todo.md', - besideTodo, - store, - 'rename-to' - ) - ).rejects.toThrow('Access denied') - expect(fsCalls).toEqual([]) - }) - it('still reads an ordinary image whose name only starts like a device', async () => { await expect( resolveLocalFileRequestPath('C:\\Users\\me\\notes\\console.png', CHAT_IMAGE, store) @@ -287,7 +276,6 @@ describe('the default check runs first for every declared kind', () => { { kind: 'document-resource', documentPath: 'C:\\repo\\README.md' } ], ['chat-image', 'read', CHAT_IMAGE], - ['document-folder', 'rename-to', besideTodo], ['document-folder', 'import-into', besideTodo] ] as const)( 'never touches a share outside every project while resolving %s %s', @@ -298,6 +286,13 @@ describe('the default check runs first for every declared kind', () => { expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) } ) + + it('never touches a share while resolving a rename of an opened document', async () => { + for (const target of networkTargets) { + await resolveLocalRenamePaths(besideTodo.documentPath, target, besideTodo, store) + } + expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) + }) }) const besideShareDocument = { diff --git a/src/main/ipc/local-file-access-resolution.test.ts b/src/main/ipc/local-file-access-resolution.test.ts index 53735470f1fa..e659a516c961 100644 --- a/src/main/ipc/local-file-access-resolution.test.ts +++ b/src/main/ipc/local-file-access-resolution.test.ts @@ -8,6 +8,7 @@ import { resolveAuthorizedPath } from './filesystem-auth' import { resolveDesktopAuthorizedPath, resolveLocalFileRequestPath, + resolveLocalRenamePaths, resolveLocalRequestPath, resolveLocalWriteRequestPath, type LocalRequestOperation @@ -51,6 +52,8 @@ function makeStore({ repoPaths = [], folderPath }: Registration): Store { const USER_FILE = { kind: 'user-file' } as const const documentResource = (documentPath: string) => ({ kind: 'document-resource', documentPath }) as const +const documentFolder = (documentPath: string) => + ({ kind: 'document-folder', documentPath }) as const async function settles(promise: Promise): Promise<'ok' | 'denied'> { return promise.then( @@ -132,16 +135,6 @@ describe('a declared kind never refuses what the default project check allows', ['user-file write', USER_FILE, 'write'], ['document-resource read', documentResource(outsideDocument()), 'read'], ['chat-image read', { kind: 'chat-image' }, 'read'], - [ - 'document-folder rename-from', - { kind: 'document-folder', documentPath: outsideDocument() }, - 'rename-from' - ], - [ - 'document-folder rename-to', - { kind: 'document-folder', documentPath: outsideDocument() }, - 'rename-to' - ], [ 'document-folder import-into', { kind: 'document-folder', documentPath: outsideDocument() }, @@ -193,6 +186,22 @@ describe('a declared kind never refuses what the default project check allows', } } ) + + it('renames an in-project document exactly as the default does', async () => { + const store = makeStore({ repoPaths: [project] }) + const source = join(project, 'src', 'notes.txt') + for (const target of [ + join(project, 'src', 'new-name.txt'), + join(project, 'new-name.txt'), + join(userData.path, 'floating-workspace', 'scratch.md') + ]) { + const byDefault = await resolveLocalRenamePaths(source, target, undefined, store) + await expect( + resolveLocalRenamePaths(source, target, documentFolder(source), store), + target + ).resolves.toEqual(byDefault) + } + }) }) describe('requests with no declared access (roots only)', () => { diff --git a/src/main/ipc/local-file-access-resolution.ts b/src/main/ipc/local-file-access-resolution.ts index ff6f0956a82a..94054a0ddf22 100644 --- a/src/main/ipc/local-file-access-resolution.ts +++ b/src/main/ipc/local-file-access-resolution.ts @@ -1,4 +1,4 @@ -import { basename, dirname, extname, isAbsolute, resolve } from 'node:path' +import { dirname, extname, isAbsolute, resolve } from 'node:path' import { realpath, stat } from 'node:fs/promises' import type { Store } from '../persistence' import type { LocalFileAccess } from '../../shared/local-file-access' @@ -17,11 +17,11 @@ import { } from './automatic-load-path-text' import { NOT_A_REGULAR_FILE_MESSAGE } from './filesystem/local-regular-file-read' -export const USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE = +const USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE = 'Access denied: a file opened by name needs an absolute path.' const USER_FILE_ACCESS: LocalFileAccess = { kind: 'user-file' } -export const CHAT_IMAGE_TYPE_MESSAGE = 'Access denied: a chat can only show local image files.' +const CHAT_IMAGE_TYPE_MESSAGE = 'Access denied: a chat can only show local image files.' /** Desktop IPC's root check: the project roots plus the app-owned floating-workspace folder. */ export async function resolveDesktopAuthorizedPath( @@ -36,7 +36,7 @@ export async function resolveDesktopAuthorizedPath( } /** A file the user named is used where it is; no root applies, and nothing is remembered. */ -export function resolveUserNamedLocalPath(targetPath: string): string { +function resolveUserNamedLocalPath(targetPath: string): string { // Why isAbsolute on the raw input: resolve() would anchor `notes.txt` or `C:notes` to main's cwd. if (typeof targetPath !== 'string' || !isAbsolute(targetPath)) { throw new Error(USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE) @@ -138,19 +138,13 @@ async function resolveChatImagePath(targetPath: string, store: Store): Promise { if ( typeof targetPath !== 'string' || @@ -164,15 +158,11 @@ async function resolveDocumentFolderPath( const documentFolder = dirname(resolve(documentPath)) if ( isRefusedAutomaticLoadPath(resolvedTarget) || - !isDescendantOrEqual(resolvedTarget, documentFolder) || - // Why no real-path twin: a rename keeps its leaf, so its real parent is the folder's real path. - (rename && !isSameFolder(dirname(resolvedTarget), documentFolder)) + !isDescendantOrEqual(resolvedTarget, documentFolder) ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - const realTarget = rename - ? resolve(await realpath(dirname(resolvedTarget)), basename(resolvedTarget)) - : resolve(await realpath(resolvedTarget)) + const realTarget = resolve(await realpath(resolvedTarget)) const realFolder = resolve(await realpath(documentFolder)) if (isRefusedAutomaticLoadPath(realTarget) || !isDescendantOrEqual(realTarget, realFolder)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -180,15 +170,13 @@ async function resolveDocumentFolderPath( return realTarget } -// Why only the document itself: a write beside it may rename that file, never a neighbour. -async function resolveDocumentRenameSource( - targetPath: string, - documentPath: string -): Promise { - if (typeof targetPath !== 'string' || resolve(targetPath) !== resolve(documentPath)) { - throw new Error(PATH_ACCESS_DENIED_MESSAGE) - } - return resolveDocumentFolderPath(targetPath, documentPath, { rename: true }) +function isOpenedDocument(targetPath: unknown, documentPath: string): boolean { + return ( + typeof targetPath === 'string' && + isAbsolute(targetPath) && + isAbsolute(documentPath) && + resolve(targetPath) === resolve(documentPath) + ) } // Why parse: IPC input is untyped, and an unrecognised access kind must fall back to roots only. @@ -231,7 +219,8 @@ function declaredKindRule( ): KindRule | undefined { switch (fileAccess.kind) { case 'user-file': - return operation === 'read' || operation === 'write' + // Why renames: resolveLocalRenamePaths declares this only for the opened document itself. + return operation !== 'import-into' ? async (targetPath) => resolveUserNamedLocalPath(targetPath) : undefined case 'document-resource': @@ -243,16 +232,9 @@ function declaredKindRule( ? (targetPath) => resolveChatImagePath(targetPath, store) : undefined case 'document-folder': - if (operation === 'rename-from') { - return (targetPath) => resolveDocumentRenameSource(targetPath, fileAccess.documentPath) - } - if (operation === 'rename-to' || operation === 'import-into') { - return (targetPath) => - resolveDocumentFolderPath(targetPath, fileAccess.documentPath, { - rename: operation === 'rename-to' - }) - } - return undefined + return operation === 'import-into' + ? (targetPath) => resolveDocumentFolderPath(targetPath, fileAccess.documentPath) + : undefined } } @@ -266,44 +248,12 @@ export async function resolveLocalRequestPath( store: Store, operation: LocalRequestOperation ): Promise { - return (await resolveLocalRequest(targetPath, access, store, operation)).path -} - -/** - * Both paths of a desktop rename. A source allowed only as the opened document (outside every - * project) gets its new name directly in that document's folder, even inside a project, so the - * Undo, declared from the new name, can move it back. - */ -export async function resolveLocalRenamePaths( - oldPath: string, - newPath: string, - access: unknown, - store: Store -): Promise<{ from: string; to: string }> { - const source = await resolveLocalRequest(oldPath, access, store, 'rename-from') - const fileAccess = parseLocalFileAccess(access) - const to = - source.byKindRule && fileAccess?.kind === 'document-folder' - ? await resolveDocumentFolderPath(newPath, fileAccess.documentPath, { rename: true }) - : await resolveLocalRequestPath(newPath, access, store, 'rename-to') - return { from: source.path, to } -} - -async function resolveLocalRequest( - targetPath: string, - access: unknown, - store: Store, - operation: LocalRequestOperation -): Promise<{ path: string; byKindRule: boolean }> { // Why the leaf is kept: a rename acts on a link itself, never on what it points to. const options = { preserveSymlink: operation === 'rename-from' || operation === 'rename-to' } const fileAccess = parseLocalFileAccess(access) const kindRule = fileAccess && declaredKindRule(fileAccess, operation, store) if (!fileAccess || !kindRule) { - return { - path: await resolveDesktopAuthorizedPath(targetPath, store, options), - byKindRule: false - } + return resolveDesktopAuthorizedPath(targetPath, store, options) } if (typeof targetPath !== 'string') { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -318,12 +268,34 @@ async function resolveLocalRequest( () => undefined ) if (insideRoots === undefined) { - return { path: await kindRule(targetPath), byKindRule: true } + return kindRule(targetPath) } if (automaticLoad && isRefusedAutomaticLoadPath(insideRoots)) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } - return { path: insideRoots, byKindRule: false } + return insideRoots +} + +/** + * Both paths of a desktop rename. Renaming the opened document (its document-folder access) follows + * the user-file rule: the user typed the new path, so it may go anywhere, and its Undo, declared + * from the moved file, may come back from there. Any other rename gets the default check only. + */ +export async function resolveLocalRenamePaths( + oldPath: string, + newPath: string, + access: unknown, + store: Store +): Promise<{ from: string; to: string }> { + const fileAccess = parseLocalFileAccess(access) + const renameAccess = + fileAccess?.kind === 'document-folder' && isOpenedDocument(oldPath, fileAccess.documentPath) + ? USER_FILE_ACCESS + : undefined + return { + from: await resolveLocalRequestPath(oldPath, renameAccess, store, 'rename-from'), + to: await resolveLocalRequestPath(newPath, renameAccess, store, 'rename-to') + } } /** A desktop read/stat request; no declared access means roots only. */ diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index daa3bfa17a1a..d5c0e5b7ad2f 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -164,7 +164,7 @@ export default function EditorFileTab({ newName, worktreeId: file.worktreeId, worktreePath, - // Why: a file opened outside every project renames in its own folder, wherever it lives. + // Why: a file opened outside every project may be renamed to any path, wherever it lives. documentScoped: editorTabDocumentFolderAccess(useAppStore.getState(), file) !== undefined }) } diff --git a/src/renderer/src/lib/execute-open-editor-path-move.ts b/src/renderer/src/lib/execute-open-editor-path-move.ts index 7b12ac999c89..ca2399027703 100644 --- a/src/renderer/src/lib/execute-open-editor-path-move.ts +++ b/src/renderer/src/lib/execute-open-editor-path-move.ts @@ -32,7 +32,7 @@ export async function executeOpenEditorPathMove(args: { toPath: string worktreeId: string worktreePath: string - /** The moved file is a user-named document: main keeps the move inside its own folder. */ + /** The moved file is a user-named document: main allows any destination, and the move back. */ documentScoped?: boolean }): Promise { const { context, fromPath, toPath, worktreeId, worktreePath } = args diff --git a/src/renderer/src/lib/local-file-access.test.ts b/src/renderer/src/lib/local-file-access.test.ts index 7c8706f9b6e1..ccb136bc47e8 100644 --- a/src/renderer/src/lib/local-file-access.test.ts +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -165,7 +165,7 @@ describe('editorTabDocumentFolderAccess', () => { useAppStore.setState(initialState, true) }) - it('scopes writes on a local user-named tab to that file and its own folder', () => { + it('declares the file itself for writes on a local user-named tab', () => { expect( editorTabDocumentFolderAccess(useAppStore.getState(), { filePath: '/Users/me/notes.md', diff --git a/src/renderer/src/lib/local-file-access.ts b/src/renderer/src/lib/local-file-access.ts index 26307a71b88d..d93b1d30e845 100644 --- a/src/renderer/src/lib/local-file-access.ts +++ b/src/renderer/src/lib/local-file-access.ts @@ -22,8 +22,8 @@ export function chatImageAccess(): LocalFileAccess { } /** - * For a write beside a document the user opened (renaming it, inserting an image next to it). Main - * keeps it inside that document's own folder. + * For acting on a document the user opened: renaming it (to any path the user typed) or inserting + * an image next to it, which main keeps inside the document's own folder. */ export function documentFolderAccess(documentPath: string): LocalFileAccess { return { kind: 'document-folder', documentPath } @@ -72,8 +72,8 @@ export function editorTabFileAccess( } /** - * The write access for acting on an open tab's file (rename, image insert): scoped to the file's own - * folder when the tab is user-named, else none, so project tabs keep their project checks. + * The write access for acting on an open tab's file (rename, image insert) when the tab is + * user-named, else none, so project tabs keep their project checks. */ export function editorTabDocumentFolderAccess( state: Pick, diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts index 645ad60ce993..fcac9a335843 100644 --- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts +++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts @@ -165,6 +165,10 @@ function getUpdatedRelativePath({ initiatingWorktreeId: string | undefined initiatingWorktreePath: string }): string { + // Why: a tab stored by its full path is user-named (editorTabFileAccess); keep it that way. + if (relativePath === filePath) { + return updatedPath + } const worktreeRelative = relativePathInsideRoot(initiatingWorktreePath, filePath) // Both sides fold on the same flavor (the file's own absolute path) so the // comparison stays consistent and a legal POSIX backslash isn't mistaken for a diff --git a/src/renderer/src/lib/rename-file.ts b/src/renderer/src/lib/rename-file.ts index 27b8a7281e07..bfadcc41fd9f 100644 --- a/src/renderer/src/lib/rename-file.ts +++ b/src/renderer/src/lib/rename-file.ts @@ -30,7 +30,7 @@ type RenameFileArgs = { operationOwner?: FileExplorerOperationOwner /** refresh the parent directory in the explorer tree, if caller tracks one */ refreshDir?: (dirPath: string) => Promise - /** The file is an open user-named document, so the rename stays in its own folder wherever it lives. */ + /** The file is an open user-named document, so the rename (and its Undo) may go anywhere. */ documentScoped?: boolean } diff --git a/src/renderer/src/lib/rename-user-named-tab-access.test.ts b/src/renderer/src/lib/rename-user-named-tab-access.test.ts new file mode 100644 index 000000000000..d7a70867173e --- /dev/null +++ b/src/renderer/src/lib/rename-user-named-tab-access.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeFileClient from '@/runtime/runtime-file-client' +import type * as EditorAutosave from '@/components/editor/editor-autosave' + +const mocks = vi.hoisted(() => ({ renameRuntimePath: vi.fn(), writeRuntimeFile: vi.fn() })) +vi.mock('@/runtime/runtime-file-client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + renameRuntimePath: mocks.renameRuntimePath, + writeRuntimeFile: mocks.writeRuntimeFile + } +}) +vi.mock('@/components/editor/editor-autosave', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, requestEditorSaveQuiesce: vi.fn().mockResolvedValue(undefined) } +}) + +import { useAppStore } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import type { Repo } from '../../../shared/repo-types' +import type { Worktree } from '../../../shared/worktree/types' +import { + clearFileExplorerUndoHistory, + redoFileExplorer, + undoFileExplorer +} from '@/components/right-sidebar/fileExplorerUndoRedo' +import { createEditorSaveQueue } from '@/components/editor/editor-save-queue' +import { editorTabDocumentFolderAccess, editorTabFileAccess } from './local-file-access' +import { renameFileOnDisk } from './rename-file' + +const PROJECT = '/Users/me/project' +const WT = `repo-local::${PROJECT}` +const repo: Repo = { + id: 'repo-local', + path: PROJECT, + displayName: 'project', + badgeColor: '#000', + addedAt: 0 +} +const worktree: Worktree = { + id: WT, + repoId: 'repo-local', + path: PROJECT, + head: 'a', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'project', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 +} + +const documentFolder = (documentPath: string) => ({ kind: 'document-folder', documentPath }) + +function openUserNamedTab(filePath: string, worktreeId: string): void { + useAppStore.getState().openFile( + { + filePath, + relativePath: filePath, + worktreeId, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) +} + +function onlyTab() { + const tabs = useAppStore.getState().openFiles + expect(tabs).toHaveLength(1) + return tabs[0]! +} + +// Why each check: reads and the next rename/image insert derive access from the tab, and a restart +// restores the same filePath/relativePath, so access must survive on the stored fields alone. +async function expectUserNamedAt(filePath: string): Promise { + const tab = onlyTab() + expect(tab.filePath).toBe(filePath) + expect(tab.relativePath).toBe(filePath) + const state = useAppStore.getState() + expect(editorTabFileAccess(state, tab)).toEqual({ kind: 'user-file' }) + expect(editorTabDocumentFolderAccess(state, tab)).toEqual(documentFolder(filePath)) + const restored = { + filePath: tab.filePath, + relativePath: tab.relativePath, + worktreeId: tab.worktreeId, + runtimeEnvironmentId: tab.runtimeEnvironmentId + } + expect(editorTabFileAccess(state, restored)).toEqual({ kind: 'user-file' }) + + mocks.writeRuntimeFile.mockClear() + await createEditorSaveQueue(useAppStore).queueSave(tab, 'edited', 'user') + expect(mocks.writeRuntimeFile).toHaveBeenCalledWith(expect.anything(), filePath, 'edited', { + kind: 'user-file' + }) +} + +async function renameTab(newName: string, worktreePath: string): Promise { + const tab = onlyTab() + await renameFileOnDisk({ + oldPath: tab.filePath, + newName, + worktreeId: tab.worktreeId, + worktreePath, + operationOwner: { kind: 'local' }, + documentScoped: editorTabDocumentFolderAccess(useAppStore.getState(), tab) !== undefined + }) +} + +function renameCalls(): unknown[][] { + return mocks.renameRuntimePath.mock.calls.map((call) => call.slice(1)) +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ repos: [repo], worktreesByRepo: { 'repo-local': [worktree] } }) + clearFileExplorerUndoHistory() + mocks.renameRuntimePath.mockReset().mockResolvedValue(undefined) + mocks.writeRuntimeFile.mockReset().mockResolvedValue(undefined) + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + dispatchEvent: vi.fn() + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('renaming a floating-workspace document, then Undo and Redo', () => { + const NOTE = '/home/me/notes/note.md' + + // Why the moved file is the document on Undo: main widens a rename only from the opened document. + it.each([ + ['another outside folder', '../other/note.md', '/home/me/notes/../other/note.md'], + [ + 'a project', + '../../../Users/me/project/note.md', + '/home/me/notes/../../../Users/me/project/note.md' + ], + ['a subfolder', 'archive/note.md', '/home/me/notes/archive/note.md'] + ])('moves it into %s and keeps it user-named', async (_, newName, moved) => { + openUserNamedTab(NOTE, FLOATING_TERMINAL_WORKTREE_ID) + + await renameTab(newName, '/home/me/notes') + await expectUserNamedAt(moved) + expect(await undoFileExplorer()).toBe(true) + await expectUserNamedAt(NOTE) + expect(await redoFileExplorer()).toBe(true) + await expectUserNamedAt(moved) + + expect(renameCalls()).toEqual([ + [NOTE, moved, documentFolder(NOTE)], + [moved, NOTE, documentFolder(moved)], + [NOTE, moved, documentFolder(NOTE)] + ]) + }) +}) + +describe('renaming a tab opened by its full path in a project workspace', () => { + it.each([ + ['to another outside name', '/tmp/notes.md', 'notes2.md', '/tmp/notes2.md'], + ['into its own project', '/Users/me/notes.md', 'project/notes.md', `${PROJECT}/notes.md`], + [ + 'out of its own project', + `${PROJECT}/docs/plan.md`, + '../../plan.md', + `${PROJECT}/docs/../../plan.md` + ] + ])('keeps it user-named %s, after Undo too', async (_, original, newName, moved) => { + openUserNamedTab(original, WT) + + await renameTab(newName, PROJECT) + await expectUserNamedAt(moved) + expect(await undoFileExplorer()).toBe(true) + await expectUserNamedAt(original) + + expect(renameCalls()).toEqual([ + [original, moved, documentFolder(original)], + [moved, original, documentFolder(moved)] + ]) + }) + + it('keeps a full-path tab user-named when its project folder is moved', async () => { + const link = `${PROJECT}/docs/link.md` + openUserNamedTab(link, WT) + + await renameFileOnDisk({ + oldPath: `${PROJECT}/docs`, + newName: 'notes', + worktreeId: WT, + worktreePath: PROJECT, + operationOwner: { kind: 'local' } + }) + + await expectUserNamedAt(`${PROJECT}/notes/link.md`) + expect(renameCalls()).toEqual([[`${PROJECT}/docs`, `${PROJECT}/notes`, undefined]]) + }) + + it('still recomputes a project tab project-relative, with no declared access', async () => { + useAppStore.getState().openFile( + { + filePath: `${PROJECT}/docs/plan.md`, + relativePath: 'docs/plan.md', + worktreeId: WT, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + + await renameTab('plan2.md', PROJECT) + + const tab = onlyTab() + expect(tab.relativePath).toBe('docs/plan2.md') + expect(editorTabFileAccess(useAppStore.getState(), tab)).toBeUndefined() + expect(renameCalls()).toEqual([ + [`${PROJECT}/docs/plan.md`, `${PROJECT}/docs/plan2.md`, undefined] + ]) + }) +}) diff --git a/src/shared/local-file-access.ts b/src/shared/local-file-access.ts index f60921334ba0..1f39df10db0a 100644 --- a/src/shared/local-file-access.ts +++ b/src/shared/local-file-access.ts @@ -9,7 +9,7 @@ export type LocalFileAccess = | { kind: 'document-resource'; documentPath: string } /** An image a chat transcript or composer shows: any local image file; a network share only inside a project. */ | { kind: 'chat-image' } - /** A write beside a document the user opened: renaming it, or adding files, inside its own folder. */ + /** A document the user opened: renaming it to any path, or adding files in its own folder. */ | { kind: 'document-folder'; documentPath: string } /** Main's refusal of a request that resolves outside every root it may serve. */