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

168 lines
6.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
createRepoRowExecutionHostLookup,
resolveWorktreeExecutionHost
} from './worktree-execution-host-resolution'
// Why (#11163, #17799): main's terminal launch scope and the renderer's owner index both answer
// "which host does this worktree execute on". They used to answer it separately, and disagreed —
// main derived the host from the worktree while the renderer fell back to an id-only repo lookup,
// so a pane on one SSH host was routed to another. One rule now, exercised here directly.
const resolve = (
repos: readonly { id: string; connectionId?: string; executionHostId?: string }[],
worktree: { repoId: string; hostId?: string | null }
): ReturnType<typeof resolveWorktreeExecutionHost> =>
resolveWorktreeExecutionHost(createRepoRowExecutionHostLookup(repos as never), worktree) as never
describe('resolveWorktreeExecutionHost', () => {
describe('the worktree names its own host', () => {
it('routes to that host even when the only row belongs to a different SSH host', () => {
// The reproduced defect: `ssh:m4air` worktree, sole row on `openclaw`.
expect(
resolve([{ id: 'r', connectionId: 'openclaw' }], { repoId: 'r', hostId: 'ssh:m4air' })
).toEqual({ kind: 'resolved', hostId: 'ssh:m4air', connectionId: 'm4air', owner: null })
})
it('answers before the repo row hydrates, because the host is not a guess', () => {
// Deliberate change from "unresolved": #6648 blocks destructive ops while the *host* is
// unknown. A worktree naming `ssh:m4air` is not that case — the repo row adds nothing the
// host id has not already settled, and refusing here stalls a remote pane on hydration.
expect(resolve([], { repoId: 'r', hostId: 'ssh:m4air' })).toEqual({
kind: 'resolved',
hostId: 'ssh:m4air',
connectionId: 'm4air',
owner: null
})
})
it('picks the row on that host when both SSH hosts carry the id', () => {
const openclaw = { id: 'r', connectionId: 'openclaw' }
const m4air = { id: 'r', connectionId: 'm4air' }
expect(resolve([openclaw, m4air], { repoId: 'r', hostId: 'ssh:m4air' })).toEqual({
kind: 'resolved',
hostId: 'ssh:m4air',
connectionId: 'm4air',
owner: m4air
})
expect(resolve([openclaw, m4air], { repoId: 'r', hostId: 'ssh:openclaw' })).toEqual({
kind: 'resolved',
hostId: 'ssh:openclaw',
connectionId: 'openclaw',
owner: openclaw
})
})
it('matches a row that names the host in either spelling', () => {
const stamped = { id: 'r', executionHostId: 'ssh:m4air' }
expect(resolve([stamped], { repoId: 'r', hostId: 'ssh:m4air' })).toEqual({
kind: 'resolved',
hostId: 'ssh:m4air',
connectionId: 'm4air',
owner: stamped
})
})
it('takes no connection from a row on a different host, whatever this host is', () => {
// The row lives on `ssh:openclaw`; neither a local nor a runtime worktree may borrow it.
for (const hostId of ['local', 'runtime:env-a']) {
expect(resolve([{ id: 'r', connectionId: 'openclaw' }], { repoId: 'r', hostId })).toEqual({
kind: 'resolved',
hostId,
connectionId: null,
owner: null
})
}
})
it('reads a runtime host nested SSH target off the row on that same host', () => {
// Not a cross-host borrow: this row *is* the runtime host's row, and the nested target
// appears nowhere else. Nulling it makes the workspace read as local, which decides whether
// this client tries to read a transcript that lives on the nested host.
const nested = { id: 'r', connectionId: 'ssh-nested', executionHostId: 'runtime:env-a' }
expect(resolve([nested], { repoId: 'r', hostId: 'runtime:env-a' })).toEqual({
kind: 'resolved',
hostId: 'runtime:env-a',
connectionId: 'ssh-nested',
owner: nested
})
})
it('gives a local row no SSH connection even when it carries a stale one', () => {
const contradictory = { id: 'r', connectionId: 'openclaw', executionHostId: 'local' }
expect(resolve([contradictory], { repoId: 'r', hostId: 'local' })).toEqual({
kind: 'resolved',
hostId: 'local',
connectionId: null,
owner: contradictory
})
})
})
describe('the worktree names no host', () => {
it('resolves from the sole row, in either spelling', () => {
const legacy = { id: 'r', connectionId: 'openclaw' }
expect(resolve([legacy], { repoId: 'r' })).toEqual({
kind: 'resolved',
hostId: 'ssh:openclaw',
connectionId: 'openclaw',
owner: legacy
})
const stamped = { id: 'r', executionHostId: 'ssh:m4air' }
expect(resolve([stamped], { repoId: 'r' })).toEqual({
kind: 'resolved',
hostId: 'ssh:m4air',
connectionId: 'm4air',
owner: stamped
})
const local = { id: 'r' }
expect(resolve([local], { repoId: 'r' })).toEqual({
kind: 'resolved',
hostId: 'local',
connectionId: null,
owner: local
})
})
it('refuses when rival rows disagree about the host, including two SSH hosts', () => {
expect(
resolve(
[
{ id: 'r', connectionId: 'openclaw' },
{ id: 'r', connectionId: 'm4air' }
],
{
repoId: 'r'
}
)
).toEqual({ kind: 'unresolved', reason: 'ambiguous' })
expect(
resolve([{ id: 'r', connectionId: 'openclaw' }, { id: 'r' }], { repoId: 'r' })
).toEqual({ kind: 'unresolved', reason: 'ambiguous' })
})
it('treats the two spellings of one host as agreement, not conflict', () => {
expect(
resolve(
[
{ id: 'r', connectionId: 'm4air' },
{ id: 'r', executionHostId: 'ssh:m4air' }
],
{ repoId: 'r' }
)
).toMatchObject({ kind: 'resolved', hostId: 'ssh:m4air', connectionId: 'm4air' })
})
it('reports an unknown owner distinctly from a conflicting one', () => {
expect(resolve([], { repoId: 'r' })).toEqual({ kind: 'unresolved', reason: 'unknown' })
})
})
it('ignores an unparseable host id rather than treating it as a host', () => {
const row = { id: 'r', connectionId: 'openclaw' }
expect(resolve([row], { repoId: 'r', hostId: 'ssh:' })).toMatchObject({
kind: 'resolved',
connectionId: 'openclaw'
})
})
})