From 0c4e8049fd73c69f28e273a219b766f2d30b17d2 Mon Sep 17 00:00:00 2001 From: Proletariat Agent Date: Wed, 6 May 2026 15:50:11 -0600 Subject: [PATCH] feat(PRLT-1369): add --executor-env and --executor-bin flags for multi-account Claude support --- apps/cli/src/commands/agent/shell.ts | 81 ++++++++-- apps/cli/src/commands/claude/index.ts | 22 +++ apps/cli/src/commands/orchestrator/start.ts | 14 ++ apps/cli/src/commands/qa/index.ts | 22 +++ apps/cli/src/commands/work/run.ts | 14 ++ apps/cli/src/commands/work/spawn.ts | 24 +++ apps/cli/src/commands/work/start.ts | 22 +++ .../src/lib/execution/executor-overrides.ts | 120 +++++++++++++++ apps/cli/src/lib/execution/runners/cloud.ts | 13 +- .../src/lib/execution/runners/devcontainer.ts | 17 ++- apps/cli/src/lib/execution/runners/docker.ts | 5 +- .../cli/src/lib/execution/runners/executor.ts | 31 +++- apps/cli/src/lib/execution/runners/host.ts | 16 +- apps/cli/src/lib/execution/spawner.ts | 6 + apps/cli/src/lib/execution/types.ts | 5 + apps/cli/test/unit/executor-overrides.test.ts | 143 ++++++++++++++++++ 16 files changed, 524 insertions(+), 31 deletions(-) create mode 100644 apps/cli/src/lib/execution/executor-overrides.ts create mode 100644 apps/cli/test/unit/executor-overrides.test.ts diff --git a/apps/cli/src/commands/agent/shell.ts b/apps/cli/src/commands/agent/shell.ts index 5188158e6..038ea8606 100644 --- a/apps/cli/src/commands/agent/shell.ts +++ b/apps/cli/src/commands/agent/shell.ts @@ -1,4 +1,4 @@ -import { Args } from '@oclif/core'; +import { Args, Flags } from '@oclif/core'; import * as path from 'node:path'; import * as fs from 'node:fs'; import { execSync, spawn } from 'node:child_process'; @@ -19,6 +19,10 @@ import { shouldOutputJson, } from '../../lib/prompt-json.js'; import { trackChildProcess } from '../../lib/signal-handler.js'; +import { + buildShellExports, + parseExecutorEnv, +} from '../../lib/execution/executor-overrides.js'; export default class Shell extends PMOCommand { static description = 'Open an interactive shell in an agent workspace'; @@ -37,6 +41,15 @@ export default class Shell extends PMOCommand { static flags = { ...pmoBaseFlags, + 'executor-env': Flags.string({ + description: + 'Set env var on Claude (KEY=VALUE). Repeatable. ' + + 'e.g. --executor-env CLAUDE_CONFIG_DIR=$HOME/.claude-work to switch accounts.', + multiple: true, + }), + 'executor-bin': Flags.string({ + description: 'Override the claude binary path (e.g. wrapper script). Defaults to "claude".', + }), }; protected getPMOOptions() { @@ -49,6 +62,15 @@ export default class Shell extends PMOCommand { // Check if JSON output mode is active const jsonMode = shouldOutputJson(flags); + // PRLT-1369: parse --executor-env / --executor-bin once up front + let executorEnv: Record | undefined + try { + executorEnv = parseExecutorEnv(flags['executor-env'] as string[] | undefined) + } catch (err) { + this.error(err instanceof Error ? err.message : String(err)) + } + const executorBin = (flags['executor-bin'] as string | undefined) || 'claude' + // Error handling config const errorConfig = { jsonMode, commandName: 'agent shell', flags }; @@ -172,9 +194,9 @@ export default class Shell extends PMOCommand { const dangerMode = permissionMode === 'danger'; if (environment === 'devcontainer') { - await this.openDevcontainerShell(workspaceInfo.path, agentDir, agentName!, displayMode, dangerMode); + await this.openDevcontainerShell(workspaceInfo.path, agentDir, agentName!, displayMode, dangerMode, executorEnv, executorBin); } else { - await this.openHostShell(workspaceInfo.path, agentDir, agentName!, displayMode, dangerMode); + await this.openHostShell(workspaceInfo.path, agentDir, agentName!, displayMode, dangerMode, executorEnv, executorBin); } return; } @@ -245,7 +267,15 @@ export default class Shell extends PMOCommand { } } - private async openDevcontainerShell(hqPath: string, agentDir: string, agentName: string, displayMode: 'terminal' | 'foreground', dangerMode: boolean): Promise { + private async openDevcontainerShell( + hqPath: string, + agentDir: string, + agentName: string, + displayMode: 'terminal' | 'foreground', + dangerMode: boolean, + executorEnv?: Record, + executorBin: string = 'claude', + ): Promise { // Check Docker is running if (!isDockerRunning()) { this.error('Docker is not running. Please start Docker Desktop and try again.'); @@ -281,8 +311,18 @@ export default class Shell extends PMOCommand { this.log(colors.success(`✓ Container running: ${containerId}`)); this.log(''); + // PRLT-1369: pass --executor-env values into the container as -e KEY=VALUE flags. + const envFlags: string[] = [] + if (executorEnv) { + for (const [k, v] of Object.entries(executorEnv)) { + envFlags.push('-e', `${k}=${v}`) + } + } + // The command to run inside the container - const claudeArgs = dangerMode ? ['exec', '-it', '-w', '/workspace', containerId!, 'claude', '--dangerously-skip-permissions'] : ['exec', '-it', '-w', '/workspace', containerId!, 'claude']; + const claudeArgs = dangerMode + ? ['exec', '-it', '-w', '/workspace', ...envFlags, containerId!, executorBin, '--dangerously-skip-permissions'] + : ['exec', '-it', '-w', '/workspace', ...envFlags, containerId!, executorBin]; if (displayMode === 'foreground') { // Run Claude directly in current terminal @@ -318,9 +358,15 @@ export default class Shell extends PMOCommand { const scriptPath = path.join(baseDir, `shell-${agentName}-${Date.now()}.sh`); // Launch claude inside the container + // PRLT-1369: -e KEY=VALUE flags forward env vars; executorBin overrides the binary + const envFlagStr = executorEnv + ? ' ' + Object.entries(executorEnv) + .map(([k, v]) => `-e ${k}='${v.replace(/'/g, "'\\''")}'`) + .join(' ') + : '' const claudeCmd = dangerMode - ? `docker exec -it -w /workspace ${containerId} claude --dangerously-skip-permissions` - : `docker exec -it -w /workspace ${containerId} claude`; + ? `docker exec -it -w /workspace${envFlagStr} ${containerId} ${executorBin} --dangerously-skip-permissions` + : `docker exec -it -w /workspace${envFlagStr} ${containerId} ${executorBin}`; const scriptContent = `#!/bin/bash # Shell for agent ${agentName} @@ -351,15 +397,25 @@ exec bash } } - private async openHostShell(hqPath: string, agentDir: string, agentName: string, displayMode: 'terminal' | 'foreground', dangerMode: boolean): Promise { + private async openHostShell( + hqPath: string, + agentDir: string, + agentName: string, + displayMode: 'terminal' | 'foreground', + dangerMode: boolean, + executorEnv?: Record, + executorBin: string = 'claude', + ): Promise { if (displayMode === 'foreground') { this.log(colors.text(`Starting Claude Code in ${agentDir}...`)); this.log(''); const claudeArgs = dangerMode ? ['--dangerously-skip-permissions'] : []; - const child = spawn('claude', claudeArgs, { + const child = spawn(executorBin, claudeArgs, { stdio: 'inherit', cwd: agentDir, + // PRLT-1369: --executor-env merges into process env (e.g. CLAUDE_CONFIG_DIR) + env: executorEnv ? { ...process.env, ...executorEnv } : process.env, }); trackChildProcess(child); @@ -386,7 +442,10 @@ exec bash fs.mkdirSync(baseDir, { recursive: true }); const scriptPath = path.join(baseDir, `shell-${agentName}-${Date.now()}.sh`); - const claudeCmd = dangerMode ? 'claude --dangerously-skip-permissions' : 'claude'; + const claudeCmd = dangerMode ? `${executorBin} --dangerously-skip-permissions` : executorBin; + // PRLT-1369: emit `export KEY='VALUE'` lines for user-supplied env (e.g. CLAUDE_CONFIG_DIR) + const envExports = buildShellExports(executorEnv); + const envBlock = envExports ? `\n${envExports}\n` : ''; const scriptContent = `#!/bin/bash # Shell for agent ${agentName} @@ -395,7 +454,7 @@ echo "======================================" echo "Agent Shell: ${agentName} (host)" echo "======================================" echo "" - +${envBlock} cd "${agentDir}" # Run Claude Code diff --git a/apps/cli/src/commands/claude/index.ts b/apps/cli/src/commands/claude/index.ts index 993d18ed9..0b9f04fa1 100644 --- a/apps/cli/src/commands/claude/index.ts +++ b/apps/cli/src/commands/claude/index.ts @@ -39,6 +39,7 @@ import { hasShellPreference, } from '../../lib/execution/config.js' import { hasDevcontainerConfig } from '../../lib/execution/devcontainer.js' +import { readExecutorOverridesFromFlags } from '../../lib/execution/executor-overrides.js' // Catch-all devcontainer image for directories without .devcontainer const CATCHALL_DEVCONTAINER_IMAGE = 'ghcr.io/chrismcdermut/proletariat-claude:latest' @@ -101,6 +102,15 @@ export default class Claude extends PromptCommand { prompt: Flags.string({ description: 'Initial task/prompt for Claude', }), + 'executor-env': Flags.string({ + description: + 'Set env var on Claude (KEY=VALUE). Repeatable. ' + + 'e.g. --executor-env CLAUDE_CONFIG_DIR=$HOME/.claude-work to switch accounts.', + multiple: true, + }), + 'executor-bin': Flags.string({ + description: 'Override the claude binary path (e.g. wrapper script). Defaults to "claude".', + }), directory: Flags.string({ description: 'Directory to run in (default: cwd)', }), @@ -151,6 +161,8 @@ export default class Claude extends PromptCommand { environment?: string 'display-mode'?: string prompt?: string + 'executor-env'?: string[] + 'executor-bin'?: string json: boolean }, jsonMode: boolean @@ -396,6 +408,7 @@ export default class Claude extends PromptCommand { // Build minimal execution context for yolo mode // Use devcontainerConfigDir for worktreePath so runner finds .devcontainer there + const yoloOverrides = readExecutorOverridesFromFlags(flags) const context: ExecutionContext = { ticketId: 'ADHOC', ticketTitle: `Ad-hoc: ${slug}`, @@ -406,6 +419,9 @@ export default class Claude extends PromptCommand { actionName: slug, actionPrompt: flags.prompt, modifiesCode: false, // Don't try to create branches + // PRLT-1369: switch Claude accounts / use wrapper binary + executorEnv: yoloOverrides?.env, + executorBin: yoloOverrides?.bin, } // Load execution config (use defaults for yolo mode) @@ -507,6 +523,8 @@ export default class Claude extends PromptCommand { prompt?: string project?: string title?: string + 'executor-env'?: string[] + 'executor-bin'?: string json: boolean }, jsonMode: boolean @@ -850,6 +868,7 @@ export default class Claude extends PromptCommand { this.log(styles.success(` Agent: ${agentName}`)) // Build execution context + const trackedOverrides = readExecutorOverridesFromFlags(flags) const context: ExecutionContext = { ticketId: ticket.id, ticketTitle: ticket.title, @@ -864,6 +883,9 @@ export default class Claude extends PromptCommand { actionName: 'Ad-hoc', actionPrompt: flags.prompt, modifiesCode: false, // Don't manage branches for adhoc + // PRLT-1369: switch Claude accounts / use wrapper binary + executorEnv: trackedOverrides?.env, + executorBin: trackedOverrides?.bin, } // Create execution record diff --git a/apps/cli/src/commands/orchestrator/start.ts b/apps/cli/src/commands/orchestrator/start.ts index 35bb7851a..2c345263d 100644 --- a/apps/cli/src/commands/orchestrator/start.ts +++ b/apps/cli/src/commands/orchestrator/start.ts @@ -25,6 +25,7 @@ import { runOrchestratorInDocker, } from '../../lib/execution/runners.js' import { getHostTmuxSessionNames } from '../../lib/execution/session-utils.js' +import { readExecutorOverridesFromFlags } from '../../lib/execution/executor-overrides.js' import { ExecutionStorage } from '../../lib/execution/storage.js' import { createMirrorExecution, @@ -238,6 +239,15 @@ export default class OrchestratorStart extends RuntimeCommand { description: 'Executor type', options: ['claude-code', 'codex', 'custom'], }), + 'executor-env': Flags.string({ + description: + 'Set env var on the orchestrator executor (KEY=VALUE). Repeatable. ' + + 'e.g. --executor-env CLAUDE_CONFIG_DIR=$HOME/.claude-work', + multiple: true, + }), + 'executor-bin': Flags.string({ + description: 'Override executor binary path. Defaults to claude/codex.', + }), 'skip-permissions': Flags.boolean({ description: 'Skip permission checks (shorthand for --permission-mode danger)', default: false, @@ -574,6 +584,7 @@ export default class OrchestratorStart extends RuntimeCommand { // Build execution context // Use ticketId='prlt', actionName='orchestrator', agentName=orchestratorName // so buildSessionName produces 'prlt-orchestrator-{name}' + const orchExecutorOverrides = readExecutorOverridesFromFlags(flags) const context: ExecutionContext = { ticketId: 'prlt', ticketTitle: 'Orchestrator', @@ -588,6 +599,9 @@ export default class OrchestratorStart extends RuntimeCommand { isOrchestrator: true, hqName, executionEnvironment: environment, + // PRLT-1369: pin orchestrator session to a specific Claude account / binary + executorEnv: orchExecutorOverrides?.env, + executorBin: orchExecutorOverrides?.bin, } // Build execution config diff --git a/apps/cli/src/commands/qa/index.ts b/apps/cli/src/commands/qa/index.ts index e78331b63..d43069ca4 100644 --- a/apps/cli/src/commands/qa/index.ts +++ b/apps/cli/src/commands/qa/index.ts @@ -39,6 +39,7 @@ import { hasShellPreference, } from '../../lib/execution/config.js' import { hasDevcontainerConfig } from '../../lib/execution/devcontainer.js' +import { readExecutorOverridesFromFlags } from '../../lib/execution/executor-overrides.js' // Catch-all devcontainer image for directories without .devcontainer const CATCHALL_DEVCONTAINER_IMAGE = 'ghcr.io/chrismcdermut/proletariat-claude:latest' @@ -86,6 +87,15 @@ export default class QA extends PromptCommand { prompt: Flags.string({ description: 'Additional instructions to append to the QA prompt', }), + 'executor-env': Flags.string({ + description: + 'Set env var on Claude (KEY=VALUE). Repeatable. ' + + 'e.g. --executor-env CLAUDE_CONFIG_DIR=$HOME/.claude-work to switch accounts.', + multiple: true, + }), + 'executor-bin': Flags.string({ + description: 'Override the claude binary path (e.g. wrapper script). Defaults to "claude".', + }), } async run(): Promise { @@ -243,6 +253,8 @@ Clean up your tmux session when done.`, 'display-mode'?: string 'permission-mode'?: string prompt?: string + 'executor-env'?: string[] + 'executor-bin'?: string json: boolean }, jsonMode: boolean @@ -376,6 +388,7 @@ Clean up your tmux session when done.`, } // Build execution context + const qaHQOverrides = readExecutorOverridesFromFlags(flags) const context: ExecutionContext = { ticketId: ticket.id, ticketTitle: ticket.title, @@ -391,6 +404,9 @@ Clean up your tmux session when done.`, actionPrompt, actionEndPrompt: actionData.endPrompt, modifiesCode: false, + // PRLT-1369: switch Claude accounts / use wrapper binary + executorEnv: qaHQOverrides?.env, + executorBin: qaHQOverrides?.bin, } // Create execution record @@ -488,6 +504,8 @@ Clean up your tmux session when done.`, 'display-mode'?: string 'permission-mode'?: string prompt?: string + 'executor-env'?: string[] + 'executor-bin'?: string json: boolean }, jsonMode: boolean @@ -553,6 +571,7 @@ Clean up your tmux session when done.`, } // Build execution context + const qaYoloOverrides = readExecutorOverridesFromFlags(flags) const context: ExecutionContext = { ticketId: 'QA', ticketTitle: 'Exploratory QA Session', @@ -565,6 +584,9 @@ Clean up your tmux session when done.`, actionPrompt, actionEndPrompt: actionData.endPrompt, modifiesCode: false, + // PRLT-1369: switch Claude accounts / use wrapper binary + executorEnv: qaYoloOverrides?.env, + executorBin: qaYoloOverrides?.bin, } // Load execution config diff --git a/apps/cli/src/commands/work/run.ts b/apps/cli/src/commands/work/run.ts index 4d14c513d..0c0ff3cb4 100644 --- a/apps/cli/src/commands/work/run.ts +++ b/apps/cli/src/commands/work/run.ts @@ -44,6 +44,7 @@ import { DEFAULT_EXECUTION_CONFIG, } from '../../lib/execution/types.js' import { runExecution } from '../../lib/execution/runners.js' +import { readExecutorOverridesFromFlags } from '../../lib/execution/executor-overrides.js' import { shouldOutputJson, outputErrorAsJson, createMetadata } from '../../lib/prompt-json.js' export default class WorkRun extends PromptCommand { @@ -99,6 +100,15 @@ export default class WorkRun extends PromptCommand { options: ['claude-code', 'codex'], default: 'claude-code', }), + 'executor-env': Flags.string({ + description: + 'Set env var on spawned executor (KEY=VALUE). Repeatable. ' + + 'e.g. --executor-env CLAUDE_CONFIG_DIR=$HOME/.claude-work', + multiple: true, + }), + 'executor-bin': Flags.string({ + description: 'Override executor binary path. Defaults to claude/codex.', + }), mode: Flags.string({ description: 'Display mode for agent output', options: ['terminal', 'background', 'foreground'], @@ -279,6 +289,7 @@ export default class WorkRun extends PromptCommand { // ===================================================================== // Build execution context (no ticket, just prompt) // ===================================================================== + const executorOverrides = readExecutorOverridesFromFlags(flags) const context: ExecutionContext = { ticketId: execution.id, ticketTitle: flags.prompt.substring(0, 60), @@ -294,6 +305,9 @@ export default class WorkRun extends PromptCommand { modifiesCode: workspace.isGitRepo && !keepAlive, // Non-git or persistent = no code mods expected executionEnvironment: environment, isEphemeral: !keepAlive, + // PRLT-1369: executor env/bin overrides for multi-account / wrapper scripts + executorEnv: executorOverrides?.env, + executorBin: executorOverrides?.bin, } // ===================================================================== diff --git a/apps/cli/src/commands/work/spawn.ts b/apps/cli/src/commands/work/spawn.ts index 518a1cded..853fc088b 100644 --- a/apps/cli/src/commands/work/spawn.ts +++ b/apps/cli/src/commands/work/spawn.ts @@ -137,6 +137,15 @@ export default class WorkSpawn extends PMOCommand { description: 'Override executor', options: ['claude-code', 'codex', 'custom'], }), + 'executor-env': Flags.string({ + description: + 'Set env var on spawned executor (KEY=VALUE). Repeatable. ' + + 'e.g. --executor-env CLAUDE_CONFIG_DIR=$HOME/.claude-work', + multiple: true, + }), + 'executor-bin': Flags.string({ + description: 'Override executor binary path. Defaults to claude/codex.', + }), force: Flags.boolean({ char: 'f', description: 'Start even if work already in progress', @@ -1057,6 +1066,11 @@ export default class WorkSpawn extends PMOCommand { if (flags['run-on-host']) confirmCmd += ' --run-on-host' if (flags['skip-permissions']) confirmCmd += ' --skip-permissions' if (flags.executor) confirmCmd += ` --executor ${flags.executor}` + // PRLT-1369: persist executor env/bin overrides on the confirm command + if (flags['executor-env']) { + for (const pair of flags['executor-env']) confirmCmd += ` --executor-env ${pair}` + } + if (flags['executor-bin']) confirmCmd += ` --executor-bin ${flags['executor-bin']}` if (flags.session) confirmCmd += ` --session ${flags.session}` if (flags['create-pr']) confirmCmd += ' --create-pr' if (flags['no-pr']) confirmCmd += ' --no-pr' @@ -2226,6 +2240,11 @@ export default class WorkSpawn extends PMOCommand { const displayToUse = batchDisplayMode || batchDisplay if (displayToUse && displayToUse !== 'devcontainer') startArgs.push('--display', displayToUse) if (flags.executor) startArgs.push('--executor', flags.executor) + // PRLT-1369: forward executor env/bin overrides to work:start + if (flags['executor-env']) { + for (const pair of flags['executor-env']) startArgs.push('--executor-env', pair) + } + if (flags['executor-bin']) startArgs.push('--executor-bin', flags['executor-bin']) if (batchRunOnHost) startArgs.push('--run-on-host') if (flags.force) startArgs.push('--force') if (flags.focus) startArgs.push('--focus') @@ -2245,6 +2264,11 @@ export default class WorkSpawn extends PMOCommand { const displayToUse = batchDisplayMode || batchDisplay if (displayToUse && displayToUse !== 'devcontainer') startArgs.push('--display', displayToUse) if (flags.executor) startArgs.push('--executor', flags.executor) + // PRLT-1369: forward executor env/bin overrides to work:start + if (flags['executor-env']) { + for (const pair of flags['executor-env']) startArgs.push('--executor-env', pair) + } + if (flags['executor-bin']) startArgs.push('--executor-bin', flags['executor-bin']) if (batchRunOnHost) startArgs.push('--run-on-host') if (flags.force) startArgs.push('--force') if (batchOutput) startArgs.push('--output', batchOutput) diff --git a/apps/cli/src/commands/work/start.ts b/apps/cli/src/commands/work/start.ts index 7ea70663c..af4258867 100644 --- a/apps/cli/src/commands/work/start.ts +++ b/apps/cli/src/commands/work/start.ts @@ -59,6 +59,7 @@ import { import { loadExecutionConfig, getTerminalApp, promptTerminalPreference, getShell, promptShellPreference, hasTerminalPreference, hasShellPreference, getAuthMethod, saveAuthMethod, getCreatePrDefault, getVerifyCiDefault, getMirrorToPmoDefault, getCleanupPolicy } from '../../lib/execution/config.js' import { hasDevcontainerConfig } from '../../lib/execution/devcontainer.js' import { detectRepoWorktrees, resolveWorktreePath, buildWorkspaceRepos } from '../../lib/execution/context.js' +import { readExecutorOverridesFromFlags } from '../../lib/execution/executor-overrides.js' import { isGHInstalled, isGHAuthenticated } from '../../lib/pr/index.js' import { buildLinearMetadata, @@ -329,6 +330,15 @@ export default class WorkStart extends PMOCommand { description: 'Override executor', options: ['claude-code', 'codex', 'custom'], }), + 'executor-env': Flags.string({ + description: + 'Set env var on spawned executor (KEY=VALUE). Repeatable. ' + + 'Use --executor-env CLAUDE_CONFIG_DIR=$HOME/.claude-work to switch Claude accounts.', + multiple: true, + }), + 'executor-bin': Flags.string({ + description: 'Override executor binary path (e.g. wrapper script). Defaults to claude/codex.', + }), prompt: Flags.string({ char: 'p', description: 'Custom prompt (overrides action)', @@ -1566,6 +1576,11 @@ export default class WorkStart extends PMOCommand { customMessage: externalIssueContextMessage ?? flags.message, // Connected integrations for prompt injection connectedIntegrations: getConnectedIntegrations(db), + // PRLT-1369: executor env/bin overrides for multi-account / wrapper scripts + ...(readExecutorOverridesFromFlags(flags) ? { + executorEnv: readExecutorOverridesFromFlags(flags)!.env, + executorBin: readExecutorOverridesFromFlags(flags)!.bin, + } : {}), } // Check if agent has devcontainer config @@ -3432,6 +3447,8 @@ export default class WorkStart extends PMOCommand { executor?: string session?: string 'tool-policy'?: string + 'executor-env'?: string[] + 'executor-bin'?: string } ): Promise { const agentName = agent.name @@ -3503,6 +3520,11 @@ export default class WorkStart extends PMOCommand { networkAllowlist: defaultAction?.networkAllowlist, // Connected integrations for prompt injection connectedIntegrations: getConnectedIntegrations(db), + // PRLT-1369: executor env/bin overrides for multi-account / wrapper scripts + ...(readExecutorOverridesFromFlags(flags) ? { + executorEnv: readExecutorOverridesFromFlags(flags)!.env, + executorBin: readExecutorOverridesFromFlags(flags)!.bin, + } : {}), } // Use devcontainer by default if available diff --git a/apps/cli/src/lib/execution/executor-overrides.ts b/apps/cli/src/lib/execution/executor-overrides.ts new file mode 100644 index 000000000..135640a36 --- /dev/null +++ b/apps/cli/src/lib/execution/executor-overrides.ts @@ -0,0 +1,120 @@ +/** + * Executor Overrides (PRLT-1369) + * + * Helpers for parsing --executor-env KEY=VALUE pairs and producing + * the shell/docker fragments that inject those env vars + bin overrides + * into spawned executor processes. + * + * Use case: switching Claude accounts (CLAUDE_CONFIG_DIR), pointing at a + * wrapper binary, or any other env-driven executor configuration. + */ + +import { Flags } from '@oclif/core' + +/** + * Parsed executor overrides applied to a spawned executor process. + */ +export interface ExecutorOverrides { + /** Environment variables to set on the executor (e.g. CLAUDE_CONFIG_DIR=...) */ + env?: Record + /** Override the executor binary (absolute path or wrapper script name) */ + bin?: string +} + +/** + * Parse one or more KEY=VALUE strings into a record. + * Accepts strings like: + * - "CLAUDE_CONFIG_DIR=/Users/me/.claude-work" + * - "FOO=bar=baz" (only the first '=' splits the pair) + * + * Throws on: + * - missing '=' separator + * - empty / non-identifier KEY (must match /^[A-Za-z_][A-Za-z0-9_]*$/) + */ +export function parseExecutorEnv(pairs: string[] | undefined): Record | undefined { + if (!pairs || pairs.length === 0) return undefined + + const env: Record = {} + for (const pair of pairs) { + const eq = pair.indexOf('=') + if (eq < 0) { + throw new Error(`Invalid --executor-env value "${pair}". Expected KEY=VALUE.`) + } + const key = pair.slice(0, eq) + const value = pair.slice(eq + 1) + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw new Error(`Invalid --executor-env key "${key}". Must match [A-Za-z_][A-Za-z0-9_]*.`) + } + env[key] = value + } + return env +} + +/** + * Quote a value for safe inclusion inside a single-quoted shell fragment. + * Replaces `'` with `'\''` so the result can be embedded in `'...'`. + */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +/** + * Build shell `export KEY='VALUE'` lines for a bash script. + * Returns a string of newline-separated `export` statements (or empty string). + */ +export function buildShellExports(env: Record | undefined): string { + if (!env) return '' + const lines: string[] = [] + for (const [key, value] of Object.entries(env)) { + lines.push(`export ${key}=${shellQuote(value)}`) + } + return lines.join('\n') +} + +/** + * Build `docker exec -e KEY=VALUE` flag fragments for injecting env vars + * into a docker exec invocation. Returns a string with a leading space + * when non-empty. + * + * Each value is single-quoted and escaped to be safe inside a containing + * single-quoted shell command. + */ +export function buildDockerEnvFlags(env: Record | undefined): string { + if (!env) return '' + const parts: string[] = [] + for (const [key, value] of Object.entries(env)) { + parts.push(`-e ${key}=${shellQuote(value)}`) + } + return parts.length > 0 ? ` ${parts.join(' ')}` : '' +} + +/** + * Reusable oclif Flag definitions so every command exposes the same + * `--executor-env` / `--executor-bin` surface. + */ +export const executorOverrideFlags = { + 'executor-env': Flags.string({ + description: + 'Set env var on spawned executor (KEY=VALUE). Repeatable. ' + + 'Useful for switching Claude accounts: --executor-env CLAUDE_CONFIG_DIR=$HOME/.claude-work', + multiple: true, + }), + 'executor-bin': Flags.string({ + description: + 'Override executor binary path (e.g. an absolute path or wrapper script). ' + + 'Defaults to the executor\'s native command (claude, codex).', + }), +} + +/** + * Read the executor-override flags off a parsed flag bag and return a + * normalized {env, bin} object (or undefined if no overrides were set). + */ +export function readExecutorOverridesFromFlags( + flags: { 'executor-env'?: string[]; 'executor-bin'?: string }, +): ExecutorOverrides | undefined { + const env = parseExecutorEnv(flags['executor-env']) + const bin = flags['executor-bin'] + if (!env && !bin) return undefined + return { env, bin } +} diff --git a/apps/cli/src/lib/execution/runners/cloud.ts b/apps/cli/src/lib/execution/runners/cloud.ts index d807a8f4e..47a8519ff 100644 --- a/apps/cli/src/lib/execution/runners/cloud.ts +++ b/apps/cli/src/lib/execution/runners/cloud.ts @@ -80,17 +80,24 @@ export async function runCloud( // Execute on remote using executor-appropriate command const escapedPrompt = prompt.replace(/'/g, "'\\''") - const { cmd: executorCmd, args: executorArgs } = getExecutorCommand(executor, escapedPrompt, config.permissionMode === 'danger') + const { cmd: executorCmd, args: executorArgs } = getExecutorCommand(executor, escapedPrompt, config.permissionMode === 'danger', context.executorBin) + + // PRLT-1369: Build env var prefix for the remote command (e.g. CLAUDE_CONFIG_DIR) + const envPrefix = context.executorEnv + ? Object.entries(context.executorEnv) + .map(([k, v]) => `${k}='${v.replace(/'/g, "'\\''")}'`) + .join(' ') + ' ' + : '' // Build the remote command based on executor type let remoteCmd: string if (isClaudeExecutor(executor)) { // TKT-053: Disable plan mode — VM runner is always nohup (no user to approve) // PRLT-950: Use -- to separate flags from positional prompt argument. - remoteCmd = `cd ${remoteWorkspace} && ${executorCmd} --print --disallowedTools EnterPlanMode -- '${escapedPrompt}'` + remoteCmd = `cd ${remoteWorkspace} && ${envPrefix}${executorCmd} --print --disallowedTools EnterPlanMode -- '${escapedPrompt}'` } else { const argsStr = executorArgs.map(a => a === escapedPrompt ? `'${escapedPrompt}'` : a).join(' ') - remoteCmd = `cd ${remoteWorkspace} && ${executorCmd} ${argsStr}` + remoteCmd = `cd ${remoteWorkspace} && ${envPrefix}${executorCmd} ${argsStr}` } const sshCmd = `ssh ${sshOpts} ${user}@${targetHost} "nohup ${remoteCmd} > /tmp/work-${context.ticketId}.log 2>&1 &"` diff --git a/apps/cli/src/lib/execution/runners/devcontainer.ts b/apps/cli/src/lib/execution/runners/devcontainer.ts index ad0379ed8..35aa446cc 100644 --- a/apps/cli/src/lib/execution/runners/devcontainer.ts +++ b/apps/cli/src/lib/execution/runners/devcontainer.ts @@ -41,6 +41,7 @@ import { runDevcontainerInTmux } from './devcontainer-tmux.js' import { runDevcontainerInTerminal } from './devcontainer-terminal.js' import { writeWorkspaceManifest } from '../context.js' import type { WorkspaceManifest } from '../types.js' +import { buildDockerEnvFlags } from '../executor-overrides.js' // ============================================================================= // Prompt File Management @@ -152,8 +153,10 @@ export function buildDevcontainerCommand( const disallowPlanFlag = displayMode === 'background' ? '--disallowedTools EnterPlanMode ' : '' // Tool registry (TKT-083): pass MCP config to Claude Code via --mcp-config flag const mcpConfigFlag = mcpConfigFile ? `--mcp-config ${mcpConfigFile} ` : '' + // PRLT-1369: --executor-bin overrides the claude binary inside the container + const claudeCmd = context.executorBin || 'claude' // PRLT-950: Use -- to separate flags from positional prompt argument. - executorCmd = `claude ${bypassTrustFlag}${permissionsFlag}${effortFlag}${printFlag}${disallowPlanFlag}${mcpConfigFlag}-- "$(cat ${promptFile})"` + executorCmd = `${claudeCmd} ${bypassTrustFlag}${permissionsFlag}${effortFlag}${printFlag}${disallowPlanFlag}${mcpConfigFlag}-- "$(cat ${promptFile})"` } else if (executor === 'codex') { const codexPermission: PermissionMode = permissionMode const codexContext = resolveCodexExecutionContext(displayMode, outputMode) @@ -163,17 +166,23 @@ export function buildDevcontainerCommand( } const codexResult = getCodexCommand('PLACEHOLDER', codexPermission, codexContext) const argsStr = codexResult.args.map(a => a === 'PLACEHOLDER' ? `"$(cat ${promptFile})"` : a).join(' ') - executorCmd = `${codexResult.cmd} ${argsStr}` + // PRLT-1369: honor --executor-bin for codex if provided + const codexCmd = context.executorBin || codexResult.cmd + executorCmd = `${codexCmd} ${argsStr}` } else { - const { cmd, args } = getExecutorCommand(executor, `PLACEHOLDER`, skipPermissions) + const { cmd, args } = getExecutorCommand(executor, `PLACEHOLDER`, skipPermissions, context.executorBin) const argsStr = args.map(a => a === 'PLACEHOLDER' ? `"$(cat ${promptFile})"` : a).join(' ') executorCmd = `${cmd} ${argsStr}` } const fullCmd = `${cdCmd}${executorCmd} && rm -f ${promptFile}` const ttyFlags = displayMode === 'background' ? '' : '-it ' + // PRLT-1369: Inject executor env vars into the docker exec invocation. + // Used for switching Claude accounts inside the container, e.g. + // --executor-env CLAUDE_CONFIG_DIR=/home/node/.claude-work + const envFlags = buildDockerEnvFlags(context.executorEnv) - return `docker exec ${ttyFlags}${containerId} bash -c '${fullCmd}'` + return `docker exec ${ttyFlags}${envFlags ? envFlags.trimStart() + ' ' : ''}${containerId} bash -c '${fullCmd}'` } // ============================================================================= diff --git a/apps/cli/src/lib/execution/runners/docker.ts b/apps/cli/src/lib/execution/runners/docker.ts index e8d99f569..6f1f77880 100644 --- a/apps/cli/src/lib/execution/runners/docker.ts +++ b/apps/cli/src/lib/execution/runners/docker.ts @@ -28,6 +28,7 @@ import { checkDockerDaemon, buildContainerMounts, } from './shared.js' +import { buildDockerEnvFlags } from '../executor-overrides.js' /** * Run command in a detached Docker container. @@ -65,6 +66,8 @@ export async function runDocker( dockerCmd += ` ${mounts.join(' ')}` dockerCmd += ` -w /workspace` dockerCmd += ` -e TICKET_ID="${context.ticketId}"` + // PRLT-1369: Inject user-supplied executor env vars (e.g. CLAUDE_CONFIG_DIR). + dockerCmd += buildDockerEnvFlags(context.executorEnv) if (config.docker.network) { dockerCmd += ` --network ${config.docker.network}` @@ -96,7 +99,7 @@ export async function runDocker( // Build executor command using getExecutorCommand() for correct invocation const escapedPrompt = prompt.replace(/'/g, "'\\''") - const { cmd, args } = getExecutorCommand(executor, escapedPrompt, config.permissionMode === 'danger') + const { cmd, args } = getExecutorCommand(executor, escapedPrompt, config.permissionMode === 'danger', context.executorBin) // For Claude Code in Docker, use --print for non-interactive output // Non-Claude executors use their native command format from getExecutorCommand() diff --git a/apps/cli/src/lib/execution/runners/executor.ts b/apps/cli/src/lib/execution/runners/executor.ts index 9978cf356..c4d7831f3 100644 --- a/apps/cli/src/lib/execution/runners/executor.ts +++ b/apps/cli/src/lib/execution/runners/executor.ts @@ -14,25 +14,40 @@ import { } from '../types.js' import { getCodexCommand } from '../codex-adapter.js' -export function getExecutorCommand(executor: ExecutorType, prompt: string, skipPermissions: boolean = true): { cmd: string; args: string[] } { +export function getExecutorCommand( + executor: ExecutorType, + prompt: string, + skipPermissions: boolean = true, + binOverride?: string, +): { cmd: string; args: string[] } { + // PRLT-1369: --executor-bin overrides the default binary (e.g. wrap claude with + // a custom script, or point at an alternate install). 'custom' executor keeps + // its echo fallback unless the user explicitly supplies --executor-bin. switch (executor) { - case 'claude-code': + case 'claude-code': { + const cmd = binOverride || 'claude' if (skipPermissions) { - return { cmd: 'claude', args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--effort', 'high', prompt] } + return { cmd, args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--effort', 'high', prompt] } } - return { cmd: 'claude', args: [prompt] } + return { cmd, args: [prompt] } + } case 'codex': { const codexPermission: PermissionMode = skipPermissions ? 'danger' : 'safe' const codexResult = getCodexCommand(prompt, codexPermission, 'interactive') - return { cmd: codexResult.cmd, args: codexResult.args } + return { cmd: binOverride || codexResult.cmd, args: codexResult.args } } case 'custom': + if (binOverride) { + return { cmd: binOverride, args: [prompt] } + } return { cmd: 'echo', args: ['Custom executor not configured'] } - default: + default: { + const cmd = binOverride || 'claude' if (skipPermissions) { - return { cmd: 'claude', args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--effort', 'high', prompt] } + return { cmd, args: ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions', '--effort', 'high', prompt] } } - return { cmd: 'claude', args: [prompt] } + return { cmd, args: [prompt] } + } } } diff --git a/apps/cli/src/lib/execution/runners/host.ts b/apps/cli/src/lib/execution/runners/host.ts index e1580c43f..036abb84e 100644 --- a/apps/cli/src/lib/execution/runners/host.ts +++ b/apps/cli/src/lib/execution/runners/host.ts @@ -11,6 +11,7 @@ import { shouldUseControlMode, buildTmuxMouseOption, buildTmuxTitleOptions, buildTmuxAttachCommand, configureITermTmuxWindowMode, } from './shared.js' import { buildSrtCommand } from './sandbox.js' +import { buildShellExports } from '../executor-overrides.js' /** * Run command on the host machine with tmux session for persistence. @@ -45,7 +46,7 @@ export async function runHost( } } - const { cmd, args: _args } = getExecutorCommand(executor, prompt, skipPermissions) + const { cmd, args: _args } = getExecutorCommand(executor, prompt, skipPermissions, context.executorBin) // Write command to temp script to avoid shell escaping issues // Use HQ .proletariat/scripts if available, otherwise fallback to home dir @@ -125,11 +126,13 @@ export async function runHost( const codexContext = resolveCodexExecutionContext(displayMode, config.outputMode) const codexResult = getCodexCommand('PLACEHOLDER', codexPermission, codexContext) const argsStr = codexResult.args.map(a => a === 'PLACEHOLDER' ? '"$(cat "$PROMPT_PATH")"' : a).join(' ') - executorInvocation = `${codexResult.cmd} ${argsStr}` + // PRLT-1369: honor --executor-bin for codex if provided + const codexCmd = context.executorBin || codexResult.cmd + executorInvocation = `${codexCmd} ${argsStr}` } else { // Non-Claude, non-Codex executors: build command from getExecutorCommand() args // Use PLACEHOLDER for reliable prompt replacement instead of fragile string comparison - const { cmd: execCmd, args: execArgs } = getExecutorCommand(executor, 'PLACEHOLDER', skipPermissions) + const { cmd: execCmd, args: execArgs } = getExecutorCommand(executor, 'PLACEHOLDER', skipPermissions, context.executorBin) const argsWithFile = execArgs.map(a => a === 'PLACEHOLDER' ? '"$(cat "$PROMPT_PATH")"' : `"${a}"`) executorInvocation = `${execCmd} ${argsWithFile.join(' ')}` } @@ -140,6 +143,11 @@ export async function runHost( // Without export, `bash -c '...'` inside srt can't access the variable. const systemPromptVar = systemPromptPath ? `\nexport SYSTEM_PROMPT_PATH="${systemPromptPath}"` : '' + // PRLT-1369: Export user-supplied executor env vars (e.g. CLAUDE_CONFIG_DIR for + // multi-account Claude). Exported so they're inherited by srt sandbox child processes. + const executorEnvExports = buildShellExports(context.executorEnv) + const executorEnvBlock = executorEnvExports ? `\n${executorEnvExports}` : '' + // PRLT-1337: Build exit code capture block. // After the executor exits, capture its exit code and report it back to agent_work // via `prlt execution complete`. This writes completed_at, exit_code, and status. @@ -209,7 +217,7 @@ SCRIPT_PATH="${scriptPath}" # srt ... -- bash -c 'claude ... "$(cat "$PROMPT_PATH")"' # Without export, the inner bash started by srt cannot access PROMPT_PATH, # causing $(cat "$PROMPT_PATH") to expand to empty and the agent to start idle. -export PROMPT_PATH="${promptPath}"${systemPromptVar} +export PROMPT_PATH="${promptPath}"${systemPromptVar}${executorEnvBlock} ${setTitleCmds} echo "🚀 Starting: ${sessionName}" ${context.executionEnvironment === 'sandbox' ? 'echo "🔒 Running in srt sandbox (filesystem + network isolation)"' : ''} diff --git a/apps/cli/src/lib/execution/spawner.ts b/apps/cli/src/lib/execution/spawner.ts index 2c905717c..602b1e630 100644 --- a/apps/cli/src/lib/execution/spawner.ts +++ b/apps/cli/src/lib/execution/spawner.ts @@ -185,6 +185,10 @@ export interface SpawnOptions { cleanupPolicy?: CleanupPolicy /** Extra domains to allow in container firewall (PRLT-1079) */ networkAllowlist?: string[] + /** PRLT-1369: env vars to set on the spawned executor (e.g. CLAUDE_CONFIG_DIR for account switching) */ + executorEnv?: Record + /** PRLT-1369: override the executor binary (absolute path or wrapper script) */ + executorBin?: string } export interface SpawnResult { @@ -376,6 +380,8 @@ export async function spawnAgentForTicket( createPR: options.createPR ?? false, toolPolicy: options.toolPolicy, networkAllowlist: options.networkAllowlist, + executorEnv: options.executorEnv, + executorBin: options.executorBin, } // Determine execution environment and display mode diff --git a/apps/cli/src/lib/execution/types.ts b/apps/cli/src/lib/execution/types.ts index 7d5011a54..ddfe703f5 100644 --- a/apps/cli/src/lib/execution/types.ts +++ b/apps/cli/src/lib/execution/types.ts @@ -256,6 +256,11 @@ export interface ExecutionContext { toolPolicy?: string // Policy profile name (e.g., 'code-agent') for tool access control // PRLT-1337: Execution lifecycle tracking executionId?: string // agent_work row ID — passed to runner scripts for exit code reporting + // PRLT-1369: Multiple Claude accounts / custom executor binaries + /** Env vars exported on the spawned executor process (e.g. CLAUDE_CONFIG_DIR for account switching). */ + executorEnv?: Record + /** Override the executor binary (absolute path or wrapper script). Defaults to the executor's native command. */ + executorBin?: string } // ============================================================================= diff --git a/apps/cli/test/unit/executor-overrides.test.ts b/apps/cli/test/unit/executor-overrides.test.ts new file mode 100644 index 000000000..d8514b787 --- /dev/null +++ b/apps/cli/test/unit/executor-overrides.test.ts @@ -0,0 +1,143 @@ +import { expect } from 'chai' +import { + parseExecutorEnv, + buildShellExports, + buildDockerEnvFlags, + readExecutorOverridesFromFlags, +} from '../../src/lib/execution/executor-overrides.js' +import { getExecutorCommand } from '../../src/lib/execution/runners/executor.js' + +/** + * Tests for the executor override helpers (PRLT-1369). + * Covers --executor-env parsing, shell/docker fragment building, and the + * binOverride parameter on getExecutorCommand. + */ + +describe('Executor Overrides (PRLT-1369)', () => { + describe('parseExecutorEnv', () => { + it('returns undefined for empty/missing input', () => { + expect(parseExecutorEnv(undefined)).to.be.undefined + expect(parseExecutorEnv([])).to.be.undefined + }) + + it('parses a single KEY=VALUE pair', () => { + const result = parseExecutorEnv(['CLAUDE_CONFIG_DIR=/Users/me/.claude-work']) + expect(result).to.deep.equal({ CLAUDE_CONFIG_DIR: '/Users/me/.claude-work' }) + }) + + it('parses multiple pairs', () => { + const result = parseExecutorEnv(['A=1', 'B=2', 'C=hello world']) + expect(result).to.deep.equal({ A: '1', B: '2', C: 'hello world' }) + }) + + it('keeps everything after the first = in the value', () => { + const result = parseExecutorEnv(['DSN=postgres://u:p=secret@host/db']) + expect(result).to.deep.equal({ DSN: 'postgres://u:p=secret@host/db' }) + }) + + it('throws on missing =', () => { + expect(() => parseExecutorEnv(['NOEQUALS'])).to.throw(/KEY=VALUE/) + }) + + it('throws on invalid identifier key', () => { + expect(() => parseExecutorEnv(['1BAD=value'])).to.throw(/Invalid --executor-env key/) + expect(() => parseExecutorEnv(['has-dash=value'])).to.throw(/Invalid --executor-env key/) + }) + }) + + describe('buildShellExports', () => { + it('returns empty string for undefined env', () => { + expect(buildShellExports(undefined)).to.equal('') + }) + + it('emits one export line per key', () => { + const out = buildShellExports({ CLAUDE_CONFIG_DIR: '/home/me/.claude-work', FOO: 'bar' }) + expect(out).to.include(`export CLAUDE_CONFIG_DIR='/home/me/.claude-work'`) + expect(out).to.include(`export FOO='bar'`) + }) + + it('escapes single quotes in values', () => { + const out = buildShellExports({ MSG: "it's fine" }) + // Single-quote escaping: ' → '\'' + expect(out).to.equal(`export MSG='it'\\''s fine'`) + }) + }) + + describe('buildDockerEnvFlags', () => { + it('returns empty string for undefined env', () => { + expect(buildDockerEnvFlags(undefined)).to.equal('') + }) + + it('emits -e KEY=VALUE flags with leading space', () => { + const out = buildDockerEnvFlags({ CLAUDE_CONFIG_DIR: '/home/node/.claude-work' }) + expect(out).to.equal(` -e CLAUDE_CONFIG_DIR='/home/node/.claude-work'`) + }) + + it('joins multiple env vars', () => { + const out = buildDockerEnvFlags({ A: '1', B: '2' }) + expect(out).to.include(`-e A='1'`) + expect(out).to.include(`-e B='2'`) + }) + }) + + describe('readExecutorOverridesFromFlags', () => { + it('returns undefined when no overrides set', () => { + expect(readExecutorOverridesFromFlags({})).to.be.undefined + }) + + it('parses env-only', () => { + const result = readExecutorOverridesFromFlags({ 'executor-env': ['FOO=bar'] }) + expect(result).to.deep.equal({ env: { FOO: 'bar' }, bin: undefined }) + }) + + it('parses bin-only', () => { + const result = readExecutorOverridesFromFlags({ 'executor-bin': '/usr/local/bin/claude-wrapper' }) + expect(result).to.deep.equal({ env: undefined, bin: '/usr/local/bin/claude-wrapper' }) + }) + + it('parses both', () => { + const result = readExecutorOverridesFromFlags({ + 'executor-env': ['CLAUDE_CONFIG_DIR=/x'], + 'executor-bin': 'claude-wrapper', + }) + expect(result).to.deep.equal({ + env: { CLAUDE_CONFIG_DIR: '/x' }, + bin: 'claude-wrapper', + }) + }) + }) + + describe('getExecutorCommand binOverride (PRLT-1369)', () => { + it('uses default "claude" binary when no override', () => { + const { cmd } = getExecutorCommand('claude-code', 'do work', true) + expect(cmd).to.equal('claude') + }) + + it('uses binOverride for claude-code', () => { + const { cmd } = getExecutorCommand('claude-code', 'do work', true, '/usr/local/bin/claude-wrapper') + expect(cmd).to.equal('/usr/local/bin/claude-wrapper') + }) + + it('preserves args when binOverride is supplied (claude-code, danger)', () => { + const { args } = getExecutorCommand('claude-code', 'do work', true, '/wrap/claude') + expect(args).to.include('--dangerously-skip-permissions') + expect(args).to.include('do work') + }) + + it('uses binOverride for codex', () => { + const { cmd } = getExecutorCommand('codex', 'do work', true, '/usr/local/bin/codex-wrapper') + expect(cmd).to.equal('/usr/local/bin/codex-wrapper') + }) + + it('promotes "custom" executor with bin override into a real command', () => { + const { cmd, args } = getExecutorCommand('custom', 'do work', true, '/path/to/agent') + expect(cmd).to.equal('/path/to/agent') + expect(args).to.deep.equal(['do work']) + }) + + it('falls back to echo for "custom" with no bin override', () => { + const { cmd } = getExecutorCommand('custom', 'do work') + expect(cmd).to.equal('echo') + }) + }) +})