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
@@ -21,7 +21,6 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
"x.ai/auth/get_url" => handle_get_url(agent).await,
"x.ai/auth/logout" => handle_logout(agent, args).await,
"x.ai/auth/info" => handle_info(agent),
"x.ai/auth/check_subscription" => handle_check_subscription(agent).await,
_ => Err(acp::Error::method_not_found()),
}
}
@@ -111,7 +110,7 @@ async fn handle_get_url(agent: &MvpAgent) -> ExtResult {
to_raw_response(&serde_json::json!({
"auth_url": auth_url,
// `external_provider` kept for older clients; `mode` is authoritative.
"external_provider": mode.is_some_and(|m| m.is_external_provider()),
"external_provider": false,
"mode": mode.map(|m| m.as_wire_str()),
}))
}
@@ -141,43 +140,16 @@ async fn handle_logout(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
}))
}
/// Single-shot subscription re-check (retry button on paywall screen).
///
/// Calls `retry_subscription_check()`, then returns the updated auth
/// response with gate info so the pager can refresh the gate state.
async fn handle_check_subscription(agent: &MvpAgent) -> ExtResult {
agent.retry_subscription_check().await;
let response = agent.auth_response_with_meta();
to_raw_response(&serde_json::json!({
"authenticated": response.meta.is_some(),
"meta": response.meta,
}))
}
/// Returns current auth method ID, user profile fields, and team/principal
/// metadata.
/// Returns current auth method ID and the account fields the Kimi flow
/// exposes (email/user id are empty until a later feature surfaces them).
fn handle_info(agent: &MvpAgent) -> ExtResult {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AuthInfoResponse {
method_id: Option<String>,
email: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
/// `grok-asset://` URL resolved by the Electron protocol handler,
/// or a full `http(s)://` URL passed through unchanged.
profile_image_url: Option<String>,
team_id: Option<String>,
team_name: Option<String>,
team_role: Option<String>,
organization_id: Option<String>,
organization_name: Option<String>,
organization_role: Option<String>,
principal_type: Option<String>,
principal_id: Option<String>,
user_blocked_reason: Option<String>,
team_blocked_reasons: Vec<String>,
coding_data_retention_opt_out: bool,
user_id: Option<String>,
auth_mode: Option<String>,
}
let method_id = agent
@@ -186,40 +158,13 @@ fn handle_info(agent: &MvpAgent) -> ExtResult {
.as_ref()
.map(|m| m.0.to_string());
let auth = agent.auth_manager.current();
let raw_asset_id = auth.as_ref().and_then(|a| a.profile_image_asset_id.clone());
// Return a grok-asset:// URL that the Electron renderer resolves at
// display time via a custom protocol handler. The handler proxies
// through cli-chat-proxy's /asset endpoint; Electron's HTTP cache
// handles reuse. No disk-cache or network call needed here.
let profile_image_url = match raw_asset_id.as_deref().filter(|k| !k.is_empty()) {
Some(key) if key.starts_with("http://") || key.starts_with("https://") => {
Some(key.to_owned())
}
Some(key) => Some(format!("grok-asset:///{key}")),
None => None,
};
to_raw_response(&AuthInfoResponse {
method_id,
email: auth.as_ref().and_then(|a| a.email.clone()),
first_name: auth.as_ref().and_then(|a| a.first_name.clone()),
last_name: auth.as_ref().and_then(|a| a.last_name.clone()),
profile_image_url,
team_id: auth.as_ref().and_then(|a| a.team_id.clone()),
team_name: auth.as_ref().and_then(|a| a.team_name.clone()),
team_role: auth.as_ref().and_then(|a| a.team_role.clone()),
organization_id: auth.as_ref().and_then(|a| a.organization_id.clone()),
organization_name: auth.as_ref().and_then(|a| a.organization_name.clone()),
organization_role: auth.as_ref().and_then(|a| a.organization_role.clone()),
principal_type: auth.as_ref().and_then(|a| a.principal_type.clone()),
principal_id: auth.as_ref().and_then(|a| a.principal_id.clone()),
user_blocked_reason: auth.as_ref().and_then(|a| a.user_blocked_reason.clone()),
team_blocked_reasons: auth
user_id: auth
.as_ref()
.map(|a| a.team_blocked_reasons.clone())
.unwrap_or_default(),
coding_data_retention_opt_out: auth
.as_ref()
.is_some_and(|a| a.coding_data_retention_opt_out),
.map(|a| a.user_id.clone())
.filter(|id| !id.is_empty()),
auth_mode: auth.as_ref().map(|a| format!("{:?}", a.auth_mode)),
})
}