feat(providers): add xAI Grok subscription OAuth (device-code) + per-provider session auth
First subscription-OAuth provider beyond Kimi Code (26th registry variant). Log in with a Grok/SuperGrok/X subscription via RFC-8628 device-code OAuth (auth.x.ai), then use it against api.x.ai/v1 — reusing the existing xai wire (ChatCompletions + OpenAI listing + Passthrough + restrict + models_dev_id xai). Sourced from Pi (earendil-works/pi auth/oauth/xai.ts): client b1a00492..., scope 'openid profile email offline_access grok-cli:access api:access', standard Bearer (no x-xai-token-auth). Foundation (generalizes Kigi's Kimi-singleton OAuth to per-provider, root cause, not a patch): - Registry: OAuthConfig on PlatformSpec (client_id/host/device+token paths/scope/scope_key); XAI_OAUTH_CONFIG + XAI_GROK_SPEC (uses_oauth, method id 'xai-grok', an interactive login after kimi-code). - Generic device-code wire (auth/oauth_device.rs) + GenericDeviceRefresher, sharing the RFC-8628 core with Kimi; Kimi's bespoke flow is byte-identical (X-Msh headers, KIMI_CODE_OAUTH_SCOPE, keyring gating unchanged). - Per-provider AuthManager via a process-global pool (auth/oauth_registry.rs): build-on-demand with start_proactive_refresh, keyed by scope. The session resolves the AuthManager for the ACTIVE model's platform for bearer/refresh/ 401-recovery/api_key — an oauth-platform model always uses its OWN token, never the primary. - Live /models under OAuth; base routes oauth().is_some() -> platform.base_url() (kimi-code stays on proxy_url). Security: adversarial review + a systematic token-leak audit found and closed FIVE channels where the primary Kimi token could reach api.x.ai (bearer resolver, api_key stamping, aux summary/classifier/image-describe models, and subagent model-override). Each fix routes through the platform-aware resolver (the oauth model's pooled token or None, NEVER the primary) and is revert-to-red verified. No access/refresh token is ever logged. Registry at 26; picker updated (xai-grok interactive login row); TUI context-window already auto-updates per model. Full gate green (234 suites, fmt, clippy -D warnings, deny). GPT/Claude/Grok officially permit third-party subscription use.
This commit is contained in:
@@ -1158,6 +1158,72 @@ fn build_agent_with_auth(auth: crate::auth::KimiAuth) -> MvpAgent {
|
||||
let cfg = AgentConfig::default();
|
||||
MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config")
|
||||
}
|
||||
/// Regression (token-leak, Facet B via the api_key channel): under a
|
||||
/// session-based (Kimi) primary auth method, `prepare_sampling_config_for_model`
|
||||
/// must resolve the session token by the MODEL's platform — so a grok
|
||||
/// (oauth-platform) model NEVER carries the primary Kimi session key as its
|
||||
/// `api_key`, while a first-party Kimi model still does. Relies on the
|
||||
/// process-global OAuth pool (there is no per-session snapshot).
|
||||
///
|
||||
/// Reverting the fix (resolving the session token from the primary regardless of
|
||||
/// the model's platform) fails the grok assertion below — it would stamp the
|
||||
/// live Kimi key on a request bound for api.x.ai.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn prepare_sampling_config_never_stamps_kimi_key_on_grok_model() {
|
||||
use crate::agent::auth_method::{
|
||||
CACHED_TOKEN_AUTH_METHOD_ID, HOUSE_API_KEY_ENV_VAR, LEGACY_XAI_API_KEY_ENV_VAR,
|
||||
XAI_API_KEY_ENV_VAR,
|
||||
};
|
||||
use crate::agent::config::{EndpointsConfig, ModelEntry};
|
||||
use kigi_test_support::EnvGuard;
|
||||
|
||||
const KIMI_KEY: &str = "kimi-session-secret-DO-NOT-LEAK";
|
||||
|
||||
// No ambient BYOK env key: a grok model with no stored oauth session then
|
||||
// resolves to no api_key at all, rather than a global-key fallback that could
|
||||
// mask the leak under test.
|
||||
let _house = EnvGuard::unset(HOUSE_API_KEY_ENV_VAR);
|
||||
let _xai = EnvGuard::unset(XAI_API_KEY_ENV_VAR);
|
||||
let _legacy = EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR);
|
||||
|
||||
// Primary: a live Kimi session token under a session-based auth method.
|
||||
let agent = build_agent_with_auth(crate::auth::KimiAuth {
|
||||
key: KIMI_KEY.to_string(),
|
||||
auth_mode: crate::auth::AuthMode::OAuth,
|
||||
..crate::auth::KimiAuth::test_default()
|
||||
});
|
||||
agent.set_auth_method(acp::AuthMethodId::new(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||
|
||||
let endpoints = EndpointsConfig::default();
|
||||
|
||||
// First-party Kimi model (non-oauth platform): the primary session key IS
|
||||
// its api_key — the byte-identical primary path, and proof the Kimi token is
|
||||
// live (so it WOULD leak if mis-routed onto a grok request). This assertion
|
||||
// also confirms the session-based primary path is active.
|
||||
let mut kimi_model = ModelEntry::fallback("kimi-k2-0905-preview", &endpoints);
|
||||
kimi_model.info.id = Some("moonshot-cn/kimi-k2-0905-preview".to_string());
|
||||
assert!(!kimi_model.has_own_credentials());
|
||||
let kimi_cfg = agent.prepare_sampling_config_for_model(&kimi_model, None);
|
||||
assert_eq!(
|
||||
kimi_cfg.api_key.as_deref(),
|
||||
Some(KIMI_KEY),
|
||||
"a first-party Kimi model must carry the primary session key (primary path unchanged)"
|
||||
);
|
||||
|
||||
// xai-grok model (oauth platform): the session token resolves from its OWN
|
||||
// pool-backed manager, INDEPENDENT of the Kimi primary — so its api_key can
|
||||
// NEVER be the Kimi session key.
|
||||
let mut grok_model = ModelEntry::fallback("grok-4-latest", &endpoints);
|
||||
grok_model.info.id = Some("xai-grok/grok-4-latest".to_string());
|
||||
assert!(!grok_model.has_own_credentials());
|
||||
let grok_cfg = agent.prepare_sampling_config_for_model(&grok_model, None);
|
||||
assert_ne!(
|
||||
grok_cfg.api_key.as_deref(),
|
||||
Some(KIMI_KEY),
|
||||
"LEAK: a grok model must never carry the primary Kimi session key as api_key"
|
||||
);
|
||||
}
|
||||
/// Regression: boot-time plugin discovery is deferred past ACP
|
||||
/// `initialize`, so the shared plugin registry starts empty.
|
||||
/// `resolve_mcp_servers` reads that snapshot to merge plugin-contributed
|
||||
|
||||
Reference in New Issue
Block a user