Skip to main content

ignition_core/
session.rs

1//! The execution session — the ONE seam every auth/gateway-client
2//! resolution flows through (CORE-09).
3//!
4//! Naming: THIS is an "execution session" — a resolved profile name plus
5//! the gateway client it builds. It is NOT a gateway session; the
6//! unrelated `client::sessions` family models the GATEWAY's
7//! Designer/Perspective/Vision sessions. One word, two domains — the
8//! doc-comments everywhere else say "gateway session" when they mean
9//! that other thing.
10//!
11//! Choreography (LOCKED — copied verbatim from the duplicated resolution
12//! sites this seam replaces: main.rs's `resolve_profile_context` +
13//! `resolve_gateway_api` + `resolve_headerless_api` + `rig_gateway_client`,
14//! and the TUI's `context::resolve_from` + `rig_client_with`):
15//!
16//! 1. The `IGNITION_URL` env overlay is applied FIRST, scoped to the
17//!    WOULD-BE selection (flag > active) — `config::apply_env_overlay`.
18//! 2. THEN the selection resolves — `config::resolve_selection`
19//!    (flag > active; unknown name → `ProfileNotFound` with the known
20//!    profiles in the hint; nothing resolvable → `NoActiveProfile`,
21//!    exactly what main.rs's `Ok(None)` consumers do today).
22//! 3. THEN the secret chain (env tokens → keyring → basic pair) —
23//!    `config::resolve_secret` over the one chain built below. The
24//!    SEQUENCE is unchanged, but the two generic (profile-less) env
25//!    rungs are auth-shape-conditional as of quick/260920-iti — see
26//!    `config::secret`'s module header for the full statement.
27//!
28//! The client is built from the POST-OVERLAY profile — the research-
29//! locked precedence (flag > `IGNITION_URL` env > profile value) must
30//! hold at the construction site, not just in the config unit tests
31//! (main.rs:431's contract, preserved here verbatim).
32//!
33//! Three credential modes, one per real call-site family:
34//!
35//! - [`Session::resolve`] — REQUIRED credential (the authed reads: a
36//!   missing secret is `SecretUnavailable`, exit 3 — never degraded).
37//! - [`Session::resolve_degraded`] — the credential DEGRADES to `None`
38//!   when the chain exhausts (version / waits / doctor: these must run
39//!   without a secret; every other credential error still propagates).
40//! - [`Session::for_url`] — headerless-BY-CONSTRUCTION rig clients: a
41//!   caller-derived URL + explicit credential, no config read at all.
42//!
43//! Concrete-with-deref (PLANNER DECISION, research open question 4):
44//! the handle is `Arc<ReqwestGatewayApi>`, not `Arc<dyn GatewayApi>` —
45//! the TUI's workers/`ClientHandle` are concretely typed and Phase 8's
46//! goal is construction-site unification, not handle-type churn;
47//! dyn-widening rides Phase 14 where MCP actually needs dyn. [`Deref`]
48//! feeds every existing free-fn action over `&GatewayApi` unchanged —
49//! the `version()` dyn precedent (`actions/version.rs`) and the
50//! `rig_stream.rs` cast precedent both keep working.
51//!
52//! Redaction boundary UNCHANGED: [`Secret::expose`] stays confined to
53//! the client's `apply_auth` header site (CORE-02's grep-auditable
54//! rule). This module composes existing public config fns and
55//! introduces NO new exposure path.
56//!
57//! `IGNITION_PROFILE` is deliberately NOT read here: the bin folds it
58//! into `--profile` in exactly one place (`apply_env_defaults`), so the
59//! flag this seam receives is already the effective selection.
60
61use std::ops::Deref;
62use std::sync::Arc;
63
64use crate::client::ReqwestGatewayApi;
65use crate::config::{self, AuthRef, Config, Credential, Profile, SecretStore};
66use crate::error::CoreError;
67
68/// One resolved execution context: a named profile and the gateway
69/// client built from its POST-OVERLAY state. Construct through the three
70/// constructors — never by struct literal (the fields are the resolved
71/// choreography's output, not inputs).
72///
73/// Manual `Debug` (no derive): the client isn't `Debug` and must never
74/// be rendered credential-side — the profile NAME is the only safe
75/// field (the redaction discipline, CORE-02).
76pub struct Session {
77    profile: String,
78    url: url::Url,
79    credential_present: bool,
80    api: Arc<ReqwestGatewayApi>,
81}
82
83impl std::fmt::Debug for Session {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_struct("Session")
86            .field("profile", &self.profile)
87            .field("api", &"ReqwestGatewayApi")
88            .finish()
89    }
90}
91
92impl Session {
93    /// Resolve through config: REQUIRED credential mode. Loads config
94    /// (`IGNITION_CLI_CONFIG` first, platform path second), applies the
95    /// env overlay scoped to the would-be selection, resolves the
96    /// selection, then walks the LOCKED secret chain with NO
97    /// degradation — a missing secret is `CoreError::SecretUnavailable`
98    /// (exit 3), correct for authed reads. Replaces main.rs's
99    /// `resolve_gateway_api` (and `named_profile_client`: per-side
100    /// resolution is just `resolve(Some(name))`).
101    pub fn resolve(profile_flag: Option<&str>) -> Result<Self, CoreError> {
102        let mut config = config::load(&config::config_path())?;
103        Self::resolve_loaded(&mut config, profile_flag).map(|(session, _)| session)
104    }
105
106    /// Resolve through a config the CALLER already loaded — the caller
107    /// owns the load policy, the seam keeps everything downstream
108    /// (overlay scoped to the selection → selection → LOCKED secret
109    /// chain → REQUIRED-credential construction). This is the cockpit's
110    /// entry point: the TUI loads with `config::load_for_tui`, whose
111    /// NEW-surface degradation contract (a schema typo warns and
112    /// defaults instead of killing startup) must apply BEFORE the seam
113    /// runs, while selection/auth failures stay fatal. Returns the
114    /// session AND the selected POST-OVERLAY profile — the url string
115    /// and cadence fields consumers like the cockpit display, which a
116    /// `Session` deliberately doesn't re-expose.
117    pub fn resolve_loaded(
118        config: &mut Config,
119        profile_flag: Option<&str>,
120    ) -> Result<(Self, Profile), CoreError> {
121        let (name, profile) = resolve_selected(config, profile_flag)?;
122        let credential = config::resolve_secret(&name, &profile.auth, &locked_secret_chain())?;
123        let api = ReqwestGatewayApi::new(&profile, Some(credential))?;
124        Ok((
125            Self {
126                profile: name,
127                url: profile.url.clone(),
128                credential_present: true,
129                api: Arc::new(api),
130            },
131            profile,
132        ))
133    }
134
135    /// Resolve ONE NAMED side of a multi-profile command through a
136    /// config the CALLER already loaded — the project diff/sync shape.
137    /// Verbatim port of main.rs's `named_profile_client`: the selection
138    /// runs against the caller's config with NO env overlay applied
139    /// (side B carries its own URL even while `IGNITION_URL` overlays
140    /// the envelope's active profile — the contract the diff/sync
141    /// goldens pin), then the LOCKED chain (required) and the client
142    /// construction. The caller's `name` rides the secret resolution
143    /// verbatim, and the impossible empty-selection arm stays
144    /// `CoreError::Internal` exactly as the original wrote it.
145    pub fn resolve_side(config: &mut Config, name: &str) -> Result<Self, CoreError> {
146        let Some((_resolved, profile)) = config::resolve_selection(config, Some(name))? else {
147            return Err(CoreError::Internal(
148                "a named profile selection resolved to nothing".to_string(),
149            ));
150        };
151        let credential = config::resolve_secret(name, &profile.auth, &locked_secret_chain())?;
152        let api = ReqwestGatewayApi::new(&profile, Some(credential))?;
153        Ok(Self {
154            profile: name.to_string(),
155            url: profile.url.clone(),
156            credential_present: true,
157            api: Arc::new(api),
158        })
159    }
160
161    /// Resolve through config with the credential DEGRADED to `None`
162    /// when the secret chain exhausts: `version` must not demand a
163    /// secret (gateway-info answers), the wait commands must keep
164    /// polling while auth is broken, the doctor diagnoses absent auth
165    /// for a living. Every OTHER credential error propagates (the
166    /// `resolve_secret_opt` behavior behind main.rs:431/760 and
167    /// `resolve_headerless_api`). Selection errors are NOT degraded:
168    /// no target is `NoActiveProfile`, as today.
169    pub fn resolve_degraded(profile_flag: Option<&str>) -> Result<Self, CoreError> {
170        let mut config = config::load(&config::config_path())?;
171        let (name, profile) = resolve_selected(&mut config, profile_flag)?;
172        let credential = resolve_secret_opt(&name, &profile.auth)?;
173        let credential_present = credential.is_some();
174        let api = ReqwestGatewayApi::new(&profile, credential)?;
175        Ok(Self {
176            profile: name,
177            url: profile.url.clone(),
178            credential_present,
179            api: Arc::new(api),
180        })
181    }
182
183    /// Resolve ONLY a credential for a selection — the adopt
184    /// composition path (ADOPT-03): the bootstrap resolves DEGRADED
185    /// (the OIDC dance needs no token), while the post-bootstrap
186    /// steps (`--project`/`--checkout`/`--bake`) need a working token
187    /// when the key step SKIPPED (the mint path already holds the
188    /// fresh key). Same overlay → selection → LOCKED chain as
189    /// [`Self::resolve_degraded`], no client construction.
190    pub fn resolve_credential_opt(
191        profile_flag: Option<&str>,
192    ) -> Result<Option<Credential>, CoreError> {
193        let mut config = config::load(&config::config_path())?;
194        let (name, profile) = resolve_selected(&mut config, profile_flag)?;
195        resolve_secret_opt(&name, &profile.auth)
196    }
197
198    /// Headerless-BY-CONSTRUCTION client for the rig family: a caller-
199    /// derived gateway URL (never a profile's), an explicit optional
200    /// credential, and the caller's `ssl_verify` (rig probes use
201    /// `false` — localhost probes against self-signed rig https are the
202    /// norm). No config is read and no secret is resolved. Replaces
203    /// main.rs's `rig_gateway_client` + the TUI's `rig_client_with`
204    /// (the URL DERIVATION stays at the call sites; only the
205    /// `ReqwestGatewayApi` construction moves behind this constructor —
206    /// no second client construction anywhere).
207    ///
208    /// Rig sessions carry NO profile: [`Self::profile_name`] returns
209    /// the empty string (callers translate to `None` where the output
210    /// model wants a profile echo).
211    pub fn for_url(
212        url: url::Url,
213        credential: Option<Credential>,
214        ssl_verify: bool,
215    ) -> Result<Self, CoreError> {
216        let profile = Profile {
217            url,
218            label: None,
219            ssl_verify,
220            auth: AuthRef::default(),
221            webdev_secret: None,
222            poll_interval_secs: None,
223        };
224        let credential_present = credential.is_some();
225        let api = ReqwestGatewayApi::new(&profile, credential)?;
226        Ok(Self {
227            profile: String::new(),
228            url: profile.url.clone(),
229            credential_present,
230            api: Arc::new(api),
231        })
232    }
233
234    /// The resolved profile's name — empty for [`Self::for_url`] rig
235    /// sessions (no profile exists).
236    pub fn profile_name(&self) -> &str {
237        &self.profile
238    }
239
240    /// The resolved profile's configured gateway URL — the POST-OVERLAY
241    /// value (flag > `IGNITION_URL` env > profile), i.e. exactly what the
242    /// session's client targets. Doctor's url check re-parses this raw
243    /// value: the honest diagnosis must describe the URL the client
244    /// ACTUALLY connects to, overlay included. For [`Self::for_url`] rig
245    /// sessions this is the caller-derived rig URL.
246    pub fn profile_url(&self) -> &url::Url {
247        &self.url
248    }
249
250    /// Whether a credential resolved into this session's client — the
251    /// doctor's `credential_present` flag (a MISSING credential is a
252    /// different diagnosis than an UNRECOGNIZED one; the degraded chain
253    /// decides, this accessor reports, and the secret itself never
254    /// crosses the seam). Always `true` for [`Self::resolve`]; the
255    /// caller's `Some`-ness for [`Self::for_url`].
256    pub fn credential_present(&self) -> bool {
257        self.credential_present
258    }
259
260    /// The gateway client, borrowed.
261    pub fn api(&self) -> &ReqwestGatewayApi {
262        &self.api
263    }
264
265    /// The gateway client as an owned `Arc` handle — the TUI's
266    /// workers/`ClientHandle` shape, without a second construction.
267    pub fn api_handle(&self) -> Arc<ReqwestGatewayApi> {
268        Arc::clone(&self.api)
269    }
270}
271
272/// Feeds every existing free-fn action over `&GatewayApi` unchanged:
273/// `version(&*session, …)` and friends work exactly as they did against
274/// a bare `ReqwestGatewayApi`.
275impl Deref for Session {
276    type Target = ReqwestGatewayApi;
277
278    fn deref(&self) -> &Self::Target {
279        &self.api
280    }
281}
282
283/// THE secret chain (env tokens → keyring → basic pair), built in
284/// exactly one place — the chain, not the structs, encodes the
285/// SEQUENCE (main.rs's private `secret_chain`, now shared). The two
286/// generic (profile-less) env rungs within it are auth-shape-conditional
287/// as of quick/260920-iti; `config::secret`'s module header states the
288/// full rule this chain enforces.
289fn locked_secret_chain() -> Vec<Box<dyn SecretStore>> {
290    vec![
291        Box::new(config::EnvStore),
292        Box::new(config::KeyringStore),
293        Box::new(config::BasicEnvStore),
294    ]
295}
296
297/// Overlay scoped to the WOULD-BE selection FIRST, then the selection —
298/// the verbatim `resolve_profile_context` / TUI `resolve_from`
299/// choreography: the `IGNITION_URL` env overlay targets the profile the
300/// command is ABOUT to select (flag > active), and only then does the
301/// selection resolve against the overlaid config. Nothing resolvable →
302/// `NoActiveProfile` (the main.rs `Ok(None)` consumer behavior — not a
303/// new error class).
304fn resolve_selected(
305    config: &mut config::Config,
306    flag: Option<&str>,
307) -> Result<(String, Profile), CoreError> {
308    let overlay_target = flag.map(str::to_string).or_else(|| config.active.clone());
309    config::apply_env_overlay(config, overlay_target.as_deref());
310    match config::resolve_selection(config, flag)? {
311        Some((name, profile)) => Ok((name, profile)),
312        None => Err(CoreError::NoActiveProfile),
313    }
314}
315
316/// Credential resolution degraded for non-authenticating consumers: the
317/// LOCKED chain with `SecretUnavailable` mapped to `Ok(None)` — every
318/// other credential error propagates (the main.rs `resolve_secret_opt`
319/// port, verbatim).
320fn resolve_secret_opt(profile: &str, auth: &AuthRef) -> Result<Option<Credential>, CoreError> {
321    config::resolve_secret(profile, auth, &locked_secret_chain())
322        .map(Some)
323        .or_else(|err| match err {
324            CoreError::SecretUnavailable { .. } => Ok(None),
325            other => Err(other),
326        })
327}
328
329#[cfg(test)]
330mod tests {
331    use super::{Session, resolve_selected};
332    use crate::client::GatewayApi;
333    use crate::config::ENV_LOCK;
334    use crate::error::CoreError;
335
336    /// The gateway-info JSON body — the one field every 8.3 gateway
337    /// answers with (`ignitionVersion`, the `version` alias tolerated).
338    fn info_body() -> serde_json::Value {
339        serde_json::json!({ "ignitionVersion": "8.3.6 (b2026042713)" })
340    }
341
342    /// Lowercased Debug dump of a recorded request's headers — the
343    /// status_contract.rs presence/absence assertion pattern.
344    fn headers_debug(request: &wiremock::Request) -> String {
345        format!("{:?}", request.headers).to_lowercase()
346    }
347
348    /// Write a config.toml into a tempdir and point
349    /// `IGNITION_CLI_CONFIG` at it. Caller holds `ENV_LOCK`.
350    fn isolate_config(dir: &tempfile::TempDir, toml: &str) {
351        let path = dir.path().join("config.toml");
352        std::fs::write(&path, toml).expect("write config fixture");
353        // SAFETY: single-threaded under ENV_LOCK; each test unsets the
354        // var before returning.
355        unsafe { std::env::set_var("IGNITION_CLI_CONFIG", &path) };
356    }
357
358    fn unset(name: &str) {
359        // SAFETY: single-threaded under ENV_LOCK.
360        unsafe { std::env::remove_var(name) };
361    }
362
363    /// Two-profile fixture: `a` active at `url_a`, `b` at `url_b`, both
364    /// on the generic `IGNITION_TOKEN` auth reference.
365    fn two_profile_toml(url_a: &str, url_b: &str) -> String {
366        format!(
367            r#"
368active = "a"
369
370[profiles.a]
371url = "{url_a}"
372
373[profiles.b]
374url = "{url_b}"
375"#
376        )
377    }
378
379    /// A scoped gateway-info mock — the guard records the requests that
380    /// actually arrived (the status_contract.rs pattern).
381    async fn mount_info(server: &wiremock::MockServer, expected: u64) -> wiremock::MockGuard {
382        wiremock::Mock::given(wiremock::matchers::method("GET"))
383            .and(wiremock::matchers::path("/data/api/v1/gateway-info"))
384            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(info_body()))
385            .expect(expected)
386            .mount_as_scoped(server)
387            .await
388    }
389
390    /// PRECEDENCE: `IGNITION_URL` overrides the SELECTED profile only —
391    /// select profile B by flag while the env carries the overlay URL;
392    /// the session's client must target the OVERLAY (mock_a) for the
393    /// selected profile, and a second session without the overlay env
394    /// targets B's OWN URL (mock_b).
395    ///
396    /// Env mutation stays inside the `ENV_LOCK` scope; the `await`ed
397    /// requests run AFTER the guard drops — the client snapshots URL +
398    /// credential at construction, so the env vars are already gone.
399    #[tokio::test]
400    async fn env_overlay_targets_the_selected_profile() {
401        let mock_a = wiremock::MockServer::start().await;
402        let mock_b = wiremock::MockServer::start().await;
403        let guard_a = mount_info(&mock_a, 1).await;
404        let guard_b = mount_info(&mock_b, 1).await;
405        let dir = tempfile::tempdir().expect("tempdir");
406
407        // Phase 1: overlay set → the session targets the overlay URL.
408        let session = {
409            let _lock = ENV_LOCK.lock().expect("env lock");
410            isolate_config(
411                &dir,
412                &two_profile_toml(mock_b.uri().as_str(), mock_b.uri().as_str()),
413            );
414            // SAFETY: single-threaded under ENV_LOCK.
415            unsafe { std::env::set_var("IGNITION_URL", mock_a.uri()) };
416            // SAFETY: single-threaded under ENV_LOCK.
417            unsafe { std::env::set_var("IGNITION_TOKEN", "overlay-token") };
418            let session = Session::resolve(Some("b")).expect("resolve selects b");
419            // SAFETY: single-threaded under ENV_LOCK.
420            unsafe { std::env::remove_var("IGNITION_URL") };
421            // SAFETY: single-threaded under ENV_LOCK.
422            unsafe { std::env::remove_var("IGNITION_TOKEN") };
423            session
424        };
425        assert_eq!(session.profile_name(), "b");
426        // The overlay URL (mock_a) answered for the selected profile —
427        // NOT the profile's own URL (also mock_b here).
428        let info = session.gateway_info().await.expect("overlay url answers");
429        assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
430        assert_eq!(
431            guard_a.received_requests().await.len(),
432            1,
433            "the env overlay URL took the request"
434        );
435        assert_eq!(
436            guard_b.received_requests().await.len(),
437            0,
438            "the profile's own URL stayed untouched while the overlay was set"
439        );
440
441        // Phase 2: overlay gone → the session targets B's OWN URL.
442        // (The required-mode credential is re-supplied per phase — the
443        // env vars were already cleaned before phase 1's await.)
444        let session = {
445            let _lock = ENV_LOCK.lock().expect("env lock");
446            isolate_config(
447                &dir,
448                &two_profile_toml(mock_b.uri().as_str(), mock_b.uri().as_str()),
449            );
450            // SAFETY: single-threaded under ENV_LOCK.
451            unsafe { std::env::set_var("IGNITION_TOKEN", "overlay-token") };
452            let session = Session::resolve(Some("b")).expect("resolve again");
453            // SAFETY: single-threaded under ENV_LOCK.
454            unsafe { std::env::remove_var("IGNITION_TOKEN") };
455            // SAFETY: single-threaded under ENV_LOCK.
456            unsafe { std::env::remove_var("IGNITION_CLI_CONFIG") };
457            session
458        };
459        let info = session.gateway_info().await.expect("own url answers");
460        assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
461        assert_eq!(
462            guard_b.received_requests().await.len(),
463            1,
464            "without the overlay the selected profile's own URL answers"
465        );
466    }
467
468    /// SELECTION: flag > active; unknown name → `ProfileNotFound` with
469    /// the known profiles in the hint; nothing resolvable →
470    /// `NoActiveProfile` (the existing main.rs consumer of
471    /// `resolve_selection`'s `Ok(None)` — not a new error class).
472    /// `IGNITION_PROFILE` is deliberately inert here: the bin folds it
473    /// into the flag (`apply_env_defaults` — the one env→flag home), so
474    /// the seam never re-reads it.
475    #[test]
476    fn selection_precedence_flag_over_active_and_errors() {
477        let _lock = ENV_LOCK.lock().expect("env lock");
478        let dir = tempfile::tempdir().expect("tempdir");
479        isolate_config(
480            &dir,
481            &two_profile_toml("http://a.example:9088/", "http://b.example:9088/"),
482        );
483        // SAFETY: single-threaded under ENV_LOCK.
484        unsafe { std::env::set_var("IGNITION_TOKEN", "t") };
485
486        // Flag beats active.
487        let session = Session::resolve(Some("b")).expect("flag selects b");
488        assert_eq!(session.profile_name(), "b");
489
490        // Unknown name → ProfileNotFound carrying the knowns.
491        let err = Session::resolve(Some("nope")).expect_err("unknown errors");
492        match &err {
493            CoreError::ProfileNotFound { name, known } => {
494                assert_eq!(name, "nope");
495                assert_eq!(known, &vec!["a".to_string(), "b".to_string()]);
496            }
497            other => panic!("wrong error class: {other}"),
498        }
499        assert_eq!(err.exit_code(), 3);
500
501        // Nothing resolvable → NoActiveProfile, both modes.
502        isolate_config(&dir, "");
503        assert!(matches!(
504            Session::resolve(None).expect_err("no selection errors"),
505            CoreError::NoActiveProfile
506        ));
507        assert!(matches!(
508            Session::resolve_degraded(None).expect_err("no selection errors"),
509            CoreError::NoActiveProfile
510        ));
511
512        // The bin's env fold is upstream: IGNITION_PROFILE alone must
513        // NOT drive the seam's selection.
514        isolate_config(
515            &dir,
516            &two_profile_toml("http://a.example:9088/", "http://b.example:9088/"),
517        );
518        // SAFETY: single-threaded under ENV_LOCK.
519        unsafe { std::env::set_var("IGNITION_PROFILE", "b") };
520        let session = Session::resolve(None).expect("active wins without a flag");
521        assert_eq!(
522            session.profile_name(),
523            "a",
524            "IGNITION_PROFILE folding belongs to the bin, not the seam"
525        );
526
527        unset("IGNITION_PROFILE");
528        unset("IGNITION_TOKEN");
529        unset("IGNITION_CLI_CONFIG");
530    }
531
532    /// SECRET CHAIN (LOCKED): an env token beats the basic env pair
533    /// (env-first), and — when NO secret exists anywhere — required
534    /// mode errors with `SecretUnavailable` (exit 3) while degraded
535    /// mode returns a header-less client (the authed gateway-info
536    /// request carries NO auth header at all).
537    ///
538    /// Same lock discipline as the precedence test: env mutations and
539    /// the sync `resolve*` calls hold `ENV_LOCK`; the `await`ed
540    /// requests run with the guard dropped (the client already
541    /// snapshotted its credential).
542    #[tokio::test]
543    async fn locked_chain_env_first_required_errors_degraded_headerless() {
544        let mock = wiremock::MockServer::start().await;
545        let guard = mount_info(&mock, 2).await;
546        let dir = tempfile::tempdir().expect("tempdir");
547
548        // Env token beats the basic pair: both sets, the authed request
549        // carries the TOKEN header and no Authorization header.
550        let session = {
551            let _lock = ENV_LOCK.lock().expect("env lock");
552            isolate_config(
553                &dir,
554                &two_profile_toml(mock.uri().as_str(), mock.uri().as_str()),
555            );
556            // SAFETY: single-threaded under ENV_LOCK.
557            unsafe {
558                std::env::set_var("IGNITION_TOKEN", "chain-token");
559                std::env::set_var("IGNITION_USER", "admin");
560                std::env::set_var("IGNITION_PASSWORD", "pw");
561            }
562            let session = Session::resolve(Some("a")).expect("env token resolves");
563            // SAFETY: single-threaded under ENV_LOCK.
564            unsafe {
565                std::env::remove_var("IGNITION_TOKEN");
566                std::env::remove_var("IGNITION_USER");
567                std::env::remove_var("IGNITION_PASSWORD");
568            }
569            session
570        };
571        let info = session.gateway_info().await.expect("token answers");
572        assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
573        let requests = guard.received_requests().await;
574        assert_eq!(requests.len(), 1, "one token-carrying request so far");
575        let headers = headers_debug(&requests[0]);
576        assert!(
577            headers.contains("x-ignition-api-token"),
578            "env token must ride the token header: {headers}"
579        );
580        assert!(
581            !headers.contains("authorization"),
582            "basic pair must lose to the env token: {headers}"
583        );
584
585        // No secret anywhere: required mode refuses; degraded mode
586        // survives (the authed request goes out with ZERO auth headers
587        // — a credential must not sneak in from the OS keyring either).
588        {
589            let _lock = ENV_LOCK.lock().expect("env lock");
590            isolate_config(
591                &dir,
592                &two_profile_toml(mock.uri().as_str(), mock.uri().as_str()),
593            );
594            let err = Session::resolve(Some("a")).expect_err("required mode demands a secret");
595            assert!(matches!(err, CoreError::SecretUnavailable { .. }));
596            assert_eq!(err.exit_code(), 3);
597            assert!(
598                err.hint().expect("hint").contains("IGNITION_TOKEN"),
599                "hint names the env path"
600            );
601            let degraded = Session::resolve_degraded(Some("a")).expect("degraded tolerates none");
602            // SAFETY: single-threaded under ENV_LOCK.
603            unsafe { std::env::remove_var("IGNITION_CLI_CONFIG") };
604            degraded
605        }
606        .gateway_info()
607        .await
608        .map(|info| {
609            assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
610        })
611        .expect("headerless answers");
612        let requests = guard.received_requests().await;
613        assert_eq!(requests.len(), 2, "the headerless request arrived");
614        let headers = headers_debug(&requests[1]);
615        assert!(
616            !headers.contains("x-ignition-api-token"),
617            "degraded mode must be header-less: {headers}"
618        );
619        assert!(
620            !headers.contains("authorization"),
621            "degraded mode must be header-less: {headers}"
622        );
623    }
624
625    /// The shared selection helper maps `Ok(None)` → `NoActiveProfile`
626    /// (never a silent pass-through) — the exact consumer behavior the
627    /// main.rs call sites implement today.
628    #[test]
629    fn resolve_selected_maps_none_to_no_active_profile() {
630        let _lock = ENV_LOCK.lock().expect("env lock");
631        let dir = tempfile::tempdir().expect("tempdir");
632        isolate_config(&dir, "");
633        let mut config = crate::config::load(&crate::config::config_path()).expect("load");
634        let err = resolve_selected(&mut config, None).expect_err("none → error");
635        assert!(matches!(err, CoreError::NoActiveProfile));
636        unset("IGNITION_CLI_CONFIG");
637    }
638}