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