From b04da03ffcd06bc85303b288a8e9423d8ce263cf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:23:52 -0700 Subject: [PATCH] Require an absolute Orca CLI for the agent-teams tmux shim (#14438) The generated tmux shim fell back to a bare orca / orca.cmd / orca-ide, and cmd.exe resolves an unqualified command against the current directory before PATH (sh does the same via ./empty PATH entries), so a stray orca.cmd in an agent's checkout could run with the agent-teams team id and token in its environment. Resolve only absolute paths, honor the Windows Path env spelling, degrade to in-process teammates when no CLI can be qualified, and make both shims exit 127 instead of guessing. Verified on macOS, Linux (dash + bash), and Windows (cmd.exe + Git Bash). Fixes STA-4215. --- .../runtime/claude-agent-teams-service.ts | 9 +- .../claude-agent-teams-shim-env.test.ts | 106 +++++++++++++++++- .../runtime/claude-agent-teams-shim-env.ts | 67 ++++++++--- src/main/runtime/orca-runtime.test.ts | 4 + 4 files changed, 166 insertions(+), 20 deletions(-) diff --git a/src/main/runtime/claude-agent-teams-service.ts b/src/main/runtime/claude-agent-teams-service.ts index 1b0199c6bd0..34ef35ecb36 100644 --- a/src/main/runtime/claude-agent-teams-service.ts +++ b/src/main/runtime/claude-agent-teams-service.ts @@ -26,7 +26,8 @@ export class ClaudeAgentTeamsService { leaderHandle: string baseEnv: Record shimDir: string - shimBin: string + /** Absolute path only; null leaves the var unset so the shim refuses to guess a cwd-relative CLI. */ + shimBin: string | null }): AgentTeamsLaunchEnv { const teamId = `team-${randomUUID()}` const token = randomBytes(32).toString('base64url') @@ -47,8 +48,10 @@ export class ClaudeAgentTeamsService { ORCA_AGENT_TEAMS_TEAM_ID: teamId, ORCA_AGENT_TEAMS_TOKEN: token, ORCA_AGENT_TEAMS_LEADER_PANE: leaderPane, - ORCA_AGENT_TEAMS_SHIM_DIR: args.shimDir, - ORCA_AGENT_TEAMS_SHIM_BIN: args.shimBin + ORCA_AGENT_TEAMS_SHIM_DIR: args.shimDir + } + if (args.shimBin) { + env.ORCA_AGENT_TEAMS_SHIM_BIN = args.shimBin } if (args.baseEnv.ORCA_PAIRING_CODE) { env.ORCA_PAIRING_CODE = args.baseEnv.ORCA_PAIRING_CODE 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 362291e3c26..7c62d2ae01a 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.test.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -1,3 +1,5 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -5,7 +7,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { buildClaudeAgentTeamsLaunchPlan, ensureClaudeAgentTeamsShimDir, - resolveClaudeAgentTeamsShimBin + resolveClaudeAgentTeamsShimBin, + windowsClaudeAgentTeamsShimScript } from './claude-agent-teams-shim-env' const roots: string[] = [] @@ -88,4 +91,105 @@ describe('claude agent teams shim env', () => { expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(cliPath) }) + + 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) + for (const name of ['orca', 'orca-ide', 'orca.cmd']) { + const path = join(root, name) + await writeFile(path, '#!/usr/bin/env sh\n', 'utf8') + if (process.platform !== 'win32') { + await chmod(path, 0o755) + } + } + + expect(resolveClaudeAgentTeamsShimBin({ PATH: '.' })).toBeNull() + expect(resolveClaudeAgentTeamsShimBin({ PATH: '' })).toBeNull() + expect( + resolveClaudeAgentTeamsShimBin({ PATH: '.', ORCA_AGENT_TEAMS_SHIM_BIN: 'orca' }) + ).toBeNull() + // Why: a bare override is still honored when it maps to a real absolute PATH entry. + expect(resolveClaudeAgentTeamsShimBin({ PATH: root, ORCA_AGENT_TEAMS_SHIM_BIN: 'orca' })).toBe( + join(root, 'orca') + ) + }) + + it.skipIf(process.platform !== 'win32')( + 'resolves through the Windows `Path` env spelling', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) + roots.push(root) + const cliPath = join(root, 'orca.cmd') + await writeFile(cliPath, '@echo off\r\n', 'utf8') + + expect(resolveClaudeAgentTeamsShimBin({ Path: root })).toBe(cliPath) + } + ) + + it('falls back to in-process teammates when no absolute CLI can be qualified', async () => { + const createTeamEnv = (): Record => { + throw new Error('native shim env must not be built without a qualified CLI') + } + + await expect( + buildClaudeAgentTeamsLaunchPlan({ + command: 'claude', + mode: 'native-panes-shim', + baseEnv: { PATH: '.' }, + createTeamEnv + }) + ).resolves.toEqual({ + command: 'claude --teammate-mode in-process', + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } + }) + }) + + it.skipIf(process.platform === 'win32')( + 'never runs a cwd-resolved orca when the shim bin is unqualified', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-shim-')) + roots.push(root) + await ensureClaudeAgentTeamsShimDir(root) + const cwd = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cwd-')) + roots.push(cwd) + const marker = join(cwd, 'hijacked') + for (const name of ['orca', 'orca-ide']) { + const decoy = join(cwd, name) + await writeFile(decoy, `#!/usr/bin/env sh\ntouch ${JSON.stringify(marker)}\n`, 'utf8') + await chmod(decoy, 0o755) + } + + const hijack = spawnSync(join(root, 'tmux'), ['display-message', '-p', '#{pane_id}'], { + cwd, + env: { PATH: `.:${process.env.PATH ?? ''}` }, + encoding: 'utf8' + }) + + expect(hijack.status).toBe(127) + expect(hijack.stderr).toContain('absolute path') + expect(existsSync(marker)).toBe(false) + + const cli = join(cwd, 'fake-orca') + await writeFile(cli, '#!/usr/bin/env sh\necho "ran $*"\n', 'utf8') + await chmod(cli, 0o755) + const qualified = spawnSync(join(root, 'tmux'), ['list-panes'], { + cwd, + env: { PATH: `.:${process.env.PATH ?? ''}`, ORCA_AGENT_TEAMS_SHIM_BIN: cli }, + encoding: 'utf8' + }) + + expect(qualified.status).toBe(0) + expect(qualified.stdout.trim()).toBe('ran agent-teams-tmux list-panes') + } + ) + + it('writes a Windows shim that rejects an unqualified shim bin', () => { + const script = windowsClaudeAgentTeamsShimScript() + + expect(script).not.toMatch(/^set "ORCA_AGENT_TEAMS_SHIM_BIN=orca/m) + expect(script).toContain('if "%ORCA_SHIM_BIN:~1,1%"==":" goto :run') + // Why: `call` would re-expand `%2`-style tmux pane args as batch parameters. + expect(script).toContain('\r\n"%ORCA_SHIM_BIN%" agent-teams-tmux %*\r\n') + expect(script).toContain('exit /b 127') + }) }) diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts index 7f6c3a1922e..b8ff1b5a5c0 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -1,7 +1,7 @@ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { accessSync, constants, existsSync } from 'node:fs' import { homedir } from 'node:os' -import { delimiter, dirname, join } from 'node:path' +import { delimiter, dirname, isAbsolute, join } from 'node:path' import { addClaudeTeammateModeAuto, addClaudeTeammateModeInProcess, @@ -9,6 +9,7 @@ import { type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name' +import { resolvePathEnvKey } from '../pty/windows-path-segment-merge' export type ClaudeAgentTeamsLaunchPlan = { command: string @@ -20,7 +21,7 @@ export async function ensureClaudeAgentTeamsShimDir(root = defaultShimRoot()): P await mkdir(root, { recursive: true }) await writeIfChanged(join(root, 'tmux'), unixShimScript()) if (process.platform === 'win32') { - await writeIfChanged(join(root, 'tmux.cmd'), windowsShimScript()) + await writeIfChanged(join(root, 'tmux.cmd'), windowsClaudeAgentTeamsShimScript()) } return root } @@ -41,8 +42,15 @@ export async function buildClaudeAgentTeamsLaunchPlan(args: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } } } - const shimDir = await ensureClaudeAgentTeamsShimDir() const shimBin = resolveClaudeAgentTeamsShimBin(args.baseEnv) + if (!shimBin) { + // Why: without an absolute CLI path the shim would resolve a bare `orca` against the pane cwd, so degrade instead. + return { + command: addClaudeTeammateModeInProcess(args.command), + env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } + } + } + const shimDir = await ensureClaudeAgentTeamsShimDir() const env = args.createTeamEnv(shimDir, shimBin) return { command: addClaudeTeammateModeAuto(args.command), @@ -51,20 +59,27 @@ export async function buildClaudeAgentTeamsLaunchPlan(args: { } } +/** Absolute path to the Orca CLI that backs the tmux shim, or null when none can be qualified. */ export function resolveClaudeAgentTeamsShimBin( env: Record = process.env -): string { - if (env.ORCA_AGENT_TEAMS_SHIM_BIN) { - return env.ORCA_AGENT_TEAMS_SHIM_BIN +): 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 bundled = bundledLauncherPath() if (bundled && isExecutableFile(bundled)) { return bundled } return ( - findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', env.PATH) ?? - findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), env.PATH) ?? - getOrcaCliCommandNameForPlatform(process.platform) + findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', pathValue) ?? + findExecutableOnPath(getOrcaCliCommandNameForPlatform(process.platform), pathValue) ) } @@ -90,7 +105,8 @@ function bundledLauncherPath(): string | null { function findExecutableOnPath(command: string, pathValue: string | undefined): string | null { for (const directory of pathValue?.split(delimiter) ?? []) { - if (!directory) { + // Why: empty and relative PATH entries resolve against a cwd we do not control, which is the hijack we are avoiding. + if (!directory || !isAbsolute(directory)) { continue } const candidate = join(directory, command) @@ -113,23 +129,42 @@ function isExecutableFile(candidate: string): boolean { } } +// Why: an unqualified command name is resolved against the invoking pane's cwd (always on cmd.exe, and via `.`/empty +// PATH entries on POSIX), so a stray `orca` next to the agent's files would run with the team token. Demand a +// fully-qualified binary instead of guessing one. function unixShimScript(): string { return [ '#!/usr/bin/env sh', 'set -eu', - `exec "\${ORCA_AGENT_TEAMS_SHIM_BIN:-${getOrcaCliCommandNameForPlatform(process.platform)}}" agent-teams-tmux "$@"`, + 'orca_bin=${ORCA_AGENT_TEAMS_SHIM_BIN:-}', + 'case $orca_bin in', + ' /*|[A-Za-z]:[\\\\/]*) ;;', + ' *)', + ' echo "orca agent-teams tmux shim: ORCA_AGENT_TEAMS_SHIM_BIN must be an absolute path" >&2', + ' exit 127', + ' ;;', + 'esac', + 'exec "$orca_bin" agent-teams-tmux "$@"', '' ].join('\n') } -function windowsShimScript(): string { +export function windowsClaudeAgentTeamsShimScript(): string { return [ '@echo off', 'setlocal', - 'if "%ORCA_AGENT_TEAMS_SHIM_BIN%"=="" (', - ` set "ORCA_AGENT_TEAMS_SHIM_BIN=${getOrcaCliCommandNameForPlatform(process.platform)}"`, - ')', - '"%ORCA_AGENT_TEAMS_SHIM_BIN%" agent-teams-tmux %*', + 'set "ORCA_SHIM_BIN=%ORCA_AGENT_TEAMS_SHIM_BIN%"', + 'if not defined ORCA_SHIM_BIN goto :unqualified', + 'if "%ORCA_SHIM_BIN:~1,1%"==":" goto :run', + 'if "%ORCA_SHIM_BIN:~0,2%"=="\\\\" goto :run', + 'goto :unqualified', + ':run', + // Why: no `call` — its extra percent-expansion pass would rewrite tmux pane args such as `%2` into batch parameters. + '"%ORCA_SHIM_BIN%" agent-teams-tmux %*', + 'exit /b %ERRORLEVEL%', + ':unqualified', + 'echo orca agent-teams tmux shim: ORCA_AGENT_TEAMS_SHIM_BIN must be an absolute path 1>&2', + 'exit /b 127', '' ].join('\r\n') } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9beb782cebc..18880defc77 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14263,6 +14263,8 @@ describe('OrcaRuntimeService', () => { command: 'claude --resume claude-session', env: { CLAUDE_PROFILE: 'captured', + // Why: native panes need an absolute CLI; without one the plan degrades to in-process teammates. + ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide', ORCA_AGENT_TEAMS_TEAM_ID: 'stale-team', ORCA_AGENT_TEAMS_TOKEN: 'stale-token', TMUX: '/tmp/orca-claude-agent-teams/stale-team,0,1' @@ -14273,6 +14275,7 @@ describe('OrcaRuntimeService', () => { agentArgs: '--teammate-mode auto', agentEnv: { CLAUDE_PROFILE: 'captured', + ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide', ORCA_AGENT_TEAMS_TEAM_ID: 'stale-team', ORCA_AGENT_TEAMS_TOKEN: 'stale-token', TMUX: '/tmp/orca-claude-agent-teams/stale-team,0,1' @@ -14287,6 +14290,7 @@ describe('OrcaRuntimeService', () => { expect(spawnCall?.env).toMatchObject({ CLAUDE_PROFILE: 'captured', CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca-ide', TMUX_PANE: '%1' }) expect(spawnCall?.env?.ORCA_AGENT_TEAMS_TEAM_ID).toMatch(/^team-/)