diff --git a/crates/vfox/src/lua_mod/http.rs b/crates/vfox/src/lua_mod/http.rs index 6dcf362950..703d4b06a9 100644 --- a/crates/vfox/src/lua_mod/http.rs +++ b/crates/vfox/src/lua_mod/http.rs @@ -109,6 +109,15 @@ fn into_headers(table: &Table) -> Result { } fn github_token(lua: &Lua) -> Option { + if let Ok(resolver) = lua.named_registry_value::("github_token_fn") + && let Ok(token) = resolver.call::(()) + { + let token = token.trim(); + if !token.is_empty() { + return Some(token.to_string()); + } + } + if let Ok(token) = lua.named_registry_value::("github_token") { let token = token.trim(); if !token.is_empty() { @@ -418,6 +427,93 @@ mod tests { .unwrap(); } + #[test] + fn test_add_default_headers_uses_lazy_resolver() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let calls = Arc::new(AtomicUsize::new(0)); + let lua = Lua::new(); + + let calls_inner = calls.clone(); + let resolver = lua + .create_function(move |_, ()| { + calls_inner.fetch_add(1, Ordering::SeqCst); + Ok("ghp_lazy".to_string()) + }) + .unwrap(); + lua.set_named_registry_value("github_token_fn", resolver) + .unwrap(); + + assert_eq!(calls.load(Ordering::SeqCst), 0); + + let headers = add_default_headers( + &lua, + "https://api.github.com/repos/neovim/neovim/releases", + HeaderMap::default(), + ); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer ghp_lazy") + ); + + // Non-GitHub-API URLs must not invoke the resolver. + let _ = add_default_headers(&lua, "https://example.com/some/path", HeaderMap::default()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn test_add_default_headers_lazy_resolver_takes_precedence_over_string() { + let lua = Lua::new(); + lua.set_named_registry_value("github_token", "ghp_string") + .unwrap(); + let resolver = lua + .create_function(|_, ()| Ok("ghp_lazy".to_string())) + .unwrap(); + lua.set_named_registry_value("github_token_fn", resolver) + .unwrap(); + + let headers = add_default_headers( + &lua, + "https://api.github.com/repos/owner/repo", + HeaderMap::default(), + ); + + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer ghp_lazy") + ); + } + + #[test] + fn test_add_default_headers_falls_back_to_string_when_resolver_empty() { + let lua = Lua::new(); + lua.set_named_registry_value("github_token", "ghp_string") + .unwrap(); + let resolver = lua.create_function(|_, ()| Ok(String::new())).unwrap(); + lua.set_named_registry_value("github_token_fn", resolver) + .unwrap(); + + let headers = add_default_headers( + &lua, + "https://api.github.com/repos/owner/repo", + HeaderMap::default(), + ); + + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer ghp_string") + ); + } + #[test] fn test_add_default_headers_uses_registry_token() { let lua = Lua::new(); diff --git a/crates/vfox/src/plugin.rs b/crates/vfox/src/plugin.rs index 79f3cd6218..9b7ae46afd 100644 --- a/crates/vfox/src/plugin.rs +++ b/crates/vfox/src/plugin.rs @@ -1,6 +1,7 @@ use std::cmp::Ordering; use std::fmt::Display; use std::path::{Path, PathBuf}; +use std::sync::Arc; use mlua::{AsChunk, FromLuaMulti, IntoLua, Lua, Table, Value}; use once_cell::sync::OnceCell; @@ -112,6 +113,21 @@ impl Plugin { Ok(()) } + /// Register a lazy resolver for the GitHub token. The resolver is only + /// invoked when a Lua plugin actually makes an HTTP request to a GitHub + /// API URL — keeping `mise hook-env`, completion, etc. from running e.g. + /// `github.credential_command` when no token is needed. + pub fn set_github_token_resolver( + &self, + resolver: Arc Option + Send + Sync>, + ) -> Result<()> { + let func = self + .lua + .create_function(move |_, ()| Ok(resolver().unwrap_or_default()))?; + self.lua.set_named_registry_value("github_token_fn", func)?; + Ok(()) + } + pub fn list() -> Result> { let config = Config::get(); if !config.plugin_dir.exists() { diff --git a/crates/vfox/src/vfox.rs b/crates/vfox/src/vfox.rs index 42a5f0cc85..0b465572d1 100644 --- a/crates/vfox/src/vfox.rs +++ b/crates/vfox/src/vfox.rs @@ -4,7 +4,7 @@ use reqwest::Url; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::str::FromStr; -use std::sync::mpsc; +use std::sync::{Arc, mpsc}; use tempfile::TempDir; use xx::file; @@ -37,7 +37,6 @@ pub struct InstallResult { pub checksum_verified: bool, } -#[derive(Debug)] pub struct Vfox { pub runtime_version: String, pub install_dir: PathBuf, @@ -56,11 +55,37 @@ pub struct Vfox { pub cmd_env: Option>, /// Optional GitHub token for Lua http requests to GitHub API endpoints. pub github_token: Option, + /// Optional lazy resolver for the GitHub token. When set, the token is only + /// resolved if a Lua plugin actually makes an HTTP request to a GitHub API + /// URL — avoiding e.g. spawning `github.credential_command` for innocuous + /// commands like `mise hook-env` that never need a token. Takes precedence + /// over `github_token` when both are set. + pub github_token_resolver: Option Option + Send + Sync>>, /// Optional runtime env type (`gnu` or `musl`) exposed to plugin hooks. pub runtime_env_type: Option, log_tx: Option>, } +impl std::fmt::Debug for Vfox { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Vfox") + .field("runtime_version", &self.runtime_version) + .field("install_dir", &self.install_dir) + .field("plugin_dir", &self.plugin_dir) + .field("cache_dir", &self.cache_dir) + .field("download_dir", &self.download_dir) + .field("skip_verification", &self.skip_verification) + .field("cmd_env", &self.cmd_env) + .field("github_token", &self.github_token.as_deref().map(|_| "***")) + .field( + "github_token_resolver", + &self.github_token_resolver.as_ref().map(|_| ""), + ) + .field("runtime_env_type", &self.runtime_env_type) + .finish_non_exhaustive() + } +} + impl Vfox { pub fn new() -> Self { Self::default() @@ -141,9 +166,15 @@ impl Vfox { } fn set_github_token(&self, plugin: &Plugin) -> Result<()> { + // Both are registered when both are set; the Lua-side `github_token()` + // tries the resolver first and falls back to the string. That matches + // the documented precedence on `github_token_resolver`. if let Some(token) = &self.github_token { plugin.set_github_token(token)?; } + if let Some(resolver) = &self.github_token_resolver { + plugin.set_github_token_resolver(resolver.clone())?; + } Ok(()) } @@ -625,6 +656,7 @@ impl Default for Vfox { skip_verification: false, cmd_env: None, github_token: None, + github_token_resolver: None, runtime_env_type: None, log_tx: None, } @@ -653,6 +685,7 @@ mod tests { skip_verification: false, cmd_env: None, github_token: None, + github_token_resolver: None, runtime_env_type: None, log_tx: None, } @@ -701,6 +734,42 @@ mod tests { file::remove_dir_all(vfox.download_dir).unwrap(); } + #[tokio::test] + async fn test_github_token_resolver_not_called_for_local_hooks() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + // env_keys and pre_uninstall on the dummy plugin do no network I/O, + // so a lazy GitHub token resolver registered on Vfox must not be + // invoked. This is the regression check for + // https://github.com/jdx/mise/discussions/9797 — `mise hook-env` and + // friends must not spawn `github.credential_command`. + let temp_dir = tempfile::tempdir().unwrap(); + let mut vfox = Vfox::test(); + vfox.install_dir = temp_dir.path().join("installs"); + let calls = Arc::new(AtomicUsize::new(0)); + let calls_inner = calls.clone(); + vfox.github_token_resolver = Some(Arc::new(move || { + calls_inner.fetch_add(1, Ordering::SeqCst); + None + })); + + vfox.env_keys( + "dummy", + "1.0.0", + serde_json::Value::Object(Default::default()), + ) + .await + .unwrap(); + + let install_dir = vfox.install_dir.join("dummy").join("1.0.0"); + std::fs::create_dir_all(&install_dir).unwrap(); + vfox.pre_uninstall("dummy", "1.0.0", &install_dir) + .await + .unwrap(); + + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + #[tokio::test] async fn test_pre_uninstall() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/docs/dev-tools/github-tokens.md b/docs/dev-tools/github-tokens.md index 56c17fcfd3..0f14ff1dd8 100644 --- a/docs/dev-tools/github-tokens.md +++ b/docs/dev-tools/github-tokens.md @@ -109,7 +109,7 @@ You can configure a custom shell command that mise runs to obtain a GitHub token credential_command = "op read 'op://Private/GitHub Token/credential'" ``` -mise executes this command with the configured default inline shell ([`unix_default_inline_shell_args`](/configuration/settings.html#unix_default_inline_shell_args) or [`windows_default_inline_shell_args`](/configuration/settings.html#windows_default_inline_shell_args)) and reads the token from stdout. The hostname is available as `MISE_CREDENTIAL_HOST`, and the provider name (`github`) is available as `MISE_CREDENTIAL_PROVIDER`. For compatibility, recognized sh-compatible shells (`ash`, `bash`, `dash`, `ksh`, `sh`, and `zsh`) also receive the hostname as `$1`/`${1}`. This is checked before `github_tokens.toml` and gh CLI tokens, so it takes priority over file-based sources. Results are cached per host per session. +mise executes this command with the configured default inline shell ([`unix_default_inline_shell_args`](/configuration/settings.html#unix_default_inline_shell_args) or [`windows_default_inline_shell_args`](/configuration/settings.html#windows_default_inline_shell_args)) and reads the token from stdout. The hostname is available as `MISE_CREDENTIAL_HOST`, and the provider name (`github`) is available as `MISE_CREDENTIAL_PROVIDER`. For compatibility, recognized sh-compatible shells (`ash`, `bash`, `dash`, `ksh`, `sh`, and `zsh`) also receive the hostname as `$1`/`${1}`. This is checked before `github_tokens.toml` and gh CLI tokens, so it takes priority over file-based sources. :::: warning Planned deprecation The legacy `$1`/`${1}` hostname argument is deprecated. Use `MISE_CREDENTIAL_HOST` instead. mise will start warning in `2026.11.0`, and `$1` compatibility will be removed in `2027.11.0`. diff --git a/settings.toml b/settings.toml index b00443b690..fcbb3782f7 100644 --- a/settings.toml +++ b/settings.toml @@ -609,13 +609,13 @@ default = "" description = "Shell command to run to obtain a Forgejo token for mise." docs = """ When set, mise executes this command via `sh -c` and reads the token -from stdout. The hostname is passed as `$1`, making the command -host-aware for Forgejo Enterprise environments. This replaces the -`git credential fill` fallback and does not require +from stdout. The hostname is exposed as `MISE_CREDENTIAL_HOST`, making +the command host-aware for self-hosted Forgejo environments. This +replaces the `git credential fill` fallback and does not require `forgejo.use_git_credentials` to be enabled. The command should print a single token to stdout. Trailing whitespace -is trimmed. Results are cached per host per session. +is trimmed. Example: `op read "op://Private/Forgejo Token/credential"` """ @@ -661,13 +661,13 @@ default = "" description = "Shell command to run to obtain a GitHub token for mise." docs = """ When set, mise executes this command via `sh -c` and reads the token -from stdout. The hostname is passed as `$1`, making the command -host-aware for GitHub Enterprise environments. This replaces the -`git credential fill` fallback and does not require +from stdout. The hostname is exposed as `MISE_CREDENTIAL_HOST`, making +the command host-aware for GitHub Enterprise environments. This +replaces the `git credential fill` fallback and does not require `github.use_git_credentials` to be enabled. The command should print a single token to stdout. Trailing whitespace -is trimmed. Results are cached per host per session. +is trimmed. Example: `op read "op://Private/GitHub Token/credential"` """ @@ -824,13 +824,13 @@ default = "" description = "Shell command to run to obtain a GitLab token for mise." docs = """ When set, mise executes this command via `sh -c` and reads the token -from stdout. The hostname is passed as `$1`, making the command -host-aware for GitLab self-managed environments. This replaces the -`git credential fill` fallback and does not require +from stdout. The hostname is exposed as `MISE_CREDENTIAL_HOST`, making +the command host-aware for self-managed GitLab environments. This +replaces the `git credential fill` fallback and does not require `gitlab.use_git_credentials` to be enabled. The command should print a single token to stdout. Trailing whitespace -is trimmed. Results are cached per host per session. +is trimmed. Example: `op read "op://Private/GitLab Token/credential"` """ diff --git a/src/github.rs b/src/github.rs index 4553b45bab..f3894cd1d1 100644 --- a/src/github.rs +++ b/src/github.rs @@ -500,16 +500,19 @@ pub fn resolve_token(host: &str) -> Option<(String, TokenSource)> { } } - // 3. credential_command + // 3. credential_command — call once with the canonical host so + // `github.com` and `api.github.com` (same instance) share a cache + // entry, while `github.com` vs a GHE host stay separate. Walking + // `lookup_hosts` here would spawn the helper twice on a single + // `resolve_token("api.github.com")` whenever the first call returned + // `None`, which manifests as extra password-manager prompts. let credential_command = &settings.github.credential_command; - if !credential_command.is_empty() { - for lookup_host in &lookup_hosts { - if let Some(token) = - tokens::get_credential_command_token("github", credential_command, lookup_host) - { - return Some((token, TokenSource::CredentialCommand)); - } - } + if !credential_command.is_empty() + && let Some(canonical) = lookup_hosts.first() + && let Some(token) = + tokens::get_credential_command_token("github", credential_command, canonical) + { + return Some((token, TokenSource::CredentialCommand)); } // 4. native GitHub OAuth device-flow token diff --git a/src/plugins/vfox_plugin.rs b/src/plugins/vfox_plugin.rs index 0979412ffc..200528a6b4 100644 --- a/src/plugins/vfox_plugin.rs +++ b/src/plugins/vfox_plugin.rs @@ -134,7 +134,14 @@ impl VfoxPlugin { vfox.cache_dir = dirs::CACHE.to_path_buf(); vfox.download_dir = dirs::DOWNLOADS.to_path_buf(); vfox.install_dir = dirs::INSTALLS.to_path_buf(); - vfox.github_token = crate::github::resolve_token("github.com").map(|(token, _)| token); + // Resolve the GitHub token lazily — only when a Lua plugin actually + // makes an HTTP request to a GitHub API URL. This avoids spawning + // `github.credential_command` (or hitting other token sources) for + // operations that never need a token, like `mise hook-env` or shell + // completion. `resolve_token` itself caches results per-process. + vfox.github_token_resolver = Some(Arc::new(|| { + crate::github::resolve_token("github.com").map(|(token, _)| token) + })); let rx = vfox.log_subscribe(); (vfox, rx) } diff --git a/src/tokens.rs b/src/tokens.rs index 931ac5d73d..789ec06e26 100644 --- a/src/tokens.rs +++ b/src/tokens.rs @@ -8,7 +8,10 @@ use std::sync::LazyLock as Lazy; use crate::file::path_env_without_shims; /// Cache for tokens obtained from `credential_command`. -/// Key format is `{provider}:{host}` to avoid cross-provider collisions. +/// Key format is `{provider}:{host}` to avoid cross-provider collisions and +/// preserve host-aware lookups (so different GitHub Enterprise instances +/// still spawn their own helper). Callers are responsible for not walking +/// equivalent hosts (e.g. `github.com` and `api.github.com`) on this path. static CREDENTIAL_COMMAND_CACHE: Lazy>>> = Lazy::new(Default::default); @@ -58,7 +61,9 @@ pub fn read_tokens_toml(filename: &str, label: &str) -> Option Option { if credential_command_uses_legacy_host_arg(cmd) { deprecated_at!( @@ -382,6 +387,35 @@ logins: assert!(!shell_supports_posix_c_arg_passing("cmd.exe")); } + #[cfg(unix)] + #[test] + fn test_get_credential_command_token_caches_per_host() { + // Same host on repeat calls must reuse the cached result (1 spawn). + // Different hosts get their own cache entries — important for + // multi-instance setups (e.g. github.com vs a GHE instance). + let dir = tempfile::tempdir().unwrap(); + let counter = dir.path().join("counter"); + let cmd = format!( + "echo invocation >> {} && echo \"token-for-$MISE_CREDENTIAL_HOST\"", + counter.display() + ); + let provider = "test-per-host"; + + let a1 = get_credential_command_token(provider, &cmd, "host-a.example.com").unwrap(); + let a2 = get_credential_command_token(provider, &cmd, "host-a.example.com").unwrap(); + let b1 = get_credential_command_token(provider, &cmd, "host-b.example.com").unwrap(); + + assert_eq!(a1, "token-for-host-a.example.com"); + assert_eq!(a2, "token-for-host-a.example.com"); + assert_eq!(b1, "token-for-host-b.example.com"); + + let invocations = std::fs::read_to_string(&counter).unwrap().lines().count(); + assert_eq!( + invocations, 2, + "1 spawn per host: 1 for host-a, 1 for host-b" + ); + } + #[test] fn test_credential_command_uses_legacy_host_arg() { assert!(credential_command_uses_legacy_host_arg("echo token-for-$1"));