M1/F1: Kimi Code OAuth device-code flow

Replace the xAI OAuth stack with the Kimi device authorization grant:
- kimi_oauth.rs wire layer (device_authorization + token poll + refresh
  against kigi_env::oauth_host(); client_id per PRD; retryable statuses
  429/5xx with backoff; expired_token restarts authorization)
- X-Msh-Device-{Name,Model,Id} headers; device_id minted uuid4-hex at
  ~/.kigi/device_id (0600)
- Storage: system keyring service `kigi`, entry `oauth/kimi-code`
  (macOS/Windows native backends), atomic-file fallback under ~/.kigi;
  official client's keyring/~/.kimi never touched
- Refresh manager: 60s tick, threshold max(300, expires_in*0.5),
  401-tombstone keyed by rejected refresh token with 300s cooldown and
  rotation auto-clear, cross-process lock with sibling-adoption
  triple-check, sleep/wake forced refresh
- Deleted xAI machinery: enterprise OIDC (PKCE/JWKS/teams), devbox login,
  external auth provider, JWT tier gating + subscription paywall stack,
  X-XAI-Token-Auth marker headers, ZDR gates, /user enrichment
- kigi login / TUI /login both drive the device flow; login-host display
  now derives from kigi_env::oauth_host()
- 264 auth unit/wiremock tests; live contract probe of
  auth.kimi.com/api/oauth/device_authorization matches the wire shapes

Gates: check/clippy --all-targets clean, fmt, deny ok, kigi-shell lib
5131 tests green.
This commit is contained in:
2026-07-17 07:37:29 -04:00
parent d6c20fc13f
commit 021b82443d
117 changed files with 4052 additions and 19900 deletions
@@ -1073,18 +1073,10 @@ pub async fn resolve_api_key(explicit: Option<&str>, kigi_home: &Path) -> Result
/// * `Err(_)` — refresh attempt failed in a way the operator needs to
/// see (network error, refresh_token rejected by the IdP, etc.).
async fn non_interactive_auth_key(kigi_home: &Path) -> Result<Option<String>> {
use crate::auth::{AuthError, AuthManager, GrokComConfig};
use crate::auth::{AuthError, AuthManager, KimiCodeConfig};
// Production's `try_ensure_fresh_auth` clones the whole config to
// pass into `AuthManager::new` AND clones `auth_provider_command`
// again for `configure_refresher`. We extract the single field we
// need first, then move the rest of `config` into `AuthManager`
// — one `Option<String>` clone instead of one full struct clone
// plus one Option clone.
let config = GrokComConfig::default();
let auth_provider_command = config.auth_provider_command.clone();
let manager = std::sync::Arc::new(AuthManager::new(kigi_home, config));
manager.configure_refresher(auth_provider_command);
let manager = std::sync::Arc::new(AuthManager::new(kigi_home, KimiCodeConfig::default()));
manager.configure_refresher();
match manager.auth().await {
Ok(auth) => {
let trimmed = auth.key.trim();
@@ -2147,7 +2139,7 @@ mod tests {
/// path entirely — useful for "plain key, no refresh wanted"
/// fixtures.
fn write_auth_json(kigi_home: &Path, key: &str) {
let scope = crate::auth::GrokComConfig::default().auth_scope();
let scope = crate::auth::KimiCodeConfig::default().auth_scope();
let body = serde_json::json!({
scope: {
"key": key,
@@ -2172,11 +2164,11 @@ mod tests {
/// returns it via the fast path; the refresher chain is NOT
/// invoked, so no network call fires.
fn write_fresh_oidc_auth_json(kigi_home: &Path, key: &str) {
let scope = crate::auth::GrokComConfig::default().auth_scope();
let scope = crate::auth::KimiCodeConfig::default().auth_scope();
let body = serde_json::json!({
scope: {
"key": key,
"auth_mode": "oidc",
"auth_mode": "oauth",
"create_time": now_offset(0),
"expires_at": now_offset(3600),
"refresh_token": "test-refresh-token",
@@ -2192,11 +2184,11 @@ mod tests {
/// refresh chain has nothing to refresh against, so the auth
/// call fails non-interactively.
fn write_expired_oidc_auth_json_no_refresh(kigi_home: &Path, key: &str) {
let scope = crate::auth::GrokComConfig::default().auth_scope();
let scope = crate::auth::KimiCodeConfig::default().auth_scope();
let body = serde_json::json!({
scope: {
"key": key,
"auth_mode": "oidc",
"auth_mode": "oauth",
"create_time": now_offset(-7200),
"expires_at": now_offset(-3600),
"user_id": "test-user",