fix(settings): keep local WSL settings scoped to the desktop host (#9635)

* fix(settings): scope local WSL settings to the desktop host

* fix(settings): verify local WSL capability ownership

* fix(settings): respect capability host ownership

* fix(settings): isolate paired host capabilities

* fix(settings): key web capabilities to paired host

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
余辉 2026-07-29 10:58:55 +08:00 committed by GitHub
parent 9150ac65cb
commit 3e4abef089
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 904 additions and 41 deletions

View File

@ -0,0 +1,247 @@
// @vitest-environment happy-dom
import { act, createElement, type ComponentProps, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/types'
import type { AgentsPane } from '@/components/settings/AgentsPane'
import { resetWindowsTerminalCapabilitiesForTests } from '@/lib/windows-terminal-capabilities'
const testState = vi.hoisted(() => ({
settings: null as GlobalSettings | null,
updateSettings: vi.fn(),
agentsPaneProps: null as ComponentProps<typeof AgentsPane> | null,
isWebClient: false,
runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[]
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: object) => unknown) =>
selector({
settings: testState.settings,
updateSettings: testState.updateSettings,
runtimeEnvironments: testState.runtimeEnvironments,
runtimeStatusByEnvironmentId: new Map()
})
}))
vi.mock('@/components/settings/AgentsPane', () => ({
AgentsPane: (props: ComponentProps<typeof AgentsPane>) => {
testState.agentsPaneProps = props
return null
}
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children }: { children: ReactNode }) => children,
DialogContent: ({ children }: { children: ReactNode }) => children,
DialogDescription: ({ children }: { children: ReactNode }) => children,
DialogHeader: ({ children }: { children: ReactNode }) => children,
DialogTitle: ({ children }: { children: ReactNode }) => children
}))
vi.mock('@/lib/web-client-location', () => ({
isWebClientLocation: () => testState.isWebClient
}))
import AgentSettingsDialog from './AgentSettingsDialog'
function installCapabilityTransports(localHostPlatform: NodeJS.Platform = 'win32'): {
localWslAvailable: ReturnType<typeof vi.fn>
localWslDistros: ReturnType<typeof vi.fn>
runtimeGetStatus: ReturnType<typeof vi.fn>
runtimeEnvironmentCall: ReturnType<typeof vi.fn>
} {
const localWslAvailable = vi.fn().mockResolvedValue(true)
const localWslDistros = vi.fn().mockResolvedValue(['Ubuntu'])
const runtimeGetStatus = vi.fn().mockResolvedValue({ hostPlatform: localHostPlatform })
const runtimeEnvironmentCall = vi.fn(async (args: { method: string }) => ({
id: args.method,
ok: true,
result:
args.method === 'status.get'
? {
hostPlatform: 'linux',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2
}
: args.method === 'host.wsl.listDistros'
? []
: false
}))
Object.defineProperty(window, 'api', {
configurable: true,
value: {
wsl: {
isAvailable: localWslAvailable,
listDistros: localWslDistros
},
pwsh: { isAvailable: vi.fn().mockResolvedValue(true) },
gitBash: { isAvailable: vi.fn().mockResolvedValue(false) },
runtime: { getStatus: runtimeGetStatus },
runtimeEnvironments: { call: runtimeEnvironmentCall }
} as unknown as Window['api']
})
return { localWslAvailable, localWslDistros, runtimeGetStatus, runtimeEnvironmentCall }
}
describe('AgentSettingsDialog', () => {
let root: Root
beforeEach(() => {
testState.settings = {
...getDefaultSettings('/tmp'),
activeRuntimeEnvironmentId: 'remote-linux'
}
testState.updateSettings.mockReset()
testState.agentsPaneProps = null
testState.isWebClient = false
testState.runtimeEnvironments = []
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
resetWindowsTerminalCapabilitiesForTests()
document.body.replaceChildren()
vi.unstubAllGlobals()
})
it('reads desktop WSL capabilities instead of the active Linux runtime', async () => {
vi.stubGlobal('navigator', { userAgent: 'Windows' })
const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports()
await act(async () => {
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
await Promise.resolve()
await Promise.resolve()
})
expect(localWslAvailable).toHaveBeenCalledTimes(1)
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(testState.agentsPaneProps).toMatchObject({
wslSupportedPlatform: true,
wslAvailable: true,
wslDistros: ['Ubuntu'],
wslCapabilitiesLoading: false
})
})
it('does not expose the desktop runtime setting on a non-Windows desktop', async () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports()
await act(async () => {
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
await Promise.resolve()
})
expect(localWslAvailable).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(testState.agentsPaneProps).toMatchObject({ wslSupportedPlatform: false })
})
it('uses the paired server capability transport for a web client', async () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
testState.isWebClient = true
const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports()
await act(async () => {
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
await Promise.resolve()
await Promise.resolve()
})
expect(localWslAvailable).toHaveBeenCalledTimes(1)
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(testState.agentsPaneProps).toMatchObject({
wslSupportedPlatform: true,
wslAvailable: true,
wslDistros: ['Ubuntu']
})
})
it('ignores a Windows browser user agent when the paired server is Linux', async () => {
vi.stubGlobal('navigator', { userAgent: 'Windows' })
testState.isWebClient = true
const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports('linux')
await act(async () => {
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
await Promise.resolve()
await Promise.resolve()
})
expect(localWslAvailable).toHaveBeenCalledTimes(1)
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(testState.agentsPaneProps).toMatchObject({ wslSupportedPlatform: false })
})
it.each([
{
name: 'Windows to Linux',
firstPlatform: 'win32' as const,
firstAvailable: true,
firstDistros: ['Ubuntu'],
secondPlatform: 'linux' as const,
secondAvailable: false,
secondDistros: []
},
{
name: 'Linux to Windows',
firstPlatform: 'linux' as const,
firstAvailable: false,
firstDistros: [],
secondPlatform: 'win32' as const,
secondAvailable: true,
secondDistros: ['Debian']
}
])('refreshes paired-server capabilities after re-pairing: $name', async (args) => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
testState.isWebClient = true
testState.settings = { ...testState.settings!, activeRuntimeEnvironmentId: null }
testState.runtimeEnvironments = [{ id: 'paired-a', createdAt: 1 }]
const { localWslAvailable, localWslDistros, runtimeGetStatus } = installCapabilityTransports(
args.firstPlatform
)
localWslAvailable.mockResolvedValue(args.firstAvailable)
localWslDistros.mockResolvedValue(args.firstDistros)
await act(async () => {
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
await Promise.resolve()
await Promise.resolve()
})
expect(testState.agentsPaneProps).toMatchObject({
wslSupportedPlatform: args.firstPlatform === 'win32',
wslAvailable: args.firstAvailable,
wslDistros: args.firstDistros
})
testState.settings = {
...testState.settings!,
activeRuntimeEnvironmentId: null
}
testState.runtimeEnvironments = [{ id: 'paired-b', createdAt: 2 }]
localWslAvailable.mockResolvedValue(args.secondAvailable)
localWslDistros.mockResolvedValue(args.secondDistros)
runtimeGetStatus.mockResolvedValue({ hostPlatform: args.secondPlatform })
await act(async () => {
root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() }))
await Promise.resolve()
await Promise.resolve()
})
expect(testState.agentsPaneProps).toMatchObject({
wslSupportedPlatform: args.secondPlatform === 'win32',
wslAvailable: args.secondAvailable,
wslDistros: args.secondDistros
})
expect(localWslAvailable).toHaveBeenCalledTimes(2)
expect(runtimeGetStatus).toHaveBeenCalledTimes(2)
})
})

View File

@ -10,11 +10,11 @@ import { AgentsPane } from '@/components/settings/AgentsPane'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import {
getWindowsTerminalCapabilityOwnerKey,
useWindowsTerminalCapabilities
isWindowsTerminalCapabilityHost,
useLocalWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { isWebClientLocation } from '@/lib/web-client-location'
import { useWindowsTerminalCapabilityOwnerKey } from '@/hooks/useWindowsTerminalCapabilityOwnerKey'
type AgentSettingsDialogProps = {
open: boolean
@ -27,20 +27,24 @@ export default function AgentSettingsDialog({
}: AgentSettingsDialogProps): React.JSX.Element | null {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const runtimeTarget = getActiveRuntimeTarget(settings)
const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null
const capabilitiesOwnerKey = getWindowsTerminalCapabilityOwnerKey(runtimeEnvironmentId)
const isWindowsRenderer =
typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows')
const isWebClient = isWebClientLocation()
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
open && (isWindowsRenderer || isWebClient || runtimeTarget.kind === 'environment'),
false,
capabilitiesOwnerKey,
runtimeTarget
const runtimeCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey(
settings?.activeRuntimeEnvironmentId
)
const wslSupportedPlatform =
isWindowsRenderer || windowsTerminalCapabilities.hostPlatform === 'win32'
const localCapabilityOwnerKey = isWebClient ? runtimeCapabilityOwnerKey : 'local'
const localWindowsTerminalCapabilities = useLocalWindowsTerminalCapabilities(
open && (isWindowsRenderer || isWebClient),
false,
localCapabilityOwnerKey
)
const wslSupportedPlatform = isWindowsTerminalCapabilityHost({
isWindowsRenderer,
isWebClient,
target: { kind: 'local' },
hostPlatform: localWindowsTerminalCapabilities.hostPlatform
})
if (!settings) {
return null
@ -69,9 +73,9 @@ export default function AgentSettingsDialog({
settings={settings}
updateSettings={updateSettings}
wslSupportedPlatform={wslSupportedPlatform}
wslAvailable={windowsTerminalCapabilities.wslAvailable}
wslDistros={windowsTerminalCapabilities.wslDistros}
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
wslAvailable={localWindowsTerminalCapabilities.wslAvailable}
wslDistros={localWindowsTerminalCapabilities.wslDistros}
wslCapabilitiesLoading={localWindowsTerminalCapabilities.isLoading}
/>
</div>
</DialogContent>

View File

@ -77,9 +77,11 @@ import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload'
import { registerWindowCloseGuard } from '../window-close-request-coordinator'
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
import {
getWindowsTerminalCapabilityOwnerKey,
isWindowsTerminalCapabilityHost,
useLocalWindowsTerminalCapabilities,
useWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { useWindowsTerminalCapabilityOwnerKey } from '@/hooks/useWindowsTerminalCapabilityOwnerKey'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { keybindingMatchesAction } from '../../../../shared/keybindings'
@ -834,32 +836,59 @@ function Settings(): React.JSX.Element {
}),
[activeSectionId, mountedSectionIds, navSections, settingsSearchQuery, visibleSectionIds]
)
const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey(
const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey(
settings?.activeRuntimeEnvironmentId
)
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
const capabilityLoadTarget = useMemo(
() => (isWebClient ? { kind: 'local' as const } : runtimeTarget),
[isWebClient, runtimeTarget]
)
const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
const needsRepoWindowsRuntimeCapabilities = [...neededSectionIds].some((sectionId) =>
sectionId.startsWith('repo-')
)
const needsLocalWindowsRuntimeCapabilities =
(isWindows || isWebClient) &&
(neededSectionIds.has('agents') || neededSectionIds.has('general'))
const shouldLoadWindowsTerminalCapabilities =
hasActiveRuntimeEnvironment ||
((isWindows || isWebClient) &&
(neededSectionIds.has('terminal') ||
neededSectionIds.has('general') ||
neededSectionIds.has('accounts') ||
neededSectionIds.has('agents') ||
needsRepoWindowsRuntimeCapabilities))
// Why: General owns the Orca CLI controls, including WSL skill-location setup.
needsRepoWindowsRuntimeCapabilities ||
(runtimeTarget.kind === 'local' && needsLocalWindowsRuntimeCapabilities)))
// Why: terminal, account, and repository settings describe the active execution host.
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
shouldLoadWindowsTerminalCapabilities,
true,
windowsTerminalCapabilityOwnerKey,
runtimeTarget
capabilityLoadTarget
)
// Why: global agent and project defaults belong to the desktop, not its active remote.
const remoteViewLocalWindowsRuntimeCapabilities = useLocalWindowsTerminalCapabilities(
needsLocalWindowsRuntimeCapabilities && runtimeTarget.kind === 'environment' && !isWebClient,
true,
'local'
)
const localWindowsRuntimeCapabilities =
runtimeTarget.kind === 'local' || isWebClient
? windowsTerminalCapabilities
: remoteViewLocalWindowsRuntimeCapabilities
// Why: only supported-but-unavailable WSL (Windows) should render disabled controls, not unsupported WSL (macOS/Linux).
const wslSupportedPlatform = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32'
const isWindowsTerminalHost = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32'
const runtimeWslSupportedPlatform = isWindowsTerminalCapabilityHost({
isWindowsRenderer: isWindows,
isWebClient,
target: runtimeTarget,
hostPlatform: windowsTerminalCapabilities.hostPlatform
})
const localWslSupportedPlatform = isWindowsTerminalCapabilityHost({
isWindowsRenderer: isWindows,
isWebClient,
target: { kind: 'local' },
hostPlatform: localWindowsRuntimeCapabilities.hostPlatform
})
const isWindowsTerminalHost = runtimeWslSupportedPlatform
if ([...neededSectionIds].some((id) => !mountedSectionIds.has(id))) {
// Why: record newly needed sections during render so panes don't wait for a follow-up Effect.
@ -1183,10 +1212,10 @@ function Settings(): React.JSX.Element {
<AgentsPane
settings={settings}
updateSettings={updateSettings}
wslSupportedPlatform={wslSupportedPlatform}
wslAvailable={windowsTerminalCapabilities.wslAvailable}
wslDistros={windowsTerminalCapabilities.wslDistros}
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
wslSupportedPlatform={localWslSupportedPlatform}
wslAvailable={localWindowsRuntimeCapabilities.wslAvailable}
wslDistros={localWindowsRuntimeCapabilities.wslDistros}
wslCapabilitiesLoading={localWindowsRuntimeCapabilities.isLoading}
/>
) : null}
</SettingsSection>
@ -1211,7 +1240,7 @@ function Settings(): React.JSX.Element {
<AccountsPane
settings={settings}
updateSettings={updateSettings}
wslSupportedPlatform={wslSupportedPlatform}
wslSupportedPlatform={runtimeWslSupportedPlatform}
wslAvailable={windowsTerminalCapabilities.wslAvailable}
wslDistros={windowsTerminalCapabilities.wslDistros}
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
@ -1310,10 +1339,10 @@ function Settings(): React.JSX.Element {
updateSettings={updateSettings}
fontSuggestions={terminalFontSuggestions}
onRequestFontSuggestions={requestFontSuggestions}
wslSupportedPlatform={wslSupportedPlatform}
wslAvailable={windowsTerminalCapabilities.wslAvailable}
wslDistros={windowsTerminalCapabilities.wslDistros}
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
wslSupportedPlatform={localWslSupportedPlatform}
wslAvailable={localWindowsRuntimeCapabilities.wslAvailable}
wslDistros={localWindowsRuntimeCapabilities.wslDistros}
wslCapabilitiesLoading={localWindowsRuntimeCapabilities.isLoading}
/>
) : null}
</SettingsSection>

View File

@ -0,0 +1,134 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../shared/constants'
import type { GlobalSettings } from '../../../shared/types'
import type { SettingsNavSection } from '@/lib/settings-navigation-types'
import { resetWindowsTerminalCapabilitiesForTests } from '@/lib/windows-terminal-capabilities'
const testState = vi.hoisted(() => ({
settings: null as GlobalSettings | null,
sections: null as SettingsNavSection[] | null,
runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[]
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: object) => unknown) =>
selector({
settings: testState.settings,
repos: [],
runtimeEnvironments: testState.runtimeEnvironments,
runtimeStatusByEnvironmentId: new Map()
})
}))
vi.mock('@/hooks/useLinearProviderConnected', () => ({
useLinearProviderConnected: () => false
}))
vi.mock('@/lib/web-client-location', () => ({
isWebClientLocation: () => true
}))
import { useSettingsNavigationMetadata } from './useSettingsNavigationMetadata'
function Probe(): null {
testState.sections = useSettingsNavigationMetadata()
return null
}
function hasAgentRuntimeEntry(): boolean {
return (
testState.sections
?.find((section) => section.id === 'agents')
?.searchEntries.some((entry) => entry.title === 'Agent Runtime') ?? false
)
}
describe('settings navigation capability ownership', () => {
let root: Root
beforeEach(() => {
testState.settings = {
...getDefaultSettings('/tmp'),
activeRuntimeEnvironmentId: null
}
testState.runtimeEnvironments = [{ id: 'paired-a', createdAt: 1 }]
testState.sections = null
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
resetWindowsTerminalCapabilitiesForTests()
document.body.replaceChildren()
vi.unstubAllGlobals()
})
it.each([
{
name: 'Windows to Linux',
firstPlatform: 'win32' as const,
secondPlatform: 'linux' as const,
firstVisible: true,
secondVisible: false
},
{
name: 'Linux to Windows',
firstPlatform: 'linux' as const,
secondPlatform: 'win32' as const,
firstVisible: false,
secondVisible: true
}
])('rebuilds web metadata from the new paired host: $name', async (args) => {
const wslIsAvailable = vi.fn().mockResolvedValue(false)
const wslListDistros = vi.fn().mockResolvedValue([])
const pwshIsAvailable = vi.fn().mockResolvedValue(false)
const gitBashIsAvailable = vi.fn().mockResolvedValue(false)
const runtimeGetStatus = vi
.fn()
.mockResolvedValueOnce({ hostPlatform: args.firstPlatform })
.mockResolvedValueOnce({ hostPlatform: args.secondPlatform })
const runtimeEnvironmentCall = vi.fn()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros },
pwsh: { isAvailable: pwshIsAvailable },
gitBash: { isAvailable: gitBashIsAvailable },
runtime: { getStatus: runtimeGetStatus },
runtimeEnvironments: { call: runtimeEnvironmentCall }
} as unknown as Window['api']
})
await act(async () => {
root.render(createElement(Probe))
await Promise.resolve()
await Promise.resolve()
})
expect(hasAgentRuntimeEntry()).toBe(args.firstVisible)
testState.settings = {
...testState.settings!,
activeRuntimeEnvironmentId: null
}
testState.runtimeEnvironments = [{ id: 'paired-b', createdAt: 2 }]
await act(async () => {
root.render(createElement(Probe))
await Promise.resolve()
await Promise.resolve()
})
expect(hasAgentRuntimeEntry()).toBe(args.secondVisible)
expect(wslIsAvailable).toHaveBeenCalledTimes(2)
expect(wslListDistros).toHaveBeenCalledTimes(2)
expect(pwshIsAvailable).toHaveBeenCalledTimes(2)
expect(gitBashIsAvailable).toHaveBeenCalledTimes(2)
expect(runtimeGetStatus).toHaveBeenCalledTimes(2)
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
})

View File

@ -204,6 +204,54 @@ describe('settings navigation metadata', () => {
)
})
it('does not expose local runtime settings from a remote Windows host', () => {
const sections = buildSettingsNavigationMetadata({
isMac: false,
isWindows: false,
isLocalWindowsHost: false,
isWindowsTerminalHost: true,
isWebClient: false,
repos: [repo]
})
const agents = sections.find((section) => section.id === 'agents')
const general = sections.find((section) => section.id === 'general')
const terminal = sections.find((section) => section.id === 'terminal')
const repoSection = sections.find((section) => section.id === 'repo-repo-1')
expect(agents?.searchEntries.some((entry) => entry.title === 'Agent Runtime')).toBe(false)
expect(general?.searchEntries.some((entry) => entry.title === 'Default Project Runtime')).toBe(
false
)
expect(terminal?.searchEntries.some((entry) => entry.title === 'Default Shell')).toBe(true)
expect(repoSection?.searchEntries.some((entry) => entry.title === 'Project Runtime')).toBe(true)
})
it('keeps local runtime settings but hides remote Linux entries on a Windows desktop', () => {
const sections = buildSettingsNavigationMetadata({
isMac: false,
isWindows: true,
isLocalWindowsHost: true,
isWindowsTerminalHost: false,
isWebClient: false,
repos: [repo]
})
const agents = sections.find((section) => section.id === 'agents')
const general = sections.find((section) => section.id === 'general')
const terminal = sections.find((section) => section.id === 'terminal')
const repoSection = sections.find((section) => section.id === 'repo-repo-1')
expect(agents?.searchEntries.some((entry) => entry.title === 'Agent Runtime')).toBe(true)
expect(general?.searchEntries.some((entry) => entry.title === 'Default Project Runtime')).toBe(
true
)
expect(terminal?.searchEntries.some((entry) => entry.title === 'Default Shell')).toBe(false)
expect(repoSection?.searchEntries.some((entry) => entry.title === 'Project Runtime')).toBe(
false
)
})
it('places Advanced near the bottom on desktop without putting it under Experimental', () => {
const desktopIds = ids()

View File

@ -77,9 +77,10 @@ import { getRepositoryPaneSearchEntries } from '@/components/settings/repository
import { buildSettingsProjectList } from '@/components/settings/settings-project-list'
import { isWebClientLocation } from '@/lib/web-client-location'
import {
getWindowsTerminalCapabilityOwnerKey,
isWindowsTerminalCapabilityHost,
useWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { useWindowsTerminalCapabilityOwnerKey } from './useWindowsTerminalCapabilityOwnerKey'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { useLinearProviderConnected } from '@/hooks/useLinearProviderConnected'
import { translate } from '@/i18n/i18n'
@ -114,6 +115,7 @@ function getDevToolsPaneSearchEntries(): SettingsNavSection['searchEntries'] {
export function buildSettingsNavigationMetadata({
isMac,
isWindows,
isLocalWindowsHost = isWindows,
isWindowsTerminalHost = isWindows,
isWebClient,
isDev = import.meta.env.DEV,
@ -122,6 +124,7 @@ export function buildSettingsNavigationMetadata({
}: {
isMac: boolean
isWindows: boolean
isLocalWindowsHost?: boolean
isWindowsTerminalHost?: boolean
isWebClient: boolean
isDev?: boolean
@ -156,7 +159,7 @@ export function buildSettingsNavigationMetadata({
'Manage AI agents, set a default, and customize commands.'
),
icon: Bot,
searchEntries: getAgentsPaneSearchEntries({ includeAgentRuntime: isWindowsTerminalHost }),
searchEntries: getAgentsPaneSearchEntries({ includeAgentRuntime: isLocalWindowsHost }),
group: 'capabilities'
},
{
@ -269,7 +272,7 @@ export function buildSettingsNavigationMetadata({
'Workspace defaults, app setup, and maintenance.'
),
icon: SlidersHorizontal,
searchEntries: getGeneralPaneSearchEntries({ includeProjectRuntime: isWindowsTerminalHost }),
searchEntries: getGeneralPaneSearchEntries({ includeProjectRuntime: isLocalWindowsHost }),
group: 'setup'
},
{
@ -623,17 +626,32 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
const isWindows = isWindowsUserAgent()
const isWebClient = isWebClientLocation()
const isLinearConnected = useLinearProviderConnected()
const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey(
const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey(
settings?.activeRuntimeEnvironmentId
)
const runtimeTarget = getActiveRuntimeTarget(settings)
const capabilityLoadTarget = isWebClient ? { kind: 'local' as const } : runtimeTarget
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
isWindows || isWebClient || runtimeTarget.kind === 'environment',
false,
windowsTerminalCapabilityOwnerKey,
runtimeTarget
capabilityLoadTarget
)
const isWindowsTerminalHost = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32'
const isLocalWindowsHost = isWindowsTerminalCapabilityHost({
isWindowsRenderer: isWindows,
isWebClient,
target: { kind: 'local' },
hostPlatform:
isWebClient || runtimeTarget.kind === 'local'
? windowsTerminalCapabilities.hostPlatform
: null
})
const isWindowsTerminalHost = isWindowsTerminalCapabilityHost({
isWindowsRenderer: isWindows,
isWebClient,
target: runtimeTarget,
hostPlatform: windowsTerminalCapabilities.hostPlatform
})
// Why: Settings and Cmd+J share this metadata so platform/runtime visibility
// and search entries cannot drift. Keep this hook free of Settings pane UI
@ -643,6 +661,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
buildSettingsNavigationMetadata({
isMac,
isWindows,
isLocalWindowsHost,
isWindowsTerminalHost,
isWebClient,
isDev: import.meta.env.DEV,
@ -650,6 +669,15 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
repos
}),
// oxlint-disable-next-line react-hooks/exhaustive-deps -- activeLocale is read implicitly by the translate() calls inside buildSettingsNavigationMetadata; without it the memo keeps the previous language's sections.
[isMac, isWindows, isWindowsTerminalHost, isWebClient, isLinearConnected, repos, activeLocale]
[
isMac,
isWindows,
isLocalWindowsHost,
isWindowsTerminalHost,
isWebClient,
isLinearConnected,
repos,
activeLocale
]
)
}

View File

@ -0,0 +1,166 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
resetWindowsTerminalCapabilitiesForTests,
useLocalWindowsTerminalCapabilities,
type WindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
const testState = vi.hoisted(() => ({
runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[],
runtimeStatusByEnvironmentId: new Map<string, { connectionGeneration?: number }>()
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: object) => unknown) => selector(testState)
}))
vi.mock('@/lib/web-client-location', () => ({
isWebClientLocation: () => true
}))
import {
resolveWindowsTerminalCapabilityOwnerKey,
useWindowsTerminalCapabilityOwnerKey
} from './useWindowsTerminalCapabilityOwnerKey'
describe('Windows terminal capability owner key', () => {
let root: Root
let latest: WindowsTerminalCapabilities | null
beforeEach(() => {
testState.runtimeEnvironments = [{ id: 'paired-a', createdAt: 1 }]
testState.runtimeStatusByEnvironmentId = new Map()
latest = null
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
resetWindowsTerminalCapabilitiesForTests()
document.body.replaceChildren()
vi.unstubAllGlobals()
})
it.each([
{
name: 'Windows to Linux',
firstPlatform: 'win32' as const,
firstAvailable: true,
firstDistros: ['Ubuntu'],
secondPlatform: 'linux' as const,
secondAvailable: false,
secondDistros: []
},
{
name: 'Linux to Windows',
firstPlatform: 'linux' as const,
firstAvailable: false,
firstDistros: [],
secondPlatform: 'win32' as const,
secondAvailable: true,
secondDistros: ['Debian']
}
])('re-probes the actual paired host with a null preference: $name', async (args) => {
const wslIsAvailable = vi
.fn()
.mockResolvedValueOnce(args.firstAvailable)
.mockResolvedValueOnce(args.secondAvailable)
const wslListDistros = vi
.fn()
.mockResolvedValueOnce(args.firstDistros)
.mockResolvedValueOnce(args.secondDistros)
const runtimeGetStatus = vi
.fn()
.mockResolvedValueOnce({ hostPlatform: args.firstPlatform })
.mockResolvedValueOnce({ hostPlatform: args.secondPlatform })
vi.stubGlobal('window', {
api: {
wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros },
pwsh: { isAvailable: vi.fn().mockResolvedValue(false) },
gitBash: { isAvailable: vi.fn().mockResolvedValue(false) },
runtime: { getStatus: runtimeGetStatus }
}
})
function Probe(): null {
const ownerKey = useWindowsTerminalCapabilityOwnerKey(null)
latest = useLocalWindowsTerminalCapabilities(true, false, ownerKey)
return null
}
await act(async () => {
root.render(createElement(Probe))
await Promise.resolve()
await Promise.resolve()
})
expect(latest).toMatchObject({
hostPlatform: args.firstPlatform,
wslAvailable: args.firstAvailable,
wslDistros: args.firstDistros
})
testState.runtimeEnvironments = [{ id: 'paired-b', createdAt: 2 }]
await act(async () => {
root.render(createElement(Probe))
await Promise.resolve()
await Promise.resolve()
})
expect(latest).toMatchObject({
hostPlatform: args.secondPlatform,
wslAvailable: args.secondAvailable,
wslDistros: args.secondDistros
})
expect(wslIsAvailable).toHaveBeenCalledTimes(2)
expect(runtimeGetStatus).toHaveBeenCalledTimes(2)
})
it('changes ownership for same-id re-pair and reconnect generations', () => {
const environment = { id: 'paired-a', createdAt: 1, pairingRevision: 2 }
const base = {
activeRuntimeEnvironmentId: null,
isWebClient: true,
runtimeEnvironments: [environment]
}
const first = resolveWindowsTerminalCapabilityOwnerKey({
...base,
runtimeStatusByEnvironmentId: new Map([['paired-a', { connectionGeneration: 1 }]])
})
const repaired = resolveWindowsTerminalCapabilityOwnerKey({
...base,
runtimeEnvironments: [{ ...environment, pairingRevision: 3 }],
runtimeStatusByEnvironmentId: new Map([['paired-a', { connectionGeneration: 1 }]])
})
const reconnected = resolveWindowsTerminalCapabilityOwnerKey({
...base,
runtimeStatusByEnvironmentId: new Map([['paired-a', { connectionGeneration: 2 }]])
})
expect(new Set([first, repaired, reconnected])).toHaveLength(3)
})
it('preserves desktop-local and desktop-remote owner keys', () => {
const runtimeEnvironments = [{ id: 'remote-a', createdAt: 1, pairingRevision: 2 }]
expect(
resolveWindowsTerminalCapabilityOwnerKey({
activeRuntimeEnvironmentId: null,
isWebClient: false,
runtimeEnvironments
})
).toBe('local')
expect(
resolveWindowsTerminalCapabilityOwnerKey({
activeRuntimeEnvironmentId: 'remote-a',
isWebClient: false,
runtimeEnvironments
})
).toBe('runtime:remote-a')
})
})

View File

@ -0,0 +1,51 @@
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
import { useAppStore } from '@/store'
import { getWindowsTerminalCapabilityOwnerKey } from '@/lib/windows-terminal-capabilities'
import { isWebClientLocation } from '@/lib/web-client-location'
type RuntimeCapabilityOwnerStatus = {
connectionGeneration?: number
}
type RuntimeCapabilityOwnerEnvironment = Pick<
PublicKnownRuntimeEnvironment,
'id' | 'createdAt' | 'pairingRevision'
>
export function resolveWindowsTerminalCapabilityOwnerKey(args: {
activeRuntimeEnvironmentId?: string | null
isWebClient: boolean
runtimeEnvironments: readonly RuntimeCapabilityOwnerEnvironment[]
runtimeStatusByEnvironmentId?: ReadonlyMap<string, RuntimeCapabilityOwnerStatus>
sshConnectionId?: string | null
}): string {
const activeEnvironmentId = args.activeRuntimeEnvironmentId?.trim() || null
const environment = args.isWebClient ? (args.runtimeEnvironments[0] ?? null) : null
const ownerKey = getWindowsTerminalCapabilityOwnerKey(
environment?.id ?? activeEnvironmentId,
args.sshConnectionId
)
if (!environment) {
return ownerKey
}
const pairingRevision = environment.pairingRevision ?? environment.createdAt
const connectionGeneration =
args.runtimeStatusByEnvironmentId?.get(environment.id)?.connectionGeneration ?? 0
return `${ownerKey}:pairing:${pairingRevision}:connection:${connectionGeneration}`
}
export function useWindowsTerminalCapabilityOwnerKey(
activeRuntimeEnvironmentId?: string | null,
sshConnectionId?: string | null
): string {
const isWebClient = isWebClientLocation()
return useAppStore((state) =>
resolveWindowsTerminalCapabilityOwnerKey({
activeRuntimeEnvironmentId,
isWebClient,
runtimeEnvironments: state.runtimeEnvironments ?? [],
runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId,
sshConnectionId
})
)
}

View File

@ -7,13 +7,62 @@ import {
getCachedWindowsTerminalCapabilities,
getWindowsTerminalCapabilityOwnerKey,
hasCachedWindowsTerminalCapabilities,
isWindowsTerminalCapabilityHost,
loadWindowsTerminalCapabilities,
refreshWindowsTerminalCapabilities,
resetWindowsTerminalCapabilitiesForTests,
selectWindowsTerminalCapabilitiesForOwner,
useLocalWindowsTerminalCapabilities,
useWindowsTerminalCapabilities
} from './windows-terminal-capabilities'
describe('Windows terminal capability host ownership', () => {
it.each([
{
name: 'local Windows desktop while the platform probe loads',
isWindowsRenderer: true,
isWebClient: false,
target: { kind: 'local' } as const,
hostPlatform: null,
expected: true
},
{
name: 'Windows desktop attached to remote Linux',
isWindowsRenderer: true,
isWebClient: false,
target: { kind: 'environment', environmentId: 'linux' } as const,
hostPlatform: 'linux' as const,
expected: false
},
{
name: 'Windows browser paired to a Linux server',
isWindowsRenderer: true,
isWebClient: true,
target: { kind: 'local' } as const,
hostPlatform: 'linux' as const,
expected: false
},
{
name: 'non-Windows browser paired to a Windows server',
isWindowsRenderer: false,
isWebClient: true,
target: { kind: 'local' } as const,
hostPlatform: 'win32' as const,
expected: true
},
{
name: 'non-Windows desktop attached to remote Windows',
isWindowsRenderer: false,
isWebClient: false,
target: { kind: 'environment', environmentId: 'windows' } as const,
hostPlatform: 'win32' as const,
expected: true
}
])('$name', ({ expected, ...args }) => {
expect(isWindowsTerminalCapabilityHost(args)).toBe(expected)
})
})
function stubTerminalCapabilityApi(args: {
wslAvailable: boolean
pwshAvailable: boolean
@ -509,6 +558,92 @@ describe('windows terminal capabilities', () => {
expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1)
})
it.each([
{
name: 'Windows to Linux',
firstPlatform: 'win32' as const,
firstAvailable: true,
firstDistros: ['Ubuntu'],
secondPlatform: 'linux' as const,
secondAvailable: false,
secondDistros: []
},
{
name: 'Linux to Windows',
firstPlatform: 'linux' as const,
firstAvailable: false,
firstDistros: [],
secondPlatform: 'win32' as const,
secondAvailable: true,
secondDistros: ['Debian']
}
])('re-probes the local transport when the paired owner changes: $name', async (args) => {
const wslIsAvailable = vi
.fn()
.mockResolvedValueOnce(args.firstAvailable)
.mockResolvedValueOnce(args.secondAvailable)
const wslListDistros = vi
.fn()
.mockResolvedValueOnce(args.firstDistros)
.mockResolvedValueOnce(args.secondDistros)
const runtimeGetStatus = vi
.fn()
.mockResolvedValueOnce({ hostPlatform: args.firstPlatform })
.mockResolvedValueOnce({ hostPlatform: args.secondPlatform })
vi.stubGlobal('window', {
api: {
wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros },
pwsh: { isAvailable: vi.fn().mockResolvedValue(false) },
gitBash: { isAvailable: vi.fn().mockResolvedValue(false) },
runtime: { getStatus: runtimeGetStatus }
}
})
let ownerKey = 'runtime:paired-a'
let latest: ReturnType<typeof useLocalWindowsTerminalCapabilities> | null = null
function HookProbe(): null {
latest = useLocalWindowsTerminalCapabilities(true, false, ownerKey)
return null
}
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
hookRoots.push(root)
await act(async () => {
root.render(createElement(HookProbe))
await Promise.resolve()
await Promise.resolve()
})
expect(latest).toMatchObject({
hostPlatform: args.firstPlatform,
wslAvailable: args.firstAvailable,
wslDistros: args.firstDistros
})
ownerKey = 'runtime:paired-b'
await act(async () => {
root.render(createElement(HookProbe))
await Promise.resolve()
await Promise.resolve()
})
expect(latest).toMatchObject({
hostPlatform: args.secondPlatform,
wslAvailable: args.secondAvailable,
wslDistros: args.secondDistros
})
expect(wslIsAvailable).toHaveBeenCalledTimes(2)
expect(runtimeGetStatus).toHaveBeenCalledTimes(2)
expect(getCachedWindowsTerminalCapabilities('runtime:paired-a')).toMatchObject({
hostPlatform: args.firstPlatform
})
expect(getCachedWindowsTerminalCapabilities('runtime:paired-b')).toMatchObject({
hostPlatform: args.secondPlatform
})
})
it('prunes expired runtime owner capability caches', async () => {
stubTerminalCapabilityApi({
wslAvailable: false,

View File

@ -263,6 +263,27 @@ export function useWindowsTerminalCapabilities(
return selectWindowsTerminalCapabilitiesForOwner(state, enabled, resolvedOwnerKey)
}
export function isWindowsTerminalCapabilityHost(args: {
isWindowsRenderer: boolean
isWebClient: boolean
target: WindowsTerminalCapabilityLoadTarget
hostPlatform: NodeJS.Platform | null
}): boolean {
return (
args.hostPlatform === 'win32' ||
(!args.isWebClient && args.target.kind === 'local' && args.isWindowsRenderer)
)
}
export function useLocalWindowsTerminalCapabilities(
enabled: boolean,
forceRefreshOnMount = false,
ownerKey = 'local'
): WindowsTerminalCapabilities {
// Why: desktop-owned defaults must not follow the active remote environment.
return useWindowsTerminalCapabilities(enabled, forceRefreshOnMount, ownerKey, { kind: 'local' })
}
export function resetWindowsTerminalCapabilitiesForTests(): void {
cachedCapabilitiesByOwnerKey.clear()
pendingCapabilitiesByOwnerKey.clear()