Skip to main content

oxicode/foundation/
mod.rs

1//! Oxi Foundation v1 host primitives for oxicode.
2//!
3//! Reads the versioned contract under `~/.oxi/foundation/v1/`. The
4//! contract is the only interface across the host boundary: oxicode
5//! does not import from `oxibrain` or `oxios` directly.
6//!
7//! # Layout
8//!
9//! ```text
10//! ~/.oxi/foundation/v1/
11//! ├── foundation.json
12//! ├── profiles.json
13//! ├── packages.lock
14//! └── packages/<sha256>/
15//! ```
16//!
17//! Override the root with `$OXI_FOUNDATION_HOME`. The contract is
18//! documented in
19//! `docs/superpowers/specs/2026-08-17-oxi-foundation-contract.md`.
20//!
21//! # Modules
22//!
23//! - [`compatibility`] — typed `foundation.json` parsing + schema /
24//!   host-version negotiation.
25//! - [`profiles`] — typed `profiles.json` parsing, role resolution, and
26//!   the pure [`resolve_profile`](profiles::resolve_profile) decision
27//!   function.
28//! - [`packages`] — typed `packages.lock` parsing, digest verification,
29//!   and capability mapping to oxicode's existing policy.
30//! - [`credentials`] — Keychain-backed credential resolver (see
31//!   `credentials.rs`).
32//! - [`compat_import`] — one-time legacy compatibility import (gated by
33//!   `OXICODE_FOUNDATION_MIGRATION=1`).
34//! - [`fixtures`] — helpers that load the shared cross-host JSON
35//!   fixtures from `tests/fixtures/oxi-foundation/v1/`.
36//!
37//! All errors are re-typeset through [`FoundationError`]; the
38//! `Display`/`Debug` impls never expose a secret value.
39//! - [`brain`] — BrainMemoryBackend, the only durable-memory authority
40//!   under the Foundation host. Talks to `oxibrain` over a Unix-domain
41//!   socket; surfaces `degraded` state on connection failure.
42
43pub mod brain;
44pub mod brain_control;
45pub mod compat_import;
46pub mod compatibility;
47pub mod credentials;
48pub mod fixtures;
49pub mod packages;
50pub mod profiles;
51
52pub mod migrate;
53
54use std::path::{Path, PathBuf};
55
56/// Resolve the oxicode home directory through the unified Oxi home layout
57/// (`$OXICODE_HOME`, else `<oxi_home>/oxicode`, else `~/.oxi/oxicode`).
58/// The Foundation host is independent: `OXICODE_HOME`/`OXI_HOME` only
59/// affect oxicode-local paths (legacy memory, migration checkpoints, etc.)
60/// and do not change the Foundation root.
61pub fn fetch_oxicode_home() -> Option<PathBuf> {
62    oxicode_catalog::oxi_home::oxicode_home()
63}
64
65/// Canonical subdirectory name under the host's `$HOME` (or
66/// `$OXI_FOUNDATION_HOME`).
67pub const FOUNDATION_ROOT_SUFFIX: &str = "oxi/foundation/v1";
68
69/// Filenames the contract requires at the foundation root.
70pub mod files {
71    /// `foundation.json` — schema version + host compatibility.
72    pub const FOUNDATION: &str = "foundation.json";
73    /// `profiles.json` — non-secret provider/model profiles.
74    pub const PROFILES: &str = "profiles.json";
75    /// `packages.lock` — immutable resolved package records.
76    pub const PACKAGES_LOCK: &str = "packages.lock";
77    /// `packages/<sha256>/` — verified immutable package content.
78    pub const PACKAGES_DIR: &str = "packages";
79}
80
81/// Resolve the foundation root. Honors `$OXI_FOUNDATION_HOME`; falls
82/// back to `$HOME/.oxi/foundation/v1`. Never reads secrets from this
83/// path.
84pub fn foundation_root() -> Option<PathBuf> {
85    if let Ok(home) = std::env::var("OXI_FOUNDATION_HOME") {
86        let trimmed = home.trim();
87        if !trimmed.is_empty() {
88            return Some(PathBuf::from(trimmed));
89        }
90    }
91    dirs::home_dir().map(|h| h.join(FOUNDATION_ROOT_SUFFIX))
92}
93
94/// `true` when the foundation installation is present and looks
95/// well-formed enough to attempt parsing. Reads only metadata; does
96/// not validate schemas.
97pub fn foundation_present(root: &Path) -> bool {
98    root.is_dir() && root.join(files::FOUNDATION).is_file() && root.join(files::PROFILES).is_file()
99}
100
101/// Full filesystem discovery — parses `foundation.json`, `profiles.json`,
102/// and `packages.lock` (when present). Returns a typed snapshot or a
103/// typed error. All reads are best-effort for the lockfile: the
104/// foundation is usable without installed packages.
105pub fn discover(root: &Path) -> Result<FoundationSnapshot, FoundationError> {
106    let compatibility = compatibility::read(&root.join(files::FOUNDATION))?;
107    let profiles = profiles::read(&root.join(files::PROFILES))?;
108    let packages = packages::read(
109        &root.join(files::PACKAGES_LOCK),
110        &root.join(files::PACKAGES_DIR),
111    )?;
112    Ok(FoundationSnapshot {
113        root: root.to_path_buf(),
114        compatibility,
115        profiles,
116        packages,
117    })
118}
119
120/// In-memory snapshot of the foundation installation. Cheap to clone —
121/// all fields are Arc-friendly.
122#[derive(Debug, Clone)]
123pub struct FoundationSnapshot {
124    /// Root directory the snapshot was loaded from.
125    pub root: PathBuf,
126    /// `foundation.json` content.
127    pub compatibility: compatibility::FoundationManifest,
128    /// `profiles.json` content.
129    pub profiles: profiles::ProfilesFile,
130    /// `packages.lock` content (verified; `OK` on load).
131    pub packages: packages::PackagesFile,
132}
133
134/// Error type for every foundation operation. Carries no secrets.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum FoundationError {
137    /// `schema_version` is not `1` or is missing.
138    UnsupportedSchema(u32),
139    /// `host_compatibility.oxicode` does not include this build.
140    IncompatibleHost(String),
141    /// The file is malformed JSON or missing required fields.
142    Parse(String),
143    /// A profile contains a known secret-shaped field.
144    SecretNotAllowed(String),
145    /// Two profiles share the same id.
146    DuplicateProfileId(String),
147    /// A package requires an unknown abstract capability.
148    UnsupportedRequirement(String),
149    /// A package's on-disk content does not match its declared digest.
150    DigestMismatch {
151        package: String,
152        expected: String,
153        actual: String,
154    },
155    /// The package's `targets` list does not include `oxicode`.
156    TargetMismatch {
157        package: String,
158        targets: Vec<String>,
159    },
160    /// The explicit profile id does not match any record.
161    UnknownProfile(String),
162    /// The requested role matched zero profiles.
163    UnknownRole(String),
164    /// The requested role matched more than one profile.
165    AmbiguousRole(String),
166    /// Keychain is unreachable (no keychain daemon, etc.).
167    KeychainUnavailable(String),
168    /// Keychain prompt was cancelled by the user.
169    KeychainLocked(String),
170    /// The credential locator has no entry.
171    KeychainNotFound { service: String, account: String },
172    /// Brain daemon is unreachable.
173    BrainUnavailable(String),
174    /// I/O failures.
175    Io(String),
176}
177
178impl std::fmt::Display for FoundationError {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        // Never include a credential value, account name that looks like
181        // a secret, or any token. Account names are accepted because
182        // they are public profile ids.
183        match self {
184            Self::UnsupportedSchema(v) => write!(f, "unsupported foundation schema_version {v}"),
185            Self::IncompatibleHost(s) => write!(f, "host compatibility check failed: {s}"),
186            Self::Parse(s) => write!(f, "foundation parse error: {s}"),
187            Self::SecretNotAllowed(s) => write!(f, "secret not allowed in foundation file: {s}"),
188            Self::DuplicateProfileId(id) => write!(f, "duplicate profile id: {id}"),
189            Self::UnsupportedRequirement(req) => {
190                write!(f, "unsupported package requirement: {req}")
191            }
192            Self::DigestMismatch {
193                package,
194                expected,
195                actual,
196            } => write!(
197                f,
198                "package {package} digest mismatch: expected {expected}, got {actual}"
199            ),
200            Self::TargetMismatch { package, targets } => write!(
201                f,
202                "package {package} targets do not include `oxicode`: {targets:?}"
203            ),
204            Self::UnknownProfile(id) => write!(f, "unknown profile id: {id}"),
205            Self::UnknownRole(r) => write!(f, "no profile matches requested role: {r}"),
206            Self::AmbiguousRole(r) => write!(f, "multiple profiles match role {r}"),
207            Self::KeychainUnavailable(s) => write!(f, "keychain unavailable: {s}"),
208            Self::KeychainLocked(s) => write!(f, "keychain locked: {s}"),
209            Self::KeychainNotFound { service, account } => {
210                write!(f, "keychain entry not found for {service}:{account}")
211            }
212            Self::BrainUnavailable(s) => write!(f, "brain daemon unavailable: {s}"),
213            Self::Io(s) => write!(f, "foundation I/O error: {s}"),
214        }
215    }
216}
217
218impl std::error::Error for FoundationError {}
219
220impl From<std::io::Error> for FoundationError {
221    fn from(e: std::io::Error) -> Self {
222        Self::Io(e.to_string())
223    }
224}
225
226impl From<serde_json::Error> for FoundationError {
227    fn from(e: serde_json::Error) -> Self {
228        Self::Parse(e.to_string())
229    }
230}
231
232/// Source of the resolved provider/model. Used in logs and diagnostics.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum CredentialSource {
235    /// `$OXICODE_PROVIDER` / `$OXICODE_MODEL` — non-persistent.
236    Environment,
237    /// Profile selected via `--profile` / `OXICODE_PROFILE`.
238    Profile,
239    /// Role-compatible profile resolution.
240    Role,
241    /// One-time legacy compatibility import.
242    CompatibilityImport,
243    /// No provider resolved.
244    Unavailable,
245}
246
247impl std::fmt::Display for CredentialSource {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        match self {
250            Self::Environment => f.write_str("environment"),
251            Self::Profile => f.write_str("profile"),
252            Self::Role => f.write_str("role"),
253            Self::CompatibilityImport => f.write_str("compatibility_import"),
254            Self::Unavailable => f.write_str("unavailable"),
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn foundation_root_honors_env_override() {
265        let tmp = tempfile::tempdir().unwrap();
266        // SAFETY: we set the env var only for this test; the test runner
267        // is single-threaded for unit tests by default.
268        // SAFETY: tests run on a single thread when using #[test] + cargo
269        // test by default; rust 2024 still warns about process-wide env.
270        // We use a scoped approach instead.
271        let original = std::env::var("OXI_FOUNDATION_HOME").ok();
272        unsafe {
273            std::env::set_var("OXI_FOUNDATION_HOME", tmp.path());
274        }
275        let root = foundation_root().unwrap();
276        unsafe {
277            std::env::remove_var("OXI_FOUNDATION_HOME");
278        }
279        if let Some(value) = original {
280            unsafe {
281                std::env::set_var("OXI_FOUNDATION_HOME", value);
282            }
283        }
284        assert_eq!(root, tmp.path());
285    }
286
287    #[test]
288    fn foundation_present_detects_layout() {
289        let tmp = tempfile::tempdir().unwrap();
290        assert!(!foundation_present(tmp.path()));
291        std::fs::write(tmp.path().join(files::FOUNDATION), "{}").unwrap();
292        std::fs::write(tmp.path().join(files::PROFILES), "{}").unwrap();
293        assert!(foundation_present(tmp.path()));
294    }
295
296    #[test]
297    fn credential_source_display_roundtrip() {
298        assert_eq!(CredentialSource::Environment.to_string(), "environment");
299        assert_eq!(CredentialSource::Profile.to_string(), "profile");
300        assert_eq!(CredentialSource::Role.to_string(), "role");
301        assert_eq!(
302            CredentialSource::CompatibilityImport.to_string(),
303            "compatibility_import"
304        );
305        assert_eq!(CredentialSource::Unavailable.to_string(), "unavailable");
306    }
307}