diff --git a/src/main/orcad/orcad-cli-launcher.test.ts b/src/main/orcad/orcad-cli-launcher.test.ts index 999dcbc0fc5..b4994efb70c 100644 --- a/src/main/orcad/orcad-cli-launcher.test.ts +++ b/src/main/orcad/orcad-cli-launcher.test.ts @@ -54,19 +54,23 @@ describe('orcad profile CLI launcher', () => { throw new Error('CLI launcher is missing') } const args = ['orchestration', 'send', 'a body\nwith "quotes" and $HOME'] - const result = await runProcess({ - program: launcher, - args, - env: { - ...process.env, - ORCA_USER_DATA_PATH: '/another/owner', - ELECTRON_RUN_AS_NODE: '1', - NODE_OPTIONS: '--require /missing/inherited/preload' - }, - timeoutMs: 5000 - }) - expect(result.code).toBe(0) - expect(JSON.parse(result.stdout)).toEqual({ profile: roots.profile, args }) + const launchers = + process.platform === 'linux' ? [launcher, join(dirname(launcher), 'orca-ide')] : [launcher] + for (const program of launchers) { + const result = await runProcess({ + program, + args, + env: { + ...process.env, + ORCA_USER_DATA_PATH: '/another/owner', + ELECTRON_RUN_AS_NODE: '1', + NODE_OPTIONS: '--require /missing/inherited/preload' + }, + timeoutMs: 5000 + }) + expect(result.code).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ profile: roots.profile, args }) + } } ) @@ -123,6 +127,24 @@ describe('orcad profile CLI launcher', () => { expect(getOrcadCliLauncherPath()).toBeNull() }) + describe('on a Linux host', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + }) + + it('refreshes both CLI names when the runtime switches server slots', async () => { + await writeCli() + await prepareOrcadCliLauncher() + roots.install = join(directory, 'updated server') + await writeCli() + await prepareOrcadCliLauncher() + const binDir = join(roots.profile, 'cli', 'bin') + const script = await readFile(join(binDir, 'orca-ide'), 'utf8') + expect(script).toContain(roots.install) + expect(script).toBe(await readFile(join(binDir, 'orca'), 'utf8')) + }) + }) + describe('on a Windows host', () => { beforeEach(() => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/orcad/orcad-cli-launcher.ts b/src/main/orcad/orcad-cli-launcher.ts index fa50a1c845a..5f8987750d5 100644 --- a/src/main/orcad/orcad-cli-launcher.ts +++ b/src/main/orcad/orcad-cli-launcher.ts @@ -35,15 +35,19 @@ export async function prepareOrcadCliLauncher(): Promise { launcherPath = await prepareWindowsLauncher(installRoot, entry, userDataPath, binDir) return } - const path = join(binDir, 'orca') const script = buildUnixCliLauncher(process.execPath, entry, userDataPath, 'node') await mkdir(binDir, { recursive: true }) - const current = await readFile(path, 'utf8').catch(() => null) - if (current !== script) { - writeFileAtomically(path, script, { mode: 0o700 }) + // Older clients type the Linux desktop `orca-ide claude-teams` into server terminals. + const names = process.platform === 'linux' ? ['orca', 'orca-ide'] : ['orca'] + for (const name of names) { + const path = join(binDir, name) + const current = await readFile(path, 'utf8').catch(() => null) + if (current !== script) { + writeFileAtomically(path, script, { mode: 0o700 }) + } + await chmod(path, 0o700) } - await chmod(path, 0o700) - launcherPath = path + launcherPath = join(binDir, 'orca') } export function buildWindowsCliLauncherConfig( diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index baade81d5bd..5231ca098b4 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -78,8 +78,8 @@ describe('buildWorktreeStartupForAgent host resolution', () => { ).toBe('orca') }) - it('keeps the rename for a runtime host with no nested SSH target', () => { - expect(launchCliNameFor(makeRepo({ executionHostId: 'runtime:vm-1' }))).toBe('orca-ide') + it('uses the headless runtime CLI with no nested SSH target (#33920)', () => { + expect(launchCliNameFor(makeRepo({ executionHostId: 'runtime:vm-1' }))).toBe('orca') }) it('uses per-launch arguments and preserves launch telemetry', () => { diff --git a/src/renderer/src/lib/agent-background-session-launch-host.test.ts b/src/renderer/src/lib/agent-background-session-launch-host.test.ts index 2b6d94326fc..39c11c5d3bd 100644 --- a/src/renderer/src/lib/agent-background-session-launch-host.test.ts +++ b/src/renderer/src/lib/agent-background-session-launch-host.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan } from '../../../shared/tui-agent-startup' import { resolveAgentBackgroundLaunchHost } from './agent-background-session-launch-host' function sshState(remotePlatform: 'linux' | 'win32') { @@ -198,7 +199,7 @@ describe('resolveAgentBackgroundLaunchHost', () => { worktreePath: '/srv/repo' }) - expect(host).toMatchObject({ connectionId: null, isRemote: false, platform: 'linux' }) + expect(host).toMatchObject({ connectionId: null, isRemote: true, platform: 'linux' }) }) it("uses a worktree's nested SSH host over its repo's runtime default", () => { @@ -231,7 +232,7 @@ describe('resolveAgentBackgroundLaunchHost', () => { expect(host).toMatchObject({ platform: 'linux', isLocalHost: false, isRemote: true }) }) - it('quotes for the Orca server that owns a folder workspace', () => { + it('uses the headless runtime CLI for a folder workspace (#33920)', () => { const host = resolveAgentBackgroundLaunchHost({ ...asLaunchInputs( makeFolderHostState({ @@ -245,7 +246,17 @@ describe('resolveAgentBackgroundLaunchHost', () => { worktreePath: '/project' }) - expect(host.platform).toBe('linux') + expect(host).toMatchObject({ platform: 'linux', connectionId: null, isLocalHost: false }) + expect( + buildAgentStartupPlan({ + agent: 'claude-agent-teams', + prompt: '', + cmdOverrides: {}, + platform: host.platform, + isRemote: host.isRemote, + allowEmptyPromptLaunch: true + })?.launchCommand + ).toBe('orca claude-teams') }) it('refuses a launch whose SSH host never reported its OS', () => { diff --git a/src/renderer/src/lib/agent-background-session-launch-host.ts b/src/renderer/src/lib/agent-background-session-launch-host.ts index e60c32e5970..970fd2e52d1 100644 --- a/src/renderer/src/lib/agent-background-session-launch-host.ts +++ b/src/renderer/src/lib/agent-background-session-launch-host.ts @@ -5,7 +5,7 @@ import { } from '@/lib/execution-host-facts' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { repoIsRemote } from '../../../shared/agent-launch-remote' +import { launchHostIsRemote } from '../../../shared/agent-launch-remote' import { getRepoSshConnectionId } from '../../../shared/execution-host' type LaunchStore = ReturnType @@ -39,13 +39,12 @@ function resolveLaunchHostFact( store: LaunchStore, worktreeId: string, worktreePath: string | undefined -): Pick & { isSshHost: boolean } { +): Pick & { isRemoteHost: boolean } { const fact = resolveWorktreeExecutionHostPlatform(store, worktreeId, worktreePath) return { platform: requireExecutionHostPlatform(fact), isLocalHost: fact.kind === 'known' && fact.hostKind === 'local', - // Why: an SSH target (nested ones too) runs the relay shim as plain `orca`. - isSshHost: fact.kind === 'known' && fact.hostKind === 'ssh' + isRemoteHost: fact.kind === 'known' && launchHostIsRemote(fact.hostKind, worktreePath) } } @@ -60,11 +59,11 @@ export function resolveAgentBackgroundLaunchHost(args: { if (repo) { // Resolve both SSH owner spellings once so spawn routing, trust writes and launch shape agree on the host. const sshConnectionId = getRepoSshConnectionId(repo) - const { isSshHost, ...hostFact } = resolveLaunchHostFact(store, worktreeId, worktreePath) + const { isRemoteHost, ...hostFact } = resolveLaunchHostFact(store, worktreeId, worktreePath) return { connectionId: sshConnectionId, ...hostFact, - isRemote: repoIsRemote(repo) || isSshHost, + isRemote: sshConnectionId !== null || isRemoteHost, expectedConnectionId: sshConnectionId } } @@ -73,11 +72,11 @@ export function resolveAgentBackgroundLaunchHost(args: { if (isFolderWorkspace && folderWorkspaceConnectionId === undefined) { throw new Error('The target folder workspace host is unavailable or ambiguous.') } - const { isSshHost, ...hostFact } = resolveLaunchHostFact(store, worktreeId, worktreePath) + const { isRemoteHost, ...hostFact } = resolveLaunchHostFact(store, worktreeId, worktreePath) return { connectionId: folderWorkspaceConnectionId ?? null, ...hostFact, - isRemote: Boolean(folderWorkspaceConnectionId) || isSshHost, + isRemote: Boolean(folderWorkspaceConnectionId) || isRemoteHost, expectedConnectionId: isFolderWorkspace ? (folderWorkspaceConnectionId ?? null) : undefined } } diff --git a/src/renderer/src/lib/launch-agent-execution-context.test.ts b/src/renderer/src/lib/launch-agent-execution-context.test.ts index 1094f73d566..803606e788c 100644 --- a/src/renderer/src/lib/launch-agent-execution-context.test.ts +++ b/src/renderer/src/lib/launch-agent-execution-context.test.ts @@ -88,7 +88,8 @@ describe('resolveAgentLaunchExecutionContext from a Windows client', () => { ) expect(context).toMatchObject({ resolvedLaunchPlatform: 'linux', - isRemote: false, + worktreeSshConnectionId: null, + isRemote: true, queuedShell: undefined }) }) diff --git a/src/renderer/src/lib/launch-agent-execution-context.ts b/src/renderer/src/lib/launch-agent-execution-context.ts index 332b9eb873a..232e3466677 100644 --- a/src/renderer/src/lib/launch-agent-execution-context.ts +++ b/src/renderer/src/lib/launch-agent-execution-context.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner' import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' +import { launchHostIsRemote } from '../../../shared/agent-launch-remote' import { getConnectionIdFromState } from '@/lib/connection-context' import { refineLocalLaunchPlatform, @@ -24,8 +25,6 @@ export function resolveAgentLaunchExecutionContext( ): AgentLaunchExecutionContext | null { // Why: `undefined` (rival host rows disagree) is not evidence of a remote; main rejects it anyway. const worktreeSshConnectionId = getConnectionIdFromState(store, args.worktreeId) - // Why: SSH remotes deploy the shim as plain `orca`, so skip the Linux-only `orca-ide` rename for remote launches. - const isRemote = Boolean(worktreeSshConnectionId) // Why getKnownWorktreeById: folder workspaces are not in allWorktrees, and their path marks WSL. const workspacePath = ( store.getKnownWorktreeById?.(args.worktreeId) ?? @@ -41,7 +40,7 @@ export function resolveAgentLaunchExecutionContext( return { worktreeSshConnectionId, resolvedLaunchPlatform: launchHost.platform, - isRemote, + isRemote: launchHostIsRemote(launchHost.hostKind, workspacePath), queuedShell: resolveExecutionHostAgentStartupShell( launchHost, store.settings?.terminalWindowsShell diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts index b328fe66918..180ebacf282 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-resolution.test.ts @@ -167,7 +167,7 @@ describe('launchAgentInNewTab execution host resolution', () => { expect(queuedCommand()).toBe("orca claude-teams '--dangerously-skip-permissions'") }) - it('keeps a runtime host with no nested SSH target on the local CLI name', async () => { + it('uses the headless runtime CLI with no nested SSH target (#33920)', async () => { store.repos = [ { id: 'repo-1', connectionId: null, executionHostId: 'runtime:vm-1', path: '/srv/vm' } ] @@ -175,6 +175,6 @@ describe('launchAgentInNewTab execution host resolution', () => { await launchOnLinux() - expect(queuedCommand()).toBe("orca-ide claude-teams '--dangerously-skip-permissions'") + expect(queuedCommand()).toBe("orca claude-teams '--dangerously-skip-permissions'") }) }) diff --git a/src/shared/agent-launch-remote.test.ts b/src/shared/agent-launch-remote.test.ts index e2139a71072..54b621e03e5 100644 --- a/src/shared/agent-launch-remote.test.ts +++ b/src/shared/agent-launch-remote.test.ts @@ -19,9 +19,18 @@ describe('repoIsRemote', () => { ) }) - it('keeps a runtime host with no nested SSH target local-shaped', () => { - // Runtime hosts without nested SSH are full installs and retain platform CLI naming. - expect(repoIsRemote({ connectionId: null, executionHostId: 'runtime:vm-1' })).toBe(false) + it('treats a headless runtime with no nested SSH target as remote', () => { + expect(repoIsRemote({ connectionId: null, executionHostId: 'runtime:vm-1' })).toBe(true) + }) + + it('keeps a WSL workspace on a Windows server on the WSL CLI name', () => { + expect( + repoIsRemote({ + connectionId: null, + executionHostId: 'runtime:win-1', + path: '\\\\wsl.localhost\\Ubuntu\\home\\u\\repo' + }) + ).toBe(false) }) it('keeps plain local rows local', () => { diff --git a/src/shared/agent-launch-remote.ts b/src/shared/agent-launch-remote.ts index f4cad1d1212..15ad8aff7dd 100644 --- a/src/shared/agent-launch-remote.ts +++ b/src/shared/agent-launch-remote.ts @@ -1,7 +1,22 @@ import type { Repo } from './repo-types' -import { getRepoSshConnectionId } from './execution-host' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ParsedExecutionHost +} from './execution-host' +import { isWslUncPath } from './wsl-paths' -/** SSH file owners use relay orca/orca.cmd, never the Linux desktop orca-ide rename. Resolve executionHostId over stale connectionId; runtime hosts are remote only with nested SSH. Local-PTY routing uses getSshTargetIdForExecutionHost; already-resolved launch connections need no re-derivation. */ -export function repoIsRemote(repo: Pick): boolean { - return getRepoSshConnectionId(repo) !== null +/** SSH relays and Orca servers put plain `orca` on PATH; the Linux `orca-ide` rename is for local desktops and WSL. */ +export function launchHostIsRemote( + hostKind: ParsedExecutionHost['kind'], + workspacePath: string | null | undefined +): boolean { + return hostKind !== 'local' && !(workspacePath && isWslUncPath(workspacePath)) +} + +export function repoIsRemote( + repo: Pick & { path?: string | null } +): boolean { + const hostKind = parseExecutionHostId(getRepoExecutionHostId(repo))?.kind ?? 'local' + return launchHostIsRemote(hostKind, repo.path) } diff --git a/src/shared/orca-cli-command-name.test.ts b/src/shared/orca-cli-command-name.test.ts new file mode 100644 index 00000000000..8e9f87e0fae --- /dev/null +++ b/src/shared/orca-cli-command-name.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' + +const agentTeams = TUI_AGENT_CONFIG['claude-agent-teams'] + +describe('Claude Agent Teams launch name on the launching host', () => { + it('uses plain orca on a headless Linux server, whose PATH has no orca-ide (#33920)', () => { + installFakeAppEnvironment({ getCliLauncherPath: () => '/home/u/.orca/cli/bin/orca' }) + expect(getTuiAgentLaunchCommand(agentTeams, 'linux')).toBe('orca claude-teams') + }) + + it('keeps the orca-ide rename on a Linux desktop', () => { + installFakeAppEnvironment({ getCliLauncherPath: undefined }) + expect(getTuiAgentLaunchCommand(agentTeams, 'linux')).toBe('orca-ide claude-teams') + }) + + it('keeps the orca-ide rename for WSL under a Windows server', () => { + installFakeAppEnvironment({ + getCliLauncherPath: () => 'C:\\Users\\u\\.orca\\cli\\bin\\orca.exe' + }) + expect(getTuiAgentLaunchCommand(agentTeams, 'linux')).toBe('orca-ide claude-teams') + }) +}) diff --git a/src/shared/orca-cli-command-name.ts b/src/shared/orca-cli-command-name.ts index 8d226e4ff3a..8f612b1720b 100644 --- a/src/shared/orca-cli-command-name.ts +++ b/src/shared/orca-cli-command-name.ts @@ -1,3 +1,5 @@ +import { getAppEnvironment, hasAppEnvironment } from './app-environment' + export function getOrcaCliCommandNameForPlatform(platform: NodeJS.Platform): string { if (platform === 'linux') { return 'orca-ide' @@ -7,3 +9,13 @@ export function getOrcaCliCommandNameForPlatform(platform: NodeJS.Platform): str } return 'orca' } + +/** The GNOME-conflict `orca-ide` rename is desktop-only: SSH relays and headless Orca servers put plain `orca` on PATH. */ +export function skipsLinuxOrcaCliRename(isRemote: boolean | undefined): boolean { + if (isRemote) { + return true + } + // Only a headless server publishes its own launcher; Windows ones (WSL launches) keep the rename. + const launcher = hasAppEnvironment() ? getAppEnvironment().getCliLauncherPath?.() : null + return launcher?.endsWith('/orca') ?? false +} diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 7416d38f13e..501dc89f4c8 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -1,5 +1,5 @@ import type { TuiAgent } from './tui-agent' -import { getOrcaCliCommandNameForPlatform } from './orca-cli-command-name' +import { getOrcaCliCommandNameForPlatform, skipsLinuxOrcaCliRename } from './orca-cli-command-name' import type { TuiAgentConfig, TuiAgentConfigSource } from './tui-agent-config-types' export type { @@ -353,8 +353,7 @@ export function getTuiAgentLaunchCommand( platform: NodeJS.Platform, opts?: { isRemote?: boolean } ): string { - // The GNOME-conflict orca-ide rename is local-only; Linux remote shims are always orca. - if (opts?.isRemote && platform === 'linux') { + if (platform === 'linux' && skipsLinuxOrcaCliRename(opts?.isRemote)) { return config.launchCmd } return config.launchCmdByPlatform?.[platform] ?? config.launchCmd