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