Skip to content

Commit 5f200a6

Browse files
authored
🤖 Add basic support for SSH runtime (#178)
1 parent 49d4a97 commit 5f200a6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

78 files changed

+7501
-1515
lines changed

.github/workflows/ci.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ on:
1010
description: 'Optional test filter (e.g., "workspace", "tests/file.test.ts", or "-t pattern")'
1111
required: false
1212
type: string
13+
# This filter is passed to unit tests, integration tests, e2e tests, and storybook tests
14+
# to enable faster iteration when debugging specific test failures in CI
1315

1416
jobs:
1517
static-check:
@@ -85,7 +87,7 @@ jobs:
8587

8688
integration-test:
8789
name: Integration Tests
88-
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }}
90+
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-24.04-32' || 'ubuntu-latest' }}
8991
steps:
9092
- name: Checkout code
9193
uses: actions/checkout@v4
@@ -95,7 +97,7 @@ jobs:
9597
- uses: ./.github/actions/setup-cmux
9698

9799
- name: Run integration tests with coverage
98-
run: TEST_INTEGRATION=1 bun x jest --coverage ${{ github.event.inputs.test_filter || 'tests' }}
100+
run: TEST_INTEGRATION=1 bun x jest --coverage --maxWorkers=200% --silent ${{ github.event.inputs.test_filter || 'tests' }}
99101
env:
100102
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
101103
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -111,6 +113,7 @@ jobs:
111113
storybook-test:
112114
name: Storybook Interaction Tests
113115
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }}
116+
if: github.event.inputs.test_filter == ''
114117
steps:
115118
- name: Checkout code
116119
uses: actions/checkout@v4
@@ -136,6 +139,7 @@ jobs:
136139
e2e-test:
137140
name: End-to-End Tests
138141
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }}
142+
if: github.event.inputs.test_filter == ''
139143
steps:
140144
- name: Checkout code
141145
uses: actions/checkout@v4

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,4 @@ tmpfork
104104
.cmux-agent-cli
105105
storybook-static/
106106
*.tgz
107+
src/test-workspaces/

bun.lock

Lines changed: 89 additions & 130 deletions
Large diffs are not rendered by default.

docs/AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Verify with React DevTools Profiler - MarkdownCore should only re-render when co
110110

111111
## Documentation Guidelines
112112

113-
**Free-floating markdown docs are not permitted.** Documentation must be organized:
113+
**Free-floating markdown docs are not permitted.** Documentation must be organized. Do not create standalone markdown files in the project root or random locations, even for implementation summaries or planning documents - use the propose_plan tool or inline comments instead.
114114

115115
- **User-facing docs**`./docs/` directory
116116
- **IMPORTANT**: Read `docs/README.md` first before writing user-facing documentation
@@ -119,6 +119,7 @@ Verify with React DevTools Profiler - MarkdownCore should only re-render when co
119119
- Use standard markdown + mermaid diagrams
120120
- **Developer docs** → inline with the code its documenting as comments. Consider them notes as notes to future Assistants to understand the logic more quickly.
121121
**DO NOT** create standalone documentation files in the project root or random locations.
122+
- **Test documentation** → inline comments in test files explaining complex test setup or edge cases, NOT separate README files.
122123

123124
**NEVER create markdown documentation files (README, guides, summaries, etc.) in the project root during feature development unless the user explicitly requests documentation.** Code + tests + inline comments are complete documentation.
124125

@@ -204,6 +205,7 @@ This project uses **Make** as the primary build orchestrator. See `Makefile` for
204205
- **Integration tests:**
205206
- Run specific integration test: `TEST_INTEGRATION=1 bun x jest tests/ipcMain/sendMessage.test.ts -t "test name pattern"`
206207
- Run all integration tests: `TEST_INTEGRATION=1 bun x jest tests` (~35 seconds, runs 40 tests)
208+
- **⚠️ Running `tests/ipcMain` locally takes a very long time.** Prefer running specific test files or use `-t` to filter to specific tests.
207209
- **Performance**: Tests use `test.concurrent()` to run in parallel within each file
208210
- **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.
209211

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"docs:watch": "make docs-watch",
4242
"storybook": "make storybook",
4343
"storybook:build": "make storybook-build",
44-
"test:storybook": "make test-storybook"
44+
"test:storybook": "make test-storybook",
45+
"rebuild": "echo \"No native modules to rebuild\""
4546
},
4647
"dependencies": {
4748
"@ai-sdk/anthropic": "^2.0.29",
@@ -69,6 +70,7 @@
6970
"markdown-it": "^14.1.0",
7071
"minimist": "^1.2.8",
7172
"rehype-harden": "^1.1.5",
73+
"shescape": "^2.1.6",
7274
"source-map-support": "^0.5.21",
7375
"streamdown": "^1.4.0",
7476
"undici": "^7.16.0",

scripts/wait_pr_checks.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ while true; do
123123
echo "💡 To extract detailed logs from the failed run:"
124124
echo " ./scripts/extract_pr_logs.sh $PR_NUMBER"
125125
echo " ./scripts/extract_pr_logs.sh $PR_NUMBER <job_pattern> # e.g., Integration"
126+
echo ""
127+
echo "💡 To re-run a subset of integration tests faster with workflow_dispatch:"
128+
echo " gh workflow run ci.yml --ref $(git rev-parse --abbrev-ref HEAD) -f test_filter=\"tests/ipcMain/specificTest.test.ts\""
129+
echo " gh workflow run ci.yml --ref $(git rev-parse --abbrev-ref HEAD) -f test_filter=\"-t 'specific test name'\""
126130
exit 1
127131
fi
128132

src/App.tsx

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import { CommandPalette } from "./components/CommandPalette";
2222
import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources";
2323

2424
import type { ThinkingLevel } from "./types/thinking";
25+
import type { RuntimeConfig } from "./types/runtime";
2526
import { CUSTOM_EVENTS } from "./constants/events";
2627
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork";
2728
import { getThinkingLevelKey } from "./constants/storage";
2829
import type { BranchListResult } from "./types/ipc";
2930
import { useTelemetry } from "./hooks/useTelemetry";
31+
import { parseRuntimeString } from "./utils/chatCommands";
3032

3133
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
3234

@@ -233,15 +235,35 @@ function AppInner() {
233235
[handleRemoveProject]
234236
);
235237

236-
const handleCreateWorkspace = async (branchName: string, trunkBranch: string) => {
238+
const handleCreateWorkspace = async (
239+
branchName: string,
240+
trunkBranch: string,
241+
runtime?: string
242+
) => {
237243
if (!workspaceModalProject) return;
238244

239245
console.assert(
240246
typeof trunkBranch === "string" && trunkBranch.trim().length > 0,
241247
"Expected trunk branch to be provided by the workspace modal"
242248
);
243249

244-
const newWorkspace = await createWorkspace(workspaceModalProject, branchName, trunkBranch);
250+
// Parse runtime config if provided
251+
let runtimeConfig: RuntimeConfig | undefined;
252+
if (runtime) {
253+
try {
254+
runtimeConfig = parseRuntimeString(runtime, branchName);
255+
} catch (err) {
256+
console.error("Failed to parse runtime config:", err);
257+
throw err; // Let modal handle the error
258+
}
259+
}
260+
261+
const newWorkspace = await createWorkspace(
262+
workspaceModalProject,
263+
branchName,
264+
trunkBranch,
265+
runtimeConfig
266+
);
245267
if (newWorkspace) {
246268
// Track workspace creation
247269
telemetry.workspaceCreated(newWorkspace.workspaceId);
@@ -406,21 +428,6 @@ function AppInner() {
406428
[handleAddWorkspace]
407429
);
408430

409-
const createWorkspaceFromPalette = useCallback(
410-
async (projectPath: string, branchName: string, trunkBranch: string) => {
411-
console.assert(
412-
typeof trunkBranch === "string" && trunkBranch.trim().length > 0,
413-
"Expected trunk branch to be provided by the command palette"
414-
);
415-
const newWs = await createWorkspace(projectPath, branchName, trunkBranch);
416-
if (newWs) {
417-
telemetry.workspaceCreated(newWs.workspaceId);
418-
setSelectedWorkspace(newWs);
419-
}
420-
},
421-
[createWorkspace, setSelectedWorkspace, telemetry]
422-
);
423-
424431
const getBranchesForProject = useCallback(
425432
async (projectPath: string): Promise<BranchListResult> => {
426433
const branchResult = await window.api.projects.listBranches(projectPath);
@@ -488,7 +495,6 @@ function AppInner() {
488495
getThinkingLevel: getThinkingLevelForWorkspace,
489496
onSetThinkingLevel: setThinkingLevelFromPalette,
490497
onOpenNewWorkspaceModal: openNewWorkspaceFromPalette,
491-
onCreateWorkspace: createWorkspaceFromPalette,
492498
getBranchesForProject,
493499
onSelectWorkspace: selectWorkspaceFromPalette,
494500
onRemoveWorkspace: removeWorkspaceFromPalette,

src/components/NewWorkspaceModal.tsx

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ interface NewWorkspaceModalProps {
1010
defaultTrunkBranch?: string;
1111
loadErrorMessage?: string | null;
1212
onClose: () => void;
13-
onAdd: (branchName: string, trunkBranch: string) => Promise<void>;
13+
onAdd: (branchName: string, trunkBranch: string, runtime?: string) => Promise<void>;
1414
}
1515

16+
// Shared form field styles
17+
const formFieldClasses =
18+
"[&_label]:text-foreground [&_input]:bg-modal-bg [&_input]:border-border-medium [&_input]:focus:border-accent [&_select]:bg-modal-bg [&_select]:border-border-medium [&_select]:focus:border-accent [&_option]:bg-modal-bg mb-5 [&_input]:w-full [&_input]:rounded [&_input]:border [&_input]:px-3 [&_input]:py-2 [&_input]:text-sm [&_input]:text-white [&_input]:focus:outline-none [&_input]:disabled:cursor-not-allowed [&_input]:disabled:opacity-60 [&_label]:mb-2 [&_label]:block [&_label]:text-sm [&_option]:text-white [&_select]:w-full [&_select]:cursor-pointer [&_select]:rounded [&_select]:border [&_select]:px-3 [&_select]:py-2 [&_select]:text-sm [&_select]:text-white [&_select]:focus:outline-none [&_select]:disabled:cursor-not-allowed [&_select]:disabled:opacity-60";
19+
1620
const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
1721
isOpen,
1822
projectName,
@@ -24,6 +28,8 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
2428
}) => {
2529
const [branchName, setBranchName] = useState("");
2630
const [trunkBranch, setTrunkBranch] = useState(defaultTrunkBranch ?? branches[0] ?? "");
31+
const [runtimeMode, setRuntimeMode] = useState<"local" | "ssh">("local");
32+
const [sshHost, setSshHost] = useState("");
2733
const [isLoading, setIsLoading] = useState(false);
2834
const [error, setError] = useState<string | null>(null);
2935
const infoId = useId();
@@ -53,6 +59,8 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
5359
const handleCancel = () => {
5460
setBranchName("");
5561
setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? "");
62+
setRuntimeMode("local");
63+
setSshHost("");
5664
setError(loadErrorMessage ?? null);
5765
onClose();
5866
};
@@ -74,13 +82,29 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
7482
console.assert(normalizedTrunkBranch.length > 0, "Expected trunk branch name to be validated");
7583
console.assert(trimmedBranchName.length > 0, "Expected branch name to be validated");
7684

85+
// Validate SSH host if SSH runtime selected
86+
if (runtimeMode === "ssh") {
87+
const trimmedHost = sshHost.trim();
88+
if (trimmedHost.length === 0) {
89+
setError("SSH host is required (e.g., hostname or user@host)");
90+
return;
91+
}
92+
// Accept both "hostname" and "user@hostname" formats
93+
// SSH will use current user or ~/.ssh/config if user not specified
94+
}
95+
7796
setIsLoading(true);
7897
setError(null);
7998

8099
try {
81-
await onAdd(trimmedBranchName, normalizedTrunkBranch);
100+
// Build runtime string if SSH selected
101+
const runtime = runtimeMode === "ssh" ? `ssh ${sshHost.trim()}` : undefined;
102+
103+
await onAdd(trimmedBranchName, normalizedTrunkBranch, runtime);
82104
setBranchName("");
83105
setTrunkBranch(defaultTrunkBranch ?? branches[0] ?? "");
106+
setRuntimeMode("local");
107+
setSshHost("");
84108
onClose();
85109
} catch (err) {
86110
const message = err instanceof Error ? err.message : "Failed to create workspace";
@@ -100,7 +124,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
100124
describedById={infoId}
101125
>
102126
<form onSubmit={(event) => void handleSubmit(event)}>
103-
<div className="[&_label]:text-foreground [&_input]:bg-modal-bg [&_input]:border-border-medium [&_input]:focus:border-accent [&_select]:bg-modal-bg [&_select]:border-border-medium [&_select]:focus:border-accent [&_option]:bg-modal-bg mb-5 [&_input]:w-full [&_input]:rounded [&_input]:border [&_input]:px-3 [&_input]:py-2 [&_input]:text-sm [&_input]:text-white [&_input]:focus:outline-none [&_input]:disabled:cursor-not-allowed [&_input]:disabled:opacity-60 [&_label]:mb-2 [&_label]:block [&_label]:text-sm [&_option]:text-white [&_select]:w-full [&_select]:cursor-pointer [&_select]:rounded [&_select]:border [&_select]:px-3 [&_select]:py-2 [&_select]:text-sm [&_select]:text-white [&_select]:focus:outline-none [&_select]:disabled:cursor-not-allowed [&_select]:disabled:opacity-60">
127+
<div className={formFieldClasses}>
104128
<label htmlFor="branchName">
105129
<TooltipWrapper inline>
106130
<span className="cursor-help underline decoration-[#666] decoration-dotted underline-offset-2">
@@ -137,7 +161,7 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
137161
{error && <div className="text-danger-light mt-1.5 text-[13px]">{error}</div>}
138162
</div>
139163

140-
<div className="[&_label]:text-foreground [&_input]:bg-modal-bg [&_input]:border-border-medium [&_input]:focus:border-accent [&_select]:bg-modal-bg [&_select]:border-border-medium [&_select]:focus:border-accent [&_option]:bg-modal-bg mb-5 [&_input]:w-full [&_input]:rounded [&_input]:border [&_input]:px-3 [&_input]:py-2 [&_input]:text-sm [&_input]:text-white [&_input]:focus:outline-none [&_input]:disabled:cursor-not-allowed [&_input]:disabled:opacity-60 [&_label]:mb-2 [&_label]:block [&_label]:text-sm [&_option]:text-white [&_select]:w-full [&_select]:cursor-pointer [&_select]:rounded [&_select]:border [&_select]:px-3 [&_select]:py-2 [&_select]:text-sm [&_select]:text-white [&_select]:focus:outline-none [&_select]:disabled:cursor-not-allowed [&_select]:disabled:opacity-60">
164+
<div className={formFieldClasses}>
141165
<label htmlFor="trunkBranch">Trunk Branch:</label>
142166
{hasBranches ? (
143167
<select
@@ -173,18 +197,62 @@ const NewWorkspaceModal: React.FC<NewWorkspaceModalProps> = ({
173197
)}
174198
</div>
175199

200+
<div className={formFieldClasses}>
201+
<label htmlFor="runtimeMode">Runtime:</label>
202+
<select
203+
id="runtimeMode"
204+
value={runtimeMode}
205+
onChange={(event) => {
206+
setRuntimeMode(event.target.value as "local" | "ssh");
207+
setError(null);
208+
}}
209+
disabled={isLoading}
210+
>
211+
<option value="local">Local</option>
212+
<option value="ssh">SSH Remote</option>
213+
</select>
214+
</div>
215+
216+
{runtimeMode === "ssh" && (
217+
<div className={formFieldClasses}>
218+
<label htmlFor="sshHost">SSH Host:</label>
219+
<input
220+
id="sshHost"
221+
type="text"
222+
value={sshHost}
223+
onChange={(event) => {
224+
setSshHost(event.target.value);
225+
setError(null);
226+
}}
227+
placeholder="hostname or user@hostname"
228+
disabled={isLoading}
229+
required
230+
aria-required="true"
231+
/>
232+
<div className="text-muted mt-1.5 text-[13px]">
233+
Workspace will be created at ~/cmux/{branchName || "<branch-name>"} on remote host
234+
</div>
235+
</div>
236+
)}
237+
176238
<ModalInfo id={infoId}>
177239
<p>This will create a git worktree at:</p>
178240
<code className="block break-all">
179-
~/.cmux/src/{projectName}/{branchName || "<branch-name>"}
241+
{runtimeMode === "ssh"
242+
? `${sshHost || "<host>"}:~/cmux/${branchName || "<branch-name>"}`
243+
: `~/.cmux/src/${projectName}/${branchName || "<branch-name>"}`}
180244
</code>
181245
</ModalInfo>
182246

183247
{branchName.trim() && (
184248
<div>
185249
<div className="text-muted mb-2 font-sans text-xs">Equivalent command:</div>
186250
<div className="bg-dark border-border-light text-light mt-5 rounded border p-3 font-mono text-[13px] break-all whitespace-pre-wrap">
187-
{formatNewCommand(branchName.trim(), trunkBranch.trim() || undefined)}
251+
{formatNewCommand(
252+
branchName.trim(),
253+
trunkBranch.trim() || undefined,
254+
runtimeMode === "ssh" && sshHost.trim() ? `ssh ${sshHost.trim()}` : undefined
255+
)}
188256
</div>
189257
</div>
190258
)}

src/config.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -129,24 +129,6 @@ export class Config {
129129
* Get the workspace worktree path for a given directory name.
130130
* The directory name is the workspace name (branch name).
131131
*/
132-
getWorkspacePath(projectPath: string, directoryName: string): string {
133-
const projectName = this.getProjectName(projectPath);
134-
return path.join(this.srcDir, projectName, directoryName);
135-
}
136-
137-
/**
138-
* Compute workspace path from metadata.
139-
* Directory uses workspace name (e.g., ~/.cmux/src/project/workspace-name).
140-
*/
141-
getWorkspacePaths(metadata: WorkspaceMetadata): {
142-
/** Worktree path (uses workspace name as directory) */
143-
namedWorkspacePath: string;
144-
} {
145-
const path = this.getWorkspacePath(metadata.projectPath, metadata.name);
146-
return {
147-
namedWorkspacePath: path,
148-
};
149-
}
150132

151133
/**
152134
* Add paths to WorkspaceMetadata to create FrontendWorkspaceMetadata.
@@ -274,6 +256,8 @@ export class Config {
274256
projectPath,
275257
// GUARANTEE: All workspaces must have createdAt (assign now if missing)
276258
createdAt: workspace.createdAt ?? new Date().toISOString(),
259+
// Include runtime config if present (for SSH workspaces)
260+
runtimeConfig: workspace.runtimeConfig,
277261
};
278262

279263
// Migrate missing createdAt to config for next load
@@ -383,7 +367,10 @@ export class Config {
383367
// Check if workspace already exists (by ID)
384368
const existingIndex = project.workspaces.findIndex((w) => w.id === metadata.id);
385369

386-
const workspacePath = this.getWorkspacePath(projectPath, metadata.name);
370+
// Compute workspace path - this is only for legacy config migration
371+
// New code should use Runtime.getWorkspacePath() directly
372+
const projectName = this.getProjectName(projectPath);
373+
const workspacePath = path.join(this.srcDir, projectName, metadata.name);
387374
const workspaceEntry: Workspace = {
388375
path: workspacePath,
389376
id: metadata.id,

src/constants/env.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Standard environment variables for non-interactive command execution.
3+
* These prevent tools from blocking on editor/credential prompts.
4+
*/
5+
export const NON_INTERACTIVE_ENV_VARS = {
6+
// Prevent interactive editors from blocking execution
7+
// Critical for git operations like rebase/commit that try to open editors
8+
GIT_EDITOR: "true", // Git-specific editor (highest priority)
9+
GIT_SEQUENCE_EDITOR: "true", // For interactive rebase sequences
10+
EDITOR: "true", // General fallback for non-git commands
11+
VISUAL: "true", // Another common editor environment variable
12+
// Prevent git from prompting for credentials
13+
GIT_TERMINAL_PROMPT: "0", // Disables git credential prompts
14+
} as const;

0 commit comments

Comments
 (0)