fix(skills): reject ambiguous SSH runtime routes

This commit is contained in:
Brennan Benson
2026-08-05 18:50:12 -07:00
parent d2741dc203
commit ae64fed822
2 changed files with 129 additions and 4 deletions
@@ -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
@@ -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({