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
@@ -19,7 +19,8 @@
));
assert!(!app.is_api_key_auth);
assert!(app.usage_visible);
assert!(!app.tier_restricted_commands.is_empty());
// Tier gating no longer exists; nothing gets re-restricted.
assert!(app.tier_restricted_commands.is_empty());
// A paid tier after API Key clears the api-key flag and tier limits.
let mut app = make_app_with_agent("sess-paid-tier");
+20 -164
View File
@@ -403,16 +403,10 @@ pub(crate) const TIER_RESTRICTED_COMMANDS: &[&str] = &["usage", "imagine", "imag
/// "x_basic"). Everything else — paid tiers and unknown future names —
/// is unrestricted (fail-open).
///
/// The string classification is shared with the shell's capability
/// (toolset) gate via [`kigi_shell::tier::is_restricted_tier_name`] so
/// the two can't drift. The pager's *cosmetic* slash-command gate treats an
/// absent tier (`None`) as restricted (it recovers live on the next settings
/// update); the shell's capability gate treats absence as unrestricted.
fn is_restricted_tier(tier: Option<&str>) -> bool {
match tier {
None => true,
Some(t) => kigi_shell::tier::is_restricted_tier_name(t),
}
/// Tier gating was an xAI concept; the Kimi Code subscription has no
/// client-visible tier, so nothing is ever restricted.
fn is_restricted_tier(_tier: Option<&str>) -> bool {
false
}
/// True for API-key labels from shell/CCP: `"ApiKey"`, `"API Key"`, `"api_key"`.
pub(crate) fn is_api_key_label(s: &str) -> bool {
@@ -961,26 +955,18 @@ impl AppView {
label: rs.gate_label.clone(),
})
}
/// Apply typed auth metadata from the shell.
/// Apply typed auth metadata from the shell. The Kimi auth model carries
/// no team/tier/gate info; those fields only ever come from remote
/// settings now.
pub fn apply_auth_meta(&mut self, meta: &kigi_shell::auth::AuthMeta) {
self.pending_gate_verification = None;
let was_gated = self.gate.is_some();
self.team_id = meta.team_id.clone();
self.team_name = meta.team_name.clone();
self.is_zdr = meta.is_zdr;
self.team_role = meta.team_role.clone();
self.coding_data_retention_opt_out = meta.coding_data_retention_opt_out;
self.gate = meta.gate.clone();
if was_gated && self.gate.is_none() {
self.gate = None;
if was_gated {
self.paywall_check_started = None;
}
self.subscription_tier = meta.subscription_tier.clone();
self.is_api_key_auth = meta.auth_mode.as_deref().is_some_and(is_api_key_label)
|| meta
.subscription_tier
.as_deref()
.is_some_and(is_api_key_label);
self.usage_visible = meta.team_name.is_none() && !self.is_api_key_auth;
self.is_api_key_auth = meta.auth_mode.as_deref().is_some_and(is_api_key_label);
self.usage_visible = !self.is_api_key_auth;
self.apply_tier_restrictions();
if let Some(show) = meta.show_resolved_model {
self.show_resolved_model = show;
@@ -5588,19 +5574,6 @@ pub(crate) mod tests {
assert_eq!(counts.get("t_seen"), Some(&2));
}
#[test]
fn apply_auth_meta_hides_usage_for_team_users() {
let mut app = test_app();
assert!(app.usage_visible);
let meta = kigi_shell::auth::AuthMeta {
team_id: Some("team-uuid".into()),
team_name: Some("Acme Corp".into()),
..Default::default()
};
app.apply_auth_meta(&meta);
assert!(!app.usage_visible);
assert_eq!(app.team_id.as_deref(), Some("team-uuid"));
}
#[test]
fn apply_auth_meta_shows_usage_for_personal_users() {
let mut app = test_app();
app.usage_visible = false;
@@ -5617,41 +5590,6 @@ pub(crate) mod tests {
assert!(!app.is_api_key_auth);
assert!(app.usage_visible);
}
#[test]
fn apply_auth_meta_api_key_skips_tier_gate() {
let mut app = test_app();
advertise_media_tools(&mut app);
app.apply_auth_meta(&kigi_shell::auth::AuthMeta {
auth_mode: Some("ApiKey".into()),
subscription_tier: Some("API Key".into()),
..Default::default()
});
assert!(app.is_api_key_auth);
assert!(!app.usage_visible);
assert!(app.tier_restricted_commands.is_empty());
assert_tier_restricted_commands_present(&app);
let mut app = test_app();
app.apply_auth_meta(&kigi_shell::auth::AuthMeta {
subscription_tier: Some("api_key".into()),
..Default::default()
});
assert!(app.is_api_key_auth);
assert!(app.tier_restricted_commands.is_empty());
app.apply_auth_meta(&kigi_shell::auth::AuthMeta {
auth_mode: Some("Oidc".into()),
subscription_tier: Some("Free".into()),
..Default::default()
});
assert!(!app.is_api_key_auth);
assert!(app.usage_visible);
assert!(!app.tier_restricted_commands.is_empty());
}
fn expected_tier_restricted_commands() -> Vec<String> {
TIER_RESTRICTED_COMMANDS
.iter()
.map(|n| (*n).to_string())
.collect()
}
/// Make every tier-restricted command visible on the welcome prompt so the
/// present/absent assertions exercise the deny list, not incidental
/// fail-closed hiding:
@@ -5668,16 +5606,6 @@ pub(crate) mod tests {
.collect(),
);
}
fn assert_tier_restricted_commands_absent(app: &AppView) {
let reg = app.welcome_prompt.slash_controller.registry();
for name in TIER_RESTRICTED_COMMANDS {
assert!(
reg.get(name).is_none(),
"/{name} must be denied on a restricted tier"
);
}
assert!(reg.get("cost").is_none(), "/cost alias must be denied");
}
fn assert_tier_restricted_commands_present(app: &AppView) {
let reg = app.welcome_prompt.slash_controller.registry();
for name in TIER_RESTRICTED_COMMANDS {
@@ -5688,103 +5616,31 @@ pub(crate) mod tests {
}
}
#[test]
fn apply_auth_meta_restricts_usage_for_free_tier() {
fn apply_auth_meta_never_restricts_tiers() {
let mut app = test_app();
advertise_media_tools(&mut app);
app.apply_auth_meta(&kigi_shell::auth::AuthMeta::default());
assert_eq!(
app.tier_restricted_commands,
expected_tier_restricted_commands()
);
assert_tier_restricted_commands_absent(&app);
assert!(app.usage_visible);
}
#[test]
fn apply_auth_meta_restricts_usage_for_x_basic_tier() {
let mut app = test_app();
advertise_media_tools(&mut app);
let meta = kigi_shell::auth::AuthMeta {
subscription_tier: Some("X Basic".into()),
..Default::default()
};
app.apply_auth_meta(&meta);
assert_eq!(
app.tier_restricted_commands,
expected_tier_restricted_commands()
);
assert_tier_restricted_commands_absent(&app);
}
#[test]
fn apply_auth_meta_lifts_restrictions_for_paid_tiers_and_teams() {
let mut app = test_app();
advertise_media_tools(&mut app);
let meta = kigi_shell::auth::AuthMeta {
subscription_tier: Some("SuperGrok".into()),
..Default::default()
};
app.apply_auth_meta(&meta);
assert!(app.tier_restricted_commands.is_empty());
assert_tier_restricted_commands_present(&app);
let mut app = test_app();
advertise_media_tools(&mut app);
app.apply_auth_meta(&kigi_shell::auth::AuthMeta::default());
assert!(!app.tier_restricted_commands.is_empty());
app.subscription_tier = Some("SuperGrok".into());
app.apply_tier_restrictions();
assert!(app.tier_restricted_commands.is_empty());
assert_tier_restricted_commands_present(&app);
let mut app = test_app();
let meta = kigi_shell::auth::AuthMeta {
team_id: Some("team-uuid".into()),
team_name: Some("Acme Corp".into()),
..Default::default()
};
app.apply_auth_meta(&meta);
assert!(app.tier_restricted_commands.is_empty());
}
#[test]
fn is_restricted_tier_classification() {
assert!(is_restricted_tier(None));
assert!(is_restricted_tier(Some("")));
assert!(is_restricted_tier(Some("Free")));
assert!(is_restricted_tier(Some("X Basic")));
assert!(is_restricted_tier(Some("x_basic")));
assert!(!is_restricted_tier(Some("SuperGrok")));
assert!(!is_restricted_tier(Some("SuperGrok Heavy")));
assert!(!is_restricted_tier(Some("X Premium")));
assert!(!is_restricted_tier(Some("X Premium+")));
fn is_restricted_tier_never_restricts() {
assert!(!is_restricted_tier(None));
assert!(!is_restricted_tier(Some("Free")));
assert!(!is_restricted_tier(Some("SomeFutureTier")));
}
#[test]
fn apply_auth_meta_clears_gate_on_subscription() {
fn apply_auth_meta_clears_gate_on_login() {
let mut app = test_app();
app.gate = Some(kigi_shell::auth::GateInfo {
message: "Subscribe to use Grok Build".into(),
url: Some("https://grok.com/supergrok?referrer=grok-build".into()),
label: None,
});
assert!(app.is_access_blocked());
let meta = kigi_shell::auth::AuthMeta::default();
app.apply_auth_meta(&meta);
assert!(app.gate.is_none());
assert!(app.has_access());
}
#[test]
fn apply_auth_meta_gate_unchanged_when_still_gated() {
let mut app = test_app();
let gate = kigi_shell::auth::GateInfo {
message: "Subscribe".into(),
url: None,
label: None,
};
app.gate = Some(gate.clone());
let meta = kigi_shell::auth::AuthMeta {
gate: Some(gate),
..Default::default()
};
app.apply_auth_meta(&meta);
assert!(app.gate.is_some());
});
assert!(app.is_access_blocked());
app.apply_auth_meta(&kigi_shell::auth::AuthMeta::default());
assert!(app.gate.is_none());
assert!(app.has_access());
}
#[test]
fn welcome_ctrl_q_requires_confirmation() {
+2 -23
View File
@@ -19,29 +19,8 @@ pub enum Command {
Leader(LeaderMgmtArgs),
/// Sign out and clear cached credentials
Logout,
/// Sign in
Login {
/// Ignored (kept for backwards compatibility). OAuth2 is now the only auth method.
#[arg(long, hide = true)]
legacy: bool,
/// Use Grok OAuth via auth.x.ai.
#[arg(long = "oauth", alias = "oidc", conflicts_with_all = ["device_auth"])]
oauth: bool,
/// Use device-code authentication for headless/remote environments.
#[arg(
long = "device-auth",
visible_alias = "device-code",
conflicts_with_all = ["oauth"]
)]
device_auth: bool,
/// Authenticate for remote development environments (hidden).
///
/// Field is always present so match arms stay feature-unification-safe
/// across Bazel/cargo graphs; clap only registers `--devbox` when
/// `devbox-login` is enabled (`arg(skip)` otherwise → always false).
#[arg(skip)]
devbox: bool,
},
/// Sign in with your Kimi Code subscription (device-code flow)
Login,
/// Manage MCP server configurations
Mcp(crate::mcp_cmd::McpArgs),
/// Manage plugins
@@ -45,15 +45,9 @@ pub(super) fn ensure_login_method(app: &mut AppView) {
// No interactive method: leave login_method_id unset (fail-closed).
}
/// Error when no interactive login method is available (empty auth_methods,
/// e.g. `preferred_method=api_key` with no credentials). Prefer the shell's
/// pin-unavailable copy when the list is empty.
fn no_login_method_error(app: &AppView) -> String {
if app.auth_methods.is_empty() {
kigi_shell::agent::auth_method::PREFERRED_API_KEY_UNAVAILABLE.to_string()
} else {
"No login method available".to_string()
}
/// Error when no interactive login method is available (empty auth_methods).
fn no_login_method_error(_app: &AppView) -> String {
"No login method available".to_string()
}
/// Log out, then start a new login flow in a single sequential task.
@@ -454,32 +454,21 @@ pub(super) fn handle_credit_limit_recheck_complete(
agent_id: AgentId,
meta: Option<serde_json::Value>,
) -> Vec<Effect> {
let old_tier = app.subscription_tier.clone();
if let Some(meta_val) = meta
&& let Ok(auth_meta) = serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val)
{
app.apply_auth_meta(&auth_meta);
}
let tier_changed = app.subscription_tier != old_tier && app.subscription_tier.is_some();
let Some(agent) = app.agents.get_mut(&agent_id) else {
return vec![];
};
// If the user already submitted another prompt while the
// recheck was in flight, don't retry the stashed one — they've
// moved on. The tier update (above) still takes effect.
// recheck was in flight, don't show the upsell — they've moved on.
let user_moved_on = !agent.session.state.is_idle() || !agent.session.pending_prompts.is_empty();
if tier_changed && !user_moved_on {
if let Some(prompt) = agent.credit_limit_stashed_prompt.take() {
let tier_name = app.subscription_tier.as_deref().unwrap_or("a higher tier");
agent.scrollback.push_block(RenderBlock::system(format!(
"Subscription upgraded to {tier_name}. Retrying\u{2026}"
)));
agent.session.enqueue_in_flight_prompt_front(prompt);
}
} else if !user_moved_on {
if !user_moved_on {
let balance = agent
.credit_balance
.as_ref()
@@ -240,9 +240,9 @@ fn login_with_empty_auth_methods_fails_closed() {
matches!(
&app.auth_state,
AuthState::Pending { error: Some(msg) }
if msg.contains("preferred_method=api_key")
if msg.contains("No login method available")
),
"must surface pin-unavailable error, got {:?}",
"must surface no-login-method error, got {:?}",
app.auth_state
);
assert!(app.login_method_id.is_none());
@@ -60,50 +60,6 @@ fn dispatch_billing(
);
}
#[test]
fn credit_limit_retry_preserves_image_submission_state() {
let mut app = test_app_with_agent();
let mut image = crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData {
data: vec![1, 2, 3],
mime_type: "image/png".into(),
});
image.display_number = 1;
let prompt = crate::app::agent::InFlightPrompt {
text: "retry [Image #1]".into(),
images: vec![image],
scrollback_entry: crate::scrollback::EntryId::new(0),
chip_elements: vec![crate::app::agent::ChipElement {
range: 6..16,
kind: crate::views::prompt_widget::KIND_IMAGE,
display: None,
}],
};
app.agents
.get_mut(&AgentId(0))
.unwrap()
.credit_limit_stashed_prompt = Some(prompt);
let effects = dispatch(
Action::TaskComplete(TaskResult::CreditLimitRecheckComplete {
agent_id: AgentId(0),
meta: Some(serde_json::json!({"subscription_tier": "Upgraded"})),
}),
&mut app,
);
assert!(
effects
.iter()
.any(|effect| matches!(effect, Effect::SendPromptBlocks { .. }))
);
let in_flight = app.agents[&AgentId(0)]
.session
.in_flight_prompt
.as_ref()
.unwrap();
assert_eq!(in_flight.images.len(), 1);
assert_eq!(in_flight.chip_elements.len(), 1);
}
#[test]
fn is_max_tier_positive_match() {
assert!(is_max_tier(Some("supergrok_heavy")));
@@ -1646,30 +1646,6 @@ fn verify_check_with_meta_resolves_pending_gate() {
assert!(app.pending_gate_verification.is_none());
}
/// The live check confirmed the block (meta WITH a gate): the paywall
/// shows with the authoritative gate.
#[test]
fn verify_check_with_gated_meta_shows_gate() {
let mut app = test_app();
let _effs = app.impose_gate(test_gate());
let meta = serde_json::to_value(kigi_shell::auth::AuthMeta {
gate: Some(test_gate()),
..Default::default()
})
.unwrap();
dispatch_task_result(
TaskResult::CheckSubscriptionComplete {
verify: Some(app.gate_verify_gen),
meta: Some(meta),
},
&mut app,
);
assert!(!app.has_access(), "verified gate must show");
assert!(app.pending_gate_verification.is_none());
}
/// The verification's own check failed (meta None) while its stale gate
/// was deferred: err on blocking — the deferred gate is promoted.
#[test]
@@ -1857,59 +1833,6 @@ fn gate_verify_timeout_stale_generation_is_ignored() {
);
}
/// A verified gate landing via `CheckSubscriptionComplete` (gated meta while
/// ungated) must arm the 5s paywall auto-check chain — verify-before-paywall
/// paths never went through the login-path chain start.
#[test]
fn verified_gate_via_check_complete_starts_paywall_chain() {
let mut app = test_app();
let _effs = app.impose_gate(test_gate());
let meta = serde_json::to_value(kigi_shell::auth::AuthMeta {
gate: Some(test_gate()),
..Default::default()
})
.unwrap();
let effects = dispatch_task_result(
TaskResult::CheckSubscriptionComplete {
verify: None,
meta: Some(meta),
},
&mut app,
);
assert!(!app.has_access());
assert!(
app.paywall_check_started.is_some(),
"verified gate must arm the paywall auto-check chain"
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::SchedulePaywallCheck)),
"verified gate must schedule the 5s chain; got: {effects:?}"
);
// Steady-state paywall-poller responses (already gated) must NOT fan
// out extra timers.
let meta = serde_json::to_value(kigi_shell::auth::AuthMeta {
gate: Some(test_gate()),
..Default::default()
})
.unwrap();
let effects = dispatch_task_result(
TaskResult::CheckSubscriptionComplete {
verify: None,
meta: Some(meta),
},
&mut app,
);
assert!(
effects.is_empty(),
"already-gated check responses must not schedule more timers; got: {effects:?}"
);
}
/// `GateRefreshed` with gate-free settings while a deferred gate awaits
/// verification must drop the pending copy — the fresh settings are newer
/// than the stale snapshot that produced it — and still run the lift
@@ -3521,7 +3521,7 @@ pub(crate) fn execute(
&kigi_home.join("auth.json"),
)
.ok()?;
let scope = kigi_shell::auth::GrokComConfig::default()
let scope = kigi_shell::auth::KimiCodeConfig::default()
.auth_scope();
let auth = kigi_shell::auth::lookup_auth(
&store,
@@ -683,11 +683,8 @@ pub(crate) async fn run(
// welcome/auth UI right away.
let mut post_render_effects = if needs_interactive_login {
if connection.auth_methods.is_empty() {
// preferred_method pin unavailable — no advertised method to start.
app.auth_state = super::app_view::AuthState::Pending {
error: Some(
kigi_shell::agent::auth_method::PREFERRED_API_KEY_UNAVAILABLE.to_string(),
),
error: Some("No login method available".to_string()),
};
vec![]
} else {
+4 -4
View File
@@ -350,16 +350,16 @@ pub async fn run(
let startup_start = std::time::Instant::now();
let raw_config = kigi_shell::config::load_effective_config()
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let grok_com_config = match kigi_shell::agent::config::Config::new_from_toml_cfg(&raw_config) {
Ok(c) => c.grok_com_config,
let kimi_code_config = match kigi_shell::agent::config::Config::new_from_toml_cfg(&raw_config) {
Ok(c) => c.kimi_code_config,
Err(e) => {
tracing::warn!(
error = % e, "failed to parse config for auth refresh, using defaults"
);
kigi_shell::auth::GrokComConfig::default()
kigi_shell::auth::KimiCodeConfig::default()
}
};
let refreshed_auth = kigi_shell::auth::try_ensure_fresh_auth(&grok_com_config).await;
let refreshed_auth = kigi_shell::auth::try_ensure_fresh_auth(&kimi_code_config).await;
let early_prefetch = kigi_shell::agent::models::start_early_prefetch_with_auth(refreshed_auth);
kigi_shell::agent::mvp_agent::warm_async_http_client();
tokio::task::spawn_blocking(|| {});
@@ -418,9 +418,9 @@ pub(crate) fn pre_acp_auth_manager(
) -> std::sync::Arc<kigi_shell::auth::AuthManager> {
let auth = std::sync::Arc::new(kigi_shell::auth::AuthManager::new(
&kigi_shell::util::kigi_home::kigi_home(),
agent_config.grok_com_config.clone(),
agent_config.kimi_code_config.clone(),
));
auth.configure_refresher(agent_config.grok_com_config.auth_provider_command.clone());
auth.configure_refresher();
auth
}
/// Preflight: preferred id must be a UUID and not a persisted session under `cwd`.
@@ -621,7 +621,7 @@ async fn resolve_existing_session(
use kigi_shell::util::kigi_home::kigi_home;
let deployment_key = agent_config.endpoints.deployment_key.clone();
ensure_authenticated_or_noninteractive(
&agent_config.grok_com_config,
&agent_config.kimi_code_config,
deployment_key.is_some(),
None,
)
@@ -629,7 +629,7 @@ async fn resolve_existing_session(
.map_err(|e| anyhow::anyhow!("Failed to authenticate for session restore: {}", e))?;
let auth_manager = std::sync::Arc::new(AuthManager::new(
&kigi_home(),
agent_config.grok_com_config.clone(),
agent_config.kimi_code_config.clone(),
));
let registry_client =
SessionRegistryClient::new(agent_config.endpoints.proxy_url(), String::new())