Files
orca/src/main/runtime/runtime-file-command-host.ts
T
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

197 lines
6.3 KiB
TypeScript

// @ts-nocheck -- mechanically split declarations.
import type { Store } from '../persistence'
import type {
ResolvedRuntimeFileTarget,
ResolvedRuntimeFileWorktree
} from './runtime-file-command-target'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { RuntimeNativeChatFileContext } from '../../shared/runtime-types'
import type { FsChangeEvent } from '../../shared/filesystem-entry-types'
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import {
MOBILE_BINARY_EXTENSIONS,
WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS,
WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS
} from './runtime-file-commands-mobile-file-list-limit'
import { watch as watchFs } from 'node:fs'
import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure'
import { basenameFromRelativePath } from './runtime-file-paths'
export type RuntimeFileCommandHost = {
getRuntimeId(): string
requireStore(): Store
resolveWorktreeSelector(selector: string): Promise<ResolvedRuntimeFileWorktree>
resolveRuntimeFileTarget(selector: string): Promise<ResolvedRuntimeFileTarget>
resolveKnownWorkspaceFileTarget?(
absolutePath: string,
executionHostId: ExecutionHostId
): Promise<(ResolvedRuntimeFileTarget & { relativePath: string }) | null>
resolveTerminalCwd?(terminalHandle: string): string | null | Promise<string | null>
resolveTerminalContext?(
terminalHandle: string
): { worktreeId: string; connectionId: string | null } | null
resolveTerminalFileUriHostname?(terminalHandle: string): string | null | Promise<string | null>
hasRecentTerminalOutputPath?(
terminalHandle: string,
pathText: string,
absolutePath: string
): boolean | Promise<boolean>
hasRecentNativeChatOutputPath?(
worktreeId: string,
context: RuntimeNativeChatFileContext,
pathText: string,
absolutePath: string
): boolean | Promise<boolean>
// `executionHostId`, not `connectionId`, on both target contracts: a repo row's connection cannot
// tell `runtime:` from `local`, and neither may re-introduce that spelling. See
// runtime-git-command-target and runtime-file-command-target.
resolveRuntimeGitTarget(
selector: string
): Promise<{ worktree: ResolvedRuntimeFileWorktree; executionHostId: ExecutionHostId }>
openFile(
worktreeId: string,
filePath: string,
relativePath: string,
runtimeEnvironmentId?: string | null
): void
openDiff(
worktreeId: string,
filePath: string,
relativePath: string,
staged: boolean,
runtimeEnvironmentId?: string | null
): void
}
export function watchWindowsRuntimeFileExplorer(
rootPath: string,
callback: (events: FsChangeEvent[]) => void,
onTerminalError: (error: Error) => void
): () => Promise<void> {
let disposed = false
let timer: ReturnType<typeof setTimeout> | null = null
let closeStarted = false
const physicalClose = new PhysicalExitTracker()
const emitOverflow = (): void => {
timer = null
if (disposed) {
return
}
callback([{ kind: 'overflow', absolutePath: rootPath }])
}
const scheduleOverflow = (): void => {
if (disposed) {
return
}
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS)
}
// Why: Parcel's Watchman probe can crash the headless server on Windows; use a conservative overflow refresh instead.
const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow)
const onClose = (): void => {
watcher.removeListener('error', onError)
physicalClose.markExited()
}
const onError = (err: Error): void => {
console.error('[runtime-files.watch] Windows watcher error', { rootPath, err })
if (timer) {
clearTimeout(timer)
timer = null
}
watcher.removeListener('close', onClose)
watcher.removeListener('error', onError)
// Why: Node nulls FSWatcher's native handle on error without a close event; treat the error as physical-exit proof.
physicalClose.markExited()
if (!disposed) {
try {
callback([{ kind: 'overflow', absolutePath: rootPath }])
} finally {
onTerminalError(err)
}
}
}
watcher.once('close', onClose)
watcher.on('error', onError)
return async () => {
disposed = true
if (timer) {
clearTimeout(timer)
timer = null
}
if (!closeStarted) {
try {
watcher.close()
} catch (err) {
console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err })
throw err
}
closeStarted = true
}
try {
await physicalClose.waitForExit(
WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS,
() => new Error('Windows watcher did not close before deletion deadline')
)
} catch (error) {
// Why: late Windows close still owns native dir handles; expose its completion so cleanup retains then clears the root.
throw new WatcherProcessFailure(
error instanceof Error ? error.message : String(error),
'supervisor',
'process_unavailable',
physicalClose.exitedPromise
)
}
}
}
export function isSafeMobileRelativePath(relativePath: string): boolean {
if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) {
return false
}
const parts = relativePath.replace(/\\/g, '/').split('/')
return parts.every((part) => part !== '' && part !== '.' && part !== '..')
}
export function isMobileMarkdownPath(relativePath: string): boolean {
return /\.(md|mdx|markdown)$/i.test(relativePath)
}
export function isMobileBinaryPath(relativePath: string): boolean {
const basename = basenameFromRelativePath(relativePath)
const dotIndex = basename.lastIndexOf('.')
if (dotIndex <= 0) {
return false
}
return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase())
}
export function isRuntimeDirectoryEntry(entry: {
isDirectory(): boolean
isSymbolicLink(): boolean
}): boolean {
// Why: listings are passive UI reads; don't stat symlink targets here (explicit open/expand resolves them).
if (entry.isSymbolicLink()) {
return false
}
if (entry.isDirectory()) {
return true
}
return false
}
export function isBinaryBuffer(buffer: Buffer): boolean {
const len = Math.min(buffer.length, 8192)
for (let i = 0; i < len; i += 1) {
if (buffer[i] === 0) {
return true
}
}
return false
}