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: <path>]" (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.
This commit is contained in:
Brennan Benson
2026-10-04 16:55:32 -07:00
committed by GitHub
parent 955dce5a5a
commit 51fe6f3fba
150 changed files with 4648 additions and 1177 deletions
+2 -1
View File
@@ -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()) {
@@ -12,6 +12,7 @@ const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoi
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
vi.mock('electron', () => ({
app: { getPath: () => '/orca-test-user-data' },
ipcMain: { handle: handleMock }
}))
+5 -5
View File
@@ -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
+4 -6
View File
@@ -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<string | null> {
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
}
+47
View File
@@ -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
}
+1 -20
View File
@@ -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 () => {
+1 -45
View File
@@ -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)
})
})
+11 -46
View File
@@ -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<string>()
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)
}
@@ -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<unknown>
const { handlers, userData } = vi.hoisted(() => ({
handlers: new Map<string, Handler>(),
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<typeof RepoWorktrees>('../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<unknown>): Promise<'ok' | 'denied'> {
return promise.then(
() => 'ok',
() => 'denied'
)
}
function call(channel: string, args: unknown): Promise<unknown> {
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'])
})
})
@@ -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,
+2 -7
View File
@@ -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<ImportItemResult> {
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.
@@ -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'
}))
+4 -1
View File
@@ -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,
-3
View File
@@ -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<ImportItemResult> {
const resolvedSource = resolve(sourcePath)
authorizeExternalPath(resolvedSource)
const originalName = basename(resolvedSource)
try {
assertSafeRemotePathSegment(originalName, remotePathFlavor)
+1
View File
@@ -29,6 +29,7 @@ const {
}))
vi.mock('electron', () => ({
app: { getPath: () => '/orca-test-user-data' },
ipcMain: { handle: handleMock }
}))
@@ -14,6 +14,7 @@ const { handleMock, copyFileMock, lstatMock, mkdirMock, renameMock, writeFileMoc
}))
vi.mock('electron', () => ({
app: { getPath: () => '/orca-test-user-data' },
ipcMain: { handle: handleMock }
}))
+32 -10
View File
@@ -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<void> => {
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<string>()
@@ -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) => ({
@@ -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<StagedExternalImportSource> {
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<ReturnType<typeof lstat>>
try {
sourceStat = await lstat(resolvedSource)
@@ -24,6 +24,7 @@ const {
const handlers = new Map<string, (event: unknown, args: unknown) => 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
}))
+32 -9
View File
@@ -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<string, unknown> {
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' }))
}
+22 -30
View File
@@ -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)
@@ -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<string, string> = {
'.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<string, string> = {
'.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<LocalFileContent> {
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<LocalFileContent> {
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<boolean> {
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
@@ -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<LocalFileContent> => {
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<boolean> => {
async (
_event,
args: { filePath: string; connectionId?: string; access?: LocalFileAccess }
): Promise<boolean> => {
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) {
@@ -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,
@@ -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<void> => {
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)
})
}
@@ -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()
})
})
})
@@ -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<Buffer> {
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<Buffer> {
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<void> {
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()
}
}
@@ -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<GlobalSettings> = {}): 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)
})
})
+9 -11
View File
@@ -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<string> {
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<Store, 'getSettings' | 'updateSettings'>,
dirPath: string
): Promise<void> {
const resolvedDir = resolveFloatingWorkspaceInput(dirPath)
@@ -113,7 +112,6 @@ export async function grantFloatingWorkspaceDirectory(
if (!canonicalDir) {
return
}
authorizeExternalPath(canonicalDir)
const trustedDirectories = await getPreservedTrustedFloatingWorkspaceDirectories(
store.getSettings()
)
+10 -24
View File
@@ -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)
})
})
-6
View File
@@ -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
})
@@ -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<typeof NodePath>('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<typeof NodeFs>()
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([])
})
})
@@ -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<typeof RepoWorktrees>('../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<unknown>): 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')
}
)
})
@@ -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<string> {
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<string> {
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 <img>, 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<string> {
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<string> {
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<string> {
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<string>
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<string> {
// 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<string> {
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<string> {
return resolveLocalRequestPath(targetPath, access, store, 'write')
}
+6 -3
View File
@@ -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()
+9 -3
View File
@@ -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<typeof vi.fn>
@@ -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',
+4 -4
View File
@@ -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<LocalLogTailReadResult> => {
const filePath = await resolveAuthorizedPath(args.filePath, store)
const filePath = await resolveUserNamedRegularFile(args.filePath, store)
return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity)
}
)
+102
View File
@@ -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<string, Handler>(),
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<typeof RepoWorktrees>('../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') })
)
}
)
+43 -3
View File
@@ -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<string, (event: unknown, args: unknown) => 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<typeof NodeFsPromises>()
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<typeof FileAccessResolution>(
'./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' })
+13 -6
View File
@@ -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<string, NotebookKernel> {
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<PythonEnvironments> => {
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<CreateVenvResult> => {
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))
}
@@ -35,7 +35,6 @@ vi.mock('./runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: (...args: Parameters<typeof callRuntimeEnvironment>) =>
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) => ({
+1 -5
View File
@@ -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-<nonce> 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
@@ -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 }
@@ -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()
})
})
+2 -4
View File
@@ -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'
@@ -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()
})
})
@@ -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
}
@@ -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',
+3 -2
View File
@@ -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) {
@@ -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
+5 -15
View File
@@ -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<typeof WslModule>()),
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()
})
})
+11 -2
View File
@@ -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<void>
createFile: (
@@ -104,6 +107,7 @@ export type FilesystemApi = {
oldPath: string
newPath: string
connectionId?: string
access?: LocalFileAccess
} & SshMutationExpectation
) => Promise<void>
copy: (
@@ -120,16 +124,20 @@ export type FilesystemApi = {
recursive?: boolean
} & SshMutationExpectation
) => Promise<void>
authorizeExternalPath: (args: { targetPath: string }) => Promise<void>
stat: (args: {
filePath: string
connectionId?: string
access?: LocalFileAccess
}) => Promise<{ size: number; isDirectory: boolean; mtime: number }>
pathsExist?: (args: {
filePaths: string[]
connectionId?: string
}) => Promise<PathExistenceResult[]>
pathExists: (args: { filePath: string; connectionId?: string }) => Promise<boolean>
pathExists: (args: {
filePath: string
connectionId?: string
access?: LocalFileAccess
}) => Promise<boolean>
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: {
+16 -5
View File
@@ -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<void> => ipcRenderer.invoke('fs:writeFile', args),
createFile: (
@@ -100,7 +103,12 @@ export const fsApi = {
args: { dirPath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => 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<void> => ipcRenderer.invoke('fs:rename', args),
copy: (
args: {
@@ -116,19 +124,21 @@ export const fsApi = {
recursive?: boolean
} & SshMutationExpectation
): Promise<void> => ipcRenderer.invoke('fs:deletePath', args),
authorizeExternalPath: (args: { targetPath: string }): Promise<void> =>
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<PathExistenceResult[]> => ipcRenderer.invoke('fs:pathsExist', args),
pathExists: (args: { filePath: string; connectionId?: string }): Promise<boolean> =>
ipcRenderer.invoke('fs:pathExists', args),
pathExists: (args: {
filePath: string
connectionId?: string
access?: LocalFileAccess
}): Promise<boolean> => 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: {
@@ -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 () => {
@@ -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<Artif
throw new ArtifactPublishPreparationError('unsupported')
}
try {
const stat = await window.api.fs.stat({ filePath: file.filePath })
const stat = await window.api.fs.stat({
filePath: file.filePath,
access: userNamedFileAccess()
})
if (stat.isDirectory) {
throw new ArtifactPublishPreparationError('unsupported')
}
if (stat.size > 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')
}
@@ -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<T>(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()
)
})
})
@@ -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
}
@@ -134,7 +134,8 @@ describe('EditorPanelHeaderPath inline rename', () => {
oldPath: '/repo/notes.md',
newName: 'renamed.mdx',
worktreeId: 'wt-1',
worktreePath: '/repo'
worktreePath: '/repo',
documentScoped: false
})
})
@@ -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)
})
})
@@ -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) {
@@ -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(
<ExternalFileChangeCompareDialog
file={{ ...file, worktreeId }}
currentContent="buffer version"
open
onOpenChange={vi.fn()}
onReload={vi.fn()}
onKeepEdits={vi.fn()}
/>
)
expect(mocks.readRuntimeFileContent.mock.calls[0]?.[0]?.access?.kind).toBe(kind)
})
})
@@ -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<DiskReadState>({ 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)
@@ -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(
<MarkdownPreview
content="![Logo](./logo.png)"
filePath="/notes/readme.md"
sourceWorktreeId="wt-1"
scrollCacheKey="test-key"
/>
)
})
expect(useLocalImageSrcSpy.mock.calls[0]?.[0]).toBe('./logo.png')
expect(useLocalImageSrcSpy.mock.calls[0]?.[4]).toEqual({
kind: 'document-resource',
documentPath: '/notes/readme.md'
})
})
})
@@ -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
})
}
@@ -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<AppState> {
function openRestoredDirtyTab(
store: StoreApi<AppState>,
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')
@@ -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
@@ -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<StoreApi<AppState>, '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)
@@ -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<AppState> {
// 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<any>()((...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<typeof createEditorSlice>))
})) as unknown as StoreApi<AppState>
}
async function saveDirtyFiles(): Promise<void> {
await new Promise<void>((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<typeof vi.fn>
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()
}
})
})
@@ -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<RuntimeFileOperationArgs, 'connectionId'> & { connectionId?: string | null }
runtimeContext?: Omit<RuntimeFileOperationArgs, 'connectionId'> & {
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)
@@ -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()
}
})
})
@@ -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
@@ -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']
},
@@ -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 {
@@ -23,6 +23,7 @@ vi.mock('@/store', () => ({
useAppStore: {
getState: vi.fn(() => ({
settings: { activeRuntimeEnvironmentId: null },
openFiles: [],
folderWorkspaces: [],
worktreesByRepo: { repo1: [{ id: 'wt-1', path: '/repo' }] }
}))
@@ -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<string, unknown>): Promise<void> {
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)
@@ -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) {
@@ -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 {
@@ -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<HTMLImageElement>): void => {
if (!isMarkdownPreviewOpenModifier(event, isMac)) {
return
@@ -93,10 +93,9 @@ type ProbeProps = {
gitStatusByWorktree?: Record<string, GitStatusEntry[]>
}
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<string, FileContent> = {}
let latestDiffContents: Record<string, DiffContent> = {}
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(<HookProbe activeFile={activeFile} openFiles={[activeFile]} />)
})
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',
@@ -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<string, InFlightContentRead<FileContent>>()
@@ -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<FileContent> = readRuntimeFileContent({
settings: readSettings,
filePath,
relativePath: readRelativePath,
worktreeId: readWorktreeId,
connectionId: readConnectionId,
expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId,
includeLocalLogMetadata: isLiveTailLogTab
}) as Promise<FileContent>
includeLocalLogMetadata: isLiveTailLogTab,
access
})
pending = { externalEventGeneration: options?.externalEventGeneration, promise }
inFlightFileReads.set(key, pending)
queueMicrotask(() => {
@@ -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<string, FileContent> = {}
function createFloatingFile(filePath: string, overrides: Partial<OpenFile> = {}): 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(<HookProbe activeFile={activeFile} />))
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(<HookProbe activeFile={activeFile} />))
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(<HookProbe activeFile={activeFile} />))
await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local'))
expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith(
expect.objectContaining({ access: undefined })
)
})
})
@@ -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()
@@ -63,7 +63,6 @@ vi.mock('./useLocalLogTail', () => ({ useLocalLogTail: vi.fn() }))
import { useEditorPanelContentState } from './useEditorPanelContentState'
const authorizeExternalPath = vi.fn()
let latestFileContents: Record<string, FileContent> = {}
function createOpenFile(overrides: Partial<OpenFile>): 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()
})
@@ -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
)
})
})
@@ -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<RuntimeFileOperationArgs, 'connectionId'> & { connectionId?: string | null }
runtimeContext?: Omit<RuntimeFileOperationArgs, 'connectionId'> & {
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<RuntimeFileOperationArgs, 'connectionId'> & { 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<RuntimeFileOperationArgs, 'connectionId'> & { connectionId?: string | null })
| null
| null,
access?: LocalFileAccess
): Promise<string | null> {
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<RuntimeFileOperationArgs, 'connectionId'> & { connectionId?: string | null })
| null
| null,
access?: LocalFileAccess
): Promise<string | null> {
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<RuntimeFileOperationArgs, 'connectionId'> & { 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<RuntimeFileOperationArgs, 'connectionId'> & { 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)
}
@@ -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'
})
})
})
@@ -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.
@@ -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()
})
})
@@ -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 =
@@ -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(<ComposerProbe pane="chat-a" />)
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(<ComposerProbe pane="chat-a" />)
@@ -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<string>()
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'
@@ -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)
@@ -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<void>()
mocks.authorizeExternalPath
.mockReturnValueOnce(authorization.promise)
.mockRejectedValueOnce(new Error('denied'))
const fileCheck = deferred<void>()
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<void>()
mocks.authorizeExternalPath.mockReturnValueOnce(authorization.promise)
const fileCheck = deferred<void>()
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<void>()
it('does not attach local paths when the owner changes during the file check', async () => {
const fileCheck = deferred<void>()
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<void>()
it('reports a one-file drop whose owner changes during its file check', async () => {
const fileCheck = deferred<void>()
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<void>()
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<void>()
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 () => {
@@ -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
}
@@ -44,11 +44,8 @@ function makeState(overrides: Partial<FakeState> = {}): FakeState {
}
}
let authorizeMock: ReturnType<typeof vi.fn>
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<void>((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)
})
})
@@ -10,11 +10,6 @@ import { canOpenAiVaultSessionLogInOrca } from './ai-vault-session-path-actions'
type AiVaultLogSession = Pick<AiVaultSession, 'filePath' | 'executionHostId'>
// 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<string>()
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<void> {
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()
}
@@ -267,7 +267,6 @@ function HandlersProbe({ scrollRef }: { scrollRef: React.RefObject<HTMLDivElemen
toggleDir: vi.fn(),
loadDir: vi.fn(),
statPath: vi.fn(),
authorizeExternalPath: vi.fn(),
markPathAsDirectory: vi.fn(),
setSelectedPath: vi.fn(),
scrollRef
@@ -53,7 +53,6 @@ function createHandlerParams(toggleDir: (worktreeId: string, dirPath: string) =>
toggleDir,
loadDir: vi.fn().mockResolvedValue(true),
statPath: vi.fn().mockResolvedValue({ isDirectory: true }),
authorizeExternalPath: vi.fn(),
markPathAsDirectory: vi.fn(),
setSelectedPath: vi.fn(),
scrollRef: createRef<HTMLDivElement>()
@@ -253,7 +253,6 @@ export function useFileExplorerTreePaneState({
toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir,
loadDir,
statPath,
authorizeExternalPath: window.api.fs.authorizeExternalPath,
markPathAsDirectory,
setSelectedPath: setSingleSelectedPath,
scrollRef
@@ -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()
})

Some files were not shown because too many files have changed in this diff Show More