From 5d7733ebe7d9d545f86a09ce77d35f2bdc03d694 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:26:11 -0700 Subject: [PATCH] fix(skills): preserve SSH pane transport identity --- .../native-chat-folder-ssh-route.ts | 135 +++++++++++++++ .../native-chat-skill-discovery-context.ts | 100 +++-------- .../native-chat-worktree-ssh-transport.ts | 102 ++++++++++++ .../use-native-chat-skills.react.test.tsx | 50 ++++++ .../use-native-chat-skills.test.ts | 157 ++++++++++++++++++ 5 files changed, 470 insertions(+), 74 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-folder-ssh-route.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-worktree-ssh-transport.ts diff --git a/src/renderer/src/components/native-chat/native-chat-folder-ssh-route.ts b/src/renderer/src/components/native-chat/native-chat-folder-ssh-route.ts new file mode 100644 index 00000000000..4f2bafde299 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-folder-ssh-route.ts @@ -0,0 +1,135 @@ +import type { AppState } from '../../store/types' +import { + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' + +type FolderSshRouteState = Pick< + AppState, + 'activeWorkspaceExecutionHostId' | 'activeWorktreeId' | 'folderWorkspaces' | 'projectGroups' +> + +export type NativeChatFolderSshRouteResolution = + | { + kind: 'resolved' + hostId: ExecutionHostId + targetId: string + environmentId: string | null + } + | { kind: 'ambiguous' } + | { kind: 'missing'; ownerHostId?: ExecutionHostId | null } + +type FolderSshRoute = Extract + +export function resolveNativeChatFolderSshRoute( + state: FolderSshRouteState, + folderWorkspaceId: string +): NativeChatFolderSshRouteResolution { + const routes = new Map() + const nonSshOwnerHostIds: (ExecutionHostId | null)[] = [] + const activeHost = + state.activeWorktreeId === folderWorkspaceKey(folderWorkspaceId) + ? parseExecutionHostId(state.activeWorkspaceExecutionHostId) + : null + const workspaces = state.folderWorkspaces.filter( + (workspace) => workspace.id === folderWorkspaceId + ) + for (const workspace of workspaces) { + const workspaceHost = parseExecutionHostId(workspace.executionHostId) + const groups = state.projectGroups.filter((group) => { + if (group.id !== workspace.projectGroupId) { + return false + } + const groupHost = parseExecutionHostId(group.executionHostId) + return !workspaceHost || !groupHost || workspaceHost.id === groupHost.id + }) + const owners = groups.length > 0 ? groups : [null] + for (const group of owners) { + const route = routeForFolderOwners(workspace, group) + if (route.kind === 'ambiguous') { + return route + } + if (route.kind === 'resolved') { + routes.set(JSON.stringify([route.hostId, route.environmentId]), route) + } else { + nonSshOwnerHostIds.push(route.ownerHostId ?? null) + } + } + } + if (routes.size === 0) { + return activeHost?.kind === 'ssh' && nonSshOwnerHostIds.length > 0 + ? { kind: 'ambiguous' } + : { kind: 'missing' } + } + const candidates = [...routes.values()].filter((route) => { + if (!activeHost) { + return true + } + return activeHost.kind === 'ssh' + ? activeHost.id === route.hostId + : activeHost.kind === 'runtime' + ? activeHost.environmentId === route.environmentId + : false + }) + const hasRelevantNonSshOwner = nonSshOwnerHostIds.some( + (ownerHostId) => !activeHost || !ownerHostId || ownerHostId === activeHost.id + ) + return candidates.length === 1 && !hasRelevantNonSshOwner ? candidates[0] : { kind: 'ambiguous' } +} + +function routeForFolderOwners( + workspace: FolderSshRouteState['folderWorkspaces'][number], + group: FolderSshRouteState['projectGroups'][number] | null +): NativeChatFolderSshRouteResolution { + const workspaceHost = parseExecutionHostId(workspace.executionHostId) + const groupHost = parseExecutionHostId(group?.executionHostId) + if ( + (workspace.executionHostId !== null && + workspace.executionHostId !== undefined && + !workspaceHost) || + (group?.executionHostId !== null && group?.executionHostId !== undefined && !groupHost) || + (workspace.connectionId !== null && + workspace.connectionId !== undefined && + !workspace.connectionId.trim()) || + (group?.connectionId !== null && + group?.connectionId !== undefined && + !group.connectionId.trim()) + ) { + return { kind: 'ambiguous' } + } + if (workspaceHost && groupHost && workspaceHost.id !== groupHost.id) { + return { kind: 'missing', ownerHostId: workspaceHost.id } + } + const logicalHost = workspaceHost ?? groupHost + const connectionIds = new Set( + [workspace.connectionId?.trim(), group?.connectionId?.trim()].filter( + (connectionId): connectionId is string => Boolean(connectionId) + ) + ) + if (connectionIds.size > 1 || (logicalHost?.kind === 'local' && connectionIds.size > 0)) { + return { kind: 'ambiguous' } + } + const connectionId = connectionIds.values().next().value + let hostId = connectionId ? toSshExecutionHostId(connectionId) : null + if (logicalHost?.kind === 'ssh') { + if (hostId && hostId !== logicalHost.id) { + return { kind: 'ambiguous' } + } + hostId = logicalHost.id + } + if (!hostId) { + return { kind: 'missing', ownerHostId: logicalHost?.id ?? null } + } + const host = parseExecutionHostId(hostId) + if (host?.kind !== 'ssh') { + return { kind: 'missing', ownerHostId: logicalHost?.id ?? null } + } + return { + kind: 'resolved', + hostId: host.id, + targetId: host.targetId, + environmentId: logicalHost?.kind === 'runtime' ? logicalHost.environmentId : null + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts index 64531a3d3e8..23c924c4759 100644 --- a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts @@ -1,20 +1,15 @@ import type { AppState } from '../../store/types' import type { PaneSkillDiscoveryTarget, SkillDiscoveryTarget } from '../../../../shared/skills' -import { - isRuntimeOwnedSshTargetId, - parseExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { resolveExactWorktreeRoute } from '@/lib/worktree-owner-route' import { getExplicitRuntimeEnvironmentIdForWorktree, getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { resolveNativeChatFolderSshRoute } from './native-chat-folder-ssh-route' +import { resolveNativeChatWorktreeSshTransport } from './native-chat-worktree-ssh-transport' export type NativeChatSkillStateInputs = Pick< AppState, @@ -78,65 +73,6 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki } } -type SshTransportResolution = - | { kind: 'resolved'; environmentId: string | null } - | { kind: 'ambiguous' } - | { kind: 'missing' } - -function resolveCatalogSshTransport( - state: NativeChatSkillStateInputs, - worktreeId: string, - hostId: ExecutionHostId -): SshTransportResolution { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - return { kind: 'missing' } - } - const rawWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId - const repoIds = new Set([getRepoIdFromWorktreeId(rawWorktreeId)]) - const environmentIds = new Set() - const catalogs = [ - ...Object.values(state.worktreesByRepo ?? {}), - ...Object.values(state.detectedWorktreesByRepo ?? {}).map((result) => result.worktrees) - ] - for (const worktrees of catalogs) { - for (const worktree of worktrees) { - if (worktree.id !== rawWorktreeId || parseExecutionHostId(worktree.hostId)?.id !== hostId) { - continue - } - repoIds.add(worktree.repoId) - const resolution = resolveExactWorktreeRoute(state, worktree) - if (resolution.kind === 'ambiguous') { - return resolution - } - if (resolution.kind === 'resolved' && resolution.route.executionHostId === hostId) { - environmentIds.add(resolution.route.runtimeEnvironmentId) - } - } - } - if (environmentIds.size === 0) { - for (const repo of state.repos) { - if (!repoIds.has(repo.id)) { - continue - } - const executionHost = parseExecutionHostId(repo.executionHostId) - const connectionId = repo.connectionId?.trim() - const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null - if (connectionHostId !== hostId && executionHost?.id !== hostId) { - continue - } - environmentIds.add(executionHost?.kind === 'runtime' ? executionHost.environmentId : null) - } - } - if (environmentIds.size > 1) { - return { kind: 'ambiguous' } - } - const environmentId = environmentIds.values().next().value - return environmentIds.size === 1 - ? { kind: 'resolved', environmentId: environmentId ?? null } - : { kind: 'missing' } -} - export function resolveNativeChatSkillDiscoveryCwd( state: NativeChatSkillWorktreeState, terminalTabId: string @@ -170,12 +106,29 @@ export function resolveNativeChatSkillDiscoveryContext( } const hostId = getExecutionHostIdForWorktree(state, worktreeId) const parsedHost = parseExecutionHostId(hostId) - if (parsedHost?.kind === 'ssh') { + const workspaceScope = parseWorkspaceKey(worktreeId) + const folderSshRoute = + workspaceScope?.type === 'folder' + ? resolveNativeChatFolderSshRoute(state, workspaceScope.folderWorkspaceId) + : { kind: 'missing' as const } + if (folderSshRoute.kind === 'ambiguous') { + return null + } + const sshHost = + folderSshRoute.kind === 'resolved' + ? parseExecutionHostId(folderSshRoute.hostId) + : parsedHost?.kind === 'ssh' + ? parsedHost + : null + if (sshHost?.kind === 'ssh') { const explicitRuntimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree( state, worktreeId ) - const catalogTransport = resolveCatalogSshTransport(state, worktreeId, parsedHost.id) + const catalogTransport = + folderSshRoute.kind === 'resolved' + ? { kind: 'resolved' as const, environmentId: folderSshRoute.environmentId } + : resolveNativeChatWorktreeSshTransport(state, worktreeId, sshHost.id) if ( catalogTransport.kind === 'ambiguous' || (explicitRuntimeEnvironmentId && @@ -189,14 +142,14 @@ export function resolveNativeChatSkillDiscoveryContext( (catalogTransport.kind === 'resolved' ? catalogTransport.environmentId : null) // Why: unresolved paired-runtime transport can collide with a local target // id; failing closed prevents the identity-only RPC reaching the wrong host. - if (!runtimeEnvironmentId && isRuntimeOwnedSshTargetId(parsedHost.targetId)) { + if (!runtimeEnvironmentId && isRuntimeOwnedSshTargetId(sshHost.targetId)) { return null } const connectionState = runtimeEnvironmentId ? state.sshStateByEnvironment .get(runtimeEnvironmentId) - ?.connectionStates.get(parsedHost.targetId) - : state.sshConnectionStates.get(parsedHost.targetId) + ?.connectionStates.get(sshHost.targetId) + : state.sshConnectionStates.get(sshHost.targetId) // Why: an unhydrated environment bucket is unknown, not disconnected — the // owning runtime answers authoritatively. A missing local entry is offline. const status = connectionState?.status ?? (runtimeEnvironmentId ? null : 'disconnected') @@ -204,7 +157,7 @@ export function resolveNativeChatSkillDiscoveryContext( key: JSON.stringify([ 'ssh', runtimeEnvironmentId ?? null, - hostId, + sshHost.id, connectionState?.connectionGeneration ?? 0, worktreeId, terminalTabId @@ -218,7 +171,6 @@ export function resolveNativeChatSkillDiscoveryContext( } } - const workspaceScope = parseWorkspaceKey(worktreeId) const cwd = resolveNativeChatSkillDiscoveryCwd(state, terminalTabId) ?? (workspaceScope?.type === 'folder' diff --git a/src/renderer/src/components/native-chat/native-chat-worktree-ssh-transport.ts b/src/renderer/src/components/native-chat/native-chat-worktree-ssh-transport.ts new file mode 100644 index 00000000000..edb8fdf20a9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-worktree-ssh-transport.ts @@ -0,0 +1,102 @@ +import type { AppState } from '../../store/types' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { + getRepoIdFromWorktreeId, + splitWorktreeIdForFilesystem +} from '../../../../shared/worktree-id' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { resolveExactWorktreeRoute } from '@/lib/worktree-owner-route' + +type WorktreeSshTransportState = Pick< + AppState, + 'detectedWorktreesByRepo' | 'repos' | 'worktreesByRepo' +> + +export type NativeChatWorktreeSshTransportResolution = + | { kind: 'resolved'; environmentId: string | null } + | { kind: 'ambiguous' } + | { kind: 'missing' } + +export function resolveNativeChatWorktreeSshTransport( + state: WorktreeSshTransportState, + worktreeId: string, + hostId: ExecutionHostId +): NativeChatWorktreeSshTransportResolution { + const scope = parseWorkspaceKey(worktreeId) + const rawWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId + const repoIds = new Set([getRepoIdFromWorktreeId(rawWorktreeId)]) + const environmentIds = new Set() + const catalogs = [ + ...Object.values(state.worktreesByRepo ?? {}), + ...Object.values(state.detectedWorktreesByRepo ?? {}).map((result) => result.worktrees) + ] + let hasDirectOwner = false + let hasRuntimeOwner = false + for (const worktrees of catalogs) { + for (const worktree of worktrees) { + if ( + !worktreeIdsEqual(worktree.id, rawWorktreeId) || + parseExecutionHostId(worktree.hostId)?.id !== hostId + ) { + continue + } + repoIds.add(worktree.repoId) + if (worktree.runtimeOwnerEnvironmentId?.trim()) { + hasRuntimeOwner = true + } else { + hasDirectOwner = true + } + const resolution = resolveExactWorktreeRoute(state, worktree) + if (resolution.kind === 'ambiguous') { + return resolution + } + if (resolution.kind === 'resolved' && resolution.route.executionHostId === hostId) { + environmentIds.add(resolution.route.runtimeEnvironmentId) + } + } + } + if (hasDirectOwner && hasRuntimeOwner) { + return { kind: 'ambiguous' } + } + if (environmentIds.size === 0) { + for (const repo of state.repos) { + if (!repoIds.has(repo.id)) { + continue + } + const executionHost = parseExecutionHostId(repo.executionHostId) + const connectionId = repo.connectionId?.trim() + const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null + if (connectionHostId !== hostId && executionHost?.id !== hostId) { + continue + } + environmentIds.add(executionHost?.kind === 'runtime' ? executionHost.environmentId : null) + } + } + if (environmentIds.size > 1) { + return { kind: 'ambiguous' } + } + const environmentId = environmentIds.values().next().value + return environmentIds.size === 1 + ? { kind: 'resolved', environmentId: environmentId ?? null } + : { kind: 'missing' } +} + +function worktreeIdsEqual(left: string, right: string): boolean { + if (left === right) { + return true + } + const parsedLeft = splitWorktreeIdForFilesystem(left) + const parsedRight = splitWorktreeIdForFilesystem(right) + return Boolean( + parsedLeft && + parsedRight && + parsedLeft.repoId === parsedRight.repoId && + normalizeRuntimePathForComparison(parsedLeft.worktreePath) === + normalizeRuntimePathForComparison(parsedRight.worktreePath) + ) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index 3c4cafff44f..6df3aef06d5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -240,10 +240,60 @@ describe('useNativeChatSkills', () => { ) }) + it('routes a projected paired SSH folder through pane discovery', async () => { + const worktreeId = 'folder:folder-1' + mocks.state = { + ...stateForHost('runtime:hub-a'), + activeWorktreeId: worktreeId, + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + folderPath: '/remote/folder', + executionHostId: 'runtime:hub-a' + } + ], + projectGroups: [ + { + id: 'group-1', + connectionId: 'private-target', + executionHostId: 'runtime:hub-a' + } + ], + sshStateByEnvironment: new Map([ + [ + 'hub-a', + { connectionStates: new Map([['private-target', sshConnectionState('private-target')]]) } + ] + ]), + tabsByWorktree: { [worktreeId]: [{ id: 'tab-1' }] }, + worktreesByRepo: {} + } + mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', result: DISCOVERY_RESULT }) + + render() + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'hub-a' }, + 'skills.discoverForPane', + { worktreeId, terminalTabId: 'tab-1' }, + { timeoutMs: 10_000 } + ) + }) + it('issues no RPC for an ambiguous direct and paired SSH pane', async () => { mocks.state = { ...stateForHost('ssh:shared-target'), activeWorkspaceExecutionHostId: 'ssh:shared-target', + repos: [ + { + id: 'repo-1', + path: '/repo', + connectionId: 'shared-target', + executionHostId: 'runtime:hub-a' + } + ], sshConnectionStates: new Map([['shared-target', sshConnectionState('shared-target')]]), worktreesByRepo: { 'repo-1': [ diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index c17bafb424d..450b2f3713e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -513,6 +513,163 @@ describe('resolveNativeChatSkillDiscoveryContext for SSH panes', () => { expect(context).toBeNull() }) + it('fails closed before a direct repo row hydrates beside a paired repo fallback', () => { + const worktreeId = 'repo-1::/remote/repo' + const context = resolveNativeChatSkillDiscoveryContext( + selectedSshInputs({ + activeWorkspaceExecutionHostId: 'ssh:shared-target', + activeWorktreeId: worktreeId, + repos: [ + { + id: 'repo-1', + path: '/remote/repo', + connectionId: 'shared-target', + executionHostId: 'runtime:hub-a' + } + ], + tabsByWorktree: { [worktreeId]: [{ id: 'tab-1' }] }, + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/remote/repo', + hostId: 'ssh:shared-target' + }, + { + id: worktreeId, + repoId: 'repo-1', + path: '/remote/repo', + hostId: 'ssh:shared-target', + runtimeOwnerEnvironmentId: 'hub-a' + } + ] + } + }), + 'tab-1' + ) + + expect(context).toBeNull() + }) + + it('resolves paired transport across equivalent Windows worktree ids', () => { + const paneWorktreeId = 'repo-1::C:\\remote\\repo' + const context = resolveNativeChatSkillDiscoveryContext( + selectedSshInputs({ + activeWorkspaceExecutionHostId: 'ssh:private-target', + activeWorktreeId: paneWorktreeId, + repos: [], + sshConnectionStates: new Map([ + ['private-target', connectionState({ targetId: 'private-target' })] + ]), + tabsByWorktree: { [paneWorktreeId]: [{ id: 'tab-1' }] }, + worktreesByRepo: { + 'repo-1': [ + { + id: 'repo-1::c:/remote/repo', + repoId: 'repo-1', + path: 'c:/remote/repo', + hostId: 'ssh:private-target', + runtimeOwnerEnvironmentId: 'hub-a' + } + ] + } + }), + 'tab-1' + ) + + expect(context?.executionHostKind).toBe('ssh') + if (context?.executionHostKind !== 'ssh') { + throw new Error('expected ssh context') + } + expect(context.runtimeTarget).toEqual({ kind: 'environment', environmentId: 'hub-a' }) + }) + + it('uses pane discovery for an SSH folder projected through a runtime', () => { + const worktreeId = 'folder:folder-1' + const context = resolveNativeChatSkillDiscoveryContext( + selectedSshInputs({ + activeWorkspaceExecutionHostId: 'runtime:hub-a', + activeWorktreeId: worktreeId, + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + folderPath: '/remote/folder', + executionHostId: 'runtime:hub-a' + } + ], + projectGroups: [ + { + id: 'group-1', + connectionId: 'private-target', + executionHostId: 'runtime:hub-a' + } + ], + repos: [], + sshStateByEnvironment: new Map([ + [ + 'hub-a', + { + connectionStates: new Map([ + ['private-target', connectionState({ targetId: 'private-target' })] + ]) + } + ] + ]), + tabsByWorktree: { [worktreeId]: [{ id: 'tab-1' }] }, + worktreesByRepo: {} + }), + 'tab-1' + ) + + expect(context?.executionHostKind).toBe('ssh') + if (context?.executionHostKind !== 'ssh') { + throw new Error('expected ssh context') + } + expect(context.runtimeTarget).toEqual({ kind: 'environment', environmentId: 'hub-a' }) + expect(context.paneTarget).toEqual({ worktreeId, terminalTabId: 'tab-1' }) + }) + + it('isolates a paired SSH folder from a duplicate local folder id', () => { + const worktreeId = 'folder:folder-1' + const context = resolveNativeChatSkillDiscoveryContext( + selectedSshInputs({ + activeWorkspaceExecutionHostId: 'runtime:hub-a', + activeWorktreeId: worktreeId, + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'local-group', + folderPath: '/local/folder', + executionHostId: 'local' + }, + { + id: 'folder-1', + projectGroupId: 'remote-group', + folderPath: '/remote/folder', + executionHostId: 'runtime:hub-a' + } + ], + projectGroups: [ + { id: 'local-group', executionHostId: 'local' }, + { + id: 'remote-group', + connectionId: 'private-target', + executionHostId: 'runtime:hub-a' + } + ], + repos: [], + tabsByWorktree: { [worktreeId]: [{ id: 'tab-1' }] }, + worktreesByRepo: {} + }), + 'tab-1' + ) + + expect(context?.executionHostKind).toBe('ssh') + expect(context?.runtimeTarget).toEqual({ kind: 'environment', environmentId: 'hub-a' }) + }) + it('keeps the same remote path on two SSH hosts cache-isolated', () => { const onTargetTwo = resolveNativeChatSkillDiscoveryContext( sshInputs({