mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user