Skip to content

Commit b432988

Browse files
committed
🤖 feat: add DockerRuntime support
- Add DockerRuntime implementation (docker exec-based runtime) - Add runtime parsing/config support for docker - Keep container naming compatible with existing workspaces --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh`_
1 parent 020a17b commit b432988

27 files changed

+1482
-236
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/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,7 @@ function createDraftSettingsHarness(
516516
initial?: Partial<{
517517
runtimeMode: RuntimeMode;
518518
sshHost: string;
519+
dockerImage: string;
519520
trunkBranch: string;
520521
runtimeString?: string | undefined;
521522
defaultRuntimeMode?: RuntimeMode;
@@ -525,12 +526,14 @@ function createDraftSettingsHarness(
525526
runtimeMode: initial?.runtimeMode ?? "local",
526527
defaultRuntimeMode: initial?.defaultRuntimeMode ?? "worktree",
527528
sshHost: initial?.sshHost ?? "",
529+
dockerImage: initial?.dockerImage ?? "",
528530
trunkBranch: initial?.trunkBranch ?? "main",
529531
runtimeString: initial?.runtimeString,
530532
} satisfies {
531533
runtimeMode: RuntimeMode;
532534
defaultRuntimeMode: RuntimeMode;
533535
sshHost: string;
536+
dockerImage: string;
534537
trunkBranch: string;
535538
runtimeString: string | undefined;
536539
};
@@ -558,18 +561,24 @@ function createDraftSettingsHarness(
558561
state.sshHost = host;
559562
});
560563

564+
const setDockerImage = mock((image: string) => {
565+
state.dockerImage = image;
566+
});
567+
561568
return {
562569
state,
563570
setRuntimeMode,
564571
setDefaultRuntimeMode,
565572
setSshHost,
573+
setDockerImage,
566574
setTrunkBranch,
567575
getRuntimeString,
568576
snapshot(): {
569577
settings: DraftWorkspaceSettings;
570578
setRuntimeMode: typeof setRuntimeMode;
571579
setDefaultRuntimeMode: typeof setDefaultRuntimeMode;
572580
setSshHost: typeof setSshHost;
581+
setDockerImage: typeof setDockerImage;
573582
setTrunkBranch: typeof setTrunkBranch;
574583
getRuntimeString: typeof getRuntimeString;
575584
} {
@@ -580,13 +589,15 @@ function createDraftSettingsHarness(
580589
runtimeMode: state.runtimeMode,
581590
defaultRuntimeMode: state.defaultRuntimeMode,
582591
sshHost: state.sshHost,
592+
dockerImage: state.dockerImage ?? "",
583593
trunkBranch: state.trunkBranch,
584594
};
585595
return {
586596
settings,
587597
setRuntimeMode,
588598
setDefaultRuntimeMode,
589599
setSshHost,
600+
setDockerImage,
590601
setTrunkBranch,
591602
getRuntimeString,
592603
};

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/hooks/useDraftWorkspaceSettings.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import { useMode } from "@/browser/contexts/ModeContext";
55
import { getDefaultModel } from "./useModelsFromSettings";
66
import {
77
type RuntimeMode,
8+
type ParsedRuntime,
89
parseRuntimeModeAndHost,
910
buildRuntimeString,
11+
RUNTIME_MODE,
1012
} from "@/common/types/runtime";
1113
import {
1214
getModelKey,
1315
getRuntimeKey,
1416
getTrunkBranchKey,
1517
getLastSshHostKey,
18+
getLastDockerImageKey,
1619
getProjectScopeId,
1720
} from "@/common/constants/storage";
1821
import type { UIMode } from "@/common/types/mode";
@@ -33,7 +36,10 @@ export interface DraftWorkspaceSettings {
3336
runtimeMode: RuntimeMode;
3437
/** Persisted default runtime for this project (used to initialize selection) */
3538
defaultRuntimeMode: RuntimeMode;
39+
/** SSH host (persisted separately from mode) */
3640
sshHost: string;
41+
/** Docker image (persisted separately from mode) */
42+
dockerImage: string;
3743
trunkBranch: string;
3844
}
3945

@@ -57,6 +63,7 @@ export function useDraftWorkspaceSettings(
5763
/** Set the default runtime mode for this project (persists via checkbox) */
5864
setDefaultRuntimeMode: (mode: RuntimeMode) => void;
5965
setSshHost: (host: string) => void;
66+
setDockerImage: (image: string) => void;
6067
setTrunkBranch: (branch: string) => void;
6168
getRuntimeString: () => string | undefined;
6269
} {
@@ -78,8 +85,9 @@ export function useDraftWorkspaceSettings(
7885
{ listener: true }
7986
);
8087

81-
// Parse default runtime string into mode (worktree when undefined)
82-
const { mode: defaultRuntimeMode } = parseRuntimeModeAndHost(defaultRuntimeString);
88+
// Parse default runtime string into mode (worktree when undefined or invalid)
89+
const parsedDefault = parseRuntimeModeAndHost(defaultRuntimeString);
90+
const defaultRuntimeMode: RuntimeMode = parsedDefault?.mode ?? RUNTIME_MODE.WORKTREE;
8391

8492
// Currently selected runtime mode for this session (initialized from default)
8593
// This allows user to select a different runtime without changing the default
@@ -105,6 +113,13 @@ export function useDraftWorkspaceSettings(
105113
{ listener: true }
106114
);
107115

116+
// Project-scoped Docker image preference (persisted separately from runtime mode)
117+
const [lastDockerImage, setLastDockerImage] = usePersistedState<string>(
118+
getLastDockerImageKey(projectPath),
119+
"",
120+
{ listener: true }
121+
);
122+
108123
// Initialize trunk branch from backend recommendation or first branch
109124
useEffect(() => {
110125
if (!trunkBranch && branches.length > 0) {
@@ -113,14 +128,31 @@ export function useDraftWorkspaceSettings(
113128
}
114129
}, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]);
115130

131+
// Build ParsedRuntime from mode + stored host/image
132+
const buildParsedRuntime = (mode: RuntimeMode): ParsedRuntime | null => {
133+
switch (mode) {
134+
case RUNTIME_MODE.LOCAL:
135+
return { mode: "local" };
136+
case RUNTIME_MODE.WORKTREE:
137+
return { mode: "worktree" };
138+
case RUNTIME_MODE.SSH:
139+
return lastSshHost ? { mode: "ssh", host: lastSshHost } : null;
140+
case RUNTIME_MODE.DOCKER:
141+
return lastDockerImage ? { mode: "docker", image: lastDockerImage } : null;
142+
default:
143+
return null;
144+
}
145+
};
146+
116147
// Setter for selected runtime mode (changes current selection, does not persist)
117148
const setRuntimeMode = (newMode: RuntimeMode) => {
118149
setSelectedRuntimeMode(newMode);
119150
};
120151

121152
// Setter for default runtime mode (persists via checkbox in tooltip)
122153
const setDefaultRuntimeMode = (newMode: RuntimeMode) => {
123-
const newRuntimeString = buildRuntimeString(newMode, lastSshHost);
154+
const parsed = buildParsedRuntime(newMode);
155+
const newRuntimeString = parsed ? buildRuntimeString(parsed) : undefined;
124156
setDefaultRuntimeString(newRuntimeString);
125157
// Also update selection to match new default
126158
setSelectedRuntimeMode(newMode);
@@ -131,9 +163,15 @@ export function useDraftWorkspaceSettings(
131163
setLastSshHost(newHost);
132164
};
133165

166+
// Setter for Docker image (persisted separately so it's remembered across mode switches)
167+
const setDockerImage = (newImage: string) => {
168+
setLastDockerImage(newImage);
169+
};
170+
134171
// Helper to get runtime string for IPC calls (uses selected mode, not default)
135172
const getRuntimeString = (): string | undefined => {
136-
return buildRuntimeString(selectedRuntimeMode, lastSshHost);
173+
const parsed = buildParsedRuntime(selectedRuntimeMode);
174+
return parsed ? buildRuntimeString(parsed) : undefined;
137175
};
138176

139177
return {
@@ -144,11 +182,13 @@ export function useDraftWorkspaceSettings(
144182
runtimeMode: selectedRuntimeMode,
145183
defaultRuntimeMode,
146184
sshHost: lastSshHost,
185+
dockerImage: lastDockerImage,
147186
trunkBranch,
148187
},
149188
setRuntimeMode,
150189
setDefaultRuntimeMode,
151190
setSshHost,
191+
setDockerImage,
152192
setTrunkBranch,
153193
getRuntimeString,
154194
};

src/browser/styles/globals.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@
7878
--color-runtime-worktree: #a855f7;
7979
--color-runtime-worktree-text: #c084fc; /* purple-400 */
8080
--color-runtime-local: hsl(0 0% 60%); /* matches --color-muted-foreground */
81+
--color-runtime-docker: #2496ed; /* Docker blue */
82+
--color-runtime-docker-text: #54b3f4;
8183

8284
/* Background & Layout */
8385
--color-background: hsl(0 0% 12%);
@@ -318,6 +320,8 @@
318320
--color-runtime-worktree: #a855f7;
319321
--color-runtime-worktree-text: #c084fc;
320322
--color-runtime-local: hsl(210 14% 48%); /* matches --color-muted-foreground */
323+
--color-runtime-docker: #2496ed; /* Docker blue */
324+
--color-runtime-docker-text: #0d7cc4;
321325

322326
--color-background: hsl(210 33% 98%);
323327
--color-background-secondary: hsl(210 36% 95%);
@@ -556,6 +560,8 @@
556560
--color-runtime-worktree: #6c71c4; /* violet */
557561
--color-runtime-worktree-text: #6c71c4;
558562
--color-runtime-local: #839496; /* base0 */
563+
--color-runtime-docker: #2aa198; /* cyan */
564+
--color-runtime-docker-text: #2aa198;
559565

560566
/* Background & Layout - Solarized base colors */
561567
--color-background: #fdf6e3; /* base3 */
@@ -764,6 +770,8 @@
764770
--color-runtime-worktree: #6c71c4; /* violet */
765771
--color-runtime-worktree-text: #6c71c4;
766772
--color-runtime-local: #839496; /* base0 */
773+
--color-runtime-docker: #2aa198; /* cyan */
774+
--color-runtime-docker-text: #2aa198;
767775

768776
/* Background & Layout - Solarized dark base colors
769777
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
});

0 commit comments

Comments
 (0)