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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user