Skip to content

Commit 4cc6c46

Browse files
committed
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
1 parent 2fa1d4f commit 4cc6c46

File tree

2 files changed

+145
-4
lines changed

2 files changed

+145
-4
lines changed

src/runtime/SSHRuntime.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion";
2424
import { findBashPath } from "./executablePaths";
2525
import { getProjectName } from "../utils/runtime/helpers";
2626
import { getErrorMessage } from "../utils/errors";
27+
import { execAsync } from "../utils/disposableExec";
2728

2829
/**
2930
* Shescape instance for bash shell escaping.
@@ -372,7 +373,24 @@ export class SSHRuntime implements Runtime {
372373
const bundleTempPath = `~/.cmux-bundle-${timestamp}.bundle`;
373374

374375
try {
375-
// Step 1: Create bundle locally and pipe to remote file via SSH
376+
// Step 1: Get origin URL from local repository (if it exists)
377+
let originUrl: string | null = null;
378+
try {
379+
using proc = execAsync(
380+
`cd ${JSON.stringify(projectPath)} && git remote get-url origin 2>/dev/null || true`
381+
);
382+
const { stdout } = await proc.result;
383+
const url = stdout.trim();
384+
// Only use URL if it's not a bundle path (avoids propagating bundle paths)
385+
if (url && !url.includes(".bundle") && !url.includes(".cmux-bundle")) {
386+
originUrl = url;
387+
}
388+
} catch (error) {
389+
// If we can't get origin, continue without it
390+
initLogger.logStderr(`Could not get origin URL: ${getErrorMessage(error)}`);
391+
}
392+
393+
// Step 2: Create bundle locally and pipe to remote file via SSH
376394
initLogger.logStep(`Creating git bundle...`);
377395
await new Promise<void>((resolve, reject) => {
378396
const sshArgs = this.buildSSHArgs(true);
@@ -405,7 +423,7 @@ export class SSHRuntime implements Runtime {
405423
});
406424
});
407425

408-
// Step 2: Clone from bundle on remote using this.exec
426+
// Step 3: Clone from bundle on remote using this.exec
409427
initLogger.logStep(`Cloning repository on remote...`);
410428

411429
// Expand tilde in destination path for git clone
@@ -427,7 +445,37 @@ export class SSHRuntime implements Runtime {
427445
throw new Error(`Failed to clone repository: ${cloneStderr || cloneStdout}`);
428446
}
429447

430-
// Step 3: Remove bundle file
448+
// Step 4: Update origin remote if we have an origin URL
449+
if (originUrl) {
450+
initLogger.logStep(`Setting origin remote to ${originUrl}...`);
451+
const setOriginStream = await this.exec(
452+
`git -C ${cloneDestPath} remote set-url origin ${JSON.stringify(originUrl)}`,
453+
{
454+
cwd: "~",
455+
timeout: 10,
456+
}
457+
);
458+
459+
const setOriginExitCode = await setOriginStream.exitCode;
460+
if (setOriginExitCode !== 0) {
461+
const stderr = await streamToString(setOriginStream.stderr);
462+
log.info(`Failed to set origin remote: ${stderr}`);
463+
// Continue anyway - this is not fatal
464+
}
465+
} else {
466+
// No origin in local repo, remove the origin that points to bundle
467+
initLogger.logStep(`Removing bundle origin remote...`);
468+
const removeOriginStream = await this.exec(
469+
`git -C ${cloneDestPath} remote remove origin 2>/dev/null || true`,
470+
{
471+
cwd: "~",
472+
timeout: 10,
473+
}
474+
);
475+
await removeOriginStream.exitCode;
476+
}
477+
478+
// Step 5: Remove bundle file
431479
initLogger.logStep(`Cleaning up bundle file...`);
432480
const rmStream = await this.exec(`rm ${bundleTempPath}`, {
433481
cwd: "~",
@@ -826,7 +874,7 @@ export class SSHRuntime implements Runtime {
826874
/**
827875
* Helper to convert a ReadableStream to a string
828876
*/
829-
async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
877+
export async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
830878
const reader = stream.getReader();
831879
const decoder = new TextDecoder("utf-8");
832880
let result = "";

tests/ipcMain/createWorkspace.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import {
2727
} from "../runtime/ssh-fixture";
2828
import type { RuntimeConfig } from "../../src/types/runtime";
2929
import type { FrontendWorkspaceMetadata } from "../../src/types/workspace";
30+
import { createRuntime } from "../../src/runtime/runtimeFactory";
31+
import type { SSHRuntime } from "../../src/runtime/SSHRuntime";
32+
import { streamToString } from "../../src/runtime/SSHRuntime";
3033

3134
const execAsync = promisify(exec);
3235

@@ -722,4 +725,94 @@ echo "Init hook executed with tilde path"
722725
});
723726
}
724727
);
728+
729+
// SSH-specific tests (outside matrix)
730+
describe("SSH-specific behavior", () => {
731+
test.concurrent(
732+
"forwards origin remote instead of bundle path",
733+
async () => {
734+
// Skip if SSH server not available
735+
if (!sshConfig) {
736+
console.log("Skipping SSH-specific test: SSH server not available");
737+
return;
738+
}
739+
740+
const env = await createTestEnvironment();
741+
const tempGitRepo = await createTempGitRepo();
742+
743+
try {
744+
// Set up a real origin remote in the test repo
745+
const originUrl = "https://github.com/example/test-repo.git";
746+
await execAsync(`git remote add origin ${originUrl}`, {
747+
cwd: tempGitRepo,
748+
});
749+
750+
// Verify origin was added
751+
const { stdout: originCheck } = await execAsync(`git remote get-url origin`, {
752+
cwd: tempGitRepo,
753+
});
754+
expect(originCheck.trim()).toBe(originUrl);
755+
756+
const branchName = generateBranchName();
757+
const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo);
758+
759+
const runtimeConfig: RuntimeConfig = {
760+
type: "ssh",
761+
host: "localhost",
762+
port: sshConfig.port,
763+
username: sshConfig.username,
764+
privateKeyPath: sshConfig.privateKeyPath,
765+
srcBaseDir: "~/workspace",
766+
};
767+
768+
const { result, cleanup } = await createWorkspaceWithCleanup(
769+
env,
770+
tempGitRepo,
771+
branchName,
772+
trunkBranch,
773+
runtimeConfig
774+
);
775+
776+
try {
777+
expect(result.success).toBe(true);
778+
if (!result.success) return;
779+
780+
// Wait for init to complete
781+
await new Promise((resolve) => setTimeout(resolve, SSH_INIT_WAIT_MS));
782+
783+
// Create runtime to check remote on SSH host
784+
const runtime = createRuntime(runtimeConfig);
785+
const workspacePath = runtime.getWorkspacePath(tempGitRepo, branchName);
786+
787+
// Check that origin remote exists and points to the original URL, not the bundle
788+
const checkOriginCmd = `git -C ${workspacePath} remote get-url origin`;
789+
const originStream = await (runtime as SSHRuntime).exec(checkOriginCmd, {
790+
cwd: "~",
791+
timeout: 10,
792+
});
793+
794+
const [stdout, stderr, exitCode] = await Promise.all([
795+
streamToString(originStream.stdout),
796+
streamToString(originStream.stderr),
797+
originStream.exitCode,
798+
]);
799+
800+
expect(exitCode).toBe(0);
801+
const remoteUrl = stdout.trim();
802+
803+
// Should be the original origin URL, not the bundle path
804+
expect(remoteUrl).toBe(originUrl);
805+
expect(remoteUrl).not.toContain(".bundle");
806+
expect(remoteUrl).not.toContain(".cmux-bundle");
807+
} finally {
808+
await cleanup();
809+
}
810+
} finally {
811+
await cleanupTestEnvironment(env);
812+
await cleanupTempGitRepo(tempGitRepo);
813+
}
814+
},
815+
TEST_TIMEOUT_MS
816+
);
817+
});
725818
});

0 commit comments

Comments
 (0)