Skip to content

Commit fb9c373

Browse files
committed
🤖 Refactor SSH sync to avoid shell escaping complexity
Replace complex single-pipeline approach with multiple SSH round trips. Problem: - Complex shell escaping with nested quoting (4 backslashes, \\\\\$HOME, etc.) - Hard to reason about what gets expanded when and where - Tilde paths like ~/cmux/project failed due to escaping bugs - User reported: "cd: /root/cmux/r2: No such file or directory" Solution: Multiple SSH round trips (simpler, no escaping issues) 1. **Create bundle**: Pipe locally to remote via ssh (one level of quoting) 2. **Clone**: Use this.exec() with proper tilde expansion 3. **Remove**: Use this.exec() to clean up bundle file Benefits: - Zero complex escaping - each step is simple - this.exec() handles all tilde expansion consistently - Easier to debug (can run each step independently) - More resilient error handling (cleanup on failure) - User said: "It's ok if we have multiple SSH round trips" Changes: - syncProjectToRemote: 3 separate steps instead of one complex pipeline - Use timestamp instead of $$ for bundle naming (simpler) - Fixed bare '~' expansion in exec() (was only handling ~/...) - Bundle path: ~/.cmux-bundle-<timestamp>.bundle All 14 integration tests passing (6 local + 6 SSH + 2 SSH-specific). Tilde path test verifies ~/workspace/... works correctly. _Generated with `cmux`_
1 parent 8594d50 commit fb9c373

File tree

1 file changed

+78
-42
lines changed

1 file changed

+78
-42
lines changed

src/runtime/SSHRuntime.ts

Lines changed: 78 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ export class SSHRuntime implements Runtime {
6464

6565
// Expand ~/path to $HOME/path before quoting (~ doesn't expand in quotes)
6666
let cwd = options.cwd;
67-
if (cwd.startsWith("~/")) {
67+
if (cwd === "~") {
68+
cwd = "$HOME";
69+
} else if (cwd.startsWith("~/")) {
6870
cwd = "$HOME/" + cwd.slice(2);
6971
}
7072

@@ -313,56 +315,90 @@ export class SSHRuntime implements Runtime {
313315
* - Simpler implementation
314316
*/
315317
private async syncProjectToRemote(projectPath: string, initLogger: InitLogger): Promise<void> {
316-
return new Promise<void>((resolve, reject) => {
317-
// Build SSH args
318-
const sshArgs = this.buildSSHArgs(true);
319-
320-
// For paths starting with ~/, expand to $HOME
321-
let remoteWorkdir: string;
322-
if (this.config.workdir.startsWith("~/")) {
323-
const pathWithoutTilde = this.config.workdir.slice(2);
324-
remoteWorkdir = `"\\\\$HOME/${pathWithoutTilde}"`; // Escape $ so local shell doesn't expand it
325-
} else {
326-
remoteWorkdir = JSON.stringify(this.config.workdir);
327-
}
318+
// Use timestamp-based bundle path to avoid conflicts (simpler than $$)
319+
const timestamp = Date.now();
320+
const bundleTempPath = `~/.cmux-bundle-${timestamp}.bundle`;
328321

329-
// Use git bundle to create a packfile of all refs, pipe through ssh for cloning
330-
// This creates a real git repository on the remote with full history
331-
// Wrap remote commands in bash to avoid issues with non-bash shells (fish, zsh, etc)
332-
// Save bundle to temp file on remote, clone from it, then clean up
333-
// Use $$ for PID to avoid conflicts, escape $ so it's evaluated on remote
334-
const bundleTempPath = `\\$HOME/.cmux-bundle-\\$\\$.bundle`;
335-
const command = `cd ${JSON.stringify(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "bash -c 'cat > ${bundleTempPath} && git clone --quiet ${bundleTempPath} ${remoteWorkdir} && rm ${bundleTempPath}'"`;
336-
337-
log.debug(`Starting git bundle+ssh: ${command}`);
338-
const proc = spawn("bash", ["-c", command]);
339-
340-
// Use helper to stream output and prevent buffer overflow
341-
streamProcessToLogger(proc, initLogger, {
342-
logStdout: false, // bundle stdout is binary, drain silently
343-
logStderr: true, // Errors go to init stream
344-
command: `git bundle+ssh: ${command}`, // Log the full command
345-
});
322+
try {
323+
// Step 1: Create bundle locally and pipe to remote file via SSH
324+
initLogger.logStep(`Creating git bundle...`);
325+
await new Promise<void>((resolve, reject) => {
326+
const sshArgs = this.buildSSHArgs(true);
327+
const command = `cd ${JSON.stringify(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`;
328+
329+
log.debug(`Creating bundle: ${command}`);
330+
const proc = spawn("bash", ["-c", command]);
331+
332+
streamProcessToLogger(proc, initLogger, {
333+
logStdout: false,
334+
logStderr: true,
335+
});
336+
337+
let stderr = "";
338+
proc.stderr.on("data", (data: Buffer) => {
339+
stderr += data.toString();
340+
});
346341

347-
let stderr = "";
348-
proc.stderr.on("data", (data: Buffer) => {
349-
stderr += data.toString();
342+
proc.on("close", (code) => {
343+
if (code === 0) {
344+
resolve();
345+
} else {
346+
reject(new Error(`Failed to create bundle: ${stderr}`));
347+
}
348+
});
349+
350+
proc.on("error", (err) => {
351+
reject(err);
352+
});
350353
});
351354

352-
proc.on("close", (code) => {
353-
if (code === 0) {
354-
resolve();
355-
} else {
356-
reject(new Error(`git bundle+ssh failed with exit code ${code ?? "unknown"}: ${stderr}`));
357-
}
355+
// Step 2: Clone from bundle on remote using this.exec (handles tilde expansion)
356+
initLogger.logStep(`Cloning repository on remote...`);
357+
const cloneStream = this.exec(`git clone --quiet ${bundleTempPath} ${JSON.stringify(this.config.workdir)}`, {
358+
cwd: "~",
359+
timeout: 300, // 5 minutes for clone
358360
});
359361

360-
proc.on("error", (err) => {
361-
reject(err);
362+
const [cloneStdout, cloneStderr, cloneExitCode] = await Promise.all([
363+
streamToString(cloneStream.stdout),
364+
streamToString(cloneStream.stderr),
365+
cloneStream.exitCode,
366+
]);
367+
368+
if (cloneExitCode !== 0) {
369+
throw new Error(`Failed to clone repository: ${cloneStderr || cloneStdout}`);
370+
}
371+
372+
// Step 3: Remove bundle file
373+
initLogger.logStep(`Cleaning up bundle file...`);
374+
const rmStream = this.exec(`rm ${bundleTempPath}`, {
375+
cwd: "~",
376+
timeout: 10,
362377
});
363-
});
378+
379+
const rmExitCode = await rmStream.exitCode;
380+
if (rmExitCode !== 0) {
381+
log.info(`Failed to remove bundle file ${bundleTempPath}, but continuing`);
382+
}
383+
384+
initLogger.logStep(`Repository cloned successfully`);
385+
} catch (error) {
386+
// Try to clean up bundle file on error
387+
try {
388+
const rmStream = this.exec(`rm -f ${bundleTempPath}`, {
389+
cwd: "~",
390+
timeout: 10,
391+
});
392+
await rmStream.exitCode;
393+
} catch {
394+
// Ignore cleanup errors
395+
}
396+
397+
throw error;
398+
}
364399
}
365400

401+
366402
/**
367403
* Run .cmux/init hook on remote machine if it exists
368404
*/

0 commit comments

Comments
 (0)