Files
orca/src/main/runtime/runtime-file-commands-constructor.ts
Neil 946627f2ce fix(runtime): route runtime filesystem commands by resolved execution host (#18325)
`ResolvedRuntimeFileTarget` carried `connectionId?: string` and no host id, so
`undefined` spelled three different answers at once — "runtime: host", "unresolved"
and "genuinely local". Its sole resolver read `store.getRepo(worktree.repoId)?.connectionId`
and never looked at `worktree.hostId`, which outranks every repo row, so one
arbitrarily chosen row decided the execution host for ~30 filesystem dispatches.
This is #18307's defect in the same file family; it was deliberately left out of
that PR rather than doubling an already-36-site diff.

The target now carries `executionHostId: ExecutionHostId` (never null, never
optional), resolved through `resolveWorktreeHostRouting` — the same adapter #18307
added — and dispatched through #18296's `resolveFilesystemRouteForHost`. Dispatch
sites call `requireRuntimeFileProvider`, where `null` means exactly one thing: the
host is `local` and the read happens here.

Four answers that used to collapse into one:

- `ssh:x` with a rival row on `ssh:y` — routes to x. Previously the first row won.
- `local` with a surviving `connectionId` — a row contradicting itself; no SSH
  connection is handed out.
- `runtime:<env>` — throws `ExecutionHostNotDispatchableError`. Its repo row's
  connection names a target in the *server's* namespace; reading it here reaches a
  same-named target on this client.
- rival rows disagreeing with no worktree host — `worktree_execution_host_unresolved`,
  matching the launch and Git paths rather than guessing a row.

Two further reads stop degrading. `assertRuntimeFileMutationExpectation` recomputed
the host from `connectionId`, so a client's host expectation could pass against a
host the workspace never named; it now compares the resolved host. And the
cross-workspace terminal tap coalesced `knownWorkspaceTarget?.connectionId ??
connectionId`, so a sibling workspace resolved as `local` inherited the origin
worktree's SSH target and statted a local path on the remote box; a non-optional
host id replaces rather than coalesces.

An unreachable SSH host still throws `SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE`;
loss of contact is never evidence of locality (docs/reference/ssh-execution-boundary.md).
Quick-open listing and path search keep degrading to empty for an unreachable host —
that is a false negative, not a local answer — and now do so only for a host that
really is remote.

The whole `runtime-file-commands-*` family carries `@ts-nocheck` from a mechanical
class split, so removing the field could not raise the compile errors that made
#18307 safe. `runtime-file-command-target.ts` is deliberately checked, and a ratchet
test stands in for the errors the family cannot produce.

No wire change: `ResolvedRuntimeFileTarget` is main-process internal, and the SSH
watcher-release and grant keys are byte-identical to before.
2026-09-02 20:47:09 -07:00

223 lines
8.6 KiB
TypeScript

// @ts-nocheck -- mechanically split class members.
import {
RuntimeFileCommandsWithActiveRuntimeTextSearches,
RuntimeFileCommandsWithActiveRuntimeTextSearches as RuntimeFileCommands
} from './runtime-file-commands-active-runtime-text-searches'
import type { RuntimeFileCommandHost } from './runtime-file-command-host'
import {
isMobileBinaryPath,
isMobileMarkdownPath,
isSafeMobileRelativePath
} from './runtime-file-command-host'
import { basenameFromRelativePath } from './runtime-file-paths'
import type { RuntimeFileListResult, RuntimeFileOpenResult } from '../../shared/runtime-types'
import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
import {
MOBILE_FILE_LIST_LIMIT,
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT,
isMobilePreviewableImagePath
} from './runtime-file-commands-mobile-file-list-limit'
import { rankRuntimeMobileFilePaths } from './runtime-mobile-file-path-search'
import { isQuickOpenQueryTooLarge } from '../../shared/quick-open-path-search'
import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths'
import { stat } from 'node:fs/promises'
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { resolveAuthorizedPath } from '../ipc/filesystem-auth'
import { isENOENT } from '../ipc/filesystem-path-containment'
import { runtimeFileRouteForTarget, type RuntimeFileRoute } from './runtime-file-command-target'
export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithActiveRuntimeTextSearches {
constructor(private readonly host: RuntimeFileCommandHost) {
super()
}
async listMobileFiles(
worktreeSelector: string,
options: { signal?: AbortSignal } = {}
): Promise<RuntimeFileListResult> {
const store = this.host.requireStore()
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree } = target
const route = runtimeFileRouteForTarget(target)
const files =
route.kind === 'ssh'
? await this.listRemoteMobileFiles(worktree.path, route.provider, undefined, options.signal)
: await listQuickOpenFiles(worktree.path, store, undefined, options.signal)
const entries = files
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
.sort((a, b) => a.localeCompare(b))
.slice(0, MOBILE_FILE_LIST_LIMIT)
.map((relativePath) => ({
relativePath,
basename: basenameFromRelativePath(relativePath),
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
}))
return {
worktree: worktree.id,
rootPath: worktree.path,
files: entries,
totalCount: files.length,
truncated: files.length > MOBILE_FILE_LIST_LIMIT
}
}
async searchMobileFilePaths(
worktreeSelector: string,
query: string,
limit: number
): Promise<RuntimeFileListResult> {
const store = this.host.requireStore()
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree } = target
const route = runtimeFileRouteForTarget(target)
// Why: identical paths exist on local and on several SSH hosts; the cache key must name the
// resolved host, which `connectionId` could not tell apart from "unresolved".
const cacheKey = `${target.executionHostId}:${worktree.id}:${worktree.path}`
const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => {
const listed =
route.kind === 'ssh'
? await this.listRemoteMobileFiles(
worktree.path,
route.provider,
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
)
: await listQuickOpenFiles(
worktree.path,
store,
undefined,
undefined,
MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1
)
const safePaths = listed
.filter((relativePath) => isSafeMobileRelativePath(relativePath))
.sort((a, b) => a.localeCompare(b))
return {
paths: safePaths.slice(0, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT),
totalCount: safePaths.length,
truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT
}
})
const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit)
return {
worktree: worktree.id,
rootPath: worktree.path,
files: matches.paths.map((relativePath) => ({
relativePath,
basename: basenameFromRelativePath(relativePath),
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
})),
totalCount: matches.totalCount,
truncated: inventory.truncated || matches.totalCount > limit
}
}
async searchQuickOpenFilePaths(
worktreeSelector: string,
query: string,
limit: number,
excludePaths?: string[],
signal?: AbortSignal
): Promise<RuntimeFileListResult> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree } = target
const route = runtimeFileRouteForTarget(target)
const result =
!query.trim() || isQuickOpenQueryTooLarge(query)
? { paths: [], totalCount: 0, truncated: false }
: route.kind === 'ssh'
? await this.searchRemoteQuickOpenFilePaths(
worktree.path,
route.provider,
query,
limit,
excludePaths,
signal
)
: await searchHostQuickOpenFilePaths(worktree.path, this.host.requireStore(), {
query,
limit,
excludePaths,
signal
})
return {
worktree: worktree.id,
rootPath: worktree.path,
files: result.paths.map((relativePath) => ({
relativePath,
basename: basenameFromRelativePath(relativePath),
kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const)
})),
totalCount: result.totalCount,
truncated: result.truncated
}
}
async openMobileFile(
worktreeSelector: string,
relativePath: string
): Promise<RuntimeFileOpenResult> {
const target = await this.host.resolveRuntimeFileTarget(worktreeSelector)
const { worktree } = target
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
// Previewable images open like text (mobile renders via files.readPreview); other binaries stay unavailable on mobile.
const kind = isMobilePreviewableImagePath(relativePath)
? 'image'
: isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
if (kind === 'binary') {
return { worktree: worktree.id, relativePath, kind, opened: false }
}
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
// Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab.
await this.assertMobileOpenTargetExists(filePath, runtimeFileRouteForTarget(target))
// Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId.
this.host.openFile(worktree.id, filePath, relativePath, undefined)
return { worktree: worktree.id, relativePath, kind, opened: true }
}
protected async assertMobileOpenTargetExists(
filePath: string,
route: RuntimeFileRoute
): Promise<void> {
try {
await (route.kind === 'ssh'
? this.statRemoteTerminalPath(filePath, route.connectionId)
: stat(await resolveAuthorizedPath(filePath, this.host.requireStore())))
} catch (error) {
if (
isENOENT(error) ||
(route.kind === 'ssh' && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error))
) {
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`)
}
throw error
}
}
async openMobileDiff(
worktreeSelector: string,
relativePath: string,
staged: boolean
): Promise<RuntimeFileOpenResult> {
const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector)
if (!isSafeMobileRelativePath(relativePath)) {
throw new Error('invalid_relative_path')
}
const kind = isMobileBinaryPath(relativePath)
? 'binary'
: isMobileMarkdownPath(relativePath)
? 'markdown'
: 'text'
const filePath = joinWorktreeRelativePath(worktree.path, relativePath)
// Why: see openMobileFile; avoid stamping internal runtimeId as runtimeEnvironmentId.
this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined)
return { worktree: worktree.id, relativePath, kind, opened: true }
}
}