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}