Skip to main content

agent_profile/adapter/
metadata.rs

1// Copyright (c) 2026 Costas Kirgoussios
2//
3// This software is licensed under the PolyForm Noncommercial License 1.0.0.
4// You may obtain a copy of the License at:
5// https://polyformproject.org/licenses/noncommercial/1.0.0/
6
7//! Declarative adapter metadata: support level, capabilities, evidence, environment declarations, conflicting
8//! options and profile presence (spec §3, §8, §21, §22, §28; SP2 design §4.2).
9
10use std::ffi::OsStr;
11use std::fmt;
12use std::path::Path;
13
14/// How far an adapter is proven, separately from its capabilities (spec §3, §37).
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum SupportLevel {
17    /// Evidence entry, every capability claimed, and the contract suite passes.
18    Proven,
19    Experimental,
20}
21
22/// Where an adapter's mechanism was verified (spec §28, extended with version and source).
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct AdapterEvidence {
25    pub mechanism_id: &'static str,
26    /// ISO date, `YYYY-MM-DD`.
27    pub verified_at: &'static str,
28    pub upstream_version: &'static str,
29    /// A URL, or `measured` with the method in `notes`.
30    pub source_url: &'static str,
31    pub notes: &'static str,
32}
33
34/// An isolation property a profile may provide (SP2 design §4.2 definitions).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Capability {
37    /// The profile replaces the user-level configuration the agent reads from its default user location.
38    ConfigIsolation,
39    /// Stored credentials (files or OS keychain entries) are separated per profile.
40    CredentialIsolation,
41    /// History, sessions and caches are separated per profile.
42    StateIsolation,
43}
44
45impl Capability {
46    /// Every capability, so each adapter can be checked for claiming all of them.
47    pub const ALL: [Capability; 3] =
48        [Capability::ConfigIsolation, Capability::CredentialIsolation, Capability::StateIsolation];
49}
50
51/// How strongly a capability holds (spec §3).
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum CapabilityState {
54    Supported,
55    NotSupported,
56    NotGuaranteed,
57    Conditional,
58    Unknown,
59}
60
61/// One capability claim and the evidence-scoped reason for it.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct CapabilityClaim {
64    pub capability: Capability,
65    pub state: CapabilityState,
66    pub basis: &'static str,
67}
68
69/// A variable an adapter's plan may set, and whether its value must never be printed (spec §22).
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct EnvOverride {
72    pub name: &'static str,
73    pub sensitive: bool,
74}
75
76/// An agent option proven to control the same mechanism the adapter uses (spec §21).
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct ConflictOption {
79    /// Every accepted spelling, each starting with `--`.
80    pub long: &'static [&'static str],
81    pub short: Option<char>,
82}
83
84impl ConflictOption {
85    /// The canonical spelling: the first long form. Every `ConflictOption` in a `static AdapterMetadata`
86    /// has at least one — `metadata_invariants` walks them all and asserts each starts with `--`, which an
87    /// empty list could not satisfy meaningfully.
88    ///
89    /// This is the *only* place a flag mechanism's spelling comes from, so the option a plan passes, the
90    /// sentence the report prints and the token the evidence pipeline reads are one string, not three.
91    pub fn spelling(&self) -> &'static str {
92        self.long.first().expect("a conflict option declares at least one long spelling")
93    }
94
95    /// Whether `arg` selects this option.
96    pub fn matches(&self, arg: &OsStr) -> bool {
97        self.matched_spelling(arg).is_some()
98    }
99
100    /// The spelling of this option that `arg` selects, without any value glued to it: `--conf` for
101    /// `--conf=secret`, `-c` for `-csecret`. This is what an error may print, because the rest of the
102    /// argument is the user's value and can be a secret. Works on encoded bytes, so non-UTF-8 arguments are
103    /// scanned too; every spelling is ASCII, which makes the byte comparison exact on every platform.
104    pub fn matched_spelling(&self, arg: &OsStr) -> Option<String> {
105        let bytes = arg.as_encoded_bytes();
106        let long = self.long.iter().find(|spelling| {
107            let spelling = spelling.as_bytes();
108            bytes == spelling
109                || (bytes.len() > spelling.len()
110                    && bytes.starts_with(spelling)
111                    && bytes[spelling.len()] == b'=')
112        });
113        if let Some(spelling) = long {
114            return Some((*spelling).to_owned());
115        }
116        let letter = self.short?;
117        let mut flag = [0u8; 4];
118        let flag = format!("-{}", letter.encode_utf8(&mut flag));
119        bytes.starts_with(flag.as_bytes()).then_some(flag)
120    }
121}
122
123/// How an adapter points its agent at the profile: the cross product of {variable, flag} x
124/// {directory, file}. This is the complete, closed set — it is the same vocabulary the probe harness
125/// speaks (`sandbox/probes/common.sh`, `env:` / `envfile:` / `flagdir:` / `flagfile:`), so a fifth
126/// variant here would be a mechanism no probe can exercise.
127///
128/// # Three renderings, one per consumer — do not collapse them
129///
130/// 1. [`std::fmt::Display`] — the **path-free** sentence, for error messages. An error message cannot name a
131///    path, because `check_conflicts` refuses an argument before any plan exists and therefore before
132///    any path has been chosen. It prints the placeholder `<dir>` / `<file>` instead.
133/// 2. [`Mechanism::sentence_for`] — the **concrete** sentence that becomes
134///    [`PlannedLaunch::mechanism`](super::PlannedLaunch::mechanism), for the launch report. A report
135///    line should name the exact path it is about to create, so here the placeholder is the real path.
136/// 3. [`Mechanism::probe_token`] — the **machine** identifier the evidence pipeline consumes. The
137///    human text above may be reworded freely; this token may not.
138///
139/// That (1) and (2) differ in form for flag mechanisms is deliberate, not drift: they answer different
140/// questions at different times. For environment mechanisms they coincide, because there is no path in
141/// the sentence to differ over.
142///
143/// All three are generated from this one value, and so is the launch itself: `Mechanism::plan` (crate
144/// internal, so not linked here — a public doc comment cannot link a private item) dispatches on the
145/// variant and takes the variable name or
146/// flag spelling out of the variant's own payload. An adapter passes no name and no flag, so there is no
147/// second string for the first to drift from.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum Mechanism {
150    /// A variable set to the profile directory.
151    Env(&'static str),
152    /// A variable set to a file inside the profile directory.
153    EnvFile(&'static str),
154    /// An option passed the profile directory.
155    FlagDir(ConflictOption),
156    /// An option passed a file inside the profile directory.
157    FlagFile(ConflictOption),
158}
159
160impl Mechanism {
161    /// The option this mechanism itself occupies, if it is a flag. `check_conflicts` scans it alongside
162    /// the adapter's declared extra `conflicts`, so an adapter cannot accept a flag it uses itself.
163    pub fn conflict_option(&self) -> Option<&ConflictOption> {
164        match self {
165            Mechanism::Env(_) | Mechanism::EnvFile(_) => None,
166            Mechanism::FlagDir(option) | Mechanism::FlagFile(option) => Some(option),
167        }
168    }
169
170    /// The report sentence naming `target`, the path this mechanism points at. Environment mechanisms
171    /// name the variable and ignore `target`.
172    pub fn sentence_for(&self, target: &Path) -> String {
173        match self {
174            Mechanism::Env(name) | Mechanism::EnvFile(name) => {
175                format!("environment variable {name}")
176            }
177            Mechanism::FlagDir(option) | Mechanism::FlagFile(option) => {
178                format!("argument {} {}", option.spelling(), target.display())
179            }
180        }
181    }
182
183    /// The evidence pipeline's identifier, e.g. `env:CODEX_HOME` or `flagfile:--config`.
184    pub fn probe_token(&self) -> String {
185        match self {
186            Mechanism::Env(name) => format!("env:{name}"),
187            Mechanism::EnvFile(name) => format!("envfile:{name}"),
188            Mechanism::FlagDir(option) => format!("flagdir:{}", option.spelling()),
189            Mechanism::FlagFile(option) => format!("flagfile:{}", option.spelling()),
190        }
191    }
192}
193
194impl fmt::Display for Mechanism {
195    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196        match self {
197            Mechanism::Env(name) | Mechanism::EnvFile(name) => {
198                write!(formatter, "environment variable {name}")
199            }
200            Mechanism::FlagDir(option) => {
201                write!(formatter, "argument {} <dir>", option.spelling())
202            }
203            Mechanism::FlagFile(option) => {
204                write!(formatter, "argument {} <file>", option.spelling())
205            }
206        }
207    }
208}
209
210/// Whether a profile exists for an adapter (spec §8).
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum ProfilePresence {
213    /// Nothing has been created for this profile yet.
214    Absent,
215    /// Every path the adapter declares exists on disk.
216    Materialized,
217    /// The profile can be identified from the agent's own profile mechanism, without anything of ours
218    /// existing on disk.
219    ///
220    /// **Reserved, not dead.** No adapter constructs this today and the SP3 review flagged it as unused
221    /// code, which it is — but it is unclaimed rather than obsolete. Spec §8 defines it normatively for
222    /// an agent that manages named profiles itself, where a profile is real because the agent says so
223    /// and `agent-profile` may have created no directory at all. Every adapter shipped so far is
224    /// directory-based, so the distinction has not yet had a case to express.
225    ///
226    /// Deleting it was considered in SP4 and rejected: the variant costs one line, while removing it
227    /// would make the first native-profile adapter a change to a public enum — and in the meantime
228    /// `presence()` would have to report such a profile as `Absent`, which is a false statement about a
229    /// profile the agent itself lists.
230    Known,
231}
232
233/// Everything about an adapter that does not depend on a launch.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct AdapterMetadata {
236    pub id: &'static str,
237    /// Executable base name, without `.exe`.
238    pub executable: &'static str,
239    /// How the agent is pointed at the profile. Generates both the report sentence and the path-free
240    /// text used in messages, so neither can disagree with what `plan()` does.
241    pub mechanism: Mechanism,
242    pub support: SupportLevel,
243    pub evidence: AdapterEvidence,
244    pub capabilities: &'static [CapabilityClaim],
245    /// Every variable `plan()` may set, with its sensitivity.
246    pub env: &'static [EnvOverride],
247    pub conflicts: &'static [ConflictOption],
248    /// Exact names of the environment variables the vendor documents as supplying credentials that the
249    /// agent then prefers over a profile's own. `doctor` reports each one that is set in the current
250    /// environment (its name only: the value is never read, `Environment::is_set` returns a `bool`).
251    ///
252    /// Every name must appear in this adapter's own evidence notes or capability bases, so the list cannot
253    /// carry a variable nobody sourced (`every_credential_variable_is_named_in_the_adapters_own_evidence`).
254    /// Exact names only, never a prefix: a family such as `AWS_*` would flag every AWS user's shell. Empty
255    /// means none is recorded, not that none exists.
256    pub credential_env: &'static [&'static str],
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use std::ffi::OsString;
263
264    const AIDER_CONFIG: ConflictOption =
265        ConflictOption { long: &["--config", "--confi", "--conf", "--con"], short: Some('c') };
266
267    #[test]
268    fn long_spellings_match_exactly_or_with_an_equals_value() {
269        for arg in ["--config", "--config=f", "--confi", "--conf=", "--con=a=b", "-c", "-cf", "-c="]
270        {
271            assert!(AIDER_CONFIG.matches(OsStr::new(arg)), "{arg}");
272        }
273        for arg in
274            ["--co", "--code-theme", "--configx", "--conf-x", "config", "-", "--", "-x", "--c"]
275        {
276            assert!(!AIDER_CONFIG.matches(OsStr::new(arg)), "{arg}");
277        }
278    }
279
280    #[test]
281    fn an_option_without_a_short_form_matches_only_long_spellings() {
282        let option = ConflictOption { long: &["--fake-profile"], short: None };
283        assert!(option.matches(OsStr::new("--fake-profile=x")));
284        assert!(!option.matches(OsStr::new("-f")));
285    }
286
287    #[cfg(unix)]
288    fn non_utf8_value(prefix: &str) -> OsString {
289        use std::os::unix::ffi::OsStringExt;
290        let mut bytes = prefix.as_bytes().to_vec();
291        bytes.push(0xff);
292        OsString::from_vec(bytes)
293    }
294
295    #[cfg(windows)]
296    fn non_utf8_value(prefix: &str) -> OsString {
297        use std::os::windows::ffi::OsStringExt;
298        let mut wide: Vec<u16> = prefix.encode_utf16().collect();
299        wide.push(0xD800);
300        OsString::from_wide(&wide)
301    }
302
303    #[test]
304    fn the_probe_token_names_the_mechanism_kind() {
305        // All four variants, none elided: the token vocabulary is a wire contract with
306        // `sandbox/probes/common.sh`, whose `case` has exactly these four arms.
307        assert_eq!(Mechanism::Env("CODEX_HOME").probe_token(), "env:CODEX_HOME");
308        assert_eq!(
309            Mechanism::EnvFile("AIDER_CONFIG_FILE").probe_token(),
310            "envfile:AIDER_CONFIG_FILE"
311        );
312        assert_eq!(Mechanism::FlagDir(AIDER_CONFIG).probe_token(), "flagdir:--config");
313        assert_eq!(Mechanism::FlagFile(AIDER_CONFIG).probe_token(), "flagfile:--config");
314    }
315
316    #[test]
317    fn non_utf8_arguments_are_scanned() {
318        assert!(AIDER_CONFIG.matches(&non_utf8_value("--config=")));
319        assert!(AIDER_CONFIG.matches(&non_utf8_value("-c")));
320        assert!(!AIDER_CONFIG.matches(&non_utf8_value("--cod")));
321    }
322}