Skip to main content

orch/github/
token.rs

1//! Centralized GitHub token resolution — supports env vars, GitHub App JWT, and gh CLI fallback.
2//!
3//! This module provides a [`TokenResolver`] that caches token resolution and supports multiple modes:
4//! - `env` (default): Read from `GH_TOKEN` or `GITHUB_TOKEN` env vars, then `gh.auth.token` config,
5//!   then `gh auth token` CLI (controlled by `allow_gh_fallback`, which defaults to `true`)
6//! - `github_app`: Generate JWT from app_id + private_key (for GitHub App authentication)
7//!
8//! ## Default behavior
9//!
10//! Out of the box, `TokenResolver::default()` works with just `gh auth login` — no extra config needed.
11//! Resolution order:
12//! 1. `GH_TOKEN` environment variable
13//! 2. `GITHUB_TOKEN` environment variable
14//! 3. `gh.auth.token` config value
15//! 4. `gh auth token` CLI (via `allow_gh_fallback = true`)
16
17use anyhow::Context;
18#[cfg(test)]
19use once_cell::sync::Lazy;
20use std::env;
21#[cfg(test)]
22use std::sync::Mutex;
23use std::sync::{Arc, OnceLock};
24use std::time::{SystemTime, UNIX_EPOCH};
25use tokio::sync::RwLock;
26
27/// Token source mode for GitHub authentication.
28#[derive(Debug, Clone, PartialEq, Default)]
29pub enum TokenMode {
30    /// Read from environment variables and config, with optional `gh auth token` fallback.
31    ///
32    /// Resolution order:
33    /// 1. `GH_TOKEN` env var
34    /// 2. `GITHUB_TOKEN` env var
35    /// 3. `gh.auth.token` config value
36    /// 4. `gh auth token` CLI (if `allow_gh_fallback` is enabled, which is the default)
37    #[default]
38    Env,
39    /// GitHub App authentication with JWT generation.
40    GitHubApp {
41        /// GitHub App ID.
42        app_id: String,
43        /// Path to the private key file (.pem).
44        private_key_path: String,
45    },
46}
47
48impl TokenMode {
49    /// Parse token mode from configuration string.
50    ///
51    /// Supported values:
52    /// - `"env"` → `TokenMode::Env`
53    /// - `"github_app"` → `TokenMode::GitHubApp` (requires `app_id` and `private_key_path` config)
54    #[allow(dead_code)]
55    pub fn from_config(
56        mode: &str,
57        app_id: Option<String>,
58        private_key_path: Option<String>,
59    ) -> Self {
60        match mode {
61            "github_app" => {
62                if let (Some(app_id), Some(key_path)) = (app_id, private_key_path) {
63                    TokenMode::GitHubApp {
64                        app_id,
65                        private_key_path: key_path,
66                    }
67                } else {
68                    tracing::warn!(
69                        "github_app mode requires app_id and private_key_path, falling back to env"
70                    );
71                    TokenMode::Env
72                }
73            }
74            _ => TokenMode::Env,
75        }
76    }
77}
78
79/// Cached token with optional expiration (for GitHub App JWTs).
80#[derive(Debug, Clone)]
81struct CachedToken {
82    token: String,
83    /// Expiration time as Unix epoch seconds (None for non-expiring tokens).
84    expires_at: Option<u64>,
85}
86
87/// Centralized token resolver for GitHub authentication.
88///
89/// The resolver caches tokens and supports async resolution for modes that
90/// may require I/O (e.g., reading private key files).
91///
92/// ## Default setup
93///
94/// ```rust,ignore
95/// let resolver = TokenResolver::default(); // uses gh auth token if no env var set
96/// let token = resolver.get_token().await?;
97/// ```
98#[derive(Debug, Clone)]
99pub struct TokenResolver {
100    mode: TokenMode,
101    /// Whether to fall back to `gh auth token` CLI when env vars and config are unset.
102    ///
103    /// Defaults to `true` — just run `gh auth login` and everything works.
104    /// Set to `false` only when you want to enforce explicit token configuration.
105    allow_gh_fallback: bool,
106    /// Cached token with expiration tracking.
107    cached: Arc<RwLock<Option<CachedToken>>>,
108}
109
110impl TokenResolver {
111    /// Create a new TokenResolver with the specified mode.
112    ///
113    /// `allow_gh_fallback` defaults to `true` — `gh auth token` is tried last if env vars
114    /// and config are not set.
115    pub fn new(mode: TokenMode) -> Self {
116        Self {
117            mode,
118            allow_gh_fallback: true,
119            cached: Arc::new(RwLock::new(None)),
120        }
121    }
122
123    /// Create a TokenResolver from configuration values.
124    #[allow(dead_code)]
125    pub fn from_config(
126        mode_str: Option<&str>,
127        app_id: Option<String>,
128        private_key_path: Option<String>,
129        allow_gh_fallback: bool,
130    ) -> Self {
131        let mode = TokenMode::from_config(mode_str.unwrap_or("env"), app_id, private_key_path);
132        Self {
133            mode,
134            allow_gh_fallback,
135            cached: Arc::new(RwLock::new(None)),
136        }
137    }
138
139    /// Create a default TokenResolver using environment variables with gh CLI fallback.
140    pub fn default_env() -> Self {
141        Self::new(TokenMode::Env)
142    }
143
144    /// Enable or disable `gh auth token` CLI fallback.
145    ///
146    /// Defaults to `true` — disable only to enforce explicit token configuration.
147    #[allow(dead_code)]
148    pub fn with_gh_fallback(mut self, enabled: bool) -> Self {
149        self.allow_gh_fallback = enabled;
150        self
151    }
152
153    /// Resolve the GitHub token asynchronously.
154    ///
155    /// Returns `Ok(Some(token))` if a token is available,
156    /// `Ok(None)` if no token could be resolved,
157    /// or `Err` if an error occurred during resolution.
158    pub async fn get_token(&self) -> anyhow::Result<Option<String>> {
159        // Check cache first
160        {
161            let cached = self.cached.read().await;
162            if let Some(ref entry) = *cached {
163                // Check expiration if present
164                if let Some(expires_at) = entry.expires_at {
165                    let now = SystemTime::now()
166                        .duration_since(UNIX_EPOCH)
167                        .unwrap_or_default()
168                        .as_secs();
169                    if now < expires_at {
170                        return Ok(Some(entry.token.clone()));
171                    }
172                    // Token expired, will re-resolve
173                } else {
174                    // No expiration, use cached token
175                    return Ok(Some(entry.token.clone()));
176                }
177            }
178        }
179
180        // Resolve fresh token
181        let token = self.resolve_fresh().await?;
182
183        // Cache the result if we got a token
184        if let Some(ref t) = token {
185            let expires_at = self.calculate_expiration();
186            let mut cached = self.cached.write().await;
187            *cached = Some(CachedToken {
188                token: t.clone(),
189                expires_at,
190            });
191        }
192
193        Ok(token)
194    }
195
196    /// Get token synchronously (for contexts where async is not available).
197    ///
198    /// Works for Env mode (env vars, config, and gh CLI fallback).
199    /// GitHub App mode requires async due to file reading.
200    #[allow(dead_code)]
201    pub fn get_token_sync(&self) -> anyhow::Result<Option<String>> {
202        match self.mode {
203            TokenMode::Env => {
204                // Try env vars + config first
205                if let Some(t) = Self::resolve_env_token() {
206                    return Ok(Some(t));
207                }
208                // gh CLI fallback
209                if self.allow_gh_fallback {
210                    return Self::resolve_gh_cli_token();
211                }
212                Ok(None)
213            }
214            TokenMode::GitHubApp { .. } => {
215                // GitHub App requires async due to file I/O
216                Err(anyhow::anyhow!(
217                    "GitHub App token resolution requires async context, use get_token() instead"
218                ))
219            }
220        }
221    }
222
223    /// Clear the cached token (useful when token is known to be invalid).
224    pub async fn clear_cache(&self) {
225        let mut cached = self.cached.write().await;
226        *cached = None;
227    }
228
229    /// Get the current token mode.
230    #[allow(dead_code)]
231    pub fn mode(&self) -> &TokenMode {
232        &self.mode
233    }
234
235    /// Resolve a fresh token based on the configured mode.
236    async fn resolve_fresh(&self) -> anyhow::Result<Option<String>> {
237        // Try primary mode first
238        let token = match &self.mode {
239            TokenMode::Env => Self::resolve_env_token(),
240            TokenMode::GitHubApp {
241                app_id,
242                private_key_path,
243            } => {
244                self.resolve_github_app_token(app_id, private_key_path)
245                    .await?
246            }
247        };
248
249        // Return if we got a token from primary mode
250        if token.is_some() {
251            return Ok(token);
252        }
253
254        // gh CLI fallback — the default when nothing else is configured
255        if self.allow_gh_fallback {
256            tracing::debug!("No token in env/config — trying gh auth token CLI");
257            return Self::resolve_gh_cli_token();
258        }
259
260        Ok(None)
261    }
262
263    /// Resolve token from environment variables and config.
264    ///
265    /// Checks (in order):
266    /// 1. `GH_TOKEN` env var
267    /// 2. `GITHUB_TOKEN` env var
268    /// 3. `gh.auth.token` config value
269    fn resolve_env_token() -> Option<String> {
270        if let Ok(t) = env::var("GH_TOKEN") {
271            if !t.is_empty() {
272                // Info, not debug: when a token turns out bad, the source is the first thing to check
273                tracing::info!("Resolved GitHub token from GH_TOKEN environment variable");
274                return Some(t);
275            }
276        }
277        if let Ok(t) = env::var("GITHUB_TOKEN") {
278            if !t.is_empty() {
279                tracing::info!("Resolved GitHub token from GITHUB_TOKEN environment variable");
280                return Some(t);
281            }
282        }
283        // Config-based token (explicit override in ~/.orch/config.yml)
284        if let Ok(t) = crate::config::get("gh.auth.token") {
285            if !t.is_empty() {
286                tracing::info!("Resolved GitHub token from gh.auth.token config");
287                return Some(t);
288            }
289        }
290        None
291    }
292
293    /// Resolve token using `gh auth token` CLI.
294    fn resolve_gh_cli_token() -> anyhow::Result<Option<String>> {
295        tracing::debug!("Attempting to resolve GitHub token via gh CLI");
296
297        // Try gh in PATH first
298        if let Some(token) = Self::try_gh_command("gh")? {
299            return Ok(Some(token));
300        }
301
302        // Try common gh install paths for launchd/Homebrew environments
303        for path in &["/opt/homebrew/bin/gh", "/usr/local/bin/gh"] {
304            if let Some(token) = Self::try_gh_command(path)? {
305                return Ok(Some(token));
306            }
307        }
308
309        Ok(None)
310    }
311
312    /// Try to get token from a specific gh binary path.
313    ///
314    /// # Why `std::process::Command` (blocking) is intentional here
315    ///
316    /// This looks like a "blocking call in async context" antipattern, but it is not a problem:
317    /// - `resolve_fresh()` is only called from `get_token()` under a write-lock
318    /// - The result is immediately cached: env tokens forever, gh CLI tokens for 1 hour
319    /// - This executes AT MOST once per hour, typically once at startup
320    /// - A one-time ~100ms call does not meaningfully block the Tokio runtime
321    ///
322    /// DO NOT replace this with `spawn_blocking` or `tokio::process::Command` —
323    /// the added complexity is not justified and previous attempts to "fix" this
324    /// broke the auth flow. See issues #418 and #421 (closed as invalid).
325    fn try_gh_command(gh_path: &str) -> anyhow::Result<Option<String>> {
326        match std::process::Command::new(gh_path)
327            .args(["auth", "token"])
328            .output()
329        {
330            Ok(out) if out.status.success() => {
331                let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
332                if !token.is_empty() {
333                    tracing::info!(gh_path, "Resolved GitHub token via gh CLI (keychain)");
334                    return Ok(Some(token));
335                }
336            }
337            Ok(out) => {
338                let stderr = String::from_utf8_lossy(&out.stderr);
339                tracing::debug!(gh_path, %stderr, "gh auth token failed");
340            }
341            Err(e) => {
342                tracing::debug!(gh_path, error = %e, "Failed to execute gh command");
343            }
344        }
345        Ok(None)
346    }
347
348    /// Resolve token for GitHub App (JWT-based).
349    async fn resolve_github_app_token(
350        &self,
351        app_id: &str,
352        private_key_path: &str,
353    ) -> anyhow::Result<Option<String>> {
354        tracing::debug!(app_id, private_key_path, "Generating GitHub App JWT");
355
356        // Read the private key
357        let private_key = tokio::fs::read_to_string(private_key_path)
358            .await
359            .with_context(|| format!("Failed to read private key from {private_key_path}"))?;
360
361        // Generate JWT
362        let jwt = generate_github_app_jwt(app_id, &private_key)?;
363
364        // Exchange JWT for installation token (requires installation_id)
365        // For now, just return the JWT (this is a placeholder for full implementation)
366        tracing::warn!(
367            "GitHub App JWT generation is available but token exchange requires installation_id"
368        );
369
370        Ok(Some(jwt))
371    }
372
373    /// Calculate expiration time for cached tokens.
374    fn calculate_expiration(&self) -> Option<u64> {
375        match self.mode {
376            // GitHub App JWTs expire (typically 10 minutes)
377            TokenMode::GitHubApp { .. } => {
378                let now = SystemTime::now()
379                    .duration_since(UNIX_EPOCH)
380                    .unwrap_or_default()
381                    .as_secs();
382                // JWT valid for 9 minutes (GitHub allows up to 10)
383                Some(now + 540)
384            }
385            // Env tokens don't expire (managed externally)
386            _ => None,
387        }
388    }
389}
390
391/// Process-wide shared [`TokenResolver`].
392///
393/// Initialized once on first call. All callers (GhHttp, agent runner, etc.)
394/// share the same instance so the token is resolved once — via GH_TOKEN,
395/// GITHUB_TOKEN, or `gh auth token` CLI — and cached for the life of the process.
396static SHARED_RESOLVER: OnceLock<Arc<TokenResolver>> = OnceLock::new();
397
398/// Get the process-wide shared token resolver.
399pub fn shared() -> Arc<TokenResolver> {
400    SHARED_RESOLVER
401        .get_or_init(|| Arc::new(TokenResolver::default_env()))
402        .clone()
403}
404
405impl Default for TokenResolver {
406    fn default() -> Self {
407        Self::default_env()
408    }
409}
410
411/// Generate a JWT for GitHub App authentication.
412///
413/// This creates a signed JWT using the app's private key (RS256).
414/// The JWT can then be used to authenticate as the app or exchange for
415/// an installation access token.
416fn generate_github_app_jwt(app_id: &str, private_key_pem: &str) -> anyhow::Result<String> {
417    use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
418    use serde::{Deserialize, Serialize};
419
420    #[derive(Debug, Serialize, Deserialize)]
421    struct Claims {
422        /// Issued at time (Unix timestamp)
423        iat: u64,
424        /// Expiration time (Unix timestamp, max 10 minutes from iat)
425        exp: u64,
426        /// GitHub App ID
427        iss: String,
428    }
429
430    let now = SystemTime::now()
431        .duration_since(UNIX_EPOCH)
432        .unwrap_or_default()
433        .as_secs();
434
435    let claims = Claims {
436        iat: now - 60,  // Allow 60 seconds clock skew
437        exp: now + 540, // 9 minutes expiration
438        iss: app_id.to_string(),
439    };
440
441    let header = Header::new(Algorithm::RS256);
442    let encoding_key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes())
443        .context("Failed to parse private key PEM")?;
444
445    let token = encode(&header, &claims, &encoding_key).context("Failed to encode JWT")?;
446
447    Ok(token)
448}
449
450/// Create a global token resolver from configuration.
451///
452/// Resolution order:
453/// 1. `GH_TOKEN` / `GITHUB_TOKEN` env vars
454/// 2. `gh.auth.token` config value
455/// 3. `gh auth token` CLI (unless `gh.allow_gh_fallback = false`)
456///
457/// This is typically called once at application startup.
458#[allow(dead_code)]
459pub fn create_token_resolver() -> TokenResolver {
460    let mode = crate::config::get("github.token_mode").ok();
461    let app_id = crate::config::get("github.app_id").ok();
462    let private_key_path = crate::config::get("github.private_key_path").ok();
463    // allow_gh_fallback defaults to true — gh auth login is all you need
464    let allow_gh_fallback = crate::config::get("gh.allow_gh_fallback")
465        .ok()
466        .map(|s| s != "false" && s != "0")
467        .unwrap_or(true);
468
469    TokenResolver::from_config(mode.as_deref(), app_id, private_key_path, allow_gh_fallback)
470}
471
472/// Serializes tests that mutate `GH_TOKEN`/`GITHUB_TOKEN`, shared with http.rs tests
473#[cfg(test)]
474pub(crate) static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn token_mode_from_config_env() {
482        let mode = TokenMode::from_config("env", None, None);
483        assert!(matches!(mode, TokenMode::Env));
484    }
485
486    #[test]
487    fn token_mode_from_config_unknown_defaults_to_env() {
488        let mode = TokenMode::from_config("unknown", None, None);
489        assert!(matches!(mode, TokenMode::Env));
490    }
491
492    #[test]
493    fn token_mode_from_config_github_app() {
494        let mode = TokenMode::from_config(
495            "github_app",
496            Some("123456".to_string()),
497            Some("/path/to/key.pem".to_string()),
498        );
499        assert!(matches!(mode, TokenMode::GitHubApp { .. }));
500    }
501
502    #[test]
503    fn token_mode_from_config_github_app_missing_params() {
504        // Should fall back to env if app_id or private_key_path is missing
505        let mode = TokenMode::from_config("github_app", Some("123456".to_string()), None);
506        assert!(matches!(mode, TokenMode::Env));
507
508        let mode = TokenMode::from_config("github_app", None, Some("/path/to/key.pem".to_string()));
509        assert!(matches!(mode, TokenMode::Env));
510    }
511
512    #[test]
513    fn token_resolver_default_is_env() {
514        let resolver = TokenResolver::default();
515        assert!(matches!(resolver.mode(), TokenMode::Env));
516    }
517
518    #[test]
519    fn token_resolver_new_env() {
520        let resolver = TokenResolver::new(TokenMode::Env);
521        assert!(matches!(resolver.mode(), TokenMode::Env));
522    }
523
524    #[test]
525    fn token_resolver_gh_fallback_enabled_by_default() {
526        let resolver = TokenResolver::new(TokenMode::Env);
527        assert!(resolver.allow_gh_fallback);
528    }
529
530    #[test]
531    fn token_resolver_with_gh_fallback() {
532        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
533        assert!(!resolver.allow_gh_fallback);
534    }
535
536    #[allow(clippy::await_holding_lock)]
537    #[tokio::test(flavor = "current_thread")]
538    async fn get_token_returns_none_when_no_env_set() {
539        let _guard = ENV_LOCK.lock().unwrap();
540        env::remove_var("GH_TOKEN");
541        env::remove_var("GITHUB_TOKEN");
542        // Disable gh fallback so we don't actually call gh CLI in tests
543        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
544        resolver.clear_cache().await;
545
546        let token = resolver.get_token().await.unwrap();
547        assert!(token.is_none());
548    }
549
550    #[allow(clippy::await_holding_lock)]
551    #[tokio::test(flavor = "current_thread")]
552    async fn get_token_prefers_gh_token() {
553        let _guard = ENV_LOCK.lock().unwrap();
554        env::remove_var("GH_TOKEN");
555        env::remove_var("GITHUB_TOKEN");
556        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
557        resolver.clear_cache().await;
558
559        env::set_var("GH_TOKEN", "gh_token_value");
560        env::remove_var("GITHUB_TOKEN");
561
562        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
563        let token = resolver.get_token().await.unwrap();
564        assert_eq!(token, Some("gh_token_value".to_string()));
565
566        env::remove_var("GH_TOKEN");
567    }
568
569    #[allow(clippy::await_holding_lock)]
570    #[tokio::test(flavor = "current_thread")]
571    async fn get_token_falls_back_to_github_token() {
572        let _guard = ENV_LOCK.lock().unwrap();
573        env::remove_var("GH_TOKEN");
574        env::remove_var("GITHUB_TOKEN");
575        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
576        resolver.clear_cache().await;
577
578        env::set_var("GITHUB_TOKEN", "github_token_value");
579
580        let token = resolver.get_token().await.unwrap();
581        assert_eq!(token, Some("github_token_value".to_string()));
582
583        env::remove_var("GITHUB_TOKEN");
584    }
585
586    #[allow(clippy::await_holding_lock)]
587    #[tokio::test(flavor = "current_thread")]
588    async fn get_token_prefers_gh_token_over_github_token() {
589        let _guard = ENV_LOCK.lock().unwrap();
590        env::remove_var("GH_TOKEN");
591        env::remove_var("GITHUB_TOKEN");
592        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
593        resolver.clear_cache().await;
594
595        env::set_var("GH_TOKEN", "gh_token_value");
596        env::set_var("GITHUB_TOKEN", "github_token_value");
597
598        let token = resolver.get_token().await.unwrap();
599        // GH_TOKEN should be preferred
600        assert_eq!(token, Some("gh_token_value".to_string()));
601
602        env::remove_var("GH_TOKEN");
603        env::remove_var("GITHUB_TOKEN");
604    }
605
606    #[allow(clippy::await_holding_lock)]
607    #[tokio::test(flavor = "current_thread")]
608    async fn get_token_ignores_empty_env_vars() {
609        let _guard = ENV_LOCK.lock().unwrap();
610        env::remove_var("GH_TOKEN");
611        env::remove_var("GITHUB_TOKEN");
612        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
613        resolver.clear_cache().await;
614
615        env::set_var("GH_TOKEN", "");
616        env::set_var("GITHUB_TOKEN", "");
617
618        let token = resolver.get_token().await.unwrap();
619        assert!(token.is_none());
620
621        env::remove_var("GH_TOKEN");
622        env::remove_var("GITHUB_TOKEN");
623    }
624
625    #[allow(clippy::await_holding_lock)]
626    #[tokio::test(flavor = "current_thread")]
627    async fn get_token_sync_env_mode() {
628        let _guard = ENV_LOCK.lock().unwrap();
629        env::remove_var("GH_TOKEN");
630        env::remove_var("GITHUB_TOKEN");
631        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
632        resolver.clear_cache().await;
633
634        env::set_var("GH_TOKEN", "sync_test_token");
635
636        let token = resolver.get_token_sync().unwrap();
637        assert_eq!(token, Some("sync_test_token".to_string()));
638
639        env::remove_var("GH_TOKEN");
640    }
641
642    #[test]
643    fn get_token_sync_github_app_requires_async() {
644        let resolver = TokenResolver::new(TokenMode::GitHubApp {
645            app_id: "123".to_string(),
646            private_key_path: "/path/to/key.pem".to_string(),
647        });
648
649        let result = resolver.get_token_sync();
650        assert!(result.is_err());
651        assert!(result.unwrap_err().to_string().contains("async"));
652    }
653
654    #[allow(clippy::await_holding_lock)]
655    #[tokio::test(flavor = "current_thread")]
656    async fn clear_cache_clears_token() {
657        let _guard = ENV_LOCK.lock().unwrap();
658        env::remove_var("GH_TOKEN");
659        env::remove_var("GITHUB_TOKEN");
660        let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
661        resolver.clear_cache().await;
662
663        env::set_var("GH_TOKEN", "cached_token");
664
665        // First call should cache the token
666        let token1 = resolver.get_token().await.unwrap();
667        assert_eq!(token1, Some("cached_token".to_string()));
668
669        // Clear cache
670        resolver.clear_cache().await;
671
672        // Token should still resolve (fresh lookup)
673        let token2 = resolver.get_token().await.unwrap();
674        assert_eq!(token2, Some("cached_token".to_string()));
675
676        env::remove_var("GH_TOKEN");
677    }
678
679    #[test]
680    fn resolve_env_token_returns_none_when_unset() {
681        let _guard = ENV_LOCK.lock().unwrap();
682        env::remove_var("GH_TOKEN");
683        env::remove_var("GITHUB_TOKEN");
684
685        let token = TokenResolver::resolve_env_token();
686        assert!(token.is_none());
687    }
688
689    #[test]
690    fn resolve_env_token_prefers_gh_token() {
691        let _guard = ENV_LOCK.lock().unwrap();
692        env::set_var("GH_TOKEN", "gh_pref");
693        env::set_var("GITHUB_TOKEN", "github_pref");
694
695        let token = TokenResolver::resolve_env_token();
696        assert_eq!(token, Some("gh_pref".to_string()));
697
698        env::remove_var("GH_TOKEN");
699        env::remove_var("GITHUB_TOKEN");
700    }
701}