Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
33a3450
Expand tilde in LocalRuntime srcBaseDir at construction time
ammar-agent Oct 27, 2025
827d598
Reject tilde paths in SSH runtime srcBaseDir
ammar-agent Oct 27, 2025
164622a
Fix SSH runtime redundant cd detection tests
ammar-agent Oct 27, 2025
8c49ea5
Restore redundant cd rejection in bash tool
ammar-agent Oct 27, 2025
242485f
Format LocalRuntime.test.ts
ammar-agent Oct 27, 2025
9102fed
Derive SSH srcBaseDir from username
ammar-agent Oct 27, 2025
42b7932
Fix lint: use nullish coalescing
ammar-agent Oct 27, 2025
0ab0930
Use /root for root user in SSH srcBaseDir
ammar-agent Oct 27, 2025
c5915d4
Format chatCommands.ts
ammar-agent Oct 27, 2025
83f2e2d
🤖 Block redundant path prefixes in file_* tools to save tokens
ammar-agent Oct 27, 2025
c354cd8
🤖 Fix: Catch runtime creation errors in WORKSPACE_CREATE handler
ammar-agent Oct 27, 2025
b644868
🤖 Enable redundant path prefix validation for SSH runtime
ammar-agent Oct 27, 2025
302cbe7
🤖 Fix: Mark unused runtime parameter with underscore prefix
ammar-agent Oct 27, 2025
b49b5a9
🤖 Use runtime.normalizePath() for DRY in validateNoRedundantPrefix
ammar-agent Oct 27, 2025
2956db0
🤖 Add resolvePath() to Runtime interface for tilde expansion
ammar-agent Oct 27, 2025
d27fdc9
🤖 Fix resolvePath() to separate path resolution from existence checking
ammar-agent Oct 27, 2025
839ff27
🤖 Fix: Remove async from LocalRuntime.resolvePath()
ammar-agent Oct 27, 2025
dd9f92f
🤖 Add timeout requirement to SSHRuntime.execSSHCommand()
ammar-agent Oct 27, 2025
4da5eb9
🤖 Update createWorkspace integration tests for new tilde resolution l…
ammar-agent Oct 27, 2025
8aa211e
📝 Clarify that integration tests require bun x jest
ammar-agent Oct 27, 2025
39bae68
🤖 Fix SSHRuntime.resolvePath() to work with BusyBox
ammar-agent Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,10 @@ This project uses **Make** as the primary build orchestrator. See `Makefile` for
- utils should be either pure functions or easily isolated (e.g. if they operate on the FS they accept
a path). Testing them should not require complex mocks or setup.
- **Integration tests:**
- **⚠️ IMPORTANT: Use `bun x jest` to run tests in the `tests/` folder** - Integration tests use Jest (not bun test), so you must run them with `bun x jest` or `TEST_INTEGRATION=1 bun x jest`
- Run specific integration test: `TEST_INTEGRATION=1 bun x jest tests/ipcMain/sendMessage.test.ts -t "test name pattern"`
- Run all integration tests: `TEST_INTEGRATION=1 bun x jest tests` (~35 seconds, runs 40 tests)
- Unit tests in `src/` use bun test: `bun test src/path/to/file.test.ts`
- **⚠️ Running `tests/ipcMain` locally takes a very long time.** Prefer running specific test files or use `-t` to filter to specific tests.
- **Performance**: Tests use `test.concurrent()` to run in parallel within each file
- **NEVER bypass IPC in integration tests** - Integration tests must use the real IPC communication paths (e.g., `mockIpcRenderer.invoke()`) even when it's harder. Directly accessing services (HistoryService, PartialService, etc.) or manipulating config/state directly bypasses the integration layer and defeats the purpose of the test.
Expand Down
67 changes: 67 additions & 0 deletions src/runtime/LocalRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "bun:test";
import * as os from "os";
import * as path from "path";
import { LocalRuntime } from "./LocalRuntime";

describe("LocalRuntime constructor", () => {
it("should expand tilde in srcBaseDir", () => {
const runtime = new LocalRuntime("~/workspace");
const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch");

// The workspace path should use the expanded home directory
const expected = path.join(os.homedir(), "workspace", "project", "branch");
expect(workspacePath).toBe(expected);
});

it("should handle absolute paths without expansion", () => {
const runtime = new LocalRuntime("/absolute/path");
const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch");

const expected = path.join("/absolute/path", "project", "branch");
expect(workspacePath).toBe(expected);
});

it("should handle bare tilde", () => {
const runtime = new LocalRuntime("~");
const workspacePath = runtime.getWorkspacePath("/home/user/project", "branch");

const expected = path.join(os.homedir(), "project", "branch");
expect(workspacePath).toBe(expected);
});
});

describe("LocalRuntime.resolvePath", () => {
it("should expand tilde to home directory", async () => {
const runtime = new LocalRuntime("/tmp");
const resolved = await runtime.resolvePath("~");
expect(resolved).toBe(os.homedir());
});

it("should expand tilde with path", async () => {
const runtime = new LocalRuntime("/tmp");
// Use a path that likely exists (or use /tmp if ~ doesn't have subdirs)
const resolved = await runtime.resolvePath("~/..");
const expected = path.dirname(os.homedir());
expect(resolved).toBe(expected);
});

it("should resolve absolute paths", async () => {
const runtime = new LocalRuntime("/tmp");
const resolved = await runtime.resolvePath("/tmp");
expect(resolved).toBe("/tmp");
});

it("should resolve non-existent paths without checking existence", async () => {
const runtime = new LocalRuntime("/tmp");
const resolved = await runtime.resolvePath("/this/path/does/not/exist/12345");
// Should resolve to absolute path without checking if it exists
expect(resolved).toBe("/this/path/does/not/exist/12345");
});

it("should resolve relative paths from cwd", async () => {
const runtime = new LocalRuntime("/tmp");
const resolved = await runtime.resolvePath(".");
// Should resolve to absolute path
expect(path.isAbsolute(resolved)).toBe(true);
});
});
12 changes: 11 additions & 1 deletion src/runtime/LocalRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { checkInitHookExists, getInitHookPath, createLineBufferedLoggers } from
import { execAsync } from "../utils/disposableExec";
import { getProjectName } from "../utils/runtime/helpers";
import { getErrorMessage } from "../utils/errors";
import { expandTilde } from "./tildeExpansion";

/**
* Local runtime implementation that executes commands and file operations
Expand All @@ -31,7 +32,8 @@ export class LocalRuntime implements Runtime {
private readonly srcBaseDir: string;

constructor(srcBaseDir: string) {
this.srcBaseDir = srcBaseDir;
// Expand tilde to actual home directory path for local file system operations
this.srcBaseDir = expandTilde(srcBaseDir);
}

async exec(command: string, options: ExecOptions): Promise<ExecStream> {
Expand Down Expand Up @@ -299,6 +301,14 @@ export class LocalRuntime implements Runtime {
}
}

resolvePath(filePath: string): Promise<string> {
// Expand tilde to actual home directory path
const expanded = expandTilde(filePath);

// Resolve to absolute path (handles relative paths like "./foo")
return Promise.resolve(path.resolve(expanded));
}

normalizePath(targetPath: string, basePath: string): string {
// For local runtime, use Node.js path resolution
// Handle special case: current directory
Expand Down
28 changes: 23 additions & 5 deletions src/runtime/Runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
*
* srcBaseDir (base directory for all workspaces):
* - Where cmux stores ALL workspace directories
* - Local: ~/.cmux/src
* - SSH: /home/user/workspace (or custom remote path)
* - Local: ~/.cmux/src (tilde expanded to full path by LocalRuntime)
* - SSH: /home/user/workspace (must be absolute path, no tilde allowed)
*
* Workspace Path Computation:
* {srcBaseDir}/{projectName}/{workspaceName}
Expand All @@ -27,14 +27,14 @@
* Example: "feature-123" or "main"
*
* Full Example (Local):
* srcBaseDir: ~/.cmux/src
* srcBaseDir: ~/.cmux/src (expanded to /home/user/.cmux/src)
* projectPath: /Users/me/git/my-project (local git repo)
* projectName: my-project (extracted)
* workspaceName: feature-123
* → Workspace: ~/.cmux/src/my-project/feature-123
* → Workspace: /home/user/.cmux/src/my-project/feature-123
*
* Full Example (SSH):
* srcBaseDir: /home/user/workspace
* srcBaseDir: /home/user/workspace (absolute path required)
* projectPath: /Users/me/git/my-project (local git repo)
* projectName: my-project (extracted)
* workspaceName: feature-123
Expand Down Expand Up @@ -195,6 +195,24 @@ export interface Runtime {
*/
stat(path: string): Promise<FileStat>;

/**
* Resolve a path to its absolute, canonical form (expanding tildes, resolving symlinks, etc.).
* This is used at workspace creation time to normalize srcBaseDir paths in config.
*
* @param path Path to resolve (may contain tildes or be relative)
* @returns Promise resolving to absolute path
* @throws RuntimeError if path cannot be resolved (e.g., doesn't exist, permission denied)
*
* @example
* // LocalRuntime
* await runtime.resolvePath("~/cmux") // => "/home/user/cmux"
* await runtime.resolvePath("./relative") // => "/current/dir/relative"
*
* // SSHRuntime
* await runtime.resolvePath("~/cmux") // => "/home/user/cmux" (via SSH shell expansion)
*/
resolvePath(path: string): Promise<string>;

/**
* Normalize a path for comparison purposes within this runtime's context.
* Handles runtime-specific path semantics (local vs remote).
Expand Down
33 changes: 33 additions & 0 deletions src/runtime/SSHRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "bun:test";
import { SSHRuntime } from "./SSHRuntime";

describe("SSHRuntime constructor", () => {
it("should accept tilde in srcBaseDir", () => {
// Tildes are now allowed - they will be resolved via resolvePath()
expect(() => {
new SSHRuntime({
host: "example.com",
srcBaseDir: "~/cmux",
});
}).not.toThrow();
});

it("should accept bare tilde in srcBaseDir", () => {
// Tildes are now allowed - they will be resolved via resolvePath()
expect(() => {
new SSHRuntime({
host: "example.com",
srcBaseDir: "~",
});
}).not.toThrow();
});

it("should accept absolute paths in srcBaseDir", () => {
expect(() => {
new SSHRuntime({
host: "example.com",
srcBaseDir: "/home/user/cmux",
});
}).not.toThrow();
});
});
76 changes: 76 additions & 0 deletions src/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,18 @@ export interface SSHRuntimeConfig {
* - Supports SSH config aliases, ProxyJump, ControlMaster, etc.
* - No password prompts (assumes key-based auth or ssh-agent)
* - Atomic file writes via temp + rename
*
* IMPORTANT: All SSH operations MUST include a timeout to prevent hangs from network issues.
* Timeouts should be either set literally for internal operations or forwarded from upstream
* for user-initiated operations.
*/
export class SSHRuntime implements Runtime {
private readonly config: SSHRuntimeConfig;
private readonly controlPath: string;

constructor(config: SSHRuntimeConfig) {
// Note: srcBaseDir may contain tildes - they will be resolved via resolvePath() before use
// The WORKSPACE_CREATE IPC handler resolves paths before storing in config
this.config = config;
// Get deterministic controlPath from connection pool
// Multiple SSHRuntime instances with same config share the same controlPath,
Expand Down Expand Up @@ -315,6 +321,76 @@ export class SSHRuntime implements Runtime {
isDirectory: fileType === "directory",
};
}
async resolvePath(filePath: string): Promise<string> {
// Use shell to expand tildes on remote system
// Bash will expand ~ automatically when we echo the unquoted variable
// This works with BusyBox (doesn't require GNU coreutils)
const command = `bash -c 'p=${shescape.quote(filePath)}; echo $p'`;
// Use 5 second timeout for path resolution (should be near-instant)
return this.execSSHCommand(command, 5000);
}

/**
* Execute a simple SSH command and return stdout
* @param command - The command to execute on the remote host
* @param timeoutMs - Timeout in milliseconds (required to prevent network hangs)
* @private
*/
private async execSSHCommand(command: string, timeoutMs: number): Promise<string> {
const sshArgs = this.buildSSHArgs();
sshArgs.push(this.config.host, command);

return new Promise((resolve, reject) => {
const proc = spawn("ssh", sshArgs);
let stdout = "";
let stderr = "";
let timedOut = false;

// Set timeout to prevent hanging on network issues
const timer = setTimeout(() => {
timedOut = true;
proc.kill();
reject(
new RuntimeErrorClass(`SSH command timed out after ${timeoutMs}ms: ${command}`, "network")
);
}, timeoutMs);

proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});

proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});

proc.on("close", (code) => {
clearTimeout(timer);
if (timedOut) return; // Already rejected

if (code !== 0) {
reject(new RuntimeErrorClass(`SSH command failed: ${stderr.trim()}`, "network"));
return;
}

const output = stdout.trim();
resolve(output);
});

proc.on("error", (err) => {
clearTimeout(timer);
if (timedOut) return; // Already rejected

reject(
new RuntimeErrorClass(
`Cannot execute SSH command: ${getErrorMessage(err)}`,
"network",
err instanceof Error ? err : undefined
)
);
});
});
}

normalizePath(targetPath: string, basePath: string): string {
// For SSH, handle paths in a POSIX-like manner without accessing the remote filesystem
const target = targetPath.trim();
Expand Down
33 changes: 33 additions & 0 deletions src/runtime/tildeExpansion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "bun:test";
import * as os from "os";
import * as path from "path";
import { expandTilde } from "./tildeExpansion";

describe("expandTilde", () => {
it("should expand ~ to home directory", () => {
const result = expandTilde("~");
expect(result).toBe(os.homedir());
});

it("should expand ~/path to home directory + path", () => {
const result = expandTilde("~/workspace");
expect(result).toBe(path.join(os.homedir(), "workspace"));
});

it("should leave absolute paths unchanged", () => {
const absolutePath = "/abs/path/to/dir";
const result = expandTilde(absolutePath);
expect(result).toBe(absolutePath);
});

it("should leave relative paths unchanged", () => {
const relativePath = "relative/path";
const result = expandTilde(relativePath);
expect(result).toBe(relativePath);
});

it("should handle nested paths correctly", () => {
const result = expandTilde("~/workspace/project/subdir");
expect(result).toBe(path.join(os.homedir(), "workspace/project/subdir"));
});
});
35 changes: 33 additions & 2 deletions src/runtime/tildeExpansion.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
/**
* Utilities for handling tilde path expansion in SSH commands
* Utilities for handling tilde path expansion
*
* When running commands over SSH, tilde paths need special handling:
* For SSH commands, tilde paths need special handling:
* - Quoted tildes won't expand: `cd '~'` fails, but `cd "$HOME"` works
* - Must escape special shell characters when using $HOME expansion
*
* For local paths, tildes should be expanded to actual file system paths.
*/

import * as os from "os";
import * as path from "path";

/**
* Expand tilde to actual home directory path for local file system operations.
*
* Converts:
* - "~" → "/home/user" (actual home directory)
* - "~/path" → "/home/user/path"
* - "/abs/path" → "/abs/path" (unchanged)
*
* @param filePath - Path that may contain tilde prefix
* @returns Fully expanded absolute path
*
* @example
* expandTilde("~") // => "/home/user"
* expandTilde("~/workspace") // => "/home/user/workspace"
* expandTilde("/abs/path") // => "/abs/path"
*/
export function expandTilde(filePath: string): string {
if (filePath === "~") {
return os.homedir();
} else if (filePath.startsWith("~/")) {
return path.join(os.homedir(), filePath.slice(2));
} else {
return filePath;
}
}

/**
* Expand tilde path to $HOME-based path for use in SSH commands.
*
Expand Down
3 changes: 0 additions & 3 deletions src/services/gitService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,6 @@ describe("removeWorktreeSafe", () => {
const result = await createWorktree(mockConfig, repoPath, "test-branch", {
trunkBranch: defaultBranch,
});
if (!result.success) {
console.error("createWorktree failed:", result.error);
}
expect(result.success).toBe(true);
const worktreePath = result.path!;

Expand Down
Loading