Merge origin/main 51fe6f3fba (#24489) into C2: no path grants for chat pastes

Main's #24489 deletes the in-memory path grant system: every desktop file
request now declares its access kind, and the composer preview reads a paste
as a chat image. The paste code this branch carries from #24905 still granted
its files, so:
- a composer paste is written without a grant, and a restored paste is kept
  only when its real path is a file inside the paste folder, as before, with
  no grant for either spelling;
- the tests keep every containment case and drop the grant assertions; a
  restore still never makes an outside file readable.
The composer attachment cache keeps this branch's draft-store reader.
This commit is contained in:
Brennan Benson
2026-10-04 17:05:22 -07:00
151 changed files with 4658 additions and 1219 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,16 +1,14 @@
import { dirname, join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { authorizeExternalPathMock, writeFileMock, mkdirMock, getPathMock, writeFileBase64Mock } =
vi.hoisted(() => ({
authorizeExternalPathMock: vi.fn(),
writeFileMock: vi.fn(),
mkdirMock: vi.fn(),
getPathMock: vi.fn((name: string) =>
name === 'temp' ? '/os/temp' : '/Users/me/Library/Application Support/orca'
),
writeFileBase64Mock: vi.fn()
}))
const { writeFileMock, mkdirMock, getPathMock, writeFileBase64Mock } = vi.hoisted(() => ({
writeFileMock: vi.fn(),
mkdirMock: vi.fn(),
getPathMock: vi.fn((name: string) =>
name === 'temp' ? '/os/temp' : '/Users/me/Library/Application Support/orca'
),
writeFileBase64Mock: vi.fn()
}))
vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock, mkdir: mkdirMock } }))
vi.mock('node:crypto', () => ({ randomUUID: () => 'uuid-1' }))
@@ -23,7 +21,6 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
writeFileBase64: writeFileBase64Mock
})
}))
vi.mock('../ipc/filesystem-auth', () => ({ authorizeExternalPath: authorizeExternalPathMock }))
import { saveClipboardImageBufferAsTempFile } from './clipboard-image-temp-file'
@@ -39,6 +36,7 @@ describe('saveClipboardImageBufferAsTempFile', () => {
expect(getPathMock).not.toHaveBeenCalledWith('userData')
expect(mkdirMock).not.toHaveBeenCalled()
expect(dirname(savedPath)).toBe('/os/temp')
expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3]))
})
it('writes a native-chat composer paste into the paste folder, where its draft can find it', async () => {
@@ -53,25 +51,15 @@ describe('saveClipboardImageBufferAsTempFile', () => {
expect(dirname(savedPath)).toBe(
join('/Users/me/Library/Application Support/orca', 'native-chat-pastes')
)
expect(authorizeExternalPathMock).toHaveBeenCalledWith(savedPath)
})
it('authorizes the local paste so the composer can preview what it just wrote', async () => {
const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3]))
expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3]))
// OS temp 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 { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispat
import { getAppEnvironment } from '../../shared/app-environment'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image'
import { authorizeExternalPath } from '../ipc/filesystem-auth'
import { nativeChatPasteFolder } from './native-chat-paste-files'
export type SaveClipboardImageAsTempFileArgs = {
@@ -52,8 +51,5 @@ export async function saveClipboardImageBufferAsTempFile(
}
const tempPath = path.join(folder, fileName)
await fs.writeFile(tempPath, buffer)
// Why: both folders are 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(),
@@ -92,8 +90,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', () => ({
@@ -319,7 +316,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,
@@ -177,7 +178,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
@@ -11,21 +11,12 @@ import {
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../shared/agent-session-host-authority'
import type * as FilesystemAuth from '../ipc/filesystem-auth'
import { isPathAllowed } from '../ipc/filesystem-auth'
import type { Store } from '../persistence'
const { authorizeExternalPathMock } = vi.hoisted(() => ({ authorizeExternalPathMock: vi.fn() }))
// Why the real grant behind the spy: the tests check what a read is then allowed to reach.
vi.mock('../ipc/filesystem-auth', async (importOriginal) => {
const actual = await importOriginal<typeof FilesystemAuth>()
authorizeExternalPathMock.mockImplementation(actual.authorizeExternalPath)
return { ...actual, authorizeExternalPath: authorizeExternalPathMock }
})
import {
NATIVE_CHAT_PASTE_TTL_MS,
isInsideNativeChatPasteFolder,
@@ -33,7 +24,7 @@ import {
sweepExpiredNativeChatPastes
} from './native-chat-paste-files'
// A store with no workspaces, so only the grants decide what a read may reach.
// A store with no workspaces: a restore must never make an outside file readable.
const NO_ROOTS_STORE: Store = Object.assign(Object.create(null), {
getRepos: () => [],
getProjectGroups: () => [],
@@ -86,7 +77,6 @@ describe('native-chat paste folder on disk', () => {
let folder: string
beforeEach(() => {
authorizeExternalPathMock.mockClear()
root = mkdtempSync(path.join(tmpdir(), 'orca-native-chat-pastes-'))
folder = path.join(root, 'native-chat-pastes')
mkdirSync(folder)
@@ -97,7 +87,7 @@ describe('native-chat paste folder on disk', () => {
rmSync(root, { recursive: true, force: true })
})
it('re-grants only files really inside the folder, and never throws on a bad path', async () => {
it('keeps only files really inside the folder, and never throws on a bad path', async () => {
const kept = path.join(folder, 'orca-paste-1.png')
writeFileSync(kept, 'png')
const outside = path.join(root, 'outside.png')
@@ -126,8 +116,6 @@ describe('native-chat paste folder on disk', () => {
{ path: 'relative/orca-paste-3.png', kept: false, exists: false },
{ path: '', kept: false, exists: false }
])
const granted = authorizeExternalPathMock.mock.calls.map(([granted]) => granted)
expect(new Set(granted)).toEqual(new Set([realpathSync(kept), kept]))
await expect(restoreNativeChatPastes('not a list')).resolves.toEqual([])
})
@@ -145,9 +133,6 @@ describe('native-chat paste folder on disk', () => {
{ path: viaAlias, kept: true, exists: true },
{ path: realpathSync(kept), kept: true, exists: true }
])
// The preview reads by the stored spelling, so a read by it is allowed too.
expect(isPathAllowed(viaAlias, NO_ROOTS_STORE)).toBe(true)
expect(isPathAllowed(realpathSync(kept), NO_ROOTS_STORE)).toBe(true)
} finally {
rmSync(alias, { force: true })
}
@@ -174,11 +159,10 @@ describe('native-chat paste folder on disk', () => {
await expect(restoreNativeChatPastes([crafted])).resolves.toEqual([
{ path: crafted, kept: false, exists: false }
])
expect(authorizeExternalPathMock).not.toHaveBeenCalled()
expect(isPathAllowed(secret, NO_ROOTS_STORE)).toBe(false)
})
it('grants the stored spelling only when it names the same file as the real path', async () => {
it('keeps a stored spelling that reaches a real paste through a link, and opens nothing outside', async () => {
const secret = path.join(root, 'outside', 'id_rsa')
mkdirSync(path.dirname(secret), { recursive: true })
writeFileSync(secret, 'PRIVATE KEY')
+5 -15
View File
@@ -1,11 +1,11 @@
// Local native-chat pastes live in an Orca-owned folder, so a restored draft can show and send them:
// a restore re-grants preview reads only for files that really are inside it, and old files expire.
// a restore keeps only files that really are inside it, and old files expire. The preview reads them
// as chat images, so no grant is involved.
import { lstat, readdir, realpath, stat, unlink } from 'node:fs/promises'
import path from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import { NATIVE_CHAT_PASTE_FOLDER } from '../../shared/native-chat-paste-folder'
import { authorizeExternalPath } from '../ipc/filesystem-auth'
// Why 30 days: no age bounds what can still name a paste (a queued send is retried with a new id
// after the host's 24 h id window), so this is a judgment. A draft or outbox entry kept longer meets
@@ -49,8 +49,8 @@ export function isInsideNativeChatPasteFolder(
}
/**
* For each restored local paste: re-grants its preview read only when its real path is a file
* inside the real paste folder (symlinks and junctions resolved). Never throws.
* For each restored local paste: kept only when its real path is a file inside the real paste
* folder (symlinks and junctions resolved). Never throws.
*/
export async function restoreNativeChatPastes(paths: unknown): Promise<RestoredNativeChatPaste[]> {
if (!Array.isArray(paths)) {
@@ -87,7 +87,7 @@ async function restoreNativeChatPaste(
if (folders === null || restored === '' || !path.isAbsolute(restored)) {
return refused
}
// Why both: the text a grant would cover and the file it really names must each be inside.
// Why both: the path the draft stored and the file it really names must each be inside.
const named = path.resolve(restored)
if (
!isInsideNativeChatPasteFolder(folders.named, named) &&
@@ -100,16 +100,6 @@ async function restoreNativeChatPaste(
if (!isInsideNativeChatPasteFolder(folders.real, real) || !(await stat(real)).isFile()) {
return refused
}
authorizeExternalPath(real)
// The stored spelling the preview reads by, only when it names that same file: a grant also
// covers the spelling's own real path, which a link inside the folder could point elsewhere.
const sameFile = await realpath(named).then(
(namedReal) => namedReal === real,
() => false
)
if (sameFile) {
authorizeExternalPath(named)
}
return { path: restored, kept: true, exists: true }
} catch {
// Missing or unreadable: not kept, and nothing about an outside path is reported.
+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)
}
@@ -51,7 +51,9 @@ 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'
})
})
it('shows a pasted image that could not come back by name, says it was not kept, and lets it be removed', () => {
@@ -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
@@ -125,7 +126,10 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El
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()
}))
@@ -162,7 +162,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)
@@ -193,8 +193,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')!)
@@ -206,8 +206,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" />)
@@ -328,15 +328,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' }
@@ -356,9 +350,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()
@@ -369,7 +363,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(
@@ -383,7 +377,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'
@@ -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

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