Files
orca/src/shared/worktree-execution-host-resolution.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

155 lines
6.5 KiB
TypeScript

/**
* One rule for "which host does this worktree execute on, and what connection routes there".
*
* Main and the renderer both have to answer it — the terminal launch scope picks a PTY route from
* it, the renderer picks a file-read route and the reconnect affordance from it — so the rule lives
* here instead of being re-derived per side. Two re-derivations already disagreed: main answered
* from the worktree's own host while the renderer fell back to an id-only repo lookup, so a pane on
* `ssh:m4air` was offered "Reconnect openclaw" and read its files off openclaw (#11163).
*
* `unresolved` is a distinct answer, never "local": the same repo id can exist on a local, an SSH
* and a runtime host at once, and loss of a usable answer must fail closed rather than authorize a
* client-side read of a remote path (#6648, #17799).
*/
import type { Repo } from './repo-types'
import {
getRepoExecutionHostId,
getRepoSshConnectionId,
getSshTargetIdForExecutionHost,
normalizeExecutionHostId,
type ExecutionHostId
} from './execution-host'
export type ExecutionHostOwnerRow = Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>
export type ExecutionHostOwnerMatch<T> =
| { kind: 'resolved'; owner: T }
| { kind: 'missing' }
| { kind: 'ambiguous' }
/**
* How a caller finds repo rows. Main scans the store array; the renderer answers from a
* WeakMap-memoized index because owner resolution runs inside retained selectors. That is a
* performance difference, not a different rule.
*/
export type ExecutionHostOwnerLookup<T extends ExecutionHostOwnerRow> = {
/** The row for `repoId`, or `ambiguous` when rival rows disagree about the owning host. */
byId: (repoId: string) => ExecutionHostOwnerMatch<T>
/** The row for `repoId` on exactly `hostId`, or null when that host carries no row. */
byHost: (repoId: string, hostId: ExecutionHostId) => T | null
}
export type WorktreeExecutionHostResolution<T extends ExecutionHostOwnerRow> =
| {
kind: 'resolved'
hostId: ExecutionHostId
/**
* The SSH target whose filesystem holds this workspace — for a `runtime:` host, its nested
* target, addressable only as the pair with `hostId`. Callers deciding what *this client*
* may dial (a PTY route, a Git provider) must use `getSshTargetIdForExecutionHost(hostId)`
* instead; this field can name a host the client cannot reach on its own.
*/
connectionId: string | null
/** Display metadata only. The decisions are `hostId` / `connectionId`. */
owner: T | null
}
/**
* Three reasons, not two, and deliberately not collapsed. `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. A vocabulary that cannot express the
* difference guarantees it is lost at the first caller that switches on it — the same shape as
* #18006, where one word had to stand for two liveness situations.
*/
| { kind: 'unresolved'; reason: 'ambiguous' | 'unknown' | 'malformed' }
/**
* The owner row's host, or `null` when the row names one that cannot be parsed.
*
* Module-private and deliberately not a second exported reading of a repo row: only this resolution
* needs the distinction, because only this resolution is routing. `getRepoExecutionHostId` stays the
* answer everywhere else — its fall-through to `local` is harmless for the grouping, label and index
* callers that make up nearly all of its ~340 call sites, and is wrong only when the value decides
* where work runs.
*/
function resolveOwnerRowHostId(row: ExecutionHostOwnerRow): ExecutionHostId | null {
return row.executionHostId?.trim()
? normalizeExecutionHostId(row.executionHostId)
: getRepoExecutionHostId(row)
}
export function resolveWorktreeExecutionHost<T extends ExecutionHostOwnerRow>(
lookup: ExecutionHostOwnerLookup<T>,
worktree: { repoId: string; hostId?: string | null }
): WorktreeExecutionHostResolution<T> {
const worktreeHostId = normalizeExecutionHostId(worktree.hostId)
if (worktreeHostId) {
// The worktree names its own host, which outranks every repo row. A row on a *different* host
// is not evidence about this one — falling back to it is the cross-host leak: one SSH host's
// pane routed to another. A row on *this* host still is evidence, and is the only place a
// runtime's nested SSH target appears.
const owner = lookup.byHost(worktree.repoId, worktreeHostId)
return {
kind: 'resolved',
hostId: worktreeHostId,
connectionId:
getSshTargetIdForExecutionHost(worktreeHostId) ??
(owner ? getRepoSshConnectionId(owner) : null),
owner
}
}
const match = lookup.byId(worktree.repoId)
if (match.kind !== 'resolved') {
return { kind: 'unresolved', reason: match.kind === 'ambiguous' ? 'ambiguous' : 'unknown' }
}
const hostId = resolveOwnerRowHostId(match.owner)
if (!hostId) {
return { kind: 'unresolved', reason: 'malformed' }
}
return {
kind: 'resolved',
hostId,
connectionId: getRepoSshConnectionId(match.owner),
owner: match.owner
}
}
const EMPTY_ROWS: readonly never[] = []
/**
* Array-backed lookup for callers holding the whole repo list (main's store). Grouped once at
* construction — a lookup is hit once per worktree key per target, so a per-call `filter` was an
* O(repos) rescan each time. Rows keep repo-list order, which `byId` depends on for `rows[0]`.
*/
export function createRepoRowExecutionHostLookup<T extends ExecutionHostOwnerRow>(
repos: readonly T[]
): ExecutionHostOwnerLookup<T> {
const rowsById = new Map<string, T[]>()
for (const repo of repos) {
const rows = rowsById.get(repo.id)
if (rows) {
rows.push(repo)
} else {
rowsById.set(repo.id, [repo])
}
}
const rowsFor = (repoId: string): readonly T[] => rowsById.get(repoId) ?? EMPTY_ROWS
return {
byId: (repoId) => {
const rows = rowsFor(repoId)
const owner = rows[0]
if (!owner) {
return { kind: 'missing' }
}
const ownerHostId = resolveOwnerRowHostId(owner)
return rows.some((repo) => resolveOwnerRowHostId(repo) !== ownerHostId)
? { kind: 'ambiguous' }
: { kind: 'resolved', owner }
},
// A row naming an unparseable host matches no host, which is what stops a worktree on a real
// host from adopting it.
byHost: (repoId, hostId) =>
rowsFor(repoId).find((repo) => resolveOwnerRowHostId(repo) === hostId) ?? null
}
}