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.
This commit is contained in:
Pedram Amini
2026-02-03 09:57:05 -06:00
parent 3d593719fb
commit fb64d4a769
3 changed files with 70 additions and 17 deletions

View File

@@ -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',

View File

@@ -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;

View File

@@ -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<SshCommandResult> {
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';