Files
orca/src/main/providers/execution-host-provider-dispatch.ts
T
Neil d5750648c2 fix(runtime): route runtime Git by resolved execution host, not repo connectionId (#18307)
`RuntimeGitTarget` 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 36 downstream dispatches.

The target now carries `executionHostId: ExecutionHostId` (never null, never
optional), resolved through the shared rule that landed with #17909/#17919 and
dispatched through the host-keyed routes from #18296. Dispatch sites call
`requireRuntimeGitProvider`, where `null` means exactly one thing: the host is
`local` and the command runs here as free functions.

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,
  which is the reproduced cross-host leak.
- `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; dialling it here reaches a
  same-named target on this client.
- rival rows disagreeing with no worktree host — `worktree_execution_host_unresolved`,
  matching the launch path rather than guessing a row.

An unreachable SSH host still throws `SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE`; loss of
contact is never evidence of locality (docs/reference/ssh-execution-boundary.md).

`resolveWorktreeLaunchHost` keeps its exact signature and now delegates to
`resolveWorktreeHostRouting`, the same resolution answering "which host is this on"
rather than "what may this client dial" — the git target needs the first question
because `local` and `runtime:` are two different non-SSH answers.

No wire change: `RuntimeGitTarget` is main-process internal, and the SSH and local
model-discovery host keys are byte-identical to before.

`RuntimeFileTarget` has the same defect in ~30 filesystem dispatches and is
deliberately left for a follow-up.
2026-09-02 19:26:07 -07:00

159 lines
7.1 KiB
TypeScript

/**
* Host-keyed provider dispatch: one entry per execution host kind, with `local` among them.
*
* The incumbent spelling across main is `const c = repo.connectionId; c ? sshProvider(c) : local()`,
* where `null` means *both* "resolved: this is local" and "could not resolve". Every path that
* cannot determine the host therefore answers "local" and runs remote work on the client — the
* #11163 defect class, which has produced a reproduced cross-host leak (an `ssh:` worktree
* resolving to another target) and near-misses where a transcript that exists only on a remote host
* would have been read locally. The shape also cannot express a `runtime:` host at all.
*
* This module removes that spelling. Its input is an `ExecutionHostId`, which is never null, and an
* id that names no host throws instead of degrading. `getRepoExecutionHostId` /
* `getWorktreeExecutionHostId` / `resolveWorktreeExecutionHost` are the resolution layer that feeds
* it; the last one already answers `unresolved` as a distinct verdict rather than "local".
*
* Why a route union rather than a uniform `getGitProviderForHost(): IGitProvider`, which is the
* VS Code shape (`registerProvider(Schemas.file, …)` symmetric with `Schemas.vscodeRemote`, and
* `ENOPRO` when nothing matches). Two properties of this process, not style preferences:
*
* - `local` git and filesystem work is free functions taking per-worktree execution options
* (`wslDistro`, `sharedLinkPaths`, admission tier), not an `IGitProvider`. There is no local
* provider object to register, and a stateless one would silently drop WSL routing.
* - `runtime:<env>` is not executed in this process *at all*. It is forwarded over the
* environment's transport (`runtimeEnvironments:call`) and the receiving server normalizes it to
* its own `local`. A repo row on a runtime host carries the server's *nested* SSH target in
* `connectionId`; that id is addressable only as the pair (environmentId, targetId). Handing it
* to this client's SSH table would dial a same-named target in the wrong namespace — turning a
* silent-local bug into a silent-wrong-host bug. `host-repo-catalog-snapshot` and
* `host-qualified-worktree-listing` already reject runtime hosts for the same reason.
*
* So the answer is Zed's shape — an enum on the owner (`Local { fs }` vs `Remote { … }`) — and the
* three kinds are symmetric variants of it. Callers switch exhaustively, so `runtime` can no longer
* collapse into `local` by omission.
*
* Note the deliberate second distinction inside the `ssh` variant: `provider: null` means "this host
* is remote and currently unreachable", which is not the same answer as "this host is local" and can
* no longer be spelled the same way. That mirrors the `live` / `unverifiable` / `exited` rule in
* docs/reference/ssh-execution-boundary.md — loss of contact is never evidence of locality.
*/
import {
parseExecutionHostId,
type ExecutionHostId,
type LOCAL_EXECUTION_HOST_ID,
type ParsedExecutionHost
} from '../../shared/execution-host'
import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from './ssh-git-dispatch'
import type { SshGitProvider } from './ssh-git-provider'
import {
getSshFilesystemProvider,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
} from './ssh-filesystem-dispatch'
import type { IFilesystemProvider, IGitProvider } from './types'
/** An id that names no execution host. Never degrade to local — that is the whole defect class. */
export class UnresolvableExecutionHostError extends Error {
constructor(readonly hostId: string | null | undefined) {
super(
`Cannot route work: ${JSON.stringify(hostId ?? null)} names no execution host. ` +
'Refusing to fall back to this machine.'
)
this.name = 'UnresolvableExecutionHostError'
}
}
/** Asking this process for a host it does not execute is a routing mistake, not a fallback. */
export class ExecutionHostNotDispatchableError extends Error {
constructor(readonly hostId: ExecutionHostId) {
super(`Execution host ${hostId} is not dispatched by this process.`)
this.name = 'ExecutionHostNotDispatchableError'
}
}
type LocalRoute = { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID }
type RuntimeRoute = { kind: 'runtime'; hostId: `runtime:${string}`; environmentId: string }
type SshRoute<TProvider> = {
kind: 'ssh'
hostId: `ssh:${string}`
connectionId: string
/** `null` is "remote, currently unreachable" — never "local". */
provider: TProvider | null
}
// The SSH table stores `SshGitProvider`; narrowing the route to `IGitProvider` would drop the
// remote-only methods (commit-message plans, push-target materialization) that callers need.
export type ExecutionHostGitRoute = LocalRoute | RuntimeRoute | SshRoute<SshGitProvider>
export type ExecutionHostFilesystemRoute = LocalRoute | RuntimeRoute | SshRoute<IFilesystemProvider>
// Takes an unvalidated string rather than `ExecutionHostId`: validating is the point, and host
// ids also arrive from persistence and IPC where the compiler cannot vouch for them.
function parseRoutableHost(hostId: string | null | undefined): ParsedExecutionHost {
const parsed = parseExecutionHostId(hostId)
if (!parsed) {
throw new UnresolvableExecutionHostError(hostId)
}
return parsed
}
export function resolveGitRouteForHost(hostId: string | null | undefined): ExecutionHostGitRoute {
const parsed = parseRoutableHost(hostId)
switch (parsed.kind) {
case 'local':
return { kind: 'local', hostId: parsed.id }
case 'ssh':
return {
kind: 'ssh',
hostId: parsed.id,
connectionId: parsed.targetId,
provider: getSshGitProvider(parsed.targetId) ?? null
}
case 'runtime':
return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId }
}
}
export function resolveFilesystemRouteForHost(
hostId: string | null | undefined
): ExecutionHostFilesystemRoute {
const parsed = parseRoutableHost(hostId)
switch (parsed.kind) {
case 'local':
return { kind: 'local', hostId: parsed.id }
case 'ssh':
return {
kind: 'ssh',
hostId: parsed.id,
connectionId: parsed.targetId,
provider: getSshFilesystemProvider(parsed.targetId) ?? null
}
case 'runtime':
return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId }
}
}
/** For call sites that are structurally remote-only: local and runtime are both routing errors. */
export function requireGitProviderForHost(hostId: string | null | undefined): IGitProvider {
const route = resolveGitRouteForHost(hostId)
if (route.kind !== 'ssh') {
throw new ExecutionHostNotDispatchableError(route.hostId)
}
if (!route.provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return route.provider
}
export function requireFilesystemProviderForHost(
hostId: string | null | undefined
): IFilesystemProvider {
const route = resolveFilesystemRouteForHost(hostId)
if (route.kind !== 'ssh') {
throw new ExecutionHostNotDispatchableError(route.hostId)
}
if (!route.provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
return route.provider
}