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