Skip to main content

osp_cli/app/
runtime.rs

1//! Runtime-scoped host state.
2
3use std::collections::HashSet;
4use std::path::PathBuf;
5
6use crate::config::{ResolvedConfig, RuntimeLoadOptions};
7use crate::core::command_policy::{
8    AccessReason, CommandAccess, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
9    VisibilityMode,
10};
11use crate::native::NativeCommandRegistry;
12use crate::plugin::PluginManager;
13use crate::plugin::config::{PluginConfigEntry, PluginConfigEnv, PluginConfigEnvCache};
14use crate::ui::RenderSettings;
15use crate::ui::messages::MessageLevel;
16use crate::ui::theme_loader::ThemeCatalog;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum TerminalKind {
20    Cli,
21    Repl,
22}
23
24impl TerminalKind {
25    pub fn as_config_terminal(self) -> &'static str {
26        match self {
27            TerminalKind::Cli => "cli",
28            TerminalKind::Repl => "repl",
29        }
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct RuntimeContext {
35    profile_override: Option<String>,
36    terminal_kind: TerminalKind,
37    terminal_env: Option<String>,
38}
39
40impl RuntimeContext {
41    pub fn new(
42        profile_override: Option<String>,
43        terminal_kind: TerminalKind,
44        terminal_env: Option<String>,
45    ) -> Self {
46        Self {
47            profile_override: profile_override
48                .map(|value| value.trim().to_ascii_lowercase())
49                .filter(|value| !value.is_empty()),
50            terminal_kind,
51            terminal_env,
52        }
53    }
54
55    pub fn profile_override(&self) -> Option<&str> {
56        self.profile_override.as_deref()
57    }
58
59    pub fn terminal_kind(&self) -> TerminalKind {
60        self.terminal_kind
61    }
62
63    pub fn terminal_env(&self) -> Option<&str> {
64        self.terminal_env.as_deref()
65    }
66}
67
68pub struct ConfigState {
69    resolved: ResolvedConfig,
70    revision: u64,
71}
72
73impl ConfigState {
74    pub fn new(resolved: ResolvedConfig) -> Self {
75        Self {
76            resolved,
77            revision: 1,
78        }
79    }
80
81    pub fn resolved(&self) -> &ResolvedConfig {
82        &self.resolved
83    }
84
85    pub fn revision(&self) -> u64 {
86        self.revision
87    }
88
89    pub fn replace_resolved(&mut self, next: ResolvedConfig) -> bool {
90        if self.resolved == next {
91            return false;
92        }
93
94        self.resolved = next;
95        self.revision += 1;
96        true
97    }
98
99    pub fn transaction<F, E>(&mut self, mutator: F) -> Result<bool, E>
100    where
101        F: FnOnce(&ResolvedConfig) -> Result<ResolvedConfig, E>,
102    {
103        let current = self.resolved.clone();
104        let candidate = mutator(&current)?;
105        Ok(self.replace_resolved(candidate))
106    }
107}
108
109#[derive(Debug, Clone)]
110pub struct UiState {
111    pub render_settings: RenderSettings,
112    pub message_verbosity: MessageLevel,
113    pub debug_verbosity: u8,
114}
115
116#[derive(Debug, Clone, Default)]
117pub struct LaunchContext {
118    pub plugin_dirs: Vec<PathBuf>,
119    pub config_root: Option<PathBuf>,
120    pub cache_root: Option<PathBuf>,
121    pub runtime_load: RuntimeLoadOptions,
122}
123
124pub struct AppClients {
125    pub plugins: PluginManager,
126    pub native_commands: NativeCommandRegistry,
127    plugin_config_env: PluginConfigEnvCache,
128}
129
130impl AppClients {
131    pub fn new(plugins: PluginManager, native_commands: NativeCommandRegistry) -> Self {
132        Self {
133            plugins,
134            native_commands,
135            plugin_config_env: PluginConfigEnvCache::default(),
136        }
137    }
138
139    pub(crate) fn plugin_config_env(&self, config: &ConfigState) -> PluginConfigEnv {
140        self.plugin_config_env.collect(config)
141    }
142
143    pub(crate) fn plugin_config_entries(
144        &self,
145        config: &ConfigState,
146        plugin_id: &str,
147    ) -> Vec<PluginConfigEntry> {
148        let config_env = self.plugin_config_env(config);
149        let mut merged = std::collections::BTreeMap::new();
150        for entry in config_env.shared {
151            merged.insert(entry.env_key.clone(), entry);
152        }
153        if let Some(entries) = config_env.by_plugin_id.get(plugin_id) {
154            for entry in entries {
155                merged.insert(entry.env_key.clone(), entry.clone());
156            }
157        }
158        merged.into_values().collect()
159    }
160}
161
162pub struct AppRuntime {
163    pub context: RuntimeContext,
164    pub config: ConfigState,
165    pub ui: UiState,
166    pub auth: AuthState,
167    pub(crate) themes: ThemeCatalog,
168    pub launch: LaunchContext,
169}
170
171pub struct AuthState {
172    builtins_allowlist: Option<HashSet<String>>,
173    external_allowlist: Option<HashSet<String>>,
174    policy_context: CommandPolicyContext,
175    builtin_policy: CommandPolicyRegistry,
176    external_policy: CommandPolicyRegistry,
177}
178
179impl AuthState {
180    pub fn from_resolved(config: &ResolvedConfig) -> Self {
181        Self {
182            builtins_allowlist: parse_allowlist(config.get_string("auth.visible.builtins")),
183            // Non-builtin top-level commands currently still use the historical
184            // `auth.visible.plugins` key. That surface now covers both external
185            // plugins and native registered integrations dispatched via the
186            // generic external command path.
187            external_allowlist: parse_allowlist(config.get_string("auth.visible.plugins")),
188            policy_context: CommandPolicyContext::default(),
189            builtin_policy: CommandPolicyRegistry::default(),
190            external_policy: CommandPolicyRegistry::default(),
191        }
192    }
193
194    pub fn policy_context(&self) -> &CommandPolicyContext {
195        &self.policy_context
196    }
197
198    pub fn set_policy_context(&mut self, context: CommandPolicyContext) {
199        self.policy_context = context;
200    }
201
202    pub fn builtin_policy(&self) -> &CommandPolicyRegistry {
203        &self.builtin_policy
204    }
205
206    pub fn builtin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
207        &mut self.builtin_policy
208    }
209
210    pub fn external_policy(&self) -> &CommandPolicyRegistry {
211        &self.external_policy
212    }
213
214    pub fn external_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
215        &mut self.external_policy
216    }
217
218    pub fn replace_external_policy(&mut self, registry: CommandPolicyRegistry) {
219        self.external_policy = registry;
220    }
221
222    pub fn builtin_access(&self, command: &str) -> CommandAccess {
223        command_access_for(
224            command,
225            &self.builtins_allowlist,
226            &self.builtin_policy,
227            &self.policy_context,
228        )
229    }
230
231    pub fn external_command_access(&self, command: &str) -> CommandAccess {
232        command_access_for(
233            command,
234            &self.external_allowlist,
235            &self.external_policy,
236            &self.policy_context,
237        )
238    }
239
240    pub fn is_builtin_visible(&self, command: &str) -> bool {
241        self.builtin_access(command).is_visible()
242    }
243
244    pub fn is_external_command_visible(&self, command: &str) -> bool {
245        self.external_command_access(command).is_visible()
246    }
247
248    pub fn plugin_policy(&self) -> &CommandPolicyRegistry {
249        self.external_policy()
250    }
251
252    pub fn plugin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
253        self.external_policy_mut()
254    }
255
256    pub fn replace_plugin_policy(&mut self, registry: CommandPolicyRegistry) {
257        self.replace_external_policy(registry);
258    }
259
260    pub fn plugin_command_access(&self, command: &str) -> CommandAccess {
261        self.external_command_access(command)
262    }
263
264    pub fn is_plugin_command_visible(&self, command: &str) -> bool {
265        self.is_external_command_visible(command)
266    }
267}
268
269fn parse_allowlist(raw: Option<&str>) -> Option<HashSet<String>> {
270    let raw = raw.map(str::trim).filter(|value| !value.is_empty())?;
271
272    if raw == "*" {
273        return None;
274    }
275
276    let values = raw
277        .split([',', ' '])
278        .map(str::trim)
279        .filter(|value| !value.is_empty())
280        .map(|value| value.to_ascii_lowercase())
281        .collect::<HashSet<String>>();
282    if values.is_empty() {
283        None
284    } else {
285        Some(values)
286    }
287}
288
289fn is_visible_in_allowlist(allowlist: &Option<HashSet<String>>, command: &str) -> bool {
290    match allowlist {
291        None => true,
292        Some(values) => values.contains(&command.to_ascii_lowercase()),
293    }
294}
295
296fn command_access_for(
297    command: &str,
298    allowlist: &Option<HashSet<String>>,
299    registry: &CommandPolicyRegistry,
300    context: &CommandPolicyContext,
301) -> CommandAccess {
302    let normalized = command.trim().to_ascii_lowercase();
303    let default_policy = CommandPolicy::new(crate::core::command_policy::CommandPath::new([
304        normalized.clone(),
305    ]))
306    .visibility(VisibilityMode::Public);
307    let mut access = registry
308        .evaluate(&default_policy.path, context)
309        .unwrap_or_else(|| crate::core::command_policy::evaluate_policy(&default_policy, context));
310
311    if !is_visible_in_allowlist(allowlist, &normalized) {
312        access = CommandAccess::hidden(AccessReason::HiddenByPolicy);
313    }
314
315    access
316}
317
318#[cfg(test)]
319mod tests {
320    use std::collections::HashSet;
321
322    use crate::config::{ConfigLayer, ConfigResolver, LoadedLayers, ResolveOptions};
323    use crate::core::command_policy::{
324        AccessReason, CommandPath, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
325        VisibilityMode,
326    };
327
328    use super::{
329        AuthState, ConfigState, RuntimeContext, TerminalKind, command_access_for,
330        is_visible_in_allowlist, parse_allowlist,
331    };
332
333    fn resolved_with(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
334        let mut file = ConfigLayer::default();
335        for (key, value) in entries {
336            file.set(*key, (*value).to_string());
337        }
338        ConfigResolver::from_loaded_layers(LoadedLayers {
339            file,
340            ..LoadedLayers::default()
341        })
342        .resolve(ResolveOptions::default())
343        .expect("config should resolve")
344    }
345
346    #[test]
347    fn runtime_context_and_allowlists_normalize_inputs() {
348        let context = RuntimeContext::new(
349            Some("  Dev  ".to_string()),
350            TerminalKind::Repl,
351            Some("xterm-256color".to_string()),
352        );
353        assert_eq!(context.profile_override(), Some("dev"));
354        assert_eq!(context.terminal_kind(), TerminalKind::Repl);
355        assert_eq!(context.terminal_env(), Some("xterm-256color"));
356
357        assert_eq!(parse_allowlist(None), None);
358        assert_eq!(parse_allowlist(Some("   ")), None);
359        assert_eq!(parse_allowlist(Some("*")), None);
360        assert_eq!(
361            parse_allowlist(Some(" LDAP, mreg ldap ")),
362            Some(HashSet::from(["ldap".to_string(), "mreg".to_string()]))
363        );
364
365        let allowlist = Some(HashSet::from(["ldap".to_string()]));
366        assert!(is_visible_in_allowlist(&allowlist, "LDAP"));
367        assert!(!is_visible_in_allowlist(&allowlist, "orch"));
368    }
369
370    #[test]
371    fn config_state_tracks_noops_changes_and_transaction_errors() {
372        let resolved = resolved_with(&[]);
373        let mut state = ConfigState::new(resolved.clone());
374        assert_eq!(state.revision(), 1);
375        assert!(!state.replace_resolved(resolved.clone()));
376        assert_eq!(state.revision(), 1);
377
378        let changed = resolved_with(&[("ui.format", "json")]);
379        assert!(state.replace_resolved(changed));
380        assert_eq!(state.revision(), 2);
381
382        let changed = state
383            .transaction(|current| {
384                let _ = current;
385                Ok::<_, &'static str>(resolved_with(&[("ui.format", "mreg")]))
386            })
387            .expect("transaction should succeed");
388        assert!(changed);
389        assert_eq!(state.revision(), 3);
390
391        let err = state
392            .transaction(|_| Err::<crate::config::ResolvedConfig, _>("boom"))
393            .expect_err("transaction error should propagate");
394        assert_eq!(err, "boom");
395        assert_eq!(state.revision(), 3);
396    }
397
398    #[test]
399    fn auth_state_and_command_access_layer_policy_overrides_on_allowlists() {
400        let resolved = resolved_with(&[
401            ("auth.visible.builtins", "config"),
402            ("auth.visible.plugins", "ldap"),
403        ]);
404        let mut auth = AuthState::from_resolved(&resolved);
405        auth.set_policy_context(
406            CommandPolicyContext::default()
407                .authenticated(true)
408                .with_capabilities(["orch.approval.decide"]),
409        );
410        assert!(auth.policy_context().authenticated);
411
412        auth.builtin_policy_mut().register(
413            CommandPolicy::new(CommandPath::new(["config"]))
414                .visibility(VisibilityMode::Authenticated),
415        );
416        assert!(auth.builtin_access("config").is_runnable());
417        assert!(auth.is_builtin_visible("config"));
418        assert!(!auth.is_builtin_visible("theme"));
419
420        let mut plugin_registry = CommandPolicyRegistry::new();
421        plugin_registry.register(
422            CommandPolicy::new(CommandPath::new(["ldap"]))
423                .visibility(VisibilityMode::CapabilityGated)
424                .require_capability("orch.approval.decide"),
425        );
426        plugin_registry.register(
427            CommandPolicy::new(CommandPath::new(["orch"]))
428                .visibility(VisibilityMode::Authenticated),
429        );
430        auth.replace_plugin_policy(plugin_registry);
431
432        assert!(auth.plugin_policy().contains(&CommandPath::new(["ldap"])));
433        assert!(
434            auth.plugin_policy_mut()
435                .contains(&CommandPath::new(["ldap"]))
436        );
437        assert!(auth.plugin_command_access("ldap").is_runnable());
438        assert!(auth.is_plugin_command_visible("ldap"));
439
440        let hidden = auth.plugin_command_access("orch");
441        assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
442        assert!(!hidden.is_visible());
443    }
444
445    #[test]
446    fn command_access_for_uses_registry_when_present_and_public_default_otherwise() {
447        let context = CommandPolicyContext::default();
448        let allowlist = Some(HashSet::from(["config".to_string()]));
449        let mut registry = CommandPolicyRegistry::new();
450        registry.register(
451            CommandPolicy::new(CommandPath::new(["config"]))
452                .visibility(VisibilityMode::Authenticated),
453        );
454
455        let denied = command_access_for("config", &allowlist, &registry, &context);
456        assert_eq!(denied.reasons, vec![AccessReason::Unauthenticated]);
457        assert!(denied.is_visible());
458        assert!(!denied.is_runnable());
459
460        let hidden = command_access_for("theme", &allowlist, &registry, &context);
461        assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
462        assert!(!hidden.is_visible());
463
464        let fallback =
465            command_access_for("config", &None, &CommandPolicyRegistry::default(), &context);
466        assert!(fallback.is_visible());
467        assert!(fallback.is_runnable());
468    }
469}