From 51fe6f3fbadc7281e42d4974542aba9cd0e4b874 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:55:32 -0700 Subject: [PATCH] fix(editor): restored tabs for files outside your projects no longer fail with Access denied (#24489) * fix(editor): read files outside projects without a grant a restart loses A file opened from outside every project (e.g. ~/notes.txt from the floating workspace) read through an in-memory grant. After a restart the restored tab only renewed that grant when it stored a full path, so a tab saved relative to the floating workspace folder failed with "Access denied" and Retry repeated it. Single-file reads (read, stat, exists) and open-editor-tab saves now resolve a path outside every project in place. Paths inside a project keep the full containment check, so a project's symlinks still cannot escape it, and every other write stays inside projects. * fix(editor): re-grant restored floating-workspace tabs by owner, not path shape Problem: a file opened from the floating workspace (e.g. ~/notes.txt via Cmd-click in the floating terminal, the floating markdown picker, or a .md opened from the OS) loads until restart, then shows "Access denied: path resolves outside allowed directories". Main's external-path grants live only in memory. On restore the editor re-granted only tabs that stored an absolute path, but floating tabs store a path relative to the floating root (~ by default), which is deliberately not an authorized root, so they were never re-granted. Restored floating notebooks also failed to start a kernel. The previous commit on this branch let main read and save any path outside a project without a grant. That widened fs:readFile/stat/pathExists for every caller, including automatic reads of untrusted content (markdown preview images), which opened a Windows UNC credential leak and a /dev/zero main-process memory blowup. This reverts that model entirely. Fix: one helper decides which client-local path a tab needs re-granted by ownership: a floating-workspace tab, or a tab stored outside its own project. It never grants paths a local project root covers (a grant would also authorize a project symlink's outside target), and skips SSH-owned, runtime-owned and not-yet-hydrated owners. Every reader that can touch a restored tab before or without the editor loader uses it: the loader, the restored dirty-tab conflict scan, and the paired-mobile markdown bridge. * fix(editor): let main decide which restored-tab paths a project already covers Problem: the restore re-grant helper decided "already inside a project" in the renderer from its worktree list. At startup that list only holds repos the session references, so a floating tab inside an unlisted repo was granted (including a symlink's outside target), and the renderer's path matcher disagrees with main's on WSL \\wsl$ vs \\wsl.localhost, which stranded a folder-workspace tab with "Access denied" after restart. The helper also treated a folder workspace with a missing or ambiguous host as local. Fix: the renderer now decides only by owner (a floating-workspace tab, or a tab stored outside a project whose owner is explicitly local) and asks main with `skipIfInsideAllowedRoots`. Main checks the path against its own allowed and registered roots, in both the named and canonical-parent spelling against both root spellings: a path a project covers gets no grant, an alias spelling of a project path gets only that spelling, and a project symlink's outside target is never granted. Explicit-open grants (Cmd-click, drag, explorer) are unchanged. Tests now prove each reader waits for the grant before reading. * fix(fs): decide a restored tab's project membership from every ancestor's real path Problem: the restore re-grant decided "inside a project" from the named path and the real path of its parent only. When a tab path crossed a project directory symlink and named the project through a spelling that was neither the registered root nor its realpath (a second alias, a `..` segment, a case variant on a case-insensitive disk, a /var-style alias of an ancestor), no check matched, the path took the full grant, and the symlink's outside target became readable and writable. Fix: a path is inside a project when the named path is inside a root, or the real path of any ancestor folder is inside a root in its registered or real spelling. Such a path gets at most its named spelling, never its realpath. An ancestor that fails to resolve for any reason other than "missing" now fails closed to the named-spelling grant instead of falling through to the full one. Tests cover each spelling; the non-symlink case also runs on Windows. * fix(fs): read local files as regular files only, from one bounded handle Problem: fs:readFile stat'ed a path and then read it to EOF. A character device such as /dev/zero reports size 0, passes the size limit and never ends, so the main process buffers until memory runs out; a FIFO hangs the open. Writes could also target an existing device or FIFO. Fix: every local fs:readFile (editor and log snapshot) opens the path once, non-blocking, refuses anything but a regular file, and reads the size check, binary probe and content from that same handle, capped at the limit even if the file lies about its size. The AI Vault log tail opens non-blocking too, and fs:writeFile refuses an existing non-regular target. * feat(fs): let desktop file requests declare their shape Problem: main decided every local file request against one allow-list plus a set of in-memory grants the renderer had to recreate after every restart, so a file the user opened outside a project (for example from the floating workspace) was denied once Orca restarted. This adds the request shape the common pattern uses, alongside the grants for now: - no shape (the default): the path must be inside a project root main recognises, symlinks included. Desktop requests also accept the app-owned floating-workspace folder; paired-client RPC never does. - user-file: a single file the user named by absolute path, used in place. Only fs:readFile/stat/pathExists and saving (fs:writeFile) accept it. - document-resource: an image or PDF a document references, limited to every project root when the document is in one, else to the document's folder, and refused by path text before any disk or network access. Notebook kernels and AI Vault log tails check their open file as user-named. * feat(editor): send each local file request's shape from the renderer Problem: after a restart, a tab opened outside every project (a floating workspace file, a file opened by absolute path, an OS-opened markdown) could only be read if the renderer first re-granted its path, and readers that ran before the editor loaded the tab had no grant at all. The renderer now says what kind of request it is making, and main checks that: - A persisted tab opened outside its owner's root (floating workspace, or an absolute stored path) whose owner is explicitly local reads and saves as a user-named file, from every reader: the editor loader, the restored-tab conflict scan, the change banner and compare dialog, the paired-phone markdown bridge and the save queue. Project tabs stay inside their root. - Clicks, drops, typed paths and browser-opened notebooks stat as user-named. - Markdown preview and rich-editor images are document resources, limited to the document's roots or folder. Images the user pasted or attached into a chat show as user-named; agent images stay inside the project. - The image cache keys on the shape, so one shape's image never answers another's request. A ratchet test lists every renderer file allowed to create a user-named request. * refactor(fs): delete the in-memory path grant system Problem: main kept a set of paths the renderer had asked it to allow (fs:authorizeExternalPath). The set lived only in memory, so a file the user opened outside every project could be read until Orca restarted and was then denied, and every new reader of a restored tab had to remember to recreate the grant first. Three rounds of re-deriving grants at restore each found another reader or path spelling it missed. Now that every desktop request declares its shape, nothing needs a grant: - delete the grant set, authorizeExternalPath, the restore re-grant from the earlier commits on this branch, the fs:authorizeExternalPath channel and its preload and web-client entries; - delete every renderer grant call (terminal and markdown link clicks, drops, typed paths, the file explorer, AI Vault logs, chat attachments, browser notebooks) and every main one (floating markdown picker and folder, OS-opened markdown, keybindings.json, pasted images, import and upload sources); - the floating workspace's picker-approved folders stay a terminal-cwd allowlist only. Main now holds no per-path permission, so a restart can't change any answer. * feat(editor): open project links that lead outside the project as named files Problem: a file inside a project that is a symlink to something outside it opened fine from the file explorer or a terminal Cmd-click, then showed "Access denied" after a restart: its tab was stored as a project file, and a project request is refused when it resolves out of the project. A folder link out of the project expanded in the explorer until restart and then failed with a raw access error. Now the click decides and the tab keeps that decision. Both gestures stat the path inside the project first; if only the user-named check passes, the path leads out of the project: - a file opens by its absolute path, so it reads and saves as a file the user named, the same before and after a restart; - the explorer does not follow a folder link out of the project and says so ("This folder links outside the project, so it can't be opened here."). Paths that stay inside the project still open as contained project tabs. Also drops the AI Vault "path not authorized" message, which nothing shows now. * chore: drop the casts the changed-code quality gate flags on this branch The FileContent casts in the editor loader and the paired-phone markdown bridge were never needed (the read result is already assignable). Tests stub window.api through vi.stubGlobal and pass narrow stores without casting; the one test store that still needs a cast states why. * fix(fs): load chat images by type, and keep escaping project links readable Problems found in review: - Chat transcript images were trusted by message role: any user-role "[Image: source: ]" (an injected Claude record, `orca terminal send`, a paired client's image-ref) became an automatic user-named read as the row scrolled into view, of any file type, and on Windows a network-share path would have opened an SMB connection to that host. - A document image named like an image but linking to a text file (logo.png -> .env) was read as text. - Windows device names (NUL.png, COM1.jpg) passed the path-text check of the automatic image loads. - A project symlink leading out of the project, opened by a typed path, a tab-strip drop or a browser file:// notebook, was stored as a project tab and immediately refused. Fix: - New chat-image request shape for every transcript image and the composer preview, whoever's turn named it: an absolute local path whose requested and real targets are image files, a regular file, size-capped; network-share and device-namespace paths and Windows device names are refused by path text before any filesystem call. Pasted screenshots still show after a restart, and agent images outside the project now render. - Document resources check the real target's type too, and refuse Windows device names by path text. - Typed paths, tab-strip drops and browser notebooks stat through the same check as the explorer and terminal, and open an escaping link by its absolute path. - Tests pin the shape at the change banner, compare dialog, markdown preview and image prewarm; a second ratchet lists every file that can open a tab the tab rule reads as user-named, and its comment says what it can't see. - Stale grant wording removed. * fix(fs): tighten automatic image loads and the project-link check Problems found in review: - Two unit tests went red on this branch: the browser-share test still expected reads without a shape, and the rename test's electron mock had no app, which the desktop root check now needs. - The device-name check ran on the raw path, so `NUL.png\.` or `COM1.png\x\..` (reachable from markdown `![](NUL.png%2F.)`) reached the filesystem; a document image whose real target was a device name passed. - Chat images in a project that lives on a Windows network share no longer rendered, though the markdown preview showed them. - Any failed project check (a missing file, a dropped connection) was taken as "this link leads out of the project" and opened as an absolute tab. - Every local read allocated about 2 MiB, even for a tiny image. Fix: - Device names and device-namespace paths are checked on the resolved path and on the real target, for chat images and document resources alike. - A network-share path in an automatic load is read only inside a project root (the user chose that share when adding the project); anywhere else it is still refused by path text before any filesystem call. - Only main's "outside allowed directories" refusal marks a project path as leading out of the project; other errors surface as before. The message now lives in shared code so both sides agree on it. - Reads size their first buffer from fstat and confirm EOF with a 1-byte probe; a file that grows past its reported size is still read in bounded chunks up to the cap. - Fixed the two red tests. * refactor(fs): name file access by its role, not its structure Problem: the static-analysis anti-slop check failed the PR because the new code named the request's file access a "shape" (`shape`, `RequestShape`, `TabShape`), which describes structure rather than the role. Rename the main-process module filesystem-request-shape.ts (and its tests) to local-file-access-resolution.ts, rename the symbols to fileAccess, FileAccessResolution and TabFileAccessFields, and say "file access" or "access kind" in the comments and test names. No behaviour change. * fix(fs): refuse every Windows device-name spelling in automatic image loads Problem: the device-name check split a file name only on '.', so names such as NUL:.png, COM1:.png, NUL:stream.png (an alternate data stream) slipped through, and CONIN$, CONOUT$, CLOCK$, COM0 and LPT0 were not listed. Those reached the filesystem from a document or chat image before being refused. Split on ':' as well, list the missing device names, and test each with Windows path rules and zero filesystem calls. Also cover the case of a local link that leads onto a network share outside every project (refused for chat images), and correct the shared comment on chat-image access. * fix(editor): let users rename and insert images into files opened outside projects Renaming a file opened outside every project (tab double-click, editor header) and inserting an image into such a markdown document failed with "Access denied", even before a restart: both writes only passed the project-root check. Document resources and chat images were also limited by file type more strictly than users expect. - Add a "document-folder" access kind for writes beside a document the user opened: main allows renaming only that document, to a name inside its own folder, and importing new files only into that folder, checked by path text and again by real path, with Windows device names refused. The renderer sends it only for local user-named, writable tabs (rename, its undo/rollback, image insert); SSH and runtime requests never carry it. - Document resources: drop the image/PDF type allowlist; folder confinement, regular-file reads, the cap and path-text refusals remain. - Chat images: judge only the real target's type, against every previewable image type (AVIF added). * fix(fs): a declared file-access kind never refuses what the project check allows A full-path tab for a file inside a project (for example a link that leads out, opened by its absolute path) was renamed under the document-folder rule, which limited the new name to the file's own folder, although the same rename with no declared access could move it anywhere in the project. Any declared kind could be stricter than the default in the same way. Every desktop local file request now goes through one resolver, resolveLocalRequestPath: it runs the default project check first (roots, Orca's floating folder, symlink containment, outside-root path text refused before any filesystem call) and only on a refusal applies the declared kind's rule, which adds paths outside projects. Reads, saves, rename source and target, and import destinations all use it, so a new kind gets the rule for free. Automatic loads (document and chat) still refuse Windows device paths and names by text first, even inside a project; a device is never a file to show. The document-resource rule no longer needs its own project branch, and chat images no longer re-run the roots check for shares. * fix(fs): symmetric outside-project renames, notebook real folder, same-share images - Renaming a file opened outside every project accepted a name in a subfolder (`archive/todo.md`), but the Undo and the rollback rename, declared from the moved file, were then refused and the file stayed moved. A rename under document-folder access must now land directly in the document's own folder (checked by path text before any filesystem call), so rename, Undo and rollback are symmetric. Image import still accepts the folder or a folder under it. Inside projects the default check still allows any in-project target. - A notebook opened through a link inside a project started its kernel in the link's folder instead of the real file's folder (main's behaviour), because notebook and AI Vault log-tail paths skipped the project check. Both now resolve through resolveLocalRequestPath (project check first, then the user-file rule). - A markdown file opened from a Windows share outside every project could not show the images beside it. Document images on a share are now allowed inside the document's own folder; the folder text check refuses every other host and share before any filesystem call. - resolveDesktopAuthorizedPath is async, so a synchronous failure in the default check rejects like any other refusal. * fix(fs): refuse share images outside projects again; keep renames and kernels as on main - Reverts the same-share document image rule from the previous commit. Its folder check compared hosts case-insensitively, so a host spelled with U+212A KELVIN SIGN (or a decomposed accent) passed as the document's own share and was contacted, reopening the network credential leak. Document and chat images on a share outside every project are again refused by path text before any filesystem call; tests now cover the look-alike hosts with zero filesystem calls. - Renaming a file opened outside every project into a project folder passed the project check, but its Undo (declared from the new path) was refused and the file stayed moved. When the rename source is allowed only as the opened document, the new name must now land directly in the document's folder even if a project would accept it (resolveLocalRenamePaths). - A notebook opened through a link outside every project started its kernel in the link's folder; main used the real file's folder. The kernel cwd is now the real file's folder in every case. * fix(fs): a file opened outside every project renames to any path, and keeps its access Renaming a document the user opened (floating workspace or full-path tab) now follows the user-file rule: the source must be the opened document, and the new path can be any absolute path, so a rename into another folder, a subfolder or a project works, and its Undo (declared from the moved file) comes back from there. Any other rename keeps the project check only. Remove the same-folder rename rule and its tests; image import stays in the document's own folder. After a move, a tab stored by its full path keeps its full path instead of being recomputed project-relative, so it keeps user-file access for save, the next rename, image insert and restore after restart. Folder moves go through the same remap. Also un-export unused resolver exports and avoid a copy for single-chunk reads. --- src/main/ai-vault/local-log-tail-reader.ts | 3 +- ...estination-serialized-local-rename.test.ts | 1 + src/main/ipc/app.test.ts | 10 +- src/main/ipc/app.ts | 10 +- src/main/ipc/automatic-load-path-text.ts | 47 ++ src/main/ipc/filesystem-allowed-roots.test.ts | 21 +- src/main/ipc/filesystem-auth.test.ts | 46 +- src/main/ipc/filesystem-auth.ts | 57 +-- .../filesystem-document-folder-writes.test.ts | 324 ++++++++++++ .../filesystem-import-local-rollback.test.ts | 1 - src/main/ipc/filesystem-import-local.ts | 9 +- .../filesystem-import-ssh-path-safety.test.ts | 3 - src/main/ipc/filesystem-import-ssh.test.ts | 5 +- src/main/ipc/filesystem-import-ssh.ts | 3 - src/main/ipc/filesystem-import.test.ts | 1 + src/main/ipc/filesystem-mutations.test.ts | 1 + src/main/ipc/filesystem-mutations.ts | 42 +- .../filesystem-runtime-upload-staging.test.ts | 1 - .../ipc/filesystem-runtime-upload-staging.ts | 5 - .../ipc/filesystem-search-rg-timeout.test.ts | 2 +- src/main/ipc/filesystem-test-harness.ts | 41 +- src/main/ipc/filesystem.test.ts | 52 +- .../filesystem-file-content-inspection.ts | 79 ++- .../filesystem/filesystem-read-handlers.ts | 87 ++-- .../filesystem/filesystem-search-handlers.ts | 4 +- .../filesystem/filesystem-write-handlers.ts | 23 +- .../local-regular-file-read.test.ts | 106 ++++ .../ipc/filesystem/local-regular-file-read.ts | 89 ++++ .../ipc/floating-workspace-directory.test.ts | 43 +- src/main/ipc/floating-workspace-directory.ts | 20 +- src/main/ipc/keybindings.test.ts | 34 +- src/main/ipc/keybindings.ts | 6 - ...ile-access-resolution-network-path.test.ts | 382 ++++++++++++++ .../ipc/local-file-access-resolution.test.ts | 469 ++++++++++++++++++ src/main/ipc/local-file-access-resolution.ts | 317 ++++++++++++ src/main/ipc/local-log-tail-lifetime.test.ts | 9 +- src/main/ipc/local-log-tail.test.ts | 12 +- src/main/ipc/local-log-tail.ts | 8 +- src/main/ipc/notebook-project-link.test.ts | 102 ++++ src/main/ipc/notebook.test.ts | 46 +- src/main/ipc/notebook.ts | 19 +- .../ipc/runtime-upload-file-stream.test.ts | 1 - src/main/ipc/runtime-upload-file-stream.ts | 6 +- .../runtime-upload-slice-boundaries.test.ts | 1 - .../os-opened-document-resolution.test.ts | 16 +- src/main/startup/os-opened-documents.ts | 6 +- .../window/clipboard-image-temp-file.test.ts | 24 +- src/main/window/clipboard-image-temp-file.ts | 4 - .../window/clipboard-ipc-handlers.test.ts | 7 +- src/main/window/clipboard-ipc-handlers.ts | 5 +- .../clipboard-runtime-owned-ssh-paste.test.ts | 3 +- src/main/worktree-root-preparation.test.ts | 20 +- src/preload/api/filesystem-api.ts | 13 +- src/preload/api/fs-bridge.ts | 21 +- .../browser-artifact-upload.test.ts | 11 +- .../describe-page/browser-artifact-upload.ts | 11 +- ...avigate-browser-page-notebook-link.test.ts | 75 +++ .../navigate/navigate-browser-page-url.ts | 19 +- .../editor/EditorPanelHeaderPath.test.tsx | 3 +- .../editor/ExternalFileChangeBanner.test.tsx | 21 +- .../editor/ExternalFileChangeBanner.tsx | 4 +- .../ExternalFileChangeCompareDialog.test.tsx | 26 + .../ExternalFileChangeCompareDialog.tsx | 8 +- .../MarkdownPreview.image-access.test.tsx | 111 +++++ .../editor/editor-header-file-rename.ts | 6 +- .../editor-restored-tab-conflict-scan.test.ts | 60 ++- .../editor-restored-tab-conflict-scan.ts | 9 +- .../components/editor/editor-save-queue.ts | 8 +- .../editor/editor-save-user-named-tab.test.ts | 109 ++++ .../editor/local-image-src-reader.ts | 12 +- .../markdown-preview-local-images.test.ts | 20 +- .../editor/markdown-preview-local-images.ts | 7 +- .../editor/markdown-preview-plugins.ts | 2 +- .../editor/rich-markdown-extensions.ts | 36 +- ...h-markdown-image-insert-code-block.test.ts | 1 + .../editor/rich-markdown-image-insert.test.ts | 60 ++- .../editor/rich-markdown-image-insert.ts | 8 +- .../editor/rich-markdown-local-image.test.ts | 3 +- .../use-markdown-preview-components.tsx | 11 +- .../useEditorPanelContentState.test.tsx | 38 +- .../editor/useEditorPanelFileContentLoader.ts | 22 +- ...ditorPanelFloatingTabContentState.test.tsx | 160 ++++++ ...orPanelHostUnresolvedContentState.test.tsx | 2 +- ...torPanelRemoteSiblingContentState.test.tsx | 13 +- .../editor/useLocalImageSrc.test.ts | 18 + .../src/components/editor/useLocalImageSrc.ts | 48 +- .../NativeChatImageAttachmentPreview.test.tsx | 4 +- .../NativeChatImageAttachmentPreview.tsx | 6 +- .../NativeChatTranscriptChrome.test.tsx | 23 + .../NativeChatTranscriptChrome.tsx | 13 +- .../native-chat-composer-drop-scope.test.tsx | 34 +- .../use-native-chat-composer-attachments.ts | 2 +- ...-native-chat-external-attachments.test.tsx | 67 ++- .../use-native-chat-external-attachments.ts | 11 +- .../ai-vault-session-log-open.test.ts | 48 +- .../ai-vault-session-log-open.ts | 162 +++--- .../file-explorer-drag-scroll-marker.test.tsx | 1 - .../file-explorer-instant-dir-toggle.test.ts | 1 - .../use-file-explorer-tree-pane-state.ts | 1 - .../useFileExplorerHandlers.test.ts | 152 +++--- .../right-sidebar/useFileExplorerHandlers.ts | 36 +- .../right-sidebar/useFileExplorerTree.ts | 6 +- .../settings/KeybindingsFileActions.tsx | 4 +- .../sidebar/useSidebarProjectDrop.ts | 7 +- .../components/tab-bar/EditorFileTab.test.tsx | 3 +- .../src/components/tab-bar/EditorFileTab.tsx | 5 +- .../tab-bar/tab-create-entry-absolute-file.ts | 17 +- .../tab-bar/tab-create-entry-action.test.ts | 60 ++- .../tab-bar/tab-create-entry-action.ts | 5 +- .../terminal-file-open-routing.ts | 12 +- .../terminal-link-click-fallback.test.ts | 4 +- .../terminal-link-escaping-file-open.test.ts | 74 +++ .../terminal-link-handlers-test-fixtures.ts | 3 - .../terminal-link-handlers-test-harness.ts | 2 - .../terminal-link-missing-file-open.test.ts | 20 +- .../terminal-link-osc-file-targets.test.ts | 66 ++- ...minal-link-remote-runtime-ssh-open.test.ts | 4 - ...inal-link-worktree-root-activation.test.ts | 15 +- .../terminal-link-wsl-path-mapping.test.ts | 24 +- .../attachment-drop-failure.test.tsx | 1 - .../attachment-drop-lifetime.test.tsx | 133 ++--- .../composer-state/attachment-drop-state.ts | 7 +- .../useGlobalFileDrop.escaping-link.test.tsx | 87 ++++ src/renderer/src/hooks/useGlobalFileDrop.ts | 20 +- src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/fr.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- .../lib/execute-open-editor-path-move.test.ts | 70 ++- .../src/lib/execute-open-editor-path-move.ts | 17 +- .../src/lib/local-file-access.test.ts | 307 ++++++++++++ src/renderer/src/lib/local-file-access.ts | 89 ++++ .../remap-open-editor-tabs-for-path-change.ts | 4 + src/renderer/src/lib/rename-file.ts | 11 +- .../lib/rename-user-named-tab-access.test.ts | 232 +++++++++ .../src/lib/user-opened-local-path.test.ts | 101 ++++ .../src/lib/user-opened-local-path.ts | 52 ++ .../runtime/mobile-markdown-bridge.test.ts | 51 ++ .../src/runtime/mobile-markdown-bridge.ts | 8 +- .../src/runtime/runtime-file-client-types.ts | 3 + .../src/runtime/runtime-file-import-client.ts | 12 +- .../runtime/runtime-file-metadata-client.ts | 8 +- .../runtime/runtime-file-mutation-client.ts | 22 +- .../src/runtime/runtime-file-read-client.ts | 33 +- .../editor-markdown-link-activation.test.ts | 7 +- .../editor/actions/markdown-link-action.ts | 2 - .../src/web/preload-api/web-filesystem-api.ts | 1 - src/shared/local-file-access.ts | 22 + 150 files changed, 4648 insertions(+), 1177 deletions(-) create mode 100644 src/main/ipc/automatic-load-path-text.ts create mode 100644 src/main/ipc/filesystem-document-folder-writes.test.ts create mode 100644 src/main/ipc/filesystem/local-regular-file-read.test.ts create mode 100644 src/main/ipc/filesystem/local-regular-file-read.ts create mode 100644 src/main/ipc/local-file-access-resolution-network-path.test.ts create mode 100644 src/main/ipc/local-file-access-resolution.test.ts create mode 100644 src/main/ipc/local-file-access-resolution.ts create mode 100644 src/main/ipc/notebook-project-link.test.ts create mode 100644 src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts create mode 100644 src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx create mode 100644 src/renderer/src/components/editor/editor-save-user-named-tab.test.ts create mode 100644 src/renderer/src/components/editor/useEditorPanelFloatingTabContentState.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-escaping-file-open.test.ts create mode 100644 src/renderer/src/hooks/useGlobalFileDrop.escaping-link.test.tsx create mode 100644 src/renderer/src/lib/local-file-access.test.ts create mode 100644 src/renderer/src/lib/local-file-access.ts create mode 100644 src/renderer/src/lib/rename-user-named-tab-access.test.ts create mode 100644 src/renderer/src/lib/user-opened-local-path.test.ts create mode 100644 src/renderer/src/lib/user-opened-local-path.ts create mode 100644 src/shared/local-file-access.ts 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) +}