diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index f6d4997..e7d5ad6 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -102,6 +102,15 @@ pub struct BuiltAuthMethods { /// 1. `xai.api_key` (if `has_external_api_key`) /// 2. `cached_token` (if `has_cached_token`) /// 3. `kimi-code` (the Kimi Code device login) +/// 4. `moonshot-cn` (Moonshot Open Platform API-key login, always) +/// 5. `moonshot-ai` (Moonshot Open Platform API-key login, always) +/// +/// The moonshot methods are for the INTERACTIVE login picker only: they come +/// after `kimi-code` so they can never become `auth_methods.first()` (the +/// pager's startup metadata / eager-auth fallback reads `first()`), and they +/// are never the `default_auth_method_id` (a configured moonshot key already +/// authenticates eagerly via `xai.api_key` — the catalog entries it stamps +/// satisfy `should_advertise_xai_api_key`). /// /// `default_auth_method_id`: /// - `cached_token` if `has_cached_token` @@ -141,6 +150,8 @@ pub fn build_auth_methods(inputs: AuthMethodsBuildInputs<'_>) -> BuiltAuthMethod } methods.push(kimi_code_auth_method(login_label)); + methods.push(moonshot_auth_method(kigi_models::PlatformId::MoonshotCn)); + methods.push(moonshot_auth_method(kigi_models::PlatformId::MoonshotAi)); BuiltAuthMethods { methods, @@ -154,6 +165,10 @@ pub enum AuthMethodKind { XaiApiKey, CachedToken, KimiCode, + /// Moonshot Open Platform API-key login (moonshot.cn). + MoonshotCn, + /// Moonshot Open Platform API-key login (moonshot.ai). + MoonshotAi, Unknown, } @@ -163,13 +178,17 @@ impl AuthMethodKind { XAI_API_KEY_METHOD_ID => Self::XaiApiKey, CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken, KIMI_CODE_METHOD_ID => Self::KimiCode, + MOONSHOT_CN_METHOD_ID => Self::MoonshotCn, + MOONSHOT_AI_METHOD_ID => Self::MoonshotAi, _ => Self::Unknown, } } - /// API key auth: no auth.json, no refresh, no user interaction. + /// API key auth: no auth.json session, no refresh, no browser round-trip. + /// The moonshot methods qualify — they validate a configured platform key + /// and then behave exactly like an external-API-key session. pub fn is_api_key(self) -> bool { - matches!(self, Self::XaiApiKey) + matches!(self, Self::XaiApiKey | Self::MoonshotCn | Self::MoonshotAi) } /// `true` for session-based methods (cached_token, interactive login). @@ -304,6 +323,108 @@ pub fn kimi_code_auth_method(label: Option<&str>) -> acp::AuthMethod { ) } +/// Interactive API-key login for the Moonshot open platforms. Method ids +/// equal [`kigi_models::PlatformId::as_str`] (`moonshot-cn` / `moonshot-ai`), +/// which is also the `[platforms.]` config-table name — one id everywhere. +pub const MOONSHOT_CN_METHOD_ID: &str = "moonshot-cn"; +pub const MOONSHOT_AI_METHOD_ID: &str = "moonshot-ai"; + +/// The open platform behind an interactive moonshot method id. `None` for +/// every other id (including `kimi-code`, whose platform uses OAuth). +pub fn moonshot_platform_for_method_id(id: &acp::AuthMethodId) -> Option { + match id.0.as_ref() { + MOONSHOT_CN_METHOD_ID => Some(kigi_models::PlatformId::MoonshotCn), + MOONSHOT_AI_METHOD_ID => Some(kigi_models::PlatformId::MoonshotAi), + _ => None, + } +} + +/// Console host for an open platform, used in method descriptions and login +/// copy ("platform.moonshot.cn" / "platform.moonshot.ai"). +pub fn moonshot_console_host(platform: kigi_models::PlatformId) -> &'static str { + match platform { + kigi_models::PlatformId::MoonshotCn => "platform.moonshot.cn", + _ => "platform.moonshot.ai", + } +} + +/// A Moonshot Open Platform API-key login method. +pub fn moonshot_auth_method(platform: kigi_models::PlatformId) -> acp::AuthMethod { + let host_suffix = match platform { + kigi_models::PlatformId::MoonshotCn => "moonshot.cn", + _ => "moonshot.ai", + }; + acp::AuthMethod::Agent( + acp::AuthMethodAgent::new( + acp::AuthMethodId::new(platform.as_str()), + format!("Moonshot Open Platform (API key \u{b7} {host_suffix})"), + ) + .description(Some(format!( + "API key from {}", + moonshot_console_host(platform) + ))), + ) +} + +/// Actionable error for a moonshot `authenticate` with no key configured. +pub fn missing_moonshot_key_error(platform: kigi_models::PlatformId) -> String { + let env_var = platform + .api_key_env_names() + .first() + .copied() + .unwrap_or(kigi_models::MOONSHOT_API_KEY_ENV); + format!( + "No API key configured for {} \u{2014} paste one in the login screen or set {env_var}", + platform.as_str(), + ) +} + +/// Validate + accept a Moonshot open-platform API key for `authenticate`. +/// +/// `key` is the caller-resolved credential (env > config; see +/// `resolve_platform_api_key`) — `None` fails with the actionable +/// missing-key message. A present key is validated with +/// `GET {platform_base}/models` (the same endpoint the catalog fetch uses): +/// 401 → "invalid API key"; any other non-success status or network error +/// surfaces as-is. SECURITY: the key is only ever sent as the bearer header — +/// it must never appear in errors or logs. +pub(crate) async fn authenticate_platform_api_key( + platform: kigi_models::PlatformId, + key: Option<&str>, +) -> Result<(), acp::Error> { + let auth_err = |message: String| { + let mut err = acp::Error::auth_required(); + err.message = message; + err + }; + let Some(key) = key else { + return Err(auth_err(missing_moonshot_key_error(platform))); + }; + let url = format!("{}/models", platform.base_url().trim_end_matches('/')); + let response = crate::http::shared_client() + .get(&url) + .header("Authorization", format!("Bearer {key}")) + .send() + .await + .map_err(|e| auth_err(format!("Couldn't reach {}: {e}", platform.as_str())))?; + let status = response.status(); + if status.as_u16() == 401 { + return Err(auth_err(format!( + "Invalid API key for {} \u{2014} check your key on {}", + platform.as_str(), + moonshot_console_host(platform), + ))); + } + if !status.is_success() { + return Err(auth_err(format!( + "{} key validation failed: HTTP {}", + platform.as_str(), + status.as_u16(), + ))); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -343,6 +464,21 @@ mod tests { assert!(api.is_api_key()); assert!(!api.is_session_based()); assert!(!api.needs_interactive_login()); + // Moonshot methods are API-key shaped: NOT session-based (no token + // refresh may ever run for them) and no browser round-trip. + for id in [MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID] { + let kind = AuthMethodKind::from_id(&acp::AuthMethodId::new(id)); + assert!(kind.is_api_key(), "{id} must classify as api-key"); + assert!(!kind.is_session_based(), "{id} must not be session-based"); + assert!( + !is_session_based_method(&acp::AuthMethodId::new(id)), + "is_session_based_method({id}) must stay false" + ); + assert!( + !kind.needs_interactive_login(), + "{id} must not need a browser login" + ); + } let unknown = AuthMethodKind::from_id(&acp::AuthMethodId::new("who-knows")); assert_eq!(unknown, AuthMethodKind::Unknown); assert!(!unknown.is_session_based()); @@ -431,7 +567,12 @@ mod tests { }); assert_eq!( method_ids(&built), - vec![XAI_API_KEY_METHOD_ID, KIMI_CODE_METHOD_ID] + vec![ + XAI_API_KEY_METHOD_ID, + KIMI_CODE_METHOD_ID, + MOONSHOT_CN_METHOD_ID, + MOONSHOT_AI_METHOD_ID + ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); assert!( @@ -454,13 +595,15 @@ mod tests { vec![ XAI_API_KEY_METHOD_ID, CACHED_TOKEN_AUTH_METHOD_ID, - KIMI_CODE_METHOD_ID + KIMI_CODE_METHOD_ID, + MOONSHOT_CN_METHOD_ID, + MOONSHOT_AI_METHOD_ID ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); } - /// Session-only user: cached_token first, interactive login as fallback. + /// Session-only user: cached_token first, interactive logins after it. #[test] fn session_only_user_first_method_is_cached_token() { let built = build_auth_methods(AuthMethodsBuildInputs { @@ -469,7 +612,12 @@ mod tests { }); assert_eq!( method_ids(&built), - vec![CACHED_TOKEN_AUTH_METHOD_ID, KIMI_CODE_METHOD_ID] + vec![ + CACHED_TOKEN_AUTH_METHOD_ID, + KIMI_CODE_METHOD_ID, + MOONSHOT_CN_METHOD_ID, + MOONSHOT_AI_METHOD_ID + ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); assert_eq!( @@ -478,13 +626,44 @@ mod tests { ); } - /// Fresh user: only the interactive login is advertised; no default - /// method (login required). + /// Fresh user: the interactive picker methods are advertised — the OAuth + /// device login FIRST (`auth_methods.first()` drives the login screen), + /// then the two Moonshot API-key logins. No default method (login + /// required). #[test] - fn fresh_user_only_advertises_interactive_login() { + fn fresh_user_advertises_picker_methods_kimi_code_first() { let built = build_auth_methods(default_inputs()); - assert_eq!(method_ids(&built), vec![KIMI_CODE_METHOD_ID]); + assert_eq!( + method_ids(&built), + vec![ + KIMI_CODE_METHOD_ID, + MOONSHOT_CN_METHOD_ID, + MOONSHOT_AI_METHOD_ID + ] + ); assert_eq!(default_id(&built), None); + assert_eq!(first_kind(&built.methods), Some(AuthMethodKind::KimiCode)); + } + + /// The moonshot methods must never be the default (eager) method: the + /// pager authenticates `default_auth_method_id` without user interaction, + /// and a configured moonshot key already rides the `xai.api_key` path. + #[test] + fn moonshot_methods_are_never_the_default() { + for (api, cached) in [(false, false), (true, false), (false, true), (true, true)] { + let built = build_auth_methods(AuthMethodsBuildInputs { + has_external_api_key: api, + has_cached_token: cached, + ..default_inputs() + }); + assert!( + !matches!( + default_id(&built), + Some(MOONSHOT_CN_METHOD_ID) | Some(MOONSHOT_AI_METHOD_ID) + ), + "default must not be a moonshot method (api={api}, cached={cached})" + ); + } } /// `XAI_API_KEY` alone (no per-model creds) triggers advertising @@ -522,4 +701,71 @@ mod tests { let _legacy = EnvGuard::set(LEGACY_XAI_API_KEY_ENV_VAR, "legacy-key"); assert_eq!(read_xai_api_key_env().unwrap(), "new-key"); } + + /// Moonshot authenticate with no configured key: actionable error naming + /// the platform, the login screen, and the platform-scoped env var. No + /// HTTP is attempted (`key: None` short-circuits). + #[tokio::test] + async fn moonshot_authenticate_without_key_is_actionable() { + let err = authenticate_platform_api_key(kigi_models::PlatformId::MoonshotCn, None) + .await + .expect_err("missing key must fail"); + assert_eq!( + err.message, + "No API key configured for moonshot-cn \u{2014} paste one in the login screen \ + or set KIGI_MOONSHOT_CN_API_KEY" + ); + } + + /// Moonshot authenticate validates the key against `GET {base}/models`; + /// a 200 accepts the key. + #[tokio::test] + #[serial] + async fn moonshot_authenticate_valid_key_succeeds() { + use wiremock::matchers::{header, method, path}; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(method("GET")) + .and(path("/models")) + .and(header("Authorization", "Bearer sk-good")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "data": [] })), + ) + .expect(1) + .mount(&server) + .await; + let _base = EnvGuard::set(kigi_models::MOONSHOT_CN_BASE_URL_ENV, &server.uri()); + authenticate_platform_api_key(kigi_models::PlatformId::MoonshotCn, Some("sk-good")) + .await + .expect("200 from /models must validate the key"); + } + + /// A 401 from `/models` is an invalid key — the error names the platform + /// and console, and NEVER contains the key itself. + #[tokio::test] + #[serial] + async fn moonshot_authenticate_401_is_invalid_key_error() { + use wiremock::matchers::{method, path}; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(method("GET")) + .and(path("/models")) + .respond_with(wiremock::ResponseTemplate::new(401)) + .mount(&server) + .await; + let _base = EnvGuard::set(kigi_models::MOONSHOT_AI_BASE_URL_ENV, &server.uri()); + let err = authenticate_platform_api_key( + kigi_models::PlatformId::MoonshotAi, + Some("sk-bad-secret"), + ) + .await + .expect_err("401 must fail"); + assert_eq!( + err.message, + "Invalid API key for moonshot-ai \u{2014} check your key on platform.moonshot.ai" + ); + assert!( + !err.message.contains("sk-bad-secret"), + "the key must never leak into errors" + ); + } } diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index 1056039..41963ce 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -821,6 +821,75 @@ pub(crate) fn resolve_platform_api_key_with( platforms.config_api_key(platform) } +/// Persist `[platforms.].api_key` into `~/.kigi/config.toml` — the exact +/// table [`resolve_platform_api_key`] reads back (env vars still win over the +/// file). Shared writer for the CLI and the TUI login screen; same in-process +/// pattern as the `kigi mcp add` writer (whole-file toml round-trip, atomic +/// tmp+rename), taken under the config write lock so it can't interleave with +/// a settings save. +/// +/// SECURITY: the key lands in the file by design; it must never be logged, +/// and errors carry only path/IO context — never the key. +pub async fn save_platform_api_key( + platform: kigi_models::PlatformId, + api_key: &str, +) -> anyhow::Result<()> { + let _guard = crate::util::config::lock_config_writes().await; + save_platform_api_key_at(&crate::util::config::user_config_path(), platform, api_key).await +} + +/// Path-injectable core of [`save_platform_api_key`] (tests use a tempdir). +/// Does NOT take the config write lock — production callers go through +/// [`save_platform_api_key`]. +pub async fn save_platform_api_key_at( + path: &std::path::Path, + platform: kigi_models::PlatformId, + api_key: &str, +) -> anyhow::Result<()> { + use toml::Value as TomlValue; + use toml::map::Map as TomlMap; + + anyhow::ensure!( + !platform.uses_oauth(), + "{} authenticates via OAuth and takes no API key", + platform.as_str(), + ); + let api_key = api_key.trim(); + anyhow::ensure!(!api_key.is_empty(), "API key must not be empty"); + + let mut root: TomlValue = match tokio::fs::read_to_string(path).await { + Ok(s) => toml::from_str(&s).map_err(|e| { + // Refuse to overwrite an unparseable config — a silent fallback + // to an empty table would drop every other section. + anyhow::anyhow!("refusing to overwrite unparseable {}: {e}", path.display()) + })?, + Err(_) => TomlValue::Table(TomlMap::new()), + }; + let table = root + .as_table_mut() + .ok_or_else(|| anyhow::anyhow!("config root is not a table"))?; + let platforms = table + .entry("platforms") + .or_insert_with(|| TomlValue::Table(TomlMap::new())) + .as_table_mut() + .ok_or_else(|| anyhow::anyhow!("[platforms] is not a table"))?; + let entry = platforms + .entry(platform.as_str().to_string()) + .or_insert_with(|| TomlValue::Table(TomlMap::new())) + .as_table_mut() + .ok_or_else(|| anyhow::anyhow!("[platforms.{}] is not a table", platform.as_str()))?; + entry.insert( + "api_key".to_string(), + TomlValue::String(api_key.to_string()), + ); + + let toml_str = toml::to_string_pretty(&root)?; + // Mode-preserving atomic write: a 0600 config must not widen while + // receiving a secret. + crate::util::config::atomic_write_string(path, &toml_str)?; + Ok(()) +} + #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(default)] pub struct HarnessConfig { @@ -9218,6 +9287,80 @@ default = "kigi-4.5" "a moonshot env key alone must advertise the API-key auth method" ); } + /// The login-screen writer persists `[platforms.].api_key` into the + /// exact table `resolve_platform_api_key` reads back, preserving sibling + /// tables and never leaking onto the other platform. + #[tokio::test] + async fn save_platform_api_key_round_trips_through_resolver() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[ui]\ncompact_mode = true\n").unwrap(); + + save_platform_api_key_at(&path, kigi_models::PlatformId::MoonshotCn, "sk-from-tui") + .await + .expect("write must succeed"); + + let raw: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let platforms: PlatformsConfig = raw + .get("platforms") + .cloned() + .expect("[platforms] written") + .try_into() + .expect("PlatformsConfig parses"); + // Env unset in this resolve (injected getenv) → config file wins. + let resolved = + resolve_platform_api_key_with(kigi_models::PlatformId::MoonshotCn, &platforms, |_| { + None + }); + assert_eq!(resolved.as_deref(), Some("sk-from-tui")); + assert!( + platforms + .config_api_key(kigi_models::PlatformId::MoonshotAi) + .is_none(), + "the cn key must not leak onto the ai platform" + ); + assert!( + raw.get("ui") + .and_then(|ui| ui.get("compact_mode")) + .and_then(|v| v.as_bool()) + .unwrap_or(false), + "sibling [ui] table must be preserved" + ); + } + /// Writer guardrails: the OAuth platform takes no key, empty keys are + /// rejected, and an unparseable config is refused (never clobbered). + #[tokio::test] + async fn save_platform_api_key_rejects_invalid_inputs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + assert!( + save_platform_api_key_at(&path, kigi_models::PlatformId::KimiCode, "sk-x") + .await + .is_err(), + "kimi-code authenticates via OAuth and must reject an API key" + ); + assert!( + save_platform_api_key_at(&path, kigi_models::PlatformId::MoonshotCn, " ") + .await + .is_err(), + "blank keys must be rejected" + ); + + let bad = "this is [not valid toml\n"; + std::fs::write(&path, bad).unwrap(); + assert!( + save_platform_api_key_at(&path, kigi_models::PlatformId::MoonshotCn, "sk-x") + .await + .is_err(), + "unparseable config must be refused" + ); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + bad, + "unparseable config must be left untouched" + ); + } #[test] #[serial] fn mcp_liveness_watchers_default_is_true() { diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs index 93a2bc9..913402b 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs @@ -508,6 +508,14 @@ impl acp::Agent for MvpAgent { ); Ok(self.auth_response_with_meta()) } + auth_method::MOONSHOT_CN_METHOD_ID | auth_method::MOONSHOT_AI_METHOD_ID => { + let platform = auth_method::moonshot_platform_for_method_id( + &arguments.method_id, + ) + .expect("match arm guarantees a moonshot method id"); + self.authenticate_moonshot(platform, arguments.method_id.clone()) + .await + } _ => { Err( acp::Error::invalid_params() diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs index cdeefee..c741938 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs @@ -447,6 +447,66 @@ impl MvpAgent { ) .await } + /// `authenticate(moonshot-cn / moonshot-ai)`: interactive open-platform + /// API-key login from the welcome picker. + /// + /// Reloads the platform keys from disk+env (the TUI persists the pasted + /// key to `[platforms.]` in config.toml immediately before this call), + /// fails with an actionable error when none is configured, validates the + /// key against `GET {platform_base}/models`, then marks the session + /// authenticated exactly like an external API key: publish the method id + /// (NOT session-based — no token refresh), swap the freshly-stamped config + /// into the models manager, and trigger the model sync so the catalog + /// gains the platform's entries. The key itself is never logged. + pub(super) async fn authenticate_moonshot( + &self, + platform: kigi_models::PlatformId, + method_id: acp::AuthMethodId, + ) -> Result { + let keys = + crate::agent::models::PlatformApiKeys::resolve_from_effective_config(); + auth_method::authenticate_platform_api_key(platform, keys.key_for(platform)) + .await + .inspect_err(|_| { + emit_login_span( + false, + method_id.0.as_ref(), + None, + Some("platform_key_invalid_or_missing"), + ); + })?; + // Swap the on-disk config (now carrying the key) into the models + // manager so `apply_platform_credentials` stamps the platform's + // catalog entries; a parse failure keeps the last-known-good config + // (`on_auth_changed` below still re-resolves keys from disk itself). + match crate::config::load_effective_config() + .map_err(|e| e.to_string()) + .and_then(|raw| crate::agent::config::Config::new_from_toml_cfg(&raw)) + { + Ok(new_cfg) => self.models_manager.apply_config(new_cfg), + Err(e) => { + tracing::warn!( + error = % e, + "moonshot auth: config reload failed; keeping last-known-good" + ); + } + } + self.set_auth_method(method_id.clone()); + self.models_manager.on_auth_changed().await; + emit_login_span(true, method_id.0.as_ref(), None, None); + // Report api-key auth mode so the pager's `apply_auth_meta` treats + // the session like every other external-API-key login (badge shown, + // `/usage` hidden). + let auth_meta = crate::auth::AuthMeta { + email: None, + auth_mode: Some("api_key".to_string()), + show_resolved_model: None, + }; + let meta = serde_json::to_value(auth_meta) + .ok() + .and_then(|v| v.as_object().cloned()); + Ok(AuthenticateResponse::new().meta(meta)) + } pub(crate) fn deployment_key(&self) -> Option { self.cfg.borrow().endpoints.deployment_key.clone() } diff --git a/crates/codegen/kigi-tui/src/acp/mod.rs b/crates/codegen/kigi-tui/src/acp/mod.rs index 155477c..701db93 100644 --- a/crates/codegen/kigi-tui/src/acp/mod.rs +++ b/crates/codegen/kigi-tui/src/acp/mod.rs @@ -909,6 +909,57 @@ mod tests { assert_eq!(mode, AuthStartMode::Pending); } + /// CROSS-CRATE: the moonshot picker methods must not change startup. + /// + /// - Fresh user: `kimi-code` is still `auth_methods.first()` → login + /// screen; the interactive fallback still resolves to `kimi-code`. + /// - Configured moonshot key (BYOK shape, the headless + /// `KIGI_MOONSHOT_CN_API_KEY`-only e2e): eager auth selects + /// `xai.api_key` via `default_auth_method_id` — NEVER a moonshot + /// method. The moonshot ids exist for the interactive picker only. + #[test] + fn moonshot_methods_are_never_selected_for_eager_auth() { + use kigi_shell::agent::auth_method::{ + AuthMethodsBuildInputs, KIMI_CODE_METHOD_ID, MOONSHOT_AI_METHOD_ID, + MOONSHOT_CN_METHOD_ID, XAI_API_KEY_METHOD_ID, build_auth_methods, + }; + + let fresh = build_auth_methods(AuthMethodsBuildInputs { + has_external_api_key: false, + has_cached_token: false, + login_label: None, + }); + let (needs, _, method_id, _) = startup_auth_metadata(&fresh.methods); + assert!(needs, "fresh user must still hit the login screen"); + assert_eq!(method_id.unwrap().0.as_ref(), KIMI_CODE_METHOD_ID); + let (_, fallback_id, _) = find_interactive_login_method(&fresh.methods); + assert_eq!( + fallback_id.unwrap().0.as_ref(), + KIMI_CODE_METHOD_ID, + "the interactive fallback must stay the OAuth device login" + ); + + let byok = build_auth_methods(AuthMethodsBuildInputs { + has_external_api_key: true, + has_cached_token: false, + login_label: None, + }); + let (needs, _, _, _) = startup_auth_metadata(&byok.methods); + assert!( + !needs, + "a configured key must keep skipping the login screen" + ); + let selected = + select_eager_auth_method(&byok.methods, byok.default_auth_method_id.as_ref()) + .expect("eager method must resolve"); + assert_eq!(selected.0.as_ref(), XAI_API_KEY_METHOD_ID); + assert!( + selected.0.as_ref() != MOONSHOT_CN_METHOD_ID + && selected.0.as_ref() != MOONSHOT_AI_METHOD_ID, + "eager auth must never pick a moonshot picker method" + ); + } + /// Inverse direction: when `xai.api_key` is NOT in the list, the pager /// MUST show the login screen. We assert this with `xai.api_key` present /// LATER in the list (the shape of a past regression) and confirm the diff --git a/crates/codegen/kigi-tui/src/app/actions.rs b/crates/codegen/kigi-tui/src/app/actions.rs index c056d16..63a8aaf 100644 --- a/crates/codegen/kigi-tui/src/app/actions.rs +++ b/crates/codegen/kigi-tui/src/app/actions.rs @@ -569,6 +569,15 @@ pub enum Action { CancelLogin, /// User submitted a manually-pasted auth token (loopback mode). SubmitAuthCode(String), + /// User selected a Moonshot row on the welcome login picker: switch the + /// welcome screen into API-key entry for that platform. + BeginPlatformKeyEntry(crate::app::app_view::PlatformLogin), + /// Esc from the API-key entry box: return to the login picker. + CancelPlatformKeyEntry, + /// User submitted a pasted Moonshot API key: persist it to + /// `[platforms.]` in config.toml, then authenticate with the + /// platform's method id. The key must never be logged. + SubmitPlatformApiKey(String), /// Copy the auth URL to the clipboard during authentication. CopyAuthUrl, /// Show the raw auth URL with mouse capture disabled for manual copy. @@ -1611,6 +1620,14 @@ pub enum Effect { PollAuthUrl { request_seq: u64 }, /// Submit a manually-pasted auth code (ext request). SubmitAuthCode { request_seq: u64, code: String }, + /// Persist a Moonshot API key to `[platforms.]` in config.toml, then + /// send AuthenticateRequest with the platform's method id. SECURITY: the + /// key must never appear in logs or errors. + PersistPlatformApiKeyAndAuthenticate { + request_seq: u64, + target: crate::app::app_view::PlatformLogin, + key: String, + }, /// Fetch MCP server list from the shell (kigi/mcp/list). FetchMcpsList { agent_id: AgentId, diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index 845ac7d..a923e7a 100644 --- a/crates/codegen/kigi-tui/src/app/app_view.rs +++ b/crates/codegen/kigi-tui/src/app/app_view.rs @@ -260,6 +260,117 @@ pub enum AuthMode { Loopback, /// RFC 8628 device flow: device code + copyable URL, no paste box. Device, + /// Open-platform API-key entry: paste box for a Moonshot key selected + /// from the welcome login picker. Esc returns to the picker (no quit). + ApiKeyEntry(PlatformLogin), +} +/// Open-platform API-key login target, selected from the welcome picker. +/// Mirrors the shell's `moonshot-cn` / `moonshot-ai` interactive auth +/// methods ([`kigi_shell::agent::auth_method`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlatformLogin { + MoonshotCn, + MoonshotAi, +} +impl PlatformLogin { + /// The picker target behind an advertised ACP method id; `None` for every + /// non-moonshot method. + pub fn from_method_id(id: &acp::AuthMethodId) -> Option { + match id.0.as_ref() { + kigi_shell::agent::auth_method::MOONSHOT_CN_METHOD_ID => Some(Self::MoonshotCn), + kigi_shell::agent::auth_method::MOONSHOT_AI_METHOD_ID => Some(Self::MoonshotAi), + _ => None, + } + } + /// The registry platform whose `[platforms.]` table stores the key. + pub fn platform_id(self) -> kigi_shell::models::PlatformId { + match self { + Self::MoonshotCn => kigi_shell::models::PlatformId::MoonshotCn, + Self::MoonshotAi => kigi_shell::models::PlatformId::MoonshotAi, + } + } + /// The ACP auth method id to `authenticate` with after persisting the key. + pub fn method_id(self) -> acp::AuthMethodId { + acp::AuthMethodId::new(self.platform_id().as_str()) + } + /// Console host shown in the paste-box copy. + pub fn console_host(self) -> &'static str { + match self { + Self::MoonshotCn => "platform.moonshot.cn", + Self::MoonshotAi => "platform.moonshot.ai", + } + } +} +/// One row of the unauthenticated welcome menu (the login picker). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingMenuItem { + /// Interactive OAuth login (the existing `kimi-code` device flow). + Login { + label: String, + }, + /// Open-platform API-key entry. + ApiKey { + target: PlatformLogin, + label: String, + }, + Quit, +} +impl PendingMenuItem { + /// Shortcut-column text for this row (`l` on the OAuth row for + /// muscle-memory compat, `q` on Quit). + pub fn shortcut(&self, index: usize) -> &'static str { + match self { + Self::Login { .. } if index == 0 => "l", + Self::Quit => "q", + _ => "", + } + } + pub fn label(&self) -> &str { + match self { + Self::Login { label } | Self::ApiKey { label, .. } => label, + Self::Quit => "Quit", + } + } +} +/// Build the welcome login-picker rows from the shell-advertised methods: +/// every INTERACTIVE method — the OAuth device login plus the Moonshot +/// API-key logins — followed by Quit. `xai.api_key` and `cached_token` are +/// non-interactive (eager-auth only) and never listed. When the shell +/// advertises no interactive method at all (fail-closed `preferred_method` +/// pins, old agents), fall back to the historical single Login row so the +/// screen keeps its shape and `Action::Login` surfaces the proper error. +pub fn pending_menu_items( + auth_methods: &[acp::AuthMethod], + login_label: Option<&str>, +) -> Vec { + use kigi_shell::agent::auth_method::AuthMethodKind; + let mut items: Vec = Vec::new(); + for method in auth_methods { + if let Some(target) = PlatformLogin::from_method_id(method.id()) { + items.push(PendingMenuItem::ApiKey { + target, + label: method.name().to_string(), + }); + } else if AuthMethodKind::from_id(method.id()).needs_interactive_login() { + items.push(PendingMenuItem::Login { + label: format!("{} (OAuth)", method.name()), + }); + } + } + if items.is_empty() { + items.push(PendingMenuItem::Login { + label: format!("Login with {}", login_label.unwrap_or("kimi.com")), + }); + } + items.push(PendingMenuItem::Quit); + items +} +/// True when the login picker offers a real choice (more than one login row +/// besides Quit). Startup then lands on the picker instead of auto-starting +/// the OAuth device flow; single-choice shells keep the historical +/// auto-trigger. +pub fn login_picker_has_choice(auth_methods: &[acp::AuthMethod]) -> bool { + pending_menu_items(auth_methods, None).len() > 2 } /// Folder-trust state for the welcome screen. /// @@ -1517,6 +1628,7 @@ impl AppView { trust_state: &self.trust_state, cwd: &self.cwd, mid_session_login: self.auth_return_view.is_some(), + auth_methods: &self.auth_methods, auth_code_input: &mut self.auth_code_input, prompt: &mut self.welcome_prompt, prompt_focused: &mut self.welcome_prompt_focused, @@ -2049,6 +2161,9 @@ struct WelcomeInputCtx<'a> { /// that was started from inside a session. Esc / `q` then cancel the /// login and return to the session rather than quitting the app. mid_session_login: bool, + /// Shell-advertised auth methods — drives the login-picker rows shown in + /// the `AuthState::Pending` welcome menu. + auth_methods: &'a [acp::AuthMethod], auth_code_input: &'a mut String, prompt: &'a mut PromptWidget, prompt_focused: &'a mut bool, @@ -2515,8 +2630,17 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco } return InputOutcome::Action(Action::QuitConfirmed); } - if key!('l').matches(key) || key!(Enter).matches(key) { - return InputOutcome::Action(Action::Login); + let items = pending_menu_items(ctx.auth_methods, None); + if let Some(outcome) = handle_menu_nav(key, ctx.menu_index, items.len()) { + return outcome; + } + // 'l' keeps its muscle-memory meaning: the first (OAuth) row. + if key!('l').matches(key) { + return dispatch_pending_menu_action(&items, 0); + } + if key!(Enter).matches(key) { + let index = ctx.menu_index.filter(|i| *i < items.len()).unwrap_or(0); + return dispatch_pending_menu_action(&items, index); } } AuthState::Authenticating { .. } if *ctx.show_raw_url => { @@ -2525,6 +2649,37 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco } return InputOutcome::Unchanged; } + AuthState::Authenticating { + mode: AuthMode::ApiKeyEntry(_), + .. + } => { + // Esc cancels BACK TO THE PICKER (unlike the OAuth flows, + // where Esc quits): the user chose this row a keystroke ago. + if key!(Esc).matches(key) { + return InputOutcome::Action(Action::CancelPlatformKeyEntry); + } + if key!('q', CONTROL).matches(key) || key!('c', CONTROL).matches(key) { + if ctx.mid_session_login { + return InputOutcome::Action(Action::CancelLogin); + } + return InputOutcome::Action(Action::QuitConfirmed); + } + if key!(Enter).matches(key) { + let trimmed = ctx.auth_code_input.trim().to_string(); + if !trimmed.is_empty() { + return InputOutcome::Action(Action::SubmitPlatformApiKey(trimmed)); + } + return InputOutcome::Unchanged; + } + if key!(Backspace).matches(key) { + ctx.auth_code_input.pop(); + return InputOutcome::Changed; + } + if let crossterm::event::KeyCode::Char(c) = key.code { + ctx.auth_code_input.push(c); + return InputOutcome::Changed; + } + } AuthState::Authenticating { mode: AuthMode::Loopback, .. @@ -2573,7 +2728,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco return InputOutcome::ActionThenForward(Action::NewSession); } AuthState::Authenticating { - mode: AuthMode::Loopback, + mode: AuthMode::Loopback | AuthMode::ApiKeyEntry(_), .. } => { let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect(); @@ -2597,7 +2752,8 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco && mouse.row < rect.y + rect.height { if matches!(ctx.auth_state, AuthState::Pending { .. }) { - return dispatch_pending_menu_action(i); + let items = pending_menu_items(ctx.auth_methods, None); + return dispatch_pending_menu_action(&items, i); } if ctx.has_claude_import && i == 0 @@ -2715,13 +2871,16 @@ fn handle_menu_nav( _ => None, } } -/// Dispatch an action for a welcome menu item when not yet authenticated. -/// Menu layout: 0 = Login, 1 = Quit. -fn dispatch_pending_menu_action(index: usize) -> InputOutcome { - match index { - 0 => InputOutcome::Action(Action::Login), - 1 => InputOutcome::Action(Action::Quit), - _ => InputOutcome::Unchanged, +/// Dispatch an action for a welcome login-picker row (not yet authenticated). +/// Rows come from [`pending_menu_items`]: interactive methods + Quit. +fn dispatch_pending_menu_action(items: &[PendingMenuItem], index: usize) -> InputOutcome { + match items.get(index) { + Some(PendingMenuItem::Login { .. }) => InputOutcome::Action(Action::Login), + Some(PendingMenuItem::ApiKey { target, .. }) => { + InputOutcome::Action(Action::BeginPlatformKeyEntry(*target)) + } + Some(PendingMenuItem::Quit) => InputOutcome::Action(Action::Quit), + None => InputOutcome::Unchanged, } } /// Dispatch an action for a welcome menu item by index. @@ -3073,6 +3232,7 @@ impl AppView { cwd: &self.cwd, auth_state: &self.auth_state, trust_state: &self.trust_state, + auth_methods: &self.auth_methods, login_label: self.login_label.as_deref(), auth_code_input: &self.auth_code_input, clipboard_copied: self.auth_clipboard_copied, @@ -6841,6 +7001,133 @@ pub(crate) mod tests { let outcome = app.handle_input(&key_event(KeyCode::Char('n'), KeyModifiers::NONE)); assert!(matches!(outcome, InputOutcome::Unchanged)); } + /// The shell's fresh-user auth methods (kimi-code + both moonshot + /// platforms) map to picker rows: OAuth first, then the two API-key rows, + /// then Quit. + fn fresh_user_auth_methods() -> Vec { + 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 + } + #[test] + fn pending_menu_items_lists_interactive_methods_plus_quit() { + let items = pending_menu_items(&fresh_user_auth_methods(), None); + assert_eq!(items.len(), 4, "3 login rows + Quit, got {items:?}"); + assert!( + matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"), + "row 0 must be the OAuth login, got {:?}", + items[0] + ); + assert_eq!( + items[1], + PendingMenuItem::ApiKey { + target: PlatformLogin::MoonshotCn, + label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(), + } + ); + assert_eq!( + items[2], + PendingMenuItem::ApiKey { + target: PlatformLogin::MoonshotAi, + label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(), + } + ); + assert_eq!(items[3], PendingMenuItem::Quit); + // The non-interactive methods must never appear as rows. + let byok = kigi_shell::agent::auth_method::build_auth_methods( + kigi_shell::agent::auth_method::AuthMethodsBuildInputs { + has_external_api_key: true, + has_cached_token: true, + login_label: None, + }, + ); + assert_eq!( + pending_menu_items(&byok.methods, None).len(), + 4, + "xai.api_key / cached_token must not add rows" + ); + } + /// Startup lands on the picker only when there is a real choice: the + /// three-method shell has one, an old kimi-code-only shell (or an empty + /// list) does not — those keep the auto-triggered device flow. + #[test] + fn login_picker_has_choice_only_with_multiple_login_rows() { + assert!(login_picker_has_choice(&fresh_user_auth_methods())); + let kimi_only = vec![kigi_shell::agent::auth_method::kimi_code_auth_method(None)]; + assert!(!login_picker_has_choice(&kimi_only)); + assert!(!login_picker_has_choice(&[])); + } + /// With all three methods advertised, arrows+Enter select a Moonshot row + /// and 'l' keeps selecting the first (OAuth) row. + #[test] + fn welcome_pending_arrows_select_moonshot_row() { + let mut app = test_app(); + app.auth_methods = fresh_user_auth_methods(); + app.auth_state = AuthState::Pending { error: None }; + app.welcome_prompt_focused = false; + // Down → row 0 (OAuth), Down → row 1 (moonshot-cn). + app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); + app.handle_input(&key_event(KeyCode::Down, KeyModifiers::NONE)); + let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); + assert!( + matches!( + outcome, + InputOutcome::Action(Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn)) + ), + "Enter on row 1 must open moonshot-cn key entry, got {outcome:?}" + ); + // 'l' is muscle-memory for the first (OAuth) row regardless of the + // arrow selection. + let outcome = app.handle_input(&key_event(KeyCode::Char('l'), KeyModifiers::NONE)); + assert!(matches!(outcome, InputOutcome::Action(Action::Login))); + } + #[test] + fn welcome_api_key_entry_esc_returns_to_picker() { + let mut app = test_app(); + app.auth_state = AuthState::Authenticating { + request_seq: 1, + handle: None, + auth_url: None, + mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotCn), + }; + let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + matches!( + outcome, + InputOutcome::Action(Action::CancelPlatformKeyEntry) + ), + "Esc must cancel back to the picker, got {outcome:?}" + ); + } + #[test] + fn welcome_api_key_entry_enter_submits_typed_key() { + let mut app = test_app(); + app.auth_state = AuthState::Authenticating { + request_seq: 1, + handle: None, + auth_url: None, + mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotAi), + }; + // Empty input: Enter is a no-op. + let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(outcome, InputOutcome::Unchanged)); + for c in "sk-42".chars() { + app.handle_input(&key_event(KeyCode::Char(c), KeyModifiers::NONE)); + } + assert_eq!(app.auth_code_input, "sk-42"); + let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE)); + match outcome { + InputOutcome::Action(Action::SubmitPlatformApiKey(key)) => { + assert_eq!(key, "sk-42"); + } + other => panic!("expected SubmitPlatformApiKey, got {other:?}"), + } + } #[test] fn welcome_done_n_starts_session() { let mut app = test_app(); diff --git a/crates/codegen/kigi-tui/src/app/dispatch/auth.rs b/crates/codegen/kigi-tui/src/app/dispatch/auth.rs index fb2e45d..af74fcb 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/auth.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/auth.rs @@ -7,7 +7,7 @@ use super::session::lifecycle::{clear_startup_actions, drain_startup_actions}; use crate::app::actions::{Action, Effect}; use crate::app::agent::AgentId; use crate::app::agent_view::AgentView; -use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState}; +use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, PlatformLogin}; use crate::scrollback::block::RenderBlock; use crate::scrollback::blocks::SessionEvent; @@ -252,6 +252,74 @@ pub(super) fn dispatch_submit_auth_code(app: &mut AppView, code: String) -> Vec< vec![Effect::SubmitAuthCode { request_seq, code }] } +/// A Moonshot row was selected on the welcome login picker: switch the +/// welcome screen into the API-key paste box for that platform. +pub(super) fn dispatch_begin_platform_key_entry( + app: &mut AppView, + target: PlatformLogin, +) -> Vec { + let request_seq = app.next_auth_request_seq; + app.next_auth_request_seq += 1; + app.auth_code_input.clear(); + app.auth_state = AuthState::Authenticating { + request_seq, + handle: None, + auth_url: None, + mode: AuthMode::ApiKeyEntry(target), + }; + vec![] +} + +/// Esc in the API-key paste box: back to the login picker (no error line). +/// Bumps the request seq so any stale in-flight auth result is dropped by +/// the `AuthComplete`/`AuthFailed` guards. +pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec { + if !matches!( + app.auth_state, + AuthState::Authenticating { + mode: AuthMode::ApiKeyEntry(_), + .. + } + ) { + return vec![]; + } + app.next_auth_request_seq += 1; + app.auth_code_input.clear(); + app.auth_state = AuthState::Pending { error: None }; + vec![] +} + +/// Enter with a non-empty key in the API-key paste box: persist the key to +/// `[platforms.]` in config.toml, then authenticate with the platform's +/// method id (one sequential background task — see the effect handler). +/// The screen shows the connecting state while the key is validated; a +/// failure lands back on the picker with the error line (`AuthFailed`). +pub(super) fn dispatch_submit_platform_api_key(app: &mut AppView, key: String) -> Vec { + let (request_seq, target) = match &app.auth_state { + AuthState::Authenticating { + request_seq, + mode: AuthMode::ApiKeyEntry(target), + .. + } => (*request_seq, *target), + _ => return vec![], + }; + let key = key.trim().to_string(); + if key.is_empty() { + return vec![]; + } + app.auth_state = AuthState::Authenticating { + request_seq, + handle: None, + auth_url: None, + mode: AuthMode::Pending, + }; + vec![Effect::PersistPlatformApiKeyAndAuthenticate { + request_seq, + target, + key, + }] +} + // TaskResult handlers. pub(super) fn handle_auth_complete( diff --git a/crates/codegen/kigi-tui/src/app/dispatch/router.rs b/crates/codegen/kigi-tui/src/app/dispatch/router.rs index 23c70a0..2f50c90 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/router.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/router.rs @@ -1,6 +1,7 @@ //! Top-level action router: maps actions and action results to handlers. use super::auth::{ - dispatch_cancel_login, dispatch_login, dispatch_logout, dispatch_submit_auth_code, + 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, }; use super::ctx::{ @@ -880,6 +881,9 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { Action::Login => dispatch_login(app), 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), + Action::CancelPlatformKeyEntry => dispatch_cancel_platform_key_entry(app), + Action::SubmitPlatformApiKey(key) => dispatch_submit_platform_api_key(app, key), Action::CopyAuthUrl => { if let AuthState::Authenticating { auth_url: Some(url), diff --git a/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs b/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs index 1ecea0f..eb1bf1a 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/tests/auth.rs @@ -100,6 +100,120 @@ fn auth_complete_with_deferred_load_also_fetches_status() { assert!(app.deferred_startup.session.is_none()); } +/// The Moonshot API-key entry flow: picking a row opens the paste box, +/// submitting a key dispatches ONE effect that persists the key and then +/// authenticates with the platform's method id, and the visual flips to the +/// connecting state under the same request seq. +#[test] +fn submit_platform_api_key_dispatches_persist_then_authenticate() { + use crate::app::app_view::PlatformLogin; + + let mut app = test_app(); + app.auth_state = AuthState::Pending { error: None }; + + let effects = dispatch( + Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn), + &mut app, + ); + assert!(effects.is_empty(), "entering key entry is UI-only"); + let seq = match &app.auth_state { + AuthState::Authenticating { + request_seq, + mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotCn), + .. + } => *request_seq, + other => panic!("expected ApiKeyEntry(MoonshotCn), got {other:?}"), + }; + + let effects = dispatch(Action::SubmitPlatformApiKey("sk-test-key".into()), &mut app); + match effects.as_slice() { + [ + Effect::PersistPlatformApiKeyAndAuthenticate { + request_seq, + target, + key, + }, + ] => { + assert_eq!(*request_seq, seq); + assert_eq!(*target, PlatformLogin::MoonshotCn); + assert_eq!(key, "sk-test-key"); + assert_eq!( + target.method_id().0.as_ref(), + "moonshot-cn", + "authenticate must use the shell's moonshot-cn method id" + ); + } + other => panic!("expected exactly the persist+authenticate effect, got {other:?}"), + } + // Same seq, connecting visual: this attempt's AuthComplete/AuthFailed + // still matches. + assert!(matches!( + app.auth_state, + AuthState::Authenticating { + request_seq, + mode: AuthMode::Pending, + .. + } if request_seq == seq + )); + + // Failed validation lands back on the picker with the error line. + dispatch( + Action::TaskComplete(TaskResult::AuthFailed { + request_seq: seq, + error: "Invalid API key for moonshot-cn".into(), + }), + &mut app, + ); + assert!(matches!( + &app.auth_state, + AuthState::Pending { error: Some(e) } if e == "Invalid API key for moonshot-cn" + )); +} + +/// Esc in the paste box returns to the picker (no error), clears the typed +/// key, and invalidates the seq so stale auth results are dropped. +#[test] +fn cancel_platform_key_entry_returns_to_picker() { + use crate::app::app_view::PlatformLogin; + + let mut app = test_app(); + app.auth_state = AuthState::Pending { error: None }; + dispatch( + Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotAi), + &mut app, + ); + app.auth_code_input = "sk-half-typed".into(); + let seq_before = app.next_auth_request_seq; + + let effects = dispatch(Action::CancelPlatformKeyEntry, &mut app); + assert!(effects.is_empty()); + assert!(matches!(app.auth_state, AuthState::Pending { error: None })); + assert!(app.auth_code_input.is_empty(), "typed key must be cleared"); + assert!(app.next_auth_request_seq > seq_before); +} + +/// A submitted empty/whitespace key is a no-op (stays in the paste box). +#[test] +fn submit_platform_api_key_ignores_blank_key() { + use crate::app::app_view::PlatformLogin; + + let mut app = test_app(); + app.auth_state = AuthState::Pending { error: None }; + dispatch( + Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn), + &mut app, + ); + let effects = dispatch(Action::SubmitPlatformApiKey(" ".into()), &mut app); + assert!(effects.is_empty()); + assert!(matches!( + app.auth_state, + AuthState::Authenticating { + mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotCn), + .. + } + )); +} + /// `/login` from the welcome screen (startup / logged-out) must NOT /// stash a return view — the normal login-then-load flow is preserved. #[test] diff --git a/crates/codegen/kigi-tui/src/app/effects/mod.rs b/crates/codegen/kigi-tui/src/app/effects/mod.rs index dfd19b7..a8b3c34 100644 --- a/crates/codegen/kigi-tui/src/app/effects/mod.rs +++ b/crates/codegen/kigi-tui/src/app/effects/mod.rs @@ -1895,6 +1895,35 @@ pub(crate) fn execute( } }); } + Effect::PersistPlatformApiKeyAndAuthenticate { request_seq, target, key } => { + let tx = acp_tx.clone(); + let abort_handle = tasks + .spawn(async move { + // Persist first so the shell's authenticate handler + // (which re-reads config + env) finds the key. The + // writer's errors never contain the key. + if let Err(e) = kigi_shell::agent::config::save_platform_api_key( + target.platform_id(), + &key, + ) + .await + { + let error = format!("Couldn't save API key: {e}"); + ulog::error( + "platform api key persist failed", + None, + Some(serde_json::json!({ "error" : & error })), + ); + return TaskResult::AuthFailed { + request_seq, + error, + }; + } + send_authenticate(&tx, request_seq, target.method_id(), false, false) + .await + }); + meta.auth_abort_handle = Some((request_seq, abort_handle)); + } Effect::SubmitAuthCode { request_seq, code } => { let tx = acp_tx.clone(); tasks diff --git a/crates/codegen/kigi-tui/src/app/event_loop.rs b/crates/codegen/kigi-tui/src/app/event_loop.rs index 2242d45..ec152c5 100644 --- a/crates/codegen/kigi-tui/src/app/event_loop.rs +++ b/crates/codegen/kigi-tui/src/app/event_loop.rs @@ -683,6 +683,13 @@ pub(crate) async fn run( error: Some("No login method available".to_string()), }; vec![] + } else if super::app_view::login_picker_has_choice(&app.auth_methods) { + // Multiple interactive login choices (OAuth + Moonshot API-key + // rows): land on the login picker instead of auto-starting the + // device flow, so the user can choose a platform. Single-choice + // shells keep the historical auto-trigger below. + app.auth_state = super::app_view::AuthState::Pending { error: None }; + vec![] } else { dispatch::dispatch(Action::Login, &mut app) } diff --git a/crates/codegen/kigi-tui/src/views/welcome/mod.rs b/crates/codegen/kigi-tui/src/views/welcome/mod.rs index 3adc696..e751e36 100644 --- a/crates/codegen/kigi-tui/src/views/welcome/mod.rs +++ b/crates/codegen/kigi-tui/src/views/welcome/mod.rs @@ -12,7 +12,11 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap}; -use crate::app::app_view::{AuthMode, AuthState, SessionPickerEntry, TrustState}; +use agent_client_protocol as acp; + +use crate::app::app_view::{ + AuthMode, AuthState, PendingMenuItem, SessionPickerEntry, TrustState, pending_menu_items, +}; use crate::startup::StartupWarning; use crate::theme::Theme; use crate::views::prompt_widget::{PromptFlag, PromptInfo, PromptWidget}; @@ -524,6 +528,9 @@ pub struct WelcomeRenderParams<'a> { /// Folder-trust state. When `Pending` (auth done, access granted), the /// welcome screen renders the trust question instead of the normal prompt. pub trust_state: &'a TrustState, + /// Shell-advertised auth methods — the login picker lists the interactive + /// ones (see [`pending_menu_items`]). + pub auth_methods: &'a [acp::AuthMethod], pub login_label: Option<&'a str>, pub auth_code_input: &'a str, pub clipboard_copied: bool, @@ -602,9 +609,14 @@ pub fn render_welcome( let mut result = match params.auth_state { AuthState::Pending { error } => { - let label = params.login_label.unwrap_or("kimi.com"); - let login_text = format!("Login with {}", label); - let menu = [("l", login_text.as_str()), ("q", "Quit")]; + // Login picker: one row per interactive method + Quit. + let items: Vec = + pending_menu_items(params.auth_methods, params.login_label); + let menu: Vec<(&str, &str)> = items + .iter() + .enumerate() + .map(|(i, item)| (item.shortcut(i), item.label())) + .collect(); let msg = error.as_deref().map(|e| (e, theme.accent_error)); let info = PromptInfo { model_name: params.model_name, @@ -1278,7 +1290,13 @@ fn render_welcome_authenticating( ]) .flex(Flex::Center) .areas(prompt_area); - render_auth_input_box(prompt_centered, buf, theme, auth_code_input); + render_auth_input_box( + prompt_centered, + buf, + theme, + auth_code_input, + "Paste your token here...", + ); // Hints let mut hint_spans = vec![ @@ -1297,6 +1315,78 @@ fn render_welcome_authenticating( (click_rect, fallback_rect) } + AuthMode::ApiKeyEntry(target) => { + // Moonshot API-key paste box: instruction + input + hints. No + // auth-URL machinery — the key comes from the platform console. + let h_pad: u16 = content_area.width / 6; + let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1); + let instruction = format!( + "Paste your Moonshot API key (from {})", + target.console_host() + ); + let msg_height = (instruction.len() as u16).div_ceil(inner_width); + let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([ + Constraint::Length(top_pad), + Constraint::Length(logo_line_count), + Constraint::Length(1), // gap + Constraint::Length(msg_height), // instruction + Constraint::Min(1), // gap + Constraint::Length(5), // prompt box + Constraint::Length(1), // gap + Constraint::Length(1), // hints + Constraint::Min(0), + ]) + .areas(content_area); + + render_logo(logo_area, buf, theme, content_area.height); + + let msg = Line::from(Span::styled( + instruction, + Style::default().fg(theme.gray_bright), + )) + .alignment(Alignment::Center); + Paragraph::new(msg) + .wrap(Wrap { trim: false }) + .block(Block::default().padding(Padding::horizontal(h_pad))) + .render(msg_area, buf); + + let [_, prompt_centered, _] = Layout::horizontal([ + Constraint::Min(0), + Constraint::Length(content_area.width), + Constraint::Min(0), + ]) + .flex(Flex::Center) + .areas(prompt_area); + render_auth_input_box( + prompt_centered, + buf, + theme, + auth_code_input, + "Paste your API key here...", + ); + + let hints = Line::from(vec![ + Span::styled( + "enter", + Style::default() + .fg(theme.accent_user) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" submit ", Style::default().fg(theme.gray)), + Span::styled( + "esc", + Style::default() + .fg(theme.accent_user) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" back", Style::default().fg(theme.gray)), + ]) + .alignment(Alignment::Center); + Paragraph::new(hints).render(hint_area, buf); + + (None, None) + } + AuthMode::Command => render_browser_status_arm( content_area, buf, @@ -2008,7 +2098,13 @@ pub(crate) fn render_session_picker( } /// Render the auth token input box (loopback mode). -fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &str) { +fn render_auth_input_box( + area: Rect, + buf: &mut Buffer, + theme: &Theme, + input: &str, + placeholder: &str, +) { let prompt_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(theme.accent_user)) @@ -2022,7 +2118,11 @@ fn render_auth_input_box(area: Rect, buf: &mut Buffer, theme: &Theme, input: &st prompt_block.render(area, buf); if inner.height > 0 && inner.width > 2 { - let display = mask_auth_token_for_display(input); + let display = if input.is_empty() { + placeholder.to_string() + } else { + mask_auth_token_for_display(input) + }; let style = if input.is_empty() { Style::default().fg(theme.gray_dim) @@ -2151,6 +2251,7 @@ mod tests { prompt_focus: WelcomePromptFocus::Unfocused, auth_state, trust_state, + auth_methods: &[], login_label: None, auth_code_input: "", clipboard_copied: false, @@ -2191,6 +2292,93 @@ mod tests { buffer_text(&buf) } + /// The unauthenticated welcome menu lists one row per interactive login + /// method — the OAuth device login plus BOTH Moonshot open platforms — + /// and Quit, when the shell advertises all three. + #[test] + fn pending_menu_lists_three_login_rows_plus_quit() { + use kigi_shell::agent::auth_method::{AuthMethodsBuildInputs, build_auth_methods}; + let built = build_auth_methods(AuthMethodsBuildInputs { + has_external_api_key: false, + has_cached_token: false, + login_label: None, + }); + let auth = AuthState::Pending { error: None }; + let trust = TrustState::Done; + let mut params = render_params(&auth, &trust, None); + params.auth_methods = &built.methods; + let text = render_done_text(¶ms); + assert!(text.contains("Kimi Code (OAuth)"), "{text}"); + assert!( + text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"), + "{text}" + ); + assert!( + text.contains("Moonshot Open Platform (API key \u{b7} moonshot.ai)"), + "{text}" + ); + assert!(text.contains("Quit"), "{text}"); + // Shortcut hints for muscle memory: `l` (first row) and `q` (Quit). + assert!(text.contains('l'), "{text}"); + assert!(text.contains('q'), "{text}"); + } + + /// An old/limited shell that advertises only `kimi-code` keeps the + /// two-row shape (single login row + Quit) — and never a Moonshot row. + #[test] + fn pending_menu_without_moonshot_methods_keeps_two_rows() { + let methods = vec![kigi_shell::agent::auth_method::kimi_code_auth_method(None)]; + let auth = AuthState::Pending { error: None }; + let trust = TrustState::Done; + let mut params = render_params(&auth, &trust, None); + params.auth_methods = &methods; + let text = render_done_text(¶ms); + assert!(text.contains("Kimi Code (OAuth)"), "{text}"); + assert!(!text.contains("Moonshot"), "{text}"); + } + + /// The Moonshot API-key entry arm renders the platform copy, the paste + /// box, and the esc-back hint — and no OAuth-URL affordances. + #[test] + fn api_key_entry_arm_shows_platform_copy_and_paste_box() { + let area = Rect::new(0, 0, 80, 40); + let mut buf = Buffer::empty(area); + let theme = Theme::current(); + + let (copy_rect, fallback_rect) = render_welcome_authenticating( + area, + &mut buf, + &theme, + logo_line_count(area.height), + None, // auth_url — none in key-entry mode + AuthMode::ApiKeyEntry(crate::app::app_view::PlatformLogin::MoonshotCn), + "", // auth_code_input + false, // clipboard_copied + false, // show_raw_url + ); + + let text = buffer_text(&buf); + // The instruction may soft-wrap; assert its two halves (each stays an + // intact word run on one row). + assert!( + text.contains("Paste your Moonshot API key"), + "key-entry arm must show the platform instruction, got:\n{text}" + ); + assert!( + text.contains("platform.moonshot.cn"), + "key-entry arm must name the platform console, got:\n{text}" + ); + assert!( + text.contains("Paste your API key here..."), + "key-entry arm must render the paste box placeholder, got:\n{text}" + ); + assert!( + text.contains("esc") && text.contains("back"), + "key-entry arm must hint esc-back, got:\n{text}" + ); + assert!(copy_rect.is_none() && fallback_rect.is_none()); + } + #[test] fn foreign_resume_tip_names_each_tool_and_age() { use kigi_workspace::foreign_sessions::ForeignSessionTool; diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 1661600..46de847 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -23,7 +23,7 @@ listed command and check the listed observable. | 11 | Worktrees | auto | `cargo test -p kigi-fast-worktree --lib`; manual: `kigi worktree list` | | 12 | Mermaid rendering | auto | `cargo test -p kigi-mermaid --lib` | | 13 | Crash handling | auto | `cargo test -p kigi-crash-handler --lib` | -| 14 | Kimi auth (device flow) | auto | `cargo test -p kigi-shell --lib auth::` (138 cases incl. live-shape fixtures); manual: `kigi login` completes in a browser, token lands in keyring service `kigi` | +| 14 | Kimi auth (login picker: device flow + Moonshot API key) | auto | `cargo test -p kigi-shell --lib auth::` (138 cases incl. live-shape fixtures) + `cargo test -p kigi-shell --lib auth_method` (moonshot validation); manual: `kigi login` completes in a browser, token lands in keyring service `kigi`; welcome picker also accepts a Moonshot API key into `[platforms.*]` in config.toml | | 15 | Inference (ChatCompletions Kimi dialect) | auto | `cargo test -p kigi-sampler` (incl. `test_kimi_wire`); e2e: `scratchpad` mock flow (write → run → answer), see AGENTS.md | | 16 | Model catalog sync (`/models`) | auto | `cargo test -p kigi-shell --lib models_fetch` + `cargo test -p kigi-models --lib` | | 17 | Search/fetch tools (OAuth-gated) | auto | `cargo test -p kigi-tools --lib web_search` and `--lib web_fetch` (Kimi wire contracts) |