diff --git a/src/cli/caller-host-worktree-selection.ts b/src/cli/caller-host-worktree-selection.ts new file mode 100644 index 00000000000..c0c144c6a13 --- /dev/null +++ b/src/cli/caller-host-worktree-selection.ts @@ -0,0 +1,88 @@ +import { resolve as resolvePath } from 'node:path' +import { isPathInsideOrEqual } from '../shared/cross-platform-path' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../shared/execution-host' +import { readOrchestrationCompatibilityEvidence } from '../shared/orchestration-compatibility-evidence' +import type { RuntimeWorktreeListResult, RuntimeWorktreeRecord } from '../shared/runtime-types' +import type { RuntimeClient } from './runtime-client' + +type CatalogRow = Pick & { + identity?: { key: string } +} + +/** The execution host this CLI process runs on: the SSH target its relay stamped, else this machine. */ +export function getCallerExecutionHostId( + env: Readonly> = process.env +): ExecutionHostId { + const host = readOrchestrationCompatibilityEvidence(env)?.host + return host?.kind === 'ssh' ? toSshExecutionHostId(host.targetId) : LOCAL_EXECUTION_HOST_ID +} + +// Why: the catalog lists SSH hosts' worktrees too and two hosts can hold the same path, so an SSH +// caller may only take its own host's rows and a local caller none of an SSH host's. `runtime:*` +// rows in this runtime's catalog are checkouts it holds itself, and unstamped rows predate stamping. +function isOnCallerHost(row: CatalogRow, callerHostId: ExecutionHostId): boolean { + const rowHostId = row.hostId ?? LOCAL_EXECUTION_HOST_ID + return parseExecutionHostId(callerHostId)?.kind === 'ssh' + ? rowHostId === callerHostId + : parseExecutionHostId(rowHostId)?.kind !== 'ssh' +} + +// Why: `id:` keeps repo inference and the runtime's scoped fast path; only a bare id that another +// host's row shares needs the exact identity, or the runtime would refuse it as ambiguous. +function toHostScopedSelector(row: CatalogRow, catalog: readonly CatalogRow[]): string { + const sharedId = catalog.some((other) => other !== row && other.id === row.id) + return sharedId && row.identity?.key ? `identity:${row.identity.key}` : `id:${row.id}` +} + +/** The caller host's innermost worktree enclosing `cwd`, as a selector; undefined when none does. */ +export function selectEnclosingWorktreeOnHost( + catalog: readonly CatalogRow[], + cwd: string, + hostId: ExecutionHostId +): string | undefined { + const currentPath = resolvePath(cwd) + let enclosing: CatalogRow | undefined + let enclosingPathLength = -1 + for (const row of catalog) { + if (!isOnCallerHost(row, hostId)) { + continue + } + const rowPath = resolvePath(row.path) + if (!isPathInsideOrEqual(rowPath, currentPath) || rowPath.length <= enclosingPathLength) { + continue + } + enclosing = row + enclosingPathLength = rowPath.length + } + return enclosing ? toHostScopedSelector(enclosing, catalog) : undefined +} + +/** Scopes a pane's forwarded bare worktree id to the caller's host; undefined when it has none. */ +export function selectWorktreeIdOnHost( + catalog: readonly CatalogRow[], + worktreeId: string, + hostId: ExecutionHostId +): string | undefined { + const match = catalog.find((row) => row.id === worktreeId && isOnCallerHost(row, hostId)) + return match ? toHostScopedSelector(match, catalog) : undefined +} + +/** Scopes a pane's forwarded bare `ORCA_WORKTREE_ID` to the caller's host before it is used. */ +export async function resolveCallerPaneWorktreeSelector( + worktreeId: string, + client: RuntimeClient +): Promise { + const callerHostId = getCallerExecutionHostId() + if (callerHostId === LOCAL_EXECUTION_HOST_ID) { + return worktreeId + } + const worktrees = await client.call('worktree.list', { + limit: 10_000 + }) + return selectWorktreeIdOnHost(worktrees.result.worktrees, worktreeId, callerHostId) +} diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 0db6ef82ff4..6b94dac0ba5 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -1,11 +1,6 @@ import { resolve as resolvePath } from 'node:path' -import type { - ComputerAppQuery, - RuntimeWorktreeListResult, - RuntimeWorktreeRecord -} from '../shared/runtime-types' +import type { ComputerAppQuery, RuntimeWorktreeListResult } from '../shared/runtime-types' import { - isPathInsideOrEqual, isWslUncPathForCallerLinuxPath, isWslUncPathForLinuxMountedPath } from '../shared/cross-platform-path' @@ -13,6 +8,11 @@ import { parseWslUncPath } from '../shared/wsl-paths' import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime/types' import { getOptionalStringFlag, getRequiredStringFlag } from './flags' +import { + getCallerExecutionHostId, + resolveCallerPaneWorktreeSelector, + selectEnclosingWorktreeOnHost +} from './caller-host-worktree-selection' export type BrowserCliTarget = { worktree?: string @@ -107,36 +107,25 @@ export async function resolveCurrentWorktreeSelector( ): Promise { assertLocalCwdWorktreeSelector('current', client) - const currentPath = resolvePath(cwd) const worktrees = await client.call('worktree.list', { limit: 10_000 }) - let enclosingWorktree: RuntimeWorktreeRecord | undefined - let enclosingPathLength = -1 - for (const worktree of worktrees.result.worktrees) { - const worktreePath = resolvePath(worktree.path) - if ( - !isPathInsideOrEqual(worktreePath, currentPath) || - worktreePath.length <= enclosingPathLength - ) { - continue - } - enclosingWorktree = worktree - enclosingPathLength = worktreePath.length - } - - if (!enclosingWorktree) { - throw new RuntimeClientError( - 'selector_not_found', - `No Orca-managed worktree contains the current directory: ${currentPath}` - ) - } - // Why: users expect "active/current" to mean the enclosing managed worktree - // even from nested subdirectories. Resolve to the concrete runtime id here: + // even from nested subdirectories. Resolve to the concrete runtime row here: // duplicate repo registrations can expose the same Git worktree path, and a // path selector would throw selector_ambiguous after losing the repo id. - return `id:${enclosingWorktree.id}` + const selector = selectEnclosingWorktreeOnHost( + worktrees.result.worktrees, + cwd, + getCallerExecutionHostId() + ) + if (!selector) { + throw new RuntimeClientError( + 'selector_not_found', + `No Orca-managed worktree contains the current directory: ${resolvePath(cwd)}` + ) + } + return selector } export async function getOptionalWorktreeSelector( @@ -301,7 +290,10 @@ export async function getEmulatorWorktreeSelector( } const terminalWorktreeId = process.env.ORCA_WORKTREE_ID if (terminalWorktreeId?.trim()) { - return terminalWorktreeId + const scoped = await resolveCallerPaneWorktreeSelector(terminalWorktreeId, client) + if (scoped) { + return scoped + } } const folderWorkspaceId = process.env.ORCA_WORKSPACE_ID?.trim() if (folderWorkspaceId?.startsWith('folder:')) { @@ -323,7 +315,11 @@ export async function getEmulatorCommandTarget( const emulator = getOptionalStringFlag(flags, 'emulator') const worktree = await getEmulatorWorktreeSelector(flags, cwd, client) if (device || emulator) { - return { device: device || undefined, emulator: emulator || undefined, worktree } + return { + device: device || undefined, + emulator: emulator || undefined, + worktree + } } return { worktree } } diff --git a/src/cli/worktree-selector-caller-host.test.ts b/src/cli/worktree-selector-caller-host.test.ts new file mode 100644 index 00000000000..941d8841166 --- /dev/null +++ b/src/cli/worktree-selector-caller-host.test.ts @@ -0,0 +1,87 @@ +/** + * #21210: several SSH hosts hold a worktree of one repo at the same path. A CLI on host D must + * resolve `current` (and a pane's forwarded worktree id) to host D's row, never another host's twin. + */ +import { describe, expect, it } from 'vitest' +import { + getCallerExecutionHostId, + selectEnclosingWorktreeOnHost, + selectWorktreeIdOnHost +} from './caller-host-worktree-selection' + +const PATH = '/srv/app' + +function row(hostId: 'local' | `ssh:${string}` | undefined, id: string, identityKey?: string) { + return { + id, + path: PATH, + ...(hostId ? { hostId } : {}), + ...(identityKey ? { identity: { key: identityKey } } : {}) + } +} + +const SSH_CALLER_D = { + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'host d', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'inc-1', + ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'att-1' +} + +const TWINS = [ + row('ssh:host-a', `repo-a::${PATH}`), + row('ssh:host%20d', `repo-d::${PATH}`), + row('local', `repo-l::${PATH}`) +] + +describe('implicit worktree selection is scoped to the caller host (#21210)', () => { + it('reads the caller host from the relay stamp, encoding the SSH target', () => { + expect(getCallerExecutionHostId(SSH_CALLER_D)).toBe('ssh:host%20d') + expect(getCallerExecutionHostId({})).toBe('local') + // A WSL stamp is still this machine. + expect( + getCallerExecutionHostId({ + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' + }) + ).toBe('local') + }) + + it("resolves an SSH caller's cwd to its own host's twin", () => { + const hostId = getCallerExecutionHostId(SSH_CALLER_D) + + expect(selectEnclosingWorktreeOnHost(TWINS, `${PATH}/src`, hostId)).toBe(`id:repo-d::${PATH}`) + }) + + it('resolves a caller with no host stamp to the local row only', () => { + expect(selectEnclosingWorktreeOnHost(TWINS, PATH, 'local')).toBe(`id:repo-l::${PATH}`) + }) + + // A runtime-stamped row in this runtime's own catalog is a checkout it holds locally. + it('keeps a runtime-stamped local checkout for a local caller', () => { + const rows = [ + TWINS[0]!, + { id: `repo-r::${PATH}`, path: PATH, hostId: 'runtime:env-1' as const } + ] + + expect(selectEnclosingWorktreeOnHost(rows, `${PATH}/src`, 'local')).toBe(`id:repo-r::${PATH}`) + expect(selectEnclosingWorktreeOnHost(rows, PATH, 'ssh:host%20d')).toBeUndefined() + }) + + it('never adopts another host or an unstamped row for an SSH caller', () => { + expect( + selectEnclosingWorktreeOnHost([TWINS[0]!, row(undefined, 'repo-u::x')], PATH, 'ssh:host-z') + ).toBeUndefined() + }) + + it('names the exact identity when another host shares the bare id', () => { + const twins = [ + row('ssh:host-a', `repo-1::${PATH}`, 'wt2:a'), + row('ssh:host%20d', `repo-1::${PATH}`, 'wt2:d') + ] + + expect(selectEnclosingWorktreeOnHost(twins, PATH, 'ssh:host%20d')).toBe('identity:wt2:d') + expect(selectWorktreeIdOnHost(twins, `repo-1::${PATH}`, 'ssh:host%20d')).toBe('identity:wt2:d') + expect(selectWorktreeIdOnHost(twins, `repo-1::${PATH}`, 'ssh:host-z')).toBeUndefined() + }) +})