Skip to main content

agent_profile/adapter/
mod.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//! Agent adapters: the adapter trait and registry (spec §2, §4, §38), capability and evidence metadata
8//! (spec §3, §28), profile presence (spec §8), lazy initialization (spec §9) and argument conflicts
9//! (spec §21). SP2 design §4-§6.
10//!
11//! Adapters never spawn processes: `plan()` returns a `LaunchPlan` and the launcher runs it (spec §4).
12
13pub mod gate;
14pub mod metadata;
15
16mod aider;
17mod amp;
18mod claude;
19mod cline;
20mod codex;
21mod r#continue;
22mod copilot;
23mod cursor;
24#[cfg(debug_assertions)]
25mod fake;
26mod gemini;
27mod kiro;
28mod opencode;
29mod pi;
30
31use std::ffi::{OsStr, OsString};
32use std::fs;
33use std::io::{self, Write};
34use std::path::{Path, PathBuf};
35
36use crate::config::{AppRoot, Config, check_case_twins};
37use crate::error::{Error, Result};
38use crate::exe::{self, Origin};
39use crate::launch::LaunchPlan;
40use crate::name::ProfileName;
41
42pub use aider::Aider;
43pub use amp::Amp;
44pub use claude::Claude;
45pub use cline::Cline;
46pub use codex::Codex;
47pub use r#continue::Continue;
48pub use copilot::Copilot;
49pub use cursor::Cursor;
50#[cfg(debug_assertions)]
51pub use fake::Fake;
52pub use gemini::Gemini;
53pub use kiro::Kiro;
54pub use metadata::{
55    AdapterEvidence, AdapterMetadata, Capability, CapabilityClaim, CapabilityState, ConflictOption,
56    EnvOverride, Mechanism, ProfilePresence, SupportLevel,
57};
58pub use opencode::Opencode;
59pub use pi::Pi;
60
61/// One supported coding agent (SP2 design §4.2).
62pub trait Adapter: Sync {
63    fn metadata(&self) -> &'static AdapterMetadata;
64
65    /// Pure: no filesystem access. Every path the adapter owns for `profile`, in creation order. The first
66    /// entry is always the adapter's profile directory.
67    fn paths(&self, root: &AppRoot, profile: &ProfileName) -> Vec<(PathBuf, PathKind)>;
68
69    /// Reads the filesystem at most; never writes. Discovers the executable, then refuses case-only twins.
70    fn plan(&self, ctx: &PlanContext<'_>) -> Result<PlannedLaunch>;
71
72    /// Reads the filesystem at most; never writes; never consults `PATH` or the executable (spec §8).
73    fn presence(&self, root: &AppRoot, profile: &ProfileName) -> ProfilePresence {
74        if check_case_twins(root, profile).is_err() {
75            return ProfilePresence::Absent;
76        }
77        let paths = self.paths(root, profile);
78        if paths.iter().all(|(path, kind)| has_kind(path, *kind)) {
79            ProfilePresence::Materialized
80        } else {
81            ProfilePresence::Absent
82        }
83    }
84
85    /// Ensures every path in `planned.paths`, in order; idempotent; never overwrites a file (spec §9, §9.1).
86    fn initialize(&self, planned: &PlannedLaunch) -> Result<()> {
87        ensure_paths(&planned.paths)
88    }
89}
90
91/// Everything `plan()` needs. The agent id is always `metadata().id`.
92#[derive(Debug, Clone, Copy)]
93pub struct PlanContext<'a> {
94    pub profile: &'a ProfileName,
95    pub root: &'a AppRoot,
96    pub config: &'a Config,
97    pub args: &'a [OsString],
98    pub path_var: Option<&'a OsStr>,
99}
100
101/// What a profile path must be.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum PathKind {
104    Dir,
105    /// A file created with `contents` when missing; an existing file is never modified.
106    File {
107        contents: &'static [u8],
108    },
109}
110
111/// A path an adapter owns, and whether it already had the declared kind at plan time.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ProfilePath {
114    pub path: PathBuf,
115    pub kind: PathKind,
116    pub existed: bool,
117}
118
119/// A launch plus what the report shows but the launcher does not need.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct PlannedLaunch {
122    pub plan: LaunchPlan,
123    pub profile: ProfileName,
124    /// The adapter's profile directory, `<root>/profiles/<profile>/<agent>`.
125    pub profile_dir: PathBuf,
126    pub paths: Vec<ProfilePath>,
127    pub executable_origin: Origin,
128    /// The per-launch report text, e.g. `environment variable CODEX_HOME`.
129    pub mechanism: String,
130    /// Override variables whose values must never be printed (spec §22).
131    pub sensitive_env: Vec<OsString>,
132    /// Non-sensitive facts shown in the report.
133    pub notes: Vec<String>,
134}
135
136/// The real adapters, in every build (SP2 design §4.2).
137pub const REAL_ADAPTERS: &[&dyn Adapter] = &[
138    &Claude, &Codex, &Aider, &Gemini, &Copilot, &Opencode, &Pi, &Kiro, &Cursor, &Continue, &Amp,
139    &Cline,
140];
141
142/// The adapters this build knows: the real ones, plus `fake` when debug assertions are on.
143pub fn registry() -> Vec<&'static dyn Adapter> {
144    #[cfg_attr(not(debug_assertions), allow(unused_mut))]
145    let mut adapters = REAL_ADAPTERS.to_vec();
146    #[cfg(debug_assertions)]
147    adapters.push(&Fake);
148    adapters
149}
150
151/// The agent ids this build knows, in registry order.
152pub fn known_agents() -> Vec<&'static str> {
153    registry().iter().map(|adapter| adapter.metadata().id).collect()
154}
155
156/// The adapter for `id`, if this build knows it.
157pub fn lookup(id: &str) -> Option<&'static dyn Adapter> {
158    registry().into_iter().find(|adapter| adapter.metadata().id == id)
159}
160
161/// Refuses an opaque argument that selects the adapter's own mechanism (spec §21, SP2 design §6). The scan
162/// stops at the first `--`, after which arguments are positional for the agent.
163///
164/// The scanned set is `metadata.conflicts` **chained with the mechanism's own option**, so a flag adapter
165/// refuses the flag it launches with whether or not it remembered to list it: `conflicts` carries only the
166/// *additional* options proven to control the same mechanism.
167pub fn check_conflicts(metadata: &AdapterMetadata, args: &[OsString]) -> Result<()> {
168    for arg in args.iter().take_while(|arg| arg.as_os_str() != "--") {
169        let mut options = metadata.conflicts.iter().chain(metadata.mechanism.conflict_option());
170        if let Some(option) = options.find_map(|option| option.matched_spelling(arg)) {
171            return Err(Error::ArgumentConflict {
172                agent: metadata.id.to_owned(),
173                option,
174                mechanism: metadata.mechanism.to_string(),
175            });
176        }
177    }
178    Ok(())
179}
180
181/// `<root>/profiles/<profile>/<id>`.
182pub(crate) fn profile_dir(root: &AppRoot, profile: &ProfileName, id: &str) -> PathBuf {
183    root.profiles_dir().join(profile.as_str()).join(id)
184}
185
186/// The one configuration file an adapter owns, for the mechanisms that name a file.
187fn config_file(paths: &[ProfilePath]) -> PathBuf {
188    paths
189        .iter()
190        .find(|entry| matches!(entry.kind, PathKind::File { .. }))
191        .expect("a configuration-file adapter owns a file")
192        .path
193        .clone()
194}
195
196impl Mechanism {
197    /// Plans a launch that points the agent at its profile *through this mechanism*.
198    ///
199    /// The variable name and the flag spelling are read out of the mechanism's own payload, and the
200    /// report sentence comes from [`Mechanism::sentence_for`] on the very same value. There is no
201    /// parameter through which an adapter could pass a second name, so declaring `Env("A")` and
202    /// launching with `"B"` is not expressible — the two used to be separate arguments and agreed only
203    /// because every adapter happened to pass the same constant twice.
204    ///
205    /// It lives here rather than in [`metadata`] because planning needs this module's private
206    /// machinery — executable discovery, the case-twin check and path materialization state.
207    ///
208    /// Adapter-specific extras stay in the adapter: `plan()` returns no notes, and an adapter that has
209    /// one (Codex's logged-out note, Aider's layering note) pushes it onto the result.
210    pub(crate) fn plan(
211        &self,
212        adapter: &dyn Adapter,
213        ctx: &PlanContext<'_>,
214    ) -> Result<PlannedLaunch> {
215        let metadata = adapter.metadata();
216        let found = discover(metadata, ctx)?;
217        check_case_twins(ctx.root, ctx.profile)?;
218        let paths = profile_paths(adapter, ctx);
219        let dir = paths[0].path.clone();
220
221        // `target` is the path the mechanism points at, and the one the report sentence names.
222        let (args, env, target) = match self {
223            Mechanism::Env(name) => (
224                ctx.args.to_vec(),
225                vec![(OsString::from(*name), dir.clone().into_os_string())],
226                dir.clone(),
227            ),
228            Mechanism::EnvFile(name) => {
229                let file = config_file(&paths);
230                (
231                    ctx.args.to_vec(),
232                    vec![(OsString::from(*name), file.clone().into_os_string())],
233                    file,
234                )
235            }
236            Mechanism::FlagDir(option) => {
237                let mut args: Vec<OsString> =
238                    vec![option.spelling().into(), dir.clone().into_os_string()];
239                args.extend(ctx.args.iter().cloned());
240                (args, Vec::new(), dir.clone())
241            }
242            Mechanism::FlagFile(option) => {
243                let file = config_file(&paths);
244                let mut args: Vec<OsString> =
245                    vec![option.spelling().into(), file.clone().into_os_string()];
246                args.extend(ctx.args.iter().cloned());
247                (args, Vec::new(), file)
248            }
249        };
250
251        Ok(PlannedLaunch {
252            plan: LaunchPlan { executable: found.path, args, env, cwd: None },
253            profile: ctx.profile.clone(),
254            profile_dir: dir,
255            paths,
256            executable_origin: found.origin,
257            mechanism: self.sentence_for(&target),
258            sensitive_env: sensitive_env(metadata),
259            notes: Vec::new(),
260        })
261    }
262}
263
264fn discover(metadata: &AdapterMetadata, ctx: &PlanContext<'_>) -> Result<exe::Found> {
265    exe::discover(
266        metadata.id,
267        metadata.executable,
268        ctx.config.agent_executable(metadata.id),
269        ctx.path_var,
270        &ctx.root.config_path(),
271    )
272}
273
274fn profile_paths(adapter: &dyn Adapter, ctx: &PlanContext<'_>) -> Vec<ProfilePath> {
275    adapter
276        .paths(ctx.root, ctx.profile)
277        .into_iter()
278        .map(|(path, kind)| ProfilePath { existed: has_kind(&path, kind), path, kind })
279        .collect()
280}
281
282fn sensitive_env(metadata: &AdapterMetadata) -> Vec<OsString> {
283    metadata.env.iter().filter(|entry| entry.sensitive).map(|entry| entry.name.into()).collect()
284}
285
286/// Whether `fs::metadata` (which follows symlinks) reports the declared kind.
287fn has_kind(path: &Path, kind: PathKind) -> bool {
288    fs::metadata(path).is_ok_and(|metadata| match kind {
289        PathKind::Dir => metadata.is_dir(),
290        PathKind::File { .. } => metadata.is_file(),
291    })
292}
293
294/// Ensures every path, ignoring `existed`, so a path created or removed after planning is still handled.
295pub fn ensure_paths(paths: &[ProfilePath]) -> Result<()> {
296    for entry in paths {
297        let error = |source: io::Error| Error::ProfileDir { path: entry.path.clone(), source };
298        match entry.kind {
299            PathKind::Dir => {
300                create_private_dir_all(&entry.path).map_err(error)?;
301                if !fs::metadata(&entry.path).map_err(error)?.is_dir() {
302                    return Err(error(io::Error::other("the path exists but is not a directory")));
303                }
304            }
305            PathKind::File { contents } => {
306                if !has_kind(&entry.path, entry.kind) {
307                    write_new_file(&entry.path, contents)?;
308                }
309            }
310        }
311    }
312    Ok(())
313}
314
315/// `create_dir_all`, but every directory it creates is `0700` on Unix, whatever the umask: a profile can hold
316/// credentials, so other local users should not be able to list it. A directory that already exists keeps its mode.
317fn create_private_dir_all(path: &Path) -> io::Result<()> {
318    let mut builder = fs::DirBuilder::new();
319    builder.recursive(true);
320    #[cfg(unix)]
321    std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
322    builder.create(path)
323}
324
325fn write_new_file(path: &Path, contents: &[u8]) -> Result<()> {
326    write_new_file_with(path, contents, || Ok(()))
327}
328
329/// A lock-free, durable, no-clobber file create (SP2 design §4.4). There is no sweep of leftover temp
330/// files: without a lock a sweep could delete another launch's live temp file.
331fn write_new_file_with(
332    path: &Path,
333    contents: &[u8],
334    before_persist: impl FnOnce() -> Result<()>,
335) -> Result<()> {
336    let error = |source: io::Error| Error::ProfileDir { path: path.to_path_buf(), source };
337    let directory = path.parent().expect("profile file paths have a parent");
338    let name = path.file_name().expect("profile file paths have a name").to_string_lossy();
339    let mut temp = tempfile::Builder::new()
340        .prefix(&format!("{name}."))
341        .suffix(".tmp")
342        .tempfile_in(directory)
343        .map_err(error)?;
344    temp.write_all(contents).and_then(|()| temp.as_file().sync_all()).map_err(error)?;
345    before_persist()?;
346    if let Err(persist) = temp.persist_noclobber(path) {
347        // A concurrent launch won; its file is complete because it, too, was renamed into place.
348        if fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
349            return Ok(());
350        }
351        return Err(error(persist.error));
352    }
353    #[cfg(unix)]
354    fs::File::open(directory).and_then(|directory| directory.sync_all()).map_err(|sync| {
355        error(io::Error::new(
356            sync.kind(),
357            format!(
358                "file created, but the directory could not be synced; it may not survive a power loss: {sync}"
359            ),
360        ))
361    })?;
362    Ok(())
363}
364
365#[cfg(all(test, debug_assertions))]
366mod tests {
367    use super::*;
368    use crate::name::Platform;
369
370    fn setup() -> (tempfile::TempDir, AppRoot, Config, PathBuf) {
371        let dir = tempfile::tempdir().unwrap();
372        let root = AppRoot::from_path(dir.path().join("root"));
373        let exe = dir.path().join("fake-agent-bin");
374        fs::write(&exe, b"x").unwrap();
375        fs::create_dir_all(root.path()).unwrap();
376        fs::write(
377            root.config_path(),
378            format!("[agents.fake]\nexecutable = {:?}\n", exe.to_str().unwrap()),
379        )
380        .unwrap();
381        let config = Config::load(&root).unwrap();
382        (dir, root, config, exe)
383    }
384
385    fn profile(name: &str) -> ProfileName {
386        ProfileName::parse(name, Platform::host()).unwrap()
387    }
388
389    fn plan_fake(
390        root: &AppRoot,
391        config: &Config,
392        name: &str,
393        args: &[OsString],
394    ) -> Result<PlannedLaunch> {
395        let profile = profile(name);
396        Fake.plan(&PlanContext { profile: &profile, root, config, args, path_var: None })
397    }
398
399    #[test]
400    fn fake_plan_sets_home_override_and_passes_args_verbatim() {
401        let (_dir, root, config, exe) = setup();
402        let args: Vec<OsString> = vec!["--foo".into(), "a b".into()];
403        let planned = plan_fake(&root, &config, "work", &args).unwrap();
404        let dir = root.profiles_dir().join("work").join("fake");
405        assert_eq!(
406            planned.plan,
407            LaunchPlan {
408                executable: exe,
409                args,
410                env: vec![("FAKE_AGENT_HOME".into(), dir.clone().into_os_string())],
411                cwd: None
412            }
413        );
414        assert_eq!(planned.profile_dir, dir);
415        assert_eq!(
416            planned.paths,
417            vec![ProfilePath { path: dir, kind: PathKind::Dir, existed: false }]
418        );
419        assert_eq!(planned.executable_origin, Origin::Configured);
420    }
421
422    #[test]
423    fn case_only_twin_is_refused_for_any_entry_type() {
424        let (_dir, root, config, _exe) = setup();
425        fs::create_dir_all(root.profiles_dir()).unwrap();
426        fs::write(root.profiles_dir().join("work"), b"a file, not a directory").unwrap();
427        let error = plan_fake(&root, &config, "WORK", &[]).unwrap_err();
428        assert!(
429            matches!(error, Error::ProfileCaseConflict { ref existing, .. } if existing == "work"),
430            "{error:?}"
431        );
432        assert!(plan_fake(&root, &config, "work", &[]).is_ok());
433    }
434
435    #[test]
436    fn initialize_is_idempotent_and_rejects_a_file_at_a_directory_path() {
437        let (_dir, root, config, _exe) = setup();
438        let planned = plan_fake(&root, &config, "work", &[]).unwrap();
439        Fake.initialize(&planned).unwrap();
440        Fake.initialize(&planned).unwrap();
441        assert!(planned.profile_dir.is_dir());
442
443        let blocked = plan_fake(&root, &config, "blocked", &[]).unwrap();
444        fs::create_dir_all(blocked.profile_dir.parent().unwrap()).unwrap();
445        fs::write(&blocked.profile_dir, b"file").unwrap();
446        assert!(matches!(Fake.initialize(&blocked), Err(Error::ProfileDir { .. })));
447    }
448
449    #[test]
450    fn conflict_scan_stops_at_double_dash_and_names_the_option() {
451        let metadata = Aider.metadata();
452        let args = |items: &[&str]| items.iter().map(OsString::from).collect::<Vec<_>>();
453        match check_conflicts(metadata, &args(&["x", "--conf=f"])).unwrap_err() {
454            Error::ArgumentConflict { agent, option, mechanism } => {
455                assert_eq!((agent.as_str(), option.as_str()), ("aider", "--conf"));
456                assert_eq!(mechanism, "argument --config <file>");
457            }
458            other => panic!("{other:?}"),
459        }
460        // The error names the option, never the value glued to it: `--conf=<secret>` and `-c<secret>` print
461        // only the spelling that matched.
462        for (given, printed) in [
463            ("--config=s3cret", "--config"),
464            ("--con=s3cret", "--con"),
465            ("-cs3cret", "-c"),
466            ("-c", "-c"),
467        ] {
468            match check_conflicts(metadata, &args(&["x", given])).unwrap_err() {
469                Error::ArgumentConflict { option, .. } => assert_eq!(option, printed, "{given}"),
470                other => panic!("{other:?}"),
471            }
472        }
473        assert!(check_conflicts(metadata, &args(&["x", "--", "--config", "f"])).is_ok());
474        assert!(check_conflicts(Codex.metadata(), &args(&["-p", "personal"])).is_ok());
475    }
476
477    fn new_file_path() -> (tempfile::TempDir, PathBuf) {
478        let dir = tempfile::tempdir().unwrap();
479        let path = dir.path().join(".aider.conf.yml");
480        (dir, path)
481    }
482
483    #[test]
484    fn new_file_does_not_exist_under_its_final_name_before_persist() {
485        let (_dir, path) = new_file_path();
486        write_new_file_with(&path, b"{}\n", || {
487            assert!(!path.exists(), "the final name must appear only at persist");
488            Ok(())
489        })
490        .unwrap();
491        assert_eq!(fs::read(&path).unwrap(), b"{}\n");
492    }
493
494    #[test]
495    fn the_temp_file_lives_beside_the_target() {
496        let (dir, path) = new_file_path();
497        let names = || -> Vec<String> {
498            fs::read_dir(dir.path())
499                .unwrap()
500                .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
501                .collect()
502        };
503        write_new_file_with(&path, b"{}\n", || {
504            let names = names();
505            assert_eq!(names.len(), 1, "{names:?}");
506            assert!(
507                names[0].starts_with(".aider.conf.yml.") && names[0].ends_with(".tmp"),
508                "{names:?}"
509            );
510            Ok(())
511        })
512        .unwrap();
513        assert_eq!(names(), [".aider.conf.yml"]);
514    }
515
516    #[test]
517    fn a_file_created_before_persist_wins_and_is_never_overwritten() {
518        let (_dir, path) = new_file_path();
519        write_new_file_with(&path, b"{}\n", || {
520            fs::write(&path, b"theirs").unwrap();
521            Ok(())
522        })
523        .unwrap();
524        assert_eq!(fs::read(&path).unwrap(), b"theirs");
525    }
526
527    #[test]
528    fn a_directory_created_before_persist_is_a_profile_error() {
529        let (_dir, path) = new_file_path();
530        let result = write_new_file_with(&path, b"{}\n", || {
531            fs::create_dir(&path).unwrap();
532            Ok(())
533        });
534        assert!(matches!(result, Err(Error::ProfileDir { .. })), "{result:?}");
535    }
536
537    #[test]
538    fn concurrent_file_creation_never_exposes_a_partial_file() {
539        let (dir, path) = new_file_path();
540        fs::write(dir.path().join(".aider.conf.yml.leftover.tmp"), b"stale").unwrap();
541        let entry = ProfilePath {
542            path: path.clone(),
543            kind: PathKind::File { contents: b"{}\n" },
544            existed: false,
545        };
546        let done = std::sync::atomic::AtomicBool::new(false);
547        std::thread::scope(|scope| {
548            let reader = scope.spawn(|| {
549                while !done.load(std::sync::atomic::Ordering::SeqCst) {
550                    if let Ok(bytes) = fs::read(&path) {
551                        assert_eq!(bytes, b"{}\n");
552                    }
553                }
554            });
555            let writers: Vec<_> = (0..8)
556                .map(|_| scope.spawn(|| ensure_paths(std::slice::from_ref(&entry))))
557                .collect();
558            for writer in writers {
559                writer.join().unwrap().unwrap();
560            }
561            done.store(true, std::sync::atomic::Ordering::SeqCst);
562            reader.join().unwrap();
563        });
564        assert_eq!(fs::read(&path).unwrap(), b"{}\n");
565    }
566
567    struct Secretive;
568
569    static SECRETIVE: AdapterMetadata = AdapterMetadata {
570        id: "secretive",
571        executable: "fake-agent",
572        mechanism: Mechanism::Env("PROFILE_SESSION_HANDLE"),
573        support: SupportLevel::Experimental,
574        evidence: AdapterEvidence {
575            mechanism_id: "secretive-v1",
576            verified_at: "2026-09-15",
577            upstream_version: "0.0.0",
578            source_url: "measured",
579            notes: "test-only adapter",
580        },
581        capabilities: &[],
582        env: &[EnvOverride { name: "PROFILE_SESSION_HANDLE", sensitive: true }],
583        conflicts: &[],
584        credential_env: &[],
585    };
586
587    impl Adapter for Secretive {
588        fn metadata(&self) -> &'static AdapterMetadata {
589            &SECRETIVE
590        }
591
592        fn paths(&self, root: &AppRoot, profile: &ProfileName) -> Vec<(PathBuf, PathKind)> {
593            vec![(profile_dir(root, profile, SECRETIVE.id), PathKind::Dir)]
594        }
595
596        fn plan(&self, ctx: &PlanContext<'_>) -> Result<PlannedLaunch> {
597            SECRETIVE.mechanism.plan(self, ctx)
598        }
599    }
600
601    #[test]
602    fn sensitive_declarations_fill_sensitive_env_and_are_redacted() {
603        let (dir, root, _config, exe) = setup();
604        fs::write(
605            root.config_path(),
606            format!("[agents.secretive]\nexecutable = {:?}\n", exe.to_str().unwrap()),
607        )
608        .unwrap();
609        let config = Config::load(&root).unwrap();
610        let profile = profile("work");
611        let ctx = PlanContext {
612            profile: &profile,
613            root: &root,
614            config: &config,
615            args: &[],
616            path_var: None,
617        };
618        let planned = Secretive.plan(&ctx).unwrap();
619        assert_eq!(planned.sensitive_env, vec![OsString::from("PROFILE_SESSION_HANDLE")]);
620        let resolution = crate::resolve::resolve(
621            crate::name::AgentId::parse("secretive").unwrap(),
622            Some(profile.clone()),
623            &config,
624            &crate::repo::Discovery::NotInRepository,
625        );
626        let text = crate::output::report_lines(
627            &planned,
628            &resolution,
629            crate::output::ReportMode::DryRun,
630            &SECRETIVE,
631        )
632        .join("\n");
633        assert!(text.contains("PROFILE_SESSION_HANDLE=<redacted>"), "{text}");
634        drop(dir);
635    }
636}