Skip to main content

everruns_core/
feature_flags.rs

1// Feature flags system
2//
3// Decision: Feature flags are system-level, computed from env vars + deployment grade.
4// Decision: Flags marked "experimental" auto-enable in dev (DeploymentGrade::Dev).
5// Decision: Explicit env var (FEATURE_<NAME>=true/false) always takes priority.
6// Decision: Struct-based for type safety; `is_enabled(&str)` for dynamic lookup.
7// Decision: Two structs — FeatureFlags (API-visible) and InternalFeatureFlags (backend-only).
8// Decision: Future extensibility: per-org/per-user flags, external providers (LaunchDarkly).
9// Decision: No database storage needed yet — env vars + deployment grade suffice.
10
11use serde::{Deserialize, Serialize};
12
13use crate::deployment::DeploymentGrade;
14
15/// Feature flags exposed via `GET /v1/feature-flags` and consumed by the frontend.
16///
17/// Currently backed by environment variables and deployment grade.
18/// Future: per-org flags, per-user flags, external providers.
19#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21pub struct FeatureFlags {
22    /// Global chat (per-user singleton chat session). Experimental.
23    pub global_chat: bool,
24    /// In-app notifications (bell, toasts, notification SSE). Experimental.
25    pub notifications: bool,
26    /// MCP endpoint (POST /mcp — Everruns as an MCP server). Experimental.
27    pub mcp_endpoint: bool,
28    /// Evals (user-facing behavioral evals for agents). Experimental.
29    pub evals: bool,
30    /// App / channel scoped budgets and periodic budget resets (`5h`, `1d`, ...).
31    /// Experimental.
32    pub app_budgets: bool,
33    /// Immutable agent versions, snapshots, forks, and app version binding.
34    /// Experimental.
35    pub agent_versions: bool,
36    /// Realtime voice endpoints and microphone controls. Experimental.
37    pub voice: bool,
38    /// Channels-first app detail page and full-page channel forms. Experimental.
39    #[serde(rename = "apps.detailV2")]
40    pub apps_detail_v2: bool,
41    /// Outbound agent delegation capabilities (`a2a_agent_delegation`, `agent_handoff`).
42    /// Experimental: auto-enabled in dev, off in prod by default.
43    /// When off, these capabilities are not registered and cannot be assigned to agents.
44    pub agent_delegation: bool,
45}
46
47/// Metadata for an API-visible feature flag (org opt-in UI + catalog).
48#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
49pub struct FeatureFlagDefinition {
50    /// Stable flag key (matches `FeatureFlags` field / `is_enabled` name).
51    pub name: &'static str,
52    /// Human-readable title for settings UI.
53    pub label: &'static str,
54    /// Short description of what the flag gates.
55    pub description: &'static str,
56    /// When true, shown with experimental badges in the UI.
57    pub experimental: bool,
58}
59
60/// All API-visible flags that organizations may opt into when the deployment allows them.
61pub const API_FEATURE_FLAG_DEFINITIONS: &[FeatureFlagDefinition] = &[
62    FeatureFlagDefinition {
63        name: "global_chat",
64        label: "Global chat",
65        description: "Per-user singleton chat session in the sidebar.",
66        experimental: true,
67    },
68    FeatureFlagDefinition {
69        name: "notifications",
70        label: "Notifications",
71        description: "In-app notification bell, toasts, and notification SSE.",
72        experimental: true,
73    },
74    FeatureFlagDefinition {
75        name: "mcp_endpoint",
76        label: "MCP server endpoint",
77        description: "Expose Everruns as an MCP server (POST /mcp).",
78        experimental: true,
79    },
80    FeatureFlagDefinition {
81        name: "evals",
82        label: "Evals",
83        description: "Behavioral evals for agents.",
84        experimental: true,
85    },
86    FeatureFlagDefinition {
87        name: "app_budgets",
88        label: "App budgets",
89        description: "App and channel scoped budgets with periodic resets.",
90        experimental: true,
91    },
92    FeatureFlagDefinition {
93        name: "agent_versions",
94        label: "Agent versions",
95        description: "Immutable agent snapshots, forks, rollback, and app version binding.",
96        experimental: true,
97    },
98    FeatureFlagDefinition {
99        name: "voice",
100        label: "Voice",
101        description: "Realtime voice endpoints and microphone controls in chat.",
102        experimental: true,
103    },
104    FeatureFlagDefinition {
105        name: "apps.detailV2",
106        label: "Apps detail v2",
107        description: "Channels-first app detail page and full-page channel forms.",
108        experimental: true,
109    },
110];
111
112impl FeatureFlags {
113    /// Effective flags for an organization: deployment/system gates AND explicit org opt-in.
114    ///
115    /// Org overrides default to disabled; a flag is on only when the deployment allows it
116    /// and the org has opted in (`enabled: true` in storage).
117    pub fn for_org(system: &Self, org_enabled: &std::collections::HashMap<String, bool>) -> Self {
118        let opt_in = |name: &str, system_on: bool| -> bool {
119            system_on && org_enabled.get(name).copied().unwrap_or(false)
120        };
121        Self {
122            global_chat: opt_in("global_chat", system.global_chat),
123            notifications: opt_in("notifications", system.notifications),
124            mcp_endpoint: opt_in("mcp_endpoint", system.mcp_endpoint),
125            evals: opt_in("evals", system.evals),
126            app_budgets: opt_in("app_budgets", system.app_budgets),
127            agent_versions: opt_in("agent_versions", system.agent_versions),
128            voice: opt_in("voice", system.voice),
129            apps_detail_v2: opt_in("apps.detailV2", system.apps_detail_v2),
130            agent_delegation: opt_in("agent_delegation", system.agent_delegation),
131        }
132    }
133
134    /// Compute feature flags from environment variables and deployment grade.
135    pub fn from_env(grade: &DeploymentGrade) -> Self {
136        Self {
137            global_chat: experimental_flag("FEATURE_GLOBAL_CHAT", grade),
138            notifications: experimental_flag("FEATURE_NOTIFICATIONS", grade),
139            mcp_endpoint: experimental_flag("FEATURE_MCP_ENDPOINT", grade),
140            evals: experimental_flag("FEATURE_EVALS", grade),
141            app_budgets: experimental_flag("FEATURE_APP_BUDGETS", grade),
142            agent_versions: experimental_flag("FEATURE_AGENT_VERSIONS", grade),
143            voice: experimental_flag("FEATURE_VOICE", grade),
144            apps_detail_v2: experimental_flag("FEATURE_APPS_DETAIL_V2", grade),
145            agent_delegation: experimental_flag("FEATURE_AGENT_DELEGATION", grade),
146        }
147    }
148
149    /// Resolve the current feature flags from env + the env-derived deployment grade.
150    /// Convenience for callers that don't have a `FeatureFlags` instance handy.
151    pub fn current() -> Self {
152        Self::from_env(&DeploymentGrade::from_env())
153    }
154
155    /// Look up a flag by name (for dynamic/string-based access).
156    pub fn is_enabled(&self, flag: &str) -> bool {
157        match flag {
158            "global_chat" => self.global_chat,
159            "notifications" => self.notifications,
160            "mcp_endpoint" => self.mcp_endpoint,
161            "evals" => self.evals,
162            "app_budgets" => self.app_budgets,
163            "agent_versions" => self.agent_versions,
164            "voice" => self.voice,
165            "apps.detailV2" => self.apps_detail_v2,
166            "agent_delegation" => self.agent_delegation,
167            _ => false,
168        }
169    }
170
171    /// All flags enabled (for testing).
172    #[cfg(test)]
173    pub fn all_enabled() -> Self {
174        Self {
175            global_chat: true,
176            notifications: true,
177            mcp_endpoint: true,
178            evals: true,
179            app_budgets: true,
180            agent_versions: true,
181            voice: true,
182            apps_detail_v2: true,
183            agent_delegation: true,
184        }
185    }
186}
187
188/// Backend-only feature flags. Not exposed via API or frontend.
189///
190/// Used for internal gating (capability registration, infrastructure behavior).
191#[derive(Debug, Default, Clone, PartialEq, Eq)]
192pub struct InternalFeatureFlags {
193    /// Docker container capability. Disabled by default on all envs.
194    /// Enable via `FEATURE_DOCKER_CAPABILITY=true`.
195    pub docker_capability: bool,
196    /// Self-hosted container sandbox capability and coding harness.
197    /// Disabled by default on all envs.
198    /// Enable via `FEATURE_CONTAINER_SANDBOX=true`, or via the legacy
199    /// fallback `FEATURE_DOCKER_CAPABILITY=true` when
200    /// `FEATURE_CONTAINER_SANDBOX` is unset.
201    pub container_sandbox: bool,
202    /// Managed session-owned sandbox capability and lifecycle orchestration.
203    /// Experimental and disabled by default.
204    pub session_sandbox: bool,
205    /// Machine-payment capabilities (e.g. the Parallel paid search/extract/task
206    /// capability). Gates registration of any capability that spends real money
207    /// through `PaymentAuthority`. Disabled by default on all envs, including dev,
208    /// because spend is irreversible. Enable via `FEATURE_MACHINE_PAYMENTS=true`.
209    pub machine_payments: bool,
210    /// Experimental sandboxed Lua execution capability (`specs/lua-execution.md`).
211    /// Disabled by default; requires the `lua` cargo feature to be compiled in to
212    /// actually run scripts. Enable via `FEATURE_LUA=true`.
213    pub lua: bool,
214}
215
216impl InternalFeatureFlags {
217    /// Compute internal feature flags from environment variables.
218    pub fn from_env() -> Self {
219        let docker_capability = standard_flag("FEATURE_DOCKER_CAPABILITY", false);
220
221        Self {
222            docker_capability,
223            container_sandbox: standard_flag("FEATURE_CONTAINER_SANDBOX", docker_capability),
224            session_sandbox: standard_flag("FEATURE_SESSION_SANDBOX", false),
225            machine_payments: standard_flag("FEATURE_MACHINE_PAYMENTS", false),
226            lua: standard_flag("FEATURE_LUA", false),
227        }
228    }
229
230    /// Look up a flag by name (for dynamic/string-based access).
231    pub fn is_enabled(&self, flag: &str) -> bool {
232        match flag {
233            "docker_capability" => self.docker_capability,
234            "container_sandbox" => self.container_sandbox,
235            "session_sandbox" => self.session_sandbox,
236            "machine_payments" => self.machine_payments,
237            "lua" => self.lua,
238            _ => false,
239        }
240    }
241}
242
243/// Resolve an experimental flag.
244///
245/// Priority: explicit env var > experimental default (enabled in dev) > false.
246fn experimental_flag(env_var: &str, grade: &DeploymentGrade) -> bool {
247    if let Ok(val) = std::env::var(env_var) {
248        return val == "true" || val == "1";
249    }
250    grade.experimental_features_enabled()
251}
252
253/// Resolve a standard (non-experimental) flag.
254///
255/// Priority: explicit env var > default.
256fn standard_flag(env_var: &str, default: bool) -> bool {
257    std::env::var(env_var)
258        .map(|v| v == "true" || v == "1")
259        .unwrap_or(default)
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    // Env-var-mutating tests must not run in parallel.
267    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
268
269    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
270        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
271    }
272
273    /// Restore an env var to a previously captured value, or remove it if it was unset.
274    fn restore_env(key: &str, prev: Option<String>) {
275        match prev {
276            Some(value) => unsafe { std::env::set_var(key, value) },
277            None => unsafe { std::env::remove_var(key) },
278        }
279    }
280
281    #[test]
282    fn test_default_flags() {
283        let flags = FeatureFlags::default();
284        assert!(!flags.global_chat);
285        assert!(!flags.notifications);
286    }
287
288    // SAFETY: env var tests must run single-threaded (--test-threads=1).
289    // set_var/remove_var are unsafe in edition 2024 due to thread-safety.
290
291    #[test]
292    fn test_experimental_enabled_in_dev() {
293        let _lock = lock_env();
294        unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
295        unsafe { std::env::remove_var("FEATURE_EVALS") };
296        let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
297        assert!(flags.global_chat);
298        assert!(flags.evals);
299    }
300
301    #[test]
302    fn test_experimental_disabled_in_prod() {
303        let _lock = lock_env();
304        unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
305        unsafe { std::env::remove_var("FEATURE_EVALS") };
306        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
307        assert!(!flags.global_chat);
308        assert!(!flags.evals);
309    }
310
311    #[test]
312    fn test_env_override_enables_in_prod() {
313        let _lock = lock_env();
314        unsafe { std::env::set_var("FEATURE_GLOBAL_CHAT", "true") };
315        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
316        assert!(flags.global_chat);
317        unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
318    }
319
320    #[test]
321    fn test_env_override_disables_in_dev() {
322        let _lock = lock_env();
323        unsafe { std::env::set_var("FEATURE_GLOBAL_CHAT", "false") };
324        let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
325        assert!(!flags.global_chat);
326        unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
327    }
328
329    #[test]
330    fn test_is_enabled_dynamic() {
331        let flags = FeatureFlags {
332            global_chat: true,
333            notifications: true,
334            mcp_endpoint: true,
335            evals: true,
336            app_budgets: true,
337            agent_versions: true,
338            voice: true,
339            apps_detail_v2: true,
340            agent_delegation: true,
341        };
342        assert!(flags.is_enabled("global_chat"));
343        assert!(flags.is_enabled("notifications"));
344        assert!(flags.is_enabled("mcp_endpoint"));
345        assert!(flags.is_enabled("evals"));
346        assert!(flags.is_enabled("app_budgets"));
347        assert!(flags.is_enabled("agent_versions"));
348        assert!(flags.is_enabled("voice"));
349        assert!(flags.is_enabled("apps.detailV2"));
350        assert!(flags.is_enabled("agent_delegation"));
351        assert!(!flags.is_enabled("nonexistent"));
352    }
353
354    #[test]
355    fn test_serialization() {
356        let flags = FeatureFlags {
357            global_chat: true,
358            notifications: true,
359            mcp_endpoint: true,
360            evals: true,
361            app_budgets: true,
362            agent_versions: true,
363            voice: true,
364            apps_detail_v2: true,
365            agent_delegation: true,
366        };
367        let json = serde_json::to_string(&flags).unwrap();
368        assert!(json.contains("\"global_chat\":true"));
369        assert!(json.contains("\"notifications\":true"));
370        assert!(json.contains("\"app_budgets\":true"));
371        assert!(json.contains("\"agent_versions\":true"));
372        assert!(json.contains("\"voice\":true"));
373        assert!(json.contains("\"apps.detailV2\":true"));
374        assert!(json.contains("\"agent_delegation\":true"));
375
376        let parsed: FeatureFlags = serde_json::from_str(&json).unwrap();
377        assert_eq!(flags, parsed);
378    }
379
380    #[test]
381    fn test_agent_delegation_enabled_in_dev() {
382        let _lock = lock_env();
383        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
384        let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
385        assert!(flags.agent_delegation);
386    }
387
388    #[test]
389    fn test_agent_delegation_disabled_in_prod() {
390        let _lock = lock_env();
391        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
392        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
393        assert!(!flags.agent_delegation);
394    }
395
396    #[test]
397    fn test_agent_delegation_env_override_in_prod() {
398        let _lock = lock_env();
399        unsafe { std::env::set_var("FEATURE_AGENT_DELEGATION", "true") };
400        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
401        assert!(flags.agent_delegation);
402        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
403    }
404
405    #[test]
406    fn test_standard_flag() {
407        let _lock = lock_env();
408        unsafe { std::env::remove_var("FEATURE_TEST_STD") };
409        assert!(!standard_flag("FEATURE_TEST_STD", false));
410        assert!(standard_flag("FEATURE_TEST_STD", true));
411
412        unsafe { std::env::set_var("FEATURE_TEST_STD", "1") };
413        assert!(standard_flag("FEATURE_TEST_STD", false));
414        unsafe { std::env::remove_var("FEATURE_TEST_STD") };
415    }
416
417    #[test]
418    fn test_notifications_enabled_in_dev() {
419        let _lock = lock_env();
420        unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
421        let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
422        assert!(flags.notifications);
423    }
424
425    #[test]
426    fn test_notifications_disabled_in_prod() {
427        let _lock = lock_env();
428        unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
429        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
430        assert!(!flags.notifications);
431    }
432
433    #[test]
434    fn test_for_org_requires_system_and_opt_in() {
435        let system = FeatureFlags {
436            global_chat: true,
437            evals: true,
438            ..FeatureFlags::default()
439        };
440        let mut org = std::collections::HashMap::new();
441        org.insert("global_chat".to_string(), true);
442        let effective = FeatureFlags::for_org(&system, &org);
443        assert!(effective.global_chat);
444        assert!(!effective.evals);
445
446        let effective_none = FeatureFlags::for_org(&system, &std::collections::HashMap::new());
447        assert!(!effective_none.global_chat);
448    }
449
450    #[test]
451    fn test_for_org_cannot_enable_when_system_off() {
452        let system = FeatureFlags::default();
453        let mut org = std::collections::HashMap::new();
454        org.insert("global_chat".to_string(), true);
455        let effective = FeatureFlags::for_org(&system, &org);
456        assert!(!effective.global_chat);
457    }
458
459    #[test]
460    fn test_notifications_respects_env_override() {
461        let _lock = lock_env();
462        unsafe { std::env::set_var("FEATURE_NOTIFICATIONS", "true") };
463        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
464        assert!(flags.notifications);
465        unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
466    }
467
468    // =========================================================================
469    // InternalFeatureFlags tests
470    // =========================================================================
471
472    #[test]
473    fn test_internal_default_flags() {
474        let flags = InternalFeatureFlags::default();
475        assert!(!flags.docker_capability);
476        assert!(!flags.container_sandbox);
477        assert!(!flags.session_sandbox);
478        assert!(!flags.machine_payments);
479    }
480
481    #[test]
482    fn test_docker_capability_flag_disabled_by_default_in_dev() {
483        let _lock = lock_env();
484        unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
485        let flags = InternalFeatureFlags::from_env();
486        assert!(
487            !flags.docker_capability,
488            "docker_capability should be disabled by default even in dev"
489        );
490    }
491
492    #[test]
493    fn test_docker_capability_flag_enabled_by_env_override() {
494        let _lock = lock_env();
495        unsafe { std::env::set_var("FEATURE_DOCKER_CAPABILITY", "true") };
496        let flags = InternalFeatureFlags::from_env();
497        assert!(flags.docker_capability);
498        unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
499    }
500
501    #[test]
502    fn test_container_sandbox_flag_enabled_by_env_override() {
503        let _lock = lock_env();
504        unsafe { std::env::set_var("FEATURE_CONTAINER_SANDBOX", "true") };
505        unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
506        let flags = InternalFeatureFlags::from_env();
507        assert!(flags.container_sandbox);
508        unsafe { std::env::remove_var("FEATURE_CONTAINER_SANDBOX") };
509    }
510
511    #[test]
512    fn test_container_sandbox_flag_falls_back_to_legacy_docker_flag() {
513        let _lock = lock_env();
514        unsafe { std::env::remove_var("FEATURE_CONTAINER_SANDBOX") };
515        unsafe { std::env::set_var("FEATURE_DOCKER_CAPABILITY", "true") };
516        let flags = InternalFeatureFlags::from_env();
517        assert!(flags.container_sandbox);
518        unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
519    }
520
521    #[test]
522    fn test_internal_is_enabled_dynamic() {
523        let flags = InternalFeatureFlags {
524            docker_capability: true,
525            container_sandbox: true,
526            session_sandbox: true,
527            machine_payments: true,
528            lua: true,
529        };
530        assert!(flags.is_enabled("docker_capability"));
531        assert!(flags.is_enabled("container_sandbox"));
532        assert!(flags.is_enabled("session_sandbox"));
533        assert!(flags.is_enabled("machine_payments"));
534        assert!(flags.is_enabled("lua"));
535        assert!(!flags.is_enabled("nonexistent"));
536    }
537
538    #[test]
539    fn test_machine_payments_disabled_by_default() {
540        let _lock = lock_env();
541        let prev = std::env::var("FEATURE_MACHINE_PAYMENTS").ok();
542        unsafe { std::env::remove_var("FEATURE_MACHINE_PAYMENTS") };
543        let flags = InternalFeatureFlags::from_env();
544        assert!(
545            !flags.machine_payments,
546            "machine_payments should be disabled by default on all envs"
547        );
548        restore_env("FEATURE_MACHINE_PAYMENTS", prev);
549    }
550
551    #[test]
552    fn test_machine_payments_enabled_by_env_override() {
553        let _lock = lock_env();
554        let prev = std::env::var("FEATURE_MACHINE_PAYMENTS").ok();
555        unsafe { std::env::set_var("FEATURE_MACHINE_PAYMENTS", "true") };
556        let flags = InternalFeatureFlags::from_env();
557        assert!(flags.machine_payments);
558        restore_env("FEATURE_MACHINE_PAYMENTS", prev);
559    }
560
561    #[test]
562    fn test_session_sandbox_flag_enabled_by_env_override() {
563        let _lock = lock_env();
564        unsafe { std::env::set_var("FEATURE_SESSION_SANDBOX", "true") };
565        let flags = InternalFeatureFlags::from_env();
566        assert!(flags.session_sandbox);
567        unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
568    }
569}