diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx
index 6f7f44387d..6f68c4f339 100644
--- a/src/browser/components/ChatInput/CreationControls.tsx
+++ b/src/browser/components/ChatInput/CreationControls.tsx
@@ -5,6 +5,7 @@ import {
type CoderWorkspaceConfig,
type RuntimeMode,
type ParsedRuntime,
+ type RuntimeEnablement,
CODER_RUNTIME_PLACEHOLDER,
} from "@/common/types/runtime";
import type { RuntimeAvailabilityMap, RuntimeAvailabilityState } from "./useCreationWorkspace";
@@ -12,7 +13,6 @@ import {
resolveDevcontainerSelection,
DEFAULT_DEVCONTAINER_CONFIG_PATH,
} from "@/browser/utils/devcontainerSelection";
-import { Select } from "../Select";
import {
Select as RadixSelect,
SelectContent,
@@ -20,9 +20,10 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
-import { Loader2, Wand2, X } from "lucide-react";
+import { GitBranch, Loader2, Wand2, X } from "lucide-react";
import { PlatformPaths } from "@/common/utils/paths";
import { useProjectContext } from "@/browser/contexts/ProjectContext";
+import { useSettings } from "@/browser/contexts/SettingsContext";
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { cn } from "@/common/lib/utils";
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
@@ -130,6 +131,8 @@ interface CreationControlsProps {
nameState: WorkspaceNameState;
/** Runtime availability state for each mode */
runtimeAvailabilityState: RuntimeAvailabilityState;
+ /** Runtime enablement toggles from Settings (hide disabled runtimes). */
+ runtimeEnablement?: RuntimeEnablement;
/** Available sections for this project */
sections?: SectionConfig[];
/** Currently selected section ID */
@@ -161,6 +164,7 @@ interface RuntimeButtonGroupProps {
onSetDefault: (mode: RuntimeChoice) => void;
disabled?: boolean;
runtimeAvailabilityState?: RuntimeAvailabilityState;
+ runtimeEnablement?: RuntimeEnablement;
coderInfo?: CoderInfo | null;
allowedRuntimeModes?: RuntimeMode[] | null;
allowSshHost?: boolean;
@@ -176,6 +180,15 @@ const RUNTIME_CHOICE_ORDER: RuntimeChoice[] = [
RUNTIME_MODE.DEVCONTAINER,
];
+const RUNTIME_FALLBACK_ORDER: RuntimeChoice[] = [
+ RUNTIME_MODE.WORKTREE,
+ RUNTIME_MODE.LOCAL,
+ RUNTIME_MODE.SSH,
+ "coder",
+ RUNTIME_MODE.DOCKER,
+ RUNTIME_MODE.DEVCONTAINER,
+];
+
const RUNTIME_CHOICE_OPTIONS: Array<{
value: RuntimeChoice;
label: string;
@@ -376,6 +389,7 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
const availabilityMap = state?.status === "loaded" ? state.data : null;
const coderInfo = props.coderInfo ?? null;
const coderAvailability = resolveCoderAvailability(coderInfo);
+ const runtimeEnablement = props.runtimeEnablement;
const allowSshHost = props.allowSshHost ?? true;
const allowSshCoder = props.allowSshCoder ?? true;
@@ -414,6 +428,14 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
return false;
}
+ // User request: hide Settings-disabled runtimes (selection auto-switches elsewhere).
+ // Keep the currently active runtime visible even if disabled to avoid trapping the user
+ // when the fallback can't find a replacement (e.g., non-git repo with Local disabled).
+ const isEnablementDisabled = runtimeEnablement?.[option.value] === false;
+ if (isEnablementDisabled && option.value !== props.value) {
+ return false;
+ }
+
const { isPolicyDisabled } = resolveRuntimeButtonState(
option.value,
availabilityMap,
@@ -435,25 +457,25 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
{runtimeOptions.map((option) => {
const isActive = props.value === option.value;
- const { isModeDisabled, isPolicyDisabled, disabledReason, isDefault } =
- resolveRuntimeButtonState(
- option.value,
- availabilityMap,
- props.defaultMode,
- coderAvailability,
- allowedModeSet,
- allowSshHost,
- allowSshCoder
- );
+ const {
+ isModeDisabled,
+ isPolicyDisabled,
+ disabledReason: resolvedDisabledReason,
+ } = resolveRuntimeButtonState(
+ option.value,
+ availabilityMap,
+ props.defaultMode,
+ coderAvailability,
+ allowedModeSet,
+ allowSshHost,
+ allowSshCoder
+ );
+ const disabledReason = resolvedDisabledReason;
const isDisabled = Boolean(props.disabled) || isModeDisabled || isPolicyDisabled;
const showDisabledReason = isModeDisabled || isPolicyDisabled;
const Icon = option.Icon;
- const handleSetDefault = () => {
- props.onSetDefault(option.value);
- };
-
return (
@@ -482,19 +504,10 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
{option.description}
+ {/* User request: remove default-runtime toggle from creation tooltip. */}
{showDisabledReason ? (
{disabledReason ?? "Unavailable"}
- ) : (
-
- )}
+ ) : null}
);
@@ -509,6 +522,7 @@ function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
*/
export function CreationControls(props: CreationControlsProps) {
const { projects } = useProjectContext();
+ const settings = useSettings();
const { beginWorkspaceCreation } = useWorkspaceContext();
const { nameState, runtimeAvailabilityState } = props;
@@ -519,19 +533,14 @@ export function CreationControls(props: CreationControlsProps) {
const isCoderSelected =
selectedRuntime.mode === RUNTIME_MODE.SSH && selectedRuntime.coder != null;
const runtimeChoice: RuntimeChoice = isCoderSelected ? "coder" : runtimeMode;
- const coderUsername =
- props.coderProps?.coderInfo?.state === "available"
- ? props.coderProps.coderInfo.username
- : undefined;
- const coderDeploymentUrl =
- props.coderProps?.coderInfo?.state === "available" ? props.coderProps.coderInfo.url : undefined;
+ const coderInfo = props.coderInfo ?? props.coderProps?.coderInfo ?? null;
+ const coderAvailability = resolveCoderAvailability(coderInfo);
+ const isCoderAvailable = coderAvailability.state === "available";
+ const coderUsername = coderInfo?.state === "available" ? coderInfo.username : undefined;
+ const coderDeploymentUrl = coderInfo?.state === "available" ? coderInfo.url : undefined;
- // Local runtime doesn't need a trunk branch selector (uses project dir as-is)
const availabilityMap =
runtimeAvailabilityState.status === "loaded" ? runtimeAvailabilityState.data : null;
- const showTrunkBranchSelector = props.branches.length > 0 && runtimeMode !== RUNTIME_MODE.LOCAL;
- // Show loading skeleton while branches are loading to avoid layout flash
- const showBranchLoadingPlaceholder = !props.branchesLoaded && runtimeMode !== RUNTIME_MODE.LOCAL;
// Centralized devcontainer selection logic
const devcontainerSelection = resolveDevcontainerSelection({
@@ -549,19 +558,154 @@ export function CreationControls(props: CreationControlsProps) {
availabilityMap.worktree.reason === "Requires git repository") ||
(props.branchesLoaded && props.branches.length === 0);
- // Keep selected runtime aligned with availability constraints
+ const branchOptions =
+ props.trunkBranch && !props.branches.includes(props.trunkBranch)
+ ? [props.trunkBranch, ...props.branches]
+ : props.branches;
+ const isBranchSelectorDisabled =
+ Boolean(props.disabled) || isNonGitRepo || branchOptions.length === 0;
+
+ // Keep selected runtime aligned with availability + Settings enablement constraints.
+ // All constraint checks (non-git, devcontainer missing, enablement, policy) are unified
+ // into a single firstEnabled fallback so every edge combination is handled consistently.
useEffect(() => {
- if (isNonGitRepo) {
- if (selectedRuntime.mode !== RUNTIME_MODE.LOCAL) {
- onSelectedRuntimeChange({ mode: "local" });
+ const runtimeEnablement = props.runtimeEnablement;
+
+ // Determine if the current selection needs correction.
+ const isCurrentDisabledBySettings = runtimeEnablement?.[runtimeChoice] === false;
+ // In non-git repos all modes except Local are unavailable (not just Worktree).
+ const isCurrentUnavailable =
+ (isNonGitRepo && selectedRuntime.mode !== RUNTIME_MODE.LOCAL) ||
+ (isDevcontainerMissing && selectedRuntime.mode === RUNTIME_MODE.DEVCONTAINER);
+
+ if (!isCurrentDisabledBySettings && !isCurrentUnavailable) {
+ return;
+ }
+
+ // Build a policy set matching RuntimeButtonGroup's eligibility logic so the
+ // auto-switch fallback never lands on a policy-forbidden runtime.
+ const allowedModes = props.allowedRuntimeModes
+ ? new Set(props.allowedRuntimeModes)
+ : null;
+
+ const firstEnabled = RUNTIME_FALLBACK_ORDER.find((mode) => {
+ if (runtimeEnablement?.[mode] === false) {
+ return false;
+ }
+ if (mode === "coder") {
+ if (!props.coderProps) {
+ return false;
+ }
+ if (!isCoderAvailable) {
+ return false;
+ }
+ }
+ // Filter by availability to avoid selecting unavailable runtimes (e.g., Docker
+ // when daemon is down, devcontainer when config missing, non-git projects).
+ if (isDevcontainerMissing && mode === RUNTIME_MODE.DEVCONTAINER) {
+ return false;
}
+ if (isNonGitRepo && mode !== RUNTIME_MODE.LOCAL) {
+ return false;
+ }
+ // Check the general availability map for any other unavailable runtimes.
+ if (mode !== "coder") {
+ const avail = availabilityMap?.[mode];
+ if (avail !== undefined && !avail.available) {
+ return false;
+ }
+ }
+ // Filter by policy constraints to avoid selecting a blocked runtime.
+ if (allowedModes) {
+ if (mode === "coder" && !(props.allowSshCoder ?? true)) {
+ return false;
+ }
+ if (mode === RUNTIME_MODE.SSH && !(props.allowSshHost ?? true)) {
+ return false;
+ }
+ if (mode !== "coder" && mode !== RUNTIME_MODE.SSH && !allowedModes.has(mode)) {
+ return false;
+ }
+ }
+ return true;
+ });
+ if (!firstEnabled || firstEnabled === runtimeChoice) {
+ return;
+ }
+
+ // User request: auto-switch away from Settings-disabled runtimes.
+ if (firstEnabled === "coder") {
+ if (!props.coderProps || !isCoderAvailable) {
+ return;
+ }
+ onSelectedRuntimeChange({
+ mode: "ssh",
+ host: CODER_RUNTIME_PLACEHOLDER,
+ coder: props.coderConfigFallback,
+ });
return;
}
- if (isDevcontainerMissing && selectedRuntime.mode === RUNTIME_MODE.DEVCONTAINER) {
- onSelectedRuntimeChange({ mode: "worktree" });
+ switch (firstEnabled) {
+ case RUNTIME_MODE.SSH: {
+ const sshHost =
+ selectedRuntime.mode === RUNTIME_MODE.SSH &&
+ selectedRuntime.host !== CODER_RUNTIME_PLACEHOLDER
+ ? selectedRuntime.host
+ : props.sshHostFallback;
+ onSelectedRuntimeChange({
+ mode: "ssh",
+ host: sshHost,
+ });
+ return;
+ }
+ case RUNTIME_MODE.DOCKER:
+ onSelectedRuntimeChange({
+ mode: "docker",
+ image: selectedRuntime.mode === "docker" ? selectedRuntime.image : "",
+ });
+ return;
+ case RUNTIME_MODE.DEVCONTAINER: {
+ const initialSelection = resolveDevcontainerSelection({
+ selectedRuntime: { mode: "devcontainer", configPath: "" },
+ availabilityState: runtimeAvailabilityState,
+ });
+ onSelectedRuntimeChange({
+ mode: "devcontainer",
+ configPath:
+ selectedRuntime.mode === "devcontainer"
+ ? selectedRuntime.configPath
+ : initialSelection.configPath,
+ shareCredentials:
+ selectedRuntime.mode === "devcontainer" ? selectedRuntime.shareCredentials : false,
+ });
+ return;
+ }
+ case RUNTIME_MODE.LOCAL:
+ onSelectedRuntimeChange({ mode: "local" });
+ return;
+ case RUNTIME_MODE.WORKTREE:
+ default:
+ onSelectedRuntimeChange({ mode: "worktree" });
+ return;
}
- }, [isDevcontainerMissing, isNonGitRepo, selectedRuntime.mode, onSelectedRuntimeChange]);
+ }, [
+ isDevcontainerMissing,
+ isNonGitRepo,
+ onSelectedRuntimeChange,
+ props.coderConfigFallback,
+ props.coderProps,
+ props.runtimeEnablement,
+ props.sshHostFallback,
+ props.allowedRuntimeModes,
+ props.allowSshHost,
+ props.allowSshCoder,
+ availabilityMap,
+ runtimeAvailabilityState,
+ runtimeChoice,
+ selectedRuntime,
+ isCoderAvailable,
+ ]);
const handleNameChange = useCallback(
(e: React.ChangeEvent) => {
@@ -698,7 +842,18 @@ export function CreationControls(props: CreationControlsProps) {
{/* Runtime type - button group */}
-
+ {/* User request: keep the configure shortcut but render it in muted gray. */}
+
+
+ -
+
+
- {/* Branch selector - shown for worktree/SSH */}
- {showTrunkBranchSelector && (
-
-
-
- )}
- {/* Loading placeholder - reserves space while branches load to avoid layout flash */}
- {showBranchLoadingPlaceholder && (
-
- )}
+ onValueChange={props.onTrunkBranchChange}
+ disabled={isBranchSelectorDisabled}
+ >
+
+
+
+
+ {branchOptions.map((branch) => (
+
+ {branch}
+
+ ))}
+
+
+ ) : (
+
+ )}
+
{/* SSH Host Input - hidden when Coder runtime is selected */}
{selectedRuntime.mode === "ssh" &&
diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx
index d540f5d573..fd87218aa5 100644
--- a/src/browser/components/ChatInput/index.tsx
+++ b/src/browser/components/ChatInput/index.tsx
@@ -44,6 +44,7 @@ import {
getInputAttachmentsKey,
AGENT_AI_DEFAULTS_KEY,
VIM_ENABLED_KEY,
+ RUNTIME_ENABLEMENT_KEY,
getProjectScopeId,
getPendingScopeId,
getDraftScopeId,
@@ -95,6 +96,7 @@ import type { PendingUserMessage } from "@/browser/utils/chatEditing";
import type { AgentSkillDescriptor } from "@/common/types/agentSkill";
import type { AgentAiDefaults } from "@/common/types/agentAiDefaults";
import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking";
+import { DEFAULT_RUNTIME_ENABLEMENT, normalizeRuntimeEnablement } from "@/common/types/runtime";
import { resolveThinkingInput } from "@/common/utils/thinking/policy";
import {
type MuxFrontendMetadata,
@@ -208,6 +210,14 @@ const ChatInputInner: React.FC
= (props) => {
};
})();
+ // User request: keep creation runtime controls synced with Settings enablement toggles.
+ const [rawRuntimeEnablement] = usePersistedState(
+ RUNTIME_ENABLEMENT_KEY,
+ DEFAULT_RUNTIME_ENABLEMENT,
+ { listener: true }
+ );
+ const runtimeEnablement = normalizeRuntimeEnablement(rawRuntimeEnablement);
+
const [input, setInput] = usePersistedState(storageKeys.inputKey, "", { listener: true });
// Keep a stable reference to the latest input value so event handlers don't need to rebind
@@ -627,6 +637,15 @@ const ChatInputInner: React.FC = (props) => {
const { projects } = useProjectContext();
const pendingSectionId = variant === "creation" ? (props.pendingSectionId ?? null) : null;
const creationProject = variant === "creation" ? projects.get(props.projectPath) : undefined;
+ const hasCreationRuntimeOverrides =
+ creationProject?.runtimeOverridesEnabled === true ||
+ Boolean(creationProject?.runtimeEnablement) ||
+ creationProject?.defaultRuntime !== undefined;
+ // Keep workspace creation in sync with Settings → Runtimes project overrides.
+ const creationRuntimeEnablement =
+ variant === "creation" && hasCreationRuntimeOverrides
+ ? normalizeRuntimeEnablement(creationProject?.runtimeEnablement)
+ : runtimeEnablement;
const creationSections = creationProject?.sections ?? [];
const [selectedSectionId, setSelectedSectionId] = useState(() => pendingSectionId);
@@ -769,6 +788,7 @@ const ChatInputInner: React.FC = (props) => {
projectName: props.projectName,
nameState: creationState.nameState,
runtimeAvailabilityState: creationState.runtimeAvailabilityState,
+ runtimeEnablement: creationRuntimeEnablement,
sections: creationSections,
selectedSectionId,
onSectionChange: handleCreationSectionChange,
diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx
index d124a98768..a1fb95c049 100644
--- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx
+++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx
@@ -1,4 +1,5 @@
import type { APIClient } from "@/browser/contexts/API";
+import { ProjectProvider } from "@/browser/contexts/ProjectContext";
import type { DraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
import {
getAgentIdKey,
@@ -161,7 +162,10 @@ type WorkspaceUpdateAgentAISettingsResult = Awaited<
type WorkspaceCreateResult = Awaited>;
type NameGenerationArgs = Parameters[0];
type NameGenerationResult = Awaited>;
-type MockOrpcProjectsClient = Pick;
+type MockOrpcProjectsClient = Pick<
+ APIClient["projects"],
+ "list" | "listBranches" | "runtimeAvailability"
+>;
type MockOrpcWorkspaceClient = Pick<
APIClient["workspace"],
"sendMessage" | "create" | "updateAgentAISettings"
@@ -266,6 +270,7 @@ const setupWindow = ({
currentORPCClient = {
projects: {
+ list: () => Promise.resolve([]),
listBranches: (input: ListBranchesArgs) => listBranchesMock(input),
runtimeAvailability: () =>
Promise.resolve({
@@ -881,7 +886,11 @@ function renderUseCreationWorkspace(options: HookOptions) {
return null;
}
- render();
+ render(
+
+
+
+ );
return () => {
if (!resultRef.current) {
diff --git a/src/browser/components/Settings/SettingsPage.tsx b/src/browser/components/Settings/SettingsPage.tsx
index 85b7df5119..12f4faf5b8 100644
--- a/src/browser/components/Settings/SettingsPage.tsx
+++ b/src/browser/components/Settings/SettingsPage.tsx
@@ -10,6 +10,7 @@ import {
Bot,
Keyboard,
Layout,
+ Container,
BrainCircuit,
ShieldCheck,
Server,
@@ -28,6 +29,7 @@ import { Button } from "@/browser/components/ui/button";
import { MCPSettingsSection } from "./sections/MCPSettingsSection";
import { SecretsSection } from "./sections/SecretsSection";
import { LayoutsSection } from "./sections/LayoutsSection";
+import { RuntimesSection } from "./sections/RuntimesSection";
import { ExperimentsSection } from "./sections/ExperimentsSection";
import { KeybindsSection } from "./sections/KeybindsSection";
import type { SettingsSection } from "./types";
@@ -75,6 +77,12 @@ const BASE_SECTIONS: SettingsSection[] = [
icon: ,
component: LayoutsSection,
},
+ {
+ id: "runtimes",
+ label: "Runtimes",
+ icon: ,
+ component: RuntimesSection,
+ },
{
id: "experiments",
label: "Experiments",
diff --git a/src/browser/components/Settings/sections/RuntimesSection.tsx b/src/browser/components/Settings/sections/RuntimesSection.tsx
new file mode 100644
index 0000000000..909e80b657
--- /dev/null
+++ b/src/browser/components/Settings/sections/RuntimesSection.tsx
@@ -0,0 +1,549 @@
+import { useEffect, useRef, useState } from "react";
+import { AlertTriangle, Loader2 } from "lucide-react";
+
+import { resolveCoderAvailability } from "@/browser/components/ChatInput/CoderControls";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/browser/components/ui/select";
+import { Switch } from "@/browser/components/ui/switch";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/ui/tooltip";
+import { useAPI } from "@/browser/contexts/API";
+import { useProjectContext } from "@/browser/contexts/ProjectContext";
+import { useRuntimeEnablement } from "@/browser/hooks/useRuntimeEnablement";
+import { RUNTIME_CHOICE_UI, type RuntimeUiSpec } from "@/browser/utils/runtimeUi";
+import { cn } from "@/common/lib/utils";
+import type { CoderInfo } from "@/common/orpc/schemas/coder";
+import type {
+ RuntimeAvailabilityStatus,
+ RuntimeEnablement,
+ RuntimeEnablementId,
+ RuntimeMode,
+} from "@/common/types/runtime";
+import { normalizeRuntimeEnablement } from "@/common/types/runtime";
+
+type RuntimeAvailabilityMap = Record;
+
+type RuntimeRow = { id: RuntimeEnablementId } & RuntimeUiSpec;
+
+type RuntimeAvailabilityState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "failed" }
+ | { status: "loaded"; data: RuntimeAvailabilityMap };
+
+interface RuntimeOverrideCacheEntry {
+ enablement: RuntimeEnablement;
+ defaultRuntime: RuntimeEnablementId | null;
+ pending: boolean;
+}
+
+const ALL_SCOPE_VALUE = "__all__";
+
+const RUNTIME_ROWS: RuntimeRow[] = [
+ { id: "local", ...RUNTIME_CHOICE_UI.local },
+ { id: "worktree", ...RUNTIME_CHOICE_UI.worktree },
+ { id: "ssh", ...RUNTIME_CHOICE_UI.ssh },
+ { id: "coder", ...RUNTIME_CHOICE_UI.coder },
+ { id: "docker", ...RUNTIME_CHOICE_UI.docker },
+ { id: "devcontainer", ...RUNTIME_CHOICE_UI.devcontainer },
+];
+
+function getProjectLabel(path: string) {
+ return path.split(/[\\/]/).pop() ?? path;
+}
+
+function getFallbackRuntime(enablement: RuntimeEnablement): RuntimeEnablementId | null {
+ return RUNTIME_ROWS.find((runtime) => enablement[runtime.id])?.id ?? null;
+}
+
+export function RuntimesSection() {
+ const { api } = useAPI();
+ const { projects, refreshProjects } = useProjectContext();
+ const { enablement, setRuntimeEnabled, defaultRuntime, setDefaultRuntime } =
+ useRuntimeEnablement();
+
+ const projectList = Array.from(projects.keys());
+
+ const [selectedScope, setSelectedScope] = useState(ALL_SCOPE_VALUE);
+ const [projectOverrideEnabled, setProjectOverrideEnabled] = useState(false);
+ const [projectEnablement, setProjectEnablement] = useState(enablement);
+ const [projectDefaultRuntime, setProjectDefaultRuntime] = useState(
+ defaultRuntime
+ );
+ const [runtimeAvailabilityState, setRuntimeAvailabilityState] =
+ useState({ status: "idle" });
+ const [coderInfo, setCoderInfo] = useState(null);
+ const [overrideCacheVersion, setOverrideCacheVersion] = useState(0);
+ // Cache pending per-project overrides locally while config updates propagate.
+ const overrideCacheRef = useRef(new Map());
+
+ const selectedProjectPath = selectedScope === ALL_SCOPE_VALUE ? null : selectedScope;
+ const isProjectScope = Boolean(selectedProjectPath);
+ const isProjectOverrideActive = isProjectScope && projectOverrideEnabled;
+
+ const syncProjects = () =>
+ refreshProjects().catch(() => {
+ // Best-effort only.
+ });
+
+ const queueProjectOverrideUpdate = (
+ projectPath: string,
+ entry: RuntimeOverrideCacheEntry,
+ payload: {
+ projectPath: string;
+ runtimeEnablement?: RuntimeEnablement | null;
+ defaultRuntime?: RuntimeEnablementId | null;
+ runtimeOverridesEnabled?: boolean | null;
+ }
+ ) => {
+ overrideCacheRef.current.set(projectPath, entry);
+ const updatePromise = api?.config?.updateRuntimeEnablement(payload);
+ if (!updatePromise) {
+ overrideCacheRef.current.set(projectPath, { ...entry, pending: false });
+ setOverrideCacheVersion((prev) => prev + 1);
+ return;
+ }
+
+ updatePromise
+ .finally(() =>
+ syncProjects().finally(() => {
+ if (overrideCacheRef.current.get(projectPath) === entry) {
+ overrideCacheRef.current.set(projectPath, { ...entry, pending: false });
+ setOverrideCacheVersion((prev) => prev + 1);
+ }
+ })
+ )
+ .catch(() => {
+ // Best-effort only.
+ });
+ };
+
+ useEffect(() => {
+ if (selectedScope === ALL_SCOPE_VALUE) {
+ return;
+ }
+
+ if (!projects.has(selectedScope)) {
+ setSelectedScope(ALL_SCOPE_VALUE);
+ }
+ }, [projects, selectedScope]);
+
+ useEffect(() => {
+ if (!selectedProjectPath) {
+ setProjectOverrideEnabled(false);
+ setProjectEnablement(enablement);
+ setProjectDefaultRuntime(defaultRuntime ?? null);
+ return;
+ }
+
+ const cached = overrideCacheRef.current.get(selectedProjectPath);
+ if (cached?.pending) {
+ setProjectOverrideEnabled(true);
+ setProjectEnablement(cached.enablement);
+ setProjectDefaultRuntime(cached.defaultRuntime ?? defaultRuntime ?? null);
+ return;
+ }
+
+ const projectConfig = projects.get(selectedProjectPath);
+ const hasOverrides =
+ projectConfig?.runtimeOverridesEnabled === true ||
+ Boolean(projectConfig?.runtimeEnablement) ||
+ projectConfig?.defaultRuntime !== undefined;
+
+ if (hasOverrides) {
+ const resolvedEnablement = normalizeRuntimeEnablement(projectConfig?.runtimeEnablement);
+ setProjectOverrideEnabled(true);
+ // When overrides are active, the project's enablement is independent of global settings
+ // (all-true defaults + only the project's explicit `false` overrides). This matches
+ // the creation flow in ChatInput/index.tsx which uses normalizeRuntimeEnablement directly.
+ setProjectEnablement(resolvedEnablement);
+ setProjectDefaultRuntime(projectConfig?.defaultRuntime ?? defaultRuntime ?? null);
+ overrideCacheRef.current.set(selectedProjectPath, {
+ enablement: resolvedEnablement,
+ defaultRuntime: projectConfig?.defaultRuntime ?? null,
+ pending: false,
+ });
+ return;
+ }
+
+ overrideCacheRef.current.delete(selectedProjectPath);
+ setProjectOverrideEnabled(false);
+ setProjectEnablement(enablement);
+ setProjectDefaultRuntime(defaultRuntime ?? null);
+ }, [defaultRuntime, enablement, projects, selectedProjectPath, overrideCacheVersion]);
+
+ useEffect(() => {
+ if (!api || !selectedProjectPath) {
+ setRuntimeAvailabilityState({ status: "idle" });
+ return;
+ }
+
+ let active = true;
+ setRuntimeAvailabilityState({ status: "loading" });
+
+ api.projects
+ .runtimeAvailability({ projectPath: selectedProjectPath })
+ .then((availability) => {
+ if (active) {
+ setRuntimeAvailabilityState({ status: "loaded", data: availability });
+ }
+ })
+ .catch(() => {
+ if (active) {
+ setRuntimeAvailabilityState({ status: "failed" });
+ }
+ });
+
+ return () => {
+ active = false;
+ };
+ }, [api, selectedProjectPath]);
+
+ useEffect(() => {
+ if (!api) {
+ setCoderInfo(null);
+ return;
+ }
+
+ let active = true;
+
+ api.coder
+ .getInfo()
+ .then((info) => {
+ if (active) {
+ setCoderInfo(info);
+ }
+ })
+ .catch(() => {
+ if (active) {
+ setCoderInfo({
+ state: "unavailable",
+ reason: { kind: "error", message: "Failed to fetch" },
+ });
+ }
+ });
+
+ return () => {
+ active = false;
+ };
+ }, [api]);
+
+ const coderAvailability = resolveCoderAvailability(coderInfo);
+ const availabilityMap =
+ runtimeAvailabilityState.status === "loaded" ? runtimeAvailabilityState.data : null;
+
+ const effectiveEnablement = isProjectOverrideActive ? projectEnablement : enablement;
+ const effectiveDefaultRuntime = isProjectOverrideActive ? projectDefaultRuntime : defaultRuntime;
+
+ const enabledRuntimeOptions = RUNTIME_ROWS.filter((runtime) => effectiveEnablement[runtime.id]);
+ const enabledRuntimeCount = enabledRuntimeOptions.length;
+
+ const defaultRuntimeValue =
+ effectiveDefaultRuntime && effectiveEnablement[effectiveDefaultRuntime]
+ ? effectiveDefaultRuntime
+ : "";
+ const defaultRuntimePlaceholder =
+ enabledRuntimeOptions.length === 0 ? "No runtimes enabled" : "Select default runtime";
+ const defaultRuntimeDisabled =
+ enabledRuntimeOptions.length === 0 || (isProjectScope && !projectOverrideEnabled);
+
+ const handleOverrideToggle = (checked: boolean) => {
+ if (!selectedProjectPath) {
+ return;
+ }
+
+ if (!checked) {
+ setProjectOverrideEnabled(false);
+ setProjectEnablement(enablement);
+ setProjectDefaultRuntime(defaultRuntime ?? null);
+ overrideCacheRef.current.delete(selectedProjectPath);
+ const updatePromise = api?.config?.updateRuntimeEnablement({
+ projectPath: selectedProjectPath,
+ runtimeEnablement: null,
+ defaultRuntime: null,
+ runtimeOverridesEnabled: null,
+ });
+ if (!updatePromise) {
+ return;
+ }
+ updatePromise
+ .finally(() => {
+ void syncProjects();
+ })
+ .catch(() => {
+ // Best-effort only.
+ });
+ return;
+ }
+
+ const nextEnablement = { ...enablement };
+ const cacheEntry: RuntimeOverrideCacheEntry = {
+ enablement: nextEnablement,
+ defaultRuntime: defaultRuntime ?? null,
+ pending: true,
+ };
+ setProjectOverrideEnabled(true);
+ setProjectEnablement(nextEnablement);
+ setProjectDefaultRuntime(defaultRuntime ?? null);
+
+ queueProjectOverrideUpdate(selectedProjectPath, cacheEntry, {
+ projectPath: selectedProjectPath,
+ runtimeEnablement: nextEnablement,
+ defaultRuntime: defaultRuntime ?? null,
+ runtimeOverridesEnabled: true,
+ });
+ };
+
+ const handleRuntimeToggle = (runtimeId: RuntimeEnablementId, enabled: boolean) => {
+ if (!enabled) {
+ // Keep at least one runtime enabled to avoid leaving users without a fallback.
+ const currentEnabledCount = RUNTIME_ROWS.filter(
+ (runtime) => effectiveEnablement[runtime.id]
+ ).length;
+ if (currentEnabledCount <= 1) {
+ return;
+ }
+ }
+
+ const nextEnablement: RuntimeEnablement = {
+ ...effectiveEnablement,
+ [runtimeId]: enabled,
+ };
+
+ if (!isProjectScope) {
+ setRuntimeEnabled(runtimeId, enabled);
+ if (defaultRuntime && !nextEnablement[defaultRuntime]) {
+ setDefaultRuntime(getFallbackRuntime(nextEnablement));
+ }
+ return;
+ }
+
+ if (!selectedProjectPath || !projectOverrideEnabled) {
+ return;
+ }
+
+ setProjectEnablement(nextEnablement);
+
+ let nextDefaultRuntime = projectDefaultRuntime ?? null;
+ if (nextDefaultRuntime && !nextEnablement[nextDefaultRuntime]) {
+ nextDefaultRuntime = getFallbackRuntime(nextEnablement);
+ setProjectDefaultRuntime(nextDefaultRuntime);
+ }
+ const cacheEntry: RuntimeOverrideCacheEntry = {
+ enablement: nextEnablement,
+ defaultRuntime: nextDefaultRuntime,
+ pending: true,
+ };
+
+ const updatePayload: {
+ projectPath: string;
+ runtimeEnablement: RuntimeEnablement;
+ defaultRuntime?: RuntimeEnablementId | null;
+ runtimeOverridesEnabled?: boolean;
+ } = {
+ projectPath: selectedProjectPath,
+ runtimeEnablement: nextEnablement,
+ runtimeOverridesEnabled: true,
+ };
+
+ if (nextDefaultRuntime !== projectDefaultRuntime) {
+ updatePayload.defaultRuntime = nextDefaultRuntime ?? null;
+ }
+
+ queueProjectOverrideUpdate(selectedProjectPath, cacheEntry, updatePayload);
+ };
+
+ const handleDefaultRuntimeChange = (value: string) => {
+ const runtimeId = value as RuntimeEnablementId;
+
+ if (!isProjectScope) {
+ setDefaultRuntime(runtimeId);
+ return;
+ }
+
+ if (!selectedProjectPath || !projectOverrideEnabled) {
+ return;
+ }
+
+ setProjectDefaultRuntime(runtimeId);
+ const cacheEntry: RuntimeOverrideCacheEntry = {
+ enablement: projectEnablement,
+ defaultRuntime: runtimeId,
+ pending: true,
+ };
+ queueProjectOverrideUpdate(selectedProjectPath, cacheEntry, {
+ projectPath: selectedProjectPath,
+ defaultRuntime: runtimeId,
+ runtimeOverridesEnabled: true,
+ });
+ };
+
+ return (
+
+
+
+
+
Scope
+
Manage runtimes globally or per project.
+
+
+
+
+ {isProjectScope ? (
+
+
+
Override project settings
+
+ Keep global defaults or customize enabled runtimes for this project.
+
+
+
+
+ ) : null}
+
+
+
+
+
+
Default runtime
+
+ {isProjectScope
+ ? "Applied to new workspaces in this project."
+ : "Applied to new workspaces by default."}
+
+
+
+
+
+
+ {RUNTIME_ROWS.map((runtime) => {
+ const Icon = runtime.Icon;
+ const isCoder = runtime.id === "coder";
+ const availability = isCoder
+ ? null
+ : (availabilityMap?.[runtime.id as RuntimeMode] ?? null);
+ const availabilityReason = isProjectScope
+ ? isCoder
+ ? coderAvailability.state !== "available" && coderAvailability.state !== "loading"
+ ? coderAvailability.reason
+ : null
+ : availability && !availability.available
+ ? availability.reason
+ : null
+ : null;
+ const showLoading = isProjectScope
+ ? isCoder
+ ? coderAvailability.state === "loading"
+ : runtimeAvailabilityState.status === "loading"
+ : false;
+ const rowDisabled = isProjectScope && !projectOverrideEnabled;
+ const isLastEnabled = effectiveEnablement[runtime.id] && enabledRuntimeCount <= 1;
+ const switchDisabled = rowDisabled || isLastEnabled;
+ const switchControl = (
+
handleRuntimeToggle(runtime.id, checked)}
+ aria-label={`Toggle ${runtime.label} runtime`}
+ />
+ );
+ const switchNode =
+ isLastEnabled && !rowDisabled ? (
+
+
+ {switchControl}
+
+ At least one runtime must be enabled.
+
+ ) : (
+ switchControl
+ );
+
+ // Inline status indicators keep availability feedback from shifting row layout.
+ return (
+
+
+
+
+
+
{runtime.label}
+ {availabilityReason ? (
+
+
+
+
+ Unavailable
+
+
+
+ {availabilityReason}
+
+
+ ) : null}
+
+
{runtime.description}
+
+
+
+
+ {showLoading ? : null}
+
+ {switchNode}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx
index c751ac9c94..0d1693cf10 100644
--- a/src/browser/contexts/WorkspaceContext.tsx
+++ b/src/browser/contexts/WorkspaceContext.tsx
@@ -33,6 +33,8 @@ import {
GATEWAY_MODELS_KEY,
HIDDEN_MODELS_KEY,
PREFERRED_COMPACTION_MODEL_KEY,
+ RUNTIME_ENABLEMENT_KEY,
+ DEFAULT_RUNTIME_KEY,
SELECTED_WORKSPACE_KEY,
WORKSPACE_DRAFTS_BY_PROJECT_KEY,
} from "@/common/constants/storage";
@@ -485,6 +487,16 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
updatePersistedState(PREFERRED_COMPACTION_MODEL_KEY, cfg.preferredCompactionModel);
}
+ // Seed runtime enablement from backend so switching ports doesn't reset the UI.
+ if (cfg.runtimeEnablement !== undefined) {
+ updatePersistedState(RUNTIME_ENABLEMENT_KEY, cfg.runtimeEnablement);
+ }
+
+ // Seed global default runtime so workspace defaults survive port changes.
+ if (cfg.defaultRuntime !== undefined) {
+ updatePersistedState(DEFAULT_RUNTIME_KEY, cfg.defaultRuntime);
+ }
+
// One-time best-effort migration: if the backend doesn't have gateway prefs yet,
// persist non-default localStorage values so future port changes keep them.
if (api.config.updateMuxGatewayPrefs) {
diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx
index fb80d461e9..877ab53d35 100644
--- a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx
+++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx
@@ -3,9 +3,14 @@ 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 { ProjectProvider } from "@/browser/contexts/ProjectContext";
import { ThinkingProvider } from "@/browser/contexts/ThinkingContext";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
-import { getLastRuntimeConfigKey, getRuntimeKey } from "@/common/constants/storage";
+import {
+ DEFAULT_RUNTIME_KEY,
+ getLastRuntimeConfigKey,
+ getRuntimeKey,
+} from "@/common/constants/storage";
import { CODER_RUNTIME_PLACEHOLDER } from "@/common/types/runtime";
import { useDraftWorkspaceSettings } from "./useDraftWorkspaceSettings";
@@ -21,6 +26,10 @@ function createStubApiClient(): APIClient {
getConfig: () => Promise.resolve({}),
onConfigChanged: () => Promise.resolve(empty()),
},
+ // ProjectProvider calls api.projects.list() on mount.
+ projects: {
+ list: () => Promise.resolve([]),
+ },
} as unknown as APIClient;
}
@@ -43,7 +52,9 @@ describe("useDraftWorkspaceSettings", () => {
const wrapper: React.FC<{ children: React.ReactNode }> = (props) => (
- {props.children}
+
+ {props.children}
+
);
@@ -69,7 +80,9 @@ describe("useDraftWorkspaceSettings", () => {
const wrapper: React.FC<{ children: React.ReactNode }> = (props) => (
- {props.children}
+
+ {props.children}
+
);
@@ -99,7 +112,9 @@ describe("useDraftWorkspaceSettings", () => {
const wrapper: React.FC<{ children: React.ReactNode }> = (props) => (
- {props.children}
+
+ {props.children}
+
);
@@ -124,7 +139,8 @@ describe("useDraftWorkspaceSettings", () => {
test("keeps Coder default even after plain SSH usage", async () => {
const projectPath = "/tmp/project";
- updatePersistedState(getRuntimeKey(projectPath), `ssh ${CODER_RUNTIME_PLACEHOLDER}`);
+ updatePersistedState(DEFAULT_RUNTIME_KEY, "coder");
+ updatePersistedState(getRuntimeKey(projectPath), "ssh dev@host");
updatePersistedState(getLastRuntimeConfigKey(projectPath), {
ssh: {
host: "dev@host",
@@ -135,7 +151,9 @@ describe("useDraftWorkspaceSettings", () => {
const wrapper: React.FC<{ children: React.ReactNode }> = (props) => (
- {props.children}
+
+ {props.children}
+
);
@@ -166,7 +184,9 @@ describe("useDraftWorkspaceSettings", () => {
const wrapper: React.FC<{ children: React.ReactNode }> = (props) => (
- {props.children}
+
+ {props.children}
+
);
@@ -207,7 +227,9 @@ describe("useDraftWorkspaceSettings", () => {
const wrapper: React.FC<{ children: React.ReactNode }> = (props) => (
- {props.children}
+
+ {props.children}
+
);
@@ -231,7 +253,9 @@ describe("useDraftWorkspaceSettings", () => {
const wrapper: React.FC<{ children: React.ReactNode }> = (props) => (
- {props.children}
+
+ {props.children}
+
);
diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts
index b908741998..5a72888e62 100644
--- a/src/browser/hooks/useDraftWorkspaceSettings.ts
+++ b/src/browser/hooks/useDraftWorkspaceSettings.ts
@@ -2,11 +2,11 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { readPersistedState, usePersistedState } from "./usePersistedState";
import { useThinkingLevel } from "./useThinkingLevel";
import { migrateGatewayModel } from "./useGatewayModels";
+import { useProjectContext } from "@/browser/contexts/ProjectContext";
import {
type RuntimeMode,
type ParsedRuntime,
type CoderWorkspaceConfig,
- parseRuntimeModeAndHost,
buildRuntimeString,
RUNTIME_MODE,
CODER_RUNTIME_PLACEHOLDER,
@@ -14,6 +14,7 @@ import {
import type { RuntimeChoice } from "@/browser/utils/runtimeUi";
import {
DEFAULT_MODEL_KEY,
+ DEFAULT_RUNTIME_KEY,
getAgentIdKey,
getModelKey,
getRuntimeKey,
@@ -96,6 +97,38 @@ const buildRuntimeForMode = (
}
};
+const normalizeRuntimeChoice = (value: unknown): RuntimeChoice | null => {
+ if (
+ value === "coder" ||
+ value === RUNTIME_MODE.LOCAL ||
+ value === RUNTIME_MODE.WORKTREE ||
+ value === RUNTIME_MODE.SSH ||
+ value === RUNTIME_MODE.DOCKER ||
+ value === RUNTIME_MODE.DEVCONTAINER
+ ) {
+ return value;
+ }
+
+ return null;
+};
+
+const buildRuntimeFromChoice = (choice: RuntimeChoice): ParsedRuntime => {
+ switch (choice) {
+ case "coder":
+ return { mode: RUNTIME_MODE.SSH, host: CODER_RUNTIME_PLACEHOLDER };
+ case RUNTIME_MODE.LOCAL:
+ return { mode: RUNTIME_MODE.LOCAL };
+ case RUNTIME_MODE.WORKTREE:
+ return { mode: RUNTIME_MODE.WORKTREE };
+ case RUNTIME_MODE.SSH:
+ return { mode: RUNTIME_MODE.SSH, host: "" };
+ case RUNTIME_MODE.DOCKER:
+ return { mode: RUNTIME_MODE.DOCKER, image: "" };
+ case RUNTIME_MODE.DEVCONTAINER:
+ return { mode: RUNTIME_MODE.DEVCONTAINER, configPath: "" };
+ }
+};
+
/**
* Hook to manage all draft workspace settings with centralized persistence
* Loads saved preferences when projectPath changes, persists all changes automatically
@@ -126,6 +159,8 @@ export function useDraftWorkspaceSettings(
const [thinkingLevel] = useThinkingLevel();
const projectScopeId = getProjectScopeId(projectPath);
+ const { projects } = useProjectContext();
+ const projectConfig = projects.get(projectPath);
const [agentId] = usePersistedState(
getAgentIdKey(projectScopeId),
@@ -153,15 +188,32 @@ export function useDraftWorkspaceSettings(
: defaultModel
);
- // Project-scoped default runtime (worktree by default, only changed via checkbox)
- const [defaultRuntimeString, setDefaultRuntimeString] = usePersistedState(
+ const [rawGlobalDefaultRuntime] = usePersistedState(DEFAULT_RUNTIME_KEY, null, {
+ listener: true,
+ });
+ const globalDefaultRuntime = normalizeRuntimeChoice(rawGlobalDefaultRuntime);
+
+ // Project-scoped default runtime (persisted when the creation tooltip checkbox is used).
+ // Legacy per-project default (only write-side used by setDefaultRuntimeChoice; reads
+ // now come from settingsDefaultRuntime above).
+ const [, setDefaultRuntimeString] = usePersistedState(
getRuntimeKey(projectPath),
- undefined, // undefined means worktree (the app default)
+ undefined,
{ listener: true }
);
- // Parse default runtime string into structured form (worktree when undefined or invalid)
- const parsedDefault = parseRuntimeModeAndHost(defaultRuntimeString);
+ const hasProjectRuntimeOverrides =
+ projectConfig?.runtimeOverridesEnabled === true ||
+ Boolean(projectConfig?.runtimeEnablement) ||
+ projectConfig?.defaultRuntime !== undefined;
+ const settingsDefaultRuntime: RuntimeChoice = hasProjectRuntimeOverrides
+ ? (projectConfig?.defaultRuntime ?? globalDefaultRuntime ?? RUNTIME_MODE.WORKTREE)
+ : (globalDefaultRuntime ?? RUNTIME_MODE.WORKTREE);
+
+ // Always use the Settings-configured default as the canonical source of truth.
+ // The old per-project localStorage key (getRuntimeKey) is now stale since the creation
+ // tooltip default toggle was removed; new defaults come from the Runtimes settings panel.
+ const parsedDefault = buildRuntimeFromChoice(settingsDefaultRuntime);
const defaultRuntimeMode: RuntimeMode = parsedDefault?.mode ?? RUNTIME_MODE.WORKTREE;
// Project-scoped trunk branch preference (persisted per project)
@@ -320,15 +372,22 @@ export function useDraftWorkspaceSettings(
]);
const defaultSshHost =
- parsedDefault?.mode === RUNTIME_MODE.SSH ? parsedDefault.host : lastSsh.host;
+ parsedDefault?.mode === RUNTIME_MODE.SSH && parsedDefault.host.trim()
+ ? parsedDefault.host
+ : lastSsh.host;
- // When the persisted default says "Coder", reuse the saved config even if last-used SSH disabled it.
+ // When the settings default says "Coder", reuse the saved config even if last-used SSH disabled it.
+ // When settings say plain "ssh", don't reattach the last-used coder config.
const defaultSshCoder = coderDefaultFromString
? (lastSshState.coderConfig ?? DEFAULT_CODER_CONFIG)
- : lastSsh.coder;
+ : settingsDefaultRuntime === RUNTIME_MODE.SSH
+ ? undefined
+ : lastSsh.coder;
const defaultDockerImage =
- parsedDefault?.mode === RUNTIME_MODE.DOCKER ? parsedDefault.image : lastDockerImage;
+ parsedDefault?.mode === RUNTIME_MODE.DOCKER && parsedDefault.image.trim()
+ ? parsedDefault.image
+ : lastDockerImage;
const defaultDevcontainerConfigPath =
parsedDefault?.mode === RUNTIME_MODE.DEVCONTAINER && parsedDefault.configPath.trim()
@@ -349,13 +408,15 @@ export function useDraftWorkspaceSettings(
const [selectedRuntime, setSelectedRuntimeState] = useState(() => defaultRuntime);
const prevProjectPathRef = useRef(null);
- const prevDefaultRuntimeModeRef = useRef(null);
+ // Track settingsDefaultRuntime (RuntimeChoice) instead of defaultRuntimeMode (RuntimeMode)
+ // so that switching between "coder" and "ssh" in Settings is detected as a change.
+ const prevSettingsDefaultRef = useRef(null);
// When switching projects or changing the persisted default mode, reset the selection.
// Importantly: do NOT reset selection when lastSsh.host/lastDockerImage changes while typing.
useEffect(() => {
const projectChanged = prevProjectPathRef.current !== projectPath;
- const defaultModeChanged = prevDefaultRuntimeModeRef.current !== defaultRuntimeMode;
+ const defaultModeChanged = prevSettingsDefaultRef.current !== settingsDefaultRuntime;
if (projectChanged || defaultModeChanged) {
setSelectedRuntimeState(
@@ -371,9 +432,10 @@ export function useDraftWorkspaceSettings(
}
prevProjectPathRef.current = projectPath;
- prevDefaultRuntimeModeRef.current = defaultRuntimeMode;
+ prevSettingsDefaultRef.current = settingsDefaultRuntime;
}, [
projectPath,
+ settingsDefaultRuntime,
defaultRuntimeMode,
defaultSshHost,
defaultDockerImage,
diff --git a/src/browser/hooks/useRuntimeEnablement.ts b/src/browser/hooks/useRuntimeEnablement.ts
new file mode 100644
index 0000000000..c40ebdfb57
--- /dev/null
+++ b/src/browser/hooks/useRuntimeEnablement.ts
@@ -0,0 +1,83 @@
+import { useRef } from "react";
+import { useAPI } from "@/browser/contexts/API";
+import { usePersistedState } from "@/browser/hooks/usePersistedState";
+import { DEFAULT_RUNTIME_KEY, RUNTIME_ENABLEMENT_KEY } from "@/common/constants/storage";
+import {
+ DEFAULT_RUNTIME_ENABLEMENT,
+ RUNTIME_ENABLEMENT_IDS,
+ normalizeRuntimeEnablement,
+ type RuntimeEnablement,
+ type RuntimeEnablementId,
+} from "@/common/types/runtime";
+
+interface RuntimeEnablementState {
+ enablement: RuntimeEnablement;
+ setRuntimeEnabled: (id: RuntimeEnablementId, enabled: boolean) => void;
+ defaultRuntime: RuntimeEnablementId | null;
+ setDefaultRuntime: (id: RuntimeEnablementId | null) => void;
+}
+
+function normalizeDefaultRuntime(value: unknown): RuntimeEnablementId | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ return RUNTIME_ENABLEMENT_IDS.includes(value as RuntimeEnablementId)
+ ? (value as RuntimeEnablementId)
+ : null;
+}
+
+export function useRuntimeEnablement(): RuntimeEnablementState {
+ const { api } = useAPI();
+ const [rawEnablement, setRawEnablement] = usePersistedState(
+ RUNTIME_ENABLEMENT_KEY,
+ DEFAULT_RUNTIME_ENABLEMENT,
+ { listener: true }
+ );
+ const [rawDefaultRuntime, setRawDefaultRuntime] = usePersistedState(
+ DEFAULT_RUNTIME_KEY,
+ null,
+ { listener: true }
+ );
+
+ // Normalize persisted values so corrupted/legacy payloads don't break toggles.
+ // Stabilize the reference: normalizeRuntimeEnablement returns a fresh object every call,
+ // so we use a ref to return the same object when the values haven't changed. This prevents
+ // downstream effects from re-running on every render.
+ const normalized = normalizeRuntimeEnablement(rawEnablement);
+ const enablementRef = useRef(normalized);
+ const prevSerializedRef = useRef(JSON.stringify(normalized));
+ const currentSerialized = JSON.stringify(normalized);
+ if (currentSerialized !== prevSerializedRef.current) {
+ enablementRef.current = normalized;
+ prevSerializedRef.current = currentSerialized;
+ }
+ const enablement = enablementRef.current;
+ const defaultRuntime = normalizeDefaultRuntime(rawDefaultRuntime);
+
+ const setRuntimeEnabled = (id: RuntimeEnablementId, enabled: boolean) => {
+ const nextMap: RuntimeEnablement = {
+ ...enablement,
+ [id]: enabled,
+ };
+
+ // Persist locally first so Settings reflects changes immediately and stays in sync.
+ setRawEnablement(nextMap);
+
+ // Best-effort backend write keeps ~/.mux/config.json aligned across devices.
+ api?.config?.updateRuntimeEnablement({ runtimeEnablement: nextMap }).catch(() => {
+ // Best-effort only.
+ });
+ };
+
+ const setDefaultRuntime = (id: RuntimeEnablementId | null) => {
+ // Keep the local cache and config.json aligned for the global default runtime.
+ setRawDefaultRuntime(id);
+
+ api?.config?.updateRuntimeEnablement({ defaultRuntime: id }).catch(() => {
+ // Best-effort only.
+ });
+ };
+
+ return { enablement, setRuntimeEnabled, defaultRuntime, setDefaultRuntime };
+}
diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts
index 689ef7ecfe..b101cb6209 100644
--- a/src/browser/stories/mocks/orpc.ts
+++ b/src/browser/stories/mocks/orpc.ts
@@ -34,6 +34,11 @@ import {
MUX_HELP_CHAT_WORKSPACE_NAME,
MUX_HELP_CHAT_WORKSPACE_TITLE,
} from "@/common/constants/muxChat";
+import {
+ normalizeRuntimeEnablement,
+ RUNTIME_ENABLEMENT_IDS,
+ type RuntimeEnablementId,
+} from "@/common/types/runtime";
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
import {
DEFAULT_TASK_SETTINGS,
@@ -98,6 +103,10 @@ export interface MockORPCClientOptions {
subagentAiDefaults?: SubagentAiDefaults;
/** Coder lifecycle preferences for config.getConfig (e.g., Settings → Coder section) */
stopCoderWorkspaceOnArchive?: boolean;
+ /** Initial runtime enablement for config.getConfig */
+ runtimeEnablement?: Record;
+ /** Initial default runtime for config.getConfig (global) */
+ defaultRuntime?: RuntimeEnablementId | null;
/** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */
onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => (() => void) | void;
/** Mock for executeBash per workspace */
@@ -290,6 +299,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
subagentAiDefaults: initialSubagentAiDefaults,
agentAiDefaults: initialAgentAiDefaults,
stopCoderWorkspaceOnArchive: initialStopCoderWorkspaceOnArchive = true,
+ runtimeEnablement: initialRuntimeEnablement,
+ defaultRuntime: initialDefaultRuntime,
agentDefinitions: initialAgentDefinitions,
listBranches: customListBranches,
gitInit: customGitInit,
@@ -405,7 +416,16 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
let muxGatewayEnabled: boolean | undefined = undefined;
let muxGatewayModels: string[] | undefined = undefined;
let stopCoderWorkspaceOnArchive = initialStopCoderWorkspaceOnArchive;
+ let runtimeEnablement: Record = initialRuntimeEnablement ?? {
+ local: true,
+ worktree: true,
+ ssh: true,
+ coder: true,
+ docker: true,
+ devcontainer: true,
+ };
+ let defaultRuntime: RuntimeEnablementId | null = initialDefaultRuntime ?? null;
let globalSecretsState: Secret[] = [...globalSecrets];
const globalMcpServersState: MockMcpServers = { ...globalMcpServers };
@@ -527,6 +547,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
muxGatewayEnabled,
muxGatewayModels,
stopCoderWorkspaceOnArchive,
+ runtimeEnablement,
+ defaultRuntime,
agentAiDefaults,
subagentAiDefaults,
muxGovernorUrl,
@@ -574,6 +596,82 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
stopCoderWorkspaceOnArchive = input.stopCoderWorkspaceOnArchive;
return Promise.resolve(undefined);
},
+ updateRuntimeEnablement: (input: {
+ projectPath?: string | null;
+ runtimeEnablement?: Record | null;
+ defaultRuntime?: RuntimeEnablementId | null;
+ runtimeOverridesEnabled?: boolean | null;
+ }) => {
+ const shouldUpdateRuntimeEnablement = input.runtimeEnablement !== undefined;
+ const shouldUpdateDefaultRuntime = input.defaultRuntime !== undefined;
+ const shouldUpdateOverridesEnabled = input.runtimeOverridesEnabled !== undefined;
+ const projectPath = input.projectPath?.trim();
+
+ const runtimeEnablementOverrides =
+ input.runtimeEnablement == null
+ ? undefined
+ : (() => {
+ const normalized = normalizeRuntimeEnablement(input.runtimeEnablement);
+ const disabled: Partial> = {};
+
+ for (const runtimeId of RUNTIME_ENABLEMENT_IDS) {
+ if (!normalized[runtimeId]) {
+ disabled[runtimeId] = false;
+ }
+ }
+
+ return Object.keys(disabled).length > 0 ? disabled : undefined;
+ })();
+
+ const runtimeOverridesEnabled = input.runtimeOverridesEnabled === true ? true : undefined;
+
+ if (projectPath) {
+ const project = projects.get(projectPath);
+ if (project) {
+ const nextProject = { ...project };
+ if (shouldUpdateRuntimeEnablement) {
+ if (runtimeEnablementOverrides) {
+ nextProject.runtimeEnablement = runtimeEnablementOverrides;
+ } else {
+ delete nextProject.runtimeEnablement;
+ }
+ }
+
+ if (shouldUpdateDefaultRuntime) {
+ if (input.defaultRuntime !== null && input.defaultRuntime !== undefined) {
+ nextProject.defaultRuntime = input.defaultRuntime;
+ } else {
+ delete nextProject.defaultRuntime;
+ }
+ }
+
+ if (shouldUpdateOverridesEnabled) {
+ if (runtimeOverridesEnabled) {
+ nextProject.runtimeOverridesEnabled = true;
+ } else {
+ delete nextProject.runtimeOverridesEnabled;
+ }
+ }
+ projects.set(projectPath, nextProject);
+ }
+
+ return Promise.resolve(undefined);
+ }
+
+ if (shouldUpdateRuntimeEnablement) {
+ if (input.runtimeEnablement == null) {
+ runtimeEnablement = normalizeRuntimeEnablement({});
+ } else {
+ runtimeEnablement = normalizeRuntimeEnablement(input.runtimeEnablement);
+ }
+ }
+
+ if (shouldUpdateDefaultRuntime) {
+ defaultRuntime = input.defaultRuntime ?? null;
+ }
+
+ return Promise.resolve(undefined);
+ },
unenrollMuxGovernor: () => Promise.resolve(undefined),
},
agents: {
diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts
index c9dcbd39b9..968e78d2f5 100644
--- a/src/common/constants/storage.ts
+++ b/src/common/constants/storage.ts
@@ -89,6 +89,16 @@ export const GATEWAY_MODELS_KEY = "gateway-models"; // enabled model IDs (canoni
export const GATEWAY_CONFIGURED_KEY = "gateway-available"; // synced from provider config
export const GATEWAY_ENABLED_KEY = "gateway-enabled"; // global on/off toggle
+/**
+ * Storage key for runtime enablement settings (shared via ~/.mux/config.json).
+ */
+export const RUNTIME_ENABLEMENT_KEY = "runtimeEnablement";
+
+/**
+ * Storage key for global default runtime selection (shared via ~/.mux/config.json).
+ */
+export const DEFAULT_RUNTIME_KEY = "defaultRuntime";
+
/**
* Get the localStorage key for cached MCP server test results (per project)
* Format: "mcpTestResults:{projectPath}"
diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts
index 223f4cd32d..9fb1f968b0 100644
--- a/src/common/orpc/schemas.ts
+++ b/src/common/orpc/schemas.ts
@@ -8,6 +8,7 @@ export { ResultSchema } from "./schemas/result";
export {
RuntimeConfigSchema,
RuntimeModeSchema,
+ RuntimeEnablementIdSchema,
RuntimeAvailabilitySchema,
RuntimeAvailabilityStatusSchema,
DevcontainerConfigInfoSchema,
diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts
index 5132928871..a3eb24b9ba 100644
--- a/src/common/orpc/schemas/api.ts
+++ b/src/common/orpc/schemas/api.ts
@@ -6,7 +6,11 @@ import { SendMessageErrorSchema } from "./errors";
import { BranchListResultSchema, FilePartSchema, MuxMessageSchema } from "./message";
import { ProjectConfigSchema, SectionConfigSchema } from "./project";
import { ResultSchema } from "./result";
-import { RuntimeConfigSchema, RuntimeAvailabilitySchema } from "./runtime";
+import {
+ RuntimeConfigSchema,
+ RuntimeAvailabilitySchema,
+ RuntimeEnablementIdSchema,
+} from "./runtime";
import { SecretSchema } from "./secrets";
import {
CompletedMessagePartSchema,
@@ -1432,6 +1436,8 @@ export const config = {
hiddenModels: z.array(z.string()).optional(),
preferredCompactionModel: z.string().optional(),
stopCoderWorkspaceOnArchive: z.boolean(),
+ runtimeEnablement: z.record(z.string(), z.boolean()),
+ defaultRuntime: z.string().nullable(),
agentAiDefaults: AgentAiDefaultsSchema,
// Legacy fields (downgrade compatibility)
subagentAiDefaults: SubagentAiDefaultsSchema,
@@ -1487,6 +1493,17 @@ export const config = {
.strict(),
output: z.void(),
},
+ updateRuntimeEnablement: {
+ input: z
+ .object({
+ projectPath: z.string().nullish(),
+ runtimeEnablement: z.record(z.string(), z.boolean()).nullish(),
+ defaultRuntime: RuntimeEnablementIdSchema.nullish(),
+ runtimeOverridesEnabled: z.boolean().nullish(),
+ })
+ .strict(),
+ output: z.void(),
+ },
unenrollMuxGovernor: {
input: z.void(),
output: z.void(),
diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts
index f6fe8fee12..cf55e255b6 100644
--- a/src/common/orpc/schemas/project.ts
+++ b/src/common/orpc/schemas/project.ts
@@ -1,10 +1,18 @@
import { z } from "zod";
-import { RuntimeConfigSchema } from "./runtime";
+import { RuntimeConfigSchema, RuntimeEnablementIdSchema } from "./runtime";
import { WorkspaceMCPOverridesSchema } from "./mcp";
import { WorkspaceAISettingsByAgentSchema, WorkspaceAISettingsSchema } from "./workspaceAiSettings";
const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh", "max"]);
+const RuntimeEnablementOverridesSchema = z
+ .object(
+ Object.fromEntries(
+ RuntimeEnablementIdSchema.options.map((runtimeId) => [runtimeId, z.literal(false)])
+ ) as Record>
+ )
+ .partial();
+
/**
* Section schema for organizing workspaces within a project.
* Sections are project-scoped and persist to config.json.
@@ -123,4 +131,13 @@ export const ProjectConfigSchema = z.object({
description:
"Hours of inactivity before auto-compacting workspaces. null/undefined = disabled.",
}),
+ runtimeEnablement: RuntimeEnablementOverridesSchema.optional().meta({
+ description: "Runtime enablement overrides (store `false` only to keep config.json minimal)",
+ }),
+ runtimeOverridesEnabled: z.boolean().optional().meta({
+ description: "Whether this project uses runtime overrides, even if no overrides are set",
+ }),
+ defaultRuntime: RuntimeEnablementIdSchema.optional().meta({
+ description: "Default runtime override for new workspaces in this project",
+ }),
});
diff --git a/src/common/orpc/schemas/runtime.ts b/src/common/orpc/schemas/runtime.ts
index cdab0503e9..d97cc89c2f 100644
--- a/src/common/orpc/schemas/runtime.ts
+++ b/src/common/orpc/schemas/runtime.ts
@@ -3,6 +3,15 @@ import { CoderWorkspaceConfigSchema } from "./coder";
export const RuntimeModeSchema = z.enum(["local", "worktree", "ssh", "docker", "devcontainer"]);
+export const RuntimeEnablementIdSchema = z.enum([
+ "local",
+ "worktree",
+ "ssh",
+ "coder",
+ "docker",
+ "devcontainer",
+]);
+
/**
* Runtime configuration union type.
*
diff --git a/src/common/types/project.ts b/src/common/types/project.ts
index bcae208fbd..29f353cf01 100644
--- a/src/common/types/project.ts
+++ b/src/common/types/project.ts
@@ -12,6 +12,7 @@ import type {
import type { TaskSettings, SubagentAiDefaults } from "./tasks";
import type { LayoutPresetsConfig } from "./uiLayouts";
import type { AgentAiDefaults } from "./agentAiDefaults";
+import type { RuntimeEnablementId } from "./runtime";
export type Workspace = z.infer;
@@ -108,4 +109,13 @@ export interface ProjectsConfig {
* Stored as `false` only (undefined behaves as true) to keep config.json minimal.
*/
stopCoderWorkspaceOnArchive?: boolean;
+
+ /** Global default runtime for new workspaces. */
+ defaultRuntime?: RuntimeEnablementId;
+
+ /**
+ * Runtime enablement overrides (shared via ~/.mux/config.json).
+ * Defaults to enabled; store `false` only to keep config.json minimal.
+ */
+ runtimeEnablement?: Partial>;
}
diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts
index 324b59ba0b..21f05598d7 100644
--- a/src/common/types/runtime.ts
+++ b/src/common/types/runtime.ts
@@ -4,7 +4,7 @@
import type { z } from "zod";
import type { RuntimeConfigSchema } from "../orpc/schemas";
-import { RuntimeModeSchema } from "../orpc/schemas";
+import { RuntimeEnablementIdSchema, RuntimeModeSchema } from "../orpc/schemas";
import type { CoderWorkspaceConfig } from "../orpc/schemas/coder";
// Re-export CoderWorkspaceConfig type from schema (single source of truth)
@@ -22,6 +22,46 @@ export const RUNTIME_MODE = {
DEVCONTAINER: "devcontainer" as const,
} as const;
+/**
+ * Runtime IDs that can be enabled/disabled in Settings → Runtimes.
+ * Note: includes "coder" which is a UI-level choice (not a RuntimeMode).
+ */
+export const RUNTIME_ENABLEMENT_IDS = RuntimeEnablementIdSchema.options;
+
+export type RuntimeEnablementId = z.infer;
+
+export type RuntimeEnablement = Record;
+
+export const DEFAULT_RUNTIME_ENABLEMENT: RuntimeEnablement = {
+ local: true,
+ worktree: true,
+ ssh: true,
+ coder: true,
+ docker: true,
+ devcontainer: true,
+};
+
+/**
+ * Normalize runtime enablement, defaulting missing/invalid keys to true.
+ */
+export function normalizeRuntimeEnablement(value: unknown): RuntimeEnablement {
+ const normalized: RuntimeEnablement = { ...DEFAULT_RUNTIME_ENABLEMENT };
+
+ if (!value || typeof value !== "object") {
+ return normalized;
+ }
+
+ const record = value as Record;
+ for (const runtimeId of RUNTIME_ENABLEMENT_IDS) {
+ const entry = record[runtimeId];
+ if (typeof entry === "boolean") {
+ normalized[runtimeId] = entry;
+ }
+ }
+
+ return normalized;
+}
+
/** Runtime string prefix for SSH mode (e.g., "ssh hostname") */
export const SSH_RUNTIME_PREFIX = "ssh ";
diff --git a/src/node/config.ts b/src/node/config.ts
index c6f0a09ec2..2f4426a8ad 100644
--- a/src/node/config.ts
+++ b/src/node/config.ts
@@ -19,6 +19,7 @@ import {
} from "@/common/types/tasks";
import { isLayoutPresetsConfigEmpty, normalizeLayoutPresetsConfig } from "@/common/types/uiLayouts";
import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults";
+import { RUNTIME_ENABLEMENT_IDS, type RuntimeEnablementId } from "@/common/types/runtime";
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility";
import { getMuxHome } from "@/common/constants/paths";
@@ -131,6 +132,78 @@ function parseOptionalPort(value: unknown): number | undefined {
return value;
}
+
+function normalizeRuntimeEnablementId(value: unknown): RuntimeEnablementId | undefined {
+ const trimmed = parseOptionalNonEmptyString(value);
+ if (!trimmed) {
+ return undefined;
+ }
+
+ const normalized = trimmed.toLowerCase();
+ if (RUNTIME_ENABLEMENT_IDS.includes(normalized as RuntimeEnablementId)) {
+ return normalized as RuntimeEnablementId;
+ }
+
+ return undefined;
+}
+
+function normalizeRuntimeEnablementOverrides(
+ value: unknown
+): Partial> | undefined {
+ if (!value || typeof value !== "object") {
+ return undefined;
+ }
+
+ const record = value as Record;
+ const overrides: Partial> = {};
+
+ for (const runtimeId of RUNTIME_ENABLEMENT_IDS) {
+ // Default ON: store `false` only so config.json stays minimal.
+ if (record[runtimeId] === false) {
+ overrides[runtimeId] = false;
+ }
+ }
+
+ return Object.keys(overrides).length > 0 ? overrides : undefined;
+}
+
+function normalizeProjectRuntimeSettings(projectConfig: ProjectConfig): ProjectConfig {
+ // Per-project runtime overrides are optional; keep config.json sparse by persisting only explicit
+ // overrides (false enablement + explicit default runtime selections).
+ if (!projectConfig || typeof projectConfig !== "object") {
+ return { workspaces: [] };
+ }
+
+ const record = projectConfig as ProjectConfig & {
+ runtimeEnablement?: unknown;
+ defaultRuntime?: unknown;
+ runtimeOverridesEnabled?: unknown;
+ };
+ const runtimeEnablement = normalizeRuntimeEnablementOverrides(record.runtimeEnablement);
+ const defaultRuntime = normalizeRuntimeEnablementId(record.defaultRuntime);
+ const runtimeOverridesEnabled = record.runtimeOverridesEnabled === true ? true : undefined;
+
+ const next = { ...record };
+ if (runtimeEnablement) {
+ next.runtimeEnablement = runtimeEnablement;
+ } else {
+ delete next.runtimeEnablement;
+ }
+
+ if (runtimeOverridesEnabled) {
+ next.runtimeOverridesEnabled = runtimeOverridesEnabled;
+ } else {
+ delete next.runtimeOverridesEnabled;
+ }
+
+ if (defaultRuntime) {
+ next.defaultRuntime = defaultRuntime;
+ } else {
+ delete next.defaultRuntime;
+ }
+
+ return next;
+}
export type ProvidersConfig = Record;
/**
@@ -184,6 +257,8 @@ export class Config {
muxGovernorUrl?: unknown;
muxGovernorToken?: unknown;
stopCoderWorkspaceOnArchive?: unknown;
+ runtimeEnablement?: unknown;
+ defaultRuntime?: unknown;
};
// Config is stored as array of [path, config] pairs
@@ -201,7 +276,11 @@ export class Config {
return true;
})
.map(([projectPath, projectConfig]) => {
- return [stripTrailingSlashes(projectPath), projectConfig] as [string, ProjectConfig];
+ const normalizedProjectConfig = normalizeProjectRuntimeSettings(projectConfig);
+ return [stripTrailingSlashes(projectPath), normalizedProjectConfig] as [
+ string,
+ ProjectConfig,
+ ];
});
const projectsMap = new Map(normalizedPairs);
@@ -221,6 +300,9 @@ export class Config {
const stopCoderWorkspaceOnArchive =
parseOptionalBoolean(parsed.stopCoderWorkspaceOnArchive) === false ? false : undefined;
+ const runtimeEnablement = normalizeRuntimeEnablementOverrides(parsed.runtimeEnablement);
+ const defaultRuntime = normalizeRuntimeEnablementId(parsed.defaultRuntime);
+
const agentAiDefaults =
parsed.agentAiDefaults !== undefined
? normalizeAgentAiDefaults(parsed.agentAiDefaults)
@@ -258,6 +340,8 @@ export class Config {
muxGovernorUrl: parseOptionalNonEmptyString(parsed.muxGovernorUrl),
muxGovernorToken: parseOptionalNonEmptyString(parsed.muxGovernorToken),
stopCoderWorkspaceOnArchive,
+ defaultRuntime,
+ runtimeEnablement,
};
}
}
@@ -304,8 +388,13 @@ export class Config {
muxGovernorUrl?: string;
muxGovernorToken?: string;
stopCoderWorkspaceOnArchive?: boolean;
+ runtimeEnablement?: ProjectsConfig["runtimeEnablement"];
+ defaultRuntime?: ProjectsConfig["defaultRuntime"];
} = {
- projects: Array.from(config.projects.entries()),
+ projects: Array.from(config.projects.entries()).map(
+ ([projectPath, projectConfig]) =>
+ [projectPath, normalizeProjectRuntimeSettings(projectConfig)] as [string, ProjectConfig]
+ ),
taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS,
};
@@ -416,6 +505,16 @@ export class Config {
data.stopCoderWorkspaceOnArchive = false;
}
+ const runtimeEnablement = normalizeRuntimeEnablementOverrides(config.runtimeEnablement);
+ if (runtimeEnablement) {
+ data.runtimeEnablement = runtimeEnablement;
+ }
+
+ const defaultRuntime = normalizeRuntimeEnablementId(config.defaultRuntime);
+ if (defaultRuntime !== undefined) {
+ data.defaultRuntime = defaultRuntime;
+ }
+
await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8");
} catch (error) {
log.error("Error saving config:", error);
diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts
index 928bae44ba..c496551f73 100644
--- a/src/node/orpc/router.ts
+++ b/src/node/orpc/router.ts
@@ -41,6 +41,11 @@ import {
normalizeSubagentAiDefaults,
normalizeTaskSettings,
} from "@/common/types/tasks";
+import {
+ normalizeRuntimeEnablement,
+ RUNTIME_ENABLEMENT_IDS,
+ type RuntimeEnablementId,
+} from "@/common/types/runtime";
import {
discoverAgentSkills,
discoverAgentSkillsDiagnostics,
@@ -508,6 +513,8 @@ export const router = (authToken?: string) => {
hiddenModels: config.hiddenModels,
preferredCompactionModel: config.preferredCompactionModel,
stopCoderWorkspaceOnArchive: config.stopCoderWorkspaceOnArchive !== false,
+ runtimeEnablement: normalizeRuntimeEnablement(config.runtimeEnablement),
+ defaultRuntime: config.defaultRuntime ?? null,
agentAiDefaults: config.agentAiDefaults ?? {},
// Legacy fields (downgrade compatibility)
subagentAiDefaults: config.subagentAiDefaults ?? {},
@@ -621,6 +628,93 @@ export const router = (authToken?: string) => {
};
});
}),
+ updateRuntimeEnablement: t
+ .input(schemas.config.updateRuntimeEnablement.input)
+ .output(schemas.config.updateRuntimeEnablement.output)
+ .handler(async ({ context, input }) => {
+ await context.config.editConfig((config) => {
+ const shouldUpdateRuntimeEnablement = input.runtimeEnablement !== undefined;
+ const shouldUpdateDefaultRuntime = input.defaultRuntime !== undefined;
+ const shouldUpdateOverridesEnabled = input.runtimeOverridesEnabled !== undefined;
+ const projectPath = input.projectPath?.trim();
+
+ if (
+ !shouldUpdateRuntimeEnablement &&
+ !shouldUpdateDefaultRuntime &&
+ !shouldUpdateOverridesEnabled
+ ) {
+ return config;
+ }
+
+ const runtimeEnablementOverrides =
+ input.runtimeEnablement == null
+ ? undefined
+ : (() => {
+ const normalized = normalizeRuntimeEnablement(input.runtimeEnablement);
+ const disabled: Partial> = {};
+
+ for (const runtimeId of RUNTIME_ENABLEMENT_IDS) {
+ if (!normalized[runtimeId]) {
+ disabled[runtimeId] = false;
+ }
+ }
+
+ return Object.keys(disabled).length > 0 ? disabled : undefined;
+ })();
+
+ const defaultRuntime = input.defaultRuntime ?? undefined;
+ const runtimeOverridesEnabled =
+ input.runtimeOverridesEnabled === true ? true : undefined;
+
+ if (projectPath) {
+ const project = config.projects.get(projectPath);
+ if (!project) {
+ log.warn("Runtime settings update requested for missing project", { projectPath });
+ return config;
+ }
+
+ const nextProject = { ...project };
+
+ if (shouldUpdateRuntimeEnablement) {
+ if (runtimeEnablementOverrides) {
+ nextProject.runtimeEnablement = runtimeEnablementOverrides;
+ } else {
+ delete nextProject.runtimeEnablement;
+ }
+ }
+
+ if (shouldUpdateDefaultRuntime) {
+ if (defaultRuntime !== undefined) {
+ nextProject.defaultRuntime = defaultRuntime;
+ } else {
+ delete nextProject.defaultRuntime;
+ }
+ }
+
+ if (shouldUpdateOverridesEnabled) {
+ if (runtimeOverridesEnabled) {
+ nextProject.runtimeOverridesEnabled = true;
+ } else {
+ delete nextProject.runtimeOverridesEnabled;
+ }
+ }
+ const nextProjects = new Map(config.projects);
+ nextProjects.set(projectPath, nextProject);
+ return { ...config, projects: nextProjects };
+ }
+
+ const next = { ...config };
+ if (shouldUpdateRuntimeEnablement) {
+ next.runtimeEnablement = runtimeEnablementOverrides;
+ }
+
+ if (shouldUpdateDefaultRuntime) {
+ next.defaultRuntime = defaultRuntime;
+ }
+
+ return next;
+ });
+ }),
saveConfig: t
.input(schemas.config.saveConfig.input)
.output(schemas.config.saveConfig.output)