Add per-provider auth.json keys; make auth methods registry-generic (P0b)

Platform API keys now live in auth.json under the platform-id scope (the
per-provider auth.json key contract), resolved env > auth.json > legacy
[platforms.*] config.toml (read-only fallback). The TUI login picker,
paste box, auth-method advertising, and authenticate handler are all
registry-generic: a new PlatformSpec row appears in the login UI and
authenticates with zero UI changes. Spec rows gained vendor/console_host/
login_label display fields (moonshot strings byte-identical, pinned by
tests).

Adversarial review caught that auth.json keys were validated at login but
never stamped onto catalog entries (completions would 401; restart lost
eager auth). Fixed red-green: resolve_model_list/resolve_model_catalog now
take a resolved PlatformApiKeys snapshot consumed by the credential-
stamping layer (auth.json beats stale config.toml, matching the login
validator), with production callers resolving fresh per catalog build.
Also from review: the new auth.json writer takes the manager's cross-
process flock (bounded retry — an unlocked RMW racing a token refresh
could revert a rotated refresh token); the oauth-401 wiremock test is
hermetic (KIGI_SHARE_DIR tempdir; it could read a dev's real auth.json
and hit live moonshot); cli_models resolves real keys; auth.json is read
once per registry sweep; caller-less lock_config_writes deleted; catalog
resolvers tightened to pub(crate); stale config.toml doc comments and the
no-credentials error copy updated.
This commit is contained in:
2026-07-21 01:18:46 -04:00
parent 99d99fb47a
commit c5ddaec71e
19 changed files with 731 additions and 375 deletions
+4 -4
View File
@@ -574,9 +574,9 @@ pub enum Action {
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.
/// User submitted a pasted platform API key: persist it to auth.json
/// under the platform-id scope, 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,
@@ -1612,7 +1612,7 @@ 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
/// Persist a platform API key to auth.json (platform-id scope), then
/// send AuthenticateRequest with the platform's method id. SECURITY: the
/// key must never appear in logs or errors.
PersistPlatformApiKeyAndAuthenticate {
+24 -28
View File
@@ -264,41 +264,35 @@ pub enum AuthMode {
/// 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`]).
/// API-key platform login target, selected from the welcome picker. Wraps a
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
/// [`Self::from_method_id`] — the production entry point — guarantees the
/// non-OAuth invariant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlatformLogin {
MoonshotCn,
MoonshotAi,
}
pub struct PlatformLogin(pub kigi_shell::models::PlatformId);
impl PlatformLogin {
/// The picker target behind an advertised ACP method id; `None` for every
/// non-moonshot method.
/// method that isn't an API-key registry platform.
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,
}
kigi_shell::agent::auth_method::platform_for_method_id(id).map(Self)
}
/// The registry platform whose `[platforms.<id>]` table stores the key.
/// The registry platform whose auth.json scope 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,
}
self.0
}
/// 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.
/// Vendor word for the paste-box copy ("Paste your {vendor} API key").
pub fn vendor(self) -> &'static str {
self.0.vendor()
}
/// Console host shown in the paste-box copy. Every API-key registry row
/// carries one (pinned by a kigi-models registry test); the fallback is
/// unreachable copy, not control flow.
pub fn console_host(self) -> &'static str {
match self {
Self::MoonshotCn => "platform.moonshot.cn",
Self::MoonshotAi => "platform.moonshot.ai",
}
self.0.console_host().unwrap_or("the provider console")
}
}
/// One row of the unauthenticated welcome menu (the login picker).
@@ -6909,14 +6903,14 @@ pub(crate) mod tests {
assert_eq!(
items[1],
PendingMenuItem::ApiKey {
target: PlatformLogin::MoonshotCn,
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn),
label: "Moonshot Open Platform (API key \u{b7} moonshot.cn)".into(),
}
);
assert_eq!(
items[2],
PendingMenuItem::ApiKey {
target: PlatformLogin::MoonshotAi,
target: PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi),
label: "Moonshot Open Platform (API key \u{b7} moonshot.ai)".into(),
}
);
@@ -6960,7 +6954,9 @@ pub(crate) mod tests {
assert!(
matches!(
outcome,
InputOutcome::Action(Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn))
InputOutcome::Action(Action::BeginPlatformKeyEntry(PlatformLogin(
kigi_shell::models::PlatformId::MoonshotCn
)))
),
"Enter on row 1 must open moonshot-cn key entry, got {outcome:?}"
);
@@ -6976,7 +6972,7 @@ pub(crate) mod tests {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotCn),
mode: AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
};
let outcome = app.handle_input(&key_event(KeyCode::Esc, KeyModifiers::NONE));
assert!(
@@ -6994,7 +6990,7 @@ pub(crate) mod tests {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotAi),
mode: AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi)),
};
// Empty input: Enter is a no-op.
let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE));
@@ -290,7 +290,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
}
/// 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
/// auth.json (platform-id scope), 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`).
@@ -112,14 +112,14 @@ fn submit_platform_api_key_dispatches_persist_then_authenticate() {
app.auth_state = AuthState::Pending { error: None };
let effects = dispatch(
Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn),
Action::BeginPlatformKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::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),
mode: AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
..
} => *request_seq,
other => panic!("expected ApiKeyEntry(MoonshotCn), got {other:?}"),
@@ -135,7 +135,10 @@ fn submit_platform_api_key_dispatches_persist_then_authenticate() {
},
] => {
assert_eq!(*request_seq, seq);
assert_eq!(*target, PlatformLogin::MoonshotCn);
assert_eq!(
*target,
PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)
);
assert_eq!(key, "sk-test-key");
assert_eq!(
target.method_id().0.as_ref(),
@@ -179,7 +182,7 @@ fn cancel_platform_key_entry_returns_to_picker() {
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotAi),
Action::BeginPlatformKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotAi)),
&mut app,
);
app.auth_code_input = "sk-half-typed".into();
@@ -200,7 +203,7 @@ fn submit_platform_api_key_ignores_blank_key() {
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginPlatformKeyEntry(PlatformLogin::MoonshotCn),
Action::BeginPlatformKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
&mut app,
);
let effects = dispatch(Action::SubmitPlatformApiKey(" ".into()), &mut app);
@@ -208,7 +211,7 @@ fn submit_platform_api_key_ignores_blank_key() {
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::ApiKeyEntry(PlatformLogin::MoonshotCn),
mode: AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
..
}
));
@@ -1218,12 +1218,13 @@ fn render_welcome_authenticating(
}
AuthMode::ApiKeyEntry(target) => {
// Moonshot API-key paste box: instruction + input + hints. No
// Platform 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 {})",
"Paste your {} API key (from {})",
target.vendor(),
target.console_host()
);
let msg_height = (instruction.len() as u16).div_ceil(inner_width);
@@ -2136,7 +2137,9 @@ mod tests {
&theme,
logo_line_count(area.height),
None, // auth_url — none in key-entry mode
AuthMode::ApiKeyEntry(crate::app::app_view::PlatformLogin::MoonshotCn),
AuthMode::ApiKeyEntry(crate::app::app_view::PlatformLogin(
kigi_shell::models::PlatformId::MoonshotCn,
)),
"", // auth_code_input
false, // clipboard_copied
false, // show_raw_url