diff --git a/src/renderer/src/lib/agent-background-session-launch-host.ts b/src/renderer/src/lib/agent-background-session-launch-host.ts new file mode 100644 index 00000000000..9ff199a5536 --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-launch-host.ts @@ -0,0 +1,84 @@ +import type { useAppStore } from '@/store' +import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' +import { repoIsRemote } from '../../../shared/agent-launch-remote' + +type LaunchStore = ReturnType +type LaunchRepo = LaunchStore['repos'][number] + +export type AgentBackgroundLaunchHost = { + /** SSH connection to spawn on, or null for a local launch. */ + connectionId: string | null + /** Platform whose shell quoting and CLI naming the startup plan must target. */ + platform: NodeJS.Platform + isRemote: boolean + /** + * Connection the agent-status consumer should accept writes from. `undefined` + * means "unknown ownership, accept anything" — the pre-existing behavior for + * workspaces with no resolvable owner. + */ + expectedConnectionId: string | null | undefined +} + +/** + * SSH connection owning a folder-workspace selector, or null otherwise. Returns + * null when ownership is ambiguous (mixed local/remote children) so an unclear + * scope fails to a local launch rather than to the wrong host. + */ +function resolveFolderWorkspaceConnectionIdForLaunch( + store: LaunchStore, + worktreeId: string +): string | null { + const parsed = parseWorkspaceKey(worktreeId) + if (parsed?.type !== 'folder') { + return null + } + return getFolderWorkspaceConnectionId(store, parsed.folderWorkspaceId) ?? null +} + +/** + * Resolve which host an automation's agent session runs on. + * + * Why this is not just `repo.connectionId`: a folder workspace has no repo row — + * its synthetic repoId is `folder-workspace:` — so repo-derived routing + * finds nothing and falls back to a LOCAL spawn using a path that exists only on + * the SSH host. Ownership has to come from the workspace scope, matching how + * ordinary terminal creation resolves it (#2989). + */ +export function resolveAgentBackgroundLaunchHost(args: { + store: LaunchStore + worktreeId: string + worktreePath: string | undefined + repo: LaunchRepo | null | undefined +}): AgentBackgroundLaunchHost { + const { store, worktreeId, worktreePath, repo } = args + if (repo) { + return { + connectionId: repo.connectionId ?? null, + platform: getAgentLaunchPlatformForRepo( + repo, + repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId) + ), + isRemote: repoIsRemote(repo), + expectedConnectionId: repo.connectionId ?? null + } + } + const folderWorkspaceConnectionId = resolveFolderWorkspaceConnectionIdForLaunch(store, worktreeId) + return { + connectionId: folderWorkspaceConnectionId, + // A remote folder workspace runs under the host's own shell; only a + // Windows-style path marks a win32 host (same rule as the repo path above). + platform: folderWorkspaceConnectionId + ? isWindowsAbsolutePathLike(worktreePath ?? '') + ? 'win32' + : 'linux' + : CLIENT_PLATFORM, + isRemote: Boolean(folderWorkspaceConnectionId), + // A resolved folder-workspace connection is as authoritative as a repo's. + expectedConnectionId: folderWorkspaceConnectionId ?? undefined + } +} diff --git a/src/renderer/src/lib/agent-background-session-test-state.ts b/src/renderer/src/lib/agent-background-session-test-state.ts index 37471e31b2c..85def0ffadb 100644 --- a/src/renderer/src/lib/agent-background-session-test-state.ts +++ b/src/renderer/src/lib/agent-background-session-test-state.ts @@ -24,7 +24,14 @@ export type AgentBackgroundSessionTestState = { | { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null } }[] - repos: { id: string; connectionId: string | null; path: string }[] + repos: { id: string; connectionId: string | null; path: string; projectGroupId?: string | null }[] + folderWorkspaces: { + id: string + projectGroupId: string + folderPath: string + connectionId?: string | null + }[] + projectGroups: { id: string; parentGroupId?: string | null; connectionId?: string | null }[] worktreesByRepo: Record< string, { id: string; repoId: string; projectId: string; path: string; displayName: string }[] @@ -88,6 +95,8 @@ export function createAgentBackgroundSessionTestState(mocks: { } ] }, + folderWorkspaces: [] as AgentBackgroundSessionTestState['folderWorkspaces'], + projectGroups: [] as AgentBackgroundSessionTestState['projectGroups'], tabsByWorktree: { 'wt-1': [] as { id: string; title: string }[] }, terminalLayoutsByTabId: {} as Record< string, @@ -123,6 +132,8 @@ export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessi } state.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] state.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + state.folderWorkspaces = [] + state.projectGroups = [] state.worktreesByRepo = { 'repo-1': [ { diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index 45b6a49e6df..a807baffa54 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -434,4 +434,54 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', }) expect(mockDispatchEvent).not.toHaveBeenCalled() }) + + it('spawns an SSH folder-workspace automation on the owning host, not locally', async () => { + // A folder workspace has no repo row — its synthetic repoId is + // `folder-workspace:` — so repo-derived routing resolves no + // connection and spawns LOCALLY with a path that only exists on the remote + // host. Ownership must come from the workspace scope instead (#2989). + state.repos = [ + { id: 'repo-1', connectionId: 'ssh-1', path: '/srv/proj/api', projectGroupId: 'grp-1' } + ] + state.projectGroups = [{ id: 'grp-1', parentGroupId: null, connectionId: 'ssh-1' }] + state.folderWorkspaces = [ + { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/srv/proj', connectionId: 'ssh-1' } + ] + state.getKnownWorktreeById = (worktreeId: string) => + worktreeId === 'folder:fw-1' ? { id: 'folder:fw-1', path: '/srv/proj' } : undefined + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'folder:fw-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'ssh-1', cwd: '/srv/proj' }) + ) + }) + + it('keeps a local folder workspace on the local host', async () => { + state.repos = [ + { id: 'repo-1', connectionId: null, path: '/home/me/proj/api', projectGroupId: 'grp-1' } + ] + state.projectGroups = [{ id: 'grp-1', parentGroupId: null, connectionId: null }] + state.folderWorkspaces = [ + { id: 'fw-1', projectGroupId: 'grp-1', folderPath: '/home/me/proj', connectionId: null } + ] + state.getKnownWorktreeById = (worktreeId: string) => + worktreeId === 'folder:fw-1' ? { id: 'folder:fw-1', path: '/home/me/proj' } : undefined + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'folder:fw-1', + prompt: 'run the automation' + }) + + expect(mockSpawn).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: null, cwd: '/home/me/proj' }) + ) + }) }) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index a9c5bab276c..0c33865b580 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -4,18 +4,15 @@ import type { LaunchAgentBackgroundSessionArgs, LaunchAgentBackgroundSessionResult } from '@/lib/agent-background-session-contract' -import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { scheduleAgentBackgroundDraft } from '@/lib/agent-background-draft-delivery' -import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { repoIsRemote } from '../../../shared/agent-launch-remote' +import { resolveAgentBackgroundLaunchHost } from '@/lib/agent-background-session-launch-host' import { makePaneKey } from '../../../shared/stable-pane-id' import { registerEagerPtyBuffer, @@ -69,15 +66,16 @@ export async function launchAgentBackgroundSession( const cmdOverrides = store.settings?.agentCmdOverrides ?? {} const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) - const launchPlatform = repo - ? getAgentLaunchPlatformForRepo( - repo, - repo.connectionId ? undefined : getLocalProjectExecutionRuntimeContext(store, worktreeId) - ) - : CLIENT_PLATFORM // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only - // `orca-ide` rename must not be applied for remote launches. - const isRemote = repo ? repoIsRemote(repo) : false + // `orca-ide` rename must not be applied for remote launches. Folder workspaces + // have no repo row, so host resolution cannot go through `repo` alone (#2989). + const launchHost = resolveAgentBackgroundLaunchHost({ + store, + worktreeId, + worktreePath: worktree.path, + repo + }) + const { platform: launchPlatform, isRemote } = launchHost const startupShell = resolveLocalWindowsAgentStartupShell({ platform: launchPlatform, isRemote, @@ -115,7 +113,7 @@ export async function launchAgentBackgroundSession( env: startupPlan.env }) let paneKey = makePaneKey(reservedTabId, leafId) - const sshConnectionId = repo?.connectionId ?? null + const sshConnectionId = launchHost.connectionId const sshStartupDelivery = createSshBackgroundStartupDelivery({ command: sshConnectionId ? startupPlan.launchCommand : null, waitForShellReady: @@ -163,7 +161,7 @@ export async function launchAgentBackgroundSession( paneKey, launchToken, mainOwnsAgentStatusWrites, - expectedConnectionId: repo ? (repo.connectionId ?? null) : undefined, + expectedConnectionId: launchHost.expectedConnectionId, runtimeEnvironmentId: runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null, getPtyId: () => ptyId, onAgentStatus