mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
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:
@@ -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 }
|
||||
}))
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}))
|
||||
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
|
||||
@@ -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' }))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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') })
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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' })
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+9
-2
@@ -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')
|
||||
}
|
||||
|
||||
+75
@@ -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=""
|
||||
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('', '/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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+32
-35
@@ -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
Reference in New Issue
Block a user