fix(agents): direct work-item and checks-fix launches quote for the execution host (#29716)

* fix(agents): direct work-item and checks-fix launches quote for the execution host

GitHub Project Start, PR checks Fix and the source-control/checks preflights
resolved their launch platform from the client OS whenever the repo had no SSH
connection, so a Windows client starting work on a paired Linux Orca server sent
a PowerShell-quoted --prefill to bash. They now read the execution-host facts
#27259 introduced (server-reported OS, SSH-reported OS, WSL), and the
path-shape resolver is removed so one helper decides.

* fix(agents): resolve direct-launch and preflight platforms from the repo owner

Review follow-up: a WSL worktree on a paired Windows server quotes for Linux,
the owner-row fixture reports its server OS, and the source-control preflights
read the repo owner rather than the focus-defaulted worktree route.

* fix(agents): preflight platform honours a worktree's own host

Review follow-up: a per-worktree host outranks the repo owner, the host-platform
test's mutable client platform is typed, and the source-control panel fixture
carries the host slices.

* fix(agents): a server-projected SSH worktree reads its server's SSH state

Review follow-up: the preflight resolver keeps the runtime owner for nested SSH
worktrees instead of reading this client's own SSH connections.

* fix(agents): cmd.exe pastes a multi-line draft instead of prefilling it

Review follow-up: now that direct launches pass the local Windows shell, a
multi-line --prefill under cmd.exe would submit at its first newline.

* fix(agents): direct launches quote for the shell their tab spawns

A project's Windows runtime can override the terminal setting (WSL setting,
Windows runtime spawns PowerShell), so resolve the tab's effective shell before
quoting. The cmd.exe parity row now expects cmd quoting.
This commit is contained in:
Neil
2026-10-16 08:46:22 -07:00
committed by GitHub
parent 7fc7f3d142
commit 8fad95829a
26 changed files with 668 additions and 294 deletions
@@ -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({
@@ -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
@@ -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)
@@ -151,6 +151,9 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): 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]: [] },
@@ -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)
)
@@ -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<GitStatusEntry, 'path' | 'conflictKind'>[]
stagedEntries: Pick<GitStatusEntry, 'path' | 'status' | 'area'>[]
@@ -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
@@ -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
@@ -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
@@ -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<Record<string, unknown>>()),
CLIENT_PLATFORM: 'win32'
}))
type State = Parameters<typeof resolveRepoWorktreeLaunchPlatform>[0]
function linuxServer(): Map<string, RuntimeEnvironmentStatus> {
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()
})
})
+51 -3
View File
@@ -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<ExecutionHostPlatformFact, { kind: 'known' }>
/** 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)
@@ -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<typeof ExecutionHostFactsModule>(
'@/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(
@@ -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(
@@ -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
)
}
@@ -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<typeof resolveSourceControlLaunchPlatform>[0]['projectRuntime']
}): ReturnType<typeof buildDirectWorkItemAgentStartupPlan> {
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'
})
@@ -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,
@@ -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<string, unknown> } = {
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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
get CLIENT_PLATFORM() {
return mocks.clientPlatform
}
}))
vi.mock('@/lib/tui-agent-startup', async () => {
const actual = await vi.importActual<typeof TuiAgentStartupModule>('@/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<string, SshConnectionState>
runtime?: Map<string, RuntimeEnvironmentStatus>
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<boolean> {
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.'
)
})
})
@@ -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([]),
@@ -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<string[]> | null
latestStore: AppState
settings: AppState['settings']
draftContent: string
promptDelivery: 'draft' | 'submit-after-ready'
launchPlatform?: NodeJS.Platform
repoProjectRuntime?: Parameters<typeof buildDirectWorkItemStartup>[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 =
@@ -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'
})
)
})
})
@@ -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()
@@ -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')
})
})
@@ -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
}
@@ -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,
@@ -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'
)
})
})
+4
View File
@@ -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 = {