From fa234fc24fa2825be86bca47c52a1658760dde85 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 18:41:34 +0000 Subject: [PATCH 1/4] Fix SSH workspace origin remote forwarding - Forward actual origin URL instead of bundle path in SSH createWorkspace - Add targeted test for SSH origin remote verification - Refactor to use execAsync utility (no manual spawn duplication) - Export streamToString for test reuse - Log errors to init log for user visibility --- src/runtime/SSHRuntime.ts | 56 ++++++++++++++-- tests/ipcMain/createWorkspace.test.ts | 93 +++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index 7c19f3d27d..0c94960c44 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -24,6 +24,7 @@ import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion"; import { findBashPath } from "./executablePaths"; import { getProjectName } from "../utils/runtime/helpers"; import { getErrorMessage } from "../utils/errors"; +import { execAsync } from "../utils/disposableExec"; /** * Shescape instance for bash shell escaping. @@ -372,7 +373,24 @@ export class SSHRuntime implements Runtime { const bundleTempPath = `~/.cmux-bundle-${timestamp}.bundle`; try { - // Step 1: Create bundle locally and pipe to remote file via SSH + // Step 1: Get origin URL from local repository (if it exists) + let originUrl: string | null = null; + try { + using proc = execAsync( + `cd ${JSON.stringify(projectPath)} && git remote get-url origin 2>/dev/null || true` + ); + const { stdout } = await proc.result; + const url = stdout.trim(); + // Only use URL if it's not a bundle path (avoids propagating bundle paths) + if (url && !url.includes(".bundle") && !url.includes(".cmux-bundle")) { + originUrl = url; + } + } catch (error) { + // If we can't get origin, continue without it + initLogger.logStderr(`Could not get origin URL: ${getErrorMessage(error)}`); + } + + // Step 2: Create bundle locally and pipe to remote file via SSH initLogger.logStep(`Creating git bundle...`); await new Promise((resolve, reject) => { const sshArgs = this.buildSSHArgs(true); @@ -405,7 +423,7 @@ export class SSHRuntime implements Runtime { }); }); - // Step 2: Clone from bundle on remote using this.exec + // Step 3: Clone from bundle on remote using this.exec initLogger.logStep(`Cloning repository on remote...`); // Expand tilde in destination path for git clone @@ -427,7 +445,37 @@ export class SSHRuntime implements Runtime { throw new Error(`Failed to clone repository: ${cloneStderr || cloneStdout}`); } - // Step 3: Remove bundle file + // Step 4: Update origin remote if we have an origin URL + if (originUrl) { + initLogger.logStep(`Setting origin remote to ${originUrl}...`); + const setOriginStream = await this.exec( + `git -C ${cloneDestPath} remote set-url origin ${JSON.stringify(originUrl)}`, + { + cwd: "~", + timeout: 10, + } + ); + + const setOriginExitCode = await setOriginStream.exitCode; + if (setOriginExitCode !== 0) { + const stderr = await streamToString(setOriginStream.stderr); + log.info(`Failed to set origin remote: ${stderr}`); + // Continue anyway - this is not fatal + } + } else { + // No origin in local repo, remove the origin that points to bundle + initLogger.logStep(`Removing bundle origin remote...`); + const removeOriginStream = await this.exec( + `git -C ${cloneDestPath} remote remove origin 2>/dev/null || true`, + { + cwd: "~", + timeout: 10, + } + ); + await removeOriginStream.exitCode; + } + + // Step 5: Remove bundle file initLogger.logStep(`Cleaning up bundle file...`); const rmStream = await this.exec(`rm ${bundleTempPath}`, { cwd: "~", @@ -826,7 +874,7 @@ export class SSHRuntime implements Runtime { /** * Helper to convert a ReadableStream to a string */ -async function streamToString(stream: ReadableStream): Promise { +export async function streamToString(stream: ReadableStream): Promise { const reader = stream.getReader(); const decoder = new TextDecoder("utf-8"); let result = ""; diff --git a/tests/ipcMain/createWorkspace.test.ts b/tests/ipcMain/createWorkspace.test.ts index e8c3f2550a..25cedbc9e7 100644 --- a/tests/ipcMain/createWorkspace.test.ts +++ b/tests/ipcMain/createWorkspace.test.ts @@ -27,6 +27,9 @@ import { } from "../runtime/ssh-fixture"; import type { RuntimeConfig } from "../../src/types/runtime"; import type { FrontendWorkspaceMetadata } from "../../src/types/workspace"; +import { createRuntime } from "../../src/runtime/runtimeFactory"; +import type { SSHRuntime } from "../../src/runtime/SSHRuntime"; +import { streamToString } from "../../src/runtime/SSHRuntime"; const execAsync = promisify(exec); @@ -722,4 +725,94 @@ echo "Init hook executed with tilde path" }); } ); + + // SSH-specific tests (outside matrix) + describe("SSH-specific behavior", () => { + test.concurrent( + "forwards origin remote instead of bundle path", + async () => { + // Skip if SSH server not available + if (!sshConfig) { + console.log("Skipping SSH-specific test: SSH server not available"); + return; + } + + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Set up a real origin remote in the test repo + const originUrl = "https://github.com/example/test-repo.git"; + await execAsync(`git remote add origin ${originUrl}`, { + cwd: tempGitRepo, + }); + + // Verify origin was added + const { stdout: originCheck } = await execAsync(`git remote get-url origin`, { + cwd: tempGitRepo, + }); + expect(originCheck.trim()).toBe(originUrl); + + const branchName = generateBranchName(); + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + + const runtimeConfig: RuntimeConfig = { + type: "ssh", + host: "localhost", + port: sshConfig.port, + username: sshConfig.username, + privateKeyPath: sshConfig.privateKeyPath, + srcBaseDir: "~/workspace", + }; + + const { result, cleanup } = await createWorkspaceWithCleanup( + env, + tempGitRepo, + branchName, + trunkBranch, + runtimeConfig + ); + + try { + expect(result.success).toBe(true); + if (!result.success) return; + + // Wait for init to complete + await new Promise((resolve) => setTimeout(resolve, SSH_INIT_WAIT_MS)); + + // Create runtime to check remote on SSH host + const runtime = createRuntime(runtimeConfig); + const workspacePath = runtime.getWorkspacePath(tempGitRepo, branchName); + + // Check that origin remote exists and points to the original URL, not the bundle + const checkOriginCmd = `git -C ${workspacePath} remote get-url origin`; + const originStream = await (runtime as SSHRuntime).exec(checkOriginCmd, { + cwd: "~", + timeout: 10, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + streamToString(originStream.stdout), + streamToString(originStream.stderr), + originStream.exitCode, + ]); + + expect(exitCode).toBe(0); + const remoteUrl = stdout.trim(); + + // Should be the original origin URL, not the bundle path + expect(remoteUrl).toBe(originUrl); + expect(remoteUrl).not.toContain(".bundle"); + expect(remoteUrl).not.toContain(".cmux-bundle"); + } finally { + await cleanup(); + } + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + TEST_TIMEOUT_MS + ); + }); }); From 8be45ef52ce4adac28edf046d01753558a8192a0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 18:56:51 +0000 Subject: [PATCH 2/4] Fix SSH runtime config in test (use identityFile and host format) --- tests/ipcMain/createWorkspace.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/ipcMain/createWorkspace.test.ts b/tests/ipcMain/createWorkspace.test.ts index 25cedbc9e7..3b51733619 100644 --- a/tests/ipcMain/createWorkspace.test.ts +++ b/tests/ipcMain/createWorkspace.test.ts @@ -758,11 +758,10 @@ echo "Init hook executed with tilde path" const runtimeConfig: RuntimeConfig = { type: "ssh", - host: "localhost", - port: sshConfig.port, - username: sshConfig.username, - privateKeyPath: sshConfig.privateKeyPath, + host: "testuser@localhost", srcBaseDir: "~/workspace", + identityFile: sshConfig.privateKeyPath, + port: sshConfig.port, }; const { result, cleanup } = await createWorkspaceWithCleanup( From 8616d77abd76bf4f04a0214bf949ad330da0f936 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 18:59:32 +0000 Subject: [PATCH 3/4] Replace JSON.stringify with shescape.quote for proper shell escaping --- src/runtime/SSHRuntime.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/runtime/SSHRuntime.ts b/src/runtime/SSHRuntime.ts index 0c94960c44..c6fb452f73 100644 --- a/src/runtime/SSHRuntime.ts +++ b/src/runtime/SSHRuntime.ts @@ -377,7 +377,7 @@ export class SSHRuntime implements Runtime { let originUrl: string | null = null; try { using proc = execAsync( - `cd ${JSON.stringify(projectPath)} && git remote get-url origin 2>/dev/null || true` + `cd ${shescape.quote(projectPath)} && git remote get-url origin 2>/dev/null || true` ); const { stdout } = await proc.result; const url = stdout.trim(); @@ -394,7 +394,7 @@ export class SSHRuntime implements Runtime { initLogger.logStep(`Creating git bundle...`); await new Promise((resolve, reject) => { const sshArgs = this.buildSSHArgs(true); - const command = `cd ${JSON.stringify(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`; + const command = `cd ${shescape.quote(projectPath)} && git bundle create - --all | ssh ${sshArgs.join(" ")} "cat > ${bundleTempPath}"`; log.debug(`Creating bundle: ${command}`); const bashPath = findBashPath(); @@ -449,7 +449,7 @@ export class SSHRuntime implements Runtime { if (originUrl) { initLogger.logStep(`Setting origin remote to ${originUrl}...`); const setOriginStream = await this.exec( - `git -C ${cloneDestPath} remote set-url origin ${JSON.stringify(originUrl)}`, + `git -C ${cloneDestPath} remote set-url origin ${shescape.quote(originUrl)}`, { cwd: "~", timeout: 10, @@ -663,7 +663,7 @@ export class SSHRuntime implements Runtime { // We create new branches from HEAD instead of the trunkBranch name to avoid issues // where the local repo's trunk name doesn't match the cloned repo's default branch initLogger.logStep(`Checking out branch: ${branchName}`); - const checkoutCmd = `(git checkout ${JSON.stringify(branchName)} 2>/dev/null || git checkout -b ${JSON.stringify(branchName)} HEAD)`; + const checkoutCmd = `(git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} HEAD)`; const checkoutStream = await this.exec(checkoutCmd, { cwd: workspacePath, // Use the full workspace path for git operations From cfd1576050cc3e50e85c4bc769765f2989778a8f Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 19:04:48 +0000 Subject: [PATCH 4/4] Fix prettier formatting --- tests/ipcMain/createWorkspace.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ipcMain/createWorkspace.test.ts b/tests/ipcMain/createWorkspace.test.ts index 3b51733619..db6fc25a9f 100644 --- a/tests/ipcMain/createWorkspace.test.ts +++ b/tests/ipcMain/createWorkspace.test.ts @@ -798,7 +798,7 @@ echo "Init hook executed with tilde path" expect(exitCode).toBe(0); const remoteUrl = stdout.trim(); - + // Should be the original origin URL, not the bundle path expect(remoteUrl).toBe(originUrl); expect(remoteUrl).not.toContain(".bundle");