From c25fad76df8040342bc2d30139fcbeccad6eb098 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:35:18 -0700 Subject: [PATCH] fix(agent-teams): run the tmux shim through the headless host's CLI, never the GUI binary (#33389) * fix(agent-teams): run the tmux shim through the headless host's CLI, never the GUI binary On an orca serve host the runtime has no Electron resources directory, so the shim fell back to PATH and picked the AppImage's Electron binary, which booted Chromium and died on the SUID sandbox. Prefer the host's profile-scoped CLI launcher, and route agent-teams-tmux through the packaged CLI redirect so a direct Electron invocation never initializes the GUI. Fixes #33354 * fix(agent-teams): prefer a fresh CLI over a shim bin inherited from an older leader pane --- .../specs/cli-command-names-parity.test.ts | 3 +- .../claude-agent-teams-shim-env.test.ts | 33 ++++++++++++- .../runtime/claude-agent-teams-shim-env.ts | 49 ++++++++++--------- src/main/startup/cli-command-names.ts | 1 + 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/cli/specs/cli-command-names-parity.test.ts b/src/cli/specs/cli-command-names-parity.test.ts index 2f9a520d566..b7633db656d 100644 --- a/src/cli/specs/cli-command-names-parity.test.ts +++ b/src/cli/specs/cli-command-names-parity.test.ts @@ -7,7 +7,8 @@ vi.mock('electron', () => ({ app: { commandLine: { getSwitchValue: () => '' } } describe('CLI launch redirect command names', () => { it('routes every CLI root through command redirect or direct serve', () => { - const roots = new Set(COMMAND_SPECS.map((spec) => spec.path[0])) + // Why agent-teams-tmux: the CLI dispatches it before spec parsing, and the tmux shim runs it. + const roots = new Set([...COMMAND_SPECS.map((spec) => spec.path[0]), 'agent-teams-tmux']) const options = { platform: 'linux', isPackaged: true, diff --git a/src/main/runtime/claude-agent-teams-shim-env.test.ts b/src/main/runtime/claude-agent-teams-shim-env.test.ts index 12271e67ae9..e9859778f59 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.test.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -1,9 +1,11 @@ import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' +import { dirname, join, relative } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name' import { buildClaudeAgentTeamsLaunchPlan, ensureClaudeAgentTeamsShimDir, @@ -96,6 +98,33 @@ describe('claude agent teams shim env', () => { expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(cliPath) }) + it.skipIf(process.platform === 'win32')( + "prefers a headless host's CLI launcher over a GUI binary earlier on PATH", + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) + roots.push(root) + // Why: an AppImage puts its Electron binary, named like the CLI, first on PATH. + const electron = join(root, getOrcaCliCommandNameForPlatform(process.platform)) + const launcher = join(root, 'cli', 'bin', 'orca') + await mkdir(dirname(launcher), { recursive: true }) + for (const path of [electron, launcher]) { + await writeFile(path, '#!/usr/bin/env sh\n', 'utf8') + await chmod(path, 0o755) + } + installFakeAppEnvironment({ getCliLauncherPath: () => launcher }) + + expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(launcher) + // An older leader pane re-running `orca claude-teams` carries its stale team env. + expect( + resolveClaudeAgentTeamsShimBin({ + PATH: root, + ORCA_AGENT_TEAMS_TEAM_ID: 'team-old', + ORCA_AGENT_TEAMS_SHIM_BIN: electron + }) + ).toBe(launcher) + } + ) + it('refuses to resolve a CLI through relative PATH entries or a bare override', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) roots.push(root) diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts index b8ff1b5a5c0..ae4d3bc86d9 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -8,7 +8,9 @@ import { isDirectClaudeCommand, type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name' +import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path' import { resolvePathEnvKey } from '../pty/windows-path-segment-merge' export type ClaudeAgentTeamsLaunchPlan = { @@ -65,18 +67,33 @@ export function resolveClaudeAgentTeamsShimBin( ): string | null { // Why: Windows callers pass an env spelt `Path`; reading only `PATH` there would find no CLI at all. const pathValue = env[resolvePathEnvKey(env, process.platform)] - const override = env.ORCA_AGENT_TEAMS_SHIM_BIN - if (override) { - // Why: a bare override name would be resolved by the shim's shell against its cwd, so qualify it or ignore it. - const qualified = isAbsolute(override) ? override : findExecutableOnPath(override, pathValue) - if (qualified) { - return qualified - } + const rawOverride = env.ORCA_AGENT_TEAMS_SHIM_BIN + // Why: a bare override name would be resolved by the shim's shell against its cwd, so qualify it or ignore it. + const override = rawOverride + ? isAbsolute(rawOverride) + ? rawOverride + : findExecutableOnPath(rawOverride, pathValue) + : null + // Why: with a team id the value was inherited from an older leader pane and may name a stale GUI binary. + const inheritedFromTeam = Boolean(env.ORCA_AGENT_TEAMS_TEAM_ID) + if (override && !inheritedFromTeam) { + return override } - const bundled = bundledLauncherPath() + // Why first: a headless host has no resources dir, and the PATH fallback below can then find + // the AppImage's Electron binary, which boots the GUI instead of the CLI. + const hostLauncher = hasAppEnvironment() ? getAppEnvironment().getCliLauncherPath?.() : null + if (hostLauncher && isAbsolute(hostLauncher) && isExecutableFile(hostLauncher)) { + return hostLauncher + } + const bundled = process.resourcesPath + ? getBundledLauncherPath(process.platform, process.resourcesPath) + : null if (bundled && isExecutableFile(bundled)) { return bundled } + if (override) { + return override + } return ( findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', pathValue) ?? findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), pathValue) @@ -87,22 +104,6 @@ function defaultShimRoot(): string { return join(homedir(), '.orca', 'claude-agent-teams-bin') } -function bundledLauncherPath(): string | null { - if (!process.resourcesPath) { - return null - } - if (process.platform === 'darwin') { - return join(process.resourcesPath, 'bin', 'orca') - } - if (process.platform === 'linux') { - return join(process.resourcesPath, 'bin', 'orca-ide') - } - if (process.platform === 'win32') { - return join(process.resourcesPath, 'bin', 'orca.exe') - } - return null -} - function findExecutableOnPath(command: string, pathValue: string | undefined): string | null { for (const directory of pathValue?.split(delimiter) ?? []) { // Why: empty and relative PATH entries resolve against a cwd we do not control, which is the hijack we are avoiding. diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 9499d3c0766..1d311552dfc 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -3,6 +3,7 @@ export const CLI_COMMAND_NAMES = [ 'account', 'agent', 'agent-context', + 'agent-teams-tmux', 'artifacts', 'automations', 'back',