diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 3b00279f..210d3236 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1295,6 +1295,94 @@ describe('docker-manager', () => { expect(agent.command).toEqual(['/bin/bash', '-c', 'echo $$HOME && echo $${USER}']); }); + it('should rewrite inline --prompt $(cat ...) to stdin redirect to avoid ARG_MAX expansion', () => { + const configWithInlinePrompt = { + ...mockConfig, + agentCommand: 'node /tmp/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" --allow-all-tools', + }; + const result = generateDockerCompose(configWithInlinePrompt, mockNetworkConfig); + const agent = result.services.agent; + + expect(agent.command).toEqual([ + '/bin/bash', + '-c', + 'node /tmp/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --prompt - < /tmp/gh-aw/aw-prompts/prompt.txt --allow-all-tools', + ]); + }); + + it('should rewrite inline --prompt $(cat ...) when cat path is single-quoted', () => { + const configWithQuotedPath = { + ...mockConfig, + agentCommand: 'node /tmp/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --prompt "$(cat \'/tmp/gh-aw/aw-prompts/prompt with spaces.txt\')" --allow-all-tools', + }; + const result = generateDockerCompose(configWithQuotedPath, mockNetworkConfig); + const agent = result.services.agent; + + expect(agent.command).toEqual([ + '/bin/bash', + '-c', + 'node /tmp/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --prompt - < \'/tmp/gh-aw/aw-prompts/prompt with spaces.txt\' --allow-all-tools', + ]); + }); + + it('should rewrite unquoted --prompt $(cat ...) form', () => { + const configWithUnquotedInlinePrompt = { + ...mockConfig, + agentCommand: 'node /tmp/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --prompt $(cat /tmp/gh-aw/aw-prompts/prompt.txt) --allow-all-tools', + }; + const result = generateDockerCompose(configWithUnquotedInlinePrompt, mockNetworkConfig); + const agent = result.services.agent; + + expect(agent.command).toEqual([ + '/bin/bash', + '-c', + 'node /tmp/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --prompt - < /tmp/gh-aw/aw-prompts/prompt.txt --allow-all-tools', + ]); + }); + + it('should not rewrite regular literal --prompt values', () => { + const configWithLiteralPrompt = { + ...mockConfig, + agentCommand: 'copilot --prompt "hello world"', + }; + const result = generateDockerCompose(configWithLiteralPrompt, mockNetworkConfig); + const agent = result.services.agent; + + expect(agent.command).toEqual(['/bin/bash', '-c', 'copilot --prompt "hello world"']); + }); + + it('should preserve shell variable expansion in prompt paths', () => { + const configWithVarPath = { + ...mockConfig, + agentCommand: 'node driver.cjs copilot --prompt "$(cat "$RUNNER_TEMP/prompt.txt")" --allow-all-tools', + }; + const result = generateDockerCompose(configWithVarPath, mockNetworkConfig); + const agent = result.services.agent; + + // Env-var path must NOT be single-quoted; $RUNNER_TEMP must expand at runtime + expect(agent.command).toEqual([ + '/bin/bash', + '-c', + 'node driver.cjs copilot --prompt - < "$$RUNNER_TEMP/prompt.txt" --allow-all-tools', + ]); + }); + + it('should preserve compound command semantics when rewriting prompt', () => { + const configWithPipeline = { + ...mockConfig, + agentCommand: 'node driver.cjs copilot --prompt "$(cat /tmp/prompt.txt)" --allow-all-tools 2>&1 | tee -a /tmp/agent-stdio.log', + }; + const result = generateDockerCompose(configWithPipeline, mockNetworkConfig); + const agent = result.services.agent; + + // Input redirection is in-place; the trailing pipeline is preserved + expect(agent.command).toEqual([ + '/bin/bash', + '-c', + 'node driver.cjs copilot --prompt - < /tmp/prompt.txt --allow-all-tools 2>&1 | tee -a /tmp/agent-stdio.log', + ]); + }); + it('should pass through GITHUB_TOKEN when present in environment', () => { const originalEnv = process.env.GITHUB_TOKEN; process.env.GITHUB_TOKEN = 'ghp_testtoken123'; diff --git a/src/docker-manager.ts b/src/docker-manager.ts index ff00583b..e3495d87 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -51,6 +51,78 @@ let agentExternallyKilled = false; */ let awfDockerHostOverride: string | undefined; +function trimMatchingOuterQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('\'') && trimmed.endsWith('\'')) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function findInlinePromptCatRange(command: string): { start: number; end: number; promptPath: string; rawPromptPath: string } | null { + // Variant 1: --prompt "$(cat /path/to/prompt.txt)" + const quotedPrefix = '--prompt "$(cat '; + const quotedStart = command.indexOf(quotedPrefix); + if (quotedStart !== -1) { + const pathStart = quotedStart + quotedPrefix.length; + const pathEnd = command.indexOf(')"', pathStart); + if (pathEnd !== -1) { + return { + start: quotedStart, + end: pathEnd + 2, + promptPath: trimMatchingOuterQuotes(command.slice(pathStart, pathEnd)), + rawPromptPath: command.slice(pathStart, pathEnd).trim(), + }; + } + } + + // Variant 2: --prompt $(cat /path/to/prompt.txt) + const unquotedPrefix = '--prompt $(cat '; + const unquotedStart = command.indexOf(unquotedPrefix); + if (unquotedStart !== -1) { + const pathStart = unquotedStart + unquotedPrefix.length; + const pathEnd = command.indexOf(')', pathStart); + if (pathEnd !== -1) { + return { + start: unquotedStart, + end: pathEnd + 1, + promptPath: trimMatchingOuterQuotes(command.slice(pathStart, pathEnd)), + rawPromptPath: command.slice(pathStart, pathEnd).trim(), + }; + } + } + + return null; +} + +/** + * Rewrites inline prompt expansions of the form: + * --prompt "$(cat /path/to/prompt.txt)" + * to: + * --prompt - < /path/to/prompt.txt + * + * Uses in-place input redirection (`< file`) instead of prepending `cat |`, + * which preserves shell semantics for compound commands (pipes, `&&`, `;`). + * The original path quoting/expansion is preserved so env-var paths like + * `"$GH_AW_PROMPT"` continue to expand correctly. + */ +function rewriteInlinePromptCatToStdin(agentCommand: string): string { + const inlinePrompt = findInlinePromptCatRange(agentCommand); + if (!inlinePrompt) return agentCommand; + + const rawPath = inlinePrompt.rawPromptPath; + if (!rawPath) return agentCommand; + + const rewrittenCommand = `${agentCommand.slice(0, inlinePrompt.start)}--prompt - < ${rawPath}${agentCommand.slice(inlinePrompt.end)}`; + if (rewrittenCommand === agentCommand) return agentCommand; + + logger.warn(`Rewriting inline prompt expansion to stdin redirect to avoid ARG_MAX/E2BIG: ${rawPath}`); + return rewrittenCommand; +} + /** * Sets the Docker host to use for AWF's own container operations. * @@ -602,6 +674,7 @@ export function generateDockerCompose( squidConfigContent?: string ): DockerComposeConfig { const projectRoot = path.join(__dirname, '..'); + const rewrittenAgentCommand = rewriteInlinePromptCatToStdin(config.agentCommand); // Guard: --build-local requires full repo checkout (not available in standalone bundle) if (config.buildLocal) { @@ -1024,10 +1097,14 @@ export function generateDockerCompose( if (config.envAll) { const totalEnvBytes = Object.entries(environment) .reduce((sum, [k, v]) => sum + k.length + (v?.length ?? 0) + 2, 0); // +2 for '=' and null - if (totalEnvBytes > ENV_SIZE_WARNING_THRESHOLD) { + const argvBytes = ['/bin/bash', '-c', rewrittenAgentCommand] + .reduce((sum, arg) => sum + arg.length + 1, 0); // +1 for each argv null terminator + const totalArgvEnvBytes = totalEnvBytes + argvBytes; + if (totalArgvEnvBytes > ENV_SIZE_WARNING_THRESHOLD) { logger.warn( - `⚠️ Total container environment size is ${(totalEnvBytes / 1024).toFixed(0)} KB — ` + - 'may cause E2BIG (Argument list too long) errors when combined with large command arguments' + `⚠️ Estimated argv+env size is ${(totalArgvEnvBytes / 1024).toFixed(0)} KB ` + + `(env ${(totalEnvBytes / 1024).toFixed(0)} KB, argv ${(argvBytes / 1024).toFixed(0)} KB) — ` + + 'may cause E2BIG (Argument list too long) errors' ); logger.warn(' Consider using --exclude-env to remove unnecessary variables'); } @@ -1565,7 +1642,7 @@ export function generateDockerCompose( start_period: '1s', }, // Escape $ with $$ for Docker Compose variable interpolation - command: ['/bin/bash', '-c', config.agentCommand.replace(/\$/g, '$$$$')], + command: ['/bin/bash', '-c', rewrittenAgentCommand.replace(/\$/g, '$$$$')], }; // Set working directory if specified (overrides Dockerfile WORKDIR)