Files
orca/src/shared/execution-host.ts
T
Neil fb69f00b65 fix(hosts): resolve a folder workspace's SSH host from the repo's host, not its raw connectionId (#18598)
* fix(hosts): resolve a folder workspace's SSH host from the repo's host, not its raw connectionId

`resolveFolderWorkspaceHost` inferred a workspace's host by reading
`repo.connectionId` directly. SSH ownership has two spellings on a repo row, and
a row carrying only `executionHostId: 'ssh:<target>'` has no `connectionId` to
read — so it counted as a local repo and the workspace resolved `{ kind: 'local' }`.
That is an execute-here answer for a workspace whose files are on an SSH host,
the #11163 class, and it fires on a well-formed row.

Resolve the host first, then read the target off it. Every other row keeps its
existing contribution, including a `runtime:` row's nested SSH target: that
target is not this client's to dial, but narrowing it here would be a second
behaviour change riding on this one. The runtime branch above still answers
`local`, and now says so — `FolderWorkspaceHost` has no runtime variant, and
widening the type is its own change, not an oversight to be silently corrected.

Three smaller items that stand on their own:

- `resolveWorktreeExecutionHost` gains a `malformed` reason distinct from
  `unknown`. `unknown` (nothing carries the id) is a verdict the launch path may
  legitimately dispose of as a plain local folder; `malformed` (the row named a
  host that cannot be parsed) must fail closed. One word for two situations is
  the shape that lost the distinction in #18006. The strict read is private to
  that module: `getRepoExecutionHostId` stays the answer everywhere else, since
  its fall-through to `local` is harmless for the grouping, label and index
  callers that are nearly all of its ~340 call sites.
- `readAllWorktreeMetaForRepo` / `readWorktreeMetaForRepo` replace four
  open-coded copies of the same host-qualified read (the F7/F8 lockstep shape).
- `getExecutionHostLabel` answers 'Unknown host' rather than 'All hosts' for an
  id that names no host. Showing one unroutable row as though it were on every
  host is wrong on its own terms. Plain English like every other label in that
  module, none of which resolve through the renderer's i18n catalog.

* fix(hosts): resolve the host in candidate selection too, not just in resolution

The first pass fixed how a repo row is classified once it reaches
`resolveFolderWorkspaceHost`. The candidate filter decides which rows reach it at
all, and it read `repo.connectionId` raw as well — so an SSH-only row outside the
project-group subtree was dropped before the new logic could see it, and the
execute-here bug survived for the population the fix was for, via a different
path. Found in review by CodeRabbit.

Three repo-row reads had the same root cause, not one:

- the scope-connection filter, comparing a path repo's raw field against the
  workspace/group connection;
- the group-connection set, built from group repos' raw fields;
- that set's membership test against path repos' raw fields.

The last two are one comparison with the mismatch on either side, so resolving
only the path side would have reintroduced it from the other direction.

All three, plus the resolution loop, now go through one `getRepoScopeConnectionId`
helper. Non-SSH hosts still fall back to the raw field, so a `runtime:` row keeps
contributing its nested target exactly as before.

The new tests use a repo matched only by path, outside the subtree — the
population every existing test missed, which is why four passing revert-tests
did not catch this. One of them is labelled as pinning the resolver rather than
the filter: under the old raw read both rows came back connectionless and matched
each other by accident, so it survives a filter revert and must not be counted as
coverage for it.
2026-09-04 01:34:47 -07:00

251 lines
9.3 KiB
TypeScript

import type { GlobalSettings } from './global-settings-types'
import type { Repo } from './repo-types'
import type { Worktree } from './worktree/types'
export const LOCAL_EXECUTION_HOST_ID = 'local'
export const ALL_EXECUTION_HOSTS_SCOPE = 'all'
export type ExecutionHostKind = 'local' | 'ssh' | 'runtime'
export type ExecutionHostId = typeof LOCAL_EXECUTION_HOST_ID | `ssh:${string}` | `runtime:${string}`
export type ExecutionHostScope = typeof ALL_EXECUTION_HOSTS_SCOPE | ExecutionHostId
export type ParsedExecutionHost =
| { kind: 'local'; id: typeof LOCAL_EXECUTION_HOST_ID }
| { kind: 'ssh'; id: `ssh:${string}`; targetId: string }
| { kind: 'runtime'; id: `runtime:${string}`; environmentId: string }
function getCurrentLocalPlatform(): NodeJS.Platform | null {
const globalNavigator = (globalThis as { navigator?: { userAgent?: string; platform?: string } })
.navigator
const userAgent = globalNavigator?.userAgent || globalNavigator?.platform || ''
if (/Windows/i.test(userAgent)) {
return 'win32'
}
if (/Mac/i.test(userAgent)) {
return 'darwin'
}
if (/Linux|X11/i.test(userAgent)) {
return 'linux'
}
return typeof process === 'undefined' ? null : process.platform
}
export function getLocalExecutionHostLabel(platform: NodeJS.Platform | null = null): string {
const localPlatform = platform ?? getCurrentLocalPlatform()
if (localPlatform === 'darwin') {
return 'Local Mac'
}
if (localPlatform === 'win32') {
return 'Local Windows'
}
if (localPlatform === 'linux') {
return 'Local Linux'
}
return 'This computer'
}
function normalizeHostPart(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
export function toSshExecutionHostId(targetId: string): `ssh:${string}` {
return `ssh:${encodeURIComponent(targetId)}`
}
export function toRuntimeExecutionHostId(environmentId: string): `runtime:${string}` {
return `runtime:${encodeURIComponent(environmentId)}`
}
// Why: runtime-owned (ephemeral-VM) SSH targets are hidden from user-facing
// SSH/run-target surfaces. The renderer can't read the target.owner field, so it
// recognizes them by their deterministic id prefix. getRuntimeOwnedSshTargetId
// (main) builds on this same prefix to keep the two in sync.
export const RUNTIME_OWNED_SSH_TARGET_ID_PREFIX = 'runtime-ssh-'
export function isRuntimeOwnedSshTargetId(targetId: string | null | undefined): boolean {
return typeof targetId === 'string' && targetId.startsWith(RUNTIME_OWNED_SSH_TARGET_ID_PREFIX)
}
export function parseExecutionHostId(value: string | null | undefined): ParsedExecutionHost | null {
const normalized = normalizeHostPart(value)
if (!normalized) {
return null
}
if (normalized === LOCAL_EXECUTION_HOST_ID) {
return { kind: 'local', id: LOCAL_EXECUTION_HOST_ID }
}
if (normalized.startsWith('ssh:')) {
const encoded = normalized.slice('ssh:'.length)
if (!encoded) {
return null
}
// `|` must stay out of a host id: composeWorktreeHostIdentity uses it as its delimiter and
// splits at the first one, so an unencoded pipe would rebind an alias to a different host.
if (encoded.includes('|')) {
return null
}
try {
const targetId = decodeURIComponent(encoded)
return targetId ? { kind: 'ssh', id: `ssh:${encoded}`, targetId } : null
} catch {
return null
}
}
if (normalized.startsWith('runtime:')) {
const encoded = normalized.slice('runtime:'.length)
if (!encoded) {
return null
}
if (encoded.includes('|')) {
return null
}
try {
const environmentId = decodeURIComponent(encoded)
return environmentId ? { kind: 'runtime', id: `runtime:${encoded}`, environmentId } : null
} catch {
return null
}
}
return null
}
export function normalizeExecutionHostId(value: string | null | undefined): ExecutionHostId | null {
return parseExecutionHostId(value)?.id ?? null
}
export function normalizeExecutionHostScope(value: string | null | undefined): ExecutionHostScope {
const normalized = normalizeHostPart(value)
if (!normalized || normalized === ALL_EXECUTION_HOSTS_SCOPE) {
return ALL_EXECUTION_HOSTS_SCOPE
}
return normalizeExecutionHostId(normalized) ?? ALL_EXECUTION_HOSTS_SCOPE
}
// An omitted scope on a request means this host, not a fan-out. Callers and the
// renderer share this so both agree on which requests answer with a merge.
export function requestedExecutionHostScope(value: string | null | undefined): ExecutionHostScope {
return normalizeExecutionHostScope(value ?? LOCAL_EXECUTION_HOST_ID)
}
export function normalizeVisibleExecutionHostIds(
value: readonly string[] | null | undefined
): ExecutionHostId[] | null {
if (!Array.isArray(value)) {
return null
}
const ids: ExecutionHostId[] = []
const seen = new Set<ExecutionHostId>()
for (const raw of value) {
const id = normalizeExecutionHostId(raw)
if (!id || seen.has(id)) {
continue
}
seen.add(id)
ids.push(id)
}
return ids.length > 0 ? ids : null
}
export function normalizeExecutionHostOrder(
value: readonly string[] | null | undefined
): ExecutionHostId[] {
const normalized = normalizeVisibleExecutionHostIds(value)
return normalized ?? []
}
export function getRepoExecutionHostId(
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
): ExecutionHostId {
const executionHostId = normalizeExecutionHostId(repo.executionHostId)
if (executionHostId) {
return executionHostId
}
const connectionId = normalizeHostPart(repo.connectionId)
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
}
export function getSshTargetIdForExecutionHost(
executionHostId: string | null | undefined
): string | null {
const parsed = parseExecutionHostId(executionHostId)
return parsed?.kind === 'ssh' ? parsed.targetId : null
}
// Why: SSH ownership has two spellings on a repo row — the legacy `connectionId`
// field and the unified `executionHostId`. Routing that reads the raw field answers
// "local" for a row that only carries `ssh:<target>`, which runs a remote operation
// on the client. Resolve the host first, then read the connection off it.
//
// The two hosts that are not themselves SSH are not the same case:
//
// - `local` has no SSH namespace to nest in, so a surviving `connectionId` is a row
// contradicting itself — the shape main's `resolveRepoOwnershipEvidence` calls
// `contradictory`. Answering with it hands out an SSH connection for a row that declares
// itself local.
// - `runtime:<env>` is a different machine with its own SSH targets, and a nested one appears
// only in this field (`repoWithFetchedOwner` spreads it through). It is not dialable on its
// own, but it is addressable as the pair (environmentId, targetId) — which is how the
// renderer reads it, recovering the environment from the worktree and looking the target up
// inside it (`selectRuntimeAwareSshStatus`). Dropping it makes a nested-SSH workspace read
// as local, which is what decides whether a transcript is read on this client.
//
// So this answers "which SSH target holds this row's files", not "which connection may this
// client dial". `getSshTargetIdForExecutionHost` answers the latter; callers routing a
// client-local PTY or Git provider want that one instead.
export function getRepoSshConnectionId(
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
): string | null {
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
if (host?.kind === 'ssh') {
return host.targetId
}
return host?.kind === 'runtime' ? normalizeHostPart(repo.connectionId) : null
}
export function getWorktreeExecutionHostId(
worktree: Pick<Worktree, 'hostId'>,
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID
): ExecutionHostId {
// Why: runtime and SSH snapshots can identify a more precise owner than
// the repo fallback; every sidebar host decision must use the same precedence.
return (
worktree.hostId ??
(repo?.connectionId || repo?.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId)
)
}
export function getSettingsFocusedExecutionHostId(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): ExecutionHostId {
const runtimeEnvironmentId = normalizeHostPart(settings?.activeRuntimeEnvironmentId)
return runtimeEnvironmentId
? toRuntimeExecutionHostId(runtimeEnvironmentId)
: LOCAL_EXECUTION_HOST_ID
}
export function getExecutionHostLabel(id: ExecutionHostScope | null | undefined): string {
if (id === ALL_EXECUTION_HOSTS_SCOPE) {
return 'All hosts'
}
const parsed = parseExecutionHostId(id)
if (!parsed) {
// Not "All hosts": an id that names no host is one *unknown* host, and answering with the
// everything-scope label shows an unroutable row as though it were on every host.
// Plain English like every other label in this module (`Local Mac`, `This computer`,
// `All hosts`) — none of them resolve through the renderer's i18n catalog, so a lone
// translated string here would read inconsistently.
return 'Unknown host'
}
switch (parsed.kind) {
case 'local':
return getLocalExecutionHostLabel()
case 'ssh':
return parsed.targetId
case 'runtime':
return parsed.environmentId
}
}