Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9803bfd
feat(explorer): add optional host filesystem navigation
Chihen-Tai Sep 29, 2026
f612e99
test(explorer): toggle Host mode through state so tree remounts are o…
Chihen-Tai Sep 29, 2026
ebede3e
fix(explorer): keep Host-mode tabs out of persisted tab chrome
Chihen-Tai Sep 29, 2026
f7441cf
docs(explorer): note the patch trigger Host-tab filtering relies on
Chihen-Tai Sep 29, 2026
305b86c
perf(explorer): make Host/Project switching shift-free and cheap
Chihen-Tai Sep 29, 2026
d1cad27
fix(explorer): keep covered Project search inert and leave Host mode …
Chihen-Tai Sep 29, 2026
b90c711
fix(explorer): trust canonical ownership for Host opens and gate SSH …
Chihen-Tai Sep 29, 2026
1f63af9
fix(explorer): virtualize Host listings, block tree shortcuts under H…
Chihen-Tai Sep 29, 2026
27454a3
fix(explorer): start a new Host folder or filter at the top
Chihen-Tai Sep 29, 2026
34a7259
Merge stablyai/orca main into feat/explorer-host-mode
Chihen-Tai Sep 30, 2026
62c980d
refactor(explorer): trim Host mode to local and SSH, end it when leav…
Chihen-Tai Sep 30, 2026
e72c331
test(explorer): lock fresh Host sessions per entry and filter reset o…
Chihen-Tai Sep 30, 2026
9d48c37
test(explorer): type the recorded visit keys without an assertion
Chihen-Tai Sep 30, 2026
95990d2
fix(explorer): end Host mode when the host becomes unavailable
Chihen-Tai Sep 30, 2026
ab170a1
refactor(explorer): conservative cleanup before merge and end Host vi…
Chihen-Tai Sep 30, 2026
7cd6d66
Merge stablyai/orca main into feat/explorer-host-mode
Chihen-Tai Sep 30, 2026
976e0e2
refactor(explorer): trim Host mode code and tests
Chihen-Tai Sep 30, 2026
0230ee9
Merge origin/main into feat/explorer-host-mode
Chihen-Tai Oct 3, 2026
b2bfb20
fix(explorer): hold the Host mode loading spinner for a minimum time
Chihen-Tai Oct 4, 2026
a154f12
Merge origin/main into feat/explorer-host-mode
Chihen-Tai Oct 5, 2026
3794745
Merge origin/main into feat/explorer-host-mode
Chihen-Tai Oct 8, 2026
4d9e126
Merge origin/main into feat/explorer-host-mode
Chihen-Tai Oct 18, 2026
b52448c
Merge remote-tracking branch 'origin/main' into feat/explorer-host-mode
Chihen-Tai Oct 28, 2026
908204f
test(explorer): await async isPathAllowed after main merge
Chihen-Tai Oct 28, 2026
c53f2d2
fix(explorer): refuse SSH host-mode file opens until an SSH stat RPC …
Chihen-Tai Oct 28, 2026
fc08bdd
Merge remote-tracking branch 'origin/main' into feat/explorer-host-mode
Chihen-Tai Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions src/main/ipc/filesystem/filesystem-host-browse-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { mkdir, 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 type { HostDirectoryListing } from '../../../shared/filesystem-entry-types'
import type { Store } from '../../persistence'
import { isPathAllowed, resolveAuthorizedPath } from './filesystem-auth'
import {
registerFilesystemHostBrowseHandlers,
resolveHostBrowseEntry
} from './filesystem-host-browse-handlers'

const { handlers } = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, args: unknown) => unknown>()
}))

vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) =>
handlers.set(channel, handler)
}
}))

// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: allowed-root lookups read only these four accessors.
const store = {
getRepos: () => [],
getProjectGroups: () => [],
getFolderWorkspaces: () => [],
getSettings: () => ({})
} as unknown as Store

describe('host browse handlers', () => {
let root: string

beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-host-browse-')))
await mkdir(join(root, 'outside', 'nested'), { recursive: true })
await writeFile(join(root, 'outside', 'notes.txt'), 'hi')
})

afterEach(async () => {
await rm(root, { recursive: true, force: true })
})

it('lists a directory outside allowed roots without granting access to it', async () => {
registerFilesystemHostBrowseHandlers()
const outside = join(root, 'outside')
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fs:browseHostDir resolves to a HostDirectoryListing.
const listing = (await handlers.get('fs:browseHostDir')?.(null, {
dirPath: outside
})) as HostDirectoryListing

expect(listing.resolvedPath).toBe(outside)
expect(listing.entries.map((entry) => entry.name).sort()).toEqual(['nested', 'notes.txt'])
await expect(isPathAllowed(outside, store)).resolves.toBe(false)
await expect(isPathAllowed(join(outside, 'nested', 'x'), store)).resolves.toBe(false)
await expect(resolveAuthorizedPath(join(outside, 'nested'), store)).rejects.toThrow(
/Access denied/
)
})

it('classifies a regular file outside the workspace without granting it', async () => {
const file = join(root, 'outside', 'notes.txt')
const resolution = await resolveHostBrowseEntry(file)

expect(resolution).toEqual({ kind: 'file', realPath: file, workspaceRelativePath: null })
await expect(isPathAllowed(file, store)).resolves.toBe(false)
await expect(isPathAllowed(join(root, 'outside'), store)).resolves.toBe(false)
await expect(isPathAllowed(join(root, 'outside', 'nested'), store)).resolves.toBe(false)
})

it('classifies a symlink to a directory as a directory and grants nothing', async () => {
const target = join(root, 'outside', 'nested')
const link = join(root, 'dir-link')
await symlink(target, link, 'dir')

const resolution = await resolveHostBrowseEntry(link)

expect(resolution).toEqual({ kind: 'directory', realPath: target, workspaceRelativePath: null })
await expect(isPathAllowed(join(target, 'anything'), store)).resolves.toBe(false)
await expect(isPathAllowed(join(link, 'anything'), store)).resolves.toBe(false)
})

it('reports the canonical path of a symlinked file for worktree ownership checks', async () => {
const target = join(root, 'outside', 'notes.txt')
const link = join(root, 'file-link')
await symlink(target, link, 'file')

await expect(resolveHostBrowseEntry(link)).resolves.toEqual({
kind: 'file',
realPath: target,
workspaceRelativePath: null
})
})

it('rejects relative and null-byte paths, locally and over SSH', async () => {
await expect(resolveHostBrowseEntry('outside/notes.txt'), 'relative').rejects.toThrow(
/absolute/
)
await expect(resolveHostBrowseEntry(`${root}\0x`), 'local NUL').rejects.toThrow(/null bytes/)
await expect(
resolveHostBrowseEntry('/home/allen\0x', 'ssh-1'),
'null-byte SSH path'
).rejects.toThrow(/null bytes/)
})

it('resolves workspace ownership against the canonical workspace root', async () => {
const realWorkspace = join(root, 'real-workspace')
await mkdir(join(realWorkspace, 'src'), { recursive: true })
await writeFile(join(realWorkspace, 'src', 'a.ts'), 'x')
const workspaceAlias = join(root, 'workspace-alias')
await symlink(realWorkspace, workspaceAlias, 'dir')
const outsideLink = join(root, 'outside', 'a-link.ts')
await symlink(join(realWorkspace, 'src', 'a.ts'), outsideLink, 'file')

const resolution = await resolveHostBrowseEntry(outsideLink, undefined, workspaceAlias)

expect(resolution).toEqual({
kind: 'file',
realPath: join(realWorkspace, 'src', 'a.ts'),
workspaceRelativePath: 'src/a.ts'
})
// Why: workspace files are reached through allowed roots; no external grant is added.
await expect(isPathAllowed(outsideLink, store)).resolves.toBe(false)
})
})
69 changes: 69 additions & 0 deletions src/main/ipc/filesystem/filesystem-host-browse-handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { ipcMain } from 'electron'
import { realpath, stat } from 'node:fs/promises'
import { isAbsolute } from 'node:path'
import { relativePathInsideRoot } from '../../../shared/cross-platform-path'
import type {
HostBrowseEntryResolution,
HostDirectoryListing
} from '../../../shared/filesystem-entry-types'
import { browseServerDirectory } from '../../runtime/runtime-server-environment-commands'

async function canonicalRelativePath(
realPath: string,
workspaceRoot: string | undefined,
canonicalize: (path: string) => Promise<string>
): Promise<string | null> {
if (!workspaceRoot) {
return null
}
try {
return relativePathInsideRoot(await canonicalize(workspaceRoot), realPath)
} catch {
return null
}
}

export async function resolveHostBrowseEntry(
targetPath: string,
connectionId?: string,
workspaceRoot?: string
): Promise<HostBrowseEntryResolution> {
if (targetPath.includes('\0')) {
throw new Error('Path cannot contain null bytes')
}
if (connectionId) {
// ponytail: no SSH filesystem provider exists since the relay removal (#31922); add an SSH realpath/stat RPC to classify remote entries.
throw new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.')
}
if (!isAbsolute(targetPath)) {
throw new Error('Host browse path must be absolute')
}
const realPath = await realpath(targetPath)
const target = await stat(realPath)
const workspaceRelativePath = await canonicalRelativePath(realPath, workspaceRoot, realpath)
if (target.isDirectory()) {
return { kind: 'directory', realPath, workspaceRelativePath }
}
if (!target.isFile()) {
return { kind: 'unsupported', realPath, workspaceRelativePath }
}
return { kind: 'file', realPath, workspaceRelativePath }
}

export function registerFilesystemHostBrowseHandlers(): void {
// Why: names-only listing never touches the external-path allowlist, so browsing
// outside the workspace grants nothing to later read/write/delete handlers.
ipcMain.handle(
'fs:browseHostDir',
(_event, args: { dirPath: string }): Promise<HostDirectoryListing> =>
browseServerDirectory(args.dirPath)
)
ipcMain.handle(
'fs:resolveHostBrowseEntry',
(
_event,
args: { targetPath: string; connectionId?: string; workspaceRoot?: string }
): Promise<HostBrowseEntryResolution> =>
resolveHostBrowseEntry(args.targetPath, args.connectionId, args.workspaceRoot)
)
}
2 changes: 2 additions & 0 deletions src/main/ipc/filesystem/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { registerFilesystemGitRemoteHandlers } from './filesystem-git-remote-han
import { registerFilesystemGitDiffHandlers } from './filesystem-git-diff-handlers'
import { registerFilesystemGitIndexHandlers } from './filesystem-git-index-handlers'
import { registerFilesystemGitUrlHandlers } from './filesystem-git-url-handlers'
import { registerFilesystemHostBrowseHandlers } from './filesystem-host-browse-handlers'

export function registerFilesystemHandlers(
store: Store,
Expand All @@ -44,5 +45,6 @@ export function registerFilesystemHandlers(
registerFilesystemGitDiffHandlers(context)
registerFilesystemGitIndexHandlers(context)
registerFilesystemGitUrlHandlers(context)
registerFilesystemHostBrowseHandlers()
registerLocalLogTailHandlers(store)
}
60 changes: 30 additions & 30 deletions src/main/runtime/runtime-server-environment-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readdir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import type { Dirent } from 'node:fs'
import { isAbsolute, join, resolve } from 'node:path'
import type { DirEntry, FilesystemPathFlavor } from '../../shared/filesystem-entry-types'
import type { HostDirectoryListing } from '../../shared/filesystem-entry-types'
import { sortDirEntries } from '../../shared/file-name-sort'
import { probeGitAvailability } from '../git/git-availability'
import { gitExecFileAsync } from '../git/runner'
Expand Down Expand Up @@ -40,36 +40,36 @@ async function isBrowseEntryDirectory(dirPath: string, entry: Dirent): Promise<b
}
}

export async function browseServerDirectory(pathValue: string): Promise<HostDirectoryListing> {
if (isServerDriveListRequest(pathValue)) {
return listWindowsDrives()
}
const dirPath = resolveServerBrowsePath(pathValue)
const dirStat = await stat(dirPath)
if (!dirStat.isDirectory()) {
throw new Error(`${dirPath} is not a directory`)
}
const entries = await readdir(dirPath, { withFileTypes: true })
const mapped = await Promise.all(
entries
.filter((entry) => entry.name !== '.' && entry.name !== '..')
.map(async (entry) => ({
name: entry.name,
isDirectory: await isBrowseEntryDirectory(dirPath, entry),
isSymlink: entry.isSymbolicLink()
}))
)
sortDirEntries(mapped)
return {
resolvedPath: dirPath,
entries: mapped,
pathFlavor: process.platform === 'win32' ? 'win32' : 'posix'
}
}

export class RuntimeServerEnvironmentCommands {
async browseDirectory(pathValue: string): Promise<{
resolvedPath: string
entries: DirEntry[]
pathFlavor: FilesystemPathFlavor
}> {
if (isServerDriveListRequest(pathValue)) {
return listWindowsDrives()
}
const dirPath = resolveServerBrowsePath(pathValue)
const dirStat = await stat(dirPath)
if (!dirStat.isDirectory()) {
throw new Error(`${dirPath} is not a directory`)
}
const entries = await readdir(dirPath, { withFileTypes: true })
const mapped = await Promise.all(
entries
.filter((entry) => entry.name !== '.' && entry.name !== '..')
.map(async (entry) => ({
name: entry.name,
isDirectory: await isBrowseEntryDirectory(dirPath, entry),
isSymlink: entry.isSymbolicLink()
}))
)
sortDirEntries(mapped)
return {
resolvedPath: dirPath,
entries: mapped,
pathFlavor: process.platform === 'win32' ? 'win32' : 'posix'
}
async browseDirectory(pathValue: string): Promise<HostDirectoryListing> {
return browseServerDirectory(pathValue)
}

async isGitAvailable(): Promise<boolean> {
Expand Down
10 changes: 10 additions & 0 deletions src/preload/api/filesystem-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { PdfExportResult } from '../../shared/pdf-export-failure-copy'
import type {
DirEntry,
FsChangedPayload,
HostBrowseEntryResolution,
HostDirectoryListing,
MarkdownDocument
} from '../../shared/filesystem-entry-types'
import type {
Expand Down Expand Up @@ -184,6 +186,14 @@ export type FilesystemApi = {
permanent?: boolean
} & SshMutationExpectation
) => Promise<void>
/** Desktop-only names listing for the Explorer's Host mode; never widens path grants. */
browseHostDir?: (args: { dirPath: string }) => Promise<HostDirectoryListing>
/** Desktop-only; classifies a host path against the workspace without granting access. */
resolveHostBrowseEntry?: (args: {
targetPath: string
connectionId?: string
workspaceRoot?: string
}) => Promise<HostBrowseEntryResolution>
stat: (args: {
filePath: string
connectionId?: string
Expand Down
13 changes: 12 additions & 1 deletion src/preload/api/fs-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import type {
AgentSessionAttachmentUploadTarget
} from '../../shared/agent-session-attachments'
import type { SearchResult } from '../../shared/code-search-types'
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
import type {
FsChangedPayload,
HostBrowseEntryResolution,
HostDirectoryListing
} from '../../shared/filesystem-entry-types'
import type {
ImportItemResult,
ResolveDroppedPathsResult,
Expand Down Expand Up @@ -183,6 +187,13 @@ export const fsApi = {
permanent?: boolean
} & SshMutationExpectation
): Promise<void> => ipcRenderer.invoke('fs:deletePath', args),
browseHostDir: (args: { dirPath: string }): Promise<HostDirectoryListing> =>
ipcRenderer.invoke('fs:browseHostDir', args),
resolveHostBrowseEntry: (args: {
targetPath: string
connectionId?: string
workspaceRoot?: string
}): Promise<HostBrowseEntryResolution> => ipcRenderer.invoke('fs:resolveHostBrowseEntry', args),
stat: (args: {
filePath: string
connectionId?: string
Expand Down
Loading