fix(skills): preserve SSH pane transport identity

This commit is contained in:
Brennan Benson
2026-08-05 18:50:13 -07:00
parent 414183111d
commit 5d7733ebe7
5 changed files with 470 additions and 74 deletions
@@ -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<NativeChatFolderSshRouteResolution, { kind: 'resolved' }>
export function resolveNativeChatFolderSshRoute(
state: FolderSshRouteState,
folderWorkspaceId: string
): NativeChatFolderSshRouteResolution {
const routes = new Map<string, FolderSshRoute>()
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
}
}
@@ -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<string | null>()
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'
@@ -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<string | null>()
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)
)
}
@@ -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(<Probe enabled />)
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': [
@@ -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({