mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
refactor(ssh): simplify stdin prompt delivery with passthrough approach
Replace heredoc-based prompt delivery with simpler stdin passthrough. How it works: 1. Bash script is sent via stdin to /bin/bash on the remote 2. Script sets up PATH, cd, env vars, then calls `exec <agent>` 3. The `exec` replaces bash with the agent process 4. The agent inherits stdin and reads the remaining content (the prompt) Benefits: - No heredoc syntax needed - No delimiter collision detection - No prompt escaping required - prompt is never parsed by any shell - Works with any prompt content (quotes, newlines, $, backticks, etc.) - Simpler, more maintainable code Changes: - Remove heredoc logic from buildSshCommandWithStdin() - Update process.ts to use stdin passthrough for ALL agents over SSH (not just OpenCode - all agents benefit from this approach) - Update tests to verify stdin passthrough behavior Verified locally that both OpenCode and Claude Code read prompts from stdin.
This commit is contained in:
@@ -668,10 +668,18 @@ describe('ssh-command-builder', () => {
|
||||
describe('buildSshCommandWithStdin', () => {
|
||||
/**
|
||||
* Tests for the stdin-based SSH execution approach.
|
||||
*
|
||||
* This method completely bypasses shell escaping issues by:
|
||||
* 1. SSH connects and runs /bin/bash on the remote
|
||||
* 2. The entire script (PATH, cd, env, command) is sent via stdin
|
||||
* 3. No command-line argument parsing/escaping occurs
|
||||
* 2. The script (PATH, cd, env, exec command) is sent via stdin
|
||||
* 3. The prompt is appended after the script and passed through to the exec'd command
|
||||
* 4. No heredoc, no delimiter collision detection, no prompt escaping needed
|
||||
*
|
||||
* How it works:
|
||||
* - Bash reads the script lines from stdin
|
||||
* - The `exec` command replaces bash with the target process
|
||||
* - The target process inherits stdin and reads the remaining content (the prompt)
|
||||
* - The prompt is NEVER parsed by any shell - it flows through as raw bytes
|
||||
*/
|
||||
|
||||
it('returns ssh command with /bin/bash as remote command', async () => {
|
||||
@@ -723,31 +731,44 @@ describe('ssh-command-builder', () => {
|
||||
expect(result.stdinScript).toContain('question');
|
||||
});
|
||||
|
||||
it('includes prompt via stdin heredoc when stdinInput provided', async () => {
|
||||
it('appends prompt after exec command via stdin passthrough', async () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run', '--format', 'json'],
|
||||
stdinInput: 'Write hello world to a file',
|
||||
});
|
||||
|
||||
// The exec line should NOT have heredoc - just the command
|
||||
const execLine = result.stdinScript
|
||||
?.split('\n')
|
||||
.find((line) => line.startsWith('exec '));
|
||||
expect(execLine).toBe("exec opencode 'run' '--format' 'json' <<'MAESTRO_PROMPT_EOF'");
|
||||
expect(execLine).toBe("exec opencode 'run' '--format' 'json'");
|
||||
|
||||
// The prompt should appear after the exec line (stdin passthrough)
|
||||
expect(result.stdinScript).toContain('Write hello world to a file');
|
||||
expect(result.stdinScript).toContain('MAESTRO_PROMPT_EOF');
|
||||
|
||||
// Verify the structure: script ends with exec, then prompt follows
|
||||
const parts = result.stdinScript?.split("exec opencode 'run' '--format' 'json'\n");
|
||||
expect(parts?.length).toBe(2);
|
||||
expect(parts?.[1]).toBe('Write hello world to a file');
|
||||
});
|
||||
|
||||
it('handles stdin prompts with special characters without escaping issues', async () => {
|
||||
it('handles stdin prompts with special characters without escaping', async () => {
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
stdinInput: "What's the $PATH? Use `echo` and \"quotes\"",
|
||||
});
|
||||
|
||||
// The script should contain the prompt verbatim (no shell interpolation in heredoc)
|
||||
// The prompt should be verbatim - no escaping needed since it's stdin passthrough
|
||||
expect(result.stdinScript).toBeDefined();
|
||||
expect(result.stdinScript).toContain("What's the $PATH? Use `echo` and \"quotes\"");
|
||||
|
||||
// Verify the prompt is AFTER the exec line (not in heredoc)
|
||||
const execLine = result.stdinScript
|
||||
?.split('\n')
|
||||
.find((line) => line.startsWith('exec '));
|
||||
expect(execLine).toBe("exec opencode 'run'");
|
||||
});
|
||||
|
||||
it('handles multi-line stdin prompts', async () => {
|
||||
@@ -762,18 +783,19 @@ describe('ssh-command-builder', () => {
|
||||
expect(result.stdinScript).toContain('Line 3');
|
||||
});
|
||||
|
||||
it('uses a unique heredoc delimiter when prompt contains the default token', async () => {
|
||||
it('handles prompts containing heredoc-like tokens without special treatment', async () => {
|
||||
// With stdin passthrough, we don't need delimiter collision detection
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'opencode',
|
||||
args: ['run'],
|
||||
stdinInput: 'Line with MAESTRO_PROMPT_EOF inside',
|
||||
stdinInput: 'Line with MAESTRO_PROMPT_EOF inside and <<EOF markers',
|
||||
});
|
||||
|
||||
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');
|
||||
// The prompt should be verbatim - no special handling needed
|
||||
expect(result.stdinScript).toContain('Line with MAESTRO_PROMPT_EOF inside and <<EOF markers');
|
||||
|
||||
// No heredoc syntax should be present
|
||||
expect(result.stdinScript).not.toContain("<<'");
|
||||
});
|
||||
|
||||
it('includes prompt as final argument when stdinInput is not provided', async () => {
|
||||
@@ -845,5 +867,26 @@ describe('ssh-command-builder', () => {
|
||||
expect(result.stdinScript).toContain('export REMOTE_VAR=');
|
||||
expect(result.stdinScript).toContain('export OPTION_VAR=');
|
||||
});
|
||||
|
||||
it('works with Claude Code stream-json format', async () => {
|
||||
// Claude Code uses --input-format stream-json and expects JSON on stdin
|
||||
const streamJsonPrompt =
|
||||
'{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello"}]}}';
|
||||
|
||||
const result = await buildSshCommandWithStdin(baseConfig, {
|
||||
command: 'claude',
|
||||
args: ['--print', '--verbose', '--output-format', 'stream-json', '--input-format', 'stream-json'],
|
||||
stdinInput: streamJsonPrompt,
|
||||
});
|
||||
|
||||
// The JSON should be passed through verbatim
|
||||
expect(result.stdinScript).toContain(streamJsonPrompt);
|
||||
|
||||
// Verify exec line doesn't have the prompt
|
||||
const execLine = result.stdinScript
|
||||
?.split('\n')
|
||||
.find((line) => line.startsWith('exec '));
|
||||
expect(execLine).not.toContain('{"type"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,17 +345,22 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
// 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;
|
||||
// IMPORTANT: ALL agent prompts are passed via stdin passthrough for SSH.
|
||||
// Benefits:
|
||||
// - Avoids CLI argument length limits (128KB-2MB depending on OS)
|
||||
// - No shell escaping needed - prompt is never parsed by any shell
|
||||
// - Works with any prompt content (quotes, newlines, special chars)
|
||||
// - Simpler code - no heredoc or delimiter collision detection
|
||||
//
|
||||
// How it works: bash reads the script, `exec` replaces bash with the agent,
|
||||
// and the agent reads the remaining stdin (the prompt) directly.
|
||||
const stdinInput = config.prompt;
|
||||
const sshCommand = await buildSshCommandWithStdin(sshResult.config, {
|
||||
command: remoteCommand,
|
||||
args: finalArgs,
|
||||
cwd: config.cwd,
|
||||
env: effectiveCustomEnvVars,
|
||||
prompt: promptForArgs,
|
||||
// prompt is not passed as CLI arg - it goes via stdinInput
|
||||
stdinInput,
|
||||
});
|
||||
|
||||
@@ -366,7 +371,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
// For SSH, env vars are passed in the stdin script, not locally
|
||||
customEnvVarsToPass = undefined;
|
||||
|
||||
logger.info(`SSH command built with stdin script`, LOG_CONTEXT, {
|
||||
logger.info(`SSH command built with stdin passthrough`, LOG_CONTEXT, {
|
||||
sessionId: config.sessionId,
|
||||
toolType: config.toolType,
|
||||
sshBinary: sshCommand.command,
|
||||
@@ -374,7 +379,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
remoteCommand,
|
||||
remoteCwd: config.cwd,
|
||||
promptLength: config.prompt?.length,
|
||||
scriptLength: sshCommand.stdinScript?.length,
|
||||
stdinScriptLength: sshCommand.stdinScript?.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,17 +140,24 @@ export function buildRemoteCommand(options: RemoteCommandOptions): string {
|
||||
* This approach completely bypasses shell escaping issues by:
|
||||
* 1. SSH connects and runs `/bin/bash` on the remote
|
||||
* 2. The script (with PATH setup, cd, env vars, command) is sent via stdin
|
||||
* 3. No shell parsing of command-line arguments occurs
|
||||
* 3. The prompt (if any) is appended after the script, passed through to the exec'd command
|
||||
*
|
||||
* This is the preferred method for SSH remote execution as it:
|
||||
* - Handles any prompt content (special chars, newlines, quotes, etc.)
|
||||
* - Avoids command-line length limits
|
||||
* - Works regardless of the remote user's login shell (bash, zsh, fish, etc.)
|
||||
* - Eliminates the escaping nightmare of nested shell contexts
|
||||
* - No heredoc or delimiter collision detection needed
|
||||
*
|
||||
* How stdin passthrough works:
|
||||
* - Bash reads and executes the script lines
|
||||
* - The `exec` command replaces bash with the target process
|
||||
* - Any remaining stdin (the prompt) is inherited by the exec'd command
|
||||
* - The prompt is NEVER parsed by any shell - it flows through as raw bytes
|
||||
*
|
||||
* @param config SSH remote configuration
|
||||
* @param remoteOptions Options for the remote command
|
||||
* @returns SSH command/args plus the script to send via stdin
|
||||
* @returns SSH command/args plus the script+prompt to send via stdin
|
||||
*
|
||||
* @example
|
||||
* const result = await buildSshCommandWithStdin(config, {
|
||||
@@ -162,7 +169,7 @@ export function buildRemoteCommand(options: RemoteCommandOptions): string {
|
||||
* });
|
||||
* // 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=...\nexec opencode run --format json <<'MAESTRO_PROMPT_EOF'\nWrite hello world to a file\nMAESTRO_PROMPT_EOF\n'
|
||||
* // result.stdinScript = 'export PATH=...\ncd /home/user/project\nexport OPENCODE_CONFIG_CONTENT=...\nexec opencode run --format json\nWrite hello world to a file'
|
||||
*/
|
||||
export async function buildSshCommandWithStdin(
|
||||
config: SshRemoteConfig,
|
||||
@@ -232,30 +239,27 @@ 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 and not sending via stdin
|
||||
// Add prompt as final argument if provided and not sending via stdin passthrough
|
||||
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)
|
||||
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(' ')}`);
|
||||
}
|
||||
// When stdinInput is provided, the prompt will be appended after the script
|
||||
// and passed through to the exec'd command via stdin inheritance
|
||||
scriptLines.push(`exec ${cmdParts.join(' ')}`);
|
||||
|
||||
const stdinScript = scriptLines.join('\n') + '\n';
|
||||
// Build the final stdin content: script + optional prompt passthrough
|
||||
// The script ends with exec, which replaces bash with the target command
|
||||
// Any content after the script (the prompt) is read by the exec'd command from stdin
|
||||
let stdinScript = scriptLines.join('\n') + '\n';
|
||||
|
||||
if (hasStdinInput && remoteOptions.stdinInput) {
|
||||
// Append the prompt after the script - it will be passed through to the exec'd command
|
||||
// No escaping needed - the prompt is never parsed by any shell
|
||||
stdinScript += remoteOptions.stdinInput;
|
||||
}
|
||||
|
||||
logger.info('SSH command built with stdin script', '[ssh-command-builder]', {
|
||||
host: config.host,
|
||||
@@ -264,7 +268,9 @@ export async function buildSshCommandWithStdin(
|
||||
sshPath,
|
||||
sshArgsCount: args.length,
|
||||
scriptLineCount: scriptLines.length,
|
||||
scriptLength: stdinScript.length,
|
||||
stdinLength: stdinScript.length,
|
||||
hasStdinInput,
|
||||
stdinInputLength: remoteOptions.stdinInput?.length,
|
||||
// Show first part of script for debugging (truncate if long)
|
||||
scriptPreview: stdinScript.length > 500 ? stdinScript.substring(0, 500) + '...' : stdinScript,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user