fix(agents): launch Claude Agent Teams as plain orca on Orca servers (#34116)

* fix(agents): launch Claude Agent Teams as plain orca on Orca servers

A paired Orca server has no SSH connection id, so client launches treated it
as a local Linux desktop and typed `orca-ide claude-teams`. Headless servers
only put `orca` on PATH. Client launches now read the resolved execution host
(SSH or server, except WSL workspaces), and launches built on a headless Linux
server use the launcher that server publishes.

Fixes #33920. Reported by @williamhrs. Client-side approach from #33987 by
@coolwindX; #33975 by @Shubham-Padkonde showed the host-built launch gap.

* fix(orcad): keep orca-ide working for older clients on Linux servers

Older clients type the desktop orca-ide claude-teams into server terminals.
Write an identical orca-ide launcher beside orca. Idea and test from #33975
by @Shubham-Padkonde.
This commit is contained in:
Neil
2026-10-26 17:02:51 -07:00
committed by GitHub
parent a1418865f3
commit 43097c517b
13 changed files with 143 additions and 48 deletions
+35 -13
View File
@@ -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' })
+10 -6
View File
@@ -35,15 +35,19 @@ export async function prepareOrcadCliLauncher(): Promise<void> {
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(
@@ -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', () => {
@@ -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', () => {
@@ -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<typeof useAppStore.getState>
@@ -39,13 +39,12 @@ function resolveLaunchHostFact(
store: LaunchStore,
worktreeId: string,
worktreePath: string | undefined
): Pick<AgentBackgroundLaunchHost, 'platform' | 'isLocalHost'> & { isSshHost: boolean } {
): Pick<AgentBackgroundLaunchHost, 'platform' | 'isLocalHost'> & { 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
}
}
@@ -88,7 +88,8 @@ describe('resolveAgentLaunchExecutionContext from a Windows client', () => {
)
expect(context).toMatchObject({
resolvedLaunchPlatform: 'linux',
isRemote: false,
worktreeSshConnectionId: null,
isRemote: true,
queuedShell: undefined
})
})
@@ -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
@@ -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'")
})
})
+12 -3
View File
@@ -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', () => {
+19 -4
View File
@@ -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<Repo, 'connectionId' | 'executionHostId'>): 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<Repo, 'connectionId' | 'executionHostId'> & { path?: string | null }
): boolean {
const hostKind = parseExecutionHostId(getRepoExecutionHostId(repo))?.kind ?? 'local'
return launchHostIsRemote(hostKind, repo.path)
}
+24
View File
@@ -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')
})
})
+12
View File
@@ -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
}
+2 -3
View File
@@ -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