mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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' })
|
||||
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user