From fb64d4a769dfc4c81d101c6aef7bd5d0a9d4c1ea Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Tue, 3 Feb 2026 09:57:05 -0600 Subject: [PATCH] feat(ssh): use heredoc for stdin prompts to avoid CLI length limits IMPORTANT: Prompts must be passed via stdin to avoid CLI argument length limits. Prompts can be huge and contain arbitrary characters that would break if passed as command-line arguments. Changes: - Add stdinInput parameter to buildSshCommandWithStdin for heredoc-based prompt delivery - Use MAESTRO_PROMPT_EOF delimiter with collision detection (appends _N suffix if prompt contains the delimiter) - OpenCode prompts now always sent via stdin heredoc, not CLI args - Add comprehensive tests for heredoc behavior and delimiter collision - Add comment in process.ts documenting this requirement to prevent regressions The heredoc approach: exec opencode 'run' <<'MAESTRO_PROMPT_EOF' ensures prompts of any size with any characters work correctly. --- .../main/utils/ssh-command-builder.test.ts | 51 +++++++++++++++---- src/main/ipc/handlers/process.ts | 9 +++- src/main/utils/ssh-command-builder.ts | 27 +++++++--- 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/__tests__/main/utils/ssh-command-builder.test.ts b/src/__tests__/main/utils/ssh-command-builder.test.ts index 3a0f7fa1..7e13f4a1 100644 --- a/src/__tests__/main/utils/ssh-command-builder.test.ts +++ b/src/__tests__/main/utils/ssh-command-builder.test.ts @@ -723,35 +723,38 @@ describe('ssh-command-builder', () => { expect(result.stdinScript).toContain('question'); }); - it('includes prompt as final argument in stdin script', async () => { + it('includes prompt via stdin heredoc when stdinInput provided', async () => { const result = await buildSshCommandWithStdin(baseConfig, { command: 'opencode', args: ['run', '--format', 'json'], - prompt: 'Write hello world to a file', + stdinInput: 'Write hello world to a file', }); - expect(result.stdinScript).toContain('opencode'); + const execLine = result.stdinScript + ?.split('\n') + .find((line) => line.startsWith('exec ')); + expect(execLine).toBe("exec opencode 'run' '--format' 'json' <<'MAESTRO_PROMPT_EOF'"); expect(result.stdinScript).toContain('Write hello world to a file'); + expect(result.stdinScript).toContain('MAESTRO_PROMPT_EOF'); }); - it('handles prompts with special characters without escaping issues', async () => { + it('handles stdin prompts with special characters without escaping issues', async () => { const result = await buildSshCommandWithStdin(baseConfig, { command: 'opencode', args: ['run'], - prompt: "What's the $PATH? Use `echo` and \"quotes\"", + stdinInput: "What's the $PATH? Use `echo` and \"quotes\"", }); - // The script should contain the prompt (escaped for bash) + // The script should contain the prompt verbatim (no shell interpolation in heredoc) expect(result.stdinScript).toBeDefined(); - // Single quotes in the prompt should be escaped - expect(result.stdinScript).toContain("'\\''"); + expect(result.stdinScript).toContain("What's the $PATH? Use `echo` and \"quotes\""); }); - it('handles multi-line prompts', async () => { + it('handles multi-line stdin prompts', async () => { const result = await buildSshCommandWithStdin(baseConfig, { command: 'opencode', args: ['run'], - prompt: 'Line 1\nLine 2\nLine 3', + stdinInput: 'Line 1\nLine 2\nLine 3', }); expect(result.stdinScript).toContain('Line 1'); @@ -759,6 +762,34 @@ describe('ssh-command-builder', () => { expect(result.stdinScript).toContain('Line 3'); }); + it('uses a unique heredoc delimiter when prompt contains the default token', async () => { + const result = await buildSshCommandWithStdin(baseConfig, { + command: 'opencode', + args: ['run'], + stdinInput: 'Line with MAESTRO_PROMPT_EOF inside', + }); + + const execLine = result.stdinScript + ?.split('\n') + .find((line) => line.startsWith('exec ')); + expect(execLine).toContain("<<'MAESTRO_PROMPT_EOF_0'"); + expect(result.stdinScript).toContain('Line with MAESTRO_PROMPT_EOF inside'); + }); + + it('includes prompt as final argument when stdinInput is not provided', async () => { + const result = await buildSshCommandWithStdin(baseConfig, { + command: 'opencode', + args: ['run'], + prompt: "Say 'hello'", + }); + + const execLine = result.stdinScript + ?.split('\n') + .find((line) => line.startsWith('exec ')); + // The prompt is escaped with single quotes - "Say 'hello'" becomes "'Say '\\''hello'\\''" + expect(execLine).toContain("opencode 'run' 'Say '\\''hello'\\'''"); + }); + it('uses exec to replace shell with command', async () => { const result = await buildSshCommandWithStdin(baseConfig, { command: 'opencode', diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index 02b8cc9b..1eb88b09 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -344,12 +344,19 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // Build the SSH command with stdin script // The script contains PATH setup, cd, env vars, and the actual command // This eliminates all shell escaping issues + // + // IMPORTANT: OpenCode prompts must be passed via stdin to avoid CLI length limits. + // Prompts can be huge and contain arbitrary characters; do NOT pass them as argv. + const shouldSendPromptViaStdin = config.toolType === 'opencode' && !!config.prompt; + const promptForArgs = shouldSendPromptViaStdin ? undefined : config.prompt; + const stdinInput = shouldSendPromptViaStdin ? config.prompt : undefined; const sshCommand = await buildSshCommandWithStdin(sshResult.config, { command: remoteCommand, args: finalArgs, cwd: config.cwd, env: effectiveCustomEnvVars, - prompt: config.prompt, // Prompt is included in the stdin script + prompt: promptForArgs, + stdinInput, }); commandToSpawn = sshCommand.command; diff --git a/src/main/utils/ssh-command-builder.ts b/src/main/utils/ssh-command-builder.ts index c129954d..632b2116 100644 --- a/src/main/utils/ssh-command-builder.ts +++ b/src/main/utils/ssh-command-builder.ts @@ -158,15 +158,15 @@ export function buildRemoteCommand(options: RemoteCommandOptions): string { * args: ['run', '--format', 'json'], * cwd: '/home/user/project', * env: { OPENCODE_CONFIG_CONTENT: '{"permission":{"*":"allow"}}' }, - * prompt: 'Write hello world to a file' + * stdinInput: 'Write hello world to a file' * }); * // result.command = 'ssh' * // result.args = ['-o', 'BatchMode=yes', ..., 'user@host', '/bin/bash'] - * // result.stdinScript = '#!/bin/bash\nexport PATH=...\ncd /home/user/project\nOPENCODE_CONFIG_CONTENT=... opencode run --format json "Write hello world to a file"\n' + * // result.stdinScript = '#!/bin/bash\nexport PATH=...\ncd /home/user/project\nOPENCODE_CONFIG_CONTENT=...\nexec opencode run --format json <<'MAESTRO_PROMPT_EOF'\nWrite hello world to a file\nMAESTRO_PROMPT_EOF\n' */ export async function buildSshCommandWithStdin( config: SshRemoteConfig, - remoteOptions: RemoteCommandOptions & { prompt?: string } + remoteOptions: RemoteCommandOptions & { prompt?: string; stdinInput?: string } ): Promise { const args: string[] = []; @@ -232,13 +232,28 @@ export async function buildSshCommandWithStdin( // For the script, we use simple quoting since we're not going through shell parsing layers const cmdParts = [remoteOptions.command, ...remoteOptions.args.map((arg) => shellEscape(arg))]; - // Add prompt as final argument if provided - if (remoteOptions.prompt) { + // Add prompt as final argument if provided and not sending via stdin + const hasStdinInput = remoteOptions.stdinInput !== undefined; + if (remoteOptions.prompt && !hasStdinInput) { cmdParts.push(shellEscape(remoteOptions.prompt)); } // Use exec to replace the shell with the command (cleaner process tree) - scriptLines.push(`exec ${cmdParts.join(' ')}`); + if (hasStdinInput) { + // IMPORTANT: Prompts must be passed via stdin to avoid CLI length limits. + // Build a safe heredoc delimiter that won't collide with the prompt content. + const delimiterBase = 'MAESTRO_PROMPT_EOF'; + let delimiter = delimiterBase; + let counter = 0; + while (remoteOptions.stdinInput?.includes(delimiter)) { + delimiter = `${delimiterBase}_${counter++}`; + } + scriptLines.push(`exec ${cmdParts.join(' ')} <<'${delimiter}'`); + scriptLines.push(remoteOptions.stdinInput ?? ''); + scriptLines.push(delimiter); + } else { + scriptLines.push(`exec ${cmdParts.join(' ')}`); + } const stdinScript = scriptLines.join('\n') + '\n';