Files
orca/src/main/ipc/filesystem.test.ts
T
Jinjing c5044042ea Ai vault view log live tail (#8205)
* Add read-only View Log tabs so AI Vault agent sessions open inside Orca'

- Adds a `readOnly` flag on OpenFile that hard-blocks edits, autosave, dirty
  state, rename, and drafts, and persists/restores it safely across sessions
- Wires AI Vault's View Log/Open Log actions to open logs as permanent
  read-only local tabs instead of shelling out to the OS, gated to
  local, single-file, non-synthetic session paths
- Registers a dedicated `jsonl` Monaco language (JSON-style coloring without
  whole-document JSON validation) and forces read-only tabs to render as raw
  source, bypassing markdown/mermaid/csv/notebook viewers

* Add live-tail streaming for local AI Vault View Log tabs

- Extend the fs:readFile snapshot path with byte-stable file identity so a
  read-only tab can resume appending exactly where the snapshot left off.
- Add a ranged local log tail reader plus IPC (read/start/stop watch) that
  streams only newly appended bytes, detects truncation/rotation, and
  cleans up watchers on tab close or renderer destruction.
- Add a renderer-side UTF-8/line-boundary decoder and useLocalLogTail hook
  that appends completed lines into the existing Monaco model, falling
  back to a full reload on reset/rotation, keeping snapshot behavior
  unchanged when liveTail is not opted in.
- Thread the new `liveTail` flag through OpenFile/PersistedOpenFile,
  workspace session persistence/restore, and the AI Vault "View Log" open
  path so live tail survives restarts and stays read-only-safe.

* Remove stale planning brief for the live-tail feature

The AI-VAULT-VIEW-LOG-LIVE-TAIL.md pick-up brief is no longer needed now that the live-tail streaming work has landed (c7fdef7b1).
2026-07-10 21:22:18 -07:00

2384 lines
80 KiB
TypeScript

/* eslint-disable max-lines -- Why: filesystem authorization and git/file IPC invariants are exercised end-to-end here, so the scenarios stay together to keep the security boundary readable. */
import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>()
const {
handleMock,
showSaveDialogMock,
fromWebContentsMock,
trashItemMock,
readdirMock,
readFileMock,
writeFileMock,
statMock,
openMock,
renameMock,
rmMock,
realpathMock,
lstatMock,
commitChangesMock,
getStatusMock,
abortMergeMock,
abortRebaseMock,
getDiffMock,
getBranchCompareMock,
getBranchDiffMock,
getStagedCommitContextMock,
stageFileMock,
bulkStageFilesMock,
unstageFileMock,
bulkUnstageFilesMock,
bulkDiscardChangesMock,
discardChangesMock,
checkIgnoredPathsMock,
listWorktreesMock,
resolveCommitMessageSettingsMock,
generateCommitMessageFromContextMock,
generatePullRequestFieldsFromContextMock,
discoverCommitMessageModelsLocalMock,
discoverCommitMessageModelsRemoteMock,
cancelGenerateCommitMessageLocalMock,
cancelGeneratePullRequestFieldsLocalMock,
getSshFilesystemProviderMock,
getSshGitProviderMock,
tryDeleteWslUncPathMock,
recordCrashBreadcrumbMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
showSaveDialogMock: vi.fn(),
fromWebContentsMock: vi.fn(),
trashItemMock: vi.fn(),
readdirMock: vi.fn(),
readFileMock: vi.fn(),
writeFileMock: vi.fn(),
statMock: vi.fn(),
openMock: vi.fn(),
renameMock: vi.fn(),
rmMock: vi.fn(),
realpathMock: vi.fn(),
lstatMock: vi.fn(),
commitChangesMock: vi.fn(),
getStatusMock: vi.fn(),
abortMergeMock: vi.fn(),
abortRebaseMock: vi.fn(),
getDiffMock: vi.fn(),
getBranchCompareMock: vi.fn(),
getBranchDiffMock: vi.fn(),
getStagedCommitContextMock: vi.fn(),
stageFileMock: vi.fn(),
bulkStageFilesMock: vi.fn(),
unstageFileMock: vi.fn(),
bulkUnstageFilesMock: vi.fn(),
bulkDiscardChangesMock: vi.fn(),
discardChangesMock: vi.fn(),
checkIgnoredPathsMock: vi.fn(),
listWorktreesMock: vi.fn(),
resolveCommitMessageSettingsMock: vi.fn(),
generateCommitMessageFromContextMock: vi.fn(),
generatePullRequestFieldsFromContextMock: vi.fn(),
discoverCommitMessageModelsLocalMock: vi.fn(),
discoverCommitMessageModelsRemoteMock: vi.fn(),
cancelGenerateCommitMessageLocalMock: vi.fn(),
cancelGeneratePullRequestFieldsLocalMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn(),
getSshGitProviderMock: vi.fn(),
tryDeleteWslUncPathMock: vi.fn(),
recordCrashBreadcrumbMock: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: {
fromWebContents: fromWebContentsMock
},
dialog: {
showSaveDialog: showSaveDialogMock
},
ipcMain: {
handle: handleMock
},
shell: {
trashItem: trashItemMock
}
}))
vi.mock('fs/promises', () => ({
readdir: readdirMock,
readFile: readFileMock,
writeFile: writeFileMock,
stat: statMock,
open: openMock,
rename: renameMock,
rm: rmMock,
realpath: realpathMock,
lstat: lstatMock
}))
vi.mock('../wsl-unc-delete', () => ({
tryDeleteWslUncPath: tryDeleteWslUncPathMock
}))
vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({
recordCrashBreadcrumb: recordCrashBreadcrumbMock
}))
vi.mock('../git/status', () => ({
commitChanges: commitChangesMock,
getStatus: getStatusMock,
abortMerge: abortMergeMock,
abortRebase: abortRebaseMock,
getDiff: getDiffMock,
getBranchCompare: getBranchCompareMock,
getBranchDiff: getBranchDiffMock,
getStagedCommitContext: getStagedCommitContextMock,
stageFile: stageFileMock,
bulkStageFiles: bulkStageFilesMock,
unstageFile: unstageFileMock,
bulkUnstageFiles: bulkUnstageFilesMock,
bulkDiscardChanges: bulkDiscardChangesMock,
discardChanges: discardChangesMock
}))
vi.mock('../git/check-ignored-paths', () => ({
checkIgnoredPaths: checkIgnoredPathsMock
}))
vi.mock('../git/worktree', () => ({
listWorktrees: listWorktreesMock,
listWorktreesStrict: listWorktreesMock
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
requireSshFilesystemProvider: (connectionId: string) => {
const provider = getSshFilesystemProviderMock(connectionId)
if (!provider) {
throw new Error(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
}
return provider
}
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE:
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
}))
vi.mock('../text-generation/commit-message-text-generation', () => ({
resolveCommitMessageSettings: resolveCommitMessageSettingsMock,
generateCommitMessageFromContext: generateCommitMessageFromContextMock,
generatePullRequestFieldsFromContext: generatePullRequestFieldsFromContextMock,
discoverCommitMessageModelsLocal: discoverCommitMessageModelsLocalMock,
discoverCommitMessageModelsRemote: discoverCommitMessageModelsRemoteMock,
cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock,
cancelGeneratePullRequestFieldsLocal: cancelGeneratePullRequestFieldsLocalMock
}))
import { registerFilesystemHandlers } from './filesystem'
import { invalidateAuthorizedRootsCache, registerWorktreeRootsForRepo } from './filesystem-auth'
// Why: paths are resolved via path.resolve() in production code, so test
// data must use resolved paths to avoid Unix-vs-Windows mismatches.
const REPO_PATH = path.resolve('/workspace/repo')
const WORKSPACE_DIR = path.resolve('/workspace')
const WORKTREE_FEATURE_PATH = path.resolve('/workspace/repo-feature')
type MockDirEntry = {
name: string
directory?: boolean
file?: boolean
symlink?: boolean
}
function dirEntry({ name, directory, file, symlink }: MockDirEntry): {
name: string
isDirectory: () => boolean
isFile: () => boolean
isSymbolicLink: () => boolean
} {
return {
name,
isDirectory: () => directory ?? false,
isFile: () => file ?? false,
isSymbolicLink: () => symlink ?? false
}
}
async function withPlatform<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
const original = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await run()
} finally {
if (original) {
Object.defineProperty(process, 'platform', original)
}
}
}
describe('registerFilesystemHandlers', () => {
const store = {
getRepos: () => [
{
id: 'repo-1',
path: REPO_PATH,
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
],
getSettings: () => ({
workspaceDir: WORKSPACE_DIR
})
}
beforeEach(() => {
handlers.clear()
for (const mock of [
handleMock,
showSaveDialogMock,
fromWebContentsMock,
trashItemMock,
readdirMock,
readFileMock,
writeFileMock,
statMock,
openMock,
renameMock,
rmMock,
realpathMock,
lstatMock,
recordCrashBreadcrumbMock,
commitChangesMock,
getStatusMock,
abortMergeMock,
abortRebaseMock,
getDiffMock,
getBranchCompareMock,
getBranchDiffMock,
getStagedCommitContextMock,
stageFileMock,
bulkStageFilesMock,
unstageFileMock,
bulkUnstageFilesMock,
bulkDiscardChangesMock,
discardChangesMock,
listWorktreesMock,
resolveCommitMessageSettingsMock,
generateCommitMessageFromContextMock,
generatePullRequestFieldsFromContextMock,
discoverCommitMessageModelsLocalMock,
discoverCommitMessageModelsRemoteMock,
cancelGenerateCommitMessageLocalMock,
cancelGeneratePullRequestFieldsLocalMock,
getSshFilesystemProviderMock,
getSshGitProviderMock,
tryDeleteWslUncPathMock
]) {
mock.mockReset()
}
handleMock.mockImplementation((channel, handler) => {
handlers.set(channel, handler)
})
// Reset module-level auth cache so each test starts with a fresh dirty
// flag — prevents stale worktree data from a prior test's cache rebuild.
invalidateAuthorizedRootsCache()
realpathMock.mockImplementation(async (targetPath: string) => targetPath)
listWorktreesMock.mockResolvedValue([
{
path: WORKTREE_FEATURE_PATH,
head: 'abc',
branch: '',
isBare: false,
isMainWorktree: false
}
])
trashItemMock.mockResolvedValue(undefined)
// Default: not a WSL UNC path, so deletePath falls through to shell.trashItem.
tryDeleteWslUncPathMock.mockResolvedValue(false)
showSaveDialogMock.mockResolvedValue({ canceled: true })
fromWebContentsMock.mockReturnValue(null)
getSshGitProviderMock.mockReturnValue(null)
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()
})
lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
})
it('returns an actionable reconnect error when the SSH filesystem provider is unavailable', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:readDir')!(null, { dirPath: '/remote/repo', connectionId: 'ssh-1' })
).rejects.toThrow(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
})
// Why: handler-level WSL UNC authorization depends on native Windows path
// resolution; path-shape classification has separate cross-platform coverage.
it.runIf(process.platform === 'win32')(
'records a redacted breadcrumb when fs:readDir throws on a WSL UNC path',
async () => {
registerFilesystemHandlers(store as never)
const wslPath = path.win32.join('\\\\wsl.localhost\\Ubuntu', 'home', 'user', 'repo')
// resolveAuthorizedPath authorizes the path, then readdir fails (distro stopped).
realpathMock.mockResolvedValue(wslPath)
registerWorktreeRootsForRepo(store as never, 'repo-1', [wslPath])
readdirMock.mockRejectedValue(Object.assign(new Error('EIO: i/o error'), { code: 'EIO' }))
await expect(handlers.get('fs:readDir')!(null, { dirPath: wslPath })).rejects.toThrow(/EIO/)
expect(recordCrashBreadcrumbMock).toHaveBeenCalledWith('fs_readdir_error', {
throwSite: 'readdir',
errorName: 'Error',
errorCode: 'EIO',
hasConnectionId: false,
isUNC: true,
isWsl: true
})
// The raw path must never appear in the breadcrumb payload.
const [, breadcrumbData] = recordCrashBreadcrumbMock.mock.calls[0]
expect(JSON.stringify(breadcrumbData)).not.toContain('user')
}
)
it('records a breadcrumb tagged ssh-provider when the SSH provider is gone', async () => {
registerFilesystemHandlers(store as never)
getSshFilesystemProviderMock.mockReturnValue(undefined)
await expect(
handlers.get('fs:readDir')!(null, { dirPath: '/remote/repo', connectionId: 'ssh-1' })
).rejects.toThrow()
expect(recordCrashBreadcrumbMock).toHaveBeenCalledWith(
'fs_readdir_error',
expect.objectContaining({ throwSite: 'ssh-provider', hasConnectionId: true })
)
})
it('records a breadcrumb tagged authorize when the path is denied', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:readDir')!(null, { dirPath: path.resolve('/etc/passwd') })
).rejects.toThrow()
expect(recordCrashBreadcrumbMock).toHaveBeenCalledWith(
'fs_readdir_error',
expect.objectContaining({ throwSite: 'authorize', hasConnectionId: false })
)
})
it('does not record a breadcrumb when fs:readDir succeeds', async () => {
registerFilesystemHandlers(store as never)
readdirMock.mockResolvedValue([dirEntry({ name: 'file.ts', file: true })])
await handlers.get('fs:readDir')!(null, { dirPath: REPO_PATH })
expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled()
})
it('rejects remote downloads with missing required arguments', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!({ sender: {} }, { filePath: ' ', connectionId: 'ssh-1' })
).rejects.toThrow('filePath is required')
await expect(
handlers.get('fs:downloadFile')!({ sender: {} }, { filePath: '/remote/file.txt' })
).rejects.toThrow('connectionId is required')
expect(showSaveDialogMock).not.toHaveBeenCalled()
})
it('surfaces provider lookup errors for remote downloads before opening a dialog', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/file.txt',
connectionId: 'ssh-1'
}
)
).rejects.toThrow(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
expect(showSaveDialogMock).not.toHaveBeenCalled()
})
it('rejects remote download directories before opening a dialog', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }),
downloadFile: vi.fn()
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/src',
connectionId: 'ssh-1'
}
)
).rejects.toThrow('Cannot download a directory')
expect(showSaveDialogMock).not.toHaveBeenCalled()
expect(provider.downloadFile).not.toHaveBeenCalled()
})
it('returns canceled remote downloads without transferring', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn()
}
getSshFilesystemProviderMock.mockReturnValue(provider)
showSaveDialogMock.mockResolvedValue({ canceled: true })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
)
).resolves.toEqual({ canceled: true })
expect(showSaveDialogMock).toHaveBeenCalledWith({ defaultPath: 'report.pdf' })
expect(statMock).not.toHaveBeenCalled()
expect(provider.downloadFile).not.toHaveBeenCalled()
})
it('parents the remote download save dialog and sanitizes reserved filename suggestions', async () => {
const parentWindow = { id: 7 }
const sender = { id: 42 }
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn()
}
getSshFilesystemProviderMock.mockReturnValue(provider)
fromWebContentsMock.mockReturnValue(parentWindow)
showSaveDialogMock.mockResolvedValue({ canceled: true })
registerFilesystemHandlers(store as never)
await handlers.get('fs:downloadFile')!(
{ sender },
{
filePath: 'C:\\repo\\CON.txt',
connectionId: 'ssh-1'
}
)
expect(fromWebContentsMock).toHaveBeenCalledWith(sender)
expect(showSaveDialogMock).toHaveBeenCalledWith(parentWindow, { defaultPath: 'download' })
})
it('rejects remote downloads when raw provider transfer is unavailable', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 })
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/file.txt',
connectionId: 'ssh-1'
}
)
).rejects.toThrow('Remote file download is unavailable. Reconnect the SSH target and retry.')
expect(showSaveDialogMock).not.toHaveBeenCalled()
})
it('rejects selected local directories before transferring a remote download', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn()
}
getSshFilesystemProviderMock.mockReturnValue(provider)
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockResolvedValue({ isDirectory: () => true })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
)
).rejects.toThrow('Cannot download to a directory')
expect(provider.downloadFile).not.toHaveBeenCalled()
})
it('downloads to a temp sibling then promotes a new destination', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn().mockResolvedValue(undefined)
}
getSshFilesystemProviderMock.mockReturnValue(provider)
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
)
).resolves.toEqual({ canceled: false, destinationPath: '/downloads/report.pdf' })
const tempPath = provider.downloadFile.mock.calls[0][1]
expect(path.dirname(tempPath)).toBe(path.normalize('/downloads'))
expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath)
expect(renameMock).toHaveBeenCalledWith(tempPath, '/downloads/report.pdf')
expect(rmMock).not.toHaveBeenCalledWith(tempPath, expect.anything())
})
it('streams runtime download chunks to a temp sibling then promotes on finish', async () => {
const writeFile = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
openMock.mockResolvedValue({ writeFile, close })
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
registerFilesystemHandlers(store as never)
const started = await handlers.get('fs:startDownloadedFile')!(
{ sender: {} },
{ suggestedName: 'report.pdf' }
)
expect(started).toMatchObject({
canceled: false,
destinationPath: '/downloads/report.pdf'
})
if (!started || typeof started !== 'object' || !('transferId' in started)) {
throw new Error('download did not start')
}
const transferId = started.transferId
await expect(
handlers.get('fs:appendDownloadedFileChunk')!(null, {
transferId,
contentBase64: Buffer.from('hello').toString('base64')
})
).resolves.toEqual({ ok: true })
await expect(handlers.get('fs:finishDownloadedFile')!(null, { transferId })).resolves.toEqual({
canceled: false,
destinationPath: '/downloads/report.pdf'
})
const tempPath = openMock.mock.calls[0][0]
expect(path.dirname(tempPath)).toBe(path.normalize('/downloads'))
expect(openMock).toHaveBeenCalledWith(tempPath, 'wx')
expect(writeFile).toHaveBeenCalledWith(Buffer.from('hello'))
expect(close).toHaveBeenCalled()
expect(renameMock).toHaveBeenCalledWith(tempPath, '/downloads/report.pdf')
})
it('cleans up a runtime download temp file on cancel', async () => {
const close = vi.fn().mockResolvedValue(undefined)
openMock.mockResolvedValue({ writeFile: vi.fn(), close })
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
registerFilesystemHandlers(store as never)
const started = await handlers.get('fs:startDownloadedFile')!(
{ sender: {} },
{ suggestedName: 'report.pdf' }
)
if (!started || typeof started !== 'object' || !('transferId' in started)) {
throw new Error('download did not start')
}
const tempPath = openMock.mock.calls[0][0]
await expect(
handlers.get('fs:cancelDownloadedFile')!(null, { transferId: started.transferId })
).resolves.toEqual({ ok: true })
expect(close).toHaveBeenCalled()
expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true })
expect(renameMock).not.toHaveBeenCalled()
})
it('cleans up the temp sibling when remote download transfer fails', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn().mockRejectedValue(new Error('transfer failed'))
}
getSshFilesystemProviderMock.mockReturnValue(provider)
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
)
).rejects.toThrow('transfer failed')
const tempPath = provider.downloadFile.mock.calls[0][1]
expect(renameMock).not.toHaveBeenCalled()
expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true })
})
it('fails rather than overwriting a destination that appears after the dialog', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn().mockResolvedValue(undefined)
}
getSshFilesystemProviderMock.mockReturnValue(provider)
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock
.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' }))
.mockResolvedValueOnce({ isDirectory: () => false })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
)
).rejects.toThrow('Destination file appeared before download completed')
const tempPath = provider.downloadFile.mock.calls[0][1]
expect(renameMock).not.toHaveBeenCalled()
expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true })
})
it('uses a backup swap when overwriting an existing destination', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),
downloadFile: vi.fn().mockResolvedValue(undefined)
}
getSshFilesystemProviderMock.mockReturnValue(provider)
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockResolvedValue({ isDirectory: () => false })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:downloadFile')!(
{ sender: {} },
{
filePath: '/remote/report.pdf',
connectionId: 'ssh-1'
}
)
).resolves.toEqual({ canceled: false, destinationPath: '/downloads/report.pdf' })
const tempPath = provider.downloadFile.mock.calls[0][1]
const backupPath = renameMock.mock.calls[0][1]
expect(renameMock.mock.calls[0]).toEqual(['/downloads/report.pdf', backupPath])
expect(renameMock.mock.calls[1]).toEqual([tempPath, '/downloads/report.pdf'])
expect(rmMock).toHaveBeenCalledWith(backupPath, { force: true })
})
it('rejects readFile when the real path escapes allowed roots', async () => {
const linkPath = path.resolve('/workspace/repo/link.txt')
realpathMock.mockImplementation(async (targetPath: string) => {
if (targetPath === linkPath) {
return path.resolve('/private/secret.txt')
}
return targetPath
})
registerFilesystemHandlers(store as never)
await expect(handlers.get('fs:readFile')!(null, { filePath: linkPath })).rejects.toThrow(
'Access denied: path resolves outside allowed directories'
)
expect(readFileMock).not.toHaveBeenCalled()
})
it('allows readDir when a registered worktree resolves to a macOS canonical alias', async () => {
const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature')
const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature')
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, aliasWorktreePath])
realpathMock.mockImplementation(async (targetPath: string) => {
if (targetPath === aliasWorktreePath) {
return canonicalWorktreePath
}
return targetPath
})
readdirMock.mockResolvedValue([dirEntry({ name: 'README.md', file: true })])
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:readDir')!(null, { dirPath: aliasWorktreePath })
).resolves.toEqual([{ name: 'README.md', isDirectory: false, isSymlink: false }])
expect(readdirMock).toHaveBeenCalledWith(canonicalWorktreePath, { withFileTypes: true })
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('does not follow symlinks when classifying readDir entries', async () => {
const modelLinkPath = path.join(REPO_PATH, 'Model')
readdirMock.mockResolvedValue([
dirEntry({ name: 'README.md', file: true }),
dirEntry({ name: 'Model', directory: true, symlink: true })
])
statMock.mockImplementation(async (targetPath: string) => ({
size: 10,
isDirectory: () => targetPath === modelLinkPath,
mtimeMs: 123
}))
registerFilesystemHandlers(store as never)
await expect(handlers.get('fs:readDir')!(null, { dirPath: REPO_PATH })).resolves.toEqual([
{ name: 'Model', isDirectory: false, isSymlink: true },
{ name: 'README.md', isDirectory: false, isSymlink: false }
])
expect(statMock).not.toHaveBeenCalledWith(modelLinkPath)
})
it('returns false from pathExists when a local authorized path is missing', async () => {
const targetPath = path.join(REPO_PATH, 'untitled-7.md')
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
registerFilesystemHandlers(store as never)
await expect(handlers.get('fs:pathExists')!(null, { filePath: targetPath })).resolves.toBe(
false
)
expect(statMock).toHaveBeenCalledWith(targetPath)
})
it('returns false from pathExists when an SSH provider reports a missing path', async () => {
const provider = {
stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:pathExists')!(null, {
filePath: '/remote/repo/untitled-7.md',
connectionId: 'ssh-1'
})
).resolves.toBe(false)
expect(provider.stat).toHaveBeenCalledWith('/remote/repo/untitled-7.md')
})
it('allows deletePath when a registered worktree parent resolves to a macOS canonical alias', async () => {
const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature')
const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature')
const aliasFilePath = path.join(aliasWorktreePath, 'README.md')
const canonicalFilePath = path.join(canonicalWorktreePath, 'README.md')
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, aliasWorktreePath])
realpathMock.mockImplementation(async (targetPath: string) => {
if (targetPath === aliasWorktreePath) {
return canonicalWorktreePath
}
return targetPath
})
registerFilesystemHandlers(store as never)
await handlers.get('fs:deletePath')!(null, { targetPath: aliasFilePath })
expect(trashItemMock).toHaveBeenCalledWith(canonicalFilePath)
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('rejects readFile when a symlink in a canonical alias worktree escapes the registered root', async () => {
const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature')
const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature')
const aliasLinkPath = path.join(aliasWorktreePath, 'link.txt')
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, aliasWorktreePath])
realpathMock.mockImplementation(async (targetPath: string) => {
if (targetPath === aliasWorktreePath) {
return canonicalWorktreePath
}
if (targetPath === aliasLinkPath) {
return path.resolve('/private/secret.txt')
}
return targetPath
})
registerFilesystemHandlers(store as never)
await expect(handlers.get('fs:readFile')!(null, { filePath: aliasLinkPath })).rejects.toThrow(
'Access denied: path resolves outside allowed directories'
)
expect(readFileMock).not.toHaveBeenCalled()
})
it('does not enumerate worktrees when filesystem handlers register', () => {
registerFilesystemHandlers(store as never)
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('rejects writes to directories', async () => {
lstatMock.mockResolvedValue({ isDirectory: () => true })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:writeFile')!(null, {
filePath: path.resolve('/workspace/repo/folder'),
content: 'data'
})
).rejects.toThrow('Cannot write to a directory')
expect(writeFileMock).not.toHaveBeenCalled()
})
it.each([
{ ext: 'png', mime: 'image/png', data: [0x89, 0x50, 0x4e, 0x47, 0x00] },
{ ext: 'pdf', mime: 'application/pdf', data: [0x25, 0x50, 0x44, 0x46, 0x00] },
{
ext: 'svg',
mime: 'image/svg+xml',
data: Array.from(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" />'))
}
])('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)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:readFile')!(null, { filePath: path.resolve(`/workspace/repo/file.${ext}`) })
).resolves.toEqual({
content: buf.toString('base64'),
isBinary: true,
isImage: true,
mimeType: mime
})
})
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))
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/large.json') })
).resolves.toEqual({
content,
isBinary: false
})
})
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
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:readFile')!(null, {
filePath: path.resolve('/workspace/repo/session.jsonl'),
includeLocalLogMetadata: true
})
).resolves.toEqual({
content: 'first\npartial',
isBinary: false,
fileIdentity: '1:2:3'
})
expect(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 })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/huge.json') })
).rejects.toThrow('exceeds 50MB limit')
expect(readFileMock).not.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()
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/archive.bin') })
).resolves.toEqual({
content: '',
isBinary: true
})
expect(readFileMock).not.toHaveBeenCalled()
})
it('moves files to trash', async () => {
registerFilesystemHandlers(store as never)
const targetPath = path.resolve('/workspace/repo/file.txt')
await handlers.get('fs:deletePath')!(null, { targetPath })
expect(trashItemMock).toHaveBeenCalledWith(targetPath)
expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(targetPath, { recursive: undefined })
})
// Regression for #6415: WSL UNC paths have no Recycle Bin, so shell.trashItem
// throws. The handler must hard-delete via the distro instead of surfacing an
// error popup.
it('hard-deletes a WSL UNC path instead of trashing it', async () => {
// Why: build the UNC-style root with path.join so it resolves as a real
// parent/child pair under the host's path semantics. A literal
// '\\wsl.localhost\...' string only resolves correctly under win32 path
// rules — on the Linux CI runner POSIX treats the backslashes as filename
// characters, so the target would not be a descendant of the root and auth
// would deny it before the WSL hard-delete ran (the real production path is
// Windows-only).
const wslUncRoot = path.join(
`${path.sep}${path.sep}wsl.localhost`,
'Ubuntu',
'home',
'me',
'repo'
)
const targetPath = path.join(wslUncRoot, 'file.txt')
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, wslUncRoot])
tryDeleteWslUncPathMock.mockResolvedValue(true)
registerFilesystemHandlers(store as never)
await handlers.get('fs:deletePath')!(null, { targetPath, recursive: true })
expect(tryDeleteWslUncPathMock).toHaveBeenCalledWith(targetPath, { recursive: true })
// Critical: we must NOT call trashItem for WSL UNC paths — that is exactly
// the call that throws and produced the user-facing error.
expect(trashItemMock).not.toHaveBeenCalled()
})
it('propagates a WSL hard-delete failure instead of swallowing it', async () => {
// Why: see sibling test — path.join keeps the UNC root/target a real
// parent/child pair under both win32 and POSIX (Linux CI) path semantics.
const wslUncRoot = path.join(
`${path.sep}${path.sep}wsl.localhost`,
'Ubuntu',
'home',
'me',
'repo'
)
const targetPath = path.join(wslUncRoot, 'file.txt')
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, wslUncRoot])
tryDeleteWslUncPathMock.mockRejectedValue(
new Error('Failed to delete WSL path: Permission denied')
)
registerFilesystemHandlers(store as never)
await expect(handlers.get('fs:deletePath')!(null, { targetPath })).rejects.toThrow(
'Failed to delete WSL path: Permission denied'
)
expect(trashItemMock).not.toHaveBeenCalled()
})
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]))
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/archive.zip') })
).resolves.toEqual({
content: '',
isBinary: true
})
})
it('normalizes repo worktree paths and keeps git file paths relative', async () => {
stageFileMock.mockResolvedValue(undefined)
registerFilesystemHandlers(store as never)
await handlers.get('git:stage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
filePath: './src/../src/file.ts'
})
// Why: validateGitRelativeFilePath uses path.relative() which produces
// platform-specific separators (backslashes on Windows).
expect(stageFileMock).toHaveBeenCalledWith(
WORKTREE_FEATURE_PATH,
path.join('src', 'file.ts'),
{}
)
})
it('uses worktree roots seeded by worktrees:list without rebuilding the cache', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue({ entries: [] })
registerFilesystemHandlers(store as never)
await handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH })
expect(listWorktreesMock).not.toHaveBeenCalled()
expect(realpathMock).not.toHaveBeenCalledWith(WORKTREE_FEATURE_PATH)
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { includeIgnored: false })
})
it('allows git operations on the known repo root without rebuilding the worktree cache', async () => {
getStatusMock.mockResolvedValue({ entries: [] })
registerFilesystemHandlers(store as never)
await handlers.get('git:status')!(null, { worktreePath: REPO_PATH })
expect(listWorktreesMock).not.toHaveBeenCalled()
expect(realpathMock).not.toHaveBeenCalledWith(REPO_PATH)
expect(getStatusMock).toHaveBeenCalledWith(REPO_PATH, { includeIgnored: false })
})
it('forwards includeIgnored through local and SSH git status IPC', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
const sshProvider = {
getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:status')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
includeIgnored: true
})
await handlers.get('git:status')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
includeIgnored: true
})
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { includeIgnored: true })
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true })
})
it('forwards upstream-negative-cache bypass through local and SSH git status IPC', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
const sshProvider = {
getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:status')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
bypassEffectiveUpstreamNegativeCache: true
})
await handlers.get('git:status')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
bypassEffectiveUpstreamNegativeCache: true
})
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
includeIgnored: false,
bypassEffectiveUpstreamNegativeCache: true
})
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', {
includeIgnored: false,
bypassEffectiveUpstreamNegativeCache: true
})
})
it('checks ignored paths through local and SSH git providers', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js'])
const sshProvider = {
checkIgnoredPaths: vi.fn().mockResolvedValue(['build/output.js'])
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:checkIgnored')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
paths: ['dist/bundle.js', 'src/index.ts']
})
).resolves.toEqual(['dist/bundle.js'])
await expect(
handlers.get('git:checkIgnored')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
paths: ['build/output.js']
})
).resolves.toEqual(['build/output.js'])
expect(checkIgnoredPathsMock).toHaveBeenCalledWith(
WORKTREE_FEATURE_PATH,
[path.join('dist', 'bundle.js'), path.join('src', 'index.ts')],
{}
)
expect(sshProvider.checkIgnoredPaths).toHaveBeenCalledWith('/remote/repo', [
path.join('build', 'output.js')
])
})
it('routes abort merge through local and SSH git providers', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
abortMergeMock.mockResolvedValue(undefined)
const sshProvider = {
abortMerge: vi.fn().mockResolvedValue(undefined)
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:abortMerge')!(null, { worktreePath: WORKTREE_FEATURE_PATH })
await handlers.get('git:abortMerge')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1'
})
expect(abortMergeMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {})
expect(sshProvider.abortMerge).toHaveBeenCalledWith('/remote/repo')
})
it('routes abort rebase through local and SSH git providers', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
abortRebaseMock.mockResolvedValue(undefined)
const sshProvider = {
abortRebase: vi.fn().mockResolvedValue(undefined)
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:abortRebase')!(null, { worktreePath: WORKTREE_FEATURE_PATH })
await handlers.get('git:abortRebase')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1'
})
expect(abortRebaseMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {})
expect(sshProvider.abortRebase).toHaveBeenCalledWith('/remote/repo')
})
it('rejects git file paths that escape the selected worktree', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:discard')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
filePath: '../outside.txt'
})
).rejects.toThrow('Access denied: git file path escapes the selected worktree')
expect(discardChangesMock).not.toHaveBeenCalled()
})
it('rejects git operations for unknown worktrees', async () => {
listWorktreesMock.mockResolvedValue([])
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:status')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
).rejects.toThrow('Access denied: unknown repository or worktree path')
expect(getStatusMock).not.toHaveBeenCalled()
})
it('normalizes git file paths for bulk stage requests', async () => {
bulkStageFilesMock.mockResolvedValue(undefined)
registerFilesystemHandlers(store as never)
await handlers.get('git:bulkStage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
filePaths: ['./src/../src/file.ts', 'nested//child.ts']
})
expect(bulkStageFilesMock).toHaveBeenCalledWith(
WORKTREE_FEATURE_PATH,
[path.join('src', 'file.ts'), path.join('nested', 'child.ts')],
{}
)
})
it('normalizes git file paths for bulk discard requests', async () => {
bulkDiscardChangesMock.mockResolvedValue(undefined)
registerFilesystemHandlers(store as never)
await handlers.get('git:bulkDiscard')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
filePaths: ['./src/../src/file.ts', 'nested//child.ts']
})
expect(bulkDiscardChangesMock).toHaveBeenCalledWith(
WORKTREE_FEATURE_PATH,
[path.join('src', 'file.ts'), path.join('nested', 'child.ts')],
{}
)
})
it('rejects bulk unstage requests that escape the selected worktree', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:bulkUnstage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
filePaths: ['src/file.ts', '../outside.txt']
})
).rejects.toThrow('Access denied: git file path escapes the selected worktree')
expect(bulkUnstageFilesMock).not.toHaveBeenCalled()
})
it('rejects bulk discard requests that escape the selected worktree', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:bulkDiscard')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
filePaths: ['src/file.ts', '../outside.txt']
})
).rejects.toThrow('Access denied: git file path escapes the selected worktree')
expect(bulkDiscardChangesMock).not.toHaveBeenCalled()
})
it('lists markdown documents recursively for a registered worktree', async () => {
readdirMock.mockImplementation(async (dirPath: string) => {
if (dirPath === WORKTREE_FEATURE_PATH) {
return [
dirEntry({ name: 'README.md', file: true }),
dirEntry({ name: 'docs', directory: true }),
dirEntry({ name: 'script.ts', file: true })
]
}
if (dirPath === path.join(WORKTREE_FEATURE_PATH, 'docs')) {
return [
dirEntry({ name: 'Guide.MDX', file: true }),
dirEntry({ name: 'notes.markdown', file: true })
]
}
return []
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: WORKTREE_FEATURE_PATH
})
).resolves.toEqual([
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'Guide.MDX'),
relativePath: 'docs/Guide.MDX',
basename: 'Guide.MDX',
name: 'Guide'
},
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'docs', 'notes.markdown'),
relativePath: 'docs/notes.markdown',
basename: 'notes.markdown',
name: 'notes'
},
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'README.md'),
relativePath: 'README.md',
basename: 'README.md',
name: 'README'
}
])
})
it('skips ignored and symlinked directories when listing markdown documents', async () => {
readdirMock.mockImplementation(async (dirPath: string) => {
if (dirPath === WORKTREE_FEATURE_PATH) {
return [
dirEntry({ name: '.git', directory: true }),
dirEntry({ name: '.hidden', directory: true }),
dirEntry({ name: '.github', directory: true }),
dirEntry({ name: 'node_modules', directory: true }),
dirEntry({ name: 'linked-docs', directory: true, symlink: true }),
dirEntry({ name: 'visible.md', file: true })
]
}
if (dirPath === path.join(WORKTREE_FEATURE_PATH, '.github')) {
return [dirEntry({ name: 'CONTRIBUTING.md', file: true })]
}
throw new Error(`Unexpected readdir: ${dirPath}`)
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: WORKTREE_FEATURE_PATH
})
).resolves.toEqual([
{
filePath: path.join(WORKTREE_FEATURE_PATH, '.github', 'CONTRIBUTING.md'),
relativePath: '.github/CONTRIBUTING.md',
basename: 'CONTRIBUTING.md',
name: 'CONTRIBUTING'
},
{
filePath: path.join(WORKTREE_FEATURE_PATH, 'visible.md'),
relativePath: 'visible.md',
basename: 'visible.md',
name: 'visible'
}
])
})
it('rejects markdown document listing for authorized but unregistered roots', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: path.resolve('/workspace/unregistered')
})
).rejects.toThrow('Access denied: unknown repository or worktree path')
expect(readdirMock).not.toHaveBeenCalled()
})
it('lists remote markdown documents through the SSH filesystem provider', async () => {
const provider = {
listFiles: vi
.fn()
.mockResolvedValue(['README.md', 'docs/guide.mdx', '../outside.md', 'src/app.ts'])
}
getSshFilesystemProviderMock.mockReturnValue(provider)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:listMarkdownDocuments')!(null, {
rootPath: '/home/user/project',
connectionId: 'ssh-1'
})
).resolves.toEqual([
{
filePath: '/home/user/project/docs/guide.mdx',
relativePath: 'docs/guide.mdx',
basename: 'guide.mdx',
name: 'guide'
},
{
filePath: '/home/user/project/README.md',
relativePath: 'README.md',
basename: 'README.md',
name: 'README'
}
])
})
it('routes branch compare queries through the git compare helper', async () => {
getBranchCompareMock.mockResolvedValue({
summary: {
baseRef: 'origin/main',
baseOid: 'base-oid',
compareRef: 'main',
headOid: 'head-oid',
mergeBase: 'merge-base-oid',
changedFiles: 1,
status: 'ready'
},
entries: [{ path: 'src/file.ts', status: 'modified' }]
})
registerFilesystemHandlers(store as never)
await handlers.get('git:branchCompare')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
baseRef: 'origin/main'
})
expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main', {})
})
it('routes local git:commit through commitChanges and returns success', async () => {
commitChangesMock.mockResolvedValue({ success: true })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
message: 'feat: ship commit'
})
).resolves.toEqual({ success: true })
expect(commitChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'feat: ship commit', {})
})
it('returns local commit hook failure payload from git:commit', async () => {
commitChangesMock.mockResolvedValue({ success: false, error: 'hook failed' })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
message: 'feat: ship commit'
})
).resolves.toEqual({ success: false, error: 'hook failed' })
})
it('generates a local commit message from main-process staged context', async () => {
const context = {
branch: 'feature/ai',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
getStagedCommitContextMock.mockResolvedValue(context)
generateCommitMessageFromContextMock.mockResolvedValue({
success: true,
message: 'Update README'
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
).resolves.toEqual({ success: true, message: 'Update README' })
expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {})
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(context, params, {
kind: 'local',
cwd: WORKTREE_FEATURE_PATH
})
})
it('uses one-shot resolved params for local commit message generation', async () => {
const context = {
branch: 'feature/ai',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const sourceControlAiResolvedParams = {
agentId: 'codex' as const,
model: 'gpt-5.5',
thinkingLevel: 'high',
customPrompt: 'Use Conventional Commits.'
}
getStagedCommitContextMock.mockResolvedValue(context)
generateCommitMessageFromContextMock.mockResolvedValue({
success: true,
message: 'feat: update readme'
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
sourceControlAiResolvedParams
})
).resolves.toEqual({ success: true, message: 'feat: update readme' })
expect(resolveCommitMessageSettingsMock).not.toHaveBeenCalled()
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
context,
sourceControlAiResolvedParams,
{
kind: 'local',
cwd: WORKTREE_FEATURE_PATH
}
)
})
it('prepares the selected Codex account home before local generation', async () => {
const context = {
branch: 'feature/ai',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
getStagedCommitContextMock.mockResolvedValue(context)
generateCommitMessageFromContextMock.mockResolvedValue({
success: true,
message: 'Update README'
})
registerFilesystemHandlers(store as never, {
prepareForCodexLaunch: () => '/managed/codex-home'
})
await handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
context,
params,
expect.objectContaining({
kind: 'local',
cwd: WORKTREE_FEATURE_PATH,
env: expect.objectContaining({ CODEX_HOME: '/managed/codex-home' })
})
)
})
it('prepares the Orca-managed Codex home for the default system selection', async () => {
const context = {
branch: 'feature/ai',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
getStagedCommitContextMock.mockResolvedValue(context)
generateCommitMessageFromContextMock.mockResolvedValue({
success: true,
message: 'Update README'
})
registerFilesystemHandlers(store as never, {
prepareForCodexLaunch: () => '/orca-managed/codex-home'
})
await handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
context,
params,
expect.objectContaining({
kind: 'local',
cwd: WORKTREE_FEATURE_PATH,
env: expect.objectContaining({ CODEX_HOME: '/orca-managed/codex-home' })
})
)
})
it('routes local WSL project commit-message generation through the project runtime target', async () => {
await withPlatform('win32', async () => {
const context = {
branch: 'feature/ai',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
const prepareForCodexLaunch = vi.fn(() => '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.codex')
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
getStagedCommitContextMock.mockResolvedValue(context)
generateCommitMessageFromContextMock.mockResolvedValue({
success: true,
message: 'Update README'
})
const wslStore = {
...store,
getRepos: () => [
{
id: 'repo-1',
path: WORKTREE_FEATURE_PATH,
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
],
getProjects: () => [
{
id: 'project-1',
sourceRepoIds: ['repo-1'],
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
}
],
getSettings: () => ({
workspaceDir: WORKSPACE_DIR,
localWindowsRuntimeDefault: { kind: 'windows-host' }
})
}
registerFilesystemHandlers(wslStore as never, { prepareForCodexLaunch })
await handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
wslDistro: 'Ubuntu'
})
expect(prepareForCodexLaunch).toHaveBeenCalledWith({
runtime: 'wsl',
wslDistro: 'Ubuntu'
})
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
context,
params,
expect.objectContaining({
kind: 'local',
cwd: WORKTREE_FEATURE_PATH,
wslDistro: 'Ubuntu',
env: expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' })
})
)
})
})
it('returns a sanitized error when local agent account preparation fails', async () => {
const context = {
branch: 'feature/ai',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const params = { agentId: 'codex', model: 'gpt-5.4-mini', thinkingLevel: 'low' }
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
getStagedCommitContextMock.mockResolvedValue(context)
registerFilesystemHandlers(store as never, {
prepareForCodexLaunch: () => {
throw new Error('failed to read /Users/alice/.codex/auth.json')
}
})
await expect(
handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
).resolves.toEqual({
success: false,
error: 'Failed to prepare the selected agent account for commit message generation.'
})
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
})
it('prepares the selected Claude auth environment before local generation', async () => {
const previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY
process.env.ANTHROPIC_API_KEY = 'do-not-leak-managed-auth-conflict'
const context = {
branch: 'feature/ai',
stagedSummary: 'M\tREADME.md',
stagedPatch: '+hello'
}
const params = { agentId: 'claude', model: 'haiku' }
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
getStagedCommitContextMock.mockResolvedValue(context)
generateCommitMessageFromContextMock.mockResolvedValue({
success: true,
message: 'Update README'
})
try {
registerFilesystemHandlers(store as never, {
prepareForClaudeLaunch: async () => ({
configDir: '/managed/claude',
envPatch: { CLAUDE_CONFIG_DIR: '/managed/claude' },
stripAuthEnv: true,
provenance: 'managed:account-1'
})
})
await handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
const target = generateCommitMessageFromContextMock.mock.calls[0]?.[2] as
| { env?: NodeJS.ProcessEnv }
| undefined
expect(target?.env).toEqual(
expect.objectContaining({
CLAUDE_CONFIG_DIR: '/managed/claude'
})
)
expect(target?.env?.ANTHROPIC_API_KEY).toBeUndefined()
} finally {
if (previousAnthropicApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY
} else {
process.env.ANTHROPIC_API_KEY = previousAnthropicApiKey
}
}
})
it('passes per-agent command overrides into local model discovery', async () => {
discoverCommitMessageModelsLocalMock.mockResolvedValue({
success: true,
capability: {
id: 'codex',
label: 'Codex',
modelSource: 'dynamic',
defaultModelId: 'gpt-5.5',
models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }]
},
models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }],
defaultModelId: 'gpt-5.5'
})
const storeWithOverride = {
...store,
getSettings: () => ({
workspaceDir: WORKSPACE_DIR,
agentCmdOverrides: { codex: 'npx codex' }
})
}
registerFilesystemHandlers(storeWithOverride as never)
await handlers.get('git:discoverCommitMessageModels')!(null, { agentId: 'codex' })
expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith(
'codex',
undefined,
'npx codex'
)
})
it('routes local WSL project model discovery through the project runtime target', async () => {
await withPlatform('win32', async () => {
discoverCommitMessageModelsLocalMock.mockResolvedValue({
success: true,
capability: {
id: 'codex',
label: 'Codex',
modelSource: 'dynamic',
defaultModelId: 'gpt-5.5',
models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }]
},
models: [{ id: 'gpt-5.5', label: 'GPT-5.5' }],
defaultModelId: 'gpt-5.5'
})
const prepareForCodexLaunch = vi.fn(() => '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.codex')
const wslStore = {
...store,
getRepos: () => [
{
id: 'repo-1',
path: WORKTREE_FEATURE_PATH,
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
],
getProjects: () => [
{
id: 'project-1',
sourceRepoIds: ['repo-1'],
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
}
],
getSettings: () => ({
workspaceDir: WORKSPACE_DIR,
agentCmdOverrides: { codex: 'npx codex' },
localWindowsRuntimeDefault: { kind: 'windows-host' }
})
}
registerFilesystemHandlers(wslStore as never, { prepareForCodexLaunch })
await handlers.get('git:discoverCommitMessageModels')!(null, {
agentId: 'codex',
worktreePath: WORKTREE_FEATURE_PATH
})
expect(prepareForCodexLaunch).toHaveBeenCalledWith({
runtime: 'wsl',
wslDistro: 'Ubuntu'
})
expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith(
'codex',
expect.objectContaining({ CODEX_HOME: '/home/tester/.codex' }),
'npx codex',
{ cwd: WORKTREE_FEATURE_PATH, wslDistro: 'Ubuntu' }
)
})
})
it('routes SSH model discovery through the remote git provider', async () => {
discoverCommitMessageModelsRemoteMock.mockResolvedValue({
success: true,
capability: {
id: 'cursor',
label: 'Cursor',
modelSource: 'dynamic',
defaultModelId: 'auto',
models: [{ id: 'auto', label: 'Auto' }]
},
models: [{ id: 'auto', label: 'Auto' }],
defaultModelId: 'auto'
})
const executeCommitMessagePlan = vi.fn()
getSshGitProviderMock.mockReturnValue({ executeCommitMessagePlan })
const storeWithOverride = {
...store,
getSettings: () => ({
workspaceDir: WORKSPACE_DIR,
agentCmdOverrides: { cursor: 'npx cursor-agent' }
})
}
registerFilesystemHandlers(storeWithOverride as never)
await handlers.get('git:discoverCommitMessageModels')!(null, {
agentId: 'cursor',
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
expect(discoverCommitMessageModelsRemoteMock).toHaveBeenCalledWith(
'cursor',
'/remote/repo',
expect.any(Function),
'npx cursor-agent'
)
const execute = discoverCommitMessageModelsRemoteMock.mock.calls[0]?.[2] as (
plan: unknown,
cwd: string,
timeoutMs: number
) => Promise<unknown>
await execute({ binary: 'cursor-agent', args: ['--list-models'] }, '/remote/repo', 60_000)
expect(executeCommitMessagePlan).toHaveBeenCalledWith(
{ binary: 'cursor-agent', args: ['--list-models'] },
'/remote/repo',
60_000
)
expect(discoverCommitMessageModelsLocalMock).not.toHaveBeenCalled()
})
it('generates an SSH commit message using remote staged context and relay execution', async () => {
const context = {
branch: 'main',
stagedSummary: 'A\tremote.txt',
stagedPatch: '+remote'
}
const params = { agentId: 'custom', model: '', customAgentCommand: 'agent' }
const executeCommitMessagePlan = vi.fn()
const prepareForCodexLaunch = vi.fn(() => '/managed/codex-home')
const prepareForClaudeLaunch = vi.fn()
resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params })
getSshGitProviderMock.mockReturnValue({
getStagedCommitContext: vi.fn().mockResolvedValue(context),
executeCommitMessagePlan
})
generateCommitMessageFromContextMock.mockResolvedValue({
success: true,
message: 'Add remote file'
})
registerFilesystemHandlers(store as never, {
prepareForCodexLaunch,
prepareForClaudeLaunch
})
await expect(
handlers.get('git:generateCommitMessage')!(null, {
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
).resolves.toEqual({ success: true, message: 'Add remote file' })
expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(
context,
params,
expect.objectContaining({
kind: 'remote',
cwd: '/remote/repo',
missingBinaryLocation: 'remote PATH'
})
)
const target = generateCommitMessageFromContextMock.mock.calls[0]?.[2]
await target.execute(
{ binary: 'agent', args: [], stdinPayload: null, label: 'agent' },
'/cwd',
1,
'commit-message'
)
expect(executeCommitMessagePlan).toHaveBeenCalledWith(
{ binary: 'agent', args: [], stdinPayload: null, label: 'agent' },
'/cwd',
1,
'commit-message'
)
expect(prepareForCodexLaunch).not.toHaveBeenCalled()
expect(prepareForClaudeLaunch).not.toHaveBeenCalled()
})
it('routes SSH generation cancellations to separate provider operations', async () => {
const cancelGenerateCommitMessage = vi.fn().mockResolvedValue(undefined)
getSshGitProviderMock.mockReturnValue({ cancelGenerateCommitMessage })
registerFilesystemHandlers(store as never)
await handlers.get('git:cancelGenerateCommitMessage')!(null, {
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
await handlers.get('git:cancelGeneratePullRequestFields')!(null, {
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith(1, '/remote/repo', 'commit-message')
expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith(
2,
'/remote/repo',
'pull-request-fields'
)
expect(cancelGenerateCommitMessageLocalMock).not.toHaveBeenCalled()
expect(cancelGeneratePullRequestFieldsLocalMock).not.toHaveBeenCalled()
})
it('does not call the generator when no staged changes exist', async () => {
resolveCommitMessageSettingsMock.mockReturnValue({
ok: true,
params: { agentId: 'codex', model: 'gpt-5.4-mini' }
})
getStagedCommitContextMock.mockResolvedValue(null)
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
).resolves.toEqual({ success: false, error: 'No staged changes to summarize.' })
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
})
it('sanitizes local staged-context read failures before returning to the renderer', async () => {
resolveCommitMessageSettingsMock.mockReturnValue({
ok: true,
params: { agentId: 'codex', model: 'gpt-5.4-mini' }
})
getStagedCommitContextMock.mockRejectedValue(new Error('fatal: /secret/repo failed'))
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:generateCommitMessage')!(null, {
worktreePath: WORKTREE_FEATURE_PATH
})
).resolves.toEqual({ success: false, error: 'Failed to read staged changes.' })
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
})
it('sanitizes SSH staged-context read failures before returning to the renderer', async () => {
resolveCommitMessageSettingsMock.mockReturnValue({
ok: true,
params: { agentId: 'codex', model: 'gpt-5.4-mini' }
})
getSshGitProviderMock.mockReturnValue({
getStagedCommitContext: vi.fn().mockRejectedValue(new Error('fatal: /remote/secret failed'))
})
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:generateCommitMessage')!(null, {
worktreePath: '/remote/repo',
connectionId: 'conn-1'
})
).resolves.toEqual({ success: false, error: 'Failed to read staged changes.' })
expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled()
})
it('routes ssh git:commit through the SSH provider instead of local commitChanges', async () => {
const sshCommitMock = vi.fn().mockResolvedValue({ success: true })
getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: '/remote/repo',
message: 'feat: remote commit',
connectionId: 'conn-1'
})
).resolves.toEqual({ success: true })
expect(sshCommitMock).toHaveBeenCalledWith('/remote/repo', 'feat: remote commit')
expect(commitChangesMock).not.toHaveBeenCalled()
})
it('routes ssh git:remoteCommitUrl through the SSH provider', async () => {
const sha = '0123456789abcdef0123456789abcdef01234567'
const sshRemoteCommitUrlMock = vi.fn().mockResolvedValue('https://github.com/org/repo/commit/x')
getSshGitProviderMock.mockReturnValue({ getRemoteCommitUrl: sshRemoteCommitUrlMock })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:remoteCommitUrl')!(null, {
worktreePath: '/remote/repo',
sha,
connectionId: 'conn-1'
})
).resolves.toBe('https://github.com/org/repo/commit/x')
expect(sshRemoteCommitUrlMock).toHaveBeenCalledWith('/remote/repo', sha)
})
it('rejects git:remoteCommitUrl with a short hash before SSH dispatch', async () => {
const sshRemoteCommitUrlMock = vi.fn()
getSshGitProviderMock.mockReturnValue({ getRemoteCommitUrl: sshRemoteCommitUrlMock })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:remoteCommitUrl')!(null, {
worktreePath: '/remote/repo',
sha: 'abc123',
connectionId: 'conn-1'
})
).rejects.toThrow('sha must be a full git object id')
expect(sshRemoteCommitUrlMock).not.toHaveBeenCalled()
})
it('routes ssh git:bulkDiscard through the SSH provider', async () => {
const sshBulkDiscardMock = vi.fn().mockResolvedValue(undefined)
getSshGitProviderMock.mockReturnValue({ bulkDiscardChanges: sshBulkDiscardMock })
registerFilesystemHandlers(store as never)
await handlers.get('git:bulkDiscard')!(null, {
worktreePath: '/remote/repo',
filePaths: ['a.ts', 'b.ts'],
connectionId: 'conn-1'
})
expect(sshBulkDiscardMock).toHaveBeenCalledWith('/remote/repo', ['a.ts', 'b.ts'])
expect(bulkDiscardChangesMock).not.toHaveBeenCalled()
})
it('routes ssh git:fastForward through the SSH provider', async () => {
const sshFastForwardMock = vi.fn().mockResolvedValue(undefined)
const pushTarget = { remoteName: 'fork', branchName: 'feature/fix' }
getSshGitProviderMock.mockReturnValue({ fastForwardBranch: sshFastForwardMock })
registerFilesystemHandlers(store as never)
await handlers.get('git:fastForward')!(null, {
worktreePath: '/remote/repo',
connectionId: 'conn-1',
pushTarget
})
expect(sshFastForwardMock).toHaveBeenCalledWith('/remote/repo', pushTarget)
})
it('rejects git:commit with empty message and does not call commitChanges', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
message: ''
})
).rejects.toThrow('Commit message is required')
expect(commitChangesMock).not.toHaveBeenCalled()
})
it('rejects git:commit with whitespace-only message and does not call commitChanges', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
message: ' '
})
).rejects.toThrow('Commit message is required')
expect(commitChangesMock).not.toHaveBeenCalled()
})
it('rejects git:commit with whitespace-only message before SSH dispatch', async () => {
const sshCommitMock = vi.fn().mockResolvedValue({ success: true })
getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: '/remote/repo',
message: '\n',
connectionId: 'conn-1'
})
).rejects.toThrow('Commit message is required')
expect(sshCommitMock).not.toHaveBeenCalled()
})
it('allows git operations on worktrees outside repo/workspace roots', async () => {
// Linked worktrees can live anywhere on disk (e.g. ~/.codex/worktrees/).
// As long as the path matches a worktree reported by `git worktree list`
// for a registered repo, it should be allowed — the security boundary is
// worktree registration, not directory containment.
const externalWorktreePath = path.resolve('/external/worktrees/feature')
listWorktreesMock.mockResolvedValue([
{
path: REPO_PATH,
head: 'abc',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: externalWorktreePath,
head: 'def',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
getBranchCompareMock.mockResolvedValue({
summary: {
baseRef: 'origin/main',
baseOid: 'base-oid',
compareRef: 'feature',
headOid: 'head-oid',
mergeBase: 'merge-base-oid',
changedFiles: 0,
status: 'ready'
},
entries: []
})
registerFilesystemHandlers(store as never)
await handlers.get('git:branchCompare')!(null, {
worktreePath: externalWorktreePath,
baseRef: 'origin/main'
})
expect(getBranchCompareMock).toHaveBeenCalledWith(externalWorktreePath, 'origin/main', {})
})
it('rejects branchCompare for a worktree added after cache was built, then succeeds after invalidation', async () => {
// Reproduces the bug where CLI-created worktrees fail with
// "Access denied: unknown repository or worktree path" because the
// filesystem-auth cache was not invalidated after creation.
const cliWorktreePath = path.resolve('/external/cli-created-worktree')
// Step 1: register handlers and trigger initial cache build with only
// the original worktree in the listing.
registerFilesystemHandlers(store as never)
// Warm the cache by calling a git operation on the existing worktree.
getStatusMock.mockResolvedValue({ entries: [] })
await handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH })
// Step 2: simulate the CLI creating a new worktree — git now lists it,
// but the auth cache is stale.
listWorktreesMock.mockResolvedValue([
{
path: WORKTREE_FEATURE_PATH,
head: 'abc',
branch: '',
isBare: false,
isMainWorktree: false
},
{
path: cliWorktreePath,
head: 'def',
branch: 'refs/heads/cli-feature',
isBare: false,
isMainWorktree: false
}
])
// Step 3: branchCompare on the new worktree should fail — this is the
// exact error the user reported.
await expect(
handlers.get('git:branchCompare')!(null, {
worktreePath: cliWorktreePath,
baseRef: 'origin/main'
})
).rejects.toThrow('Access denied: unknown repository or worktree path')
// Step 4: invalidate the cache (what our fix does after CLI create).
invalidateAuthorizedRootsCache()
// Step 5: the same branchCompare should now succeed.
getBranchCompareMock.mockResolvedValue({
summary: {
baseRef: 'origin/main',
baseOid: 'base-oid',
compareRef: 'cli-feature',
headOid: 'head-oid',
mergeBase: 'merge-base-oid',
changedFiles: 0,
status: 'ready'
},
entries: []
})
await handlers.get('git:branchCompare')!(null, {
worktreePath: cliWorktreePath,
baseRef: 'origin/main'
})
expect(getBranchCompareMock).toHaveBeenCalledWith(cliWorktreePath, 'origin/main', {})
})
it('routes branch diff queries through the pinned branch diff helper', async () => {
getBranchDiffMock.mockResolvedValue({
kind: 'text',
originalContent: 'left',
modifiedContent: 'right',
originalIsBinary: false,
modifiedIsBinary: false
})
registerFilesystemHandlers(store as never)
await handlers.get('git:branchDiff')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
compare: {
baseRef: 'origin/main',
baseOid: 'base-oid',
headOid: 'head-oid',
mergeBase: 'merge-base-oid'
},
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
})
// Why: validateGitRelativeFilePath uses path.relative() which produces
// platform-specific separators (backslashes on Windows).
expect(getBranchDiffMock).toHaveBeenCalledWith(
WORKTREE_FEATURE_PATH,
{
headOid: 'head-oid',
mergeBase: 'merge-base-oid',
filePath: path.join('src', 'file.ts'),
oldPath: path.join('src', 'old-file.ts')
},
{}
)
})
// Why: the original SSH Quick Open bug had two halves — relay-side policy
// drift AND the main dispatcher silently dropping excludePaths before the
// provider saw them. This test guards the second half: regardless of
// relay behavior, a new linked worktree under the root must be forwarded
// so the remote scan can prune it. See docs/design/share-quick-open-file-listing.md.
it('fs:listFiles forwards excludePaths to the SSH filesystem provider', async () => {
const listFilesMock = vi.fn().mockResolvedValue([])
getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock })
registerFilesystemHandlers(store as never)
await handlers.get('fs:listFiles')!(null, {
rootPath: '/home/user/repo',
connectionId: 'conn-1',
excludePaths: ['/home/user/repo/worktrees/feature']
})
expect(listFilesMock).toHaveBeenCalledWith('/home/user/repo', {
excludePaths: ['/home/user/repo/worktrees/feature']
})
})
// Why #7721: without a cancel path, every workspace switch left the previous
// workspace's full-tree SSH scan running, stacking scans on the relay until
// interactive fs.readDir/fs.stat starved past their 30s timeout.
it('fs:cancelListFiles aborts an in-flight SSH listing by request token (#7721)', async () => {
let capturedSignal: AbortSignal | undefined
const listFilesMock = vi.fn(
(_rootPath: string, options: { signal?: AbortSignal }) =>
new Promise<string[]>((_resolve, reject) => {
capturedSignal = options.signal
options.signal?.addEventListener('abort', () => reject(new Error('listing cancelled')), {
once: true
})
})
)
getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock })
registerFilesystemHandlers(store as never)
const pending = handlers.get('fs:listFiles')!(null, {
rootPath: '/home/user/repo',
connectionId: 'conn-1',
requestToken: 'token-1'
}) as Promise<string[]>
expect(capturedSignal?.aborted).toBe(false)
await handlers.get('fs:cancelListFiles')!(null, { requestToken: 'token-1' })
expect(capturedSignal?.aborted).toBe(true)
await expect(pending).rejects.toThrow('listing cancelled')
// Unknown or already-settled tokens are a no-op, not an error.
expect(() =>
handlers.get('fs:cancelListFiles')!(null, { requestToken: 'unknown' })
).not.toThrow()
})
})