Login picker: offer all three platforms like the official CLI

The unauthenticated welcome screen previously offered only 'Login with
Kimi Code'. It now lists every interactive platform the shell
advertises, matching the official kimi-cli picker:

  Kimi Code (OAuth)
  Moonshot Open Platform (API key · moonshot.cn)
  Moonshot Open Platform (API key · moonshot.ai)
  Quit

- Shell: new ACP auth methods moonshot-cn / moonshot-ai (advertised
  after kimi-code; the BYOK first-position invariant holds).
  authenticate(moonshot-*) reloads keys from env>config, fails with an
  actionable message when none is configured, validates the key against
  GET {base}/models (401 → 'invalid API key' naming the console), then
  swaps the fresh config in, triggers the model sync, and reports
  auth_mode api_key so the pager treats it like other API-key logins.
  Never session-based; keys never logged.
- Config: save_platform_api_key persists [platforms.<id>].api_key via an
  atomic mode-preserving write under the config lock; refuses OAuth
  platforms, blank keys, and unparseable files.
- TUI: the Pending welcome renders the picker rows from the advertised
  methods (arrows/Enter/mouse; 'l' keeps selecting the OAuth row).
  Choosing a Moonshot row opens a masked paste box ('Paste your Moonshot
  API key (from platform.moonshot.cn)'); Esc returns to the picker,
  Enter persists the key and authenticates in one sequential effect;
  failures return to the picker with the error line, success lands on
  the normal welcome. Startup eager-auth is unchanged: a key already in
  the environment authenticates exactly as before, and single-method
  shells keep the historical auto-device-flow.

Gates: workspace check/clippy 0/0; shell 4870 + tui 6620 lib tests
green; headless probe advertises [kimi-code, moonshot-cn, moonshot-ai]
for a fresh user and xai.api_key-first with a key configured.
This commit is contained in:
2026-07-18 04:02:39 -04:00
parent f6253fbf56
commit 3952c28f16
14 changed files with 1253 additions and 31 deletions
@@ -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.<id>]` 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<kigi_models::PlatformId> {
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"
);
}
}
@@ -821,6 +821,75 @@ pub(crate) fn resolve_platform_api_key_with(
platforms.config_api_key(platform)
}
/// Persist `[platforms.<id>].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.<id>].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() {
@@ -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()
@@ -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.<id>]` 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<AuthenticateResponse, acp::Error> {
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<String> {
self.cfg.borrow().endpoints.deployment_key.clone()
}
+51
View File
@@ -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
@@ -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.<id>]` 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.<id>]` 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,
+298 -11
View File
@@ -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<Self> {
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.<id>]` 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<PendingMenuItem> {
use kigi_shell::agent::auth_method::AuthMethodKind;
let mut items: Vec<PendingMenuItem> = 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<acp::AuthMethod> {
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();
@@ -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<Effect> {
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<Effect> {
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.<id>]` 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<Effect> {
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(
@@ -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<Effect> {
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),
@@ -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]
@@ -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
@@ -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)
}
@@ -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<PendingMenuItem> =
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(&params);
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(&params);
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;
+1 -1
View File
@@ -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) |