Skip to content

Commit 1eaca8a

Browse files
committed
🤖 feat: add Docker runtime support
Add Docker runtime for running workspaces in isolated containers. Key features: - One container per workspace (mux-{project}-{workspace}) - Fixed paths inside container: /src for workspace, /tmp/mux-bashes for background processes - Container lifecycle managed by mux (created on workspace creation, destroyed on deletion) - Project sync via git bundle (docker cp to transfer) New files: - DockerRuntime.ts: Main runtime implementation with exec, file ops, workspace management - DockerRuntime.test.ts: Basic unit tests Schema changes: - Added 'docker' to RuntimeModeSchema enum - Docker config: { type: 'docker', image: string } Frontend updates: - DockerIcon component - Docker runtime in selector with cyan/docker-blue colors - /new -r docker <image> syntax support Usage: /new my-feature -r docker ubuntu:22.04 _Generated with mux_
1 parent abbdbe4 commit 1eaca8a

File tree

16 files changed

+1194
-24
lines changed

16 files changed

+1194
-24
lines changed

docs/system-prompt.mdx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ When the user asks you to remember something:
4747
/**
4848
* Build environment context XML block describing the workspace.
4949
* @param workspacePath - Workspace directory path
50-
* @param runtimeType - Runtime type: "local", "worktree", or "ssh"
50+
* @param runtimeType - Runtime type: "local", "worktree", "ssh", or "docker"
5151
*/
5252
function buildEnvironmentContext(
5353
workspacePath: string,
54-
runtimeType: "local" | "worktree" | "ssh"
54+
runtimeType: "local" | "worktree" | "ssh" | "docker"
5555
): string {
5656
if (runtimeType === "local") {
5757
// Local runtime works directly in project directory - may or may not be git
@@ -78,6 +78,19 @@ You are in a clone of a git repository at ${workspacePath}
7878
`;
7979
}
8080

81+
if (runtimeType === "docker") {
82+
// Docker runtime runs in an isolated container
83+
return `
84+
<environment>
85+
You are in a clone of a git repository at ${workspacePath} inside a Docker container
86+
87+
- This IS a git repository - run git commands directly (no cd needed)
88+
- Tools run here automatically inside the container
89+
- You are meant to do your work isolated from the user and other agents
90+
</environment>
91+
`;
92+
}
93+
8194
// Worktree runtime creates a git worktree locally
8295
return `
8396
<environment>

src/browser/components/RuntimeIconSelector.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react";
22
import { cn } from "@/common/lib/utils";
33
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
4-
import { SSHIcon, WorktreeIcon, LocalIcon } from "./icons/RuntimeIcons";
4+
import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon } from "./icons/RuntimeIcons";
55
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
66

77
interface RuntimeIconSelectorProps {
@@ -36,6 +36,11 @@ const RUNTIME_STYLES = {
3636
active:
3737
"bg-[var(--color-runtime-local)]/30 text-foreground border-[var(--color-runtime-local)]/60",
3838
},
39+
docker: {
40+
idle: "bg-transparent text-muted border-[var(--color-runtime-docker)]/30 hover:border-[var(--color-runtime-docker)]/50",
41+
active:
42+
"bg-[var(--color-runtime-docker)]/20 text-[var(--color-runtime-docker-text)] border-[var(--color-runtime-docker)]/60",
43+
},
3944
} as const;
4045

4146
const RUNTIME_INFO: Record<RuntimeMode, { label: string; description: string }> = {
@@ -51,6 +56,10 @@ const RUNTIME_INFO: Record<RuntimeMode, { label: string; description: string }>
5156
label: "SSH",
5257
description: "Remote clone on SSH host",
5358
},
59+
docker: {
60+
label: "Docker",
61+
description: "Isolated container per workspace",
62+
},
5463
};
5564

5665
interface RuntimeIconButtonProps {
@@ -74,7 +83,9 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) {
7483
? SSHIcon
7584
: props.mode === RUNTIME_MODE.WORKTREE
7685
? WorktreeIcon
77-
: LocalIcon;
86+
: props.mode === RUNTIME_MODE.DOCKER
87+
? DockerIcon
88+
: LocalIcon;
7889

7990
return (
8091
<Tooltip>
@@ -127,7 +138,12 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) {
127138
* Each tooltip has a "Default for project" checkbox to persist the preference.
128139
*/
129140
export function RuntimeIconSelector(props: RuntimeIconSelectorProps) {
130-
const modes: RuntimeMode[] = [RUNTIME_MODE.LOCAL, RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH];
141+
const modes: RuntimeMode[] = [
142+
RUNTIME_MODE.LOCAL,
143+
RUNTIME_MODE.WORKTREE,
144+
RUNTIME_MODE.SSH,
145+
RUNTIME_MODE.DOCKER,
146+
];
131147
const disabledModes = props.disabledModes ?? [];
132148

133149
return (

src/browser/components/icons/RuntimeIcons.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,28 @@ export function LocalIcon({ size = 10, className }: IconProps) {
7373
</svg>
7474
);
7575
}
76+
77+
/** Container/box icon for Docker runtime */
78+
export function DockerIcon({ size = 10, className }: IconProps) {
79+
return (
80+
<svg
81+
width={size}
82+
height={size}
83+
viewBox="0 0 16 16"
84+
fill="none"
85+
stroke="currentColor"
86+
strokeWidth="1.5"
87+
strokeLinecap="round"
88+
strokeLinejoin="round"
89+
aria-label="Docker Runtime"
90+
className={className}
91+
>
92+
{/* Container box with stacked layers */}
93+
<rect x="2" y="6" width="12" height="8" rx="1" />
94+
<line x1="2" y1="10" x2="14" y2="10" />
95+
<line x1="5" y1="3" x2="5" y2="6" />
96+
<line x1="8" y1="2" x2="8" y2="6" />
97+
<line x1="11" y1="3" x2="11" y2="6" />
98+
</svg>
99+
);
100+
}

src/browser/styles/globals.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
--color-runtime-worktree: #a855f7;
7777
--color-runtime-worktree-text: #c084fc; /* purple-400 */
7878
--color-runtime-local: hsl(0 0% 60%); /* matches --color-muted-foreground */
79+
--color-runtime-docker: #2496ed; /* Docker blue */
80+
--color-runtime-docker-text: #54b3f4;
7981

8082
/* Background & Layout */
8183
--color-background: hsl(0 0% 12%);
@@ -323,6 +325,8 @@
323325
--color-runtime-worktree: #a855f7;
324326
--color-runtime-worktree-text: #c084fc;
325327
--color-runtime-local: hsl(210 14% 48%); /* matches --color-muted-foreground */
328+
--color-runtime-docker: #2496ed; /* Docker blue */
329+
--color-runtime-docker-text: #0d7cc4;
326330

327331
--color-background: hsl(210 33% 98%);
328332
--color-background-secondary: hsl(210 36% 95%);
@@ -555,6 +559,8 @@
555559
--color-runtime-worktree: #6c71c4; /* violet */
556560
--color-runtime-worktree-text: #6c71c4;
557561
--color-runtime-local: #839496; /* base0 */
562+
--color-runtime-docker: #2aa198; /* cyan */
563+
--color-runtime-docker-text: #2aa198;
558564

559565
/* Background & Layout - Solarized base colors */
560566
--color-background: #fdf6e3; /* base3 */
@@ -770,6 +776,8 @@
770776
--color-runtime-worktree: #6c71c4; /* violet */
771777
--color-runtime-worktree-text: #6c71c4;
772778
--color-runtime-local: #839496; /* base0 */
779+
--color-runtime-docker: #2aa198; /* cyan */
780+
--color-runtime-docker-text: #2aa198;
773781

774782
/* Background & Layout - Solarized dark base colors
775783
Palette reference:

src/browser/utils/chatCommands.test.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,37 @@ describe("parseRuntimeString", () => {
9898
});
9999
});
100100

101-
test("throws error for unknown runtime type", () => {
101+
test("parses docker runtime with image", () => {
102+
const result = parseRuntimeString("docker ubuntu:22.04", workspaceName);
103+
expect(result).toEqual({
104+
type: "docker",
105+
image: "ubuntu:22.04",
106+
});
107+
});
108+
109+
test("parses docker with registry image", () => {
110+
const result = parseRuntimeString("docker ghcr.io/myorg/dev:latest", workspaceName);
111+
expect(result).toEqual({
112+
type: "docker",
113+
image: "ghcr.io/myorg/dev:latest",
114+
});
115+
});
116+
117+
test("throws error for docker without image", () => {
102118
expect(() => parseRuntimeString("docker", workspaceName)).toThrow(
103-
"Unknown runtime type: 'docker'. Use 'ssh <host>', 'worktree', or 'local'"
119+
"Docker runtime requires image"
104120
);
121+
expect(() => parseRuntimeString("docker ", workspaceName)).toThrow(
122+
"Docker runtime requires image"
123+
);
124+
});
125+
126+
test("throws error for unknown runtime type", () => {
105127
expect(() => parseRuntimeString("remote", workspaceName)).toThrow(
106-
"Unknown runtime type: 'remote'. Use 'ssh <host>', 'worktree', or 'local'"
128+
"Unknown runtime type: 'remote'. Use 'ssh <host>', 'docker <image>', 'worktree', or 'local'"
129+
);
130+
expect(() => parseRuntimeString("kubernetes", workspaceName)).toThrow(
131+
"Unknown runtime type: 'kubernetes'. Use 'ssh <host>', 'docker <image>', 'worktree', or 'local'"
107132
);
108133
});
109134
});

src/browser/utils/chatCommands.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type {
1616
} from "@/common/types/message";
1717
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
1818
import type { RuntimeConfig } from "@/common/types/runtime";
19-
import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/common/types/runtime";
19+
import { RUNTIME_MODE, SSH_RUNTIME_PREFIX, DOCKER_RUNTIME_PREFIX } from "@/common/types/runtime";
2020
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
2121
import { WORKSPACE_ONLY_COMMANDS } from "@/constants/slashCommands";
2222
import type { Toast } from "@/browser/components/ChatInputToast";
@@ -439,6 +439,7 @@ async function handleForkCommand(
439439
* Parse runtime string from -r flag into RuntimeConfig for backend
440440
* Supports formats:
441441
* - "ssh <host>" or "ssh <user@host>" -> SSH runtime
442+
* - "docker <image>" -> Docker container runtime
442443
* - "worktree" -> Worktree runtime (git worktrees)
443444
* - "local" -> Local runtime (project-dir, no isolation)
444445
* - undefined -> Worktree runtime (default)
@@ -482,7 +483,22 @@ export function parseRuntimeString(
482483
};
483484
}
484485

485-
throw new Error(`Unknown runtime type: '${runtime}'. Use 'ssh <host>', 'worktree', or 'local'`);
486+
// Parse "docker <image>" format
487+
if (lowerTrimmed === RUNTIME_MODE.DOCKER || lowerTrimmed.startsWith(DOCKER_RUNTIME_PREFIX)) {
488+
const imagePart = trimmed.slice(DOCKER_RUNTIME_PREFIX.length - 1).trim(); // Preserve original case for image
489+
if (!imagePart) {
490+
throw new Error("Docker runtime requires image (e.g., 'docker ubuntu:22.04')");
491+
}
492+
493+
return {
494+
type: RUNTIME_MODE.DOCKER,
495+
image: imagePart,
496+
};
497+
}
498+
499+
throw new Error(
500+
`Unknown runtime type: '${runtime}'. Use 'ssh <host>', 'docker <image>', 'worktree', or 'local'`
501+
);
486502
}
487503

488504
export interface CreateWorkspaceOptions {

src/browser/utils/ui/runtimeBadge.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,19 @@ export function extractSshHostname(runtimeConfig?: RuntimeConfig): string | null
2424

2525
return hostname || null;
2626
}
27+
28+
/**
29+
* Extract Docker image from Docker runtime config.
30+
* Returns null if runtime is not Docker.
31+
*
32+
* Examples:
33+
* - "ubuntu:22.04" -> "ubuntu:22.04"
34+
* - "ghcr.io/myorg/dev-image:latest" -> "ghcr.io/myorg/dev-image:latest"
35+
*/
36+
export function extractDockerImage(runtimeConfig?: RuntimeConfig): string | null {
37+
if (!runtimeConfig?.type || runtimeConfig.type !== "docker") {
38+
return null;
39+
}
40+
41+
return runtimeConfig.image || null;
42+
}

src/common/orpc/schemas/runtime.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { z } from "zod";
22

3-
export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh"]);
3+
export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh", "docker"]);
44

55
/**
66
* Runtime configuration union type.
@@ -56,4 +56,9 @@ export const RuntimeConfigSchema = z.union([
5656
.meta({ description: "Path to SSH private key (if not using ~/.ssh/config or ssh-agent)" }),
5757
port: z.number().optional().meta({ description: "SSH port (default: 22)" }),
5858
}),
59+
// Docker runtime - each workspace runs in its own container
60+
z.object({
61+
type: z.literal("docker"),
62+
image: z.string().meta({ description: "Docker image to use (e.g., ubuntu:22.04)" }),
63+
}),
5964
]);

src/common/types/runtime.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,27 @@ export const RUNTIME_MODE = {
1414
LOCAL: "local" as const,
1515
WORKTREE: "worktree" as const,
1616
SSH: "ssh" as const,
17+
DOCKER: "docker" as const,
1718
} as const;
1819

1920
/** Runtime string prefix for SSH mode (e.g., "ssh hostname") */
2021
export const SSH_RUNTIME_PREFIX = "ssh ";
2122

23+
/** Runtime string prefix for Docker mode (e.g., "docker ubuntu:22.04") */
24+
export const DOCKER_RUNTIME_PREFIX = "docker ";
25+
2226
export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
2327

2428
/**
25-
* Parse runtime string from localStorage or UI input into mode and host
29+
* Parse runtime string from localStorage or UI input into mode and host/image
2630
* Format: "ssh <host>" -> { mode: "ssh", host: "<host>" }
2731
* "ssh" -> { mode: "ssh", host: "" }
32+
* "docker <image>" -> { mode: "docker", host: "<image>" }
33+
* "docker" -> { mode: "docker", host: "" }
2834
* "worktree" -> { mode: "worktree", host: "" }
2935
* "local" or undefined -> { mode: "local", host: "" }
3036
*
37+
* Note: For Docker, "host" field stores the image name for consistency.
3138
* Use this for UI state management (localStorage, form inputs)
3239
*/
3340
export function parseRuntimeModeAndHost(runtime: string | null | undefined): {
@@ -60,6 +67,17 @@ export function parseRuntimeModeAndHost(runtime: string | null | undefined): {
6067
return { mode: RUNTIME_MODE.SSH, host: "" };
6168
}
6269

70+
// Check for "docker <image>" format
71+
if (lowerTrimmed.startsWith(DOCKER_RUNTIME_PREFIX)) {
72+
const image = trimmed.substring(DOCKER_RUNTIME_PREFIX.length).trim();
73+
return { mode: RUNTIME_MODE.DOCKER, host: image };
74+
}
75+
76+
// Plain "docker" without image
77+
if (lowerTrimmed === RUNTIME_MODE.DOCKER) {
78+
return { mode: RUNTIME_MODE.DOCKER, host: "" };
79+
}
80+
6381
// Try to parse as a plain mode
6482
const modeResult = RuntimeModeSchema.safeParse(lowerTrimmed);
6583
if (modeResult.success) {
@@ -71,15 +89,20 @@ export function parseRuntimeModeAndHost(runtime: string | null | undefined): {
7189
}
7290

7391
/**
74-
* Build runtime string for storage/IPC from mode and host
75-
* Returns: "ssh <host>" for SSH, "local" for local, undefined for worktree (default)
92+
* Build runtime string for storage/IPC from mode and host/image
93+
* Returns: "ssh <host>" for SSH, "docker <image>" for Docker, "local" for local, undefined for worktree (default)
7694
*/
7795
export function buildRuntimeString(mode: RuntimeMode, host: string): string | undefined {
7896
if (mode === RUNTIME_MODE.SSH) {
7997
const trimmedHost = host.trim();
8098
// Persist SSH mode even without a host so UI remains in SSH state
8199
return trimmedHost ? `${SSH_RUNTIME_PREFIX}${trimmedHost}` : "ssh";
82100
}
101+
if (mode === RUNTIME_MODE.DOCKER) {
102+
const trimmedImage = host.trim();
103+
// Persist Docker mode even without an image so UI remains in Docker state
104+
return trimmedImage ? `${DOCKER_RUNTIME_PREFIX}${trimmedImage}` : "docker";
105+
}
83106
if (mode === RUNTIME_MODE.LOCAL) {
84107
return "local";
85108
}
@@ -96,6 +119,15 @@ export function isSSHRuntime(
96119
return config?.type === "ssh";
97120
}
98121

122+
/**
123+
* Type guard to check if a runtime config is Docker
124+
*/
125+
export function isDockerRuntime(
126+
config: RuntimeConfig | undefined
127+
): config is Extract<RuntimeConfig, { type: "docker" }> {
128+
return config?.type === "docker";
129+
}
130+
99131
/**
100132
* Type guard to check if a runtime config uses worktree semantics.
101133
* This includes both explicit "worktree" type AND legacy "local" with srcBaseDir.

src/common/utils/runtimeCompatibility.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ import type { RuntimeConfig } from "@/common/types/runtime";
1919
* - "local" with srcBaseDir: Legacy worktree config (for backward compat)
2020
* - "worktree": Explicit worktree runtime
2121
* - "ssh": Remote SSH runtime
22+
* - "docker": Docker container runtime
2223
*/
2324
export function isIncompatibleRuntimeConfig(config: RuntimeConfig | undefined): boolean {
2425
if (!config) {
2526
return false;
2627
}
2728
// All known types are compatible
28-
const knownTypes = ["local", "worktree", "ssh"];
29+
const knownTypes = ["local", "worktree", "ssh", "docker"];
2930
if (!knownTypes.includes(config.type)) {
3031
// Unknown type from a future version
3132
return true;

0 commit comments

Comments
 (0)