diff --git a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts index 394bcac8123..0fa2c9b67f5 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts @@ -12,7 +12,7 @@ import { openSourceControlAiSettingsTarget } from '@/components/right-sidebar/so import { getConnectionId } from '@/lib/connection-context' import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch' import { readSourceControlLaunchRecipeAgentId } from '../../../../shared/source-control-launch-agent-selection' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { resolveRepoWorktreeLaunchPlatform } from '@/lib/execution-host-facts' import { useAppStore } from '@/store' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai' @@ -147,10 +147,15 @@ export function useCheckRunDetailsFixWithAI(args: { const connectionId = args.worktreeId ? (getConnectionId(args.worktreeId) ?? repo?.connectionId ?? null) : null - const launchPlatform = resolveSourceControlLaunchPlatform({ - connectionId, - worktreePath: worktree?.path ?? null - }) + // Why: the fix agent runs on the workspace's execution host, so its OS decides quoting (#27971). + const launchPlatform = + args.worktreeId && repo + ? resolveRepoWorktreeLaunchPlatform( + useAppStore.getState(), + repo, + worktree ?? { id: args.worktreeId } + ) + : undefined const fixChecksRecipe = useMemo( () => resolveSourceControlActionRecipe({ diff --git a/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx b/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx index f43c6f6be53..0759c82442c 100644 --- a/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx +++ b/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx @@ -22,7 +22,8 @@ export function ChecksFixDialog({ fixChecksComposerPrompt: string | null setFixChecksComposerPrompt: (value: string | null) => void fixChecksRecipe: SourceControlActionRecipe - fixChecksLaunchPlatform: NodeJS.Platform + /** Unset when the repo's host has not reported its OS; the launch then reports why. */ + fixChecksLaunchPlatform: NodeJS.Platform | undefined repoConnectionId: string | null targetRepoId: string | null item: GitHubWorkItem diff --git a/src/renderer/src/components/pull-request-page/checks/tab.tsx b/src/renderer/src/components/pull-request-page/checks/tab.tsx index 0c12d91ae7e..9b94d3b3e08 100644 --- a/src/renderer/src/components/pull-request-page/checks/tab.tsx +++ b/src/renderer/src/components/pull-request-page/checks/tab.tsx @@ -23,7 +23,7 @@ import { } from '@/lib/github-source-runtime-context' import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { resolveRepoExecutionHostPlatform } from '@/lib/execution-host-facts' import { resolveSourceControlActionRecipe } from '../../../../../shared/source-control-ai' import type { GitHubWorkItem, @@ -119,29 +119,42 @@ export function ChecksTab({ }), [repo, settings] ) - const fixChecksLaunchPlatform = useMemo( - () => - resolveSourceControlLaunchPlatform({ - connectionId: repo?.connectionId ?? null, - worktreePath: repo?.path ?? null, - projectRuntime: repo?.connectionId - ? undefined - : getLocalRepoProjectExecutionRuntimeContext( - // Why: repo-scoped resolution only reads repos/projects/settings, and subscribing keeps the launch platform fresh. - { - activeRepoId: null, - activeWorktreeId: null, - projects, - repos, - settings, - worktreesByRepo: {} - }, - repo?.id, - CLIENT_PLATFORM - ) - }), - [projects, repo?.connectionId, repo?.id, repo?.path, repos, settings] - ) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const sshStateByEnvironment = useAppStore((s) => s.sshStateByEnvironment) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + // Why: the fix agent runs on the repo's execution host, so its OS decides quoting (#27971). + const fixChecksLaunchPlatform = useMemo(() => { + if (!repo) { + return undefined + } + const fact = resolveRepoExecutionHostPlatform( + { sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId }, + repo, + () => + getLocalRepoProjectExecutionRuntimeContext( + // Why: repo-scoped resolution only reads repos/projects/settings, and subscribing keeps the launch platform fresh. + { + activeRepoId: null, + activeWorktreeId: null, + projects, + repos, + settings, + worktreesByRepo: {} + }, + repo.id, + CLIENT_PLATFORM + ) + ) + return fact.kind === 'known' ? fact.platform : undefined + }, [ + projects, + repo, + repos, + runtimeStatusByEnvironmentId, + settings, + sshConnectionStates, + sshStateByEnvironment + ]) // Why: parses a fresh host object per call, so memoize to keep the check action callbacks stable. const runtimeHost = useMemo(() => getGitHubSourceRuntimeHost(sourceContext), [sourceContext]) const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx index c69dfeb608c..f28414592cd 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx @@ -151,6 +151,9 @@ function resetState(overrides: Partial> = {}): void { groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] }, repos: [mocks.activeRepo], worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] }, + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + runtimeStatusByEnvironmentId: new Map(), rightSidebarOpen: false, rightSidebarTab: 'source-control', gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx index fb54b599386..71c3ce6b703 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx @@ -8,8 +8,7 @@ import { readDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inve import { useMountedRef } from '@/hooks/useMountedRef' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { getWorktreeGitIdentityDisplay } from '../../../../../shared/worktree/git-identity-display' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { resolveRepoWorktreeLaunchPlatform } from '@/lib/execution-host-facts' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { normalizeGlobalWindowsRuntimeDefault } from '../../../../../shared/project-execution-runtime' import { @@ -218,13 +217,11 @@ export function useChecksPanelControllerState() { const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : '' const activeWorktreePath = activeWorktree?.path ?? null const activeWorktreePushTarget = activeWorktree?.pushTarget ?? null - const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({ - connectionId: activeConnectionId, - worktreePath: activeWorktreePath, - projectRuntime: activeConnectionId - ? undefined - : getLocalProjectExecutionRuntimeContext(useAppStore.getState(), activeWorktreeId) - }) + // Why: agents run on the workspace's execution host, so its OS decides quoting (#27971). + const activeSourceControlLaunchPlatform = + activeWorktree && repo + ? resolveRepoWorktreeLaunchPlatform(useAppStore.getState(), repo, activeWorktree) + : undefined const runtimeEnvironmentId = useAppStore((s) => getRuntimeEnvironmentIdForWorktree(s, activeWorktreeId) ) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts b/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts index 32f00e18e24..c7721c631f6 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/controller-types.ts @@ -19,7 +19,7 @@ export type SourceControlAiControllerParams = { activeWorktreeId: string | null | undefined activeConnectionId: string | null | undefined activeGroupId: string | null | undefined - activeSourceControlLaunchPlatform: NodeJS.Platform + activeSourceControlLaunchPlatform: NodeJS.Platform | undefined conflictOperation: GitConflictOperation unresolvedConflicts: Pick[] stagedEntries: Pick[] diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index a16e3723073..ac7c96ef060 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -90,7 +90,7 @@ export async function launchSourceControlRecoveryAgentWithDefault({ }: { activeWorktreeId: string activeGroupId: string | null | undefined - activeSourceControlLaunchPlatform: NodeJS.Platform + activeSourceControlLaunchPlatform: NodeJS.Platform | undefined sourceRepoConnectionId?: string | null actionId: SourceControlLaunchActionId basePrompt: string | null diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts index 3ee965b7ef8..e9268ed12b6 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts @@ -15,7 +15,7 @@ import type { SourceControlAiStoreSnapshot } from './controller-types' type SourceControlRecoveryAiParams = { activeWorktreeId: string | null | undefined activeGroupId: string | null | undefined - activeSourceControlLaunchPlatform: NodeJS.Platform + activeSourceControlLaunchPlatform: NodeJS.Platform | undefined sourceRepoConnectionId?: string | null worktreePath: string | null commitMessage: string diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts index 2501e3aaec4..7f234a8915c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts @@ -1,8 +1,7 @@ import { useMemo } from 'react' import { getConnectionId } from '@/lib/connection-context' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { ipcOrRuntimeTargetForHostId } from '@/runtime/runtime-client-target' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { resolveRepoWorktreeLaunchPlatform } from '@/lib/execution-host-facts' import { getWorktreeGitIdentityDisplay } from '../../../../../../shared/worktree/git-identity-display' import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors' @@ -129,13 +128,11 @@ export function useSourceControlWorktreeContext() { const activeConnectionId = activeWorktreeId ? (getConnectionId(activeWorktreeId) ?? activeRepoConnectionId) : null - const activeSourceControlLaunchPlatform = resolveSourceControlLaunchPlatform({ - connectionId: activeConnectionId, - worktreePath, - projectRuntime: activeConnectionId - ? undefined - : getLocalProjectExecutionRuntimeContext(useAppStore.getState(), activeWorktreeId) - }) + // Why: agents run on the workspace's execution host, so its OS decides quoting (#27971). + const activeSourceControlLaunchPlatform = + activeWorktree && activeRepo + ? resolveRepoWorktreeLaunchPlatform(useAppStore.getState(), activeRepo, activeWorktree) + : undefined // Why: the sidebar stays mounted when closed, so gate polling on tab AND open or branchCompare/PR fetch would run with no visible consumer. const isBranchVisible = rightSidebarTab === 'source-control' && rightSidebarOpen const hasUncommittedEntries = entries.length > 0 diff --git a/src/renderer/src/lib/execution-host-facts.test.ts b/src/renderer/src/lib/execution-host-facts.test.ts new file mode 100644 index 00000000000..69e703e18a5 --- /dev/null +++ b/src/renderer/src/lib/execution-host-facts.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import { resolveRepoWorktreeLaunchPlatform } from './execution-host-facts' + +vi.mock('./new-workspace', async (importOriginal) => ({ + ...(await importOriginal>()), + CLIENT_PLATFORM: 'win32' +})) + +type State = Parameters[0] + +function linuxServer(): Map { + return new Map([ + [ + 'linux-box', + { + checkedAt: 1, + status: { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + hostPlatform: 'linux' + } + } + ] + ]) +} + +function state(): State { + return { + repos: [], + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + projects: [], + activeRepoId: null, + activeWorktreeId: null, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads no other setting. + settings: {} as State['settings'], + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + runtimeStatusByEnvironmentId: linuxServer() + } +} + +const serverRepo = { path: '/srv/repo', executionHostId: 'runtime:linux-box' } + +describe('resolveRepoWorktreeLaunchPlatform from a Windows client', () => { + it("reads a server project's worktree as the server's OS (#27971)", () => { + expect( + resolveRepoWorktreeLaunchPlatform(state(), serverRepo, { id: 'wt', path: '/srv/wt' }) + ).toBe('linux') + }) + + it("lets a worktree's own host outrank its repo's", () => { + expect( + resolveRepoWorktreeLaunchPlatform(state(), serverRepo, { + id: 'wt', + path: 'C:\\wt', + hostId: 'local' + }) + ).toBe('win32') + }) + + it("reads a server-projected SSH worktree through that server's connection", () => { + const nested = { + ...state(), + sshStateByEnvironment: new Map([ + [ + 'linux-box', + { + connectionStates: new Map([ + [ + 'box', + { + targetId: 'box', + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + remotePlatform: 'linux' as const + } + ] + ]), + targetLabels: new Map(), + targetGenerations: new Map(), + removedTargetLabels: new Map(), + targetsHydrated: true + } + ] + ]) + } + expect( + resolveRepoWorktreeLaunchPlatform(nested, serverRepo, { + id: 'wt', + path: '/home/a/wt', + hostId: 'ssh:box', + runtimeOwnerEnvironmentId: 'linux-box' + }) + ).toBe('linux') + }) + + it('is undefined when the server has not reported its OS', () => { + const withheld = { ...state(), runtimeStatusByEnvironmentId: new Map() } + expect( + resolveRepoWorktreeLaunchPlatform(withheld, serverRepo, { id: 'wt', path: '/srv/wt' }) + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/execution-host-facts.ts b/src/renderer/src/lib/execution-host-facts.ts index d6635931161..5ef43967c7f 100644 --- a/src/renderer/src/lib/execution-host-facts.ts +++ b/src/renderer/src/lib/execution-host-facts.ts @@ -115,18 +115,22 @@ function resolveHostPlatform( return { kind: 'known', platform, hostKind: 'runtime' } } -/** Platform of the host that runs agents for a repo that has no workspace yet. */ +/** + * Platform of the host that runs agents for a repo's workspaces, read from the repo row's owner. + * `workspacePath` names a workspace on that host (e.g. a WSL worktree); it defaults to the repo. + */ export function resolveRepoExecutionHostPlatform( state: HostFactsState, repo: { connectionId?: string | null; executionHostId?: string | null; path: string }, - projectRuntime: () => ProjectExecutionRuntimeResolution | undefined + projectRuntime: () => ProjectExecutionRuntimeResolution | undefined, + workspacePath: string | null | undefined = repo.path ): ExecutionHostPlatformFact { const host = parseExecutionHostId(getRepoExecutionHostId(repo)) return resolveHostPlatform(state, { host, nestedSshTargetId: host?.kind === 'runtime' ? repo.connectionId?.trim() || null : null, sshOwnerEnvironmentId: null, - workspacePath: repo.path, + workspacePath, projectRuntime }) } @@ -150,6 +154,50 @@ export function resolveWorktreeExecutionHostPlatform( }) } +export type KnownExecutionHostPlatform = Extract + +/** A caller's platform only refines a local launch (e.g. WSL); a remote host's own OS wins. */ +export function refineLocalLaunchPlatform( + fact: KnownExecutionHostPlatform, + callerPlatform: NodeJS.Platform | undefined +): KnownExecutionHostPlatform { + return fact.hostKind === 'local' && callerPlatform ? { ...fact, platform: callerPlatform } : fact +} + +/** + * Preflight platform for a worktree's agent launch: the worktree's own host when its row names + * one, else its repo's owner. `undefined` when withheld: the launch itself resolves the host and + * tells the user why it stopped. + */ +export function resolveRepoWorktreeLaunchPlatform( + state: WorktreeHostFactsState, + repo: { connectionId?: string | null; executionHostId?: string | null; path: string }, + worktree: { + id: string + path?: string | null + hostId?: string | null + runtimeOwnerEnvironmentId?: string | null + } +): NodeJS.Platform | undefined { + const repoHost = parseExecutionHostId(getRepoExecutionHostId(repo)) + const ownHost = parseExecutionHostId(worktree.hostId) + // Why: a per-worktree host (e.g. a local checkout of a server project) outranks the repo's. + const host = ownHost && ownHost.id !== repoHost?.id ? ownHost : repoHost + const serverOwner = + worktree.runtimeOwnerEnvironmentId?.trim() || + (repoHost?.kind === 'runtime' ? repoHost.environmentId : null) + const fact = resolveHostPlatform(state, { + host, + nestedSshTargetId: + host?.kind === 'runtime' && host === repoHost ? repo.connectionId?.trim() || null : null, + // Why: a server-projected SSH worktree is reached through that server's own connection. + sshOwnerEnvironmentId: host?.kind === 'ssh' ? serverOwner : null, + workspacePath: worktree.path ?? repo.path, + projectRuntime: () => getLocalProjectExecutionRuntimeContext(state, worktree.id) + }) + return fact.kind === 'known' ? fact.platform : undefined +} + export function requireExecutionHostPlatform(fact: ExecutionHostPlatformFact): NodeJS.Platform { if (fact.kind === 'withheld') { throw new Error(fact.reason) diff --git a/src/renderer/src/lib/fix-checks-agent-launch.test.ts b/src/renderer/src/lib/fix-checks-agent-launch.test.ts index 9d1c71bfaf9..0b18fa505d5 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.test.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ExecutionHostFactsModule from '@/lib/execution-host-facts' const mocks = vi.hoisted(() => { const store = { @@ -54,7 +55,7 @@ const mocks = vi.hoisted(() => { pickSourceControlLaunchAgent: vi.fn(), readSourceControlLaunchRecipeAgentId: vi.fn(), resolveSourceControlActionRecipe: vi.fn(), - resolveSourceControlLaunchPlatform: vi.fn(), + resolveRepoWorktreeLaunchPlatform: vi.fn(), toastError: vi.fn(), toastMessage: vi.fn() } @@ -102,9 +103,15 @@ vi.mock('../../../shared/source-control-launch-agent-selection', () => ({ readSourceControlLaunchRecipeAgentId: mocks.readSourceControlLaunchRecipeAgentId })) -vi.mock('@/lib/source-control-launch-platform', () => ({ - resolveSourceControlLaunchPlatform: mocks.resolveSourceControlLaunchPlatform -})) +vi.mock('@/lib/execution-host-facts', async () => { + const actual = await vi.importActual( + '@/lib/execution-host-facts' + ) + return { + ...actual, + resolveRepoWorktreeLaunchPlatform: mocks.resolveRepoWorktreeLaunchPlatform + } +}) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: mocks.activateAndRevealWorktree @@ -158,7 +165,7 @@ describe('startFixChecksAgent', () => { mocks.resolveSourceControlActionRecipe.mockReturnValue({ commandInputTemplate: '{basePrompt}' }) - mocks.resolveSourceControlLaunchPlatform.mockReturnValue('darwin') + mocks.resolveRepoWorktreeLaunchPlatform.mockReturnValue('darwin') }) it('activates the attached workspace as a surface-providing caller', async () => { @@ -230,7 +237,7 @@ describe('startFixChecksAgent', () => { expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() }) - it('falls back to the repo connection when an attached workspace lookup is unresolved', async () => { + it("resolves the attached workspace's own execution host", async () => { mocks.store.repos = [ { ...mocks.store.repos[0], @@ -250,44 +257,15 @@ describe('startFixChecksAgent', () => { ).resolves.toBe(true) expect(mocks.store.ensureRemoteDetectedAgents).toHaveBeenCalledWith('ssh-1') - expect(mocks.resolveSourceControlLaunchPlatform).toHaveBeenCalledWith({ - connectionId: 'ssh-1', - worktreePath: '/repo/wt-1', - projectRuntime: undefined - }) + expect(mocks.resolveRepoWorktreeLaunchPlatform).toHaveBeenCalledWith( + mocks.store, + expect.objectContaining({ id: 'repo-1', connectionId: 'ssh-1' }), + expect.objectContaining({ id: 'wt-1', path: '/repo/wt-1' }) + ) }) - it('passes the local project runtime when resolving an attached WSL workspace launch platform', async () => { - mocks.store.repos = [ - { - ...mocks.store.repos[0], - path: 'C:\\Users\\alice\\repo' - } - ] - mocks.store.worktrees = [ - { - id: 'wt-1', - repoId: 'repo-1', - path: 'C:\\Users\\alice\\repo-worktree' - } - ] - mocks.store.worktreesByRepo = { 'repo-1': mocks.store.worktrees } - mocks.store.projects = [ - { - id: 'repo-1', - displayName: 'Repo', - badgeColor: '#000000', - sourceRepoIds: ['repo-1'], - createdAt: 1, - updatedAt: 1, - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - ] - mocks.resolveSourceControlLaunchPlatform.mockImplementation(({ projectRuntime }) => - projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl' - ? 'linux' - : 'win32' - ) + it("plans a Linux server's launch for that server from a Windows client (#27971)", async () => { + mocks.resolveRepoWorktreeLaunchPlatform.mockReturnValue('linux') const { startFixChecksAgent } = await import('./fix-checks-agent-launch') await expect( @@ -299,21 +277,13 @@ describe('startFixChecksAgent', () => { }) ).resolves.toBe(true) - expect(mocks.resolveSourceControlLaunchPlatform).toHaveBeenCalledWith({ - connectionId: null, - worktreePath: 'C:\\Users\\alice\\repo-worktree', - projectRuntime: expect.objectContaining({ - status: 'resolved', - runtime: expect.objectContaining({ kind: 'wsl', distro: 'Ubuntu' }) - }) - }) expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( expect.objectContaining({ launchPlatform: 'linux' }) ) }) - it('fails without launching when the launch platform cannot be resolved', async () => { - mocks.resolveSourceControlLaunchPlatform.mockReturnValue(undefined) + it('fails without launching when the execution host has not reported its OS', async () => { + mocks.resolveRepoWorktreeLaunchPlatform.mockReturnValue(undefined) const { startFixChecksAgent } = await import('./fix-checks-agent-launch') await expect( diff --git a/src/renderer/src/lib/fix-checks-agent-launch.ts b/src/renderer/src/lib/fix-checks-agent-launch.ts index d5f04bfae68..2c6e3632802 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.ts @@ -1,5 +1,4 @@ import { toast } from 'sonner' -import { getConnectionId } from '@/lib/connection-context' import { ensureDetectedAgentsForRepo, ensureDetectedAgentsForWorktree, @@ -10,14 +9,12 @@ import { findGithubPrWorkspaceAttachment } from '@/lib/github-work-item-workspac import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { pickSourceControlLaunchAgent, readSourceControlLaunchRecipeAgentId } from '../../../shared/source-control-launch-agent-selection' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { resolveRepoWorktreeLaunchPlatform } from '@/lib/execution-host-facts' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '@/store' import { resolveSourceControlActionRecipe } from '../../../shared/source-control-ai' @@ -158,18 +155,17 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis ) return false } - const targetConnectionId = getConnectionId(targetWorktreeId) ?? repo?.connectionId ?? null const agent = await pickExistingWorktreeAgent(targetWorktreeId, savedAgentId) if (!agent) { return false } - const launchPlatform = resolveSourceControlLaunchPlatform({ - connectionId: targetConnectionId, - worktreePath: targetWorktree.path, - projectRuntime: targetConnectionId - ? undefined - : getLocalProjectExecutionRuntimeContext(store, targetWorktreeId, CLIENT_PLATFORM) - }) + // Why: the agent runs on the workspace's execution host, so its OS decides quoting (#27971). + const latestStore = useAppStore.getState() + const targetRepo = + latestStore.repos.find((candidate) => candidate.id === targetWorktree.repoId) ?? repo + const launchPlatform = targetRepo + ? resolveRepoWorktreeLaunchPlatform(latestStore, targetRepo, targetWorktree) + : undefined if (!launchPlatform) { toast.error( translate( diff --git a/src/renderer/src/lib/launch-agent-execution-context.ts b/src/renderer/src/lib/launch-agent-execution-context.ts index 0ec03f3cdd3..76ae016082c 100644 --- a/src/renderer/src/lib/launch-agent-execution-context.ts +++ b/src/renderer/src/lib/launch-agent-execution-context.ts @@ -2,6 +2,7 @@ import { toast } from 'sonner' import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' import { getConnectionIdFromState } from '@/lib/connection-context' import { + refineLocalLaunchPlatform, resolveExecutionHostAgentStartupShell, resolveWorktreeExecutionHostPlatform } from '@/lib/execution-host-facts' @@ -37,15 +38,13 @@ export function resolveAgentLaunchExecutionContext( toast.error(fact.reason) return null } - // Why: a caller's platform only refines a local launch (e.g. WSL); a remote host's own OS wins. - const resolvedLaunchPlatform = - fact.hostKind === 'local' && args.launchPlatform ? args.launchPlatform : fact.platform + const launchHost = refineLocalLaunchPlatform(fact, args.launchPlatform) return { worktreeSshConnectionId, - resolvedLaunchPlatform, + resolvedLaunchPlatform: launchHost.platform, isRemote, queuedShell: resolveExecutionHostAgentStartupShell( - { ...fact, platform: resolvedLaunchPlatform }, + launchHost, store.settings?.terminalWindowsShell ) } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index d5abd264a3f..3033e3f4b20 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -4,7 +4,10 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../../shared/tui-agent-selection' import { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent' import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { + resolveExecutionHostAgentStartupShell, + type KnownExecutionHostPlatform +} from '@/lib/execution-host-facts' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import type { DeclinedStructuredLaunchTerminalOptions } from '@/lib/structured-agent-session-launch-admission' import { @@ -18,25 +21,21 @@ export function buildDirectWorkItemStartup(args: { draftContent: string promptDelivery: PromptDelivery settings: AppState['settings'] - launchPlatform?: NodeJS.Platform + /** The workspace's execution host; its OS and shell decide quoting, not this client's. */ + launchHost: KnownExecutionHostPlatform + /** This client's Windows shell, which only shapes a local launch. */ + terminalWindowsShell: string | null | undefined launchConnectionId: string | null - worktreePath: string - repoProjectRuntime?: Parameters[0]['projectRuntime'] }): ReturnType { - const launchPlatform = - args.launchPlatform ?? - resolveSourceControlLaunchPlatform({ - connectionId: args.launchConnectionId, - worktreePath: args.worktreePath, - projectRuntime: args.repoProjectRuntime - }) + const shell = resolveExecutionHostAgentStartupShell(args.launchHost, args.terminalWindowsShell) return buildDirectWorkItemAgentStartupPlan({ agent: args.agent, agentArgs: args.agentArgs, draftContent: args.draftContent, promptDelivery: args.promptDelivery, settings: args.settings, - launchPlatform, + launchPlatform: args.launchHost.platform, + ...(shell ? { shell } : {}), // Why: SSH hosts run the plain `orca` shim, so the Linux-only `orca-ide` rename is not applied. isRemote: typeof args.launchConnectionId === 'string' }) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index b9175034e7b..f55bcf3cadf 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -10,6 +10,7 @@ import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-re import type { LaunchSource } from '../../../shared/telemetry-events' import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' import type { TuiAgent } from '../../../shared/tui-agent' +import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -30,6 +31,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: { | null | undefined launchPlatform: NodeJS.Platform + /** Unset keeps the platform default: PowerShell on win32, POSIX elsewhere. */ + shell?: AgentStartupShell /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must not be applied for remote launches. */ isRemote?: boolean @@ -55,6 +58,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { draft: args.draftContent, cmdOverrides: args.settings?.agentCmdOverrides ?? {}, platform: args.launchPlatform, + ...(args.shell ? { shell: args.shell } : {}), isRemote: args.isRemote, agentArgs: effectiveAgentArgs, agentEnv: effectiveAgentEnv @@ -83,6 +87,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { prompt: '', cmdOverrides: args.settings?.agentCmdOverrides ?? {}, platform: args.launchPlatform, + ...(args.shell ? { shell: args.shell } : {}), isRemote: args.isRemote, agentArgs: effectiveAgentArgs, agentEnv: effectiveAgentEnv, diff --git a/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts b/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts new file mode 100644 index 00000000000..3d9494de987 --- /dev/null +++ b/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import type { SshConnectionState } from '../../../shared/ssh-types' +import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup' + +const mocks = vi.hoisted(() => { + // Why declared: an inferred literal `'win32'` would reject the other client platforms. + const mutable: { clientPlatform: NodeJS.Platform; store: Record } = { + clientPlatform: 'win32', + store: {} + } + return { + ...mutable, + toastError: vi.fn(), + createWorktree: vi.fn(), + activateAndRevealWorktree: vi.fn() + } +}) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.store } })) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) +vi.mock('@/lib/ensure-hooks-confirmed', () => ({ + ensureHooksConfirmed: vi.fn().mockResolvedValue('run') +})) +vi.mock('@/lib/launch-work-item-direct-preflight', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveDirectSetupDecision: vi.fn().mockResolvedValue({ kind: 'resolved', decision: 'inherit' }) +})) +vi.mock('@/lib/agent-detection-target-inventory', () => ({ + ensureDetectedAgentsForRepo: vi.fn().mockResolvedValue(['claude']), + ensureDetectedAgentsForNewWorktree: vi.fn().mockResolvedValue(['claude']) +})) +vi.mock('@/lib/agent-session-launch-plan', () => ({ + planAgentSessionLaunch: vi.fn(() => ({ route: 'terminal' })) +})) +vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ + deliverLaunchPromptToAgentTab: vi.fn(), + seedNativeChatLaunchDraftForAgentTab: vi.fn() +})) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: (a: string) => a })) +vi.mock('@/lib/new-workspace', async (importOriginal) => ({ + ...(await importOriginal>()), + get CLIENT_PLATFORM() { + return mocks.clientPlatform + } +})) +vi.mock('@/lib/tui-agent-startup', async () => { + const actual = await vi.importActual('@/lib/tui-agent-startup') + return { ...actual, buildAgentDraftLaunchPlan: vi.fn(actual.buildAgentDraftLaunchPlan) } +}) + +import { launchWorkItemDirect } from './launch-work-item-direct' +import { buildAgentDraftLaunchPlan } from '@/lib/tui-agent-startup' + +function runtimeStatus(hostPlatform?: NodeJS.Platform): RuntimeEnvironmentStatus { + return { + checkedAt: 1, + status: { + runtimeId: 'rt', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + ...(hostPlatform ? { hostPlatform } : {}) + } + } +} + +function sshState(remotePlatform: 'linux' | 'win32'): SshConnectionState { + return { targetId: 'box', status: 'connected', error: null, reconnectAttempt: 0, remotePlatform } +} + +type HostCase = { + name: string + client: NodeJS.Platform + repo: { path: string; connectionId?: string; executionHostId?: string } + ssh?: Map + runtime?: Map + expected: NodeJS.Platform +} + +function setStore(hostCase: HostCase): void { + mocks.clientPlatform = hostCase.client + mocks.store = { + repos: [{ id: 'repo-1', displayName: 'Repo', addedAt: 1, ...hostCase.repo }], + activeRepoId: null, + activeWorktreeId: null, + projects: [], + projectGroups: [], + folderWorkspaces: [], + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + settings: { defaultTuiAgent: 'claude', disabledTuiAgents: [], agentCmdOverrides: {} }, + sshConnectionStates: hostCase.ssh ?? new Map(), + sshStateByEnvironment: new Map(), + runtimeStatusByEnvironmentId: hostCase.runtime ?? new Map(), + createWorktree: mocks.createWorktree, + updateWorktreeMeta: vi.fn().mockResolvedValue(undefined), + setSidebarOpen: vi.fn() + } +} + +function startLinearIssue(): Promise { + return launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + openModalFallback: vi.fn(), + agentOverride: 'claude', + item: { + type: 'issue', + number: null, + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-1/ship-it', + linearIdentifier: 'ENG-1' + } + }) +} + +const HOST_CASES: HostCase[] = [ + { + name: 'Windows client -> paired Linux Orca server', + client: 'win32', + repo: { path: '/srv/repo', executionHostId: 'runtime:linux-box' }, + runtime: new Map([['linux-box', runtimeStatus('linux')]]), + expected: 'linux' + }, + { + name: 'Windows client -> Linux SSH host', + client: 'win32', + repo: { path: '/home/alice/repo', connectionId: 'box' }, + ssh: new Map([['box', sshState('linux')]]), + expected: 'linux' + }, + { + name: 'Linux client -> paired Windows Orca server', + client: 'linux', + repo: { path: 'D:\\repo', executionHostId: 'runtime:win-box' }, + runtime: new Map([['win-box', runtimeStatus('win32')]]), + expected: 'win32' + }, + { + name: 'macOS client -> Windows SSH host', + client: 'darwin', + repo: { path: 'C:\\Users\\alice\\repo', connectionId: 'box' }, + ssh: new Map([['box', sshState('win32')]]), + expected: 'win32' + }, + { + name: 'Linux client -> paired Linux Orca server', + client: 'linux', + repo: { path: '/srv/repo', executionHostId: 'runtime:linux-box' }, + runtime: new Map([['linux-box', runtimeStatus('linux')]]), + expected: 'linux' + }, + { + name: 'Windows client -> paired Windows Orca server', + client: 'win32', + repo: { path: 'D:\\repo', executionHostId: 'runtime:win-box' }, + runtime: new Map([['win-box', runtimeStatus('win32')]]), + expected: 'win32' + }, + { name: 'local Linux', client: 'linux', repo: { path: '/repo' }, expected: 'linux' }, + { name: 'local Windows', client: 'win32', repo: { path: 'C:\\repo' }, expected: 'win32' } +] + +describe('launchWorkItemDirect quotes for the execution host (#27971)', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createWorktree.mockResolvedValue({ + worktree: { id: 'repo-1::/wt', path: '/wt' }, + setup: undefined + }) + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'tab-1' }) + }) + + it.each(HOST_CASES)('$name', async (hostCase) => { + setStore(hostCase) + + await expect(startLinearIssue()).resolves.toBe(true) + + expect(buildAgentDraftLaunchPlan).toHaveBeenCalledWith( + expect.objectContaining({ platform: hostCase.expected }) + ) + const command: string = mocks.activateAndRevealWorktree.mock.calls[0]?.[1]?.startup?.command + expect(command).toContain('Linked Linear issue: ENG-1') + if (hostCase.expected === 'win32') { + expect(command).toContain('`n') + } else { + // Why: a PowerShell "`n" draft leaves bash waiting at its continuation prompt. + expect(command).not.toContain('`n') + } + }) + + it("keeps the server's OS when the created worktree is not hydrated yet", async () => { + setStore(HOST_CASES[0]) + mocks.createWorktree.mockResolvedValue({ worktree: { id: 'wt-new', path: '/wt' } }) + + await expect(startLinearIssue()).resolves.toBe(true) + + expect(buildAgentDraftLaunchPlan).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'linux' }) + ) + }) + + it("quotes a Windows server's new WSL worktree for that distro", async () => { + setStore(HOST_CASES[5]) + mocks.createWorktree.mockResolvedValue({ + worktree: { id: 'wt-wsl', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\wt' } + }) + + await expect(startLinearIssue()).resolves.toBe(true) + + expect(buildAgentDraftLaunchPlan).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'linux' }) + ) + }) + + it("quotes a local Windows project's WSL runtime for that distro", async () => { + setStore({ name: 'wsl', client: 'win32', repo: { path: 'C:\\repo' }, expected: 'linux' }) + mocks.store.projects = [ + { + id: 'repo-1', + displayName: 'Repo', + badgeColor: '#000000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + ] + mocks.createWorktree.mockResolvedValue({ + worktree: { id: 'repo-1::C:\\repo-wt', path: 'C:\\repo-wt' } + }) + + await expect(startLinearIssue()).resolves.toBe(true) + + expect(buildAgentDraftLaunchPlan).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'linux' }) + ) + }) + + it("applies this client's Windows shell only to a local launch", async () => { + setStore({ name: 'local', client: 'win32', repo: { path: 'C:\\repo' }, expected: 'win32' }) + mocks.store.settings = { + defaultTuiAgent: 'claude', + agentCmdOverrides: {}, + terminalWindowsShell: 'cmd.exe' + } + + await expect(startLinearIssue()).resolves.toBe(true) + + expect(buildAgentDraftLaunchPlan).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'win32', shell: 'cmd' }) + ) + }) + + it('stops before creating a workspace when the server has not reported its OS', async () => { + setStore({ + name: 'unreported', + client: 'win32', + repo: { path: '/srv/repo', executionHostId: 'runtime:linux-box' }, + runtime: new Map([['linux-box', runtimeStatus()]]), + expected: 'linux' + }) + + await expect(startLinearIssue()).resolves.toBe(false) + + expect(mocks.createWorktree).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledWith( + 'This Orca server does not report its operating system. Update the server to launch agents on it.' + ) + }) +}) diff --git a/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts b/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts index aea09f69b40..6dc2a0e7098 100644 --- a/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-owner-row.test.ts @@ -40,6 +40,12 @@ describe('launchWorkItemDirect owner row', () => { mocks.store = { repos: [], worktreesByRepo: {}, + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + // Why: a launch stops before creating anything on a server that has not reported its OS. + runtimeStatusByEnvironmentId: new Map([ + ['env-b', { checkedAt: 1, status: { hostPlatform: 'linux' } }] + ]), settings: {}, ensureDetectedAgents: vi.fn().mockResolvedValue([]), ensureRemoteDetectedAgents: vi.fn().mockResolvedValue([]), diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 9dbc6ea0aa4..5d00b080de4 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -2,8 +2,16 @@ import type { TuiAgent } from '../../../shared/tui-agent' import type { AppState } from '@/store/types' import { getConnectionId } from '@/lib/connection-context' import type { RepoHostFields } from '@/lib/agent-detection-target-inventory' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import { + refineLocalLaunchPlatform, + resolveRepoExecutionHostPlatform, + type KnownExecutionHostPlatform +} from '@/lib/execution-host-facts' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime' +import { isWslUncPath } from '../../../shared/wsl-paths' import type { AgentSessionLaunchPlan, planAgentSessionLaunch @@ -26,6 +34,50 @@ export type DirectWorkItemAgentLaunchPreparation = { plan: AgentSessionLaunchPlan | null } +type DirectLaunchHostArgs = { + latestStore: AppState + worktreeId: string + worktreePath: string + repoHost: RepoHostFields & { path: string } + launchPlatform?: NodeJS.Platform + repoLaunchHost: KnownExecutionHostPlatform + repoProjectRuntime?: ProjectExecutionRuntimeResolution +} + +function directLaunchProjectRuntime( + args: DirectLaunchHostArgs +): ProjectExecutionRuntimeResolution | undefined { + return ( + getLocalProjectExecutionRuntimeContext(args.latestStore, args.worktreeId, CLIENT_PLATFORM) ?? + args.repoProjectRuntime + ) +} + +function resolveDirectWorkItemLaunchHost(args: DirectLaunchHostArgs): KnownExecutionHostPlatform { + // Why the repo's owner: the worktree was created on that host and may not be hydrated yet; its + // own path and runtime still refine the OS (a WSL worktree on a Windows host runs in Linux). + const fact = resolveRepoExecutionHostPlatform( + args.latestStore, + args.repoHost, + () => directLaunchProjectRuntime(args), + args.worktreePath + ) + return refineLocalLaunchPlatform( + fact.kind === 'known' ? fact : args.repoLaunchHost, + args.launchPlatform + ) +} + +/** The shell the local tab actually spawns, which a project runtime can override (e.g. WSL setting, Windows runtime). */ +function resolveDirectWorkItemTabShell(args: DirectLaunchHostArgs): string | undefined { + return resolveLocalWindowsTerminalShellOverrideForTab({ + explicitShellOverride: undefined, + defaultWindowsShell: args.latestStore.settings?.terminalWindowsShell ?? undefined, + isWslWorktree: isWslUncPath(args.worktreePath), + projectRuntime: directLaunchProjectRuntime(args) + }) +} + export async function prepareDirectWorkItemAgentLaunch(args: { worktreeId: string worktreePath: string @@ -33,14 +85,16 @@ export async function prepareDirectWorkItemAgentLaunch(args: { agentOverride?: TuiAgent agentArgs?: string | null repoConnectionId: string | null - repoHost: RepoHostFields + repoHost: RepoHostFields & { path: string } detectedAgentsPromise: Promise | null latestStore: AppState settings: AppState['settings'] draftContent: string promptDelivery: 'draft' | 'submit-after-ready' launchPlatform?: NodeJS.Platform - repoProjectRuntime?: Parameters[0]['repoProjectRuntime'] + /** Host the repo creates worktrees on; stands in while the new worktree is not hydrated. */ + repoLaunchHost: KnownExecutionHostPlatform + repoProjectRuntime?: ProjectExecutionRuntimeResolution planLaunch: typeof planAgentSessionLaunch /** The start action this launch serves. */ requestId: AgentLaunchRequestId @@ -83,17 +137,9 @@ export async function prepareDirectWorkItemAgentLaunch(args: { draftContent: args.draftContent, promptDelivery: args.promptDelivery, settings: args.settings, - launchPlatform: args.launchPlatform, - launchConnectionId, - worktreePath: args.worktreePath, - repoProjectRuntime: - launchConnectionId === null - ? (getLocalProjectExecutionRuntimeContext( - args.latestStore, - args.worktreeId, - CLIENT_PLATFORM - ) ?? args.repoProjectRuntime) - : undefined + launchHost: resolveDirectWorkItemLaunchHost(args), + terminalWindowsShell: resolveDirectWorkItemTabShell(args), + launchConnectionId }) const plan = diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index d1b9c032132..a5084518e48 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -207,6 +207,21 @@ describe('launchWorkItemDirect', () => { seedNativeChatLaunchDraft: mocks.seedNativeChatLaunchDraft, markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed } as typeof mocks.store + // Why: SSH fixtures use target `ssh-1`, which reports a Linux host once connected. + mocks.store.sshConnectionStates = new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + remotePlatform: 'linux' + } + ] + ]) + mocks.store.sshStateByEnvironment = new Map() + mocks.store.runtimeStatusByEnvironmentId = new Map() // @ts-expect-error -- test shim globalThis.window = { api: mockApi } }) @@ -418,6 +433,7 @@ describe('launchWorkItemDirect', () => { agentArgs: '--dangerously-skip-permissions', agentEnv: {}, platform: 'win32', + shell: 'powershell', isRemote: false }) expect(buildAgentStartupPlan).not.toHaveBeenCalledWith( @@ -812,57 +828,4 @@ describe('launchWorkItemDirect', () => { `command test -n "$fish_pid" && set --erase -g ORCA_PI_PREFILL; command test -z "$fish_pid" && unset ORCA_PI_PREFILL; true` ) }) - - it('plans direct local Windows-path launches with POSIX startup for WSL project runtime', async () => { - mocks.store.repos = [ - { - id: 'repo-1', - path: 'C:\\Users\\alice\\repo', - displayName: 'Repo', - addedAt: 1 - } - ] - mocks.store.projects = [ - { - id: 'repo-1', - displayName: 'Repo', - badgeColor: '#000000', - sourceRepoIds: ['repo-1'], - createdAt: 1, - updatedAt: 1, - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - ] - mocks.store.createWorktree.mockResolvedValue({ - worktree: { - id: 'repo-1::C:\\Users\\alice\\repo-worktree', - path: 'C:\\Users\\alice\\repo-worktree' - } - }) - const { launchWorkItemDirect } = await import('./launch-work-item-direct') - - await expect( - launchWorkItemDirect({ - item: { - title: 'Fix failing checks', - url: 'https://github.com/acme/repo/pull/1', - type: 'issue', - number: 1, - pasteContent: 'Fix the failing checks.' - }, - repoId: 'repo-1', - openModalFallback: mocks.openModalFallback, - launchSource: 'task_page', - agentOverride: 'codex', - promptDelivery: 'submit-after-ready' - }) - ).resolves.toBe(true) - - expect(buildAgentStartupPlan).toHaveBeenCalledWith( - expect.objectContaining({ - agent: 'codex', - platform: 'linux' - }) - ) - }) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 3eec4417918..b3f6f3bfd2c 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -32,7 +32,10 @@ import { resolveDirectSetupDecision } from '@/lib/launch-work-item-direct-preflight' import type { LaunchWorkItemDirectArgs } from '@/lib/launch-work-item-direct-types' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' +import { + refineLocalLaunchPlatform, + resolveRepoExecutionHostPlatform +} from '@/lib/execution-host-facts' import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing' import { prepareDirectWorkItemAgentLaunch } from '@/lib/launch-work-item-direct-route-preparation' @@ -90,14 +93,14 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom const repoProjectRuntime = repoConnectionId ? undefined : getLocalRepoProjectExecutionRuntimeContext(store, repoId, CLIENT_PLATFORM) - const preflightLaunchPlatform = - args.launchPlatform ?? - resolveSourceControlLaunchPlatform({ - connectionId: repoConnectionId, - worktreePath: repo.path, - projectRuntime: repoProjectRuntime - }) - const shell = preflightLaunchPlatform === 'win32' ? 'powershell' : 'posix' + // Why: the agent runs on the repo's execution host, so its OS decides quoting, not this client's (#27971). + const repoLaunchFact = resolveRepoExecutionHostPlatform(store, repo, () => repoProjectRuntime) + if (repoLaunchFact.kind === 'withheld') { + toast.error(repoLaunchFact.reason) + return false + } + const repoLaunchHost = refineLocalLaunchPlatform(repoLaunchFact, args.launchPlatform) + const shell = repoLaunchHost.platform === 'win32' ? 'powershell' : 'posix' const agentArgsPlan = planAgentCliArgsSuffix(agentArgs, shell) if (!agentArgsPlan.ok) { // Why: direct launches may create a worktree before the agent startup plan @@ -216,6 +219,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom draftContent, promptDelivery, launchPlatform: args.launchPlatform, + repoLaunchHost, repoProjectRuntime, planLaunch: planAgentSessionLaunch, requestId: newAgentLaunchRequestId() diff --git a/src/renderer/src/lib/source-control-launch-platform.test.ts b/src/renderer/src/lib/source-control-launch-platform.test.ts deleted file mode 100644 index 89bf9ce96a0..00000000000 --- a/src/renderer/src/lib/source-control-launch-platform.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('./new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' })) - -describe('resolveSourceControlLaunchPlatform', () => { - it('uses linux shell planning for ssh POSIX paths and local WSL paths', async () => { - const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform') - - expect( - resolveSourceControlLaunchPlatform({ - connectionId: 'ssh-1', - worktreePath: '/home/alice/repo' - }) - ).toBe('linux') - expect( - resolveSourceControlLaunchPlatform({ - connectionId: null, - worktreePath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` - }) - ).toBe('linux') - }) - - it('uses windows shell planning for ssh Windows paths', async () => { - const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform') - - expect( - resolveSourceControlLaunchPlatform({ - connectionId: 'ssh-1', - worktreePath: String.raw`C:\Users\alice\repo` - }) - ).toBe('win32') - }) - - it('uses linux shell planning for Windows paths forced to local WSL runtime', async () => { - const { resolveSourceControlLaunchPlatform } = await import('./source-control-launch-platform') - - expect( - resolveSourceControlLaunchPlatform({ - connectionId: null, - worktreePath: String.raw`C:\Users\alice\repo`, - projectRuntime: { - status: 'resolved', - runtime: { - kind: 'wsl', - hostPlatform: 'wsl', - distro: 'Ubuntu', - reason: 'project-override', - projectId: 'project-1', - cacheKey: 'project-1:wsl:Ubuntu' - } - } - }) - ).toBe('linux') - }) -}) diff --git a/src/renderer/src/lib/source-control-launch-platform.ts b/src/renderer/src/lib/source-control-launch-platform.ts deleted file mode 100644 index 43cff0ff7e4..00000000000 --- a/src/renderer/src/lib/source-control-launch-platform.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' -import { isWslUncPath } from '../../../shared/wsl-paths' -import { CLIENT_PLATFORM } from './new-workspace' -import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' - -export function resolveSourceControlLaunchPlatform(args: { - connectionId?: string | null - worktreePath?: string | null - projectRuntime?: ProjectExecutionRuntimeResolution -}): NodeJS.Platform { - const path = args.worktreePath?.trim() ?? '' - if (typeof args.connectionId === 'string') { - return path && isWindowsAbsolutePathLike(path) && !isWslUncPath(path) ? 'win32' : 'linux' - } - if (args.projectRuntime?.status === 'repair-required') { - return args.projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : CLIENT_PLATFORM - } - if (args.projectRuntime?.status === 'resolved' && args.projectRuntime.runtime.kind === 'wsl') { - return 'linux' - } - if (path && isWslUncPath(path)) { - return 'linux' - } - return CLIENT_PLATFORM -} diff --git a/src/shared/launch-parity-workspace-start-cases.test-fixture.ts b/src/shared/launch-parity-workspace-start-cases.test-fixture.ts index aec8125eebb..4fb52e8137b 100644 --- a/src/shared/launch-parity-workspace-start-cases.test-fixture.ts +++ b/src/shared/launch-parity-workspace-start-cases.test-fixture.ts @@ -69,16 +69,15 @@ export const WORKSPACE_START_LAUNCH_CASES: WindowLaunchCase[] = [ { command: `${POSIX_CLAUDE} --prefill 'fix Bob'"'"'s bug'`, agentCommand: POSIX_CLAUDE }, ZSH ), - // main today: the work item quotes for PowerShell on Windows even when cmd.exe is the shell - // (C4 keeps it; C4-fix flips this row to cmd.exe quoting). + // The work item quotes for the cmd.exe tab it spawns (#27971 passes the local shell). start( 'work item, Windows C: repo, cmd.exe set', 'win32', repo(WIN_PATH), draft, { - command: `${POSIX_CLAUDE} --prefill 'fix Bob''s bug'`, - agentCommand: POSIX_CLAUDE, + command: `${CMD_CLAUDE} --prefill "fix Bob's bug"`, + agentCommand: CMD_CLAUDE, ...IN_CMD }, CMD_EXE, diff --git a/src/shared/tui-agent-startup-shell.spec.ts b/src/shared/tui-agent-startup-shell.spec.ts index 1698e86b08b..64eca460df4 100644 --- a/src/shared/tui-agent-startup-shell.spec.ts +++ b/src/shared/tui-agent-startup-shell.spec.ts @@ -147,4 +147,14 @@ describe('one Unix startup dialect', () => { ) expect(plan?.env?.ORCA_PI_PREFILL).toBe('hello') }) + + it('leaves a multi-line argv draft to the paste fallback in cmd.exe', () => { + const draft = 'Linked Linear issue: ENG-1\nhttps://linear.app/acme/issue/ENG-1' + const args = { agent: 'claude' as const, draft, cmdOverrides: {}, platform: 'win32' as const } + + expect(buildAgentDraftLaunchPlan({ ...args, shell: 'cmd' })).toBeNull() + expect(buildAgentDraftLaunchPlan({ ...args, shell: 'powershell' })?.launchCommand).toContain( + '`n' + ) + }) }) diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 8c56c1d9d95..ba516c97f15 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -253,6 +253,10 @@ export function buildAgentDraftLaunchPlan(args: { agentCommand: baseCommand.commandWithoutSessionOptions }) let plan: AgentDraftLaunchPlan | null = null + if (config.draftPromptFlag && shell === 'cmd' && /[\r\n]/.test(trimmed)) { + // Why: cmd.exe cannot quote a newline, so each one would submit the line early; paste instead. + return null + } if (config.draftPromptFlag) { const quoted = quoteStartupArg(trimmed, shell) plan = {