Files
orca/src/shared/execution-host.ts
T
Neil c61ca56a9b fix(ssh): resolve the worktree's execution host instead of guessing from one repo row (#17909)
* fix(host-routing): resolve the execution host before reading a connection

Three issues in one defect class: a resolver reads one spelling of one
arbitrarily chosen row instead of resolving the worktree's execution host,
so something local answers a question about a remote.

returned that row's connectionId. With duplicate repo rows for one repo id
it could pair a runtime owner with a client-owned SSH connection. It now
resolves through the same ambiguity-aware index getRuntimeEnvironmentIdForWorktree
uses, prefers the repo row for the host the worktree names, and derives the
connection from the resolved host. Conflicting rows return `undefined`
(this module's documented "cannot determine the host"), never `null`.

`store.getRepo(worktree.repoId)?.connectionId ?? null`. `getRepo` is
host-blind and the same repo id can exist on local, SSH and runtime hosts,
so a remote worktree could spawn its PTY on the client with the remote cwd.
resolveWorktreeLaunchHost picks the row for the worktree's host and reads
the connection off that host; conflicting rows are unresolved, not local.

session-partition owner maps that contradict each other. Both now compute
through one shared function whose argument records the divergence. No
behaviour change on either side: converging needs a read-both migration,
since both partitions hold real data written by shipping builds.

* fix(host-routing): keep nested SSH connections resolvable under a runtime host

getRepoSshConnectionId read only the resolved execution host, so a repo row
owned by a runtime that reaches a nested SSH target (connectionId: ssh-*,
executionHostId: runtime:*) resolved to no connection — answering 'local' for
a remote worktree, the same defect #17909 fixed in the other direction.

* fix(host-routing): resolve both sides of the execution host through one rule

The renderer resolver leaked between two different SSH hosts: a worktree on
`ssh:m4air` whose only indexed repo row belonged to `openclaw` answered
'openclaw', because the host-scoped lookup missing fell through to an id-only
one. Main's resolver, in the same change, answered 'm4air' — two resolvers, one
right and one wrong, on identical input.

Both sides now adapt one shared rule (`worktree-execution-host-resolution.ts`):
the worktree's own host outranks every repo row, and a row on a different host
is never evidence about this one. The renderer's WeakMap index becomes the
memoizing adapter it always was; `resolveWorktreeLaunchHost` becomes main's
mapping of unresolved onto its throw.

Settles the rule the change previously answered two ways.
`getRepoSshConnectionId` and `getSshTargetIdForExecutionHost` disagreed for a
runtime host carrying a nested `connectionId`; they now compose, so the
execution host is the single authority. On a `runtime:*` row that field is a
paired HUB's private SSH target, spread through by `repoWithFetchedOwner` and
unaddressable from this client — the project-first successor of the row nulls it
for exactly that reason. That also fixes the `kind !== 'ssh'` fallback, which
fired for `local`: a row declaring itself local handed out an SSH connection.
2026-09-02 16:41:13 -07:00

246 lines
8.9 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): string {
if (id === ALL_EXECUTION_HOSTS_SCOPE) {
return 'All hosts'
}
const parsed = parseExecutionHostId(id)
if (!parsed) {
return 'All hosts'
}
switch (parsed.kind) {
case 'local':
return getLocalExecutionHostLabel()
case 'ssh':
return parsed.targetId
case 'runtime':
return parsed.environmentId
}
}