fix(tui): route each OAuth picker row to its own provider, not kimi.com
Root cause: PendingMenuItem::Login carried only a label, so every provider row collapsed to the id-less Action::Login, and dispatch_login resolved the FIRST advertised interactive method — the Kimi device flow. Selecting Grok/Claude/Copilot/Codex all opened kimi.com. (The shell side was already correct: authenticate() dispatches each method id to its own OAuth flow.) - PendingMenuItem::Login now carries the advertised method id (None only on the no-interactive-method fallback row). - New Action::LoginWith(AuthMethodId); the picker dispatches it with the row's own id. Action::Login keeps its meaning (resolved/default method) for /login, auto-login, and re-auth. - dispatch_login_with resolves the id against the advertised methods and FAILS CLOSED on an unknown id — no silent first-method fallback — then adopts the method's label and start mode. Tests: picker rows pinned to their method ids; LoginWith(claude-pro-max) must authenticate with claude-pro-max even when kimi-code was previously resolved; unknown ids surface an error and start nothing. Verified: kigi-tui 6867 tests green, clippy clean.
This commit is contained in:
1 parent
c2d1067f0d
commit
1e57cd9225
5 files changed
+195
-29
No files matched your search
@@ -175,6 +175,44 @@ pub(super) fn strip_trailing_auth_error_blocks(agent: &mut AgentView) {
|
||||
/// restored once auth completes or is cancelled. Without this, `/login`
|
||||
/// with an external auth provider configured appeared to do nothing.
|
||||
pub(super) fn dispatch_login(app: &mut AppView) -> Vec<Effect> {
|
||||
dispatch_login_with(app, None)
|
||||
}
|
||||
|
||||
/// Start an interactive login flow with an explicitly chosen method (a
|
||||
/// provider row on the login picker). `None` keeps the historical behavior:
|
||||
/// re-use the current method or resolve the first interactive one.
|
||||
///
|
||||
/// The explicit id is resolved against the shell-advertised `auth_methods`
|
||||
/// and FAILS CLOSED when absent — silently falling back to the first method
|
||||
/// is exactly the bug that sent every provider row to the Kimi flow.
|
||||
pub(super) fn dispatch_login_with(
|
||||
app: &mut AppView,
|
||||
method_id: Option<agent_client_protocol::AuthMethodId>,
|
||||
) -> Vec<Effect> {
|
||||
if let Some(id) = method_id {
|
||||
let Some(method) = app.auth_methods.iter().find(|m| *m.id() == id) else {
|
||||
app.auth_state = AuthState::Pending {
|
||||
error: Some(format!("Login method not available: {}", id.0)),
|
||||
};
|
||||
return vec![];
|
||||
};
|
||||
// Mirror `find_interactive_login_method`: external auth providers
|
||||
// start in Command mode, everything else Pending (the mode firms up
|
||||
// when the auth URL arrives).
|
||||
let is_provider = method
|
||||
.meta()
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("external_provider"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
app.login_label = Some(method.name().to_string());
|
||||
app.login_method_id = Some(method.id().clone());
|
||||
app.auth_start_mode = if is_provider {
|
||||
AuthMode::Command
|
||||
} else {
|
||||
AuthMode::Pending
|
||||
};
|
||||
}
|
||||
ensure_login_method(app);
|
||||
let Some(method_id) = app.login_method_id.clone() else {
|
||||
app.auth_state = AuthState::Pending {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Top-level action router: maps actions and action results to handlers.
|
||||
use super::auth::{
|
||||
dispatch_begin_platform_key_entry, dispatch_cancel_login, dispatch_cancel_platform_key_entry,
|
||||
dispatch_login, dispatch_logout, dispatch_submit_auth_code, dispatch_submit_platform_api_key,
|
||||
dispatch_switch_account,
|
||||
dispatch_login, dispatch_login_with, dispatch_logout, dispatch_submit_auth_code,
|
||||
dispatch_submit_platform_api_key, dispatch_switch_account,
|
||||
};
|
||||
use super::ctx::{
|
||||
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
|
||||
@@ -876,6 +876,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
||||
vec![]
|
||||
}
|
||||
Action::Login => dispatch_login(app),
|
||||
Action::LoginWith(method_id) => dispatch_login_with(app, Some(method_id)),
|
||||
Action::CancelLogin => dispatch_cancel_login(app),
|
||||
Action::SubmitAuthCode(code) => dispatch_submit_auth_code(app, code),
|
||||
Action::BeginPlatformKeyEntry(target) => dispatch_begin_platform_key_entry(app, target),
|
||||
|
||||
@@ -435,3 +435,74 @@ fn auth_complete_preserves_show_resolved_model_when_absent() {
|
||||
|
||||
assert!(!app.show_resolved_model);
|
||||
}
|
||||
|
||||
/// `LoginWith` must authenticate with EXACTLY the chosen method and adopt
|
||||
/// its label — even when a different method was resolved earlier.
|
||||
/// Regression: the picker collapsed every row to the id-less `Login`,
|
||||
/// which resolved to the first interactive method (kimi-code), so the
|
||||
/// Grok/Claude/Copilot/Codex rows all opened the Kimi device flow.
|
||||
#[test]
|
||||
fn login_with_routes_to_the_chosen_method() {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
app.auth_methods = kigi_shell::agent::auth_method::build_auth_methods(
|
||||
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
||||
has_external_api_key: false,
|
||||
has_cached_token: false,
|
||||
login_label: None,
|
||||
},
|
||||
)
|
||||
.methods;
|
||||
// A previous login resolved kimi-code; the explicit choice must win.
|
||||
app.login_method_id = Some(acp::AuthMethodId::new("kimi-code"));
|
||||
app.login_label = Some("Kimi Code".into());
|
||||
|
||||
let effects = dispatch(
|
||||
Action::LoginWith(acp::AuthMethodId::new("claude-pro-max")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
let authenticated_with = effects.iter().find_map(|e| match e {
|
||||
Effect::Authenticate { method_id, .. } => Some(method_id.0.to_string()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
authenticated_with.as_deref(),
|
||||
Some("claude-pro-max"),
|
||||
"the chosen provider's own method must ride the Authenticate effect"
|
||||
);
|
||||
assert_eq!(
|
||||
app.login_method_id.as_ref().map(|id| id.0.as_ref()),
|
||||
Some("claude-pro-max")
|
||||
);
|
||||
assert_eq!(
|
||||
app.login_label.as_deref(),
|
||||
Some("Claude Pro/Max (subscription)"),
|
||||
"the picker label must follow the chosen method"
|
||||
);
|
||||
assert!(matches!(app.auth_state, AuthState::Authenticating { .. }));
|
||||
}
|
||||
|
||||
/// A `LoginWith` id that is not among the shell-advertised methods fails
|
||||
/// closed with an error on the picker — silently falling back to the
|
||||
/// first method is exactly the routing bug this guards against.
|
||||
#[test]
|
||||
fn login_with_unknown_method_fails_closed() {
|
||||
let mut app = test_app();
|
||||
app.auth_state = AuthState::Pending { error: None };
|
||||
|
||||
let effects = dispatch(
|
||||
Action::LoginWith(acp::AuthMethodId::new("no-such-provider")),
|
||||
&mut app,
|
||||
);
|
||||
|
||||
assert!(effects.is_empty(), "must not start any auth flow");
|
||||
assert!(
|
||||
matches!(
|
||||
&app.auth_state,
|
||||
AuthState::Pending { error: Some(e) } if e.contains("no-such-provider")
|
||||
),
|
||||
"must surface the unknown method, got {:?}",
|
||||
app.auth_state
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user