Skip to main content

oxicode/foundation/
profiles.rs

1//! `profiles.json` parsing and role resolution.
2//!
3//! The pure decision function [`resolve_profile`] is the only thing
4//! that decides which provider/model an agent runs against. It is
5//! deliberately testable with no filesystem, no environment, and no
6//! network — feed it inputs, get a typed result.
7
8use std::collections::HashSet;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use super::FoundationError;
14
15/// Typed `profiles.json`.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ProfilesFile {
18    /// MUST be `1`.
19    pub schema_version: u32,
20    /// Profile records. Non-empty when parsing succeeds.
21    #[serde(default)]
22    pub profiles: Vec<Profile>,
23}
24
25/// A single profile record. Non-secret.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct Profile {
29    /// Profile id (unique within the file).
30    pub id: String,
31    /// Provider implementation (e.g. `anthropic`, `openai`).
32    pub provider: String,
33    /// Model name (validated against the catalog at first use).
34    pub model: String,
35    /// Roles this profile binds to (e.g. `coding.primary`).
36    #[serde(default)]
37    pub roles: Vec<String>,
38    /// Keychain locator: `{ service, account }`.
39    pub credential: CredentialLocator,
40}
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct CredentialLocator {
43    pub service: String,
44    pub account: String,
45}
46
47/// Resolved profile + source class. The caller resolves the
48/// credential locator against the OS Keychain.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ResolvedProfile {
51    pub profile: Profile,
52    pub source: super::CredentialSource,
53}
54
55/// Read and validate `profiles.json`. Returns a typed `ProfilesFile`.
56pub fn read(path: &Path) -> Result<ProfilesFile, FoundationError> {
57    let raw = std::fs::read_to_string(path)?;
58    let parsed: ProfilesFile = serde_json::from_str(&raw)?;
59    parsed.validate()?;
60    Ok(parsed)
61}
62
63impl ProfilesFile {
64    /// Validate schema, uniqueness, and non-secret fields.
65    pub fn validate(&self) -> Result<(), FoundationError> {
66        if self.schema_version != 1 {
67            return Err(FoundationError::UnsupportedSchema(self.schema_version));
68        }
69        let mut seen = HashSet::new();
70        for p in &self.profiles {
71            if p.id.is_empty() {
72                return Err(FoundationError::Parse("profile id is empty".to_string()));
73            }
74            if !seen.insert(p.id.clone()) {
75                return Err(FoundationError::DuplicateProfileId(p.id.clone()));
76            }
77            if p.provider.is_empty() {
78                return Err(FoundationError::Parse(format!(
79                    "profile {} has empty provider",
80                    p.id
81                )));
82            }
83            if p.model.is_empty() {
84                return Err(FoundationError::Parse(format!(
85                    "profile {} has empty model",
86                    p.id
87                )));
88            }
89            if p.roles.is_empty() {
90                return Err(FoundationError::Parse(format!(
91                    "profile {} has no roles",
92                    p.id
93                )));
94            }
95            for r in &p.roles {
96                if r.is_empty() {
97                    return Err(FoundationError::Parse(format!(
98                        "profile {} has empty role",
99                        p.id
100                    )));
101                }
102            }
103            p.credential.validate()?;
104            check_secret_keys_absent(p)?;
105        }
106        Ok(())
107    }
108
109    /// Find a profile by id.
110    pub fn find(&self, id: &str) -> Option<&Profile> {
111        self.profiles.iter().find(|p| p.id == id)
112    }
113
114    /// Find profiles whose `roles` contains the requested role.
115    pub fn for_role(&self, role: &str) -> Vec<&Profile> {
116        self.profiles
117            .iter()
118            .filter(|p| p.roles.iter().any(|r| r == role))
119            .collect()
120    }
121}
122
123impl CredentialLocator {
124    pub fn validate(&self) -> Result<(), FoundationError> {
125        if self.service.is_empty() {
126            return Err(FoundationError::Parse(
127                "credential.service is empty".to_string(),
128            ));
129        }
130        if self.account.is_empty() {
131            return Err(FoundationError::Parse(
132                "credential.account is empty".to_string(),
133            ));
134        }
135        Ok(())
136    }
137}
138
139/// Pure decision function. Returns the resolved profile, or a typed
140/// reason. See the contract spec for precedence rules.
141pub fn resolve_profile(input: ResolveInput<'_>) -> Result<ResolvedProfile, FoundationError> {
142    // 1. Environment override — non-persistent automation.
143    if let Some(env) = input.explicit_environment_override
144        && let Some(profile) = env_into_profile(env)
145    {
146        return Ok(ResolvedProfile {
147            profile,
148            source: super::CredentialSource::Environment,
149        });
150    }
151
152    // 2. Explicit profile id.
153    if let Some(id) = input.explicit_profile {
154        let profile = input
155            .foundation_profiles
156            .find(id)
157            .ok_or_else(|| FoundationError::UnknownProfile(id.to_string()))?
158            .clone();
159        return Ok(ResolvedProfile {
160            profile,
161            source: super::CredentialSource::Profile,
162        });
163    }
164
165    // 3. Role-compatible profile.
166    if let Some(role) = input.requested_role {
167        let matches = input.foundation_profiles.for_role(role);
168        match matches.as_slice() {
169            [] => return Err(FoundationError::UnknownRole(role.to_string())),
170            [one] => {
171                return Ok(ResolvedProfile {
172                    profile: (*one).clone(),
173                    source: super::CredentialSource::Role,
174                });
175            }
176            _ => return Err(FoundationError::AmbiguousRole(role.to_string())),
177        }
178    }
179
180    // 4. Compatibility import.
181    if let Some(import) = input.compatibility_import {
182        return Ok(ResolvedProfile {
183            profile: import.profile.clone(),
184            source: super::CredentialSource::CompatibilityImport,
185        });
186    }
187
188    Err(FoundationError::UnknownProfile(
189        "no profile, role, environment override, or compatibility import provided".to_string(),
190    ))
191}
192
193/// Inputs to the pure decision function. Mirrors the spec.
194#[derive(Debug, Clone)]
195pub struct ResolveInput<'a> {
196    /// `--profile` / `OXICODE_PROFILE`.
197    pub explicit_profile: Option<&'a str>,
198    /// Parsed environment override. See [`EnvironmentOverride::from_env`].
199    pub explicit_environment_override: Option<&'a EnvironmentOverride>,
200    /// Requested role id (e.g. `coding.primary`).
201    pub requested_role: Option<&'a str>,
202    /// Profiles parsed from `profiles.json`.
203    pub foundation_profiles: &'a ProfilesFile,
204    /// Optional one-time compatibility import.
205    pub compatibility_import: Option<&'a CompatibilityImport>,
206}
207
208/// Parsed environment override (`OXICODE_PROVIDER` + `OXICODE_MODEL`).
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct EnvironmentOverride {
211    pub provider: String,
212    pub model: String,
213}
214
215impl EnvironmentOverride {
216    /// Read both env vars. Returns `None` if either is unset or empty.
217    pub fn from_env() -> Option<Self> {
218        let provider = std::env::var("OXICODE_PROVIDER").ok()?;
219        let model = std::env::var("OXICODE_MODEL").ok()?;
220        if provider.trim().is_empty() || model.trim().is_empty() {
221            return None;
222        }
223        Some(Self {
224            provider: provider.trim().to_string(),
225            model: model.trim().to_string(),
226        })
227    }
228}
229
230/// One-time legacy compatibility import. Selected only when
231/// `OXICODE_FOUNDATION_MIGRATION=1` is set.
232#[derive(Debug, Clone)]
233pub struct CompatibilityImport {
234    pub profile: Profile,
235}
236
237fn env_into_profile(env: &EnvironmentOverride) -> Option<Profile> {
238    if env.provider.is_empty() || env.model.is_empty() {
239        return None;
240    }
241    Some(Profile {
242        id: "__env_override__".to_string(),
243        provider: env.provider.clone(),
244        model: env.model.clone(),
245        roles: vec![],
246        credential: CredentialLocator {
247            // Env variables never reach the Keychain — the
248            // credential module recognizes the literal sentinel and
249            // pulls from the env at read time.
250            service: "__env__".to_string(),
251            account: "__env__".to_string(),
252        },
253    })
254}
255
256/// Known secret-shaped field names. A profile carrying any of these
257/// is rejected before reaching the registry.
258const SECRET_FIELD_NAMES: &[&str] = &[
259    "api_key",
260    "apikey",
261    "api-key",
262    "bearer_token",
263    "bearer",
264    "password",
265    "secret",
266    "secret_value",
267    "private_key",
268    "private-key",
269    "access_token",
270    "refresh_token",
271    "session_token",
272    "oauth_token",
273];
274
275fn check_secret_keys_absent(profile: &Profile) -> Result<(), FoundationError> {
276    // We only inspect fields we ourselves deserialize; the spec
277    // scrubs known shapes but does not rely on a denylist for
278    // unknown serde fields. Reject any shape that looks like a
279    // plaintext credential.
280    let value = serde_json::to_value(profile).map_err(|e| FoundationError::Parse(e.to_string()))?;
281    if let Some(obj) = value.as_object() {
282        for k in obj.keys() {
283            let lower = k.to_ascii_lowercase();
284            if SECRET_FIELD_NAMES
285                .iter()
286                .any(|s| s.eq_ignore_ascii_case(&lower))
287            {
288                return Err(FoundationError::SecretNotAllowed(k.clone()));
289            }
290        }
291    }
292    Ok(())
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn pf(json: &str) -> ProfilesFile {
300        let f: ProfilesFile = serde_json::from_str(json).unwrap();
301        f.validate().unwrap();
302        f
303    }
304
305    fn profile(id: &str, provider: &str, model: &str, roles: &[&str]) -> Profile {
306        Profile {
307            id: id.to_string(),
308            provider: provider.to_string(),
309            model: model.to_string(),
310            roles: roles.iter().map(|s| s.to_string()).collect(),
311            credential: CredentialLocator {
312                service: "dev.oxi.foundation".to_string(),
313                account: id.to_string(),
314            },
315        }
316    }
317
318    #[test]
319    fn rejects_duplicate_profile_ids() {
320        let raw = r#"{
321            "schema_version": 1,
322            "profiles": [
323                {"id":"a","provider":"openai","model":"gpt-4o","roles":["x"],"credential":{"service":"s","account":"a"}},
324                {"id":"a","provider":"openai","model":"gpt-4","roles":["y"],"credential":{"service":"s","account":"a"}}
325            ]
326        }"#;
327        let err = serde_json::from_str::<ProfilesFile>(raw)
328            .unwrap()
329            .validate()
330            .unwrap_err();
331        assert!(matches!(err, FoundationError::DuplicateProfileId(id) if id == "a"));
332    }
333    #[test]
334    fn rejects_secret_in_profile() {
335        // The parser uses `deny_unknown_fields`, so a profile that
336        // smuggles a secret-shaped field is rejected at parse time
337        // (before any validator logic runs). The error type is
338        // `FoundationError::Parse` because the parser's JSON errors
339        // are mapped through that variant.
340        let raw = r#"{
341            "schema_version": 1,
342            "profiles": [
343                {"id":"a","provider":"openai","model":"gpt-4o","roles":["x"], "api_key":"sk-xxx",
344                 "credential":{"service":"s","account":"a"}}
345            ]
346        }"#;
347        let result = serde_json::from_str::<ProfilesFile>(raw);
348        let err = match result {
349            Ok(file) => file.validate().unwrap_err(),
350            Err(e) => FoundationError::Parse(e.to_string()),
351        };
352        assert!(matches!(
353            err,
354            FoundationError::Parse(_) | FoundationError::SecretNotAllowed(_)
355        ));
356    }
357
358    #[test]
359    fn resolve_profile_prefers_env_override() {
360        let f = pf(r#"{"schema_version":1,"profiles":[
361            {"id":"x","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
362             "credential":{"service":"s","account":"x"}}
363        ]}"#);
364        let env = EnvironmentOverride {
365            provider: "anthropic".to_string(),
366            model: "claude-sonnet".to_string(),
367        };
368        let resolved = resolve_profile(ResolveInput {
369            explicit_profile: Some("x"),
370            explicit_environment_override: Some(&env),
371            requested_role: None,
372            foundation_profiles: &f,
373            compatibility_import: None,
374        })
375        .unwrap();
376        assert_eq!(resolved.source, super::super::CredentialSource::Environment);
377        assert_eq!(resolved.profile.provider, "anthropic");
378    }
379
380    #[test]
381    fn resolve_profile_explicit_id() {
382        let f = pf(r#"{"schema_version":1,"profiles":[
383            {"id":"x","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
384             "credential":{"service":"s","account":"x"}}
385        ]}"#);
386        let resolved = resolve_profile(ResolveInput {
387            explicit_profile: Some("x"),
388            explicit_environment_override: None,
389            requested_role: None,
390            foundation_profiles: &f,
391            compatibility_import: None,
392        })
393        .unwrap();
394        assert_eq!(resolved.profile.id, "x");
395        assert_eq!(resolved.source, super::super::CredentialSource::Profile);
396    }
397
398    #[test]
399    fn resolve_profile_unknown_id_is_error() {
400        let f = pf(r#"{"schema_version":1,"profiles":[
401            {"id":"x","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
402             "credential":{"service":"s","account":"x"}}
403        ]}"#);
404        let err = resolve_profile(ResolveInput {
405            explicit_profile: Some("nope"),
406            explicit_environment_override: None,
407            requested_role: None,
408            foundation_profiles: &f,
409            compatibility_import: None,
410        })
411        .unwrap_err();
412        assert!(matches!(err, FoundationError::UnknownProfile(_)));
413    }
414
415    #[test]
416    fn resolve_profile_role_unique() {
417        let f = pf(r#"{"schema_version":1,"profiles":[
418            {"id":"a","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
419             "credential":{"service":"s","account":"a"}}
420        ]}"#);
421        let resolved = resolve_profile(ResolveInput {
422            explicit_profile: None,
423            explicit_environment_override: None,
424            requested_role: Some("coding.primary"),
425            foundation_profiles: &f,
426            compatibility_import: None,
427        })
428        .unwrap();
429        assert_eq!(resolved.source, super::super::CredentialSource::Role);
430    }
431
432    #[test]
433    fn resolve_profile_role_ambiguous() {
434        let f = pf(r#"{"schema_version":1,"profiles":[
435            {"id":"a","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
436             "credential":{"service":"s","account":"a"}},
437            {"id":"b","provider":"anthropic","model":"claude","roles":["coding.primary"],
438             "credential":{"service":"s","account":"b"}}
439        ]}"#);
440        let err = resolve_profile(ResolveInput {
441            explicit_profile: None,
442            explicit_environment_override: None,
443            requested_role: Some("coding.primary"),
444            foundation_profiles: &f,
445            compatibility_import: None,
446        })
447        .unwrap_err();
448        assert!(matches!(err, FoundationError::AmbiguousRole(_)));
449    }
450
451    #[test]
452    fn resolve_profile_role_unknown() {
453        let f = pf(r#"{"schema_version":1,"profiles":[
454            {"id":"a","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
455             "credential":{"service":"s","account":"a"}}
456        ]}"#);
457        let err = resolve_profile(ResolveInput {
458            explicit_profile: None,
459            explicit_environment_override: None,
460            requested_role: Some("nonexistent"),
461            foundation_profiles: &f,
462            compatibility_import: None,
463        })
464        .unwrap_err();
465        assert!(matches!(err, FoundationError::UnknownRole(_)));
466    }
467
468    #[test]
469    fn resolve_profile_no_inputs_is_error() {
470        let f = pf(r#"{"schema_version":1,"profiles":[
471            {"id":"a","provider":"openai","model":"gpt-4o","roles":["coding.primary"],
472             "credential":{"service":"s","account":"a"}}
473        ]}"#);
474        let err = resolve_profile(ResolveInput {
475            explicit_profile: None,
476            explicit_environment_override: None,
477            requested_role: None,
478            foundation_profiles: &f,
479            compatibility_import: None,
480        })
481        .unwrap_err();
482        assert!(matches!(err, FoundationError::UnknownProfile(_)));
483    }
484
485    #[test]
486    fn resolve_profile_compatibility_import_only() {
487        let f = pf(r#"{"schema_version":1,"profiles":[]}"#);
488        let p = profile("legacy", "anthropic", "claude-sonnet", &["coding.primary"]);
489        let import = CompatibilityImport { profile: p.clone() };
490        let resolved = resolve_profile(ResolveInput {
491            explicit_profile: None,
492            explicit_environment_override: None,
493            requested_role: None,
494            foundation_profiles: &f,
495            compatibility_import: Some(&import),
496        })
497        .unwrap();
498        assert_eq!(
499            resolved.source,
500            super::super::CredentialSource::CompatibilityImport
501        );
502        assert_eq!(resolved.profile.id, "legacy");
503    }
504}