mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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.
This commit is contained in:
@@ -26,7 +26,8 @@ export class ClaudeAgentTeamsService {
|
||||
leaderHandle: string
|
||||
baseEnv: Record<string, string | undefined>
|
||||
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
|
||||
|
||||
@@ -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<string, string> => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, string | undefined> = 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')
|
||||
}
|
||||
|
||||
@@ -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-/)
|
||||
|
||||
Reference in New Issue
Block a user