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 std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::deployment::DeploymentGrade;
16
17/// Feature flags exposed via `GET /v1/feature-flags` and consumed by the frontend.
18///
19/// Currently backed by environment variables and deployment grade.
20/// Future: per-org flags, per-user flags, external providers.
21///
22/// Decision: this is the type-safe representation used throughout the backend.
23/// The API does not serialize it directly — it exposes the untyped
24/// [`FeatureFlagMap`] instead, so adding/removing a flag never changes
25/// `docs/api/openapi.json`.
26#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct FeatureFlags {
28    /// Platform Chat: the per-user singleton assistant chat surface — sidebar
29    /// entry, the `/chat` page, and the `POST /v1/sessions/chat` (+ voice)
30    /// endpoints. When off, the whole Platform Chat feature is disabled
31    /// (UI hidden and APIs return 404); other chat surfaces (per-session,
32    /// agent, channel) are unaffected. Experimental.
33    pub global_chat: bool,
34    /// In-app notifications (bell, toasts, notification SSE). Experimental.
35    pub notifications: bool,
36    /// Evals (user-facing behavioral evals for agents). Experimental.
37    pub evals: bool,
38    /// Skills registry management UI. Experimental.
39    pub skills: bool,
40    /// Workspace memory management UI. Experimental.
41    pub memory: bool,
42    /// Knowledge index management UI. Experimental.
43    pub knowledge: bool,
44    /// Plugin marketplace and installed-plugin management UI. Experimental.
45    pub plugins: bool,
46    /// App / channel scoped budgets and periodic budget resets (`5h`, `1d`, ...).
47    /// Experimental.
48    pub app_budgets: bool,
49    /// Immutable agent versions, snapshots, forks, and app version binding.
50    /// Experimental.
51    pub agent_versions: bool,
52    /// Realtime voice endpoints and microphone controls. Experimental.
53    pub voice: bool,
54    /// Outbound agent delegation capabilities (`a2a_agent_delegation`, `agent_handoff`).
55    /// Experimental: auto-enabled in dev, off in prod by default.
56    /// When off, these capabilities are not registered and cannot be assigned to agents.
57    pub agent_delegation: bool,
58    /// Observers (online scoring of production sessions). Experimental.
59    pub observers: bool,
60    /// Public Chat (isolated, public-facing chat web app + `public_chat`
61    /// channel). Experimental. Gates the public endpoints, channel creation,
62    /// the builder UI, and the public web route. See `knowledge/integrations/public-chat.md`.
63    pub public_chat: bool,
64    /// Browser-native WebMCP tools exposed by the authenticated Everruns UI.
65    /// Experimental remote-control surface; requires deployment enablement and
66    /// per-org opt-in. See `knowledge/ui/webmcp.md`.
67    pub webmcp: bool,
68}
69
70/// Untyped API representation of feature flags: a generic `{ "<flag>": bool }` map.
71///
72/// Decision: the public API is intentionally untyped. The set of flags churns
73/// frequently; encoding each flag as a named schema property would force a
74/// `docs/api/openapi.json` change on every add/remove. A generic string→bool map
75/// keeps the API spec stable. The frontend layers its own typed view on top.
76#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
77#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
78#[serde(transparent)]
79pub struct FeatureFlagMap(pub BTreeMap<String, bool>);
80
81impl From<&FeatureFlags> for FeatureFlagMap {
82    fn from(flags: &FeatureFlags) -> Self {
83        flags.to_map()
84    }
85}
86
87impl From<FeatureFlags> for FeatureFlagMap {
88    fn from(flags: FeatureFlags) -> Self {
89        flags.to_map()
90    }
91}
92
93/// Metadata for an API-visible feature flag (org opt-in UI + catalog).
94#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
95pub struct FeatureFlagDefinition {
96    /// Stable flag key (matches `FeatureFlags` field / `is_enabled` name).
97    pub name: &'static str,
98    /// Human-readable title for settings UI.
99    pub label: &'static str,
100    /// Short description of what the flag gates.
101    pub description: &'static str,
102    /// When true, shown with experimental badges in the UI.
103    pub experimental: bool,
104}
105
106/// All API-visible flags that organizations may opt into when the deployment allows them.
107pub const API_FEATURE_FLAG_DEFINITIONS: &[FeatureFlagDefinition] = &[
108    FeatureFlagDefinition {
109        name: "global_chat",
110        label: "Platform Chat",
111        description: "Adds a personal Platform Chat assistant you can open from the sidebar or the \
112             /chat page anywhere in the app. It's a quick scratchpad to talk to your agents \
113             without first setting up a dedicated app or channel. Turning it off disables the \
114             whole feature — the chat page, the sidebar entry, and its session and voice APIs all \
115             become unavailable.",
116        experimental: true,
117    },
118    FeatureFlagDefinition {
119        name: "notifications",
120        label: "Notifications",
121        description: "Turns on the in-app notification bell, toasts, and live updates. You get \
122             alerted in real time when something you care about happens, instead of refreshing \
123             or checking back manually.",
124        experimental: true,
125    },
126    FeatureFlagDefinition {
127        name: "evals",
128        label: "Evals",
129        description: "Lets you define and run behavioral evals against your agents. Use it to \
130             confirm an agent responds the way you expect and to catch regressions as you change \
131             prompts or models.",
132        experimental: true,
133    },
134    FeatureFlagDefinition {
135        name: "skills",
136        label: "Skills",
137        description: "Create and manage reusable instruction packages that teach agents \
138             specialized workflows.",
139        experimental: true,
140    },
141    FeatureFlagDefinition {
142        name: "memory",
143        label: "Memory",
144        description: "Manage knowledge stores agents can read, including manual notes and \
145             synchronized files.",
146        experimental: true,
147    },
148    FeatureFlagDefinition {
149        name: "knowledge",
150        label: "Knowledge indexes",
151        description: "Connect external document collections and make them searchable by agents.",
152        experimental: true,
153    },
154    FeatureFlagDefinition {
155        name: "plugins",
156        label: "Plugins",
157        description: "Install and manage extensions that add integrations, skills, and other \
158             capabilities.",
159        experimental: true,
160    },
161    FeatureFlagDefinition {
162        name: "app_budgets",
163        label: "App budgets",
164        description: "Adds spending limits scoped to individual apps and channels, with automatic \
165             resets on a schedule. It helps you cap and control costs so a single app or channel \
166             can't run away with your usage.",
167        experimental: true,
168    },
169    FeatureFlagDefinition {
170        name: "agent_versions",
171        label: "Agent versions",
172        description: "Captures immutable snapshots of your agents so you can fork, roll back, and \
173             pin apps to a specific version. This gives you a safety net to experiment freely \
174             and return to a known-good agent at any time.",
175        experimental: true,
176    },
177    FeatureFlagDefinition {
178        name: "voice",
179        label: "Voice",
180        description: "Enables realtime voice in chat with microphone controls. You can talk to \
181             your agents and hear responses instead of typing, for a hands-free conversation.",
182        experimental: true,
183    },
184    FeatureFlagDefinition {
185        name: "observers",
186        label: "Observers",
187        description: "Runs automatic online scoring on your production sessions. It continuously \
188             evaluates live conversations so you can monitor quality on real traffic without \
189             manually reviewing each one.",
190        experimental: true,
191    },
192    FeatureFlagDefinition {
193        name: "public_chat",
194        label: "Public Chat",
195        description: "Isolated, public-facing chat web app and the public_chat channel.",
196        experimental: true,
197    },
198    FeatureFlagDefinition {
199        name: "webmcp",
200        label: "WebMCP UI tools",
201        description: "Exposes a small browser-native tool surface from the authenticated UI so a \
202             browser agent can search, navigate, and perform confirmed actions in Everruns.",
203        experimental: true,
204    },
205];
206
207impl FeatureFlags {
208    /// Effective flags for an organization: deployment/system gates AND explicit org opt-in.
209    ///
210    /// Org overrides default to disabled; a flag is on only when the deployment allows it
211    /// and the org has opted in (`enabled: true` in storage).
212    pub fn for_org(system: &Self, org_enabled: &std::collections::HashMap<String, bool>) -> Self {
213        let opt_in = |name: &str, system_on: bool| -> bool {
214            system_on && org_enabled.get(name).copied().unwrap_or(false)
215        };
216        Self {
217            global_chat: opt_in("global_chat", system.global_chat),
218            notifications: opt_in("notifications", system.notifications),
219            evals: opt_in("evals", system.evals),
220            skills: opt_in("skills", system.skills),
221            memory: opt_in("memory", system.memory),
222            knowledge: opt_in("knowledge", system.knowledge),
223            plugins: opt_in("plugins", system.plugins),
224            app_budgets: opt_in("app_budgets", system.app_budgets),
225            agent_versions: opt_in("agent_versions", system.agent_versions),
226            voice: opt_in("voice", system.voice),
227            agent_delegation: opt_in("agent_delegation", system.agent_delegation),
228            observers: opt_in("observers", system.observers),
229            public_chat: opt_in("public_chat", system.public_chat),
230            webmcp: opt_in("webmcp", system.webmcp),
231        }
232    }
233
234    /// Compute feature flags from environment variables and deployment grade.
235    pub fn from_env(grade: &DeploymentGrade) -> Self {
236        Self {
237            global_chat: experimental_flag("FEATURE_GLOBAL_CHAT", grade),
238            notifications: experimental_flag("FEATURE_NOTIFICATIONS", grade),
239            evals: experimental_flag("FEATURE_EVALS", grade),
240            skills: experimental_flag("FEATURE_SKILLS", grade),
241            memory: experimental_flag("FEATURE_MEMORY", grade),
242            knowledge: experimental_flag("FEATURE_KNOWLEDGE", grade),
243            plugins: experimental_flag("FEATURE_PLUGINS", grade),
244            app_budgets: experimental_flag("FEATURE_APP_BUDGETS", grade),
245            agent_versions: experimental_flag("FEATURE_AGENT_VERSIONS", grade),
246            voice: experimental_flag("FEATURE_VOICE", grade),
247            agent_delegation: experimental_flag("FEATURE_AGENT_DELEGATION", grade),
248            observers: experimental_flag("FEATURE_OBSERVERS", grade),
249            public_chat: experimental_flag("FEATURE_PUBLIC_CHAT", grade),
250            webmcp: experimental_flag("FEATURE_WEBMCP", grade),
251        }
252    }
253
254    /// Resolve the current feature flags from env + the env-derived deployment grade.
255    /// Convenience for callers that don't have a `FeatureFlags` instance handy.
256    pub fn current() -> Self {
257        Self::from_env(&DeploymentGrade::from_env())
258    }
259
260    /// Generic `name -> enabled` map for the untyped API representation.
261    ///
262    /// Keys and values match the JSON wire format of the typed `FeatureFlags`
263    /// response body. The JSON content is equivalent; only the key order differs
264    /// (`BTreeMap` sorts keys, whereas the struct serializes in field order), which
265    /// is irrelevant to JSON consumers.
266    pub fn to_map(&self) -> FeatureFlagMap {
267        FeatureFlagMap(BTreeMap::from([
268            ("global_chat".to_string(), self.global_chat),
269            ("notifications".to_string(), self.notifications),
270            ("evals".to_string(), self.evals),
271            ("skills".to_string(), self.skills),
272            ("memory".to_string(), self.memory),
273            ("knowledge".to_string(), self.knowledge),
274            ("plugins".to_string(), self.plugins),
275            ("app_budgets".to_string(), self.app_budgets),
276            ("agent_versions".to_string(), self.agent_versions),
277            ("voice".to_string(), self.voice),
278            ("agent_delegation".to_string(), self.agent_delegation),
279            ("observers".to_string(), self.observers),
280            ("public_chat".to_string(), self.public_chat),
281            ("webmcp".to_string(), self.webmcp),
282        ]))
283    }
284
285    /// Look up a flag by name (for dynamic/string-based access).
286    pub fn is_enabled(&self, flag: &str) -> bool {
287        match flag {
288            "global_chat" => self.global_chat,
289            "notifications" => self.notifications,
290            "evals" => self.evals,
291            "skills" => self.skills,
292            "memory" => self.memory,
293            "knowledge" => self.knowledge,
294            "plugins" => self.plugins,
295            "app_budgets" => self.app_budgets,
296            "agent_versions" => self.agent_versions,
297            "voice" => self.voice,
298            "agent_delegation" => self.agent_delegation,
299            "observers" => self.observers,
300            "public_chat" => self.public_chat,
301            "webmcp" => self.webmcp,
302            _ => false,
303        }
304    }
305
306    /// All flags enabled (for testing).
307    #[cfg(test)]
308    pub fn all_enabled() -> Self {
309        Self {
310            global_chat: true,
311            notifications: true,
312            evals: true,
313            skills: true,
314            memory: true,
315            knowledge: true,
316            plugins: true,
317            app_budgets: true,
318            agent_versions: true,
319            voice: true,
320            agent_delegation: true,
321            observers: true,
322            public_chat: true,
323            webmcp: true,
324        }
325    }
326}
327
328/// Backend-only feature flags. Not exposed via API or frontend.
329///
330/// Used for internal gating (capability registration, infrastructure behavior).
331#[derive(Debug, Default, Clone, PartialEq, Eq)]
332pub struct InternalFeatureFlags {
333    /// Docker container capability. Disabled by default on all envs.
334    /// Enable via `FEATURE_DOCKER_CAPABILITY=true`.
335    pub docker_capability: bool,
336    /// Self-hosted container sandbox capability and coding harness.
337    /// Disabled by default on all envs.
338    /// Enable via `FEATURE_CONTAINER_SANDBOX=true`, or via the legacy
339    /// fallback `FEATURE_DOCKER_CAPABILITY=true` when
340    /// `FEATURE_CONTAINER_SANDBOX` is unset.
341    pub container_sandbox: bool,
342    /// Managed session-owned sandbox capability and lifecycle orchestration.
343    /// Experimental and disabled by default.
344    pub session_sandbox: bool,
345    /// Machine-payment capabilities (e.g. the Parallel paid search/extract/task
346    /// capability). Gates registration of any capability that spends real money
347    /// through `PaymentAuthority`. Disabled by default on all envs, including dev,
348    /// because spend is irreversible. Enable via `FEATURE_MACHINE_PAYMENTS=true`.
349    pub machine_payments: bool,
350    /// Experimental sandboxed Lua execution capability (`knowledge/execution/lua-execution.md`).
351    /// Disabled by default; requires the `lua` cargo feature to be compiled in to
352    /// actually run scripts. Enable via `FEATURE_LUA=true`.
353    pub lua: bool,
354}
355
356impl InternalFeatureFlags {
357    /// Compute internal feature flags from environment variables.
358    pub fn from_env() -> Self {
359        let docker_capability = standard_flag("FEATURE_DOCKER_CAPABILITY", false);
360
361        Self {
362            docker_capability,
363            container_sandbox: standard_flag("FEATURE_CONTAINER_SANDBOX", docker_capability),
364            session_sandbox: standard_flag("FEATURE_SESSION_SANDBOX", false),
365            machine_payments: standard_flag("FEATURE_MACHINE_PAYMENTS", false),
366            lua: standard_flag("FEATURE_LUA", false),
367        }
368    }
369
370    /// Look up a flag by name (for dynamic/string-based access).
371    pub fn is_enabled(&self, flag: &str) -> bool {
372        match flag {
373            "docker_capability" => self.docker_capability,
374            "container_sandbox" => self.container_sandbox,
375            "session_sandbox" => self.session_sandbox,
376            "machine_payments" => self.machine_payments,
377            "lua" => self.lua,
378            _ => false,
379        }
380    }
381}
382
383/// Resolve an experimental flag.
384///
385/// Priority: explicit env var > experimental default (enabled in dev) > false.
386fn experimental_flag(env_var: &str, grade: &DeploymentGrade) -> bool {
387    if let Ok(val) = std::env::var(env_var) {
388        return val == "true" || val == "1";
389    }
390    grade.experimental_features_enabled()
391}
392
393/// Resolve a standard (non-experimental) flag.
394///
395/// Priority: explicit env var > default.
396fn standard_flag(env_var: &str, default: bool) -> bool {
397    std::env::var(env_var)
398        .map(|v| v == "true" || v == "1")
399        .unwrap_or(default)
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    // Env-var-mutating tests must not run in parallel.
407    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
408
409    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
410        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
411    }
412
413    /// Restore an env var to a previously captured value, or remove it if it was unset.
414    fn restore_env(key: &str, prev: Option<String>) {
415        match prev {
416            Some(value) => unsafe { std::env::set_var(key, value) },
417            None => unsafe { std::env::remove_var(key) },
418        }
419    }
420
421    #[test]
422    fn test_default_flags() {
423        let flags = FeatureFlags::default();
424        assert!(!flags.global_chat);
425        assert!(!flags.notifications);
426    }
427
428    // SAFETY: env var tests must run single-threaded (--test-threads=1).
429    // set_var/remove_var are unsafe in edition 2024 due to thread-safety.
430
431    #[test]
432    fn test_experimental_enabled_in_dev() {
433        let _lock = lock_env();
434        unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
435        unsafe { std::env::remove_var("FEATURE_EVALS") };
436        let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
437        assert!(flags.global_chat);
438        assert!(flags.evals);
439    }
440
441    #[test]
442    fn test_experimental_disabled_in_prod() {
443        let _lock = lock_env();
444        unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
445        unsafe { std::env::remove_var("FEATURE_EVALS") };
446        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
447        assert!(!flags.global_chat);
448        assert!(!flags.evals);
449    }
450
451    #[test]
452    fn test_env_override_enables_in_prod() {
453        let _lock = lock_env();
454        unsafe { std::env::set_var("FEATURE_GLOBAL_CHAT", "true") };
455        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
456        assert!(flags.global_chat);
457        unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
458    }
459
460    #[test]
461    fn test_env_override_disables_in_dev() {
462        let _lock = lock_env();
463        unsafe { std::env::set_var("FEATURE_GLOBAL_CHAT", "false") };
464        let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
465        assert!(!flags.global_chat);
466        unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
467    }
468
469    #[test]
470    fn test_is_enabled_dynamic() {
471        let flags = FeatureFlags {
472            global_chat: true,
473            notifications: true,
474            evals: true,
475            skills: true,
476            memory: true,
477            knowledge: true,
478            plugins: true,
479            app_budgets: true,
480            agent_versions: true,
481            voice: true,
482            agent_delegation: true,
483            observers: true,
484            public_chat: true,
485            webmcp: true,
486        };
487        assert!(flags.is_enabled("global_chat"));
488        assert!(flags.is_enabled("notifications"));
489        assert!(flags.is_enabled("evals"));
490        assert!(flags.is_enabled("skills"));
491        assert!(flags.is_enabled("memory"));
492        assert!(flags.is_enabled("knowledge"));
493        assert!(flags.is_enabled("plugins"));
494        assert!(flags.is_enabled("app_budgets"));
495        assert!(flags.is_enabled("agent_versions"));
496        assert!(flags.is_enabled("voice"));
497        assert!(flags.is_enabled("agent_delegation"));
498        assert!(flags.is_enabled("observers"));
499        assert!(flags.is_enabled("public_chat"));
500        assert!(
501            !flags.is_enabled("mcp_endpoint"),
502            "MCP is a product surface, not a feature flag"
503        );
504        assert!(flags.is_enabled("webmcp"));
505        assert!(!flags.is_enabled("nonexistent"));
506    }
507
508    #[test]
509    fn test_serialization() {
510        let flags = FeatureFlags {
511            global_chat: true,
512            notifications: true,
513            evals: true,
514            skills: true,
515            memory: true,
516            knowledge: true,
517            plugins: true,
518            app_budgets: true,
519            agent_versions: true,
520            voice: true,
521            agent_delegation: true,
522            observers: true,
523            public_chat: true,
524            webmcp: true,
525        };
526        let json = serde_json::to_string(&flags).unwrap();
527        assert!(json.contains("\"global_chat\":true"));
528        assert!(json.contains("\"notifications\":true"));
529        assert!(json.contains("\"app_budgets\":true"));
530        assert!(json.contains("\"agent_versions\":true"));
531        assert!(json.contains("\"voice\":true"));
532        assert!(json.contains("\"agent_delegation\":true"));
533        assert!(json.contains("\"observers\":true"));
534        assert!(json.contains("\"webmcp\":true"));
535
536        let parsed: FeatureFlags = serde_json::from_str(&json).unwrap();
537        assert_eq!(flags, parsed);
538    }
539
540    #[test]
541    fn test_to_map_matches_serialized_flags() {
542        // `to_map` must produce the same keys/values as the struct's JSON form
543        // (compared as `serde_json::Value`, so key order is ignored). If a field is
544        // added to `FeatureFlags` but not to `to_map`, this fails — keeping the
545        // untyped API representation in sync with the struct.
546        let flags = FeatureFlags::all_enabled();
547        let typed: serde_json::Value = serde_json::to_value(&flags).unwrap();
548        let map: serde_json::Value = serde_json::to_value(flags.to_map()).unwrap();
549        assert_eq!(typed, map);
550
551        let default_typed: serde_json::Value =
552            serde_json::to_value(FeatureFlags::default()).unwrap();
553        let default_map: serde_json::Value =
554            serde_json::to_value(FeatureFlags::default().to_map()).unwrap();
555        assert_eq!(default_typed, default_map);
556    }
557
558    #[test]
559    fn test_mcp_endpoint_is_not_in_api_feature_flag_catalog() {
560        assert!(
561            API_FEATURE_FLAG_DEFINITIONS
562                .iter()
563                .all(|definition| definition.name != "mcp_endpoint")
564        );
565    }
566
567    #[test]
568    fn test_agent_delegation_enabled_in_dev() {
569        let _lock = lock_env();
570        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
571        let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
572        assert!(flags.agent_delegation);
573    }
574
575    #[test]
576    fn test_agent_delegation_disabled_in_prod() {
577        let _lock = lock_env();
578        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
579        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
580        assert!(!flags.agent_delegation);
581    }
582
583    #[test]
584    fn test_agent_delegation_env_override_in_prod() {
585        let _lock = lock_env();
586        unsafe { std::env::set_var("FEATURE_AGENT_DELEGATION", "true") };
587        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
588        assert!(flags.agent_delegation);
589        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
590    }
591
592    #[test]
593    fn test_standard_flag() {
594        let _lock = lock_env();
595        unsafe { std::env::remove_var("FEATURE_TEST_STD") };
596        assert!(!standard_flag("FEATURE_TEST_STD", false));
597        assert!(standard_flag("FEATURE_TEST_STD", true));
598
599        unsafe { std::env::set_var("FEATURE_TEST_STD", "1") };
600        assert!(standard_flag("FEATURE_TEST_STD", false));
601        unsafe { std::env::remove_var("FEATURE_TEST_STD") };
602    }
603
604    #[test]
605    fn test_notifications_enabled_in_dev() {
606        let _lock = lock_env();
607        unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
608        let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
609        assert!(flags.notifications);
610    }
611
612    #[test]
613    fn test_notifications_disabled_in_prod() {
614        let _lock = lock_env();
615        unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
616        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
617        assert!(!flags.notifications);
618    }
619
620    #[test]
621    fn test_for_org_requires_system_and_opt_in() {
622        let system = FeatureFlags {
623            global_chat: true,
624            evals: true,
625            ..FeatureFlags::default()
626        };
627        let mut org = std::collections::HashMap::new();
628        org.insert("global_chat".to_string(), true);
629        let effective = FeatureFlags::for_org(&system, &org);
630        assert!(effective.global_chat);
631        assert!(!effective.evals);
632
633        let effective_none = FeatureFlags::for_org(&system, &std::collections::HashMap::new());
634        assert!(!effective_none.global_chat);
635    }
636
637    #[test]
638    fn test_optional_ui_modules_require_org_opt_in() {
639        let system = FeatureFlags {
640            evals: true,
641            skills: true,
642            memory: true,
643            knowledge: true,
644            plugins: true,
645            ..FeatureFlags::default()
646        };
647        let disabled = FeatureFlags::for_org(&system, &std::collections::HashMap::new());
648        assert!(!disabled.evals);
649        assert!(!disabled.skills);
650        assert!(!disabled.memory);
651        assert!(!disabled.knowledge);
652        assert!(!disabled.plugins);
653
654        let org = std::collections::HashMap::from([
655            ("evals".to_string(), true),
656            ("skills".to_string(), true),
657            ("memory".to_string(), true),
658            ("knowledge".to_string(), true),
659            ("plugins".to_string(), true),
660        ]);
661        let enabled = FeatureFlags::for_org(&system, &org);
662        assert!(enabled.evals);
663        assert!(enabled.skills);
664        assert!(enabled.memory);
665        assert!(enabled.knowledge);
666        assert!(enabled.plugins);
667    }
668
669    #[test]
670    fn test_for_org_cannot_enable_when_system_off() {
671        let system = FeatureFlags::default();
672        let mut org = std::collections::HashMap::new();
673        org.insert("global_chat".to_string(), true);
674        let effective = FeatureFlags::for_org(&system, &org);
675        assert!(!effective.global_chat);
676    }
677
678    #[test]
679    fn test_notifications_respects_env_override() {
680        let _lock = lock_env();
681        unsafe { std::env::set_var("FEATURE_NOTIFICATIONS", "true") };
682        let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
683        assert!(flags.notifications);
684        unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
685    }
686
687    // =========================================================================
688    // InternalFeatureFlags tests
689    // =========================================================================
690
691    #[test]
692    fn test_internal_default_flags() {
693        let flags = InternalFeatureFlags::default();
694        assert!(!flags.docker_capability);
695        assert!(!flags.container_sandbox);
696        assert!(!flags.session_sandbox);
697        assert!(!flags.machine_payments);
698    }
699
700    #[test]
701    fn test_docker_capability_flag_disabled_by_default_in_dev() {
702        let _lock = lock_env();
703        unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
704        let flags = InternalFeatureFlags::from_env();
705        assert!(
706            !flags.docker_capability,
707            "docker_capability should be disabled by default even in dev"
708        );
709    }
710
711    #[test]
712    fn test_docker_capability_flag_enabled_by_env_override() {
713        let _lock = lock_env();
714        unsafe { std::env::set_var("FEATURE_DOCKER_CAPABILITY", "true") };
715        let flags = InternalFeatureFlags::from_env();
716        assert!(flags.docker_capability);
717        unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
718    }
719
720    #[test]
721    fn test_container_sandbox_flag_enabled_by_env_override() {
722        let _lock = lock_env();
723        unsafe { std::env::set_var("FEATURE_CONTAINER_SANDBOX", "true") };
724        unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
725        let flags = InternalFeatureFlags::from_env();
726        assert!(flags.container_sandbox);
727        unsafe { std::env::remove_var("FEATURE_CONTAINER_SANDBOX") };
728    }
729
730    #[test]
731    fn test_container_sandbox_flag_falls_back_to_legacy_docker_flag() {
732        let _lock = lock_env();
733        unsafe { std::env::remove_var("FEATURE_CONTAINER_SANDBOX") };
734        unsafe { std::env::set_var("FEATURE_DOCKER_CAPABILITY", "true") };
735        let flags = InternalFeatureFlags::from_env();
736        assert!(flags.container_sandbox);
737        unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
738    }
739
740    #[test]
741    fn test_internal_is_enabled_dynamic() {
742        let flags = InternalFeatureFlags {
743            docker_capability: true,
744            container_sandbox: true,
745            session_sandbox: true,
746            machine_payments: true,
747            lua: true,
748        };
749        assert!(flags.is_enabled("docker_capability"));
750        assert!(flags.is_enabled("container_sandbox"));
751        assert!(flags.is_enabled("session_sandbox"));
752        assert!(flags.is_enabled("machine_payments"));
753        assert!(flags.is_enabled("lua"));
754        assert!(!flags.is_enabled("nonexistent"));
755    }
756
757    #[test]
758    fn test_machine_payments_disabled_by_default() {
759        let _lock = lock_env();
760        let prev = std::env::var("FEATURE_MACHINE_PAYMENTS").ok();
761        unsafe { std::env::remove_var("FEATURE_MACHINE_PAYMENTS") };
762        let flags = InternalFeatureFlags::from_env();
763        assert!(
764            !flags.machine_payments,
765            "machine_payments should be disabled by default on all envs"
766        );
767        restore_env("FEATURE_MACHINE_PAYMENTS", prev);
768    }
769
770    #[test]
771    fn test_machine_payments_enabled_by_env_override() {
772        let _lock = lock_env();
773        let prev = std::env::var("FEATURE_MACHINE_PAYMENTS").ok();
774        unsafe { std::env::set_var("FEATURE_MACHINE_PAYMENTS", "true") };
775        let flags = InternalFeatureFlags::from_env();
776        assert!(flags.machine_payments);
777        restore_env("FEATURE_MACHINE_PAYMENTS", prev);
778    }
779
780    #[test]
781    fn test_session_sandbox_flag_enabled_by_env_override() {
782        let _lock = lock_env();
783        unsafe { std::env::set_var("FEATURE_SESSION_SANDBOX", "true") };
784        let flags = InternalFeatureFlags::from_env();
785        assert!(flags.session_sandbox);
786        unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
787    }
788}