Skip to main content

oxicode/
services.rs

1//! Composition root for oxicode-cli.
2//!
3//! Wires concrete file-based port implementations (from `oxicode-fs`) to
4//! the `Oxicode` engine. Future run modes (TUI / print / RPC) build on
5//! top of the `Oxicode` produced here.
6//!
7//! Migration note:
8//! - Legacy `App` in `lib.rs` is the single-user interactive
9//!   composition. This module is the port-based composition.
10//! - Both paths coexist; new run modes consume `build_oxicode(...)` here.
11
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use anyhow::{Context, Result};
16
17use oxicode_sdk::Oxicode;
18use oxicode_sdk::fs::{
19    FileConfigStore, FileModelCatalog, FilePersonaProvider, FileSkillLoader, FileStateStore,
20    SimpleAccessGate, TomlCapabilityResolver,
21};
22use oxicode_sdk::inmem::{
23    CountingResourceMonitor, InMemoryCronScheduler, InMemoryMemoryStore, InProcessEventBus,
24};
25use oxicode_sdk::ports::InternalUrlRouter;
26use oxicode_sdk::ports::catalog::CatalogEvent;
27use oxicode_sdk::ports::fs::CatalogConfig;
28use oxicode_sdk::ports::inmem::url_router::CompositeUrlRouter;
29
30use crate::internal_urls::issue_handler::IssueProtocolHandler;
31use crate::internal_urls::memory_handler::MemoryProtocolHandler;
32use crate::internal_urls::pr_handler::PrProtocolHandler;
33
34/// Resolved paths under the oxicode home directory.
35#[derive(Debug, Clone)]
36pub struct OxicodePaths {
37    /// Root directory (`$OXICODE_HOME` or `$HOME/.oxicode`).
38    pub home: PathBuf,
39    /// `auth.json` location.
40    pub auth: PathBuf,
41    /// `settings.toml` location.
42    pub config: PathBuf,
43    /// Sessions directory.
44    pub sessions: PathBuf,
45    /// Skills root.
46    pub skills: PathBuf,
47    /// Oxi Foundation root. Independent from `home` and resolved
48    /// via `$OXI_FOUNDATION_HOME` or `~/.oxi/foundation/v1`. Set
49    /// to `None` when the foundation is not installed; the
50    /// composition root enters offline mode in that case.
51    pub foundation: Option<PathBuf>,
52}
53
54impl OxicodePaths {
55    /// Resolve from the conventional home directory.
56    pub fn from_home(home: impl Into<PathBuf>) -> Self {
57        let home = home.into();
58        Self {
59            auth: home.join("auth.json"),
60            config: home.join("settings.toml"),
61            sessions: home.join("sessions"),
62            skills: home.join("skills"),
63            home,
64            foundation: crate::foundation::foundation_root(),
65        }
66    }
67
68    /// Default — uses `$OXICODE_HOME` or `$HOME/.oxicode`.
69    pub fn default_paths() -> Result<Self> {
70        oxicode_sdk::fs::home_dir()
71            .map(Self::from_home)
72            .context("could not resolve oxicode home directory")
73    }
74}
75
76/// Build an `Oxicode` engine wired with file-based port implementations.
77///
78/// This is the **composition root** for oxicode-cli. The catalog port
79/// performs network I/O during `init()`. Errors there fall back to
80/// a noop catalog so the user can re-run `oxicode refresh` later.
81///
82/// `hook_runner` registers the user's configured [`HookRunner`](oxicode_sdk::ports::HookRunner)
83/// (global + approved-project `[[hooks]]`) on the SDK's port registry. Pass
84/// `None` to keep the noop runner (default).
85pub async fn build_oxicode(
86    paths: &OxicodePaths,
87    embedding_provider: Option<Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
88    hook_runner: Option<Arc<dyn oxicode_sdk::ports::HookRunner>>,
89) -> Result<Oxicode> {
90    build_oxicode_with_catalog(
91        paths,
92        build_catalog_config(paths),
93        embedding_provider,
94        hook_runner,
95    )
96    .await
97}
98
99/// Build an `Oxicode` engine with a custom catalog config. Useful for
100/// tests (e.g. pointing the catalog at a tempdir).
101///
102/// `hook_runner` follows the same semantics as [`build_oxicode`]: `Some`
103/// installs the runner on the SDK's port registry, `None` keeps the noop
104/// default.
105pub async fn build_oxicode_with_catalog(
106    paths: &OxicodePaths,
107    catalog_config: CatalogConfig,
108    embedding_provider: Option<Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
109    hook_runner: Option<Arc<dyn oxicode_sdk::ports::HookRunner>>,
110) -> Result<Oxicode> {
111    ensure_parent(&paths.auth)?;
112    ensure_parent(&paths.config)?;
113    ensure_parent(&paths.sessions)?;
114
115    // Foundation v1 host: when a foundation installation is present,
116    // resolve the profile (explicit id → role → env override →
117    // one-time compatibility import), look up the Keychain credential,
118    // and register ONLY the selected provider with the resolved key.
119    // Provider/model registration is gated on profile + credential
120    // validation succeeding — plan §3.b, §3.f. Other built-in
121    // providers remain constructable but cannot be invoked because
122    // they carry no credentials. The same pattern handles the
123    // `OXICODE_PROVIDER`/`OXICODE_MODEL` automation override.
124    let foundation_provider: Option<Arc<dyn oxicode_ai::Provider>> = if let Some(froot) = paths
125        .foundation
126        .clone()
127        .or_else(crate::foundation::foundation_root)
128    {
129        if crate::foundation::foundation_present(&froot) {
130            match resolve_and_register_profile(&froot).await {
131                Ok(p) => Some(p),
132                Err(e) => {
133                    tracing::warn!(
134                        "Foundation v1 profile resolution failed: {e}; \
135                             engine will start without a registered provider"
136                    );
137                    None
138                }
139            }
140        } else {
141            None
142        }
143    } else {
144        None
145    };
146
147    let catalog: Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog> =
148        match FileModelCatalog::init(catalog_config).await {
149            Ok(c) => c,
150            Err(e) => {
151                tracing::warn!(error = %e, "catalog init failed; continuing with noop");
152                oxicode_sdk::NoopModelCatalog::new()
153            }
154        };
155
156    let skill_loader = Arc::new(FileSkillLoader::single(&paths.skills));
157    let rule_registry: Arc<dyn oxicode_sdk::ports::RuleRegistry> =
158        Arc::new(oxicode_sdk::ports::NoopRuleRegistry);
159    let agent_artifact_store = crate::internal_urls::agent_handler::AgentArtifactStore::new();
160    let local_root = paths.home.join("local-artifacts");
161
162    let mut builder = oxicode_sdk::OxicodeBuilder::new()
163        .with_builtins()
164        .with_state(Arc::new(FileStateStore::new(&paths.sessions)))
165        .with_auth(crate::store::auth_storage::shared_auth_storage())
166        .with_config(Arc::new(FileConfigStore::new(&paths.config)))
167        .with_skills(skill_loader.clone())
168        .with_personas(Arc::new(FilePersonaProvider::new(
169            paths.home.join("personas"),
170        )))
171        .with_access(Arc::new(SimpleAccessGate::from_file(
172            paths.home.join("access.toml"),
173        )))
174        .with_capabilities(Arc::new(TomlCapabilityResolver::from_file(
175            paths.home.join("capabilities.toml"),
176        )))
177        .with_event_bus(InProcessEventBus::new(64))
178        .with_memory(Arc::new(InMemoryMemoryStore::new()))
179        .with_cron(Arc::new(InMemoryCronScheduler::new()))
180        .with_resources(Arc::new(CountingResourceMonitor::new()))
181        .with_catalog(catalog)
182        .with_url_router(build_url_router(
183            paths,
184            skill_loader,
185            rule_registry,
186            agent_artifact_store,
187            local_root,
188        ));
189
190    if let Some(ep) = embedding_provider {
191        builder = builder.with_embeddings(ep);
192    }
193    if let Some(runner) = hook_runner {
194        builder = builder.with_hooks(runner.clone());
195    }
196    if let Some(provider) = foundation_provider {
197        builder = builder.provider_arc("<foundation>", provider);
198    }
199
200    let oxicode = builder.build();
201
202    Ok(oxicode)
203}
204fn build_url_router(
205    paths: &OxicodePaths,
206    skill_loader: Arc<dyn oxicode_sdk::ports::SkillLoader>,
207    rule_registry: Arc<dyn oxicode_sdk::ports::RuleRegistry>,
208    agent_store: crate::internal_urls::agent_handler::AgentArtifactStore,
209    local_root: PathBuf,
210) -> Arc<dyn InternalUrlRouter> {
211    let memory_root = paths.home.join("memory");
212    let router = CompositeUrlRouter::new();
213    // Foundation v1 host: `memory://` resolves through the
214    // brain-backed handler when the foundation installation is
215    // present. When no foundation is present (test fixtures, host
216    // without oxibrain yet), the handler falls back to the legacy
217    // disk-rooted resolver so pre-Foundation callers continue to
218    // work.
219    let handler: Arc<dyn oxicode_sdk::ports::ProtocolHandler> =
220        if crate::foundation::foundation_present(
221            &crate::foundation::foundation_root()
222                .unwrap_or_else(|| std::path::PathBuf::from("~/.oxi/foundation/v1")),
223        ) {
224            let socket = crate::foundation::brain::default_socket_path();
225            let brain = Arc::new(crate::foundation::brain::BrainMemoryBackend::new(socket));
226            Arc::new(MemoryProtocolHandler::new(brain))
227        } else {
228            // Legacy disk-rooted fallback. NOT used under the
229            // Foundation v1 host — see
230            // `resolve_memory_url_legacy` for the deprecation
231            // context.
232            struct LegacyHandler {
233                memory_root: PathBuf,
234            }
235            #[async_trait::async_trait]
236            impl oxicode_sdk::ports::ProtocolHandler for LegacyHandler {
237                fn scheme(&self) -> &str {
238                    "memory"
239                }
240                async fn resolve(
241                    &self,
242                    url: &str,
243                    _selector: Option<&str>,
244                    _ctx: &oxicode_sdk::ports::ResolveContext,
245                ) -> Result<oxicode_sdk::ports::ResolvedUrl, oxicode_sdk::SdkError>
246                {
247                    let content = crate::internal_urls::memory_handler::resolve_memory_url_legacy(
248                        url,
249                        &self.memory_root,
250                    )
251                    .ok_or_else(|| oxicode_sdk::SdkError::PortNotConfigured { port: "memory" })?;
252                    let size = content.len();
253                    Ok(oxicode_sdk::ports::ResolvedUrl {
254                        url: url.to_string(),
255                        content,
256                        content_type: "text/markdown".to_string(),
257                        size: Some(size),
258                        source_path: None,
259                        notes: vec![],
260                        immutable: true,
261                    })
262                }
263            }
264            Arc::new(LegacyHandler { memory_root })
265        };
266    router.register(handler);
267    router.register(Arc::new(IssueProtocolHandler));
268    router.register(Arc::new(PrProtocolHandler));
269    router.register(Arc::new(
270        crate::internal_urls::skill_handler::SkillProtocolHandler::new(skill_loader),
271    ));
272    router.register(Arc::new(
273        crate::internal_urls::rule_handler::RuleProtocolHandler::new(rule_registry),
274    ));
275    router.register(Arc::new(
276        crate::internal_urls::agent_handler::AgentProtocolHandler::new(agent_store),
277    ));
278    router.register(Arc::new(
279        crate::internal_urls::local_handler::LocalProtocolHandler::new(local_root),
280    ));
281    Arc::new(router)
282}
283
284/// Build a `CatalogConfig` rooted at `paths.home`.
285fn build_catalog_config(paths: &OxicodePaths) -> CatalogConfig {
286    CatalogConfig {
287        cache_path: paths.home.join("cache").join("models-dev.json"),
288        etag_path: paths.home.join("cache").join("models-dev.json.etag"),
289        override_path: paths.home.join("catalog").join("overrides.toml"),
290        mtime_window: std::time::Duration::from_secs(60 * 60),
291        fetch_enabled: std::env::var("OXICODE_MODELS_DEV_DISABLE_FETCH")
292            .ok()
293            .map(|v| !matches!(v.as_str(), "1" | "true" | "TRUE"))
294            .unwrap_or(true),
295        models_dev_url: std::env::var("OXICODE_MODELS_DEV_URL")
296            .unwrap_or_else(|_| "https://models.dev".to_string()),
297        user_agent: format!("oxicode-cli/{}", env!("CARGO_PKG_VERSION")),
298        local_discovery_urls: local_discovery_from_env(),
299        snapshot_path: paths.home.join("cache").join("models-dev.json"),
300    }
301}
302
303/// Resolve local-discovery URLs from environment.
304///
305/// `OXICODE_LOCAL_DISCOVERY` is a comma-separated list of base URLs.
306fn local_discovery_from_env() -> Vec<String> {
307    std::env::var("OXICODE_LOCAL_DISCOVERY")
308        .ok()
309        .map(|s| {
310            s.split(',')
311                .map(|u| u.trim().to_string())
312                .filter(|u| !u.is_empty())
313                .collect()
314        })
315        .unwrap_or_default()
316}
317
318/// Spawn a background task that drains the catalog event channel and
319/// logs at info level.
320pub fn spawn_catalog_event_logger(
321    catalog: Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>,
322) -> tokio::task::JoinHandle<()> {
323    let mut rx = catalog.subscribe();
324    tokio::spawn(async move {
325        while let Ok(event) = rx.recv().await {
326            match event {
327                CatalogEvent::Updated {
328                    provider_count,
329                    model_count,
330                } => {
331                    tracing::info!(provider_count, model_count, "catalog refreshed");
332                }
333                CatalogEvent::RefreshFailed { reason, .. } => {
334                    tracing::warn!(reason, "catalog refresh failed");
335                }
336                CatalogEvent::OverrideApplied {
337                    path,
338                    provider_overrides,
339                    model_overrides,
340                } => {
341                    tracing::info!(
342                        path = %path.display(),
343                        provider_overrides,
344                        model_overrides,
345                        "catalog overrides applied"
346                    );
347                }
348                CatalogEvent::LocalDiscovered {
349                    base_url,
350                    model_count,
351                } => {
352                    tracing::info!(base_url, model_count, "local models discovered");
353                }
354            }
355        }
356    })
357}
358
359fn ensure_parent(path: &Path) -> Result<()> {
360    if let Some(parent) = path.parent() {
361        std::fs::create_dir_all(parent)
362            .with_context(|| format!("create_dir_all {}", parent.display()))?;
363    }
364    Ok(())
365}
366
367// ── Memory backend helpers ──────────────────────────────────────────────
368
369/// `true` when a Unix-domain socket file exists at `path` (best-effort:
370/// stat only, no connect). This is the "daemon installed" gate for durable
371/// memory — the Foundation layout gates profiles/packages, not the memory
372/// authority.
373pub(crate) fn brain_socket_present(path: &Path) -> bool {
374    #[cfg(unix)]
375    {
376        use std::os::unix::fs::FileTypeExt;
377        std::fs::symlink_metadata(path)
378            .map(|m| m.file_type().is_socket())
379            .unwrap_or(false)
380    }
381    #[cfg(not(unix))]
382    {
383        path.exists()
384    }
385}
386
387/// Create a memory backend if memory is enabled in settings.
388///
389/// Under the Oxi Foundation v1 host, the only durable-memory authority is the
390/// oxibrain daemon (plan §5). Local SQLite/Mnemopi/JSON/file-summary
391/// fallbacks are explicitly forbidden (§5.h, §6.f): the Foundation host MUST
392/// NOT silently run a second durable store.
393///
394/// Returns a [`crate::foundation::brain::BrainMemoryBackend`] when
395/// `memory_enabled` is set and the daemon's socket exists at the canonical
396/// path (or `$OXIBRAIN_SOCKET`). The backend is not eagerly connected — the
397/// first call attaches and surfaces `degraded` per call when the daemon is
398/// unreachable. When the socket is absent, returns `None`: the agent memory
399/// tools surface a typed "backend unavailable" result naming the socket and
400/// the recovery command (`oxibrain serve`). Code work continues; only
401/// durable-memory tool calls fail visibly.
402pub fn create_memory_backend(
403    settings: &crate::store::settings::Settings,
404) -> Option<Arc<dyn oxicode_agent::tools::MemoryBackend>> {
405    if !settings.memory_enabled {
406        return None;
407    }
408    let socket = crate::foundation::brain::default_socket_path();
409    if brain_socket_present(&socket) {
410        let backend = crate::foundation::brain::BrainMemoryBackend::new(socket.clone());
411        tracing::info!(
412            "durable memory authority is oxibrain at {}",
413            socket.display()
414        );
415        return Some(Arc::new(backend));
416    }
417    tracing::warn!(
418        "memory_enabled but no oxibrain socket at {} — durable-memory tools \
419         will return typed unavailable results. Start the daemon with \
420         `oxibrain serve` or set OXIBRAIN_SOCKET.",
421        socket.display()
422    );
423    None
424}
425
426/// Initial status-bar chip value before the prober's first tick lands.
427pub(crate) fn initial_brain_chip(
428    settings: &crate::store::settings::Settings,
429) -> crate::tui_vt::main_loop::BrainChip {
430    use crate::tui_vt::main_loop::BrainChip;
431    if !settings.memory_enabled {
432        return BrainChip::Off;
433    }
434    if brain_socket_present(&crate::foundation::brain::default_socket_path()) {
435        // Socket present; the immediate first probe will confirm or degrade.
436        BrainChip::Degraded
437    } else {
438        BrainChip::Down
439    }
440}
441#[cfg(test)]
442mod memory_backend_tests {
443    use super::*;
444
445    /// These tests mutate the process-global `OXIBRAIN_SOCKET` env var;
446    /// cargo runs tests on parallel threads, so they must serialize or they
447    /// observe each other's sockets (flaky `is_some`/`is_none` failures).
448    static ENV_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
449
450    fn test_settings() -> crate::store::settings::Settings {
451        let mut s = crate::store::settings::Settings::default();
452        s.memory_enabled = true;
453        s
454    }
455
456    /// Run `f` with `OXIBRAIN_SOCKET` set to `value` (removed when `None`),
457    /// holding the env lock and cleaning the variable up afterwards.
458    #[cfg(unix)]
459    fn with_socket_env<T>(value: Option<&std::path::Path>, f: impl FnOnce() -> T) -> T {
460        let _guard = ENV_LOCK.lock();
461        unsafe {
462            match value {
463                Some(p) => std::env::set_var("OXIBRAIN_SOCKET", p),
464                None => std::env::remove_var("OXIBRAIN_SOCKET"),
465            }
466        }
467        let out = f();
468        unsafe {
469            std::env::remove_var("OXIBRAIN_SOCKET");
470        }
471        out
472    }
473
474    /// Bind a real unix socket in a leaked tempdir so the socket file
475    /// outlives the helper (the daemon-present gate stats the path).
476    #[cfg(unix)]
477    fn bind_real_socket() -> std::path::PathBuf {
478        let dir = tempfile::TempDir::new().unwrap();
479        let sock = dir.path().join("oxibrain.sock");
480        std::os::unix::net::UnixListener::bind(&sock).unwrap();
481        std::mem::forget(dir);
482        sock
483    }
484
485    #[cfg(unix)]
486    #[test]
487    fn brain_backend_returned_when_socket_present() {
488        // A real unix socket must pass the daemon-present gate.
489        let sock = bind_real_socket();
490        with_socket_env(Some(&sock), || {
491            let backend = create_memory_backend(&test_settings());
492            assert!(backend.is_some(), "socket present ⇒ brain backend");
493        });
494    }
495
496    #[cfg(unix)]
497    #[test]
498    fn absent_socket_returns_none() {
499        let dir = tempfile::TempDir::new().unwrap();
500        let missing = dir.path().join("missing.sock");
501        with_socket_env(Some(&missing), || {
502            let backend = create_memory_backend(&test_settings());
503            assert!(
504                backend.is_none(),
505                "absent socket ⇒ no local durable fallback (plan §5.h)"
506            );
507        });
508    }
509
510    #[cfg(unix)]
511    #[test]
512    fn regular_file_is_not_a_socket() {
513        let dir = tempfile::TempDir::new().unwrap();
514        let fake = dir.path().join("oxibrain.sock");
515        std::fs::write(&fake, b"not a socket").unwrap();
516        with_socket_env(Some(&fake), || {
517            let backend = create_memory_backend(&test_settings());
518            assert!(
519                backend.is_none(),
520                "regular file must not pass the socket gate"
521            );
522        });
523    }
524
525    #[test]
526    fn memory_disabled_returns_none() {
527        // `memory_enabled` defaults to true (the daemon is the authority);
528        // disabling it must yield no backend regardless of socket state.
529        let mut s = crate::store::settings::Settings::default();
530        assert!(s.memory_enabled, "memory_enabled default flipped to true");
531        s.memory_enabled = false;
532        assert!(create_memory_backend(&s).is_none());
533    }
534}
535
536/// Build a project-memory recall block for injection into the system
537/// prompt. Returns an empty string when no memories exist.
538pub async fn build_memory_recall(
539    backend: &dyn oxicode_agent::tools::MemoryBackend,
540    subject: &str,
541) -> String {
542    match backend.list(subject).await {
543        Ok(items) if !items.is_empty() => {
544            let mut block = String::from(
545                "\n\n## Project Memory\n\nThe following facts were learned in previous sessions:\n",
546            );
547            for item in &items {
548                block.push_str(&format!("- [{}] {}\n", item.kind, item.content));
549            }
550            block
551        }
552        _ => String::new(),
553    }
554}
555
556/// Store a session summary into the memory backend.
557///
558/// **NOTE: currently uncalled** — defined as a future hook point for
559/// session-end reflection. Nothing wires it to session lifecycle yet.
560/// See FINAL-ROADMAP.md §알려진 갭 (⑨ mental-models).
561pub async fn session_reflect(
562    backend: &dyn oxicode_agent::tools::MemoryBackend,
563    subject: &str,
564    summary: &str,
565) {
566    if let Err(e) = backend.put(summary, "summary", subject).await {
567        tracing::warn!("Failed to store session memory: {e}");
568    }
569}
570
571// ── Foundation profile → provider registration ────────────────────────────
572
573/// Resolve a Foundation profile and register the selected provider.
574///
575/// Precedence (plan §2.c):
576///   1. `OXICODE_PROVIDER` + `OXICODE_MODEL` env override.
577///   2. Explicit `--profile` / `OXICODE_PROFILE` id.
578///   3. Role-compatible Foundation profile.
579///   4. One-time compatibility import (gated by `OXICODE_FOUNDATION_MIGRATION=1`).
580///
581/// The resolved credential is read from the OS Keychain; the
582/// provider/model is registered only when profile + credential
583/// validation succeeds. Errors are reported but never silently
584/// replaced by another remote provider (plan §3.f).
585async fn resolve_and_register_profile(
586    foundation_root: &Path,
587) -> Result<Arc<dyn oxicode_ai::Provider>, crate::foundation::FoundationError> {
588    use crate::foundation::profiles::{
589        EnvironmentOverride, ResolveInput, read as read_profiles, resolve_profile,
590    };
591
592    let profiles_path = foundation_root.join(crate::foundation::files::PROFILES);
593    let profiles = read_profiles(&profiles_path)?;
594    let explicit_profile = std::env::var("OXICODE_PROFILE")
595        .ok()
596        .filter(|s| !s.trim().is_empty());
597    let compat_import_path = foundation_root.join("compatibility.json");
598    let compat_import =
599        crate::foundation::compat_import::read_compatibility_shim(&compat_import_path)?;
600
601    let env_override = EnvironmentOverride::from_env();
602
603    let resolved = resolve_profile(ResolveInput {
604        explicit_profile: explicit_profile.as_deref(),
605        explicit_environment_override: env_override.as_ref(),
606        requested_role: None,
607        foundation_profiles: &profiles,
608        compatibility_import: compat_import.as_ref(),
609    })?;
610
611    let resolver = crate::foundation::credentials::KeychainCredentialResolver::default();
612    let credential = resolver.resolve(&resolved.profile);
613    let api_key = match credential {
614        crate::foundation::credentials::Credential::Keychain(s)
615        | crate::foundation::credentials::Credential::Environment(s) => s,
616        crate::foundation::credentials::Credential::Unavailable(e) => {
617            return Err(crate::foundation::FoundationError::KeychainUnavailable(
618                e.to_string(),
619            ));
620        }
621    };
622    let provider_name = resolved.profile.provider.as_str();
623    let provider: Arc<dyn oxicode_ai::Provider> = Arc::from(
624        oxicode_ai::register_builtins::create_builtin_provider_with_options(
625            provider_name,
626            Some(&api_key),
627            None,
628        )
629        .ok_or_else(|| {
630            crate::foundation::FoundationError::IncompatibleHost(provider_name.to_string())
631        })?,
632    );
633
634    tracing::info!(
635        provider = provider_name,
636        model = %resolved.profile.model,
637        source = ?resolved.source,
638        "Foundation profile resolved with Keychain credential"
639    );
640    Ok(provider)
641}
642#[cfg(test)]
643mod tests {
644
645    use super::*;
646
647    #[test]
648    fn paths_are_consistent() {
649        let p = OxicodePaths::from_home("/tmp/oxicode-test");
650        assert!(p.auth.starts_with("/tmp/oxicode-test"));
651        assert!(p.config.starts_with("/tmp/oxicode-test"));
652        assert!(p.sessions.starts_with("/tmp/oxicode-test"));
653        assert!(p.skills.starts_with("/tmp/oxicode-test"));
654    }
655
656    #[tokio::test]
657    async fn build_oxicode_succeeds() {
658        let tmp = tempfile::TempDir::new().unwrap();
659        let paths = OxicodePaths::from_home(tmp.path());
660        let oxicode = build_oxicode(&paths, None, None).await.unwrap();
661        let _ = oxicode.ports().state;
662    }
663}