From 268045109df3a5ab9b8ee8f0cefc2d311157ba74 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 11 Dec 2025 10:41:03 -0600 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20DockerRuntime?= =?UTF-8?q?=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DockerRuntime implementation (docker exec-based runtime)\n- Add runtime parsing/config support for docker\n- Keep container naming compatible with existing workspaces\n\n_Generated with mux_ --- docs/system-prompt.mdx | 17 +- .../ChatInput/useCreationWorkspace.test.tsx | 11 + .../components/RuntimeIconSelector.tsx | 22 +- src/browser/components/icons/RuntimeIcons.tsx | 25 + .../hooks/useDraftWorkspaceSettings.ts | 48 +- src/browser/styles/globals.css | 8 + src/browser/utils/chatCommands.test.ts | 31 +- src/browser/utils/chatCommands.ts | 70 +- src/browser/utils/ui/runtimeBadge.ts | 16 + src/cli/run.ts | 16 +- src/common/constants/storage.ts | 10 + src/common/orpc/schemas/runtime.ts | 7 +- src/common/types/runtime.test.ts | 75 +- src/common/types/runtime.ts | 100 ++- src/common/utils/runtimeCompatibility.ts | 3 +- src/node/runtime/DockerRuntime.test.ts | 63 ++ src/node/runtime/DockerRuntime.ts | 815 ++++++++++++++++++ src/node/runtime/Runtime.ts | 5 + src/node/runtime/SSHRuntime.ts | 118 +-- src/node/runtime/initHook.ts | 89 +- src/node/runtime/runtimeFactory.ts | 86 +- src/node/runtime/streamUtils.ts | 39 + src/node/services/agentSession.ts | 2 +- src/node/services/aiService.ts | 2 +- src/node/services/systemMessage.ts | 17 +- src/node/services/terminalService.ts | 5 +- src/node/services/workspaceService.ts | 14 +- 27 files changed, 1480 insertions(+), 234 deletions(-) create mode 100644 src/node/runtime/DockerRuntime.test.ts create mode 100644 src/node/runtime/DockerRuntime.ts create mode 100644 src/node/runtime/streamUtils.ts diff --git a/docs/system-prompt.mdx b/docs/system-prompt.mdx index f515a2a571..5192358824 100644 --- a/docs/system-prompt.mdx +++ b/docs/system-prompt.mdx @@ -47,11 +47,11 @@ When the user asks you to remember something: /** * Build environment context XML block describing the workspace. * @param workspacePath - Workspace directory path - * @param runtimeType - Runtime type: "local", "worktree", or "ssh" + * @param runtimeType - Runtime type: "local", "worktree", "ssh", or "docker" */ function buildEnvironmentContext( workspacePath: string, - runtimeType: "local" | "worktree" | "ssh" + runtimeType: "local" | "worktree" | "ssh" | "docker" ): string { if (runtimeType === "local") { // 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} `; } + if (runtimeType === "docker") { + // Docker runtime runs in an isolated container + return ` + +You are in a clone of a git repository at ${workspacePath} inside a Docker container + +- This IS a git repository - run git commands directly (no cd needed) +- Tools run here automatically inside the container +- You are meant to do your work isolated from the user and other agents + +`; + } + // Worktree runtime creates a git worktree locally return ` diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index 8d9d2848ef..223b444fd9 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -521,6 +521,7 @@ function createDraftSettingsHarness( initial?: Partial<{ runtimeMode: RuntimeMode; sshHost: string; + dockerImage: string; trunkBranch: string; runtimeString?: string | undefined; defaultRuntimeMode?: RuntimeMode; @@ -530,12 +531,14 @@ function createDraftSettingsHarness( runtimeMode: initial?.runtimeMode ?? "local", defaultRuntimeMode: initial?.defaultRuntimeMode ?? "worktree", sshHost: initial?.sshHost ?? "", + dockerImage: initial?.dockerImage ?? "", trunkBranch: initial?.trunkBranch ?? "main", runtimeString: initial?.runtimeString, } satisfies { runtimeMode: RuntimeMode; defaultRuntimeMode: RuntimeMode; sshHost: string; + dockerImage: string; trunkBranch: string; runtimeString: string | undefined; }; @@ -563,11 +566,16 @@ function createDraftSettingsHarness( state.sshHost = host; }); + const setDockerImage = mock((image: string) => { + state.dockerImage = image; + }); + return { state, setRuntimeMode, setDefaultRuntimeMode, setSshHost, + setDockerImage, setTrunkBranch, getRuntimeString, snapshot(): { @@ -575,6 +583,7 @@ function createDraftSettingsHarness( setRuntimeMode: typeof setRuntimeMode; setDefaultRuntimeMode: typeof setDefaultRuntimeMode; setSshHost: typeof setSshHost; + setDockerImage: typeof setDockerImage; setTrunkBranch: typeof setTrunkBranch; getRuntimeString: typeof getRuntimeString; } { @@ -585,6 +594,7 @@ function createDraftSettingsHarness( runtimeMode: state.runtimeMode, defaultRuntimeMode: state.defaultRuntimeMode, sshHost: state.sshHost, + dockerImage: state.dockerImage ?? "", trunkBranch: state.trunkBranch, }; return { @@ -592,6 +602,7 @@ function createDraftSettingsHarness( setRuntimeMode, setDefaultRuntimeMode, setSshHost, + setDockerImage, setTrunkBranch, getRuntimeString, }; diff --git a/src/browser/components/RuntimeIconSelector.tsx b/src/browser/components/RuntimeIconSelector.tsx index e61a783b01..73c677be78 100644 --- a/src/browser/components/RuntimeIconSelector.tsx +++ b/src/browser/components/RuntimeIconSelector.tsx @@ -1,7 +1,7 @@ import React from "react"; import { cn } from "@/common/lib/utils"; import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; -import { SSHIcon, WorktreeIcon, LocalIcon } from "./icons/RuntimeIcons"; +import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon } from "./icons/RuntimeIcons"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; interface RuntimeIconSelectorProps { @@ -36,6 +36,11 @@ const RUNTIME_STYLES = { active: "bg-[var(--color-runtime-local)]/30 text-foreground border-[var(--color-runtime-local)]/60", }, + docker: { + idle: "bg-transparent text-muted border-[var(--color-runtime-docker)]/30 hover:border-[var(--color-runtime-docker)]/50", + active: + "bg-[var(--color-runtime-docker)]/20 text-[var(--color-runtime-docker-text)] border-[var(--color-runtime-docker)]/60", + }, } as const; const RUNTIME_INFO: Record = { @@ -51,6 +56,10 @@ const RUNTIME_INFO: Record label: "SSH", description: "Remote clone on SSH host", }, + docker: { + label: "Docker", + description: "Isolated container per workspace", + }, }; interface RuntimeIconButtonProps { @@ -74,7 +83,9 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) { ? SSHIcon : props.mode === RUNTIME_MODE.WORKTREE ? WorktreeIcon - : LocalIcon; + : props.mode === RUNTIME_MODE.DOCKER + ? DockerIcon + : LocalIcon; return ( @@ -127,7 +138,12 @@ function RuntimeIconButton(props: RuntimeIconButtonProps) { * Each tooltip has a "Default for project" checkbox to persist the preference. */ export function RuntimeIconSelector(props: RuntimeIconSelectorProps) { - const modes: RuntimeMode[] = [RUNTIME_MODE.LOCAL, RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH]; + const modes: RuntimeMode[] = [ + RUNTIME_MODE.LOCAL, + RUNTIME_MODE.WORKTREE, + RUNTIME_MODE.SSH, + RUNTIME_MODE.DOCKER, + ]; const disabledModes = props.disabledModes ?? []; return ( diff --git a/src/browser/components/icons/RuntimeIcons.tsx b/src/browser/components/icons/RuntimeIcons.tsx index 51ae7b7c72..ea8a932408 100644 --- a/src/browser/components/icons/RuntimeIcons.tsx +++ b/src/browser/components/icons/RuntimeIcons.tsx @@ -73,3 +73,28 @@ export function LocalIcon({ size = 10, className }: IconProps) { ); } + +/** Container/box icon for Docker runtime */ +export function DockerIcon({ size = 10, className }: IconProps) { + return ( + + {/* Container box with stacked layers */} + + + + + + + ); +} diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index ea83ff2f14..6e1fedaeb3 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -5,14 +5,17 @@ import { useMode } from "@/browser/contexts/ModeContext"; import { getDefaultModel } from "./useModelsFromSettings"; import { type RuntimeMode, + type ParsedRuntime, parseRuntimeModeAndHost, buildRuntimeString, + RUNTIME_MODE, } from "@/common/types/runtime"; import { getModelKey, getRuntimeKey, getTrunkBranchKey, getLastSshHostKey, + getLastDockerImageKey, getProjectScopeId, } from "@/common/constants/storage"; import type { UIMode } from "@/common/types/mode"; @@ -33,7 +36,10 @@ export interface DraftWorkspaceSettings { runtimeMode: RuntimeMode; /** Persisted default runtime for this project (used to initialize selection) */ defaultRuntimeMode: RuntimeMode; + /** SSH host (persisted separately from mode) */ sshHost: string; + /** Docker image (persisted separately from mode) */ + dockerImage: string; trunkBranch: string; } @@ -57,6 +63,7 @@ export function useDraftWorkspaceSettings( /** Set the default runtime mode for this project (persists via checkbox) */ setDefaultRuntimeMode: (mode: RuntimeMode) => void; setSshHost: (host: string) => void; + setDockerImage: (image: string) => void; setTrunkBranch: (branch: string) => void; getRuntimeString: () => string | undefined; } { @@ -78,8 +85,9 @@ export function useDraftWorkspaceSettings( { listener: true } ); - // Parse default runtime string into mode (worktree when undefined) - const { mode: defaultRuntimeMode } = parseRuntimeModeAndHost(defaultRuntimeString); + // Parse default runtime string into mode (worktree when undefined or invalid) + const parsedDefault = parseRuntimeModeAndHost(defaultRuntimeString); + const defaultRuntimeMode: RuntimeMode = parsedDefault?.mode ?? RUNTIME_MODE.WORKTREE; // Currently selected runtime mode for this session (initialized from default) // This allows user to select a different runtime without changing the default @@ -105,6 +113,13 @@ export function useDraftWorkspaceSettings( { listener: true } ); + // Project-scoped Docker image preference (persisted separately from runtime mode) + const [lastDockerImage, setLastDockerImage] = usePersistedState( + getLastDockerImageKey(projectPath), + "", + { listener: true } + ); + // Initialize trunk branch from backend recommendation or first branch useEffect(() => { if (!trunkBranch && branches.length > 0) { @@ -113,6 +128,22 @@ export function useDraftWorkspaceSettings( } }, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]); + // Build ParsedRuntime from mode + stored host/image + const buildParsedRuntime = (mode: RuntimeMode): ParsedRuntime | null => { + switch (mode) { + case RUNTIME_MODE.LOCAL: + return { mode: "local" }; + case RUNTIME_MODE.WORKTREE: + return { mode: "worktree" }; + case RUNTIME_MODE.SSH: + return lastSshHost ? { mode: "ssh", host: lastSshHost } : null; + case RUNTIME_MODE.DOCKER: + return lastDockerImage ? { mode: "docker", image: lastDockerImage } : null; + default: + return null; + } + }; + // Setter for selected runtime mode (changes current selection, does not persist) const setRuntimeMode = (newMode: RuntimeMode) => { setSelectedRuntimeMode(newMode); @@ -120,7 +151,8 @@ export function useDraftWorkspaceSettings( // Setter for default runtime mode (persists via checkbox in tooltip) const setDefaultRuntimeMode = (newMode: RuntimeMode) => { - const newRuntimeString = buildRuntimeString(newMode, lastSshHost); + const parsed = buildParsedRuntime(newMode); + const newRuntimeString = parsed ? buildRuntimeString(parsed) : undefined; setDefaultRuntimeString(newRuntimeString); // Also update selection to match new default setSelectedRuntimeMode(newMode); @@ -131,9 +163,15 @@ export function useDraftWorkspaceSettings( setLastSshHost(newHost); }; + // Setter for Docker image (persisted separately so it's remembered across mode switches) + const setDockerImage = (newImage: string) => { + setLastDockerImage(newImage); + }; + // Helper to get runtime string for IPC calls (uses selected mode, not default) const getRuntimeString = (): string | undefined => { - return buildRuntimeString(selectedRuntimeMode, lastSshHost); + const parsed = buildParsedRuntime(selectedRuntimeMode); + return parsed ? buildRuntimeString(parsed) : undefined; }; return { @@ -144,11 +182,13 @@ export function useDraftWorkspaceSettings( runtimeMode: selectedRuntimeMode, defaultRuntimeMode, sshHost: lastSshHost, + dockerImage: lastDockerImage, trunkBranch, }, setRuntimeMode, setDefaultRuntimeMode, setSshHost, + setDockerImage, setTrunkBranch, getRuntimeString, }; diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 2bf98c0689..098c958daa 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -76,6 +76,8 @@ --color-runtime-worktree: #a855f7; --color-runtime-worktree-text: #c084fc; /* purple-400 */ --color-runtime-local: hsl(0 0% 60%); /* matches --color-muted-foreground */ + --color-runtime-docker: #2496ed; /* Docker blue */ + --color-runtime-docker-text: #54b3f4; /* Background & Layout */ --color-background: hsl(0 0% 12%); @@ -323,6 +325,8 @@ --color-runtime-worktree: #a855f7; --color-runtime-worktree-text: #c084fc; --color-runtime-local: hsl(210 14% 48%); /* matches --color-muted-foreground */ + --color-runtime-docker: #2496ed; /* Docker blue */ + --color-runtime-docker-text: #0d7cc4; --color-background: hsl(210 33% 98%); --color-background-secondary: hsl(210 36% 95%); @@ -555,6 +559,8 @@ --color-runtime-worktree: #6c71c4; /* violet */ --color-runtime-worktree-text: #6c71c4; --color-runtime-local: #839496; /* base0 */ + --color-runtime-docker: #2aa198; /* cyan */ + --color-runtime-docker-text: #2aa198; /* Background & Layout - Solarized base colors */ --color-background: #fdf6e3; /* base3 */ @@ -770,6 +776,8 @@ --color-runtime-worktree: #6c71c4; /* violet */ --color-runtime-worktree-text: #6c71c4; --color-runtime-local: #839496; /* base0 */ + --color-runtime-docker: #2aa198; /* cyan */ + --color-runtime-docker-text: #2aa198; /* Background & Layout - Solarized dark base colors Palette reference: diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index 9b79ec9530..a85e49b788 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -98,12 +98,37 @@ describe("parseRuntimeString", () => { }); }); - test("throws error for unknown runtime type", () => { + test("parses docker runtime with image", () => { + const result = parseRuntimeString("docker ubuntu:22.04", workspaceName); + expect(result).toEqual({ + type: "docker", + image: "ubuntu:22.04", + }); + }); + + test("parses docker with registry image", () => { + const result = parseRuntimeString("docker ghcr.io/myorg/dev:latest", workspaceName); + expect(result).toEqual({ + type: "docker", + image: "ghcr.io/myorg/dev:latest", + }); + }); + + test("throws error for docker without image", () => { expect(() => parseRuntimeString("docker", workspaceName)).toThrow( - "Unknown runtime type: 'docker'. Use 'ssh ', 'worktree', or 'local'" + "Docker runtime requires image" ); + expect(() => parseRuntimeString("docker ", workspaceName)).toThrow( + "Docker runtime requires image" + ); + }); + + test("throws error for unknown runtime type", () => { expect(() => parseRuntimeString("remote", workspaceName)).toThrow( - "Unknown runtime type: 'remote'. Use 'ssh ', 'worktree', or 'local'" + "Unknown runtime type: 'remote'. Use 'ssh ', 'docker ', 'worktree', or 'local'" + ); + expect(() => parseRuntimeString("kubernetes", workspaceName)).toThrow( + "Unknown runtime type: 'kubernetes'. Use 'ssh ', 'docker ', 'worktree', or 'local'" ); }); }); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index d86cfa68c5..34317776c6 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -16,7 +16,7 @@ import type { } from "@/common/types/message"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { RuntimeConfig } from "@/common/types/runtime"; -import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/common/types/runtime"; +import { RUNTIME_MODE, parseRuntimeModeAndHost } from "@/common/types/runtime"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { WORKSPACE_ONLY_COMMANDS } from "@/constants/slashCommands"; import type { Toast } from "@/browser/components/ChatInputToast"; @@ -436,9 +436,12 @@ async function handleForkCommand( } /** - * Parse runtime string from -r flag into RuntimeConfig for backend + * Parse runtime string from -r flag into RuntimeConfig for backend. + * Uses shared parseRuntimeModeAndHost for parsing, then converts to RuntimeConfig. + * * Supports formats: * - "ssh " or "ssh " -> SSH runtime + * - "docker " -> Docker container runtime * - "worktree" -> Worktree runtime (git worktrees) * - "local" -> Local runtime (project-dir, no isolation) * - undefined -> Worktree runtime (default) @@ -447,42 +450,45 @@ export function parseRuntimeString( runtime: string | undefined, _workspaceName: string ): RuntimeConfig | undefined { - if (!runtime) { - return undefined; // Default to worktree (backend decides) + // Use shared parser from common/types/runtime + const parsed = parseRuntimeModeAndHost(runtime); + + // null means invalid input (e.g., "ssh" without host, "docker" without image) + if (parsed === null) { + // Determine which error to throw based on input + const trimmed = runtime?.trim().toLowerCase() ?? ""; + if (trimmed === RUNTIME_MODE.SSH || trimmed.startsWith("ssh ")) { + throw new Error("SSH runtime requires host (e.g., 'ssh hostname' or 'ssh user@host')"); + } + if (trimmed === RUNTIME_MODE.DOCKER || trimmed.startsWith("docker ")) { + throw new Error("Docker runtime requires image (e.g., 'docker ubuntu:22.04')"); + } + throw new Error( + `Unknown runtime type: '${runtime ?? ""}'. Use 'ssh ', 'docker ', 'worktree', or 'local'` + ); } - const trimmed = runtime.trim(); - const lowerTrimmed = trimmed.toLowerCase(); - - // Worktree runtime (explicit or default) - if (lowerTrimmed === RUNTIME_MODE.WORKTREE) { - return undefined; // Explicit worktree - let backend use default - } + // Convert ParsedRuntime to RuntimeConfig + switch (parsed.mode) { + case RUNTIME_MODE.WORKTREE: + return undefined; // Let backend use default worktree config - // Local runtime (project-dir, no isolation) - if (lowerTrimmed === RUNTIME_MODE.LOCAL) { - // Return "local" type without srcBaseDir to indicate project-dir runtime - return { type: RUNTIME_MODE.LOCAL }; - } + case RUNTIME_MODE.LOCAL: + return { type: RUNTIME_MODE.LOCAL }; - // Parse "ssh " or "ssh " format - if (lowerTrimmed === RUNTIME_MODE.SSH || lowerTrimmed.startsWith(SSH_RUNTIME_PREFIX)) { - const hostPart = trimmed.slice(SSH_RUNTIME_PREFIX.length - 1).trim(); // Preserve original case for host - if (!hostPart) { - throw new Error("SSH runtime requires host (e.g., 'ssh hostname' or 'ssh user@host')"); - } + case RUNTIME_MODE.SSH: + return { + type: RUNTIME_MODE.SSH, + host: parsed.host, + srcBaseDir: "~/mux", // Default remote base directory (tilde resolved by backend) + }; - // Accept both "hostname" and "user@hostname" formats - // SSH will use current user or ~/.ssh/config if user not specified - // Use tilde path - backend will resolve it via runtime.resolvePath() - return { - type: RUNTIME_MODE.SSH, - host: hostPart, - srcBaseDir: "~/mux", // Default remote base directory (tilde will be resolved by backend) - }; + case RUNTIME_MODE.DOCKER: + return { + type: RUNTIME_MODE.DOCKER, + image: parsed.image, + }; } - - throw new Error(`Unknown runtime type: '${runtime}'. Use 'ssh ', 'worktree', or 'local'`); } export interface CreateWorkspaceOptions { diff --git a/src/browser/utils/ui/runtimeBadge.ts b/src/browser/utils/ui/runtimeBadge.ts index d9b01a9157..eb87c2a297 100644 --- a/src/browser/utils/ui/runtimeBadge.ts +++ b/src/browser/utils/ui/runtimeBadge.ts @@ -24,3 +24,19 @@ export function extractSshHostname(runtimeConfig?: RuntimeConfig): string | null return hostname || null; } + +/** + * Extract Docker image from Docker runtime config. + * Returns null if runtime is not Docker. + * + * Examples: + * - "ubuntu:22.04" -> "ubuntu:22.04" + * - "ghcr.io/myorg/dev-image:latest" -> "ghcr.io/myorg/dev-image:latest" + */ +export function extractDockerImage(runtimeConfig?: RuntimeConfig): string | null { + if (!runtimeConfig?.type || runtimeConfig.type !== "docker") { + return null; + } + + return runtimeConfig.image || null; +} diff --git a/src/cli/run.ts b/src/cli/run.ts index f5ef06c943..5821100b31 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -52,18 +52,22 @@ function parseRuntimeConfig(value: string | undefined, srcBaseDir: string): Runt return { type: "local" }; } - const { mode, host } = parseRuntimeModeAndHost(value); + const parsed = parseRuntimeModeAndHost(value); + if (!parsed) { + throw new Error( + `Invalid runtime: '${value}'. Use 'local', 'worktree', 'ssh ', or 'docker '` + ); + } - switch (mode) { + switch (parsed.mode) { case RUNTIME_MODE.LOCAL: return { type: "local" }; case RUNTIME_MODE.WORKTREE: return { type: "worktree", srcBaseDir }; case RUNTIME_MODE.SSH: - if (!host.trim()) { - throw new Error("SSH runtime requires a host (e.g., --runtime 'ssh user@host')"); - } - return { type: "ssh", host: host.trim(), srcBaseDir }; + return { type: "ssh", host: parsed.host, srcBaseDir }; + case RUNTIME_MODE.DOCKER: + return { type: "docker", image: parsed.image }; default: return { type: "local" }; } diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index beb43ba657..b922a40eee 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -145,6 +145,16 @@ export function getLastSshHostKey(projectPath: string): string { return `lastSshHost:${projectPath}`; } +/** + * Get the localStorage key for the last entered Docker image for a project + * Stores the last entered Docker image separately from runtime mode + * so it persists when switching between runtime modes + * Format: "lastDockerImage:{projectPath}" + */ +export function getLastDockerImageKey(projectPath: string): string { + return `lastDockerImage:${projectPath}`; +} + /** * Get the localStorage key for the preferred compaction model (global) * Format: "preferredCompactionModel" diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts index 125e39f547..fb91811b4b 100644 --- a/src/common/orpc/schemas/runtime.ts +++ b/src/common/orpc/schemas/runtime.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh"]); +export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh", "docker"]); /** * Runtime configuration union type. @@ -56,4 +56,9 @@ export const RuntimeConfigSchema = z.union([ .meta({ description: "Path to SSH private key (if not using ~/.ssh/config or ssh-agent)" }), port: z.number().optional().meta({ description: "SSH port (default: 22)" }), }), + // Docker runtime - each workspace runs in its own container + z.object({ + type: z.literal("docker"), + image: z.string().meta({ description: "Docker image to use (e.g., ubuntu:22.04)" }), + }), ]); diff --git a/src/common/types/runtime.test.ts b/src/common/types/runtime.test.ts index 9d3c8a6258..dccffeb20b 100644 --- a/src/common/types/runtime.test.ts +++ b/src/common/types/runtime.test.ts @@ -9,69 +9,96 @@ describe("parseRuntimeModeAndHost", () => { }); }); - it("parses SSH mode without host", () => { - expect(parseRuntimeModeAndHost("ssh")).toEqual({ - mode: "ssh", - host: "", + it("returns null for SSH mode without host", () => { + expect(parseRuntimeModeAndHost("ssh")).toBeNull(); + }); + + it("returns null for SSH with trailing space but no host", () => { + expect(parseRuntimeModeAndHost("ssh ")).toBeNull(); + }); + + it("parses Docker mode with image", () => { + expect(parseRuntimeModeAndHost("docker ubuntu:22.04")).toEqual({ + mode: "docker", + image: "ubuntu:22.04", }); }); + it("returns null for Docker mode without image", () => { + expect(parseRuntimeModeAndHost("docker")).toBeNull(); + }); + it("parses local mode", () => { expect(parseRuntimeModeAndHost("local")).toEqual({ mode: "local", - host: "", + }); + }); + + it("parses worktree mode", () => { + expect(parseRuntimeModeAndHost("worktree")).toEqual({ + mode: "worktree", }); }); it("defaults to worktree for undefined", () => { expect(parseRuntimeModeAndHost(undefined)).toEqual({ mode: "worktree", - host: "", }); }); it("defaults to worktree for null", () => { expect(parseRuntimeModeAndHost(null)).toEqual({ mode: "worktree", - host: "", }); }); + + it("returns null for unrecognized runtime", () => { + expect(parseRuntimeModeAndHost("unknown")).toBeNull(); + }); }); describe("buildRuntimeString", () => { it("builds SSH string with host", () => { - expect(buildRuntimeString("ssh", "user@host")).toBe("ssh user@host"); + expect(buildRuntimeString({ mode: "ssh", host: "user@host" })).toBe("ssh user@host"); }); - it("builds SSH string without host (persists SSH mode)", () => { - expect(buildRuntimeString("ssh", "")).toBe("ssh"); + it("builds Docker string with image", () => { + expect(buildRuntimeString({ mode: "docker", image: "ubuntu:22.04" })).toBe( + "docker ubuntu:22.04" + ); }); it("returns 'local' for local mode", () => { - expect(buildRuntimeString("local", "")).toBe("local"); + expect(buildRuntimeString({ mode: "local" })).toBe("local"); }); it("returns undefined for worktree mode (default)", () => { - expect(buildRuntimeString("worktree", "")).toBeUndefined(); - }); - - it("trims whitespace from host", () => { - expect(buildRuntimeString("ssh", " user@host ")).toBe("ssh user@host"); + expect(buildRuntimeString({ mode: "worktree" })).toBeUndefined(); }); }); describe("round-trip parsing and building", () => { - it("preserves SSH mode without host", () => { - const built = buildRuntimeString("ssh", ""); + it("preserves SSH mode with host", () => { + const built = buildRuntimeString({ mode: "ssh", host: "user@host" }); const parsed = parseRuntimeModeAndHost(built); - expect(parsed.mode).toBe("ssh"); - expect(parsed.host).toBe(""); + expect(parsed).toEqual({ mode: "ssh", host: "user@host" }); }); - it("preserves SSH mode with host", () => { - const built = buildRuntimeString("ssh", "user@host"); + it("preserves Docker mode with image", () => { + const built = buildRuntimeString({ mode: "docker", image: "node:20" }); + const parsed = parseRuntimeModeAndHost(built); + expect(parsed).toEqual({ mode: "docker", image: "node:20" }); + }); + + it("preserves local mode", () => { + const built = buildRuntimeString({ mode: "local" }); + const parsed = parseRuntimeModeAndHost(built); + expect(parsed).toEqual({ mode: "local" }); + }); + + it("preserves worktree mode", () => { + const built = buildRuntimeString({ mode: "worktree" }); const parsed = parseRuntimeModeAndHost(built); - expect(parsed.mode).toBe("ssh"); - expect(parsed.host).toBe("user@host"); + expect(parsed).toEqual({ mode: "worktree" }); }); }); diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index 291adf6636..f8ab2b51fa 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -14,77 +14,106 @@ export const RUNTIME_MODE = { LOCAL: "local" as const, WORKTREE: "worktree" as const, SSH: "ssh" as const, + DOCKER: "docker" as const, } as const; /** Runtime string prefix for SSH mode (e.g., "ssh hostname") */ export const SSH_RUNTIME_PREFIX = "ssh "; +/** Runtime string prefix for Docker mode (e.g., "docker ubuntu:22.04") */ +export const DOCKER_RUNTIME_PREFIX = "docker "; + export type RuntimeConfig = z.infer; /** - * Parse runtime string from localStorage or UI input into mode and host + * Parsed runtime result - discriminated union based on mode. + * SSH requires host, Docker requires image, others have no extra args. + */ +export type ParsedRuntime = + | { mode: "local" } + | { mode: "worktree" } + | { mode: "ssh"; host: string } + | { mode: "docker"; image: string }; + +/** + * Parse runtime string from localStorage or UI input into structured result. * Format: "ssh " -> { mode: "ssh", host: "" } - * "ssh" -> { mode: "ssh", host: "" } - * "worktree" -> { mode: "worktree", host: "" } - * "local" or undefined -> { mode: "local", host: "" } + * "docker " -> { mode: "docker", image: "" } + * "worktree" -> { mode: "worktree" } + * "local" or undefined -> { mode: "local" } * - * Use this for UI state management (localStorage, form inputs) + * Note: "ssh" or "docker" without arguments returns null (invalid). + * Use this for UI state management (localStorage, form inputs). */ -export function parseRuntimeModeAndHost(runtime: string | null | undefined): { - mode: RuntimeMode; - host: string; -} { +export function parseRuntimeModeAndHost(runtime: string | null | undefined): ParsedRuntime | null { if (!runtime) { - return { mode: RUNTIME_MODE.WORKTREE, host: "" }; + return { mode: RUNTIME_MODE.WORKTREE }; } const trimmed = runtime.trim(); const lowerTrimmed = trimmed.toLowerCase(); if (lowerTrimmed === RUNTIME_MODE.LOCAL) { - return { mode: RUNTIME_MODE.LOCAL, host: "" }; + return { mode: RUNTIME_MODE.LOCAL }; } if (lowerTrimmed === RUNTIME_MODE.WORKTREE) { - return { mode: RUNTIME_MODE.WORKTREE, host: "" }; + return { mode: RUNTIME_MODE.WORKTREE }; } - // Check for "ssh " format first (before trying to parse as plain mode) + // Check for "ssh " format if (lowerTrimmed.startsWith(SSH_RUNTIME_PREFIX)) { const host = trimmed.substring(SSH_RUNTIME_PREFIX.length).trim(); + if (!host) return null; // "ssh " without host is invalid return { mode: RUNTIME_MODE.SSH, host }; } - // Plain "ssh" without host + // Plain "ssh" without host is invalid if (lowerTrimmed === RUNTIME_MODE.SSH) { - return { mode: RUNTIME_MODE.SSH, host: "" }; + return null; + } + + // Check for "docker " format + if (lowerTrimmed.startsWith(DOCKER_RUNTIME_PREFIX)) { + const image = trimmed.substring(DOCKER_RUNTIME_PREFIX.length).trim(); + if (!image) return null; // "docker " without image is invalid + return { mode: RUNTIME_MODE.DOCKER, image }; + } + + // Plain "docker" without image is invalid + if (lowerTrimmed === RUNTIME_MODE.DOCKER) { + return null; } - // Try to parse as a plain mode + // Try to parse as a plain mode (local/worktree) const modeResult = RuntimeModeSchema.safeParse(lowerTrimmed); if (modeResult.success) { - return { mode: modeResult.data, host: "" }; + const mode = modeResult.data; + if (mode === "local") return { mode: "local" }; + if (mode === "worktree") return { mode: "worktree" }; + // ssh/docker without args handled above } - // Default to local for unrecognized strings - return { mode: RUNTIME_MODE.LOCAL, host: "" }; + // Unrecognized - return null + return null; } /** - * Build runtime string for storage/IPC from mode and host - * Returns: "ssh " for SSH, "local" for local, undefined for worktree (default) + * Build runtime string for storage/IPC from parsed runtime. + * Returns: "ssh " for SSH, "docker " for Docker, "local" for local, undefined for worktree (default) */ -export function buildRuntimeString(mode: RuntimeMode, host: string): string | undefined { - if (mode === RUNTIME_MODE.SSH) { - const trimmedHost = host.trim(); - // Persist SSH mode even without a host so UI remains in SSH state - return trimmedHost ? `${SSH_RUNTIME_PREFIX}${trimmedHost}` : "ssh"; +export function buildRuntimeString(parsed: ParsedRuntime): string | undefined { + switch (parsed.mode) { + case RUNTIME_MODE.SSH: + return `${SSH_RUNTIME_PREFIX}${parsed.host}`; + case RUNTIME_MODE.DOCKER: + return `${DOCKER_RUNTIME_PREFIX}${parsed.image}`; + case RUNTIME_MODE.LOCAL: + return "local"; + case RUNTIME_MODE.WORKTREE: + // Worktree is default, no string needed + return undefined; } - if (mode === RUNTIME_MODE.LOCAL) { - return "local"; - } - // Worktree is default, no string needed - return undefined; } /** @@ -96,6 +125,15 @@ export function isSSHRuntime( return config?.type === "ssh"; } +/** + * Type guard to check if a runtime config is Docker + */ +export function isDockerRuntime( + config: RuntimeConfig | undefined +): config is Extract { + return config?.type === "docker"; +} + /** * Type guard to check if a runtime config uses worktree semantics. * This includes both explicit "worktree" type AND legacy "local" with srcBaseDir. diff --git a/src/common/utils/runtimeCompatibility.ts b/src/common/utils/runtimeCompatibility.ts index f40214cffc..af63491b7f 100644 --- a/src/common/utils/runtimeCompatibility.ts +++ b/src/common/utils/runtimeCompatibility.ts @@ -19,13 +19,14 @@ import type { RuntimeConfig } from "@/common/types/runtime"; * - "local" with srcBaseDir: Legacy worktree config (for backward compat) * - "worktree": Explicit worktree runtime * - "ssh": Remote SSH runtime + * - "docker": Docker container runtime */ export function isIncompatibleRuntimeConfig(config: RuntimeConfig | undefined): boolean { if (!config) { return false; } // All known types are compatible - const knownTypes = ["local", "worktree", "ssh"]; + const knownTypes = ["local", "worktree", "ssh", "docker"]; if (!knownTypes.includes(config.type)) { // Unknown type from a future version return true; diff --git a/src/node/runtime/DockerRuntime.test.ts b/src/node/runtime/DockerRuntime.test.ts new file mode 100644 index 0000000000..f4c8151350 --- /dev/null +++ b/src/node/runtime/DockerRuntime.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; +import { DockerRuntime, getContainerName } from "./DockerRuntime"; + +/** + * DockerRuntime constructor tests (run with bun test) + * + * Note: Docker workspace operation tests require Docker + * and should be in tests/runtime/runtime.test.ts + */ +describe("DockerRuntime constructor", () => { + it("should accept image name", () => { + expect(() => { + new DockerRuntime({ image: "ubuntu:22.04" }); + }).not.toThrow(); + }); + + it("should accept registry image", () => { + expect(() => { + new DockerRuntime({ image: "ghcr.io/myorg/dev-image:latest" }); + }).not.toThrow(); + }); + + it("should return image via getImage()", () => { + const runtime = new DockerRuntime({ image: "node:20" }); + expect(runtime.getImage()).toBe("node:20"); + }); + + it("should return /src for workspace path", () => { + const runtime = new DockerRuntime({ image: "ubuntu:22.04" }); + expect(runtime.getWorkspacePath("/any/project", "any-branch")).toBe("/src"); + }); + + it("should accept containerName for existing workspaces", () => { + // When recreating runtime for existing workspace, containerName is passed in config + const runtime = new DockerRuntime({ + image: "ubuntu:22.04", + containerName: "mux-myproject-my-feature", + }); + expect(runtime.getImage()).toBe("ubuntu:22.04"); + // Runtime should be ready for exec operations without calling createWorkspace + }); +}); + +describe("getContainerName", () => { + it("should generate container name from project and workspace", () => { + expect(getContainerName("/home/user/myproject", "feature-branch")).toBe( + "mux-myproject-feature-branch" + ); + }); + + it("should sanitize special characters", () => { + expect(getContainerName("/home/user/my@project", "feature/branch")).toBe( + "mux-my-project-feature-branch" + ); + }); + + it("should handle long names", () => { + const longName = "a".repeat(100); + const result = getContainerName("/project", longName); + // Docker has 64 char limit, function uses 63 to be safe + expect(result.length).toBeLessThanOrEqual(63); + }); +}); diff --git a/src/node/runtime/DockerRuntime.ts b/src/node/runtime/DockerRuntime.ts new file mode 100644 index 0000000000..b0e9fbea81 --- /dev/null +++ b/src/node/runtime/DockerRuntime.ts @@ -0,0 +1,815 @@ +/** + * Docker runtime implementation that executes commands inside Docker containers. + * + * Features: + * - Each workspace runs in its own container + * - Container name derived from project+workspace name + * - Uses docker exec for command execution + * - Hardcoded paths: srcBaseDir=/src, bgOutputDir=/tmp/mux-bashes + * - Managed lifecycle: container created/destroyed with workspace + */ + +import { spawn, exec } from "child_process"; +import { Readable, Writable } from "stream"; +import * as path from "path"; +import type { + Runtime, + ExecOptions, + ExecStream, + FileStat, + WorkspaceCreationParams, + WorkspaceCreationResult, + WorkspaceInitParams, + WorkspaceInitResult, + WorkspaceForkParams, + WorkspaceForkResult, + InitLogger, +} from "./Runtime"; +import { RuntimeError } from "./Runtime"; +import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; +import { log } from "@/node/services/log"; +import { checkInitHookExists, getMuxEnv, runInitHookOnRuntime } from "./initHook"; +import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; +import { getProjectName } from "@/node/utils/runtime/helpers"; +import { getErrorMessage } from "@/common/utils/errors"; +import { DisposableProcess } from "@/node/utils/disposableExec"; +import { streamToString, shescape } from "./streamUtils"; + +/** Hardcoded source directory inside container */ +const CONTAINER_SRC_DIR = "/src"; + +/** Hardcoded background output directory inside container */ +const _CONTAINER_BG_OUTPUT_DIR = "/tmp/mux-bashes"; + +/** + * Result of running a Docker command + */ +interface DockerCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** + * Run a Docker CLI command and return result. + * Unlike execAsync, this always resolves (never rejects) and returns exit code. + */ +function runDockerCommand(command: string, timeoutMs = 30000): Promise { + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let timedOut = false; + + const child = exec(command); + + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + resolve({ exitCode: -1, stdout, stderr: "Command timed out" }); + }, timeoutMs); + + child.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + clearTimeout(timer); + if (timedOut) return; + resolve({ exitCode: code ?? -1, stdout, stderr }); + }); + + child.on("error", (err) => { + clearTimeout(timer); + if (timedOut) return; + resolve({ exitCode: -1, stdout, stderr: err.message }); + }); + }); +} + +export interface DockerRuntimeConfig { + /** Docker image to use (e.g., ubuntu:22.04) */ + image: string; + /** + * Container name for existing workspaces. + * When creating a new workspace, this is computed during createWorkspace(). + * When recreating runtime for an existing workspace, this should be passed + * to allow exec operations without calling createWorkspace again. + */ + containerName?: string; +} + +/** + * Sanitize a string for use in Docker container names. + * Docker names must match: [a-zA-Z0-9][a-zA-Z0-9_.-]* + */ +function sanitizeContainerName(name: string): string { + return name + .replace(/[^a-zA-Z0-9_.-]/g, "-") + .replace(/^[^a-zA-Z0-9]+/, "") + .replace(/-+/g, "-") + .slice(0, 63); // Docker has a 64 char limit +} + +/** + * Generate container name from project path and workspace name. + * Format: mux-{projectName}-{workspaceName} + */ +export function getContainerName(projectPath: string, workspaceName: string): string { + const projectName = getProjectName(projectPath); + return sanitizeContainerName(`mux-${projectName}-${workspaceName}`); +} + +/** + * Docker runtime implementation that executes commands inside Docker containers. + */ +export class DockerRuntime implements Runtime { + private readonly config: DockerRuntimeConfig; + /** Container name - set during construction (for existing) or createWorkspace (for new) */ + private containerName?: string; + + constructor(config: DockerRuntimeConfig) { + this.config = config; + // If container name is provided (existing workspace), store it + if (config.containerName) { + this.containerName = config.containerName; + } + } + + /** + * Get Docker image name + */ + public getImage(): string { + return this.config.image; + } + + /** + * Execute command inside Docker container with streaming I/O + */ + exec(command: string, options: ExecOptions): Promise { + const startTime = performance.now(); + + // Short-circuit if already aborted + if (options.abortSignal?.aborted) { + throw new RuntimeError("Operation aborted before execution", "exec"); + } + + // Verify container name is available (set in constructor for existing workspaces, + // or set in createWorkspace for new workspaces) + if (!this.containerName) { + throw new RuntimeError( + "Docker runtime not initialized with container name. " + + "For existing workspaces, pass containerName in config. " + + "For new workspaces, call createWorkspace first.", + "exec" + ); + } + const containerName = this.containerName; + + // Build command parts + const parts: string[] = []; + + // Add cd command if cwd is specified + parts.push(`cd ${shescape.quote(options.cwd)}`); + + // Add environment variable exports (user env first, then non-interactive overrides) + const envVars = { ...options.env, ...NON_INTERACTIVE_ENV_VARS }; + for (const [key, value] of Object.entries(envVars)) { + parts.push(`export ${key}=${shescape.quote(value)}`); + } + + // Add the actual command + parts.push(command); + + // Join all parts with && to ensure each step succeeds before continuing + let fullCommand = parts.join(" && "); + + // Wrap in bash for consistent shell behavior + fullCommand = `bash -c ${shescape.quote(fullCommand)}`; + + // Optionally wrap with timeout + if (options.timeout !== undefined) { + const remoteTimeout = Math.ceil(options.timeout) + 1; + fullCommand = `timeout -s KILL ${remoteTimeout} ${fullCommand}`; + } + + // Build docker exec args + const dockerArgs: string[] = ["exec", "-i"]; + + // Add environment variables directly to docker exec + for (const [key, value] of Object.entries(envVars)) { + dockerArgs.push("-e", `${key}=${value}`); + } + + dockerArgs.push(containerName, "bash", "-c", fullCommand); + + log.debug(`Docker command: docker ${dockerArgs.join(" ")}`); + + // Spawn docker exec command + const dockerProcess = spawn("docker", dockerArgs, { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + + // Wrap in DisposableProcess for automatic cleanup + const disposable = new DisposableProcess(dockerProcess); + + // Convert Node.js streams to Web Streams + const stdout = Readable.toWeb(dockerProcess.stdout) as unknown as ReadableStream; + const stderr = Readable.toWeb(dockerProcess.stderr) as unknown as ReadableStream; + const stdin = Writable.toWeb(dockerProcess.stdin) as unknown as WritableStream; + + // Track if we killed the process due to timeout or abort + let timedOut = false; + let aborted = false; + + // Create promises for exit code and duration + const exitCode = new Promise((resolve, reject) => { + dockerProcess.on("close", (code, signal) => { + if (aborted || options.abortSignal?.aborted) { + resolve(EXIT_CODE_ABORTED); + return; + } + if (timedOut) { + resolve(EXIT_CODE_TIMEOUT); + return; + } + resolve(code ?? (signal ? -1 : 0)); + }); + + dockerProcess.on("error", (err) => { + reject(new RuntimeError(`Failed to execute Docker command: ${err.message}`, "exec", err)); + }); + }); + + const duration = exitCode.then(() => performance.now() - startTime); + + // Handle abort signal + if (options.abortSignal) { + options.abortSignal.addEventListener("abort", () => { + aborted = true; + disposable[Symbol.dispose](); + }); + } + + // Handle timeout + if (options.timeout !== undefined) { + const timeoutHandle = setTimeout(() => { + timedOut = true; + disposable[Symbol.dispose](); + }, options.timeout * 1000); + + void exitCode.finally(() => clearTimeout(timeoutHandle)); + } + + return Promise.resolve({ stdout, stderr, stdin, exitCode, duration }); + } + + /** + * Read file contents from container as a stream + */ + readFile(filePath: string, abortSignal?: AbortSignal): ReadableStream { + return new ReadableStream({ + start: async (controller: ReadableStreamDefaultController) => { + try { + const stream = await this.exec(`cat ${shescape.quote(filePath)}`, { + cwd: CONTAINER_SRC_DIR, + timeout: 300, + abortSignal, + }); + + const reader = stream.stdout.getReader(); + const exitCodePromise = stream.exitCode; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(value); + } + + const code = await exitCodePromise; + if (code !== 0) { + const stderr = await streamToString(stream.stderr); + throw new RuntimeError(`Failed to read file ${filePath}: ${stderr}`, "file_io"); + } + + controller.close(); + } catch (err) { + if (err instanceof RuntimeError) { + controller.error(err); + } else { + controller.error( + new RuntimeError( + `Failed to read file ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + "file_io", + err instanceof Error ? err : undefined + ) + ); + } + } + }, + }); + } + + /** + * Write file contents to container atomically from a stream + */ + writeFile(filePath: string, abortSignal?: AbortSignal): WritableStream { + const tempPath = `${filePath}.tmp.${Date.now()}`; + const writeCommand = `mkdir -p $(dirname ${shescape.quote(filePath)}) && cat > ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} ${shescape.quote(filePath)}`; + + let execPromise: Promise | null = null; + + const getExecStream = () => { + execPromise ??= this.exec(writeCommand, { + cwd: CONTAINER_SRC_DIR, + timeout: 300, + abortSignal, + }); + return execPromise; + }; + + return new WritableStream({ + write: async (chunk: Uint8Array) => { + const stream = await getExecStream(); + const writer = stream.stdin.getWriter(); + try { + await writer.write(chunk); + } finally { + writer.releaseLock(); + } + }, + close: async () => { + const stream = await getExecStream(); + await stream.stdin.close(); + const exitCode = await stream.exitCode; + + if (exitCode !== 0) { + const stderr = await streamToString(stream.stderr); + throw new RuntimeError(`Failed to write file ${filePath}: ${stderr}`, "file_io"); + } + }, + abort: async (reason?: unknown) => { + const stream = await getExecStream(); + await stream.stdin.abort(); + throw new RuntimeError(`Failed to write file ${filePath}: ${String(reason)}`, "file_io"); + }, + }); + } + + /** + * Get file statistics from container + */ + async stat(filePath: string, abortSignal?: AbortSignal): Promise { + const stream = await this.exec(`stat -c '%s %Y %F' ${shescape.quote(filePath)}`, { + cwd: CONTAINER_SRC_DIR, + timeout: 10, + abortSignal, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + streamToString(stream.stdout), + streamToString(stream.stderr), + stream.exitCode, + ]); + + if (exitCode !== 0) { + throw new RuntimeError(`Failed to stat ${filePath}: ${stderr}`, "file_io"); + } + + const parts = stdout.trim().split(" "); + if (parts.length < 3) { + throw new RuntimeError(`Failed to parse stat output for ${filePath}: ${stdout}`, "file_io"); + } + + const size = parseInt(parts[0], 10); + const mtime = parseInt(parts[1], 10); + const fileType = parts.slice(2).join(" "); + + return { + size, + modifiedTime: new Date(mtime * 1000), + isDirectory: fileType === "directory", + }; + } + + resolvePath(filePath: string): Promise { + // Inside container, paths are already absolute + // Just return as-is since we use fixed /src path + return Promise.resolve( + filePath.startsWith("/") ? filePath : path.posix.join(CONTAINER_SRC_DIR, filePath) + ); + } + + normalizePath(targetPath: string, basePath: string): string { + const target = targetPath.trim(); + let base = basePath.trim(); + + if (base.length > 1 && base.endsWith("/")) { + base = base.slice(0, -1); + } + + if (target === ".") { + return base; + } + + if (target.startsWith("/")) { + return target; + } + + return base.endsWith("/") ? base + target : base + "/" + target; + } + + getWorkspacePath(_projectPath: string, _workspaceName: string): string { + // For Docker, workspace path is always /src inside the container + return CONTAINER_SRC_DIR; + } + + async createWorkspace(params: WorkspaceCreationParams): Promise { + try { + const { projectPath, branchName, initLogger } = params; + + // Generate container name + const containerName = getContainerName(projectPath, branchName); + + initLogger.logStep(`Creating Docker container: ${containerName}...`); + + // Check if container already exists + const checkResult = await runDockerCommand(`docker inspect ${containerName}`, 10000); + if (checkResult.exitCode === 0) { + return { + success: false, + error: `Workspace already exists: container ${containerName} is running`, + }; + } + + // Create and start container + // Use sleep infinity to keep container running + const runCmd = `docker run -d --name ${containerName} ${this.config.image} sleep infinity`; + + initLogger.logStep(`Starting container with image ${this.config.image}...`); + + const runResult = await runDockerCommand(runCmd, 60000); + + if (runResult.exitCode !== 0) { + return { + success: false, + error: `Failed to create container: ${runResult.stderr}`, + }; + } + + // Create /src directory in container + initLogger.logStep("Preparing workspace directory..."); + const mkdirResult = await runDockerCommand( + `docker exec ${containerName} mkdir -p ${CONTAINER_SRC_DIR}`, + 10000 + ); + + if (mkdirResult.exitCode !== 0) { + // Clean up container on failure + await runDockerCommand(`docker rm -f ${containerName}`, 10000); + return { + success: false, + error: `Failed to create workspace directory: ${mkdirResult.stderr}`, + }; + } + + // Store container name on runtime instance for exec operations + this.containerName = containerName; + + initLogger.logStep("Container created successfully"); + + return { + success: true, + workspacePath: CONTAINER_SRC_DIR, + }; + } catch (error) { + return { + success: false, + error: getErrorMessage(error), + }; + } + } + + async initWorkspace(params: WorkspaceInitParams): Promise { + const { projectPath, branchName, trunkBranch, workspacePath, initLogger, abortSignal } = params; + + try { + if (!this.containerName) { + return { + success: false, + error: "Container not initialized. Call createWorkspace first.", + }; + } + const containerName = this.containerName; + + // 1. Sync project to container using git bundle + docker cp + initLogger.logStep("Syncing project files to container..."); + try { + await this.syncProjectToContainer( + projectPath, + containerName, + workspacePath, + initLogger, + abortSignal + ); + } catch (error) { + const errorMsg = getErrorMessage(error); + initLogger.logStderr(`Failed to sync project: ${errorMsg}`); + initLogger.logComplete(-1); + return { + success: false, + error: `Failed to sync project: ${errorMsg}`, + }; + } + initLogger.logStep("Files synced successfully"); + + // 2. Checkout branch + initLogger.logStep(`Checking out branch: ${branchName}`); + const checkoutCmd = `git checkout ${shescape.quote(branchName)} 2>/dev/null || git checkout -b ${shescape.quote(branchName)} ${shescape.quote(trunkBranch)}`; + + const checkoutStream = await this.exec(checkoutCmd, { + cwd: workspacePath, + timeout: 300, + abortSignal, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + streamToString(checkoutStream.stdout), + streamToString(checkoutStream.stderr), + checkoutStream.exitCode, + ]); + + if (exitCode !== 0) { + const errorMsg = `Failed to checkout branch: ${stderr || stdout}`; + initLogger.logStderr(errorMsg); + initLogger.logComplete(-1); + return { + success: false, + error: errorMsg, + }; + } + initLogger.logStep("Branch checked out successfully"); + + // 3. Run .mux/init hook if it exists + const hookExists = await checkInitHookExists(projectPath); + if (hookExists) { + const muxEnv = getMuxEnv(projectPath, "docker", branchName); + const hookPath = `${workspacePath}/.mux/init`; + await runInitHookOnRuntime(this, hookPath, workspacePath, muxEnv, initLogger, abortSignal); + } else { + initLogger.logComplete(0); + } + + return { success: true }; + } catch (error) { + const errorMsg = getErrorMessage(error); + initLogger.logStderr(`Initialization failed: ${errorMsg}`); + initLogger.logComplete(-1); + return { + success: false, + error: errorMsg, + }; + } + } + + /** + * Sync project to container using git bundle + */ + private async syncProjectToContainer( + projectPath: string, + containerName: string, + workspacePath: string, + initLogger: InitLogger, + abortSignal?: AbortSignal + ): Promise { + if (abortSignal?.aborted) { + throw new Error("Sync operation aborted before starting"); + } + + const timestamp = Date.now(); + const bundlePath = `/tmp/mux-bundle-${timestamp}.bundle`; + const localBundlePath = `/tmp/mux-bundle-${timestamp}.bundle`; + + try { + // Step 1: Get origin URL from local repository + let originUrl: string | null = null; + const originResult = await runDockerCommand( + `cd ${shescape.quote(projectPath)} && git remote get-url origin 2>/dev/null || true`, + 10000 + ); + if (originResult.exitCode === 0) { + const url = originResult.stdout.trim(); + if (url && !url.includes(".bundle") && !url.includes(".mux-bundle")) { + originUrl = url; + } + } + + // Step 2: Create bundle locally + initLogger.logStep("Creating git bundle..."); + const bundleResult = await runDockerCommand( + `cd ${shescape.quote(projectPath)} && git bundle create ${localBundlePath} --all`, + 300000 + ); + + if (bundleResult.exitCode !== 0) { + throw new Error(`Failed to create bundle: ${bundleResult.stderr}`); + } + + // Step 3: Copy bundle to container + initLogger.logStep("Copying bundle to container..."); + const copyResult = await runDockerCommand( + `docker cp ${localBundlePath} ${containerName}:${bundlePath}`, + 300000 + ); + + if (copyResult.exitCode !== 0) { + throw new Error(`Failed to copy bundle: ${copyResult.stderr}`); + } + + // Step 4: Clone from bundle inside container + initLogger.logStep("Cloning repository in container..."); + const cloneStream = await this.exec(`git clone --quiet ${bundlePath} ${workspacePath}`, { + cwd: "/tmp", + timeout: 300, + abortSignal, + }); + + const [cloneStdout, cloneStderr, cloneExitCode] = await Promise.all([ + streamToString(cloneStream.stdout), + streamToString(cloneStream.stderr), + cloneStream.exitCode, + ]); + + if (cloneExitCode !== 0) { + throw new Error(`Failed to clone repository: ${cloneStderr || cloneStdout}`); + } + + // Step 5: Create local tracking branches + initLogger.logStep("Creating local tracking branches..."); + const trackingStream = await this.exec( + `cd ${workspacePath} && for branch in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin/ | grep -v 'origin/HEAD'); do localname=\${branch#origin/}; git show-ref --verify --quiet refs/heads/$localname || git branch $localname $branch; done`, + { + cwd: workspacePath, + timeout: 30, + abortSignal, + } + ); + await trackingStream.exitCode; + + // Step 6: Update origin remote + if (originUrl) { + initLogger.logStep(`Setting origin remote to ${originUrl}...`); + const setOriginStream = await this.exec( + `git -C ${workspacePath} remote set-url origin ${shescape.quote(originUrl)}`, + { + cwd: workspacePath, + timeout: 10, + abortSignal, + } + ); + await setOriginStream.exitCode; + } else { + initLogger.logStep("Removing bundle origin remote..."); + const removeOriginStream = await this.exec( + `git -C ${workspacePath} remote remove origin 2>/dev/null || true`, + { + cwd: workspacePath, + timeout: 10, + abortSignal, + } + ); + await removeOriginStream.exitCode; + } + + // Step 7: Clean up bundle files + initLogger.logStep("Cleaning up bundle file..."); + const rmStream = await this.exec(`rm ${bundlePath}`, { + cwd: "/tmp", + timeout: 10, + abortSignal, + }); + await rmStream.exitCode; + + // Clean up local bundle + await runDockerCommand(`rm ${localBundlePath}`, 5000); + + initLogger.logStep("Repository cloned successfully"); + } catch (error) { + // Try to clean up on error + try { + const rmStream = await this.exec(`rm -f ${bundlePath}`, { + cwd: "/tmp", + timeout: 10, + }); + await rmStream.exitCode; + } catch { + // Ignore cleanup errors + } + await runDockerCommand(`rm -f ${localBundlePath}`, 5000); + + throw error; + } + } + + // eslint-disable-next-line @typescript-eslint/require-await + async renameWorkspace( + _projectPath: string, + _oldName: string, + _newName: string, + _abortSignal?: AbortSignal + ): Promise< + { success: true; oldPath: string; newPath: string } | { success: false; error: string } + > { + // For Docker, renaming means: + // 1. Create new container with new name + // 2. Copy /src from old container to new + // 3. Remove old container + // This is complex and error-prone, so we don't support it for now + return { + success: false, + error: + "Renaming Docker workspaces is not supported. Create a new workspace and delete the old one.", + }; + } + + async deleteWorkspace( + projectPath: string, + workspaceName: string, + force: boolean, + abortSignal?: AbortSignal + ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }> { + if (abortSignal?.aborted) { + return { success: false, error: "Delete operation aborted" }; + } + + const containerName = getContainerName(projectPath, workspaceName); + const deletedPath = CONTAINER_SRC_DIR; + + try { + // Check if container exists + const inspectResult = await runDockerCommand(`docker inspect ${containerName}`, 10000); + + if (inspectResult.exitCode !== 0) { + // Container doesn't exist - deletion is idempotent + return { success: true, deletedPath }; + } + + if (!force) { + // Check for uncommitted changes + const checkResult = await runDockerCommand( + `docker exec ${containerName} bash -c 'cd ${CONTAINER_SRC_DIR} && git diff --quiet --exit-code && git diff --quiet --cached --exit-code'`, + 10000 + ); + + if (checkResult.exitCode !== 0) { + return { + success: false, + error: "Workspace contains uncommitted changes. Use force flag to delete anyway.", + }; + } + + // Check for unpushed commits + const unpushedResult = await runDockerCommand( + `docker exec ${containerName} bash -c 'cd ${CONTAINER_SRC_DIR} && git log --branches --not --remotes --oneline'`, + 10000 + ); + + if (unpushedResult.exitCode === 0 && unpushedResult.stdout.trim()) { + return { + success: false, + error: `Workspace contains unpushed commits:\n\n${unpushedResult.stdout.trim()}`, + }; + } + } + + // Stop and remove container + const rmResult = await runDockerCommand(`docker rm -f ${containerName}`, 30000); + + if (rmResult.exitCode !== 0) { + return { + success: false, + error: `Failed to remove container: ${rmResult.stderr}`, + }; + } + + return { success: true, deletedPath }; + } catch (error) { + return { success: false, error: `Failed to delete workspace: ${getErrorMessage(error)}` }; + } + } + + forkWorkspace(_params: WorkspaceForkParams): Promise { + return Promise.resolve({ + success: false, + error: "Forking Docker workspaces is not yet implemented. Create a new workspace instead.", + }); + } + + tempDir(): Promise { + return Promise.resolve("/tmp"); + } +} diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 71754f181f..028b20cf7e 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -419,6 +419,11 @@ export interface Runtime { tempDir(): Promise; } +/** + * Result of checking if a runtime type is available for a project. + */ +export type RuntimeAvailability = { available: true } | { available: false; reason: string }; + /** * Error thrown by runtime implementations */ diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index a889b0209b..061b4e15e2 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -17,7 +17,8 @@ import type { import { RuntimeError as RuntimeErrorClass } from "./Runtime"; import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; import { log } from "@/node/services/log"; -import { checkInitHookExists, createLineBufferedLoggers, getMuxEnv } from "./initHook"; +import { checkInitHookExists, getMuxEnv, runInitHookOnRuntime } from "./initHook"; +import { expandTildeForSSH as expandHookPath } from "./tildeExpansion"; import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; import { streamProcessToLogger } from "./streamProcess"; import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion"; @@ -26,20 +27,7 @@ import { getErrorMessage } from "@/common/utils/errors"; import { execAsync, DisposableProcess } from "@/node/utils/disposableExec"; import { getControlPath, sshConnectionPool, type SSHRuntimeConfig } from "./sshConnectionPool"; import { getBashPath } from "@/node/utils/main/bashPath"; - -/** - * Shell-escape helper for remote bash. - * Reused across all SSH runtime operations for performance. - * Note: For background process commands, use shellQuote from backgroundCommands for parity. - */ -const shescape = { - quote(value: unknown): string { - const s = String(value); - if (s.length === 0) return "''"; - // Use POSIX-safe pattern to embed single quotes within single-quoted strings - return "'" + s.replace(/'/g, "'\"'\"'") + "'"; - }, -}; +import { streamToString, shescape } from "./streamUtils"; // Re-export SSHRuntimeConfig from connection pool (defined there to avoid circular deps) export type { SSHRuntimeConfig } from "./sshConnectionPool"; @@ -771,78 +759,6 @@ export class SSHRuntime implements Runtime { } } - /** - * Run .mux/init hook on remote machine if it exists - * @param workspacePath - Path to the workspace directory on remote - * @param muxEnv - MUX_ environment variables (from getMuxEnv) - * @param initLogger - Logger for streaming output - * @param abortSignal - Optional abort signal - */ - private async runInitHook( - workspacePath: string, - muxEnv: Record, - initLogger: InitLogger, - abortSignal?: AbortSignal - ): Promise { - // Construct hook path - expand tilde if present - const remoteHookPath = `${workspacePath}/.mux/init`; - initLogger.logStep(`Running init hook: ${remoteHookPath}`); - - // Expand tilde in hook path for execution - // Tilde won't be expanded when the path is quoted, so we need to expand it ourselves - const hookCommand = expandTildeForSSH(remoteHookPath); - - // Run hook remotely and stream output - // No timeout - user init hooks can be arbitrarily long - const hookStream = await this.exec(hookCommand, { - cwd: workspacePath, // Run in the workspace directory - timeout: 3600, // 1 hour - generous timeout for init hooks - abortSignal, - env: muxEnv, - }); - - // Create line-buffered loggers - const loggers = createLineBufferedLoggers(initLogger); - - // Stream stdout/stderr through line-buffered loggers - const stdoutReader = hookStream.stdout.getReader(); - const stderrReader = hookStream.stderr.getReader(); - const decoder = new TextDecoder(); - - // Read stdout in parallel - const readStdout = async () => { - try { - while (true) { - const { done, value } = await stdoutReader.read(); - if (done) break; - loggers.stdout.append(decoder.decode(value, { stream: true })); - } - loggers.stdout.flush(); - } finally { - stdoutReader.releaseLock(); - } - }; - - // Read stderr in parallel - const readStderr = async () => { - try { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - loggers.stderr.append(decoder.decode(value, { stream: true })); - } - loggers.stderr.flush(); - } finally { - stderrReader.releaseLock(); - } - }; - - // Wait for completion - const [exitCode] = await Promise.all([hookStream.exitCode, readStdout(), readStderr()]); - - initLogger.logComplete(exitCode); - } - getWorkspacePath(projectPath: string, workspaceName: string): string { const projectName = getProjectName(projectPath); return path.posix.join(this.config.srcBaseDir, projectName, workspaceName); @@ -957,11 +873,13 @@ export class SSHRuntime implements Runtime { await this.pullLatestFromOrigin(workspacePath, trunkBranch, initLogger, abortSignal); // 4. Run .mux/init hook if it exists - // Note: runInitHook calls logComplete() internally if hook exists + // Note: runInitHookOnRuntime calls logComplete() internally const hookExists = await checkInitHookExists(projectPath); if (hookExists) { const muxEnv = getMuxEnv(projectPath, "ssh", branchName); - await this.runInitHook(workspacePath, muxEnv, initLogger, abortSignal); + // Expand tilde in hook path (quoted paths don't auto-expand on remote) + const hookPath = expandHookPath(`${workspacePath}/.mux/init`); + await runInitHookOnRuntime(this, hookPath, workspacePath, muxEnv, initLogger, abortSignal); } else { // No hook - signal completion immediately initLogger.logComplete(0); @@ -1330,23 +1248,5 @@ export class SSHRuntime implements Runtime { } } -/** - * Helper to convert a ReadableStream to a string - */ -export async function streamToString(stream: ReadableStream): Promise { - const reader = stream.getReader(); - const decoder = new TextDecoder("utf-8"); - let result = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - result += decoder.decode(value, { stream: true }); - } - result += decoder.decode(); - return result; - } finally { - reader.releaseLock(); - } -} +// Re-export for backward compatibility with existing imports +export { streamToString } from "./streamUtils"; diff --git a/src/node/runtime/initHook.ts b/src/node/runtime/initHook.ts index f6bd9b6905..6de98b3cec 100644 --- a/src/node/runtime/initHook.ts +++ b/src/node/runtime/initHook.ts @@ -1,9 +1,9 @@ import * as fs from "fs"; import * as fsPromises from "fs/promises"; import * as path from "path"; -import type { InitLogger } from "./Runtime"; +import type { ExecOptions, ExecStream, InitLogger } from "./Runtime"; import type { RuntimeConfig } from "@/common/types/runtime"; -import { isWorktreeRuntime, isSSHRuntime } from "@/common/types/runtime"; +import { isWorktreeRuntime, isSSHRuntime, isDockerRuntime } from "@/common/types/runtime"; /** * Check if .mux/init hook exists and is executable @@ -32,12 +32,12 @@ export function getInitHookPath(projectPath: string): string { * Get MUX_ environment variables for bash execution. * Used by both init hook and regular bash tool calls. * @param projectPath - Path to project root (local path for LocalRuntime, remote path for SSHRuntime) - * @param runtime - Runtime type: "local", "worktree", or "ssh" + * @param runtime - Runtime type: "local", "worktree", "ssh", or "docker" * @param workspaceName - Name of the workspace (branch name or custom name) */ export function getMuxEnv( projectPath: string, - runtime: "local" | "worktree" | "ssh", + runtime: "local" | "worktree" | "ssh" | "docker", workspaceName: string ): Record { return { @@ -51,9 +51,12 @@ export function getMuxEnv( * Get the effective runtime type from a RuntimeConfig. * Handles legacy "local" with srcBaseDir → "worktree" mapping. */ -export function getRuntimeType(config: RuntimeConfig | undefined): "local" | "worktree" | "ssh" { +export function getRuntimeType( + config: RuntimeConfig | undefined +): "local" | "worktree" | "ssh" | "docker" { if (!config) return "worktree"; // Default to worktree for undefined config if (isSSHRuntime(config)) return "ssh"; + if (isDockerRuntime(config)) return "docker"; if (isWorktreeRuntime(config)) return "worktree"; return "local"; } @@ -112,3 +115,79 @@ export function createLineBufferedLoggers(initLogger: InitLogger) { }, }; } + +/** + * Minimal runtime interface needed for running init hooks. + * This allows the helper to work with any runtime implementation. + */ +export interface InitHookRuntime { + exec(command: string, options: ExecOptions): Promise; +} + +/** + * Run .mux/init hook on a runtime and stream output to logger. + * Shared implementation used by SSH and Docker runtimes. + * + * @param runtime - Runtime instance with exec capability + * @param hookPath - Full path to the init hook (e.g., "/src/.mux/init" or "~/mux/project/workspace/.mux/init") + * @param workspacePath - Working directory for the hook + * @param muxEnv - MUX_ environment variables from getMuxEnv() + * @param initLogger - Logger for streaming output + * @param abortSignal - Optional abort signal + */ +export async function runInitHookOnRuntime( + runtime: InitHookRuntime, + hookPath: string, + workspacePath: string, + muxEnv: Record, + initLogger: InitLogger, + abortSignal?: AbortSignal +): Promise { + initLogger.logStep(`Running init hook: ${hookPath}`); + + const hookStream = await runtime.exec(hookPath, { + cwd: workspacePath, + timeout: 3600, // 1 hour - generous timeout for init hooks + abortSignal, + env: muxEnv, + }); + + // Create line-buffered loggers for proper output handling + const loggers = createLineBufferedLoggers(initLogger); + const stdoutReader = hookStream.stdout.getReader(); + const stderrReader = hookStream.stderr.getReader(); + const decoder = new TextDecoder(); + + // Read stdout in parallel + const readStdout = async () => { + try { + while (true) { + const { done, value } = await stdoutReader.read(); + if (done) break; + loggers.stdout.append(decoder.decode(value, { stream: true })); + } + loggers.stdout.flush(); + } finally { + stdoutReader.releaseLock(); + } + }; + + // Read stderr in parallel + const readStderr = async () => { + try { + while (true) { + const { done, value } = await stderrReader.read(); + if (done) break; + loggers.stderr.append(decoder.decode(value, { stream: true })); + } + loggers.stderr.flush(); + } finally { + stderrReader.releaseLock(); + } + }; + + // Wait for all streams and exit code + const [exitCode] = await Promise.all([hookStream.exitCode, readStdout(), readStderr()]); + + initLogger.logComplete(exitCode); +} diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index c3dfec2409..4c09b6909d 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -1,10 +1,14 @@ -import type { Runtime } from "./Runtime"; +import * as fs from "fs/promises"; +import * as path from "path"; +import type { Runtime, RuntimeAvailability } from "./Runtime"; import { LocalRuntime } from "./LocalRuntime"; import { WorktreeRuntime } from "./WorktreeRuntime"; import { SSHRuntime } from "./SSHRuntime"; -import type { RuntimeConfig } from "@/common/types/runtime"; +import { DockerRuntime, getContainerName } from "./DockerRuntime"; +import type { RuntimeConfig, RuntimeMode } from "@/common/types/runtime"; import { hasSrcBaseDir } from "@/common/types/runtime"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; +import { execAsync } from "@/node/utils/disposableExec"; // Re-export for backward compatibility with existing imports export { isIncompatibleRuntimeConfig }; @@ -26,19 +30,26 @@ export class IncompatibleRuntimeError extends Error { export interface CreateRuntimeOptions { /** * Project path - required for project-dir local runtimes (type: "local" without srcBaseDir). + * For Docker runtimes with existing workspaces, used together with workspaceName to derive container name. * For other runtime types, this is optional and used only for getWorkspacePath calculations. */ projectPath?: string; + /** + * Workspace name - required for Docker runtimes when connecting to an existing workspace. + * Used together with projectPath to derive the container name. + */ + workspaceName?: string; } /** * Create a Runtime instance based on the configuration. * - * Handles three runtime types: + * Handles runtime types: * - "local" without srcBaseDir: Project-dir runtime (no isolation) - requires projectPath in options * - "local" with srcBaseDir: Legacy worktree config (backward compat) * - "worktree": Explicit worktree runtime * - "ssh": Remote SSH runtime + * - "docker": Docker container runtime */ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOptions): Runtime { // Check for incompatible configs from newer versions @@ -77,6 +88,18 @@ export function createRuntime(config: RuntimeConfig, options?: CreateRuntimeOpti port: config.port, }); + case "docker": { + // For existing workspaces, derive container name from project+workspace + const containerName = + options?.projectPath && options?.workspaceName + ? getContainerName(options.projectPath, options.workspaceName) + : undefined; + return new DockerRuntime({ + image: config.image, + containerName, + }); + } + default: { const unknownConfig = config as { type?: string }; throw new Error(`Unknown runtime type: ${unknownConfig.type ?? "undefined"}`); @@ -91,3 +114,60 @@ export function runtimeRequiresProjectPath(config: RuntimeConfig): boolean { // Project-dir local runtime (no srcBaseDir) requires projectPath return config.type === "local" && !hasSrcBaseDir(config); } + +/** + * Check if a project has a .git directory (is a git repository). + */ +async function isGitRepository(projectPath: string): Promise { + try { + const gitPath = path.join(projectPath, ".git"); + const stat = await fs.stat(gitPath); + // .git can be a directory (normal repo) or a file (worktree) + return stat.isDirectory() || stat.isFile(); + } catch { + return false; + } +} + +/** + * Check if Docker daemon is running and accessible. + */ +async function isDockerAvailable(): Promise { + try { + using proc = execAsync("docker info"); + await proc.result; + return true; + } catch { + return false; + } +} + +/** + * Check availability of all runtime types for a given project. + * Returns a record of runtime mode to availability status. + * + * This consolidates runtime-specific availability checks: + * - local: Always available + * - worktree: Requires git repository + * - ssh: Requires git repository + * - docker: Requires Docker daemon running + */ +export async function checkRuntimeAvailability( + projectPath: string +): Promise> { + const [isGit, dockerAvailable] = await Promise.all([ + isGitRepository(projectPath), + isDockerAvailable(), + ]); + + const gitRequiredReason = "Requires git repository"; + + return { + local: { available: true }, + worktree: isGit ? { available: true } : { available: false, reason: gitRequiredReason }, + ssh: isGit ? { available: true } : { available: false, reason: gitRequiredReason }, + docker: dockerAvailable + ? { available: true } + : { available: false, reason: "Docker daemon not running" }, + }; +} diff --git a/src/node/runtime/streamUtils.ts b/src/node/runtime/streamUtils.ts new file mode 100644 index 0000000000..ea7a0eec9d --- /dev/null +++ b/src/node/runtime/streamUtils.ts @@ -0,0 +1,39 @@ +/** + * Stream and shell utilities shared across runtime implementations + */ + +/** + * Shell-escape helper for bash commands. + * Uses single-quote wrapping with proper escaping for embedded quotes. + * Reused across SSH and Docker runtime operations. + */ +export const shescape = { + quote(value: unknown): string { + const s = String(value); + if (s.length === 0) return "''"; + // Use POSIX-safe pattern to embed single quotes within single-quoted strings + return "'" + s.replace(/'/g, "'\"'\"'") + "'"; + }, +}; + +/** + * Convert a ReadableStream to a string. + * Used by SSH and Docker runtimes for capturing command output. + */ +export async function streamToString(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder("utf-8"); + let result = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value, { stream: true }); + } + result += decoder.decode(); + return result; + } finally { + reader.releaseLock(); + } +} diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 732691b029..4b3f9bbe5e 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -291,7 +291,7 @@ export class AgentSession { : (() => { const runtime = createRuntime( metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, - { projectPath: metadata.projectPath } + { projectPath: metadata.projectPath, workspaceName: metadata.name } ); return runtime.getWorkspacePath(metadata.projectPath, metadata.name); })(); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 373346c32c..4c4c41ca10 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -991,7 +991,7 @@ export class AIService extends EventEmitter { // Get workspace path - handle both worktree and in-place modes const runtime = createRuntime( metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, - { projectPath: metadata.projectPath } + { projectPath: metadata.projectPath, workspaceName: metadata.name } ); // In-place workspaces (CLI/benchmarks) have projectPath === name // Use path directly instead of reconstructing via getWorkspacePath diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index 4d051bb59f..8e0a4f9ca6 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -66,11 +66,11 @@ When the user asks you to remember something: /** * Build environment context XML block describing the workspace. * @param workspacePath - Workspace directory path - * @param runtimeType - Runtime type: "local", "worktree", or "ssh" + * @param runtimeType - Runtime type: "local", "worktree", "ssh", or "docker" */ function buildEnvironmentContext( workspacePath: string, - runtimeType: "local" | "worktree" | "ssh" + runtimeType: "local" | "worktree" | "ssh" | "docker" ): string { if (runtimeType === "local") { // Local runtime works directly in project directory - may or may not be git @@ -97,6 +97,19 @@ You are in a clone of a git repository at ${workspacePath} `; } + if (runtimeType === "docker") { + // Docker runtime runs in an isolated container + return ` + +You are in a clone of a git repository at ${workspacePath} inside a Docker container + +- This IS a git repository - run git commands directly (no cd needed) +- Tools run here automatically inside the container +- You are meant to do your work isolated from the user and other agents + +`; + } + // Worktree runtime creates a git worktree locally return ` diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 7a5563d018..f880ae2258 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -66,9 +66,10 @@ export class TerminalService { throw new Error(`Workspace not found: ${params.workspaceId}`); } - // 2. Create runtime + // 2. Create runtime (pass workspace info for Docker container name derivation) const runtime = createRuntime( - workspaceMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir } + workspaceMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, + { projectPath: workspaceMetadata.projectPath, workspaceName: workspaceMetadata.name } ); // 3. Compute workspace path diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index b87d0175c9..a00855ae8d 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -565,7 +565,7 @@ export class WorkspaceService extends EventEmitter { const runtime = createRuntime( metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, - { projectPath } + { projectPath, workspaceName: metadata.name } ); // Delete workspace from runtime @@ -696,7 +696,7 @@ export class WorkspaceService extends EventEmitter { const runtime = createRuntime( oldMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, - { projectPath } + { projectPath, workspaceName: oldName } ); const renameResult = await runtime.renameWorkspace(projectPath, oldName, newName); @@ -813,7 +813,10 @@ export class WorkspaceService extends EventEmitter { type: "local", srcBaseDir: this.config.srcDir, }; - const runtime = createRuntime(sourceRuntimeConfig); + const runtime = createRuntime(sourceRuntimeConfig, { + projectPath: foundProjectPath, + workspaceName: sourceMetadata.name, + }); const newWorkspaceId = this.config.generateStableId(); @@ -1268,7 +1271,10 @@ export class WorkspaceService extends EventEmitter { type: "local" as const, srcBaseDir: this.config.srcDir, }; - const runtime = createRuntime(runtimeConfig, { projectPath: metadata.projectPath }); + const runtime = createRuntime(runtimeConfig, { + projectPath: metadata.projectPath, + workspaceName: metadata.name, + }); const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name); // Create bash tool From e9efc5363b84273dc19e16cc8d804d438e915a4c Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 11 Dec 2025 12:00:11 -0600 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20unify=20SSH/Dock?= =?UTF-8?q?er=20runtimes=20and=20runtime=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract RemoteRuntime base class (shared exec + file ops)\n- Switch runtime selection UI/state to discriminated union (SSH host / Docker image)\n- Add Docker option to CreationControls and persist last-used host/image\n- Fix remote stdin.close idempotence and SSH tilde expansion edge cases\n\n_Generated with mux_ --- docs/system-prompt.mdx | 94 ++-- .../components/ChatInput/CreationControls.tsx | 91 ++- src/browser/components/ChatInput/index.tsx | 6 +- .../ChatInput/useCreationWorkspace.test.tsx | 73 ++- .../ChatInput/useCreationWorkspace.ts | 28 +- .../hooks/useDraftWorkspaceSettings.test.ts | 116 ++++ .../hooks/useDraftWorkspaceSettings.ts | 181 ++++-- src/common/types/runtime.ts | 3 +- src/common/utils/assertNever.ts | 18 + src/node/runtime/DockerRuntime.ts | 281 +--------- src/node/runtime/RemoteRuntime.ts | 457 +++++++++++++++ src/node/runtime/Runtime.ts | 2 +- src/node/runtime/SSHRuntime.ts | 523 ++++-------------- src/node/services/systemMessage.ts | 97 ++-- tests/ipc/createWorkspace.test.ts | 2 +- tests/runtime/runtime.test.ts | 23 + 16 files changed, 1098 insertions(+), 897 deletions(-) create mode 100644 src/browser/hooks/useDraftWorkspaceSettings.test.ts create mode 100644 src/common/utils/assertNever.ts create mode 100644 src/node/runtime/RemoteRuntime.ts diff --git a/docs/system-prompt.mdx b/docs/system-prompt.mdx index 5192358824..d4c39c3e53 100644 --- a/docs/system-prompt.mdx +++ b/docs/system-prompt.mdx @@ -47,59 +47,59 @@ When the user asks you to remember something: /** * Build environment context XML block describing the workspace. * @param workspacePath - Workspace directory path - * @param runtimeType - Runtime type: "local", "worktree", "ssh", or "docker" + * @param runtimeType - Runtime type (local, worktree, ssh, docker) */ -function buildEnvironmentContext( - workspacePath: string, - runtimeType: "local" | "worktree" | "ssh" | "docker" -): string { - if (runtimeType === "local") { - // Local runtime works directly in project directory - may or may not be git - return ` - -You are working in a directory at ${workspacePath} - -- Tools run here automatically -- You are meant to do your work isolated from the user and other agents - -`; - } - - if (runtimeType === "ssh") { - // SSH runtime clones the repository on a remote host - return ` - -You are in a clone of a git repository at ${workspacePath} - -- This IS a git repository - run git commands directly (no cd needed) -- Tools run here automatically -- You are meant to do your work isolated from the user and other agents - -`; - } - - if (runtimeType === "docker") { - // Docker runtime runs in an isolated container - return ` - -You are in a clone of a git repository at ${workspacePath} inside a Docker container - -- This IS a git repository - run git commands directly (no cd needed) -- Tools run here automatically inside the container -- You are meant to do your work isolated from the user and other agents - -`; +function buildEnvironmentContext(workspacePath: string, runtimeType: RuntimeMode): string { + // Common lines shared across git-based runtimes + const gitCommonLines = [ + "- This IS a git repository - run git commands directly (no cd needed)", + "- Tools run here automatically", + "- You are meant to do your work isolated from the user and other agents", + ]; + + let description: string; + let lines: string[]; + + switch (runtimeType) { + case RUNTIME_MODE.LOCAL: + // Local runtime works directly in project directory - may or may not be git + description = `You are working in a directory at ${workspacePath}`; + lines = [ + "- Tools run here automatically", + "- You are meant to do your work isolated from the user and other agents", + ]; + break; + + case RUNTIME_MODE.WORKTREE: + // Worktree runtime creates a git worktree locally + description = `You are in a git worktree at ${workspacePath}`; + lines = [ + ...gitCommonLines, + "- Do not modify or visit other worktrees (especially the main project) without explicit user intent", + ]; + break; + + case RUNTIME_MODE.SSH: + // SSH runtime clones the repository on a remote host + description = `You are in a clone of a git repository at ${workspacePath}`; + lines = gitCommonLines; + break; + + case RUNTIME_MODE.DOCKER: + // Docker runtime runs in an isolated container + description = `You are in a clone of a git repository at ${workspacePath} inside a Docker container`; + lines = gitCommonLines; + break; + + default: + assertNever(runtimeType, `Unknown runtime type: ${String(runtimeType)}`); } - // Worktree runtime creates a git worktree locally return ` -You are in a git worktree at ${workspacePath} +${description} -- This IS a git repository - run git commands directly (no cd needed) -- Tools run here automatically -- Do not modify or visit other worktrees (especially the main project) without explicit user intent -- You are meant to do your work isolated from the user and other agents +${lines.join("\n")} `; } diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index fc2f482d1e..4ce3f3e97f 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -1,10 +1,10 @@ import React, { useCallback, useEffect } from "react"; -import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; +import { RUNTIME_MODE, type RuntimeMode, type ParsedRuntime } from "@/common/types/runtime"; import { Select } from "../Select"; import { Loader2, Wand2 } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; -import { SSHIcon, WorktreeIcon, LocalIcon } from "../icons/RuntimeIcons"; +import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon } from "../icons/RuntimeIcons"; import { DocsLink } from "../DocsLink"; import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; @@ -14,12 +14,12 @@ interface CreationControlsProps { branchesLoaded: boolean; trunkBranch: string; onTrunkBranchChange: (branch: string) => void; - runtimeMode: RuntimeMode; + /** Currently selected runtime (discriminated union: SSH has host, Docker has image) */ + selectedRuntime: ParsedRuntime; defaultRuntimeMode: RuntimeMode; - sshHost: string; - onRuntimeModeChange: (mode: RuntimeMode) => void; + /** Set the currently selected runtime (discriminated union) */ + onSelectedRuntimeChange: (runtime: ParsedRuntime) => void; onSetDefaultRuntime: (mode: RuntimeMode) => void; - onSshHostChange: (host: string) => void; disabled: boolean; /** Project name to display as header */ projectName: string; @@ -80,6 +80,17 @@ const RUNTIME_OPTIONS: Array<{ idleClass: "bg-transparent text-muted border-transparent hover:border-[var(--color-runtime-ssh)]/40", }, + { + value: RUNTIME_MODE.DOCKER, + label: "Docker", + description: "Run in Docker container", + docsPath: "/runtime/docker", + Icon: DockerIcon, + activeClass: + "bg-[var(--color-runtime-docker)]/20 text-[var(--color-runtime-docker-text)] border-[var(--color-runtime-docker)]/60", + idleClass: + "bg-transparent text-muted border-transparent hover:border-[var(--color-runtime-docker)]/40", + }, ]; function RuntimeButtonGroup(props: RuntimeButtonGroupProps) { @@ -153,18 +164,20 @@ export function CreationControls(props: CreationControlsProps) { // Don't check until branches have loaded to avoid prematurely switching runtime const isNonGitRepo = props.branchesLoaded && props.branches.length === 0; + // Extract mode from discriminated union for convenience + const runtimeMode = props.selectedRuntime.mode; + // Local runtime doesn't need a trunk branch selector (uses project dir as-is) - const showTrunkBranchSelector = - props.branches.length > 0 && props.runtimeMode !== RUNTIME_MODE.LOCAL; + const showTrunkBranchSelector = props.branches.length > 0 && runtimeMode !== RUNTIME_MODE.LOCAL; - const { runtimeMode, onRuntimeModeChange } = props; + const { selectedRuntime, onSelectedRuntimeChange } = props; // Force local runtime for non-git directories (only after branches loaded) useEffect(() => { - if (isNonGitRepo && runtimeMode !== RUNTIME_MODE.LOCAL) { - onRuntimeModeChange(RUNTIME_MODE.LOCAL); + if (isNonGitRepo && selectedRuntime.mode !== RUNTIME_MODE.LOCAL) { + onSelectedRuntimeChange({ mode: "local" }); } - }, [isNonGitRepo, runtimeMode, onRuntimeModeChange]); + }, [isNonGitRepo, selectedRuntime.mode, onSelectedRuntimeChange]); const handleNameChange = useCallback( (e: React.ChangeEvent) => { @@ -263,12 +276,39 @@ export function CreationControls(props: CreationControlsProps) {
{ + // Convert mode to ParsedRuntime with appropriate defaults + switch (mode) { + case RUNTIME_MODE.SSH: + onSelectedRuntimeChange({ + mode: "ssh", + host: selectedRuntime.mode === "ssh" ? selectedRuntime.host : "", + }); + break; + case RUNTIME_MODE.DOCKER: + onSelectedRuntimeChange({ + mode: "docker", + image: selectedRuntime.mode === "docker" ? selectedRuntime.image : "", + }); + break; + case RUNTIME_MODE.LOCAL: + onSelectedRuntimeChange({ mode: "local" }); + break; + case RUNTIME_MODE.WORKTREE: + default: + onSelectedRuntimeChange({ mode: "worktree" }); + break; + } + }} defaultMode={props.defaultRuntimeMode} onSetDefault={props.onSetDefaultRuntime} disabled={props.disabled} - disabledModes={isNonGitRepo ? [RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH] : undefined} + disabledModes={ + isNonGitRepo + ? [RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH, RUNTIME_MODE.DOCKER] + : undefined + } /> {/* Branch selector - shown for worktree/SSH */} @@ -293,19 +333,34 @@ export function CreationControls(props: CreationControlsProps) { )} {/* SSH Host Input */} - {props.runtimeMode === RUNTIME_MODE.SSH && ( + {selectedRuntime.mode === "ssh" && (
props.onSshHostChange(e.target.value)} + value={selectedRuntime.host} + onChange={(e) => onSelectedRuntimeChange({ mode: "ssh", host: e.target.value })} placeholder="user@host" disabled={props.disabled} className="bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50" />
)} + + {/* Docker Image Input */} + {selectedRuntime.mode === "docker" && ( +
+ + onSelectedRuntimeChange({ mode: "docker", image: e.target.value })} + placeholder="ubuntu:22.04" + disabled={props.disabled} + className="bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50" + /> +
+ )}
diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 67ae836050..764426fa50 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1446,12 +1446,10 @@ const ChatInputInner: React.FC = (props) => { branchesLoaded={creationState.branchesLoaded} trunkBranch={creationState.trunkBranch} onTrunkBranchChange={creationState.setTrunkBranch} - runtimeMode={creationState.runtimeMode} + selectedRuntime={creationState.selectedRuntime} defaultRuntimeMode={creationState.defaultRuntimeMode} - sshHost={creationState.sshHost} - onRuntimeModeChange={creationState.setRuntimeMode} + onSelectedRuntimeChange={creationState.setSelectedRuntime} onSetDefaultRuntime={creationState.setDefaultRuntimeMode} - onSshHostChange={creationState.setSshHost} disabled={isSendInFlight} projectName={props.projectName} nameState={creationState.nameState} diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index 223b444fd9..995c3e513a 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -10,7 +10,7 @@ import { } from "@/common/constants/storage"; import type { SendMessageError as _SendMessageError } from "@/common/types/errors"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import type { RuntimeMode } from "@/common/types/runtime"; +import type { RuntimeMode, ParsedRuntime } from "@/common/types/runtime"; import type { FrontendWorkspaceMetadata, WorkspaceActivitySnapshot, @@ -409,8 +409,7 @@ describe("useCreationWorkspace", () => { persistedPreferences[getModelKey(getProjectScopeId(TEST_PROJECT_PATH))] = "gpt-4"; draftSettingsState = createDraftSettingsHarness({ - runtimeMode: "ssh", - sshHost: "example.com", + selectedRuntime: { mode: "ssh", host: "example.com" }, runtimeString: "ssh example.com", trunkBranch: "dev", }); @@ -519,26 +518,20 @@ type DraftSettingsHarness = ReturnType; function createDraftSettingsHarness( initial?: Partial<{ - runtimeMode: RuntimeMode; - sshHost: string; - dockerImage: string; + selectedRuntime: ParsedRuntime; trunkBranch: string; runtimeString?: string | undefined; defaultRuntimeMode?: RuntimeMode; }> ) { const state = { - runtimeMode: initial?.runtimeMode ?? "local", + selectedRuntime: initial?.selectedRuntime ?? { mode: "local" as const }, defaultRuntimeMode: initial?.defaultRuntimeMode ?? "worktree", - sshHost: initial?.sshHost ?? "", - dockerImage: initial?.dockerImage ?? "", trunkBranch: initial?.trunkBranch ?? "main", runtimeString: initial?.runtimeString, } satisfies { - runtimeMode: RuntimeMode; + selectedRuntime: ParsedRuntime; defaultRuntimeMode: RuntimeMode; - sshHost: string; - dockerImage: string; trunkBranch: string; runtimeString: string | undefined; }; @@ -549,41 +542,47 @@ function createDraftSettingsHarness( const getRuntimeString = mock(() => state.runtimeString); - const setRuntimeMode = mock((mode: RuntimeMode) => { - state.runtimeMode = mode; - const trimmedHost = state.sshHost.trim(); - state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined; + const setSelectedRuntime = mock((runtime: ParsedRuntime) => { + state.selectedRuntime = runtime; + if (runtime.mode === "ssh") { + state.runtimeString = runtime.host ? `ssh ${runtime.host}` : "ssh"; + } else if (runtime.mode === "docker") { + state.runtimeString = runtime.image ? `docker ${runtime.image}` : "docker"; + } else { + state.runtimeString = undefined; + } }); const setDefaultRuntimeMode = mock((mode: RuntimeMode) => { state.defaultRuntimeMode = mode; - state.runtimeMode = mode; - const trimmedHost = state.sshHost.trim(); - state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined; - }); - - const setSshHost = mock((host: string) => { - state.sshHost = host; - }); - - const setDockerImage = mock((image: string) => { - state.dockerImage = image; + // Update selected runtime to match new default + if (mode === "ssh") { + const host = state.selectedRuntime.mode === "ssh" ? state.selectedRuntime.host : ""; + state.selectedRuntime = { mode: "ssh", host }; + state.runtimeString = host ? `ssh ${host}` : "ssh"; + } else if (mode === "docker") { + const image = state.selectedRuntime.mode === "docker" ? state.selectedRuntime.image : ""; + state.selectedRuntime = { mode: "docker", image }; + state.runtimeString = image ? `docker ${image}` : "docker"; + } else if (mode === "local") { + state.selectedRuntime = { mode: "local" }; + state.runtimeString = undefined; + } else { + state.selectedRuntime = { mode: "worktree" }; + state.runtimeString = undefined; + } }); return { state, - setRuntimeMode, + setSelectedRuntime, setDefaultRuntimeMode, - setSshHost, - setDockerImage, setTrunkBranch, getRuntimeString, snapshot(): { settings: DraftWorkspaceSettings; - setRuntimeMode: typeof setRuntimeMode; + setSelectedRuntime: typeof setSelectedRuntime; setDefaultRuntimeMode: typeof setDefaultRuntimeMode; - setSshHost: typeof setSshHost; - setDockerImage: typeof setDockerImage; setTrunkBranch: typeof setTrunkBranch; getRuntimeString: typeof getRuntimeString; } { @@ -591,18 +590,14 @@ function createDraftSettingsHarness( model: "gpt-4", thinkingLevel: "medium", mode: "exec", - runtimeMode: state.runtimeMode, + selectedRuntime: state.selectedRuntime, defaultRuntimeMode: state.defaultRuntimeMode, - sshHost: state.sshHost, - dockerImage: state.dockerImage ?? "", trunkBranch: state.trunkBranch, }; return { settings, - setRuntimeMode, + setSelectedRuntime, setDefaultRuntimeMode, - setSshHost, - setDockerImage, setTrunkBranch, getRuntimeString, }; diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index f3d9112bbe..54d31c0155 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import type { RuntimeConfig, RuntimeMode } from "@/common/types/runtime"; +import type { RuntimeConfig, RuntimeMode, ParsedRuntime } from "@/common/types/runtime"; import type { UIMode } from "@/common/types/mode"; import type { ThinkingLevel } from "@/common/types/thinking"; import { parseRuntimeString } from "@/browser/utils/chatCommands"; @@ -62,15 +62,13 @@ interface UseCreationWorkspaceReturn { branchesLoaded: boolean; trunkBranch: string; setTrunkBranch: (branch: string) => void; - runtimeMode: RuntimeMode; + /** Currently selected runtime (discriminated union: SSH has host, Docker has image) */ + selectedRuntime: ParsedRuntime; defaultRuntimeMode: RuntimeMode; - sshHost: string; - /** Set the currently selected runtime mode (does not persist) */ - setRuntimeMode: (mode: RuntimeMode) => void; + /** Set the currently selected runtime (discriminated union) */ + setSelectedRuntime: (runtime: ParsedRuntime) => void; /** Set the default runtime mode for this project (persists via checkbox) */ setDefaultRuntimeMode: (mode: RuntimeMode) => void; - /** Set the SSH host (persisted separately from runtime mode) */ - setSshHost: (host: string) => void; toast: Toast | null; setToast: (toast: Toast | null) => void; isSending: boolean; @@ -104,14 +102,8 @@ export function useCreationWorkspace({ const [creatingWithIdentity, setCreatingWithIdentity] = useState(null); // Centralized draft workspace settings with automatic persistence - const { - settings, - setRuntimeMode, - setDefaultRuntimeMode, - setSshHost, - setTrunkBranch, - getRuntimeString, - } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); + const { settings, setSelectedRuntime, setDefaultRuntimeMode, setTrunkBranch, getRuntimeString } = + useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); // Project scope ID for reading send options at send time const projectScopeId = getProjectScopeId(projectPath); @@ -258,12 +250,10 @@ export function useCreationWorkspace({ branchesLoaded, trunkBranch: settings.trunkBranch, setTrunkBranch, - runtimeMode: settings.runtimeMode, + selectedRuntime: settings.selectedRuntime, defaultRuntimeMode: settings.defaultRuntimeMode, - sshHost: settings.sshHost, - setRuntimeMode, + setSelectedRuntime, setDefaultRuntimeMode, - setSshHost, toast, setToast, isSending, diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.ts b/src/browser/hooks/useDraftWorkspaceSettings.test.ts new file mode 100644 index 0000000000..d70df65608 --- /dev/null +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { renderHook, act, cleanup, waitFor } from "@testing-library/react"; +import { GlobalWindow } from "happy-dom"; +import { useState } from "react"; +import { + getLastDockerImageKey, + getLastSshHostKey, + getRuntimeKey, +} from "@/common/constants/storage"; +import { useDraftWorkspaceSettings } from "./useDraftWorkspaceSettings"; + +// A minimal in-memory persisted-state implementation. +// We keep it here (rather than relying on real localStorage) so tests remain deterministic. +const persisted = new Map(); + +void mock.module("@/browser/hooks/usePersistedState", () => { + return { + usePersistedState: (key: string, defaultValue: T) => { + const [value, setValue] = useState(() => { + return persisted.has(key) ? (persisted.get(key) as T) : defaultValue; + }); + + const setAndPersist = (next: T) => { + persisted.set(key, next); + setValue(next); + }; + + return [value, setAndPersist] as const; + }, + }; +}); + +void mock.module("@/browser/hooks/useModelLRU", () => ({ + useModelLRU: () => ({ recentModels: ["test-model"] }), +})); + +void mock.module("@/browser/hooks/useThinkingLevel", () => ({ + useThinkingLevel: () => ["medium", () => undefined] as const, +})); + +void mock.module("@/browser/contexts/ModeContext", () => ({ + useMode: () => ["plan", () => undefined] as const, +})); + +describe("useDraftWorkspaceSettings", () => { + beforeEach(() => { + persisted.clear(); + + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + }); + + afterEach(() => { + cleanup(); + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + }); + + test("does not reset selected runtime to the default while editing SSH host", async () => { + const projectPath = "/tmp/project"; + + const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main")); + + act(() => { + result.current.setSelectedRuntime({ mode: "ssh", host: "dev@host" }); + }); + + await waitFor(() => { + expect(result.current.settings.selectedRuntime).toEqual({ mode: "ssh", host: "dev@host" }); + }); + }); + + test("seeds SSH host from the remembered value when switching modes", async () => { + const projectPath = "/tmp/project"; + persisted.set(getRuntimeKey(projectPath), undefined); + persisted.set(getLastSshHostKey(projectPath), "remembered@host"); + + const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main")); + + act(() => { + // Simulate UI switching into ssh mode with an empty field. + result.current.setSelectedRuntime({ mode: "ssh", host: "" }); + }); + + await waitFor(() => { + expect(result.current.settings.selectedRuntime).toEqual({ + mode: "ssh", + host: "remembered@host", + }); + }); + + expect(persisted.get(getLastSshHostKey(projectPath))).toBe("remembered@host"); + }); + + test("seeds Docker image from the remembered value when switching modes", async () => { + const projectPath = "/tmp/project"; + persisted.set(getRuntimeKey(projectPath), undefined); + persisted.set(getLastDockerImageKey(projectPath), "ubuntu:22.04"); + + const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main")); + + act(() => { + // Simulate UI switching into docker mode with an empty field. + result.current.setSelectedRuntime({ mode: "docker", image: "" }); + }); + + await waitFor(() => { + expect(result.current.settings.selectedRuntime).toEqual({ + mode: "docker", + image: "ubuntu:22.04", + }); + }); + + expect(persisted.get(getLastDockerImageKey(projectPath))).toBe("ubuntu:22.04"); + }); +}); diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 6e1fedaeb3..7a2a5232fa 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { usePersistedState } from "./usePersistedState"; import { useThinkingLevel } from "./useThinkingLevel"; import { useMode } from "@/browser/contexts/ModeContext"; @@ -32,14 +32,13 @@ export interface DraftWorkspaceSettings { mode: UIMode; // Workspace creation settings (project-specific) - /** Currently selected runtime for this workspace creation (may differ from default) */ - runtimeMode: RuntimeMode; + /** + * Currently selected runtime for this workspace creation. + * Uses discriminated union so SSH has host, Docker has image, etc. + */ + selectedRuntime: ParsedRuntime; /** Persisted default runtime for this project (used to initialize selection) */ defaultRuntimeMode: RuntimeMode; - /** SSH host (persisted separately from mode) */ - sshHost: string; - /** Docker image (persisted separately from mode) */ - dockerImage: string; trunkBranch: string; } @@ -58,12 +57,10 @@ export function useDraftWorkspaceSettings( recommendedTrunk: string | null ): { settings: DraftWorkspaceSettings; - /** Set the currently selected runtime mode (does not persist) */ - setRuntimeMode: (mode: RuntimeMode) => void; + /** Set the currently selected runtime (discriminated union) */ + setSelectedRuntime: (runtime: ParsedRuntime) => void; /** Set the default runtime mode for this project (persists via checkbox) */ setDefaultRuntimeMode: (mode: RuntimeMode) => void; - setSshHost: (host: string) => void; - setDockerImage: (image: string) => void; setTrunkBranch: (branch: string) => void; getRuntimeString: () => string | undefined; } { @@ -85,19 +82,10 @@ export function useDraftWorkspaceSettings( { listener: true } ); - // Parse default runtime string into mode (worktree when undefined or invalid) + // Parse default runtime string into structured form (worktree when undefined or invalid) const parsedDefault = parseRuntimeModeAndHost(defaultRuntimeString); const defaultRuntimeMode: RuntimeMode = parsedDefault?.mode ?? RUNTIME_MODE.WORKTREE; - // Currently selected runtime mode for this session (initialized from default) - // This allows user to select a different runtime without changing the default - const [selectedRuntimeMode, setSelectedRuntimeMode] = useState(defaultRuntimeMode); - - // Sync selected mode when default changes (e.g., from checkbox or project switch) - useEffect(() => { - setSelectedRuntimeMode(defaultRuntimeMode); - }, [defaultRuntimeMode]); - // Project-scoped trunk branch preference (persisted per project) const [trunkBranch, setTrunkBranch] = usePersistedState( getTrunkBranchKey(projectPath), @@ -120,58 +108,141 @@ export function useDraftWorkspaceSettings( { listener: true } ); - // Initialize trunk branch from backend recommendation or first branch + // If the default runtime string contains a host/image (e.g. older persisted values like "ssh devbox"), + // prefer it as the initial remembered value. useEffect(() => { - if (!trunkBranch && branches.length > 0) { - const defaultBranch = recommendedTrunk ?? branches[0]; - setTrunkBranch(defaultBranch); + if ( + parsedDefault?.mode === RUNTIME_MODE.SSH && + !lastSshHost.trim() && + parsedDefault.host.trim() + ) { + setLastSshHost(parsedDefault.host); } - }, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]); + if ( + parsedDefault?.mode === RUNTIME_MODE.DOCKER && + !lastDockerImage.trim() && + parsedDefault.image.trim() + ) { + setLastDockerImage(parsedDefault.image); + } + }, [ + projectPath, + parsedDefault, + lastSshHost, + lastDockerImage, + setLastSshHost, + setLastDockerImage, + ]); + + const defaultSshHost = + parsedDefault?.mode === RUNTIME_MODE.SSH ? parsedDefault.host : lastSshHost; + const defaultDockerImage = + parsedDefault?.mode === RUNTIME_MODE.DOCKER ? parsedDefault.image : lastDockerImage; // Build ParsedRuntime from mode + stored host/image - const buildParsedRuntime = (mode: RuntimeMode): ParsedRuntime | null => { + // Defined as a function so it can be used in both useState init and useEffect + const buildRuntimeForMode = ( + mode: RuntimeMode, + sshHost: string, + dockerImage: string + ): ParsedRuntime => { switch (mode) { case RUNTIME_MODE.LOCAL: return { mode: "local" }; - case RUNTIME_MODE.WORKTREE: - return { mode: "worktree" }; case RUNTIME_MODE.SSH: - return lastSshHost ? { mode: "ssh", host: lastSshHost } : null; + return { mode: "ssh", host: sshHost }; case RUNTIME_MODE.DOCKER: - return lastDockerImage ? { mode: "docker", image: lastDockerImage } : null; + return { mode: "docker", image: dockerImage }; + case RUNTIME_MODE.WORKTREE: default: - return null; + return { mode: "worktree" }; } }; - // Setter for selected runtime mode (changes current selection, does not persist) - const setRuntimeMode = (newMode: RuntimeMode) => { - setSelectedRuntimeMode(newMode); + // Currently selected runtime for this session (initialized from default) + // Uses discriminated union: SSH has host, Docker has image + const [selectedRuntime, setSelectedRuntimeState] = useState(() => + buildRuntimeForMode(defaultRuntimeMode, defaultSshHost, defaultDockerImage) + ); + + const prevProjectPathRef = useRef(null); + const prevDefaultRuntimeModeRef = useRef(null); + + // When switching projects or changing the persisted default mode, reset the selection. + // Importantly: do NOT reset selection when lastSshHost/lastDockerImage changes while typing. + useEffect(() => { + const projectChanged = prevProjectPathRef.current !== projectPath; + const defaultModeChanged = prevDefaultRuntimeModeRef.current !== defaultRuntimeMode; + + if (projectChanged || defaultModeChanged) { + setSelectedRuntimeState( + buildRuntimeForMode(defaultRuntimeMode, defaultSshHost, defaultDockerImage) + ); + } + + prevProjectPathRef.current = projectPath; + prevDefaultRuntimeModeRef.current = defaultRuntimeMode; + }, [projectPath, defaultRuntimeMode, defaultSshHost, defaultDockerImage]); + + // When the user switches into SSH/Docker mode, seed the field with the remembered host/image. + // This avoids clearing the last host/image when the UI switches modes with an empty field. + const prevSelectedRuntimeModeRef = useRef(null); + useEffect(() => { + const prevMode = prevSelectedRuntimeModeRef.current; + if (prevMode !== selectedRuntime.mode) { + if (selectedRuntime.mode === RUNTIME_MODE.SSH) { + if (!selectedRuntime.host.trim() && lastSshHost.trim()) { + setSelectedRuntimeState({ mode: RUNTIME_MODE.SSH, host: lastSshHost }); + } + } + + if (selectedRuntime.mode === RUNTIME_MODE.DOCKER) { + if (!selectedRuntime.image.trim() && lastDockerImage.trim()) { + setSelectedRuntimeState({ mode: RUNTIME_MODE.DOCKER, image: lastDockerImage }); + } + } + } + + prevSelectedRuntimeModeRef.current = selectedRuntime.mode; + }, [selectedRuntime, lastSshHost, lastDockerImage]); + + // Initialize trunk branch from backend recommendation or first branch + useEffect(() => { + if (!trunkBranch && branches.length > 0) { + const defaultBranch = recommendedTrunk ?? branches[0]; + setTrunkBranch(defaultBranch); + } + }, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]); + + // Setter for selected runtime (also persists host/image for future mode switches) + const setSelectedRuntime = (runtime: ParsedRuntime) => { + setSelectedRuntimeState(runtime); + + // Persist host/image so they're remembered when switching modes. + // Avoid wiping the remembered value when the UI switches modes with an empty field. + if (runtime.mode === RUNTIME_MODE.SSH) { + if (runtime.host.trim()) { + setLastSshHost(runtime.host); + } + } else if (runtime.mode === RUNTIME_MODE.DOCKER) { + if (runtime.image.trim()) { + setLastDockerImage(runtime.image); + } + } }; // Setter for default runtime mode (persists via checkbox in tooltip) const setDefaultRuntimeMode = (newMode: RuntimeMode) => { - const parsed = buildParsedRuntime(newMode); - const newRuntimeString = parsed ? buildRuntimeString(parsed) : undefined; + const newRuntime = buildRuntimeForMode(newMode, lastSshHost, lastDockerImage); + const newRuntimeString = buildRuntimeString(newRuntime); setDefaultRuntimeString(newRuntimeString); // Also update selection to match new default - setSelectedRuntimeMode(newMode); - }; - - // Setter for SSH host (persisted separately so it's remembered across mode switches) - const setSshHost = (newHost: string) => { - setLastSshHost(newHost); - }; - - // Setter for Docker image (persisted separately so it's remembered across mode switches) - const setDockerImage = (newImage: string) => { - setLastDockerImage(newImage); + setSelectedRuntimeState(newRuntime); }; - // Helper to get runtime string for IPC calls (uses selected mode, not default) + // Helper to get runtime string for IPC calls const getRuntimeString = (): string | undefined => { - const parsed = buildParsedRuntime(selectedRuntimeMode); - return parsed ? buildRuntimeString(parsed) : undefined; + return buildRuntimeString(selectedRuntime); }; return { @@ -179,16 +250,12 @@ export function useDraftWorkspaceSettings( model, thinkingLevel, mode, - runtimeMode: selectedRuntimeMode, + selectedRuntime, defaultRuntimeMode, - sshHost: lastSshHost, - dockerImage: lastDockerImage, trunkBranch, }, - setRuntimeMode, + setSelectedRuntime, setDefaultRuntimeMode, - setSshHost, - setDockerImage, setTrunkBranch, getRuntimeString, }; diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index f8ab2b51fa..bd1f5fd0d5 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -40,7 +40,8 @@ export type ParsedRuntime = * Format: "ssh " -> { mode: "ssh", host: "" } * "docker " -> { mode: "docker", image: "" } * "worktree" -> { mode: "worktree" } - * "local" or undefined -> { mode: "local" } + * "local" -> { mode: "local" } + * undefined/null -> { mode: "worktree" } (default) * * Note: "ssh" or "docker" without arguments returns null (invalid). * Use this for UI state management (localStorage, form inputs). diff --git a/src/common/utils/assertNever.ts b/src/common/utils/assertNever.ts new file mode 100644 index 0000000000..e35b62a53f --- /dev/null +++ b/src/common/utils/assertNever.ts @@ -0,0 +1,18 @@ +/** + * Exhaustive type checking helper. + * Use in switch/if-else chains to ensure all cases of a union are handled. + * + * @example + * type Mode = "a" | "b" | "c"; + * function handle(mode: Mode) { + * switch (mode) { + * case "a": return 1; + * case "b": return 2; + * case "c": return 3; + * default: assertNever(mode); // Compile error if any case is missing + * } + * } + */ +export function assertNever(value: never, message?: string): never { + throw new Error(message ?? `Unexpected value: ${String(value)}`); +} diff --git a/src/node/runtime/DockerRuntime.ts b/src/node/runtime/DockerRuntime.ts index b0e9fbea81..6146416534 100644 --- a/src/node/runtime/DockerRuntime.ts +++ b/src/node/runtime/DockerRuntime.ts @@ -7,16 +7,14 @@ * - Uses docker exec for command execution * - Hardcoded paths: srcBaseDir=/src, bgOutputDir=/tmp/mux-bashes * - Managed lifecycle: container created/destroyed with workspace + * + * Extends RemoteRuntime for shared exec/file operations. */ import { spawn, exec } from "child_process"; -import { Readable, Writable } from "stream"; import * as path from "path"; import type { - Runtime, ExecOptions, - ExecStream, - FileStat, WorkspaceCreationParams, WorkspaceCreationResult, WorkspaceInitParams, @@ -26,13 +24,10 @@ import type { InitLogger, } from "./Runtime"; import { RuntimeError } from "./Runtime"; -import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; -import { log } from "@/node/services/log"; +import { RemoteRuntime, type SpawnResult } from "./RemoteRuntime"; import { checkInitHookExists, getMuxEnv, runInitHookOnRuntime } from "./initHook"; -import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; import { getProjectName } from "@/node/utils/runtime/helpers"; import { getErrorMessage } from "@/common/utils/errors"; -import { DisposableProcess } from "@/node/utils/disposableExec"; import { streamToString, shescape } from "./streamUtils"; /** Hardcoded source directory inside container */ @@ -125,13 +120,15 @@ export function getContainerName(projectPath: string, workspaceName: string): st /** * Docker runtime implementation that executes commands inside Docker containers. + * Extends RemoteRuntime for shared exec/file operations. */ -export class DockerRuntime implements Runtime { +export class DockerRuntime extends RemoteRuntime { private readonly config: DockerRuntimeConfig; /** Container name - set during construction (for existing) or createWorkspace (for new) */ private containerName?: string; constructor(config: DockerRuntimeConfig) { + super(); this.config = config; // If container name is provided (existing workspace), store it if (config.containerName) { @@ -146,19 +143,24 @@ export class DockerRuntime implements Runtime { return this.config.image; } - /** - * Execute command inside Docker container with streaming I/O - */ - exec(command: string, options: ExecOptions): Promise { - const startTime = performance.now(); + // ===== RemoteRuntime abstract method implementations ===== - // Short-circuit if already aborted - if (options.abortSignal?.aborted) { - throw new RuntimeError("Operation aborted before execution", "exec"); - } + protected readonly commandPrefix = "Docker"; + + protected getBasePath(): string { + return CONTAINER_SRC_DIR; + } + + protected quoteForRemote(filePath: string): string { + return shescape.quote(filePath); + } - // Verify container name is available (set in constructor for existing workspaces, - // or set in createWorkspace for new workspaces) + protected cdCommand(cwd: string): string { + return `cd ${shescape.quote(cwd)}`; + } + + protected spawnRemoteProcess(fullCommand: string, options: ExecOptions): SpawnResult { + // Verify container name is available if (!this.containerName) { throw new RuntimeError( "Docker runtime not initialized with container name. " + @@ -167,234 +169,28 @@ export class DockerRuntime implements Runtime { "exec" ); } - const containerName = this.containerName; - - // Build command parts - const parts: string[] = []; - - // Add cd command if cwd is specified - parts.push(`cd ${shescape.quote(options.cwd)}`); - - // Add environment variable exports (user env first, then non-interactive overrides) - const envVars = { ...options.env, ...NON_INTERACTIVE_ENV_VARS }; - for (const [key, value] of Object.entries(envVars)) { - parts.push(`export ${key}=${shescape.quote(value)}`); - } - - // Add the actual command - parts.push(command); - - // Join all parts with && to ensure each step succeeds before continuing - let fullCommand = parts.join(" && "); - - // Wrap in bash for consistent shell behavior - fullCommand = `bash -c ${shescape.quote(fullCommand)}`; - - // Optionally wrap with timeout - if (options.timeout !== undefined) { - const remoteTimeout = Math.ceil(options.timeout) + 1; - fullCommand = `timeout -s KILL ${remoteTimeout} ${fullCommand}`; - } // Build docker exec args const dockerArgs: string[] = ["exec", "-i"]; // Add environment variables directly to docker exec + const envVars = { ...options.env }; for (const [key, value] of Object.entries(envVars)) { dockerArgs.push("-e", `${key}=${value}`); } - dockerArgs.push(containerName, "bash", "-c", fullCommand); - - log.debug(`Docker command: docker ${dockerArgs.join(" ")}`); + dockerArgs.push(this.containerName, "bash", "-c", fullCommand); // Spawn docker exec command - const dockerProcess = spawn("docker", dockerArgs, { + const process = spawn("docker", dockerArgs, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); - // Wrap in DisposableProcess for automatic cleanup - const disposable = new DisposableProcess(dockerProcess); - - // Convert Node.js streams to Web Streams - const stdout = Readable.toWeb(dockerProcess.stdout) as unknown as ReadableStream; - const stderr = Readable.toWeb(dockerProcess.stderr) as unknown as ReadableStream; - const stdin = Writable.toWeb(dockerProcess.stdin) as unknown as WritableStream; - - // Track if we killed the process due to timeout or abort - let timedOut = false; - let aborted = false; - - // Create promises for exit code and duration - const exitCode = new Promise((resolve, reject) => { - dockerProcess.on("close", (code, signal) => { - if (aborted || options.abortSignal?.aborted) { - resolve(EXIT_CODE_ABORTED); - return; - } - if (timedOut) { - resolve(EXIT_CODE_TIMEOUT); - return; - } - resolve(code ?? (signal ? -1 : 0)); - }); - - dockerProcess.on("error", (err) => { - reject(new RuntimeError(`Failed to execute Docker command: ${err.message}`, "exec", err)); - }); - }); - - const duration = exitCode.then(() => performance.now() - startTime); - - // Handle abort signal - if (options.abortSignal) { - options.abortSignal.addEventListener("abort", () => { - aborted = true; - disposable[Symbol.dispose](); - }); - } - - // Handle timeout - if (options.timeout !== undefined) { - const timeoutHandle = setTimeout(() => { - timedOut = true; - disposable[Symbol.dispose](); - }, options.timeout * 1000); - - void exitCode.finally(() => clearTimeout(timeoutHandle)); - } - - return Promise.resolve({ stdout, stderr, stdin, exitCode, duration }); + return { process }; } - /** - * Read file contents from container as a stream - */ - readFile(filePath: string, abortSignal?: AbortSignal): ReadableStream { - return new ReadableStream({ - start: async (controller: ReadableStreamDefaultController) => { - try { - const stream = await this.exec(`cat ${shescape.quote(filePath)}`, { - cwd: CONTAINER_SRC_DIR, - timeout: 300, - abortSignal, - }); - - const reader = stream.stdout.getReader(); - const exitCodePromise = stream.exitCode; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - controller.enqueue(value); - } - - const code = await exitCodePromise; - if (code !== 0) { - const stderr = await streamToString(stream.stderr); - throw new RuntimeError(`Failed to read file ${filePath}: ${stderr}`, "file_io"); - } - - controller.close(); - } catch (err) { - if (err instanceof RuntimeError) { - controller.error(err); - } else { - controller.error( - new RuntimeError( - `Failed to read file ${filePath}: ${err instanceof Error ? err.message : String(err)}`, - "file_io", - err instanceof Error ? err : undefined - ) - ); - } - } - }, - }); - } - - /** - * Write file contents to container atomically from a stream - */ - writeFile(filePath: string, abortSignal?: AbortSignal): WritableStream { - const tempPath = `${filePath}.tmp.${Date.now()}`; - const writeCommand = `mkdir -p $(dirname ${shescape.quote(filePath)}) && cat > ${shescape.quote(tempPath)} && mv ${shescape.quote(tempPath)} ${shescape.quote(filePath)}`; - - let execPromise: Promise | null = null; - - const getExecStream = () => { - execPromise ??= this.exec(writeCommand, { - cwd: CONTAINER_SRC_DIR, - timeout: 300, - abortSignal, - }); - return execPromise; - }; - - return new WritableStream({ - write: async (chunk: Uint8Array) => { - const stream = await getExecStream(); - const writer = stream.stdin.getWriter(); - try { - await writer.write(chunk); - } finally { - writer.releaseLock(); - } - }, - close: async () => { - const stream = await getExecStream(); - await stream.stdin.close(); - const exitCode = await stream.exitCode; - - if (exitCode !== 0) { - const stderr = await streamToString(stream.stderr); - throw new RuntimeError(`Failed to write file ${filePath}: ${stderr}`, "file_io"); - } - }, - abort: async (reason?: unknown) => { - const stream = await getExecStream(); - await stream.stdin.abort(); - throw new RuntimeError(`Failed to write file ${filePath}: ${String(reason)}`, "file_io"); - }, - }); - } - - /** - * Get file statistics from container - */ - async stat(filePath: string, abortSignal?: AbortSignal): Promise { - const stream = await this.exec(`stat -c '%s %Y %F' ${shescape.quote(filePath)}`, { - cwd: CONTAINER_SRC_DIR, - timeout: 10, - abortSignal, - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - streamToString(stream.stdout), - streamToString(stream.stderr), - stream.exitCode, - ]); - - if (exitCode !== 0) { - throw new RuntimeError(`Failed to stat ${filePath}: ${stderr}`, "file_io"); - } - - const parts = stdout.trim().split(" "); - if (parts.length < 3) { - throw new RuntimeError(`Failed to parse stat output for ${filePath}: ${stdout}`, "file_io"); - } - - const size = parseInt(parts[0], 10); - const mtime = parseInt(parts[1], 10); - const fileType = parts.slice(2).join(" "); - - return { - size, - modifiedTime: new Date(mtime * 1000), - isDirectory: fileType === "directory", - }; - } + // ===== Runtime interface implementations ===== resolvePath(filePath: string): Promise { // Inside container, paths are already absolute @@ -404,25 +200,6 @@ export class DockerRuntime implements Runtime { ); } - normalizePath(targetPath: string, basePath: string): string { - const target = targetPath.trim(); - let base = basePath.trim(); - - if (base.length > 1 && base.endsWith("/")) { - base = base.slice(0, -1); - } - - if (target === ".") { - return base; - } - - if (target.startsWith("/")) { - return target; - } - - return base.endsWith("/") ? base + target : base + "/" + target; - } - getWorkspacePath(_projectPath: string, _workspaceName: string): string { // For Docker, workspace path is always /src inside the container return CONTAINER_SRC_DIR; @@ -808,8 +585,4 @@ export class DockerRuntime implements Runtime { error: "Forking Docker workspaces is not yet implemented. Create a new workspace instead.", }); } - - tempDir(): Promise { - return Promise.resolve("/tmp"); - } } diff --git a/src/node/runtime/RemoteRuntime.ts b/src/node/runtime/RemoteRuntime.ts new file mode 100644 index 0000000000..75cc097113 --- /dev/null +++ b/src/node/runtime/RemoteRuntime.ts @@ -0,0 +1,457 @@ +/** + * Abstract base class for remote execution runtimes (SSH, Docker). + * + * Provides shared implementation for: + * - exec() with streaming I/O, timeout/abort handling + * - readFile(), writeFile(), stat() via exec + * - normalizePath() for POSIX paths + * - tempDir() returning /tmp + * + * Subclasses implement: + * - spawnRemoteProcess() - how to spawn the external process (ssh/docker) + * - getBasePath() - base directory for workspace operations + * - quoteForRemote() - path quoting strategy + * - onExitCode() - optional exit code handling (SSH connection pool) + */ + +import type { ChildProcess } from "child_process"; +import { Readable } from "stream"; +import type { + Runtime, + ExecOptions, + ExecStream, + FileStat, + WorkspaceCreationParams, + WorkspaceCreationResult, + WorkspaceInitParams, + WorkspaceInitResult, + WorkspaceForkParams, + WorkspaceForkResult, +} from "./Runtime"; +import { RuntimeError } from "./Runtime"; +import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; +import { log } from "@/node/services/log"; +import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; +import { DisposableProcess } from "@/node/utils/disposableExec"; +import { streamToString, shescape } from "./streamUtils"; + +/** + * Result from spawning a remote process. + */ +export interface SpawnResult { + /** The spawned child process */ + process: ChildProcess; + /** Optional async work to do before exec (e.g., acquire connection) */ + preExec?: Promise; +} + +/** + * Abstract base class for remote execution runtimes. + */ +export abstract class RemoteRuntime implements Runtime { + /** + * Spawn the external process for command execution. + * SSH spawns `ssh`, Docker spawns `docker exec`. + * + * @param fullCommand The full shell command to execute (already wrapped in bash -c) + * @param options Original exec options + * @returns The spawned process and optional pre-exec work + */ + protected abstract spawnRemoteProcess(fullCommand: string, options: ExecOptions): SpawnResult; + + /** + * Get the base path for file operations (used as cwd for file commands). + * SSH returns srcBaseDir, Docker returns /src. + */ + protected abstract getBasePath(): string; + + /** + * Quote a path for use in remote shell commands. + * SSH uses expandTildeForSSH (handles ~ expansion), Docker uses shescape.quote. + */ + protected abstract quoteForRemote(path: string): string; + + /** + * Build the cd command for the given cwd. + * SSH needs special handling for ~, Docker uses simple quoting. + */ + protected abstract cdCommand(cwd: string): string; + + /** + * Called when exec completes with an exit code. + * Subclasses can use this for connection pool health tracking. + */ + protected onExitCode(_exitCode: number, _options: ExecOptions): void { + // Default: no-op. SSH overrides to report to connection pool. + } + + /** + * Command prefix (e.g., "SSH" or "Docker") for logging. + */ + protected abstract readonly commandPrefix: string; + + /** + * Execute command with streaming I/O. + * Shared implementation that delegates process spawning to subclass. + */ + async exec(command: string, options: ExecOptions): Promise { + const startTime = performance.now(); + + // Short-circuit if already aborted + if (options.abortSignal?.aborted) { + throw new RuntimeError("Operation aborted before execution", "exec"); + } + + // Build command parts + const parts: string[] = []; + + // Add cd command + parts.push(this.cdCommand(options.cwd)); + + // Add environment variable exports (user env first, then non-interactive overrides) + const envVars = { ...options.env, ...NON_INTERACTIVE_ENV_VARS }; + for (const [key, value] of Object.entries(envVars)) { + parts.push(`export ${key}=${shescape.quote(value)}`); + } + + // Add the actual command + parts.push(command); + + // Join all parts with && to ensure each step succeeds before continuing + let fullCommand = parts.join(" && "); + + // Wrap in bash for consistent shell behavior + fullCommand = `bash -c ${shescape.quote(fullCommand)}`; + + // Optionally wrap with timeout + if (options.timeout !== undefined) { + const remoteTimeout = Math.ceil(options.timeout) + 1; + fullCommand = `timeout -s KILL ${remoteTimeout} ${fullCommand}`; + } + + // Spawn the remote process (SSH or Docker) + const { process: childProcess, preExec } = this.spawnRemoteProcess(fullCommand, options); + + // Wait for any pre-exec work (e.g., SSH connection pool) + if (preExec) { + await preExec; + } + + log.debug(`${this.commandPrefix} command: ${fullCommand}`); + + // Wrap in DisposableProcess for automatic cleanup + const disposable = new DisposableProcess(childProcess); + + // Convert Node.js streams to Web Streams + const stdout = Readable.toWeb(childProcess.stdout!) as unknown as ReadableStream; + const stderr = Readable.toWeb(childProcess.stderr!) as unknown as ReadableStream; + + // Writable.toWeb(childProcess.stdin) is surprisingly easy to get into an invalid state + // for short-lived remote commands (notably via SSH) where stdin may already be closed + // by the time callers attempt `await stream.stdin.close()`. + // + // Wrap stdin ourselves so close() is idempotent. + const stdin = new WritableStream({ + write: async (chunk) => { + const nodeStdin = childProcess.stdin; + if (!nodeStdin || nodeStdin.destroyed) { + return; + } + + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + nodeStdin.off("error", onError); + reject(err); + }; + nodeStdin.on("error", onError); + + nodeStdin.write(Buffer.from(chunk), (err) => { + nodeStdin.off("error", onError); + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + }, + close: async () => { + const nodeStdin = childProcess.stdin; + if (!nodeStdin || nodeStdin.destroyed || nodeStdin.writableEnded) { + return; + } + + await new Promise((resolve) => { + nodeStdin.end(() => resolve()); + }); + }, + abort: () => { + childProcess.stdin?.destroy(); + }, + }); + + // Track if we killed the process due to timeout or abort + let timedOut = false; + let aborted = false; + + // Create promises for exit code and duration + const exitCode = new Promise((resolve, reject) => { + childProcess.on("close", (code, signal) => { + if (aborted || options.abortSignal?.aborted) { + resolve(EXIT_CODE_ABORTED); + return; + } + if (timedOut) { + resolve(EXIT_CODE_TIMEOUT); + return; + } + const finalExitCode = code ?? (signal ? -1 : 0); + + // Let subclass handle exit code (e.g., SSH connection pool) + this.onExitCode(finalExitCode, options); + + resolve(finalExitCode); + }); + + childProcess.on("error", (err) => { + reject( + new RuntimeError( + `Failed to execute ${this.commandPrefix} command: ${err.message}`, + "exec", + err + ) + ); + }); + }); + + const duration = exitCode.then(() => performance.now() - startTime); + + // Handle abort signal + if (options.abortSignal) { + options.abortSignal.addEventListener("abort", () => { + aborted = true; + disposable[Symbol.dispose](); + }); + } + + // Handle timeout + if (options.timeout !== undefined) { + const timeoutHandle = setTimeout(() => { + timedOut = true; + disposable[Symbol.dispose](); + }, options.timeout * 1000); + + void exitCode.finally(() => clearTimeout(timeoutHandle)); + } + + return { stdout, stderr, stdin, exitCode, duration }; + } + + /** + * Read file contents as a stream via exec. + */ + readFile(filePath: string, abortSignal?: AbortSignal): ReadableStream { + return new ReadableStream({ + start: async (controller: ReadableStreamDefaultController) => { + try { + const stream = await this.exec(`cat ${this.quoteForRemote(filePath)}`, { + cwd: this.getBasePath(), + timeout: 300, + abortSignal, + }); + + const reader = stream.stdout.getReader(); + const exitCodePromise = stream.exitCode; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(value); + } + + const code = await exitCodePromise; + if (code !== 0) { + const stderr = await streamToString(stream.stderr); + throw new RuntimeError(`Failed to read file ${filePath}: ${stderr}`, "file_io"); + } + + controller.close(); + } catch (err) { + if (err instanceof RuntimeError) { + controller.error(err); + } else { + controller.error( + new RuntimeError( + `Failed to read file ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + "file_io", + err instanceof Error ? err : undefined + ) + ); + } + } + }, + }); + } + + /** + * Write file contents atomically via exec. + * Uses temp file + mv for atomic write. + */ + writeFile(filePath: string, abortSignal?: AbortSignal): WritableStream { + const quotedPath = this.quoteForRemote(filePath); + const tempPath = `${filePath}.tmp.${Date.now()}`; + const quotedTempPath = this.quoteForRemote(tempPath); + + // Build write command - subclasses can override buildWriteCommand for special handling + const writeCommand = this.buildWriteCommand(quotedPath, quotedTempPath); + + let execPromise: Promise | null = null; + + const getExecStream = () => { + execPromise ??= this.exec(writeCommand, { + cwd: this.getBasePath(), + timeout: 300, + abortSignal, + }); + return execPromise; + }; + + return new WritableStream({ + write: async (chunk: Uint8Array) => { + const stream = await getExecStream(); + const writer = stream.stdin.getWriter(); + try { + await writer.write(chunk); + } finally { + writer.releaseLock(); + } + }, + close: async () => { + const stream = await getExecStream(); + await stream.stdin.close(); + const exitCode = await stream.exitCode; + + if (exitCode !== 0) { + const stderr = await streamToString(stream.stderr); + throw new RuntimeError(`Failed to write file ${filePath}: ${stderr}`, "file_io"); + } + }, + abort: async (reason?: unknown) => { + const stream = await getExecStream(); + await stream.stdin.abort(); + throw new RuntimeError(`Failed to write file ${filePath}: ${String(reason)}`, "file_io"); + }, + }); + } + + /** + * Build the write command for atomic file writes. + * Can be overridden by subclasses for special handling (e.g., SSH symlink preservation). + */ + protected buildWriteCommand(quotedPath: string, quotedTempPath: string): string { + return `mkdir -p $(dirname ${quotedPath}) && cat > ${quotedTempPath} && mv ${quotedTempPath} ${quotedPath}`; + } + + /** + * Get file statistics via exec. + */ + async stat(filePath: string, abortSignal?: AbortSignal): Promise { + const stream = await this.exec(`stat -c '%s %Y %F' ${this.quoteForRemote(filePath)}`, { + cwd: this.getBasePath(), + timeout: 10, + abortSignal, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + streamToString(stream.stdout), + streamToString(stream.stderr), + stream.exitCode, + ]); + + if (exitCode !== 0) { + throw new RuntimeError(`Failed to stat ${filePath}: ${stderr}`, "file_io"); + } + + const parts = stdout.trim().split(" "); + if (parts.length < 3) { + throw new RuntimeError(`Failed to parse stat output for ${filePath}: ${stdout}`, "file_io"); + } + + const size = parseInt(parts[0], 10); + const mtime = parseInt(parts[1], 10); + const fileType = parts.slice(2).join(" "); + + return { + size, + modifiedTime: new Date(mtime * 1000), + isDirectory: fileType === "directory", + }; + } + + /** + * Normalize path for comparison (POSIX semantics). + * Shared between SSH and Docker. + */ + normalizePath(targetPath: string, basePath: string): string { + const target = targetPath.trim(); + let base = basePath.trim(); + + // Normalize base path - remove trailing slash (except for root "/") + if (base.length > 1 && base.endsWith("/")) { + base = base.slice(0, -1); + } + + // Handle special case: current directory + if (target === ".") { + return base; + } + + // Handle absolute paths and tilde + if (target.startsWith("/") || target === "~" || target.startsWith("~/")) { + let normalizedTarget = target; + // Remove trailing slash for comparison (except for root "/") + if (normalizedTarget.length > 1 && normalizedTarget.endsWith("/")) { + normalizedTarget = normalizedTarget.slice(0, -1); + } + return normalizedTarget; + } + + // Relative path - resolve against base + const normalizedTarget = base.endsWith("/") ? base + target : base + "/" + target; + + // Remove trailing slash + if (normalizedTarget.length > 1 && normalizedTarget.endsWith("/")) { + return normalizedTarget.slice(0, -1); + } + + return normalizedTarget; + } + + /** + * Return /tmp as the temp directory for remote runtimes. + */ + tempDir(): Promise { + return Promise.resolve("/tmp"); + } + + // Abstract methods that subclasses must implement + abstract resolvePath(path: string): Promise; + abstract getWorkspacePath(projectPath: string, workspaceName: string): string; + abstract createWorkspace(params: WorkspaceCreationParams): Promise; + abstract initWorkspace(params: WorkspaceInitParams): Promise; + abstract renameWorkspace( + projectPath: string, + oldName: string, + newName: string, + abortSignal?: AbortSignal + ): Promise< + { success: true; oldPath: string; newPath: string } | { success: false; error: string } + >; + abstract deleteWorkspace( + projectPath: string, + workspaceName: string, + force: boolean, + abortSignal?: AbortSignal + ): Promise<{ success: true; deletedPath: string } | { success: false; error: string }>; + abstract forkWorkspace(params: WorkspaceForkParams): Promise; +} diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 028b20cf7e..11852c719d 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -15,7 +15,7 @@ * srcBaseDir (base directory for all workspaces): * - Where mux stores ALL workspace directories * - Local: ~/.mux/src (tilde expanded to full path by LocalRuntime) - * - SSH: /home/user/workspace (must be absolute path, no tilde allowed) + * - SSH: /home/user/workspace (tilde paths are allowed and are resolved before use) * * Workspace Path Computation: * {srcBaseDir}/{projectName}/{workspaceName} diff --git a/src/node/runtime/SSHRuntime.ts b/src/node/runtime/SSHRuntime.ts index 061b4e15e2..4b2d6158bd 100644 --- a/src/node/runtime/SSHRuntime.ts +++ b/src/node/runtime/SSHRuntime.ts @@ -1,11 +1,24 @@ +/** + * SSH runtime implementation that executes commands and file operations + * over SSH using the ssh command-line tool. + * + * Features: + * - Uses system ssh command (respects ~/.ssh/config) + * - 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. + * + * Extends RemoteRuntime for shared exec/file operations. + */ + import { spawn } from "child_process"; -import { Readable, Writable } from "stream"; import * as path from "path"; import type { - Runtime, ExecOptions, - ExecStream, - FileStat, WorkspaceCreationParams, WorkspaceCreationResult, WorkspaceInitParams, @@ -15,16 +28,15 @@ import type { InitLogger, } from "./Runtime"; import { RuntimeError as RuntimeErrorClass } from "./Runtime"; -import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; +import { RemoteRuntime, type SpawnResult } from "./RemoteRuntime"; import { log } from "@/node/services/log"; import { checkInitHookExists, getMuxEnv, runInitHookOnRuntime } from "./initHook"; import { expandTildeForSSH as expandHookPath } from "./tildeExpansion"; -import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; import { streamProcessToLogger } from "./streamProcess"; import { expandTildeForSSH, cdCommandForSSH } from "./tildeExpansion"; import { getProjectName, execBuffered } from "@/node/utils/runtime/helpers"; import { getErrorMessage } from "@/common/utils/errors"; -import { execAsync, DisposableProcess } from "@/node/utils/disposableExec"; +import { execAsync } from "@/node/utils/disposableExec"; import { getControlPath, sshConnectionPool, type SSHRuntimeConfig } from "./sshConnectionPool"; import { getBashPath } from "@/node/utils/main/bashPath"; import { streamToString, shescape } from "./streamUtils"; @@ -36,23 +48,16 @@ export type { SSHRuntimeConfig } from "./sshConnectionPool"; * SSH runtime implementation that executes commands and file operations * over SSH using the ssh command-line tool. * - * Features: - * - Uses system ssh command (respects ~/.ssh/config) - * - 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. + * Extends RemoteRuntime for shared exec/file operations. */ -export class SSHRuntime implements Runtime { +export class SSHRuntime extends RemoteRuntime { private readonly config: SSHRuntimeConfig; private readonly controlPath: string; /** Cached resolved bgOutputDir (tilde expanded to absolute path) */ private resolvedBgOutputDir: string | null = null; constructor(config: SSHRuntimeConfig) { + super(); // 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; @@ -99,54 +104,36 @@ export class SSHRuntime implements Runtime { return this.config; } - /** - * Execute command over SSH with streaming I/O - */ - async exec(command: string, options: ExecOptions): Promise { - const startTime = performance.now(); + // ===== RemoteRuntime abstract method implementations ===== - // Short-circuit if already aborted - if (options.abortSignal?.aborted) { - throw new RuntimeErrorClass("Operation aborted before execution", "exec"); - } - - // Ensure connection is healthy before executing - // This provides backoff protection and singleflighting for concurrent requests - await sshConnectionPool.acquireConnection(this.config); + protected readonly commandPrefix = "SSH"; - // Build command parts - const parts: string[] = []; + protected getBasePath(): string { + return this.config.srcBaseDir; + } - // Add cd command if cwd is specified - parts.push(cdCommandForSSH(options.cwd)); + protected quoteForRemote(filePath: string): string { + return expandTildeForSSH(filePath); + } - // Add environment variable exports (user env first, then non-interactive overrides) - const envVars = { ...options.env, ...NON_INTERACTIVE_ENV_VARS }; - for (const [key, value] of Object.entries(envVars)) { - parts.push(`export ${key}=${shescape.quote(value)}`); - } + protected cdCommand(cwd: string): string { + return cdCommandForSSH(cwd); + } - // Add the actual command - parts.push(command); - - // Join all parts with && to ensure each step succeeds before continuing - let fullCommand = parts.join(" && "); - - // Always wrap in bash to ensure consistent shell behavior - // (user's login shell may be fish, zsh, etc. which have different syntax) - fullCommand = `bash -c ${shescape.quote(fullCommand)}`; - - // Optionally wrap with timeout to ensure the command is killed on the remote side - // even if the local SSH client is killed but the ControlMaster connection persists - // Use timeout command with KILL signal - // Set remote timeout slightly longer (+1s) than local timeout to ensure - // the local timeout fires first in normal cases (for cleaner error handling) - // Note: Using BusyBox-compatible syntax (-s KILL) which also works with GNU timeout - if (options.timeout !== undefined) { - const remoteTimeout = Math.ceil(options.timeout) + 1; - fullCommand = `timeout -s KILL ${remoteTimeout} ${fullCommand}`; + /** + * Handle exit codes for SSH connection pool health tracking. + */ + protected onExitCode(exitCode: number, _options: ExecOptions): void { + // SSH exit code 255 indicates connection failure - report to pool for backoff + // This prevents thundering herd when a previously healthy host goes down + if (exitCode === 255) { + sshConnectionPool.reportFailure(this.config, "SSH connection failed (exit code 255)"); + } else { + sshConnectionPool.markHealthy(this.config); } + } + protected spawnRemoteProcess(fullCommand: string, options: ExecOptions): SpawnResult { // Build SSH args from shared base config // -T: Disable pseudo-terminal allocation (default) // -t: Force pseudo-terminal allocation (for interactive shells) @@ -157,11 +144,6 @@ export class SSHRuntime implements Runtime { // Cap at 15 seconds - users wanting long timeouts for builds shouldn't wait that long for connection // ServerAliveInterval: Send keepalive every 5 seconds to detect dead connections // ServerAliveCountMax: Consider connection dead after 2 missed keepalives (10 seconds total) - // Together these ensure that: - // 1. Connection establishment can't hang indefinitely (max 15s) - // 2. Established connections that die are detected quickly - // 3. The overall command timeout is respected from the moment ssh command starts - // When no timeout specified, use default 15s connect timeout to prevent hanging on connection const connectTimeout = options.timeout !== undefined ? Math.min(Math.ceil(options.timeout), 15) : 15; sshArgs.push("-o", `ConnectTimeout=${connectTimeout}`); @@ -171,250 +153,64 @@ export class SSHRuntime implements Runtime { sshArgs.push(this.config.host, fullCommand); - // Debug: log the actual SSH command being executed - log.debug(`SSH command: ssh ${sshArgs.join(" ")}`); - log.debug(`Remote command: ${fullCommand}`); - // Spawn ssh command - const sshProcess = spawn("ssh", sshArgs, { + const process = spawn("ssh", sshArgs, { stdio: ["pipe", "pipe", "pipe"], // Prevent console window from appearing on Windows windowsHide: true, }); - // Wrap in DisposableProcess for automatic cleanup - const disposable = new DisposableProcess(sshProcess); - - // Convert Node.js streams to Web Streams - const stdout = Readable.toWeb(sshProcess.stdout) as unknown as ReadableStream; - const stderr = Readable.toWeb(sshProcess.stderr) as unknown as ReadableStream; - const stdin = Writable.toWeb(sshProcess.stdin) as unknown as WritableStream; - - // No stream cleanup in DisposableProcess - streams close naturally when process exits - // bash.ts handles cleanup after waiting for exitCode + // Pre-exec: acquire connection from pool for backoff protection + const preExec = sshConnectionPool.acquireConnection(this.config); - // Track if we killed the process due to timeout or abort - let timedOut = false; - let aborted = false; - - // Create promises for exit code and duration - // Uses special exit codes (EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT) for expected error conditions - const exitCode = new Promise((resolve, reject) => { - sshProcess.on("close", (code, signal) => { - // Check abort first (highest priority) - if (aborted || options.abortSignal?.aborted) { - resolve(EXIT_CODE_ABORTED); - return; - } - // Check if we killed the process due to timeout - if (timedOut) { - resolve(EXIT_CODE_TIMEOUT); - return; - } - - const exitCode = code ?? (signal ? -1 : 0); - - // SSH exit code 255 indicates connection failure - report to pool for backoff - // This prevents thundering herd when a previously healthy host goes down - // Any other exit code means the connection worked (command may have failed) - if (exitCode === 255) { - sshConnectionPool.reportFailure(this.config, "SSH connection failed (exit code 255)"); - } else { - sshConnectionPool.markHealthy(this.config); - } - - resolve(exitCode); - // Cleanup runs automatically via DisposableProcess - }); - - sshProcess.on("error", (err) => { - // Spawn errors are connection-level failures - sshConnectionPool.reportFailure(this.config, `SSH spawn error: ${err.message}`); - reject(new RuntimeErrorClass(`Failed to execute SSH command: ${err.message}`, "exec", err)); - }); - }); - - const duration = exitCode.then(() => performance.now() - startTime); - - // Handle abort signal - if (options.abortSignal) { - options.abortSignal.addEventListener("abort", () => { - aborted = true; - disposable[Symbol.dispose](); // Kill process and run cleanup - }); - } - - // Handle timeout (only if timeout specified) - if (options.timeout !== undefined) { - const timeoutHandle = setTimeout(() => { - timedOut = true; - disposable[Symbol.dispose](); // Kill process and run cleanup - }, options.timeout * 1000); - - // Clear timeout if process exits naturally - void exitCode.finally(() => clearTimeout(timeoutHandle)); - } - - return { stdout, stderr, stdin, exitCode, duration }; - } - - /** - * Read file contents over SSH as a stream - */ - readFile(path: string, abortSignal?: AbortSignal): ReadableStream { - // Return stdout, but wrap to handle errors from exec() and exit code - return new ReadableStream({ - start: async (controller: ReadableStreamDefaultController) => { - try { - // Use expandTildeForSSH to handle ~ paths (shescape.quote doesn't expand tildes) - const stream = await this.exec(`cat ${expandTildeForSSH(path)}`, { - cwd: this.config.srcBaseDir, - timeout: 300, // 5 minutes - reasonable for large files - abortSignal, - }); - - const reader = stream.stdout.getReader(); - const exitCode = stream.exitCode; - - // Read all chunks - while (true) { - const { done, value } = await reader.read(); - if (done) break; - controller.enqueue(value); - } - - // Check exit code after reading completes - const code = await exitCode; - if (code !== 0) { - const stderr = await streamToString(stream.stderr); - throw new RuntimeErrorClass(`Failed to read file ${path}: ${stderr}`, "file_io"); - } - - controller.close(); - } catch (err) { - if (err instanceof RuntimeErrorClass) { - controller.error(err); - } else { - controller.error( - new RuntimeErrorClass( - `Failed to read file ${path}: ${err instanceof Error ? err.message : String(err)}`, - "file_io", - err instanceof Error ? err : undefined - ) - ); - } - } - }, - }); + return { process, preExec }; } /** - * Write file contents over SSH atomically from a stream - * Preserves symlinks and file permissions by resolving and copying metadata + * Override buildWriteCommand for SSH to handle symlinks and preserve permissions. */ - writeFile(path: string, abortSignal?: AbortSignal): WritableStream { - // Use expandTildeForSSH to handle ~ paths (shescape.quote doesn't expand tildes) - const expandedPath = expandTildeForSSH(path); - const tempPath = `${path}.tmp.${Date.now()}`; - const expandedTempPath = expandTildeForSSH(tempPath); + protected buildWriteCommand(quotedPath: string, quotedTempPath: string): string { // Resolve symlinks to get the actual target path, preserving the symlink itself // If target exists, save its permissions to restore after write // If path doesn't exist, use 600 as default // Then write atomically using mv (all-or-nothing for readers) - const writeCommand = `RESOLVED=$(readlink -f ${expandedPath} 2>/dev/null || echo ${expandedPath}) && PERMS=$(stat -c '%a' "$RESOLVED" 2>/dev/null || echo 600) && mkdir -p $(dirname "$RESOLVED") && cat > ${expandedTempPath} && chmod "$PERMS" ${expandedTempPath} && mv ${expandedTempPath} "$RESOLVED"`; - - // Need to get the exec stream in async callbacks - let execPromise: Promise | null = null; - - const getExecStream = () => { - execPromise ??= this.exec(writeCommand, { - cwd: this.config.srcBaseDir, - timeout: 300, // 5 minutes - reasonable for large files - abortSignal, - }); - return execPromise; - }; - - // Wrap stdin to handle errors from exit code - return new WritableStream({ - write: async (chunk: Uint8Array) => { - const stream = await getExecStream(); - const writer = stream.stdin.getWriter(); - try { - await writer.write(chunk); - } finally { - writer.releaseLock(); - } - }, - close: async () => { - const stream = await getExecStream(); - // Close stdin and wait for command to complete - await stream.stdin.close(); - const exitCode = await stream.exitCode; - - if (exitCode !== 0) { - const stderr = await streamToString(stream.stderr); - throw new RuntimeErrorClass(`Failed to write file ${path}: ${stderr}`, "file_io"); - } - }, - abort: async (reason?: unknown) => { - const stream = await getExecStream(); - await stream.stdin.abort(); - throw new RuntimeErrorClass(`Failed to write file ${path}: ${String(reason)}`, "file_io"); - }, - }); + return `RESOLVED=$(readlink -f ${quotedPath} 2>/dev/null || echo ${quotedPath}) && PERMS=$(stat -c '%a' "$RESOLVED" 2>/dev/null || echo 600) && mkdir -p $(dirname "$RESOLVED") && cat > ${quotedTempPath} && chmod "$PERMS" ${quotedTempPath} && mv ${quotedTempPath} "$RESOLVED"`; } + // ===== SSH-specific helper methods ===== + /** - * Get file statistics over SSH + * Build base SSH args shared by all SSH operations. + * Includes: port, identity file, LogLevel, ControlMaster options. */ - async stat(path: string, abortSignal?: AbortSignal): Promise { - // Use stat with format string to get: size, mtime, type - // %s = size, %Y = mtime (seconds since epoch), %F = file type - // Use expandTildeForSSH to handle ~ paths (shescape.quote doesn't expand tildes) - const stream = await this.exec(`stat -c '%s %Y %F' ${expandTildeForSSH(path)}`, { - cwd: this.config.srcBaseDir, - timeout: 10, // 10 seconds - stat should be fast - abortSignal, - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - streamToString(stream.stdout), - streamToString(stream.stderr), - stream.exitCode, - ]); + private buildSSHArgs(): string[] { + const args: string[] = []; - if (exitCode !== 0) { - throw new RuntimeErrorClass(`Failed to stat ${path}: ${stderr}`, "file_io"); + // Add port if specified + if (this.config.port) { + args.push("-p", this.config.port.toString()); } - const parts = stdout.trim().split(" "); - if (parts.length < 3) { - throw new RuntimeErrorClass(`Failed to parse stat output for ${path}: ${stdout}`, "file_io"); + // Add identity file if specified + if (this.config.identityFile) { + args.push("-i", this.config.identityFile); + // Disable strict host key checking for test environments + args.push("-o", "StrictHostKeyChecking=no"); + args.push("-o", "UserKnownHostsFile=/dev/null"); } - const size = parseInt(parts[0], 10); - const mtime = parseInt(parts[1], 10); - const fileType = parts.slice(2).join(" "); + // Suppress SSH warnings (e.g., ControlMaster messages) that would pollute command output + // These go to stderr and get merged with stdout in bash tool results + // Use FATAL (not ERROR) because mux_client_request_session messages are at ERROR level + args.push("-o", "LogLevel=FATAL"); - return { - size, - modifiedTime: new Date(mtime * 1000), - isDirectory: fileType === "directory", - }; - } - async resolvePath(filePath: string): Promise { - // Expand tilde on the remote system. - // IMPORTANT: This must not single-quote a "~" path directly, because quoted tildes won't expand. - // We reuse expandTildeForSSH() to produce a "$HOME"-based, bash-safe expression. - // - // Note: This does not attempt to canonicalize relative paths (no filesystem access). - // It only ensures ~ is expanded so callers can compare against absolute paths. - const script = `echo ${expandTildeForSSH(filePath)}`; - const command = `bash -c ${shescape.quote(script)}`; + // Add ControlMaster options for connection multiplexing + // This ensures all SSH operations reuse the master connection + args.push("-o", "ControlMaster=auto"); + args.push("-o", `ControlPath=${this.controlPath}`); + args.push("-o", "ControlPersist=60"); - // Use 10 second timeout for path resolution to allow for slower SSH connections - return this.execSSHCommand(command, 10000); + return args; } /** @@ -492,73 +288,34 @@ export class SSHRuntime implements Runtime { }); } - normalizePath(targetPath: string, basePath: string): string { - // For SSH, handle paths in a POSIX-like manner without accessing the remote filesystem - const target = targetPath.trim(); - let base = basePath.trim(); + // ===== Runtime interface implementations ===== - // Normalize base path - remove trailing slash (except for root "/") - if (base.length > 1 && base.endsWith("/")) { - base = base.slice(0, -1); - } - - // Handle special case: current directory - if (target === ".") { - return base; - } - - // Handle tilde expansion - keep as-is for comparison - let normalizedTarget = target; - if (target === "~" || target.startsWith("~/")) { - normalizedTarget = target; - } else if (target.startsWith("/")) { - // Absolute path - use as-is - normalizedTarget = target; - } else { - // Relative path - resolve against base using POSIX path joining - normalizedTarget = base.endsWith("/") ? base + target : base + "/" + target; - } - - // Remove trailing slash for comparison (except for root "/") - if (normalizedTarget.length > 1 && normalizedTarget.endsWith("/")) { - normalizedTarget = normalizedTarget.slice(0, -1); - } + async resolvePath(filePath: string): Promise { + // Expand ~ on the remote host. + // Note: `p='~/x'; echo "$p"` does NOT expand ~ (tilde expansion happens before assignment). + // We do explicit expansion using parameter substitution (no reliance on `realpath`, `readlink -f`, etc.). + const script = [ + `p=${shescape.quote(filePath)}`, + 'if [ "$p" = "~" ]; then', + ' echo "$HOME"', + 'elif [ "${p#\\~/}" != "$p" ]; then', + ' echo "$HOME/${p#\\~/}"', + 'elif [ "${p#/}" != "$p" ]; then', + ' echo "$p"', + "else", + ' echo "$PWD/$p"', + "fi", + ].join("\n"); + + const command = `bash -lc ${shescape.quote(script)}`; - return normalizedTarget; + // Use 10 second timeout for path resolution to allow for slower SSH connections + return this.execSSHCommand(command, 10000); } - /** - * Build base SSH args shared by all SSH operations. - * Includes: port, identity file, LogLevel, ControlMaster options. - */ - private buildSSHArgs(): string[] { - const args: string[] = []; - - // Add port if specified - if (this.config.port) { - args.push("-p", this.config.port.toString()); - } - - // Add identity file if specified - if (this.config.identityFile) { - args.push("-i", this.config.identityFile); - // Disable strict host key checking for test environments - args.push("-o", "StrictHostKeyChecking=no"); - args.push("-o", "UserKnownHostsFile=/dev/null"); - } - - // Suppress SSH warnings (e.g., ControlMaster messages) that would pollute command output - // These go to stderr and get merged with stdout in bash tool results - // Use FATAL (not ERROR) because mux_client_request_session messages are at ERROR level - args.push("-o", "LogLevel=FATAL"); - - // Add ControlMaster options for connection multiplexing - // This ensures all SSH operations reuse the master connection - args.push("-o", "ControlMaster=auto"); - args.push("-o", `ControlPath=${this.controlPath}`); - args.push("-o", "ControlPersist=60"); - - return args; + getWorkspacePath(projectPath: string, workspaceName: string): string { + const projectName = getProjectName(projectPath); + return path.posix.join(this.config.srcBaseDir, projectName, workspaceName); } /** @@ -759,11 +516,6 @@ export class SSHRuntime implements Runtime { } } - getWorkspacePath(projectPath: string, workspaceName: string): string { - const projectName = getProjectName(projectPath); - return path.posix.join(this.config.srcBaseDir, projectName, workspaceName); - } - async createWorkspace(params: WorkspaceCreationParams): Promise { try { const { projectPath, branchName, initLogger, abortSignal } = params; @@ -1009,15 +761,19 @@ export class SSHRuntime implements Runtime { } finally { stderrReader.releaseLock(); } + return { success: false, - error: `Failed to rename directory: ${stderr || "Unknown error"}`, + error: `Failed to rename directory: ${stderr.trim() || "Unknown error"}`, }; } return { success: true, oldPath, newPath }; } catch (error) { - return { success: false, error: `Failed to rename directory: ${getErrorMessage(error)}` }; + return { + success: false, + error: `Failed to rename directory: ${getErrorMessage(error)}`, + }; } } @@ -1135,29 +891,17 @@ export class SSHRuntime implements Runtime { if (checkExitCode === 1) { return { success: false, - error: `Workspace contains uncommitted changes. Use force flag to delete anyway.`, + error: "Workspace contains uncommitted changes. Use force flag to delete anyway.", }; } if (checkExitCode === 2) { // Read stderr which contains the unpushed commits output - const stderrReader = checkStream.stderr.getReader(); - const decoder = new TextDecoder(); - let stderr = ""; - try { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - stderr += decoder.decode(value, { stream: true }); - } - } finally { - stderrReader.releaseLock(); - } - + const stderr = await streamToString(checkStream.stderr); const commitList = stderr.trim(); const errorMsg = commitList ? `Workspace contains unpushed commits:\n\n${commitList}` - : `Workspace contains unpushed commits. Use force flag to delete anyway.`; + : "Workspace contains unpushed commits. Use force flag to delete anyway."; return { success: false, @@ -1167,21 +911,10 @@ export class SSHRuntime implements Runtime { if (checkExitCode !== 0) { // Unexpected error - const stderrReader = checkStream.stderr.getReader(); - const decoder = new TextDecoder(); - let stderr = ""; - try { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - stderr += decoder.decode(value, { stream: true }); - } - } finally { - stderrReader.releaseLock(); - } + const stderr = await streamToString(checkStream.stderr); return { success: false, - error: `Failed to check workspace state: ${stderr || `exit code ${checkExitCode}`}`, + error: `Failed to check workspace state: ${stderr.trim() || `exit code ${checkExitCode}`}`, }; } @@ -1202,21 +935,10 @@ export class SSHRuntime implements Runtime { if (exitCode !== 0) { // Read stderr for error message - const stderrReader = stream.stderr.getReader(); - const decoder = new TextDecoder(); - let stderr = ""; - try { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - stderr += decoder.decode(value, { stream: true }); - } - } finally { - stderrReader.releaseLock(); - } + const stderr = await streamToString(stream.stderr); return { success: false, - error: `Failed to delete directory: ${stderr || "Unknown error"}`, + error: `Failed to delete directory: ${stderr.trim() || "Unknown error"}`, }; } @@ -1227,26 +949,9 @@ export class SSHRuntime implements Runtime { } forkWorkspace(_params: WorkspaceForkParams): Promise { - // SSH forking is not yet implemented due to unresolved complexities: - // - Users expect the new workspace's filesystem state to match the remote workspace, - // not the local project (which may be out of sync or on a different commit) - // - This requires: detecting the branch, copying remote state, handling uncommitted changes - // - For now, users should create a new workspace from the desired branch instead return Promise.resolve({ success: false, error: "Forking SSH workspaces is not yet implemented. Create a new workspace instead.", }); } - - /** - * Get the runtime's temp directory (resolved absolute path on remote). - */ - tempDir(): Promise { - // Use configured bgOutputDir's parent or default /tmp - // The bgOutputDir is typically /tmp/mux-bashes, so we return /tmp - return Promise.resolve("/tmp"); - } } - -// Re-export for backward compatibility with existing imports -export { streamToString } from "./streamUtils"; diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index 8e0a4f9ca6..c9a5b30c18 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -1,5 +1,7 @@ import type { WorkspaceMetadata } from "@/common/types/workspace"; import type { MCPServerMap } from "@/common/types/mcp"; +import type { RuntimeMode } from "@/common/types/runtime"; +import { RUNTIME_MODE } from "@/common/types/runtime"; import { readInstructionSet, readInstructionSetFromRuntime, @@ -13,6 +15,7 @@ import { import type { Runtime } from "@/node/runtime/Runtime"; import { getMuxHome } from "@/common/constants/paths"; import { getAvailableTools } from "@/common/utils/tools/toolDefinitions"; +import { assertNever } from "@/common/utils/assertNever"; // NOTE: keep this in sync with the docs/models.md file @@ -66,59 +69,59 @@ When the user asks you to remember something: /** * Build environment context XML block describing the workspace. * @param workspacePath - Workspace directory path - * @param runtimeType - Runtime type: "local", "worktree", "ssh", or "docker" + * @param runtimeType - Runtime type (local, worktree, ssh, docker) */ -function buildEnvironmentContext( - workspacePath: string, - runtimeType: "local" | "worktree" | "ssh" | "docker" -): string { - if (runtimeType === "local") { - // Local runtime works directly in project directory - may or may not be git - return ` - -You are working in a directory at ${workspacePath} - -- Tools run here automatically -- You are meant to do your work isolated from the user and other agents - -`; - } - - if (runtimeType === "ssh") { - // SSH runtime clones the repository on a remote host - return ` - -You are in a clone of a git repository at ${workspacePath} - -- This IS a git repository - run git commands directly (no cd needed) -- Tools run here automatically -- You are meant to do your work isolated from the user and other agents - -`; - } - - if (runtimeType === "docker") { - // Docker runtime runs in an isolated container - return ` - -You are in a clone of a git repository at ${workspacePath} inside a Docker container - -- This IS a git repository - run git commands directly (no cd needed) -- Tools run here automatically inside the container -- You are meant to do your work isolated from the user and other agents - -`; +function buildEnvironmentContext(workspacePath: string, runtimeType: RuntimeMode): string { + // Common lines shared across git-based runtimes + const gitCommonLines = [ + "- This IS a git repository - run git commands directly (no cd needed)", + "- Tools run here automatically", + "- You are meant to do your work isolated from the user and other agents", + ]; + + let description: string; + let lines: string[]; + + switch (runtimeType) { + case RUNTIME_MODE.LOCAL: + // Local runtime works directly in project directory - may or may not be git + description = `You are working in a directory at ${workspacePath}`; + lines = [ + "- Tools run here automatically", + "- You are meant to do your work isolated from the user and other agents", + ]; + break; + + case RUNTIME_MODE.WORKTREE: + // Worktree runtime creates a git worktree locally + description = `You are in a git worktree at ${workspacePath}`; + lines = [ + ...gitCommonLines, + "- Do not modify or visit other worktrees (especially the main project) without explicit user intent", + ]; + break; + + case RUNTIME_MODE.SSH: + // SSH runtime clones the repository on a remote host + description = `You are in a clone of a git repository at ${workspacePath}`; + lines = gitCommonLines; + break; + + case RUNTIME_MODE.DOCKER: + // Docker runtime runs in an isolated container + description = `You are in a clone of a git repository at ${workspacePath} inside a Docker container`; + lines = gitCommonLines; + break; + + default: + assertNever(runtimeType, `Unknown runtime type: ${String(runtimeType)}`); } - // Worktree runtime creates a git worktree locally return ` -You are in a git worktree at ${workspacePath} +${description} -- This IS a git repository - run git commands directly (no cd needed) -- Tools run here automatically -- Do not modify or visit other worktrees (especially the main project) without explicit user intent -- You are meant to do your work isolated from the user and other agents +${lines.join("\n")} `; } diff --git a/tests/ipc/createWorkspace.test.ts b/tests/ipc/createWorkspace.test.ts index a3d7d51474..d68907ac6a 100644 --- a/tests/ipc/createWorkspace.test.ts +++ b/tests/ipc/createWorkspace.test.ts @@ -35,7 +35,7 @@ import { getSrcBaseDir } from "../../src/common/types/runtime"; import type { FrontendWorkspaceMetadata } from "../../src/common/types/workspace"; import { createRuntime } from "../../src/node/runtime/runtimeFactory"; import type { SSHRuntime } from "../../src/node/runtime/SSHRuntime"; -import { streamToString } from "../../src/node/runtime/SSHRuntime"; +import { streamToString } from "../../src/node/runtime/streamUtils"; const execAsync = promisify(exec); diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index 386f57f068..2a3145f98a 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -193,6 +193,29 @@ describeIntegration("Runtime integration tests", () => { ); // 15 second timeout for test (includes workspace creation overhead) }); + describe("resolvePath() - Path resolution", () => { + test.concurrent("expands ~ to the home directory", async () => { + const runtime = createRuntime(); + + const resolved = await runtime.resolvePath("~"); + + if (type === "ssh") { + expect(resolved).toBe("/home/testuser"); + } else { + expect(resolved).toBe(os.homedir()); + } + }); + + test.concurrent("expands ~/path by prefixing the home directory", async () => { + const runtime = createRuntime(); + + const home = await runtime.resolvePath("~"); + const resolved = await runtime.resolvePath("~/mux"); + + expect(resolved).toBe(`${home}/mux`); + }); + }); + describe("readFile() - File reading", () => { test.concurrent("reads file contents", async () => { const runtime = createRuntime(); From d41c317388e88c5eacc28ea8c3cf261474ff29c7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 11 Dec 2025 13:33:57 -0600 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A4=96=20ci:=20stabilize=20integratio?= =?UTF-8?q?n=20suite=20(DockerRuntime=20contract=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DockerRuntime to runtime contract integration matrix (reusing ssh fixture container)\n- Make runtime contract tests sequential for remote fixtures to avoid timeouts\n- Avoid global bun module mocks leaking across tests\n- Make lockfile failure test deterministic in CI\n- Reduce sendMessage heavy test flake and ensure CI uses enough Jest workers\n\n_Generated with mux_ --- jest.config.js | 4 +- .../hooks/useDraftWorkspaceSettings.test.ts | 116 -------------- .../hooks/useDraftWorkspaceSettings.test.tsx | 124 +++++++++++++++ src/node/runtime/DockerRuntime.ts | 20 ++- src/node/services/serverService.test.ts | 15 +- tests/ipc/sendMessage.heavy.test.ts | 6 +- tests/runtime/runtime.test.ts | 148 +++++++++++------- tests/runtime/test-fixtures/test-helpers.ts | 37 +++-- 8 files changed, 273 insertions(+), 197 deletions(-) delete mode 100644 src/browser/hooks/useDraftWorkspaceSettings.test.ts create mode 100644 src/browser/hooks/useDraftWorkspaceSettings.test.tsx diff --git a/jest.config.js b/jest.config.js index f831bd2763..f7be994803 100644 --- a/jest.config.js +++ b/jest.config.js @@ -21,7 +21,9 @@ module.exports = { // Transform ESM modules to CommonJS for Jest transformIgnorePatterns: ["node_modules/(?!(@orpc|shiki|json-schema-typed|rou3)/)"], // Run tests in parallel (use 50% of available cores, or 4 minimum) - maxWorkers: "50%", + // CI runners often have a low core count; "50%" can result in a single Jest worker, + // which can push the integration job over its 10-minute timeout. + maxWorkers: process.env.CI ? 4 : "50%", // Force exit after tests complete to avoid hanging on lingering handles forceExit: true, // 10 minute timeout for integration tests, 10s for unit tests diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.ts b/src/browser/hooks/useDraftWorkspaceSettings.test.ts deleted file mode 100644 index d70df65608..0000000000 --- a/src/browser/hooks/useDraftWorkspaceSettings.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { renderHook, act, cleanup, waitFor } from "@testing-library/react"; -import { GlobalWindow } from "happy-dom"; -import { useState } from "react"; -import { - getLastDockerImageKey, - getLastSshHostKey, - getRuntimeKey, -} from "@/common/constants/storage"; -import { useDraftWorkspaceSettings } from "./useDraftWorkspaceSettings"; - -// A minimal in-memory persisted-state implementation. -// We keep it here (rather than relying on real localStorage) so tests remain deterministic. -const persisted = new Map(); - -void mock.module("@/browser/hooks/usePersistedState", () => { - return { - usePersistedState: (key: string, defaultValue: T) => { - const [value, setValue] = useState(() => { - return persisted.has(key) ? (persisted.get(key) as T) : defaultValue; - }); - - const setAndPersist = (next: T) => { - persisted.set(key, next); - setValue(next); - }; - - return [value, setAndPersist] as const; - }, - }; -}); - -void mock.module("@/browser/hooks/useModelLRU", () => ({ - useModelLRU: () => ({ recentModels: ["test-model"] }), -})); - -void mock.module("@/browser/hooks/useThinkingLevel", () => ({ - useThinkingLevel: () => ["medium", () => undefined] as const, -})); - -void mock.module("@/browser/contexts/ModeContext", () => ({ - useMode: () => ["plan", () => undefined] as const, -})); - -describe("useDraftWorkspaceSettings", () => { - beforeEach(() => { - persisted.clear(); - - globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; - globalThis.document = globalThis.window.document; - }); - - afterEach(() => { - cleanup(); - globalThis.window = undefined as unknown as Window & typeof globalThis; - globalThis.document = undefined as unknown as Document; - }); - - test("does not reset selected runtime to the default while editing SSH host", async () => { - const projectPath = "/tmp/project"; - - const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main")); - - act(() => { - result.current.setSelectedRuntime({ mode: "ssh", host: "dev@host" }); - }); - - await waitFor(() => { - expect(result.current.settings.selectedRuntime).toEqual({ mode: "ssh", host: "dev@host" }); - }); - }); - - test("seeds SSH host from the remembered value when switching modes", async () => { - const projectPath = "/tmp/project"; - persisted.set(getRuntimeKey(projectPath), undefined); - persisted.set(getLastSshHostKey(projectPath), "remembered@host"); - - const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main")); - - act(() => { - // Simulate UI switching into ssh mode with an empty field. - result.current.setSelectedRuntime({ mode: "ssh", host: "" }); - }); - - await waitFor(() => { - expect(result.current.settings.selectedRuntime).toEqual({ - mode: "ssh", - host: "remembered@host", - }); - }); - - expect(persisted.get(getLastSshHostKey(projectPath))).toBe("remembered@host"); - }); - - test("seeds Docker image from the remembered value when switching modes", async () => { - const projectPath = "/tmp/project"; - persisted.set(getRuntimeKey(projectPath), undefined); - persisted.set(getLastDockerImageKey(projectPath), "ubuntu:22.04"); - - const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main")); - - act(() => { - // Simulate UI switching into docker mode with an empty field. - result.current.setSelectedRuntime({ mode: "docker", image: "" }); - }); - - await waitFor(() => { - expect(result.current.settings.selectedRuntime).toEqual({ - mode: "docker", - image: "ubuntu:22.04", - }); - }); - - expect(persisted.get(getLastDockerImageKey(projectPath))).toBe("ubuntu:22.04"); - }); -}); diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx new file mode 100644 index 0000000000..01905ad31f --- /dev/null +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { GlobalWindow } from "happy-dom"; +import React from "react"; +import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { ModeProvider } from "@/browser/contexts/ModeContext"; +import { ThinkingProvider } from "@/browser/contexts/ThinkingContext"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { getLastDockerImageKey, getLastSshHostKey } from "@/common/constants/storage"; +import { useDraftWorkspaceSettings } from "./useDraftWorkspaceSettings"; + +function createStubApiClient(): APIClient { + // useModelLRU() only needs providers.getConfig + providers.onConfigChanged. + // Provide a minimal stub so tests can run without spinning up a real oRPC client. + async function* empty() { + // no-op + } + + return { + providers: { + getConfig: () => Promise.resolve({}), + onConfigChanged: () => Promise.resolve(empty()), + }, + } as unknown as APIClient; +} + +describe("useDraftWorkspaceSettings", () => { + beforeEach(() => { + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + globalThis.localStorage = globalThis.window.localStorage; + globalThis.localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + }); + + test("does not reset selected runtime to the default while editing SSH host", async () => { + const projectPath = "/tmp/project"; + + const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( + + + {props.children} + + + ); + + const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"), { + wrapper, + }); + + act(() => { + result.current.setSelectedRuntime({ mode: "ssh", host: "dev@host" }); + }); + + await waitFor(() => { + expect(result.current.settings.selectedRuntime).toEqual({ mode: "ssh", host: "dev@host" }); + }); + }); + + test("seeds SSH host from the remembered value when switching modes", async () => { + const projectPath = "/tmp/project"; + + updatePersistedState(getLastSshHostKey(projectPath), "remembered@host"); + + const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( + + + {props.children} + + + ); + + const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"), { + wrapper, + }); + + act(() => { + // Simulate UI switching into ssh mode with an empty field. + result.current.setSelectedRuntime({ mode: "ssh", host: "" }); + }); + + await waitFor(() => { + expect(result.current.settings.selectedRuntime).toEqual({ + mode: "ssh", + host: "remembered@host", + }); + }); + }); + + test("seeds Docker image from the remembered value when switching modes", async () => { + const projectPath = "/tmp/project"; + + updatePersistedState(getLastDockerImageKey(projectPath), "ubuntu:22.04"); + + const wrapper: React.FC<{ children: React.ReactNode }> = (props) => ( + + + {props.children} + + + ); + + const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"), { + wrapper, + }); + + act(() => { + // Simulate UI switching into docker mode with an empty field. + result.current.setSelectedRuntime({ mode: "docker", image: "" }); + }); + + await waitFor(() => { + expect(result.current.settings.selectedRuntime).toEqual({ + mode: "docker", + image: "ubuntu:22.04", + }); + }); + }); +}); diff --git a/src/node/runtime/DockerRuntime.ts b/src/node/runtime/DockerRuntime.ts index 6146416534..5413f5e1df 100644 --- a/src/node/runtime/DockerRuntime.ts +++ b/src/node/runtime/DockerRuntime.ts @@ -190,11 +190,27 @@ export class DockerRuntime extends RemoteRuntime { return { process }; } + /** + * Override buildWriteCommand to preserve symlinks and file permissions. + * + * This matches SSHRuntime behavior: write through the symlink to the final target, + * while keeping the symlink itself intact. + */ + protected buildWriteCommand(quotedPath: string, quotedTempPath: string): string { + return `RESOLVED=$(readlink -f ${quotedPath} 2>/dev/null || echo ${quotedPath}) && PERMS=$(stat -c '%a' "$RESOLVED" 2>/dev/null || echo 600) && mkdir -p $(dirname "$RESOLVED") && cat > ${quotedTempPath} && chmod "$PERMS" ${quotedTempPath} && mv ${quotedTempPath} "$RESOLVED"`; + } // ===== Runtime interface implementations ===== resolvePath(filePath: string): Promise { - // Inside container, paths are already absolute - // Just return as-is since we use fixed /src path + // DockerRuntime uses a fixed workspace base (/src), but we still want reasonable shell-style + // behavior for callers that pass "~" or "~/...". + if (filePath === "~") { + return Promise.resolve("/root"); + } + if (filePath.startsWith("~/")) { + return Promise.resolve(path.posix.join("/root", filePath.slice(2))); + } + return Promise.resolve( filePath.startsWith("/") ? filePath : path.posix.join(CONTAINER_SRC_DIR, filePath) ); diff --git a/src/node/services/serverService.test.ts b/src/node/services/serverService.test.ts index 25676aaac8..c96a72327a 100644 --- a/src/node/services/serverService.test.ts +++ b/src/node/services/serverService.test.ts @@ -77,22 +77,19 @@ describe("ServerService.startServer", () => { } test("cleans up server when lockfile acquisition fails", async () => { - // Skip on Windows where chmod doesn't work the same way - if (process.platform === "win32") { - return; - } - const service = new ServerService(); - // Make muxHome read-only so lockfile.acquire() will fail - await fs.chmod(tempDir, 0o444); + // Use a muxHome path that is a FILE (not a directory) so lockfile.acquire() fails + // after the server has started. + const muxHome = path.join(tempDir, "not-a-dir"); + await fs.writeFile(muxHome, "not a directory"); let thrownError: Error | null = null; try { // Start server - this should fail when trying to write lockfile await service.startServer({ - muxHome: tempDir, + muxHome, context: stubContext as ORPCContext, authToken: "test-token", port: 0, // random port @@ -103,7 +100,7 @@ describe("ServerService.startServer", () => { // Verify that an error was thrown expect(thrownError).not.toBeNull(); - expect(thrownError!.message).toMatch(/EACCES|permission denied/i); + expect(thrownError!.message).toMatch(/ENOTDIR|not a directory|EACCES|permission denied/i); // Verify the server is NOT left running expect(service.isServerRunning()).toBe(false); diff --git a/tests/ipc/sendMessage.heavy.test.ts b/tests/ipc/sendMessage.heavy.test.ts index dcf9d83630..96fb659c09 100644 --- a/tests/ipc/sendMessage.heavy.test.ts +++ b/tests/ipc/sendMessage.heavy.test.ts @@ -41,8 +41,8 @@ describeIntegration("sendMessage heavy/load tests", () => { await withSharedWorkspace(provider, async ({ env, workspaceId, collector }) => { // Build up large conversation history to exceed context limit // This approach is model-agnostic - it keeps sending until we've built up enough history - const largeMessage = "x".repeat(50_000); - for (let i = 0; i < 10; i++) { + const largeMessage = "x".repeat(70_000); + for (let i = 0; i < 8; i++) { await sendMessageWithModel( env, workspaceId, @@ -101,7 +101,7 @@ describeIntegration("sendMessage heavy/load tests", () => { await collector.waitForEvent("stream-end", 30000); }); }, - 180000 // 3 minute timeout for building large history and API calls + 300000 // 5 minute timeout for building large history and API calls ); }); diff --git a/tests/runtime/runtime.test.ts b/tests/runtime/runtime.test.ts index 2a3145f98a..a980a66995 100644 --- a/tests/runtime/runtime.test.ts +++ b/tests/runtime/runtime.test.ts @@ -48,7 +48,7 @@ describeIntegration("Runtime integration tests", () => { console.log("Starting SSH server container..."); sshConfig = await startSSHServer(); console.log(`SSH server ready on port ${sshConfig.port}`); - }, 60000); // 60s timeout for Docker operations + }, 120000); // 120s timeout for Docker build/start operations afterAll(async () => { if (sshConfig) { @@ -57,18 +57,49 @@ describeIntegration("Runtime integration tests", () => { } }, 30000); - // Test matrix: Run all tests for both local and SSH runtimes - describe.each<{ type: RuntimeType }>([{ type: "local" }, { type: "ssh" }])( + // Test matrix: Run all tests for local, SSH, and Docker runtimes + describe.each<{ type: RuntimeType }>([{ type: "local" }, { type: "ssh" }, { type: "docker" }])( "Runtime: $type", ({ type }) => { // Helper to create runtime for this test type // Use a base working directory - TestWorkspace will create subdirectories as needed // For local runtime, use os.tmpdir() which matches where TestWorkspace creates directories - const getBaseWorkdir = () => (type === "ssh" ? sshConfig!.workdir : os.tmpdir()); - const createRuntime = (): Runtime => createTestRuntime(type, getBaseWorkdir(), sshConfig); + const getBaseWorkdir = () => { + if (type === "ssh") { + return sshConfig!.workdir; + } + if (type === "docker") { + return "/src"; + } + return os.tmpdir(); + }; + + // DockerRuntime is slower than local/ssh, and the integration job has a hard + // time budget. Keep the Docker coverage focused on the core Runtime contract. + const describeLocalOnly = type === "local" ? describe : describe.skip; + + const describeNonDocker = type === "docker" ? describe.skip : describe; + + // Running these runtime contract tests with test.concurrent can easily overwhelm + // the docker/ssh fixtures in CI and cause the overall integration job to hit its + // 10-minute timeout. Keep runtime tests deterministic by running them sequentially + // for remote runtimes. + const testForRuntime = type === "local" ? test.concurrent : test; + const isRemote = type !== "local"; + const testLocalOnly = isRemote ? test.skip : testForRuntime; + const testDockerOnly = type === "docker" ? testForRuntime : test.skip; + const createRuntime = (): Runtime => + createTestRuntime( + type, + getBaseWorkdir(), + sshConfig, + type === "docker" + ? { image: "mux-ssh-test", containerName: sshConfig!.containerId } + : undefined + ); describe("exec() - Command execution", () => { - test.concurrent("captures stdout and stderr separately", async () => { + testForRuntime("captures stdout and stderr separately", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -83,7 +114,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.duration).toBeGreaterThan(0); }); - test.concurrent("returns correct exit code for failed commands", async () => { + testForRuntime("returns correct exit code for failed commands", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -95,7 +126,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.exitCode).toBe(42); }); - test.concurrent("handles stdin input", async () => { + testLocalOnly("handles stdin input", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -109,7 +140,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.exitCode).toBe(0); }); - test.concurrent("passes environment variables", async () => { + testForRuntime("passes environment variables", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -122,7 +153,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.stdout.trim()).toBe("test-value"); }); - test.concurrent("sets NON_INTERACTIVE_ENV_VARS to prevent prompts", async () => { + testForRuntime("sets NON_INTERACTIVE_ENV_VARS to prevent prompts", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -137,7 +168,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.stdout).toContain("GIT_EDITOR=true"); }); - test.concurrent("handles empty output", async () => { + testForRuntime("handles empty output", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -148,7 +179,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.exitCode).toBe(0); }); - test.concurrent("handles commands with quotes and special characters", async () => { + testLocalOnly("handles commands with quotes and special characters", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -160,7 +191,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.stdout.trim()).toBe('hello "world"'); }); - test.concurrent("respects working directory", async () => { + testForRuntime("respects working directory", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -168,7 +199,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.stdout.trim()).toContain(workspace.path); }); - test.concurrent( + testLocalOnly( "handles timeout correctly", async () => { const runtime = createRuntime(); @@ -194,19 +225,21 @@ describeIntegration("Runtime integration tests", () => { }); describe("resolvePath() - Path resolution", () => { - test.concurrent("expands ~ to the home directory", async () => { + testForRuntime("expands ~ to the home directory", async () => { const runtime = createRuntime(); const resolved = await runtime.resolvePath("~"); if (type === "ssh") { expect(resolved).toBe("/home/testuser"); + } else if (type === "docker") { + expect(resolved).toBe("/root"); } else { expect(resolved).toBe(os.homedir()); } }); - test.concurrent("expands ~/path by prefixing the home directory", async () => { + testForRuntime("expands ~/path by prefixing the home directory", async () => { const runtime = createRuntime(); const home = await runtime.resolvePath("~"); @@ -217,7 +250,7 @@ describeIntegration("Runtime integration tests", () => { }); describe("readFile() - File reading", () => { - test.concurrent("reads file contents", async () => { + testForRuntime("reads file contents", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -231,7 +264,7 @@ describeIntegration("Runtime integration tests", () => { expect(content).toBe(testContent); }); - test.concurrent("reads empty file", async () => { + testForRuntime("reads empty file", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -244,7 +277,7 @@ describeIntegration("Runtime integration tests", () => { expect(content).toBe(""); }); - test.concurrent("reads binary data correctly", async () => { + testLocalOnly("reads binary data correctly", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -276,7 +309,7 @@ describeIntegration("Runtime integration tests", () => { expect(readData).toEqual(binaryData); }); - test.concurrent("throws RuntimeError for non-existent file", async () => { + testForRuntime("throws RuntimeError for non-existent file", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -285,7 +318,7 @@ describeIntegration("Runtime integration tests", () => { ).rejects.toThrow(RuntimeError); }); - test.concurrent("throws RuntimeError when reading a directory", async () => { + testForRuntime("throws RuntimeError when reading a directory", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -297,7 +330,7 @@ describeIntegration("Runtime integration tests", () => { }); describe("writeFile() - File writing", () => { - test.concurrent("writes file contents", async () => { + testForRuntime("writes file contents", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -313,7 +346,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.stdout).toBe(content); }); - test.concurrent("overwrites existing file", async () => { + testForRuntime("overwrites existing file", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -330,7 +363,7 @@ describeIntegration("Runtime integration tests", () => { expect(content).toBe("new content"); }); - test.concurrent("writes empty file", async () => { + testForRuntime("writes empty file", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -340,7 +373,7 @@ describeIntegration("Runtime integration tests", () => { expect(content).toBe(""); }); - test.concurrent("writes binary data", async () => { + testLocalOnly("writes binary data", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -358,7 +391,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.stdout.trim()).toBe("6"); }); - test.concurrent("creates parent directories if needed", async () => { + testForRuntime("creates parent directories if needed", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -368,7 +401,7 @@ describeIntegration("Runtime integration tests", () => { expect(content).toBe("content"); }); - test.concurrent("handles special characters in content", async () => { + testForRuntime("handles special characters in content", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -379,7 +412,7 @@ describeIntegration("Runtime integration tests", () => { expect(content).toBe(specialContent); }); - test.concurrent("preserves symlinks when editing target file", async () => { + testDockerOnly("preserves symlinks when editing target file", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -422,7 +455,7 @@ describeIntegration("Runtime integration tests", () => { expect(targetContent).toBe("new content"); }); - test.concurrent("preserves file permissions when editing through symlink", async () => { + testDockerOnly("preserves file permissions when editing through symlink", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -469,7 +502,7 @@ describeIntegration("Runtime integration tests", () => { }); describe("stat() - File metadata", () => { - test.concurrent("returns file metadata", async () => { + testForRuntime("returns file metadata", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -486,7 +519,7 @@ describeIntegration("Runtime integration tests", () => { expect(stat.modifiedTime.getTime()).toBeLessThanOrEqual(Date.now()); }); - test.concurrent("returns directory metadata", async () => { + testForRuntime("returns directory metadata", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -497,7 +530,7 @@ describeIntegration("Runtime integration tests", () => { expect(stat.isDirectory).toBe(true); }); - test.concurrent("throws RuntimeError for non-existent path", async () => { + testForRuntime("throws RuntimeError for non-existent path", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -506,7 +539,7 @@ describeIntegration("Runtime integration tests", () => { ); }); - test.concurrent("returns correct size for empty file", async () => { + testForRuntime("returns correct size for empty file", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -519,8 +552,8 @@ describeIntegration("Runtime integration tests", () => { }); }); - describe("Edge cases", () => { - test.concurrent( + describeLocalOnly("Edge cases", () => { + testForRuntime( "handles large files efficiently", async () => { const runtime = createRuntime(); @@ -538,7 +571,7 @@ describeIntegration("Runtime integration tests", () => { 30000 ); - test.concurrent("handles concurrent operations", async () => { + testLocalOnly("handles concurrent operations", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -553,7 +586,7 @@ describeIntegration("Runtime integration tests", () => { await Promise.all(operations); }); - test.concurrent("handles paths with spaces", async () => { + testForRuntime("handles paths with spaces", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -564,7 +597,7 @@ describeIntegration("Runtime integration tests", () => { expect(content).toBe("content"); }); - test.concurrent("handles very long file paths", async () => { + testForRuntime("handles very long file paths", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -577,8 +610,8 @@ describeIntegration("Runtime integration tests", () => { }); }); - describe("Git operations", () => { - test.concurrent("can initialize a git repository", async () => { + describeNonDocker("Git operations", () => { + testForRuntime("can initialize a git repository", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -595,7 +628,7 @@ describeIntegration("Runtime integration tests", () => { expect(stat.isDirectory).toBe(true); }); - test.concurrent("can create commits", async () => { + testForRuntime("can create commits", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -622,7 +655,7 @@ describeIntegration("Runtime integration tests", () => { expect(logResult.stdout).toContain("Initial commit"); }); - test.concurrent("can create and checkout branches", async () => { + testForRuntime("can create and checkout branches", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -655,7 +688,7 @@ describeIntegration("Runtime integration tests", () => { expect(branchResult.stdout.trim()).toBe("feature-branch"); }); - test.concurrent("can handle git status in dirty workspace", async () => { + testForRuntime("can handle git status in dirty workspace", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -684,8 +717,8 @@ describeIntegration("Runtime integration tests", () => { }); }); - describe("Environment and shell behavior", () => { - test.concurrent("preserves multi-line output formatting", async () => { + describeNonDocker("Environment and shell behavior", () => { + testForRuntime("preserves multi-line output formatting", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -699,7 +732,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.stdout).toContain("line3"); }); - test.concurrent("handles commands with pipes", async () => { + testForRuntime("handles commands with pipes", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -713,7 +746,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.stdout.trim()).toBe("line2"); }); - test.concurrent("handles command substitution", async () => { + testForRuntime("handles command substitution", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -725,7 +758,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.stdout).toContain("Current dir:"); }); - test.concurrent("handles large stdout output", async () => { + testForRuntime("handles large stdout output", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -741,7 +774,7 @@ describeIntegration("Runtime integration tests", () => { expect(lines[999]).toBe("1000"); }); - test.concurrent("handles commands that produce no output but take time", async () => { + testForRuntime("handles commands that produce no output but take time", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -756,8 +789,8 @@ describeIntegration("Runtime integration tests", () => { }); }); - describe("Error handling", () => { - test.concurrent("handles command not found", async () => { + describeLocalOnly("Error handling", () => { + testForRuntime("handles command not found", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -770,7 +803,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.stderr.toLowerCase()).toContain("not found"); }); - test.concurrent("handles syntax errors in bash", async () => { + testForRuntime("handles syntax errors in bash", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -782,7 +815,7 @@ describeIntegration("Runtime integration tests", () => { expect(result.exitCode).not.toBe(0); }); - test.concurrent("handles permission denied errors", async () => { + testForRuntime("handles permission denied errors", async () => { const runtime = createRuntime(); await using workspace = await TestWorkspace.create(runtime, type); @@ -814,11 +847,12 @@ describeIntegration("Runtime integration tests", () => { * So the actual workspace path is: /home/testuser/workspace/{projectName}/{workspaceName} */ describe("SSHRuntime workspace operations", () => { + const testForRuntime = test; const srcBaseDir = "/home/testuser/workspace"; const createSSHRuntime = (): Runtime => createTestRuntime("ssh", srcBaseDir, sshConfig); describe("renameWorkspace", () => { - test.concurrent("successfully renames directory", async () => { + testForRuntime("successfully renames directory", async () => { const runtime = createSSHRuntime(); // Use unique project name to avoid conflicts with concurrent tests const projectName = `rename-test-${Date.now()}-${Math.random().toString(36).substring(7)}`; @@ -868,7 +902,7 @@ describeIntegration("Runtime integration tests", () => { }); }); - test.concurrent("returns error when trying to rename non-existent directory", async () => { + testForRuntime("returns error when trying to rename non-existent directory", async () => { const runtime = createSSHRuntime(); const projectName = `nonexist-rename-${Date.now()}-${Math.random().toString(36).substring(7)}`; const projectPath = `/some/path/${projectName}`; @@ -884,7 +918,7 @@ describeIntegration("Runtime integration tests", () => { }); describe("deleteWorkspace", () => { - test.concurrent("successfully deletes directory", async () => { + testForRuntime("successfully deletes directory", async () => { const runtime = createSSHRuntime(); const projectName = `delete-test-${Date.now()}-${Math.random().toString(36).substring(7)}`; const projectPath = `/some/path/${projectName}`; @@ -928,7 +962,7 @@ describeIntegration("Runtime integration tests", () => { }); }); - test.concurrent("returns success for non-existent directory (idempotent)", async () => { + testForRuntime("returns success for non-existent directory (idempotent)", async () => { const runtime = createSSHRuntime(); const projectName = `nonexist-delete-${Date.now()}-${Math.random().toString(36).substring(7)}`; const projectPath = `/some/path/${projectName}`; diff --git a/tests/runtime/test-fixtures/test-helpers.ts b/tests/runtime/test-fixtures/test-helpers.ts index bfb3822099..1d310331e2 100644 --- a/tests/runtime/test-fixtures/test-helpers.ts +++ b/tests/runtime/test-fixtures/test-helpers.ts @@ -8,6 +8,7 @@ import * as os from "os"; import * as path from "path"; import type { Runtime } from "@/node/runtime/Runtime"; import { WorktreeRuntime } from "@/node/runtime/WorktreeRuntime"; +import { DockerRuntime } from "@/node/runtime/DockerRuntime"; import { SSHRuntime } from "@/node/runtime/SSHRuntime"; import type { SSHServerConfig } from "./ssh-fixture"; @@ -15,7 +16,12 @@ import type { SSHServerConfig } from "./ssh-fixture"; * Runtime type for test matrix * Note: "local" here means worktree runtime (isolated git worktrees), not project-dir runtime */ -export type RuntimeType = "local" | "ssh"; + +export interface DockerRuntimeTestConfig { + image: string; + containerName: string; +} +export type RuntimeType = "local" | "ssh" | "docker"; /** * Create runtime instance based on type @@ -23,7 +29,8 @@ export type RuntimeType = "local" | "ssh"; export function createTestRuntime( type: RuntimeType, workdir: string, - sshConfig?: SSHServerConfig + sshConfig?: SSHServerConfig, + dockerConfig?: DockerRuntimeTestConfig ): Runtime { switch (type) { case "local": { @@ -43,6 +50,15 @@ export function createTestRuntime( port: sshConfig.port, }); } + case "docker": { + if (!dockerConfig) { + throw new Error("Docker config required for Docker runtime"); + } + return new DockerRuntime({ + image: dockerConfig.image, + containerName: dockerConfig.containerName, + }); + } } } @@ -64,18 +80,20 @@ export class TestWorkspace { * Create a test workspace with isolated directory */ static async create(runtime: Runtime, type: RuntimeType): Promise { - const isRemote = type === "ssh"; + const isRemote = type !== "local"; if (isRemote) { - // For SSH, create subdirectory in remote workdir - // The path is already set in SSHRuntime config - // Create a unique subdirectory + // For SSH/Docker, create a unique subdirectory in the runtime's filesystem. const testId = `test-${Date.now()}-${Math.random().toString(36).substring(7)}`; - const workspacePath = `/home/testuser/workspace/${testId}`; + + const workspacePath = + type === "ssh" ? `/home/testuser/workspace/${testId}` : `/src/${testId}`; + + const cwd = type === "ssh" ? "/home/testuser" : "/"; // Create directory on remote const stream = await runtime.exec(`mkdir -p ${workspacePath}`, { - cwd: "/home/testuser", + cwd, timeout: 30, }); await stream.stdin.close(); @@ -102,8 +120,9 @@ export class TestWorkspace { if (this.isRemote) { // Remove remote directory try { + const cwd = this.path.startsWith("/home/testuser") ? "/home/testuser" : "/"; const stream = await this.runtime.exec(`rm -rf ${this.path}`, { - cwd: "/home/testuser", + cwd, timeout: 60, }); await stream.stdin.close(); From a7b2aa6bc3c2f0f179f556200e9570c45d25b8c5 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 12 Dec 2025 10:43:28 -0600 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=A4=96=20fix:=20wait=20for=20plan=20f?= =?UTF-8?q?ile=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deletePlanFilesForWorkspace() used runtime.exec() without waiting for completion.\nAwait stdin.close() + exitCode so callers can assume plan files are gone when it resolves.\n\n_Generated with mux_ --- src/node/services/workspaceService.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index a00855ae8d..67237c7353 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1087,12 +1087,25 @@ export class WorkspaceService extends EventEmitter { }); try { - // Use exec to delete files since runtime doesn't have a deleteFile method - // Delete both paths in one command for efficiency - await runtime.exec(`rm -f ${quotedPlanPath} ${quotedLegacyPlanPath}`, { + // Use exec to delete files since runtime doesn't have a deleteFile method. + // Delete both paths in one command for efficiency. + // + // IMPORTANT: runtime.exec() returns streams immediately; we must wait for completion + // so callers can safely assume the plan file is gone when this function resolves. + const stream = await runtime.exec(`rm -f ${quotedPlanPath} ${quotedLegacyPlanPath}`, { cwd: metadata.projectPath, timeout: 10, }); + + try { + await stream.stdin.close(); + } catch { + // Ignore stdin-close errors (e.g. already closed). + } + + await stream.exitCode.catch(() => { + // Best-effort: ignore failures. + }); } catch { // Plan files don't exist or can't be deleted - ignore }