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 4d2cccb80ed..b4e8635acd1 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,6 +1,12 @@ import type { AppState } from '../../store/types' import type { PaneSkillDiscoveryTarget, SkillDiscoveryTarget } from '../../../../shared/skills' -import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' +import { + isRuntimeOwnedSshTargetId, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { getExplicitRuntimeEnvironmentIdForWorktree, @@ -71,6 +77,46 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki } } +function hasRuntimeSshTransportEvidence( + state: NativeChatSkillStateInputs, + worktreeId: string, + hostId: ExecutionHostId +): boolean { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + return false + } + const rawWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId + const repoIds = new Set([getRepoIdFromWorktreeId(rawWorktreeId)]) + 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) + if (worktree.runtimeOwnerEnvironmentId?.trim()) { + return true + } + } + } + for (const repo of state.repos) { + const connectionId = repo.connectionId?.trim() + if ( + repoIds.has(repo.id) && + parseExecutionHostId(repo.executionHostId)?.kind === 'runtime' && + connectionId && + toSshExecutionHostId(connectionId) === hostId + ) { + return true + } + } + return false +} + export function resolveNativeChatSkillDiscoveryCwd( state: NativeChatSkillWorktreeState, terminalTabId: string @@ -106,9 +152,13 @@ export function resolveNativeChatSkillDiscoveryContext( const parsedHost = parseExecutionHostId(hostId) if (parsedHost?.kind === 'ssh') { const runtimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) - // Why: a runtime-owned SSH target without an explicit owner stamp is - // ambiguous; failing closed beats resolving it against local providers. - if (isRuntimeOwnedSshTargetId(parsedHost.targetId) && !runtimeEnvironmentId) { + // 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) || + hasRuntimeSshTransportEvidence(state, worktreeId, parsedHost.id)) + ) { return null } const connectionState = runtimeEnvironmentId 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 9ab4875f316..c474233e31f 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 @@ -310,6 +310,45 @@ describe('resolveNativeChatSkillDiscoveryContext for SSH panes', () => { expect(context.sshDisconnected).toBe(false) }) + it('keeps a non-ephemeral paired SSH target on its owning runtime', () => { + const context = resolveNativeChatSkillDiscoveryContext( + selectedSshInputs({ + repos: [], + sshConnectionStates: new Map([ + ['private-target', connectionState({ targetId: 'private-target' })] + ]), + sshStateByEnvironment: new Map([ + [ + 'hub-a', + { + connectionStates: new Map([ + ['private-target', connectionState({ targetId: 'private-target' })] + ]) + } + ] + ]), + worktreesByRepo: { + 'repo-1': [ + { + id: 'worktree-1', + repoId: 'repo-1', + path: '/repo/worktree', + 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('treats an unhydrated environment bucket as unknown rather than disconnected', () => { const context = resolveNativeChatSkillDiscoveryContext( sshInputs({ @@ -403,6 +442,42 @@ describe('resolveNativeChatSkillDiscoveryContext for SSH panes', () => { expect(context?.key).toContain('ssh:target-2') }) + it('fails closed when two runtimes project a local SSH target id', () => { + const worktreeId = 'repo-1::/remote/repo' + const context = resolveNativeChatSkillDiscoveryContext( + selectedSshInputs({ + activeWorkspaceExecutionHostId: 'ssh:shared-target', + activeWorktreeId: worktreeId, + repos: [], + sshConnectionStates: new Map([ + ['shared-target', connectionState({ targetId: 'shared-target' })] + ]), + tabsByWorktree: { [worktreeId]: [{ id: 'tab-1' }] }, + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/remote/repo', + hostId: 'ssh:shared-target', + runtimeOwnerEnvironmentId: 'hub-a' + }, + { + id: worktreeId, + repoId: 'repo-1', + path: '/remote/repo', + hostId: 'ssh:shared-target', + runtimeOwnerEnvironmentId: 'hub-b' + } + ] + } + }), + 'tab-1' + ) + + expect(context).toBeNull() + }) + it('keeps the same remote path on two SSH hosts cache-isolated', () => { const onTargetTwo = resolveNativeChatSkillDiscoveryContext( sshInputs({