From 3e4abef0891c136bd4bedf0a6ca2b0be486bf821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E8=BE=89?= <42717106+OnlyYu1996@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:58:55 +0800 Subject: [PATCH] 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> --- .../agent/AgentSettingsDialog.test.tsx | 247 ++++++++++++++++++ .../components/agent/AgentSettingsDialog.tsx | 36 +-- .../src/components/settings/Settings.tsx | 65 +++-- ...vigationMetadata.capability-owner.test.tsx | 134 ++++++++++ .../useSettingsNavigationMetadata.test.ts | 48 ++++ .../hooks/useSettingsNavigationMetadata.ts | 42 ++- ...WindowsTerminalCapabilityOwnerKey.test.tsx | 166 ++++++++++++ .../useWindowsTerminalCapabilityOwnerKey.ts | 51 ++++ .../lib/windows-terminal-capabilities.test.ts | 135 ++++++++++ .../src/lib/windows-terminal-capabilities.ts | 21 ++ 10 files changed, 904 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/components/agent/AgentSettingsDialog.test.tsx create mode 100644 src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx create mode 100644 src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx create mode 100644 src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts diff --git a/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx b/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx new file mode 100644 index 00000000000..9c53f03f7b7 --- /dev/null +++ b/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx @@ -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 | 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) => { + 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 + localWslDistros: ReturnType + runtimeGetStatus: ReturnType + runtimeEnvironmentCall: ReturnType +} { + 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) + }) +}) diff --git a/src/renderer/src/components/agent/AgentSettingsDialog.tsx b/src/renderer/src/components/agent/AgentSettingsDialog.tsx index 637ee48e897..f67556b3325 100644 --- a/src/renderer/src/components/agent/AgentSettingsDialog.tsx +++ b/src/renderer/src/components/agent/AgentSettingsDialog.tsx @@ -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} /> diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 4ebacbb93e4..51c26f8c5ab 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -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 { ) : null} @@ -1211,7 +1240,7 @@ function Settings(): React.JSX.Element { ) : null} diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx b/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx new file mode 100644 index 00000000000..1cb259a8578 --- /dev/null +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx @@ -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() + }) +}) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index 4878f3cd94d..fe4dc94da12 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -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() diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 8935eba63b7..df84c7ef0c7 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -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 + ] ) } diff --git a/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx new file mode 100644 index 00000000000..0b8a177016f --- /dev/null +++ b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx @@ -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() +})) + +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') + }) +}) diff --git a/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts new file mode 100644 index 00000000000..8f8cfccfc98 --- /dev/null +++ b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts @@ -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 + 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 + }) + ) +} diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 18f00b5b44c..713ddfb7902 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -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 | 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, diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index ba8da74c86a..37c888f41dc 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -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()