fix(agents): keep the global Windows runtime for local detection inside SSH workspaces

The no-project fallback for the configured Windows runtime default bailed out
whenever any repo or worktree was active, so an SSH workspace (or a stale
active repo id) sent local agent detection to the raw Windows host. Refresh
in the status bar then overwrote detectedAgentIds with the host's empty list
and the usage meters vanished.

Only a local project can own the runtime; defer to it, otherwise apply the
global default under one shared cache key.

Fixes #18837
This commit is contained in:
Neil
2026-09-07 14:11:07 -07:00
parent d0506bf5de
commit cbcfad0437
4 changed files with 167 additions and 13 deletions
@@ -68,13 +68,39 @@ describe('useActiveProjectSkillRuntime', () => {
expect(result.current.discoveryTarget).toBeUndefined()
})
it('does not adopt the global default once a project is active', () => {
it('lets an active local project override the global default', () => {
setGlobalWslDefault('Ubuntu')
useAppStore.setState({ activeRepoId: 'repo-1' })
useAppStore.setState({
activeRepoId: 'repo-1',
repos: [{ id: 'repo-1', path: 'C:\\repo', displayName: 'r', badgeColor: 'b', addedAt: 1 }],
projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] as never
})
const { result } = renderHook(() => useActiveProjectSkillRuntime())
expect(result.current.agentRuntime).toBeUndefined()
useAppStore.setState({ activeRepoId: null })
expect(result.current.agentRuntime).toEqual({ runtime: 'host', label: 'Windows' })
useAppStore.setState({ activeRepoId: null, repos: [], projects: [] })
})
it('keeps the global WSL default while an SSH project is active', () => {
setGlobalWslDefault('Ubuntu')
useAppStore.setState({
activeRepoId: 'repo-ssh',
repos: [
{
id: 'repo-ssh',
path: '/home/alice/repo',
displayName: 'r',
badgeColor: 'b',
addedAt: 1,
connectionId: 'builder',
executionHostId: 'ssh:builder'
}
]
})
const { result } = renderHook(() => useActiveProjectSkillRuntime())
expect(result.current.agentRuntime).toMatchObject({ runtime: 'wsl' })
useAppStore.setState({ activeRepoId: null, repos: [] })
})
it('does not inject the local WSL runtime or shell into a remote environment', () => {
@@ -4,6 +4,7 @@ import type { Repo } from '../../../shared/repo-types'
import type { Worktree } from '../../../shared/worktree/types'
import type { AppState } from '@/store/types'
import {
getGlobalWindowsExecutionRuntimeContext,
getLocalAgentPreflightContext,
getLocalPreflightContext,
getLocalProjectExecutionRuntimeContext,
@@ -375,18 +376,54 @@ describe('local preflight context', () => {
expect(localPreflightContextKey(context)).toBe('local-project:wsl:Ubuntu')
})
it('does not use the global runtime default for active SSH projects', () => {
it('keeps the global WSL runtime for local agent checks inside an active SSH project', () => {
const state = {
...makeState({
repoPath: '/home/alice/repo',
repo: { connectionId: 'builder', executionHostId: 'ssh:builder' }
worktreePath: '/home/alice/repo',
repo: { connectionId: 'builder', executionHostId: 'ssh:builder' },
worktree: { hostId: 'ssh:builder' }
}),
settings: {
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
}
} as unknown as AppState
expect(getLocalAgentPreflightContext(state, 'win32')).toBeUndefined()
// Why: the SSH host owns remote execution, but the local CLIs this probe
// looks for still live in the configured Windows runtime, not the raw host.
expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe(
'local-project:wsl:Ubuntu'
)
})
it('keeps the global WSL runtime when the active repo id has no repo row', () => {
const state = {
...makeState({ repoPath: undefined }),
activeRepoId: 'ghost',
activeWorktreeId: null,
settings: {
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
}
} as unknown as AppState
expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe(
'local-project:wsl:Ubuntu'
)
})
it('lets a local project own the runtime instead of the global default', () => {
const state = {
...makeState({ repoPath: 'C:\\Users\\alice\\repo' }),
projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }],
settings: {
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
}
} as unknown as AppState
expect(getGlobalWindowsExecutionRuntimeContext(state, undefined, 'win32')).toBeUndefined()
expect(localPreflightContextKey(getLocalAgentPreflightContext(state, 'win32'))).toBe(
'repo-1:windows-host'
)
})
it('uses the project override over legacy agent location for local agent checks', () => {
@@ -41,6 +41,9 @@ type LocalProjectRuntimeState = Pick<
// or `[]` fallback would miss the cache on every read.
const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {}
const EMPTY_REPOS: AppState['repos'] = []
// Why: the global Windows default is one runtime regardless of which remote
// workspace is active, so every non-owned caller shares this cache key.
const GLOBAL_LOCAL_PROJECT_ID = 'local-project'
type LocalProjectRuntimeWslContext = {
wslAvailable?: boolean
@@ -91,7 +94,7 @@ export function getLocalProjectExecutionRuntimeContext(
})
}
/** Resolves the Windows default only when no project can own the runtime. */
/** Resolves the Windows default only when no local project can own the runtime. */
export function getGlobalWindowsExecutionRuntimeContext(
state: LocalProjectRuntimeState,
worktreeId?: string | null,
@@ -100,16 +103,22 @@ export function getGlobalWindowsExecutionRuntimeContext(
): ProjectExecutionRuntimeResolution | undefined {
if (
appPlatform !== 'win32' ||
worktreeId ||
state.activeRepoId ||
state.activeWorktreeId ||
worktreeId === FLOATING_TERMINAL_WORKTREE_ID ||
!state.settings?.localWindowsRuntimeDefault
) {
return undefined
}
// Why: an SSH/runtime workspace (or a stale active id) cannot own the local
// Windows runtime, and the local CLIs still live where the default says.
// Only a local project owner may displace the global default.
const worktree = getLocalWorktree(state, worktreeId)
const repo = getLocalRuntimeRepoForWorktree(state, worktree)
if (isLocalRuntimeRepo(repo) && isLocalRuntimeWorktree(worktree)) {
return undefined
}
return resolveProjectExecutionRuntime({
appPlatform: 'win32',
projectId: getLocalPreflightProjectId(state, worktreeId),
projectId: GLOBAL_LOCAL_PROJECT_ID,
projectRuntimePreference: { kind: 'inherit-global' },
globalWindowsRuntimeDefault: state.settings.localWindowsRuntimeDefault,
...wslContext
@@ -323,6 +332,9 @@ function getLocalPreflightProjectId(
): string {
const activeWorktree = getLocalWorktree(state, worktreeId)
return (
activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project'
activeWorktree?.projectId ??
activeWorktree?.repoId ??
state.activeRepoId ??
GLOBAL_LOCAL_PROJECT_ID
)
}
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { createDetectedAgentsSlice } from './detected-agents'
const detectAgents = vi.fn()
const refreshAgents = vi.fn()
globalThis.window = {
api: {
preflight: { detectAgents, refreshAgents },
platform: { get: () => ({ platform: 'win32' }) }
} as unknown as Window['api']
} as Window & typeof globalThis
const sshRepo = {
id: 'repo-ssh',
path: '/home/alice/repo',
displayName: 'repo',
badgeColor: '#000000',
addedAt: 0,
connectionId: 'builder',
executionHostId: 'ssh:builder'
} satisfies Repo
const sshWorktree = {
id: 'repo-ssh::main',
repoId: 'repo-ssh',
path: '/home/alice/repo',
hostId: 'ssh:builder'
} as Worktree
function createTestStore() {
const store = create<AppState>()(
(...args) => createDetectedAgentsSlice(...args) as unknown as AppState
)
store.setState({
settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } },
repos: [sshRepo],
projects: [],
worktreesByRepo: { 'repo-ssh': [sshWorktree] },
activeRepoId: 'repo-ssh',
activeWorktreeId: 'repo-ssh::main'
} as Partial<AppState>)
return store
}
describe('local detected agents inside an SSH workspace', () => {
beforeEach(() => {
// Why: mirrors the reported machine -- CLIs live in WSL, the Windows host has none.
const agentsFor = (context: { projectRuntime?: { runtime?: { kind?: string } } }) =>
context?.projectRuntime?.runtime?.kind === 'wsl' ? ['claude', 'codex'] : []
detectAgents.mockReset().mockImplementation(async (context) => agentsFor(context))
refreshAgents.mockReset().mockImplementation(async (context) => ({
agents: agentsFor(context),
addedPathSegments: [],
shellHydrationOk: true,
pathSource: 'shell_hydrate',
pathFailureReason: 'none'
}))
})
it('refreshes in the global WSL runtime instead of the raw Windows host', async () => {
const store = createTestStore()
await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude', 'codex'])
await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['claude', 'codex'])
const detectContext = detectAgents.mock.calls[0]?.[0]
expect(detectContext).toMatchObject({
projectRuntime: { runtime: { kind: 'wsl', distro: 'Ubuntu', reason: 'global-default' } }
})
// Why: Refresh must re-detect in the runtime the visible list came from (#18837).
expect(refreshAgents).toHaveBeenCalledWith(detectContext)
expect(store.getState().detectedAgentIds).toEqual(['claude', 'codex'])
})
})