diff --git a/src/main/ai-vault/local-log-tail-reader.ts b/src/main/ai-vault/local-log-tail-reader.ts index 3045981e5c5..9044a64e833 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/destination-serialized-local-rename.test.ts b/src/main/destination-serialized-local-rename.test.ts index 7bdab6a0914..6a969a6ee40 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/app.test.ts b/src/main/ipc/app.test.ts index aba55bee6e5..07b8b82f9a7 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)) { @@ -416,7 +416,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 ea74a240c38..d056727fdb7 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/automatic-load-path-text.ts b/src/main/ipc/automatic-load-path-text.ts new file mode 100644 index 00000000000..2bc92ef3444 --- /dev/null +++ b/src/main/ipc/automatic-load-path-text.ts @@ -0,0 +1,47 @@ +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|conin\$|conout\$|clock\$|com[0-9¹²³]|lpt[0-9¹²³])$/i + +/** + * `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 + } + const stem = + basename(filePath) + .replace(/[. ]+$/, '') + .split(/[.:]/)[0] ?? '' + 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`). WSL paths are UNC in form but stay on this machine, so they + * are not network paths. + */ +export function isNetworkSharePath(filePath: string): boolean { + if (!usesWindowsPaths() || isDeviceNamespacePath(filePath)) { + return false + } + const normalized = toBackslashes(filePath) + return normalized.startsWith('\\\\') && parseWslUncPath(normalized) === null +} diff --git a/src/main/ipc/filesystem-allowed-roots.test.ts b/src/main/ipc/filesystem-allowed-roots.test.ts index ce4a30c62fa..7d965f218d7 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 fa82b7a652c..12a9995eecf 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 620baa9b595..e031c6a91e1 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -1,7 +1,7 @@ import { resolve, dirname, basename } from 'node:path' -import { realpathSync } from 'node:fs' 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 { @@ -16,45 +16,16 @@ 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.' -// 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 {} -} - -/** - * 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. - */ +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[] } -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( @@ -63,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) ) @@ -81,6 +44,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( @@ -91,7 +56,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-document-folder-writes.test.ts b/src/main/ipc/filesystem-document-folder-writes.test.ts new file mode 100644 index 00000000000..6e8e50ad205 --- /dev/null +++ b/src/main/ipc/filesystem-document-folder-writes.test.ts @@ -0,0 +1,324 @@ +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 { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +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' + +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: () => [], + 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') + projectPaths = [] + registerFilesystemMutationHandlers(STORE) +}) + +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 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: join(docFolder, 'other.md'), + newPath: join(docFolder, 'moved.md'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect( + await settles( + call('fs:rename', { oldPath: note, newPath: '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('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']) + }) + + // 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') + + 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('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') + + await call('fs:rename', { oldPath: note, newPath: moved, access: documentFolder(note) }) + expect(await readdir(project)).toEqual(['note.md']) + + await call('fs:rename', { oldPath: moved, newPath: note, access: documentFolder(moved) }) + expect(await readdir(project)).toEqual([]) + expect(await readdir(docFolder)).toEqual(['note.md']) + }) + + it('moves it into a subfolder, and Undo brings it back', async () => { + await mkdir(join(docFolder, 'archive')) + const moved = join(docFolder, 'archive', 'note.md') + + 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', () => { + 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('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( + 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']) + }) +}) + +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 }) + 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([]) + }) + + 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-import-local-rollback.test.ts b/src/main/ipc/filesystem-import-local-rollback.test.ts index 7e196d700dc..b76fe597b35 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 c77d841a53d..d7960a2beeb 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 297dcb22045..3a6b0f2f86c 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.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index 6a5d502b14c..3c18eea6981 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-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index 9268c6ece24..a0cb300ba21 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' @@ -97,8 +96,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-import.test.ts b/src/main/ipc/filesystem-import.test.ts index 8047c19f62d..c2df10fd70b 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 18d05ab5c96..36db8630dc5 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 ad1308f0c30..e96650a7442 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -3,7 +3,12 @@ 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, + resolveLocalRenamePaths, + resolveLocalRequestPath +} from './local-file-access-resolution' +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' @@ -49,7 +54,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 +81,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 }) } @@ -89,7 +94,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,9 +117,14 @@ 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 }) - await renameLocalPathSerializedByDestination(oldPath, newPath) + // Outside every project, a document the user opened may still be renamed, to any path. + const { from, to } = await resolveLocalRenamePaths( + args.oldPath, + args.newPath, + args.access, + store + ) + await renameLocalPathSerializedByDestination(from, to) } ) @@ -133,10 +148,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 }) @@ -155,6 +170,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { destDir: string connectionId?: string ensureDir?: boolean + access?: LocalFileAccess } & SshMutationExpectation ): Promise<{ results: ImportItemResult[] }> => { assertSshMutationExpectation( @@ -180,7 +196,13 @@ 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) + // An image inserted into a document the user opened lands in that document's own folder. + const resolvedDest = await resolveLocalRequestPath( + args.destDir, + args.access, + store, + 'import-into' + ) const results: ImportItemResult[] = [] const reservedNames = new Set() diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts index de32bd8ebb4..7f6b4d96b17 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 6531f8a499a..9d5d8467e3e 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 d4096500381..db6f3c9c57c 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 }, @@ -43,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-harness.ts b/src/main/ipc/filesystem-test-harness.ts index a3a06b95e85..069f4c10d83 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 }, @@ -237,6 +238,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 +303,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 68946d56cff..8582a3b00ac 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) @@ -83,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({ @@ -361,8 +369,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 +383,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 +397,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 +411,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 +425,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 +442,7 @@ describe('registerFilesystemHandlers', () => { isBinary: true }) - expect(readFileMock).not.toHaveBeenCalled() + expect(handle.read).toHaveBeenCalledTimes(1) }) it('moves files to trash', async () => { @@ -519,8 +512,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 b140532c372..49473eb3f3f 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 @@ -9,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', @@ -19,25 +25,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 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, stats.size) + 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 readLocalFileBounded(handle, sizeLimit, stats.size) + 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 { - 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 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` - ) + throw fileTooLargeError(stats.size, MAX_TEXT_FILE_SIZE) } + const buffer = await readLocalFileBounded(handle, MAX_TEXT_FILE_SIZE, stats.size) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } @@ -62,17 +100,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 1d9fe681fee..7b51d3a54f4 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -4,13 +4,16 @@ 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' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' -import { resolveAuthorizedPath } from '../filesystem-auth' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import { + resolveDesktopAuthorizedPath, + resolveLocalFileRequestPath +} from '../local-file-access-resolution' import { isENOENT } from '../filesystem-path-containment' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' @@ -18,14 +21,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 { @@ -43,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) => ({ @@ -71,52 +70,21 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext 'fs:readFile', async ( _event, - args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } - ): Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> => { + 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) - 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 } + const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) + return args.includeLocalLogMetadata === true + ? readLocalLogSnapshot(filePath) + : readLocalFileContent(filePath) } ) @@ -143,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 } } @@ -173,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)) { @@ -189,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 94cc07a2f1f..63ee18a1d4b 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -26,7 +26,7 @@ import { getSshFilesystemProvider, requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { resolveAuthorizedPath } from '../filesystem-auth' +import { resolveDesktopAuthorizedPath } from '../local-file-access-resolution' import { listQuickOpenFiles } from '../filesystem-list-files' import { isFileNameFilterQueryTooLarge, @@ -52,7 +52,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 07d4de5b56a..ff614b8d35a 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -4,10 +4,15 @@ import type { SshMutationExpectation } from '../../../shared/ssh-types' import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' -import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import { + resolveDesktopAuthorizedPath, + resolveLocalWriteRequestPath +} from '../local-file-access-resolution' 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 @@ -16,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 + access?: LocalFileAccess + } & SshMutationExpectation ): Promise => { assertSshMutationExpectation( args.connectionId, @@ -28,7 +38,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex const provider = requireSshFilesystemProvider(args.connectionId) return 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()) { @@ -39,6 +49,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex throw error } } + await assertLocalWriteTargetIsRegularFile(filePath) await writeFile(filePath, args.content, 'utf-8') } ) @@ -64,7 +75,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex return provider.deletePath(args.targetPath, args.recursive) } // 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). @@ -84,8 +95,4 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) registerFilesystemMutationHandlers(store) - - ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { - authorizeExternalPath(args.targetPath) - }) } 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 00000000000..995e40ed09e --- /dev/null +++ b/src/main/ipc/filesystem/local-regular-file-read.test.ts @@ -0,0 +1,106 @@ +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, vi } 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() + } + }) + + 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'])( + '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 00000000000..acffa8cb109 --- /dev/null +++ b/src/main/ipc/filesystem/local-regular-file-read.ts @@ -0,0 +1,89 @@ +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, 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(nextChunkBytes, limit + 1 - total)) + const { bytesRead } = await handle.read(chunk, 0, chunk.length, total) + if (bytesRead === 0) { + return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, total) + } + chunks.push(chunk.subarray(0, bytesRead)) + total += bytesRead + 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 + } +} + +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() + } +} diff --git a/src/main/ipc/floating-workspace-directory.test.ts b/src/main/ipc/floating-workspace-directory.test.ts index 2b372586cf1..55438efe587 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, 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, 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, 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 9e5f7d34cb7..7c7a70cdd34 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' @@ -68,12 +67,14 @@ 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 ~. - authorizeExternalPath(cwd) return cwd } @@ -94,18 +95,16 @@ 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( - store: Store, +export async function trustFloatingWorkspaceDirectory( + store: Pick, dirPath: string ): Promise { const resolvedDir = resolveFloatingWorkspaceInput(dirPath) @@ -113,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 ec8016287d5..7199d39a0a4 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 7a71f3116c0..6e137bf6ca6 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/local-file-access-resolution-network-path.test.ts b/src/main/ipc/local-file-access-resolution-network-path.test.ts new file mode 100644 index 00000000000..b2340146526 --- /dev/null +++ b/src/main/ipc/local-file-access-resolution-network-path.test.ts @@ -0,0 +1,382 @@ +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(() => { + const calls: string[] = [] + return { fsCalls: calls } +}) + +vi.mock('node:fs/promises', () => { + const record = (name: string) => + vi.fn(async (target: unknown) => { + fsCalls.push(`${name} ${String(target)}`) + if (name !== 'realpath') { + 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') } +}) +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, + resolveLocalRenamePaths, + resolveLocalRequestPath +} from './local-file-access-resolution' + +// 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 besideTodo = { kind: 'document-folder', documentPath: 'C:\\Users\\me\\notes\\todo.md' } + +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 declared access too', async () => { + await expect(resolveLocalFileRequestPath(networkTargets[0], undefined, store)).rejects.toThrow( + 'Access denied' + ) + 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', + '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) => { + 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.each(deviceTargets)( + 'refuses %s as a new file beside an opened document without touching it', + async (target) => { + await expect( + resolveLocalRequestPath(target, besideTodo, store, 'import-into') + ).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( + resolveLocalRequestPath(target, besideTodo, store, 'import-into') + ).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') + }) +}) + +// 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 for both access kinds 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 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( + 'C:\\repo\\dev-link.png', + { kind: 'document-resource', documentPath: 'C:\\repo\\README.md' }, + store + ) + ).rejects.toThrow('Access denied') + }) +}) + +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', '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([]) + } + ) + + 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 = { + kind: 'document-resource', + 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 + }) + + 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 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 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([]) + }) + + // 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', + '\\\\attacker.example\\share\\x.png' + ])('refuses %s beside a share document without touching any share', async (target) => { + await expect( + resolveLocalFileRequestPath(target, besideShareDocument, shareProjectStore) + ).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.test.ts b/src/main/ipc/local-file-access-resolution.test.ts new file mode 100644 index 00000000000..e659a516c96 --- /dev/null +++ b/src/main/ipc/local-file-access-resolution.test.ts @@ -0,0 +1,469 @@ +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, + resolveLocalRenamePaths, + resolveLocalRequestPath, + resolveLocalWriteRequestPath, + type LocalRequestOperation +} from './local-file-access-resolution' +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 +const documentFolder = (documentPath: string) => + ({ kind: 'document-folder', 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-file-access-')) + 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('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 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) + } + } + ) + + 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)', () => { + 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') + }) + + 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', () => { + 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, 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') + }) + + 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') + } + ) + + it.skipIf(process.platform === 'win32')( + 'read a link of any name in a project or beside the document when its target stays there', + async () => { + 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, 'notes-link'), inProject, store)) + ).toBe('ok') + expect( + await settles( + resolveLocalFileRequestPath(join(outside, 'doc-folder', 'a.png'), besideDoc, store) + ) + ).toBe('ok') + } + ) +}) + +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(['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({}))) + ).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/local-file-access-resolution.ts b/src/main/ipc/local-file-access-resolution.ts new file mode 100644 index 00000000000..94054a0ddf2 --- /dev/null +++ b/src/main/ipc/local-file-access-resolution.ts @@ -0,0 +1,317 @@ +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 { + isDeviceNamespacePath, + isNetworkSharePath, + isWindowsReservedDeviceName +} from './automatic-load-path-text' +import { NOT_A_REGULAR_FILE_MESSAGE } from './filesystem/local-regular-file-read' + +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' } + +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( + 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. */ +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. + * 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) + } + return filePath +} + +// 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() + return Boolean(PREVIEWABLE_BINARY_MIME_TYPES[extension]) && extension !== '.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) +} + +/** + * 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. + */ +async function resolveDocumentResourcePath( + targetPath: string, + documentPath: string +): Promise { + if ( + typeof targetPath !== 'string' || + !isAbsolute(targetPath) || + typeof documentPath !== 'string' || + !isAbsolute(documentPath) + ) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const resolvedTarget = resolve(targetPath) + if (isRefusedAutomaticLoadPath(resolvedTarget)) { + 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) + } + return realTarget +} + +/** + * 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 (the default check); anywhere else its path text, + * like a device path, is refused before any filesystem call. + */ +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 (isRefusedAutomaticLoadPath(resolvedTarget)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + 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) + } + 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'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 +} + +/** + * Adding a file beside a document the user opened: the target must stay inside the document's own + * folder, symlinks included. + */ +async function resolveDocumentFolderPath( + targetPath: string, + documentPath: string +): 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 = resolve(await realpath(resolvedTarget)) + const realFolder = resolve(await realpath(documentFolder)) + if (isRefusedAutomaticLoadPath(realTarget) || !isDescendantOrEqual(realTarget, realFolder)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + return realTarget +} + +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. +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 === '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 && + typeof access.documentPath === 'string' + ) { + return { kind: 'document-resource', documentPath: access.documentPath } + } + return undefined +} + +/** 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': + // Why renames: resolveLocalRenamePaths declares this only for the opened document itself. + return operation !== 'import-into' + ? 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': + return operation === 'import-into' + ? (targetPath) => resolveDocumentFolderPath(targetPath, fileAccess.documentPath) + : 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, + 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) + const kindRule = fileAccess && declaredKindRule(fileAccess, operation, store) + if (!fileAccess || !kindRule) { + return resolveDesktopAuthorizedPath(targetPath, store, options) + } + if (typeof targetPath !== 'string') { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + // 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) + } + // 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 +} + +/** + * 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. */ +export function resolveLocalFileRequestPath( + targetPath: string, + access: unknown, + store: Store +): Promise { + return resolveLocalRequestPath(targetPath, access, store, 'read') +} + +/** A desktop save; user-file access adds the open file the user named. */ +export function resolveLocalWriteRequestPath( + targetPath: string, + access: unknown, + store: Store +): Promise { + return resolveLocalRequestPath(targetPath, access, store, 'write') +} diff --git a/src/main/ipc/local-log-tail-lifetime.test.ts b/src/main/ipc/local-log-tail-lifetime.test.ts index 4abf283b9ec..95d320164a8 100644 --- a/src/main/ipc/local-log-tail-lifetime.test.ts +++ b/src/main/ipc/local-log-tail-lifetime.test.ts @@ -13,12 +13,16 @@ vi.mock('electron', () => ({ handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler) } })) -vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: authorize })) +vi.mock('./local-file-access-resolution', () => ({ resolveUserNamedRegularFile: authorize })) import { closeAllLocalLogTailWatchers, 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,8 +45,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(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 5f7f46ab0f5..ce88830304e 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('./local-file-access-resolution', () => ({ + resolveUserNamedRegularFile: resolveAuthorizedPathMock +})) vi.mock('../ai-vault/local-log-tail-reader', () => ({ readLocalLogTailRange: readRangeMock @@ -29,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 @@ -64,7 +70,7 @@ beforeEach(() => { watchMock.mockReset() resolveAuthorizedPathMock.mockReset().mockImplementation(async (path: string) => path) readRangeMock.mockReset() - registerLocalLogTailHandlers({} as never) + registerLocalLogTailHandlers(NO_STORE) }) afterEach(() => { @@ -87,7 +93,7 @@ describe('local log tail IPC', () => { ) emitChange?.('change') - expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.anything()) + 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 0892665ad50..85d402910de 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,8 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' -import { resolveAuthorizedPath } from './filesystem-auth' +import type { Store } from '../persistence' +import { resolveUserNamedRegularFile } from './local-file-access-resolution' import { abortWhenRendererGone } from './renderer-lifetime-abort' type TailSenderOwner = { @@ -109,7 +109,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, store) if ( sender.isDestroyed() || owner.signal.aborted || @@ -147,7 +147,7 @@ export function registerLocalLogTailHandlers(store: Store): void { ipcMain.handle( 'fs:readLocalLogTail', async (_event, args: LocalLogTailReadArgs): Promise => { - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveUserNamedRegularFile(args.filePath, store) return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity) } ) 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 00000000000..d0036d1d38f --- /dev/null +++ b/src/main/ipc/notebook-project-link.test.ts @@ -0,0 +1,102 @@ +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') }) + ) + } +) + +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 961c652f316..f8bd1ddc567 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -1,5 +1,9 @@ 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 { afterEach, 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>() @@ -14,11 +18,23 @@ vi.mock('electron', () => ({ handlers.set(channel, handler) } })) -vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock })) +vi.mock('./local-file-access-resolution', () => ({ + resolveUserNamedRegularFile: resolveAuthorizedPathMock, + 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' +import type * as FileAccessResolution from './local-file-access-resolution' function fakeKernel() { let onFrame: (frame: KernelFrame) => void = () => {} @@ -122,6 +138,30 @@ describe('notebook IPC', () => { 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( + './local-file-access-resolution' + ) + 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 }) + } + }) + it.each(['shutdown', 'destroyed', 'did-navigate', 'render-process-gone'])( 'does not start a kernel after %s while path authorization is pending', async (boundary) => { @@ -229,9 +269,9 @@ describe('notebook IPC', () => { const old = start({ sender: owner }, alias) const fresh = start({ sender: owner }, canonical) handlers.get('notebook:shutdownKernel')!({ sender: owner }, alias) - aliasAuthorization.resolve(canonical.filePath) + aliasAuthorization.resolve(`/real${canonical.filePath}`) await expect(old).resolves.toMatchObject({ status: 'failed' }) - canonicalAuthorization.resolve(canonical.filePath) + canonicalAuthorization.resolve(`/real${canonical.filePath}`) await expect(fresh).resolves.toEqual({ status: 'ready' }) handlers.get('notebook:execute')!({ sender: owner }, { ...canonical, code: 'canonical' }) diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index f599734a03c..f5ec3109beb 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -1,8 +1,12 @@ import { randomUUID } from 'node:crypto' +import { realpath } from 'node:fs/promises' 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 './local-file-access-resolution' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' import { startNotebookKernel, type NotebookKernel } from '../notebook/notebook-kernel' import { @@ -57,6 +61,8 @@ function kernelsOf(owner: WebContents): Map { return kernels } +// 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', @@ -64,7 +70,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, 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 @@ -96,8 +102,9 @@ export function registerNotebookHandlers(store: Store): void { starts.set(args.filePath, pending) pending.add(controller) try { - // 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)) + // 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))) if (controller.signal.aborted || owner.isDestroyed()) { return { status: 'failed', detail: 'The notebook closed before its kernel started.' } } @@ -144,9 +151,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, store) 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/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts index 4d11bc18652..87d2c66c61a 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 28271774ce0..079549e4711 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 0c2bd457b1f..6a7b580e086 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-document-resolution.test.ts b/src/main/startup/os-opened-document-resolution.test.ts index 1267d51e5db..6c0f5990128 100644 --- a/src/main/startup/os-opened-document-resolution.test.ts +++ b/src/main/startup/os-opened-document-resolution.test.ts @@ -4,14 +4,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveOsOpenedDocuments } from './os-opened-documents' -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('resolveOsOpenedDocuments', () => { @@ -19,7 +15,6 @@ describe('resolveOsOpenedDocuments', () => { 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-')) @@ -48,17 +43,15 @@ describe('resolveOsOpenedDocuments', () => { name: 'design notes' } ]) - expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) } ) - it('never authorizes unsupported or relative files even when they exist', async () => { + it('drops unsupported or relative files even when they exist', async () => { const filePath = join(fileRoot, 'private.txt') await writeFile(filePath, 'private') expect(await resolveOsOpenedDocuments([filePath, 'relative.csv', 'file:///%zz.tsv'])).toEqual( [] ) - expect(authorizeExternalPath).not.toHaveBeenCalled() expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled() }) @@ -71,21 +64,16 @@ describe('resolveOsOpenedDocuments', () => { const documents = await resolveOsOpenedDocuments([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.csv') expect(await resolveOsOpenedDocuments([missingPath])).toEqual([]) - expect(authorizeExternalPath).not.toHaveBeenCalled() }) it('returns nothing for an empty input without touching the filesystem', async () => { expect(await resolveOsOpenedDocuments([])).toEqual([]) expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled() - expect(authorizeExternalPath).not.toHaveBeenCalled() }) }) diff --git a/src/main/startup/os-opened-documents.ts b/src/main/startup/os-opened-documents.ts index ec2b9c20e76..cdc78803ef3 100644 --- a/src/main/startup/os-opened-documents.ts +++ b/src/main/startup/os-opened-documents.ts @@ -2,7 +2,6 @@ import { stat } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { FileDocument } from '../../shared/filesystem-entry-types' -import { authorizeExternalPath } from '../ipc/filesystem-auth' import { ensureDefaultFloatingWorkspacePath } from '../ipc/floating-workspace-directory' import { fileDocumentFromFilePath, isMarkdownDocumentName } from '../ipc/markdown-documents' @@ -135,7 +134,7 @@ export class OsOpenedDocumentState { /** * Turns OS-handed paths into the same document 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 resolveOsOpenedDocuments( filePaths: readonly string[] @@ -152,14 +151,13 @@ export async function resolveOsOpenedDocuments( for (const filePath of supportedPaths) { try { // Why: the shell can hand over a directory named like a document, 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( fileDocumentFromFilePath(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 71ca0804c0e..e6fdd9f900b 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 0024c4b6d1f..cadbd677650 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 1bcf1749c30..79f9eabc066 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', () => ({ @@ -321,7 +318,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 e81140d734a..7873316ef3d 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/local-file-access-resolution' 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/main/window/clipboard-runtime-owned-ssh-paste.test.ts b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts index 6e09eb5fd3e..979c5b7d413 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 c5f25eca502..eada5ebc261 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 d8b74cd9304..926d54d7013 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: ( @@ -104,6 +107,7 @@ export type FilesystemApi = { oldPath: string newPath: string connectionId?: string + access?: LocalFileAccess } & SshMutationExpectation ) => Promise copy: ( @@ -120,16 +124,20 @@ export type FilesystemApi = { recursive?: boolean } & SshMutationExpectation ) => Promise - authorizeExternalPath: (args: { targetPath: string }) => Promise 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 @@ -147,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 d58dd17ff8f..b0c9adf644f 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: ( @@ -100,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: { @@ -116,19 +124,21 @@ export const fsApi = { recursive?: boolean } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:deletePath', args), - authorizeExternalPath: (args: { targetPath: string }): Promise => - ipcRenderer.invoke('fs:authorizeExternalPath', args), 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 @@ -157,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/browser-pane/describe-page/browser-artifact-upload.test.ts b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts index 5490e8de27a..c525d22a755 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/components/browser-pane/describe-page/browser-artifact-upload.ts b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.ts index 8288e2d306a..85a6ca22b0f 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-notebook-link.test.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts new file mode 100644 index 00000000000..38a0326840d --- /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 9627437b256..2f33e434982 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 type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { normalizeBrowserNavigationUrl, redactKagiSessionToken @@ -25,6 +21,7 @@ import type { BrowserTabPageState } from '../describe-page/browser-page-types' import type { MutableRefObject } from 'react' +import { statUserOpenedPath } from '@/lib/user-opened-local-path' export type NavigateBrowserPageToUrlArgs = { url: string @@ -107,17 +104,19 @@ 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) + 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/EditorPanelHeaderPath.test.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx index 14fd66b266d..04ca2e7264d 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/ExternalFileChangeBanner.test.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.test.tsx index eae2909bef5..a4c6598b307 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/ExternalFileChangeBanner.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx index dda2fd2d547..71a11eaa775 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.test.tsx b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.test.tsx index 2a906005555..799cf3d7285 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/ExternalFileChangeCompareDialog.tsx b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx index cb8230c3344..26cd9fbe95f 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/MarkdownPreview.image-access.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx new file mode 100644 index 00000000000..7e815d20f43 --- /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 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' +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/editor-header-file-rename.ts b/src/renderer/src/components/editor/editor-header-file-rename.ts index 5dc67d80358..e5df74ac141 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/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 8e25daabad7..1e6bbe22779 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' @@ -32,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' }) @@ -250,6 +254,56 @@ describe('attachRestoredTabConflictScan', () => { } }) + 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', { + relativePath: 'notes.txt', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }) + + const detach = attachRestoredTabConflictScan(store) + try { + await vi.advanceTimersByTimeAsync(10) + 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() + } finally { + detach() + } + }) + + 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).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/repo/file.ts', access: undefined }) + ) + } 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 92fde37b51e..72d44520981 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 { readRuntimeFileContent } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { canAutoSaveOpenFile } from './editor-autosave' @@ -44,9 +45,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 { @@ -66,7 +70,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 b5a0281470d..508edc4165b 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 00000000000..36818ef2d24 --- /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 { + // 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: [], + 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 declared access', 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 5e845031bd0..acef96fc54a 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.test.ts b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts index f7d60703fb3..3f4b8f8f447 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/markdown-preview-local-images.ts b/src/renderer/src/components/editor/markdown-preview-local-images.ts index 57d37f09d9e..bd14a540f51 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/markdown-preview-plugins.ts b/src/renderer/src/components/editor/markdown-preview-plugins.ts index 77fd6850872..84b36524587 100644 --- a/src/renderer/src/components/editor/markdown-preview-plugins.ts +++ b/src/renderer/src/components/editor/markdown-preview-plugins.ts @@ -16,7 +16,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/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index c7e69917c25..f7df5e88062 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 go through main's file + // checks; 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-image-insert-code-block.test.ts b/src/renderer/src/components/editor/rich-markdown-image-insert-code-block.test.ts index 7d2b4b9feee..e343914f2ac 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 aaad674547d..516067e4850 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', () => ({ @@ -55,17 +57,24 @@ function editorWithRunResult(runResult: boolean, markdown = 'hello world') { return { editor, chain, insertContentAt } } +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) @@ -92,12 +101,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({ @@ -115,7 +124,8 @@ describe('insertRichMarkdownImageFromPath', () => { worktreePath: '/folder-workspace' }), ['/tmp/image.png'], - '/folder-workspace' + '/folder-workspace', + { access: undefined } ) }) @@ -150,6 +160,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 0160ed433d0..1f6f020dfdb 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' @@ -69,10 +70,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/editor/rich-markdown-local-image.test.ts b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts index 056fbdd8d33..d6c74e235a3 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 6f5f0b0d63e..1b7d21b2dee 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/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 6df420f6abc..3ce87d778fc 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,35 +282,16 @@ 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' } + }) ) }) - 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/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index e7fdfc88754..25c0693f9dc 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 { editorTabFileAccess } from '@/lib/local-file-access' const inFlightFileReads = new Map>() @@ -120,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() @@ -155,17 +155,18 @@ 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. - 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. + // Why: a client-local external tab names a client path, so this read must stay off + // the worktree's SSH host. readConnectionId = undefined } } } const readScope = getRuntimeFileReadScope(readSettings, readConnectionId) - const key = inFlightReadKey(readScope, filePath) + const access = restoredOpenFile + ? editorTabFileAccess(useAppStore.getState(), restoredOpenFile) + : undefined + // 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 ( options?.force && @@ -178,15 +179,16 @@ export function useEditorPanelFileContentLoader({ } let pending = inFlightFileReads.get(key) if (!pending) { - const promise = readRuntimeFileContent({ + const promise: Promise = readRuntimeFileContent({ settings: readSettings, filePath, relativePath: readRelativePath, worktreeId: readWorktreeId, connectionId: readConnectionId, expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId, - includeLocalLogMetadata: isLiveTailLogTab - }) as Promise + includeLocalLogMetadata: isLiveTailLogTab, + access + }) 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 new file mode 100644 index 00000000000..457e437748d --- /dev/null +++ b/src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx @@ -0,0 +1,160 @@ +// @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' + +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 client-local editor tabs', () => { + let container: HTMLDivElement | null = null + let root: Root | null = null + + beforeEach(() => { + latestFileContents = {} + // Why an empty fs API: restoring must read with nothing re-granted or prepared first. + vi.stubGlobal('api', { fs: {} }) + 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 check the same file path as user-named. + it.each(['/Users/me/notes.txt', '/Users/me/analysis.ipynb'])( + 'reads %s as the file the user named 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(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ + filePath, + connectionId: undefined, + access: { kind: 'user-file' } + }) + ) + } + ) + + 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, + worktreeId: 'repo-local::/Users/me/project' + }) + + await act(async () => root?.render()) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ filePath, access: { kind: 'user-file' } }) + ) + }) + + 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' + }) + + await act(async () => root?.render()) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) + 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 c6ff09f8c97..600b79f8adf 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 08fcc4df79a..2a35b1308f9 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 } } + vi.stubGlobal('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/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index 1c93693ff1c..26ebe4de483 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 access kinds', () => { + 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 38339ee36ea..2e1224ecb97 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 access kind 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 2b5924e9826..068592d16c4 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: 'chat-image' + }) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 52e9a5f7c3a..819cfaeaf9e 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 { chatImageAccess } from '@/lib/local-file-access' type Props = { attachment: NativeChatComposerImageAttachment @@ -45,7 +46,10 @@ export function NativeChatImageAttachmentPreview({ const localSrc = useLocalImageSrc( !isPending && (isNearViewport || isOpen) ? attachment.path : undefined, attachment.path, - attachment.connectionId + attachment.connectionId, + undefined, + // 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/NativeChatTranscriptChrome.test.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx index 3992bbce1f5..2ba15a08d4c 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([ + ['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 }], + runtimeContext: runtimeContext('wt-1') + }) + ) + await flushPromises() + }) + + 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 4dea3e2d617..402c82816a8 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx @@ -15,6 +15,7 @@ import { } from '@/components/editor/useLocalImageSrc' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { chatImageAccess } from '@/lib/local-file-access' type VisibilityListener = (isVisible: boolean) => void @@ -70,6 +71,10 @@ function transcriptImageIdentity( }` } +// 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() + function TranscriptImagePreview({ block, runtimeContext @@ -90,7 +95,8 @@ function TranscriptImagePreview({ leaseActive && !external && runtimeContext !== undefined ? source : undefined, filePath, runtimeContext?.connectionId, - runtimeContext + runtimeContext, + TRANSCRIPT_IMAGE_ACCESS ) const displaySrc = external && leaseActive ? source : localSrc const label = @@ -124,9 +130,10 @@ function TranscriptImagePreview({ return } if (!leaseActive) { - releaseLocalImageSrc(source, filePath, context.connectionId, context) + releaseLocalImageSrc(source, filePath, context.connectionId, context, TRANSCRIPT_IMAGE_ACCESS) } - return () => releaseLocalImageSrc(source, filePath, context.connectionId, context) + return () => + releaseLocalImageSrc(source, filePath, context.connectionId, context, TRANSCRIPT_IMAGE_ACCESS) }, [external, filePath, leaseActive, runtimeContext, source]) const showPreview = 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 508a7c20e5e..f25d5d86381 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 chat images and leaves the other pane untouched', async () => { + intake.readFile.mockImplementation(async ({ access }: { access?: { kind: string } }) => { + if (access?.kind !== 'chat-image') { 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 341e242901f..088763d835d 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 1d8fa94eeb2..3c63879d198 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,11 +114,9 @@ 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 } - } as unknown as Window['api'] + vi.stubGlobal('api', { fs: { stat: mocks.stat } }) }) afterEach(() => { @@ -135,52 +133,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 +187,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 +199,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 +213,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 +223,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 +237,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 +302,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 98d3bc81446..5e88f793843 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 bde07f87540..6baa96675e6 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 2f4b6ff495c..5b941719ad4 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 ) - - 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 - } - - 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." - ) - ) - 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) + 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 + } + ) + + if (existingWritableTab) { + toast( + translate( + 'auto.components.right.sidebar.aiVaultSessionLogOpen.alreadyEditable', + 'Log is already open for editing.' + ) + ) + } + + 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 d14caca306a..039854bf0d8 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 2eca309a9e3..dfafbcfd029 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 0b55ffbc3a1..efe5f476744 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' @@ -37,7 +40,6 @@ describe('activateFileExplorerNode', () => { canToggleDirectories: false, loadDir: vi.fn(), statPath: vi.fn(), - authorizeExternalPath: vi.fn(), markPathAsDirectory: vi.fn(), setSelectedPath }) @@ -59,7 +61,6 @@ describe('activateFileExplorerNode', () => { toggleDir, loadDir, statPath: vi.fn().mockResolvedValue({ isDirectory: true }), - authorizeExternalPath: vi.fn(), markPathAsDirectory, setSelectedPath: vi.fn() }) @@ -74,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({ @@ -97,7 +151,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 +168,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 +192,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 5b91445639a..3464763cf55 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts @@ -38,8 +38,7 @@ type UseFileExplorerHandlersParams = { depth: number, options?: { force?: boolean; failOnError?: boolean } ) => Promise - statPath: (path: string) => Promise<{ isDirectory: boolean }> - authorizeExternalPath: (args: { targetPath: string }) => Promise + statPath: (path: string) => Promise<{ isDirectory: boolean; escapesWorktree?: boolean }> 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 @@ -90,23 +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 { - // 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 + 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 @@ -138,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), @@ -165,7 +170,6 @@ export function useFileExplorerHandlers({ canToggleDirectories = true, loadDir, statPath, - authorizeExternalPath, markPathAsDirectory, setSelectedPath, scrollRef @@ -187,7 +191,6 @@ export function useFileExplorerHandlers({ canToggleDirectories, loadDir, statPath, - authorizeExternalPath, markPathAsDirectory, setSelectedPath }) @@ -200,7 +203,6 @@ export function useFileExplorerHandlers({ markPathAsDirectory, openFile, statPath, - authorizeExternalPath, toggleDir, setSelectedPath ] diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index ee186eac965..fe1cea422a6 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/settings/KeybindingsFileActions.tsx b/src/renderer/src/components/settings/KeybindingsFileActions.tsx index 5d355b10949..f9b1288a73e 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/sidebar/useSidebarProjectDrop.ts b/src/renderer/src/components/sidebar/useSidebarProjectDrop.ts index f44048aea87..90f40d0f8f2 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 @@ -79,8 +80,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/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index f8d930b044c..592c9579568 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 34d173c246a..9a651ab75f2 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 may be renamed to any path, wherever it lives. + documentScoped: editorTabDocumentFolderAccess(useAppStore.getState(), file) !== undefined }) } 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 2b60d1d2506..7e201f7fc6c 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,20 +1,20 @@ 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 type { statUserOpenedPath, UserOpenedPathStat } from '@/lib/user-opened-local-path' type AbsoluteFileOperations = { assertAbsolutePathAllowed: () => void - authorizeExternalPath: (args: { targetPath: string }) => Promise openFile: ( file: Omit, options?: { preview?: boolean; targetGroupId?: string } ) => void - statRuntimePath: typeof statRuntimePath + statUserOpenedPath: typeof statUserOpenedPath } export async function openAbsoluteTabEntryFile(args: { @@ -28,11 +28,9 @@ 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> + let stat: UserOpenedPathStat try { - stat = await args.operations.statRuntimePath(args.context, filePath) + stat = await args.operations.statUserOpenedPath(args.context, filePath) } catch { throw new Error(`File not found: ${filePath}`) } @@ -44,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 880bb561db4..93d007adf31 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,7 @@ 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), + statUserOpenedPath: vi.fn().mockResolvedValue({ isDirectory: false, escapesWorktree: false }), assertAbsolutePathAllowed: vi.fn(), ...overrides } @@ -254,7 +254,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,8 +264,7 @@ describe('openTabEntryWithOperations', () => { operations }) - expect(operations.authorizeExternalPath).toHaveBeenCalledWith({ targetPath: '/tmp/notes.md' }) - expect(operations.statRuntimePath).toHaveBeenCalledWith( + expect(operations.statUserOpenedPath).toHaveBeenCalledWith( baseArgs.runtimeContext, '/tmp/notes.md' ) @@ -298,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() @@ -311,13 +331,12 @@ describe('openTabEntryWithOperations', () => { }) ).rejects.toThrow(TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE) - expect(operations.authorizeExternalPath).not.toHaveBeenCalled() - expect(operations.statRuntimePath).not.toHaveBeenCalled() + expect(operations.statUserOpenedPath).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( @@ -329,8 +348,7 @@ describe('openTabEntryWithOperations', () => { }) ).rejects.toThrow('Enter an absolute path for this computer.') - expect(operations.authorizeExternalPath).not.toHaveBeenCalled() - expect(operations.statRuntimePath).not.toHaveBeenCalled() + expect(operations.statUserOpenedPath).not.toHaveBeenCalled() expect(operations.openFile).not.toHaveBeenCalled() }) @@ -346,23 +364,22 @@ describe('openTabEntryWithOperations', () => { operations }) - expect(operations.authorizeExternalPath).toHaveBeenCalledWith({ - targetPath: 'C:/tmp/notes.md' - }) - expect(operations.statRuntimePath).toHaveBeenCalledWith( + expect(operations.statUserOpenedPath).toHaveBeenCalledWith( baseArgs.runtimeContext, 'C:/tmp/notes.md' ) }) - 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<{ isDirectory: boolean; escapesWorktree: boolean }>( + (resolve) => { + releaseStat = () => resolve({ isDirectory: false, escapesWorktree: false }) + } + ) let allowed = true const operations = makeOperations({ - authorizeExternalPath: vi.fn(() => authorization), + statUserOpenedPath: vi.fn(() => statResult), assertAbsolutePathAllowed: vi.fn(() => { if (!allowed) { throw new Error(TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE) @@ -376,15 +393,14 @@ describe('openTabEntryWithOperations', () => { query: '/tmp/notes.md', operations }) - await vi.waitFor(() => expect(operations.authorizeExternalPath).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(operations.statUserOpenedPath).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 6c7ec6bbdb5..4fedc19935b 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,7 +67,7 @@ export type TabEntryOperations = { options?: { preview?: boolean; targetGroupId?: string } ) => void statRuntimePath: typeof statRuntimePath - authorizeExternalPath: (args: { targetPath: string }) => Promise + statUserOpenedPath: typeof statUserOpenedPath assertAbsolutePathAllowed: () => void } @@ -290,7 +291,7 @@ export async function openTabBarEntry(args: TabCreateEntryArgs): Promise { openWorkspaceBrowserTab, openFile: state.openFile, statRuntimePath, - authorizeExternalPath: window.api.fs.authorizeExternalPath, + statUserOpenedPath, 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 21bb89d39aa..9680392f5ee 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,6 +22,7 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +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). */ @@ -173,11 +173,7 @@ 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) + 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. @@ -239,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-click-fallback.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-click-fallback.test.ts index f7c92a460bc..7ded98221b3 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-escaping-file-open.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-escaping-file-open.test.ts new file mode 100644 index 00000000000..6c5815564fb --- /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-handlers-test-fixtures.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts index 12751c02373..f609b05aaa6 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 5230fa00ed2..43fd7a05a6d 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 500975a29b8..6f91f728650 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: { @@ -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 }) @@ -58,19 +59,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 c537d57ba0f..2c59fd08101 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,7 @@ 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' })) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/test.txt' }), { forceContentReload: true } @@ -88,9 +81,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' }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: 'C:/repo/src/index.ts' }), { forceContentReload: true } @@ -118,9 +111,9 @@ describe('handleOscLink', () => { ) await flushAsyncWork() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ - targetPath: '//server/share/repo/test.txt' - }) + expect(statMock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '//server/share/repo/test.txt' }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '//server/share/repo/test.txt' }), { forceContentReload: true } @@ -140,7 +133,6 @@ describe('handleOscLink', () => { ) await flushAsyncWork() - expect(authorizeExternalPathMock).not.toHaveBeenCalled() expect(openFileMock).not.toHaveBeenCalled() }) @@ -151,7 +143,7 @@ describe('handleOscLink', () => { await flushAsyncWork() await flushDoubleRaf() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/test.txt' }) + expect(statMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/tmp/test.txt' })) expect(openFilePathMock).not.toHaveBeenCalled() expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/test.txt' }), @@ -177,7 +169,7 @@ describe('handleOscLink', () => { ) await flushAsyncWork() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/test.txt' }) + expect(statMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/tmp/test.txt' })) expect(openFilePathMock).toHaveBeenCalledWith('/tmp/test.txt') expect(openFileMock).not.toHaveBeenCalled() expect(setPendingEditorRevealMock).not.toHaveBeenCalled() @@ -195,7 +187,7 @@ describe('handleOscLink', () => { await flushAsyncWork() await flushDoubleRaf() - expect(authorizeExternalPathMock).toHaveBeenCalledWith({ targetPath: '/tmp/test.txt' }) + expect(statMock).toHaveBeenCalledWith(expect.objectContaining({ filePath: '/tmp/test.txt' })) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/test.txt' }), { forceContentReload: true } @@ -224,9 +216,9 @@ 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' }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '//server/Share/Repo/src/app.ts', @@ -258,9 +250,9 @@ 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' }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/project/docs/README.md', @@ -287,9 +279,11 @@ 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' + }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md' @@ -320,9 +314,11 @@ 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' + }) + ) expect(openFileMock).toHaveBeenCalledWith( expect.objectContaining({ filePath: '\\\\wsl.localhost\\Ubuntu\\root\\workspace\\myrepo\\README.md' @@ -353,9 +349,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' }) + ) 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 67562aec052..2df62eb3e99 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 1920afc3643..66ece84e48c 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' }) + ) 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 5ef937f9ed2..744cd12f988 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: { @@ -70,7 +64,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 }) @@ -183,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 9b72a3d0dba..31900f8202d 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 577d7c1fdf1..6709b5e76f3 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 03d1f15dc24..3358a7e028e 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 { @@ -220,11 +221,7 @@ 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 }) + const stat = await window.api.fs.stat({ filePath, access: userNamedFileAccess() }) results.push({ status: 'imported', destPath: filePath, 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 00000000000..c03f7a44146 --- /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 e201893088b..18f01ad0ff7 100644 --- a/src/renderer/src/hooks/useGlobalFileDrop.ts +++ b/src/renderer/src/hooks/useGlobalFileDrop.ts @@ -8,8 +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' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -21,6 +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 { statUserOpenedPath } from '@/lib/user-opened-local-path' export function getEditorFileDropSettingsForWorktree( store: WorktreeRuntimeOwnerState, @@ -160,18 +159,19 @@ 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) + 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 @@ -191,7 +191,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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8991d2192cb..d38846bea87 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13497,7 +13497,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}}.", @@ -13765,7 +13766,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 8b224926b3e..0ed1d209c1e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11545,7 +11545,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}}.", @@ -11798,7 +11799,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 beb91b07eca..afaef580140 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -13319,7 +13319,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}}.", @@ -13587,7 +13588,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 30cfa9d7051..3f9aed9a822 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13217,7 +13217,8 @@ "fca915a67a": "リモートの項目は完全に削除され、取り消せません。ローカルの項目は {{value0}} に移動します。" }, "useFileExplorerHandlers": { - "32cd9fd991": "シンボリックリンクターゲットを開けません" + "32cd9fd991": "シンボリックリンクターゲットを開けません", + "folderLinksOutsideProject": "このフォルダーはプロジェクト外へのリンクのため、ここでは開けません。" }, "useFileExplorerImport": { "25919b2050": "{{value0}} {{value1}} をスキップしました。", @@ -13485,7 +13486,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 522a0a6de00..33c5f8c86b9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13217,7 +13217,8 @@ "fca915a67a": "원격 항목은 영구적으로 삭제되며 되돌릴 수 없습니다. 로컬 항목은 {{value0}}(으)로 이동합니다." }, "useFileExplorerHandlers": { - "32cd9fd991": "심볼릭 링크 대상을 열 수 없습니다" + "32cd9fd991": "심볼릭 링크 대상을 열 수 없습니다", + "folderLinksOutsideProject": "이 폴더는 프로젝트 외부로 연결되어 있어 여기에서 열 수 없습니다." }, "useFileExplorerImport": { "25919b2050": "{{value0}} {{value1}}을(를) 건너뛰었습니다.", @@ -13485,7 +13486,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 473fc6de55c..b3bf3c86ed1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13269,7 +13269,8 @@ "fca915a67a": "远程项目将永久删除且无法撤销。本地项目将移至 {{value0}}。" }, "useFileExplorerHandlers": { - "32cd9fd991": "无法打开符号链接目标" + "32cd9fd991": "无法打开符号链接目标", + "folderLinksOutsideProject": "此文件夹链接到项目之外,无法在此打开。" }, "useFileExplorerImport": { "25919b2050": "跳过 {{value0}} {{value1}}。", @@ -13537,7 +13538,6 @@ }, "aiVaultSessionLogOpen": { "workspaceGone": "无法打开日志 — 工作区已不可用。", - "notAuthorized": "无法打开日志 — 路径未授权。", "alreadyEditable": "日志已处于编辑状态。" }, "github": { 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 76c5bbc1c7e..3798a305445 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 09f89194296..ca239902770 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 allows any destination, and the move back. */ + 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 new file mode 100644 index 00000000000..a1a8a90dd5a --- /dev/null +++ b/src/renderer/src/lib/local-file-access.test.ts @@ -0,0 +1,307 @@ +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 { editorTabDocumentFolderAccess, 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 TabFileAccessFields = Parameters[1] + +function accessKind(file: TabFileAccessFields): 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, TabFileAccessFields, 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' + ], + [ + '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', + { + 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) + }) +}) + +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('declares the file itself for writes on a local user-named tab', () => { + 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 +// 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/native-chat/use-native-chat-external-attachments.ts', + 'components/sidebar/useSidebarProjectDrop.ts', + 'hooks/composer-state/attachment-drop-state.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. +// 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', + '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-document-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) && + !/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('lists every file that builds user-file access', () => { + expect( + rendererFilesMatching((source) => /\buserNamedFileAccess\b|kind: 'user-file'/.test(source)) + ).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 new file mode 100644 index 00000000000..d93b1d30e84 --- /dev/null +++ b/src/renderer/src/lib/local-file-access.ts @@ -0,0 +1,89 @@ +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' } +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 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 } +} + +/** 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 } +} + +type EditorTabAccessFile = Pick< + OpenFile, + | 'filePath' + | 'relativePath' + | 'worktreeId' + | 'runtimeEnvironmentId' + | 'externalSshTargetId' + | 'readOnly' + | 'liveTail' +> + +/** + * 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. + */ +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 +} + +/** + * 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, + 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/remap-open-editor-tabs-for-path-change.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts index 645ad60ce99..fcac9a33584 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 71161349e13..bfadcc41fd9 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 (and its Undo) may go anywhere. */ + 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/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 00000000000..d7a70867173 --- /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/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 00000000000..a00a0e37a94 --- /dev/null +++ b/src/renderer/src/lib/user-opened-local-path.test.ts @@ -0,0 +1,101 @@ +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 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 }) => { + if (access ? !userNamedOk : !containedOk) { + throw new Error( + access + ? 'ENOENT: no such file' + : 'Access denied: path resolves outside allowed directories.' + ) + } + 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.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) + + 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 00000000000..c6ef7d5632b --- /dev/null +++ b/src/renderer/src/lib/user-opened-local-path.ts @@ -0,0 +1,52 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { isPathOutsideAllowedDirectoriesError } from '../../../shared/local-file-access' +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) { + // 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()) + } catch { + throw containedError + } + return { isDirectory: stat.isDirectory, escapesWorktree: true } + } +} diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts index e039cc515eb..8597316794f 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,56 @@ describe('mobile markdown bridge', () => { } }) + 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', + 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 }) + setupWindow({ readFile }) + 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(readFile).toHaveBeenCalledWith( + expect.objectContaining({ filePath: '/Users/me/notes.md', access: { kind: 'user-file' } }) + ) + } finally { + detach() + } + }) + + 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 c51a7656843..ef26415f9b1 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 { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { readRuntimeFileContent } from './runtime-file-client' @@ -243,14 +244,15 @@ 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, worktreeId: file.worktreeId, connectionId, - expectedExternalSshTargetId: file.externalSshTargetId - })) as FileContent + expectedExternalSshTargetId: file.externalSshTargetId, + access: editorTabFileAccess(state, file) + }) 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 6525fc7e357..9fe3f342bfc 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 + /** File access of the local fallback read; remote reads stay root-relative. */ + access?: LocalFileAccess } export type RuntimeFileOperationArgs = { diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 2aebfbcfa8c..7bb142cc33a 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-metadata-client.ts b/src/renderer/src/runtime/runtime-file-metadata-client.ts index e9d2e867b77..e89c2c1e299 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 b2a358a5200..dbb0c55f7b9 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 } @@ -88,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/renderer/src/runtime/runtime-file-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts index 40b74611c67..f0559975291 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, 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 005ac4b9972..f5d005c8e13 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 17af9ca5211..435173e0e98 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 889e89b5103..ab7062504a1 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', { diff --git a/src/shared/local-file-access.ts b/src/shared/local-file-access.ts new file mode 100644 index 00000000000..1f39df10db0 --- /dev/null +++ b/src/shared/local-file-access.ts @@ -0,0 +1,22 @@ +/** + * 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 = + /** 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 } + /** An image a chat transcript or composer shows: any local image file; a network share only inside a project. */ + | { kind: 'chat-image' } + /** 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. */ +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) +}