update
This commit is contained in:
+16
-10
@@ -487,21 +487,27 @@ fn session_token_auth_gate_truth_table() {
|
||||
use crate::agent::auth_method::{ModelByok, session_token_auth_gate as gate};
|
||||
// Non-session methods never refresh, regardless of BYOK status or endpoint.
|
||||
for fp in [false, true] {
|
||||
assert!(!gate(false, ModelByok::NotByok, fp));
|
||||
assert!(!gate(false, ModelByok::Byok, fp));
|
||||
assert!(!gate(false, ModelByok::Unknown, fp));
|
||||
// Session method: a definite classification ignores the endpoint —
|
||||
// NotByok always refreshes (only ever routes to the session endpoint),
|
||||
// a genuine per-model Byok never does.
|
||||
assert!(gate(true, ModelByok::NotByok, fp));
|
||||
assert!(!gate(true, ModelByok::Byok, fp));
|
||||
assert!(!gate(false, ModelByok::NotByok, fp, true));
|
||||
assert!(!gate(false, ModelByok::Byok, fp, true));
|
||||
assert!(!gate(false, ModelByok::Unknown, fp, true));
|
||||
// Session method on an endpoint that DOES take the session credential
|
||||
// (kimi-code, an OAuth platform's own pool, or a bare / [model.*]
|
||||
// model): a definite classification ignores the endpoint — NotByok
|
||||
// refreshes, a genuine per-model Byok never does.
|
||||
assert!(gate(true, ModelByok::NotByok, fp, true));
|
||||
assert!(!gate(true, ModelByok::Byok, fp, true));
|
||||
// …and an API-key registry platform endpoint is refused on every arm,
|
||||
// first-party flag included: that is the leak guard.
|
||||
assert!(!gate(true, ModelByok::NotByok, fp, false));
|
||||
assert!(!gate(true, ModelByok::Byok, fp, false));
|
||||
assert!(!gate(true, ModelByok::Unknown, fp, false));
|
||||
}
|
||||
// Session method + Unknown BYOK: refresh only against a first-party xAI
|
||||
// host, so a transiently-unclassifiable config can't demote a live session
|
||||
// (the stale-token 401 regression) yet the session token never leaks to a
|
||||
// third-party BYOK endpoint. This arm was unconditionally `false` pre-fix.
|
||||
assert!(gate(true, ModelByok::Unknown, true));
|
||||
assert!(!gate(true, ModelByok::Unknown, false));
|
||||
assert!(gate(true, ModelByok::Unknown, true, true));
|
||||
assert!(!gate(true, ModelByok::Unknown, false, true));
|
||||
}
|
||||
|
||||
/// Pre-fix, the gate read `auth_type` and skipped recovery here, 401'ing every
|
||||
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
//! LEAK GUARD, part 2: the model→platform lookup (H5) and the AUX resolver
|
||||
//! decision (H3/H4). Shares the fixtures in
|
||||
//! [`super::session_bearer_leak_tests`]; see that module's header for the chain
|
||||
//! and the storage-discipline contract.
|
||||
|
||||
use super::session_bearer_leak_tests::{
|
||||
KIMI_TOKEN, actor_on_managed_model, actor_with_catalog, managed_entry,
|
||||
};
|
||||
use super::*;
|
||||
use kigi_sampler::BearerResolver;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn dual_credential_slug_collision_resolves_the_selected_oauth_platform() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
// (api-key twin, oauth twin, shared slug, host)
|
||||
for (api_key_twin, oauth_twin, slug, base_url) in [
|
||||
(
|
||||
"xai/grok-4.5",
|
||||
"xai-grok/grok-4.5",
|
||||
"grok-4.5",
|
||||
"https://api.x.ai/v1",
|
||||
),
|
||||
(
|
||||
"anthropic/claude-opus-4-8",
|
||||
"claude-pro-max/claude-opus-4-8",
|
||||
"claude-opus-4-8",
|
||||
"https://api.anthropic.com/v1",
|
||||
),
|
||||
(
|
||||
"openai/gpt-5.5-codex",
|
||||
"openai-codex/gpt-5.5-codex",
|
||||
"gpt-5.5-codex",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
),
|
||||
] {
|
||||
// API-key platform FIRST, exactly as `PlatformId::ALL` orders them.
|
||||
let catalog = vec![
|
||||
managed_entry(api_key_twin, slug, base_url),
|
||||
managed_entry(oauth_twin, slug, base_url),
|
||||
];
|
||||
let (_dir, actor, _rx) = actor_with_catalog(catalog, oauth_twin, "unused").await;
|
||||
let cfg = actor.reconstruct_full_config().await;
|
||||
|
||||
let resolver = cfg.bearer_resolver.as_ref().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{oauth_twin}: selecting the OAuth twin must keep a LIVE bearer_resolver \
|
||||
(mid-session refresh); resolving {api_key_twin} instead drops it"
|
||||
)
|
||||
});
|
||||
assert_ne!(
|
||||
resolver.current_bearer(),
|
||||
Some(KIMI_TOKEN.to_string()),
|
||||
"{oauth_twin}: the resolver must read its OWN pool, never the Kimi primary"
|
||||
);
|
||||
|
||||
let platform = kigi_models::parse_managed_model_key(oauth_twin)
|
||||
.expect("managed key")
|
||||
.0;
|
||||
assert_eq!(
|
||||
cfg.anthropic_oauth,
|
||||
platform.wire_api() == kigi_models::PlatformWireApi::Messages,
|
||||
"{oauth_twin}: the Claude OAuth Messages adaptation must follow the \
|
||||
SELECTED platform"
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.openai_codex,
|
||||
platform.sends_codex_responses_headers(),
|
||||
"{oauth_twin}: the Codex identity headers must follow the SELECTED platform"
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.github_copilot,
|
||||
platform.sends_copilot_editor_headers(),
|
||||
"{oauth_twin}: the Copilot editor headers must follow the SELECTED platform"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The other half of H5: selecting the API-KEY twin of a colliding slug must
|
||||
/// still resolve the API-key platform — no bearer_resolver, no adaptations. The
|
||||
/// unified lookup must not simply prefer OAuth.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn dual_credential_slug_collision_resolves_the_selected_api_key_platform() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let catalog = vec![
|
||||
managed_entry(
|
||||
"anthropic/claude-opus-4-8",
|
||||
"claude-opus-4-8",
|
||||
"https://api.anthropic.com/v1",
|
||||
),
|
||||
managed_entry(
|
||||
"claude-pro-max/claude-opus-4-8",
|
||||
"claude-opus-4-8",
|
||||
"https://api.anthropic.com/v1",
|
||||
),
|
||||
];
|
||||
let (_dir, actor, _rx) =
|
||||
actor_with_catalog(catalog, "anthropic/claude-opus-4-8", "sk-ant-byok").await;
|
||||
let cfg = actor.reconstruct_full_config().await;
|
||||
assert!(
|
||||
cfg.bearer_resolver.is_none(),
|
||||
"selecting the API-key twin must get NO session bearer resolver"
|
||||
);
|
||||
assert!(
|
||||
!cfg.anthropic_oauth,
|
||||
"the API-key Anthropic Messages request must stay byte-identical"
|
||||
);
|
||||
assert_eq!(cfg.api_key.as_deref(), Some("sk-ant-byok"));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// MANDATORY counterpart: the subscription-OAuth platforms have NON-first-party
|
||||
/// base URLs, so the fix must not disable their resolver. Each must still get a
|
||||
/// LIVE `bearer_resolver` — and it must read THAT platform's own pooled
|
||||
/// `AuthManager`, never the Kimi primary. The pooled managers are empty here (a
|
||||
/// TempDir pool home), which is what makes `current_bearer() == None` a proof
|
||||
/// that the Kimi bearer cannot be what they resolve.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn oauth_platform_models_keep_a_live_resolver_from_their_own_pool() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
for (catalog_key, slug, base_url) in [
|
||||
(
|
||||
"claude-pro-max/claude-opus-4-8",
|
||||
"claude-opus-4-8",
|
||||
"https://api.anthropic.com/v1",
|
||||
),
|
||||
(
|
||||
"github-copilot/gpt-4.1",
|
||||
"gpt-4.1",
|
||||
"https://api.githubcopilot.com",
|
||||
),
|
||||
(
|
||||
"xai-grok/grok-4-latest",
|
||||
"grok-4-latest",
|
||||
"https://api.x.ai/v1",
|
||||
),
|
||||
(
|
||||
"openai-codex/gpt-5.5",
|
||||
"gpt-5.5",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
),
|
||||
] {
|
||||
let (_dir, actor, _rx) =
|
||||
actor_on_managed_model(catalog_key, slug, base_url, "unused").await;
|
||||
let cfg = actor.reconstruct_full_config().await;
|
||||
let resolver = cfg.bearer_resolver.as_ref().unwrap_or_else(|| {
|
||||
panic!("{catalog_key}: must keep a live bearer_resolver for refresh")
|
||||
});
|
||||
assert_ne!(
|
||||
resolver.current_bearer(),
|
||||
Some(KIMI_TOKEN.to_string()),
|
||||
"{catalog_key}: the Kimi bearer must never be what it resolves"
|
||||
);
|
||||
|
||||
// The resolver is LIVE over that platform's pooled manager: a
|
||||
// token rotated inside the pool is observed by the
|
||||
// already-built resolver (this is what mid-session refresh
|
||||
// does). The pool is read here, never mutated.
|
||||
let pooled = crate::auth::oauth_registry::manager_for_model(
|
||||
&crate::auth::oauth_registry::pool_home(),
|
||||
catalog_key,
|
||||
actor.auth_manager.as_ref(),
|
||||
)
|
||||
.expect("an OAuth platform always resolves a manager");
|
||||
assert!(
|
||||
!Arc::ptr_eq(
|
||||
&pooled,
|
||||
actor.auth_manager.as_ref().expect("primary is present")
|
||||
),
|
||||
"{catalog_key}: must route to its OWN pooled manager, not the Kimi primary"
|
||||
);
|
||||
assert_eq!(
|
||||
resolver.current_bearer(),
|
||||
pooled.current_or_expired().map(|a| a.key),
|
||||
"{catalog_key}: the resolver must read THIS platform's pooled manager"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// H3/H4 — the stamped AUX paths (image-describe, the auto-mode classifier and
|
||||
/// the session-summary client all funnel through `aux_bearer_resolver`).
|
||||
/// `stamp_session_local_sampler_fields` copies the SESSION model's resolver onto
|
||||
/// every aux config and `SamplingClient::post` REPLACES the request's auth
|
||||
/// header from it, so an API-key-platform aux model would have its own key
|
||||
/// overwritten by the Kimi bearer ON THE AUX HOST.
|
||||
///
|
||||
/// Revert-to-red: returning `stamped` unconditionally (the pre-fix
|
||||
/// "re-point only when the aux model is OAuth" shape) makes the deepseek /
|
||||
/// openai / `[model.*]`-on-openai.com rows resolve `KIMI_TOKEN`.
|
||||
#[test]
|
||||
fn aux_bearer_resolver_clears_the_session_resolver_off_a_third_party_aux_host() {
|
||||
#[derive(Debug)]
|
||||
struct Fixed(&'static str);
|
||||
impl BearerResolver for Fixed {
|
||||
fn current_bearer(&self) -> Option<String> {
|
||||
Some(self.0.to_string())
|
||||
}
|
||||
}
|
||||
let stamped: kigi_sampler::SharedBearerResolver = Arc::new(Fixed(KIMI_TOKEN));
|
||||
let platform =
|
||||
|key: &str| kigi_models::parse_managed_model_key(key).map(|(platform, _)| platform);
|
||||
|
||||
// Cleared: every API-key registry platform, and a `[model.*]` aux model
|
||||
// pointed at a third-party host.
|
||||
for (key, base_url) in [
|
||||
("deepseek/deepseek-chat", "https://api.deepseek.com/v1"),
|
||||
("openai/gpt-5-mini", "https://api.openai.com/v1"),
|
||||
(
|
||||
"moonshot-cn/kimi-k2-turbo-preview",
|
||||
"https://api.moonshot.cn/v1",
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
crate::session::acp_session::sampler_turn::aux_bearer_resolver(
|
||||
Some(stamped.clone()),
|
||||
platform(key),
|
||||
base_url,
|
||||
)
|
||||
.is_none(),
|
||||
"LEAK: an aux model on {base_url} must not inherit the session bearer resolver"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
crate::session::acp_session::sampler_turn::aux_bearer_resolver(
|
||||
Some(stamped.clone()),
|
||||
None,
|
||||
"https://api.openai.com/v1",
|
||||
)
|
||||
.is_none(),
|
||||
"LEAK: a [model.*] aux model on a third-party host must not inherit it either"
|
||||
);
|
||||
|
||||
// Kept (byte-identical): the first-party subscription channel and a
|
||||
// platform-less aux model on the session's own endpoint.
|
||||
for (key, base_url) in [
|
||||
(
|
||||
Some("kimi-code/kimi-for-coding"),
|
||||
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||||
),
|
||||
(None, kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url),
|
||||
(None, "http://127.0.0.1:4141/v1"),
|
||||
] {
|
||||
let resolved = crate::session::acp_session::sampler_turn::aux_bearer_resolver(
|
||||
Some(stamped.clone()),
|
||||
key.and_then(platform),
|
||||
base_url,
|
||||
)
|
||||
.unwrap_or_else(|| panic!("{key:?} @ {base_url} must keep the session resolver"));
|
||||
assert_eq!(resolved.current_bearer(), Some(KIMI_TOKEN.to_string()));
|
||||
}
|
||||
|
||||
// Re-pointed: an OAuth aux model gets a LIVE resolver over its OWN pool
|
||||
// (empty here), never the stamped Kimi one.
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime for the pooled manager's refresh task");
|
||||
rt.block_on(async {
|
||||
for key in [
|
||||
"xai-grok/grok-4-latest",
|
||||
"claude-pro-max/claude-opus-4-8",
|
||||
"github-copilot/gpt-4.1",
|
||||
"openai-codex/gpt-5.5",
|
||||
] {
|
||||
let resolved = crate::session::acp_session::sampler_turn::aux_bearer_resolver(
|
||||
Some(stamped.clone()),
|
||||
platform(key),
|
||||
"https://example.invalid/v1",
|
||||
)
|
||||
.unwrap_or_else(|| panic!("{key} must keep a live resolver from its own pool"));
|
||||
assert_ne!(
|
||||
resolved.current_bearer(),
|
||||
Some(KIMI_TOKEN.to_string()),
|
||||
"{key}: the aux resolver must never resolve the Kimi session bearer"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
//! LEAK GUARD (bearer_resolver channel): the primary (Kimi) subscription bearer
|
||||
//! must never be stamped on a request to a host that does not own it.
|
||||
//!
|
||||
//! Chain the guard closes: a session-based ACP method (`cached_token` /
|
||||
//! `kimi-code` / any OAuth platform) + a selected API-key-platform model
|
||||
//! classifies `ModelByok::NotByok` (the model carries no `[model.*]` key), the
|
||||
//! pre-fix `session_token_auth_gate` returned `true` unconditionally on that
|
||||
//! arm, `auth_manager_for_model` fell through to the primary Kimi manager for a
|
||||
//! non-OAuth platform, and `SamplingClient::post` then REPLACED the correctly
|
||||
//! resolved provider key with the Kimi bearer on the wire.
|
||||
//!
|
||||
//! The `api_key` half of the same defect (the config never even gets the
|
||||
//! provider key, because `resolve_credentials` stamps the session token) is
|
||||
//! pinned in `agent/mvp_agent/tests/api_key_channel_leak_tests.rs`, which drives
|
||||
//! the real `prepare_sampling_config_for_model` resolution path. These tests
|
||||
//! deliberately do NOT hand-stamp a provider key except where the assertion is
|
||||
//! about the resolver overwriting one that already resolved correctly.
|
||||
//!
|
||||
//! The counterpart contract these tests also pin: the four subscription-OAuth
|
||||
//! platforms have non-first-party base URLs but MUST keep a live
|
||||
//! `bearer_resolver` drawn from their OWN pooled `AuthManager`, or they lose
|
||||
//! mid-session token refresh.
|
||||
//!
|
||||
//! STORAGE DISCIPLINE (H6): nothing here touches the developer's real `~/.kigi`
|
||||
//! and nothing hot-swaps the process-global OAuth pool. Under `cfg(test)`
|
||||
//! `oauth_registry::pool_home()` is a process-lifetime `TempDir`, so every
|
||||
//! pooled manager is empty — which is exactly what the assertions need (a live
|
||||
//! resolver that is provably NOT the Kimi one).
|
||||
|
||||
use super::support::*;
|
||||
use super::*;
|
||||
use crate::agent::auth_method::ModelByok;
|
||||
use crate::agent::config::{ModelAuthFacts, ModelEntry, ModelInfo};
|
||||
use crate::auth::{AuthManager, AuthMode, KimiAuth, KimiCodeConfig};
|
||||
use kigi_sampler::BearerResolver;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// The primary session bearer. Any occurrence of this string in an outgoing
|
||||
/// request to a third-party host is the defect.
|
||||
pub(super) const KIMI_TOKEN: &str = "kimi-subscription-token-DO-NOT-LEAK";
|
||||
|
||||
/// `(tempdir, manager)` standing in for the session's primary Kimi
|
||||
/// `AuthManager`, holding a live (unexpired) OAuth session bearer.
|
||||
fn kimi_primary() -> (tempfile::TempDir, Arc<AuthManager>) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let am = Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
|
||||
am.hot_swap(KimiAuth {
|
||||
key: KIMI_TOKEN.to_string(),
|
||||
auth_mode: AuthMode::OAuth,
|
||||
refresh_token: Some("rt".into()),
|
||||
expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
..KimiAuth::test_default()
|
||||
});
|
||||
(dir, am)
|
||||
}
|
||||
|
||||
/// One catalog entry: catalog key `catalog_key`, routing slug `slug`, routed at
|
||||
/// `base_url`, carrying no credential of its own (the shape every fetched
|
||||
/// registry model has).
|
||||
pub(super) fn managed_entry(catalog_key: &str, slug: &str, base_url: &str) -> (String, ModelEntry) {
|
||||
let mut info = ModelInfo::fallback(slug);
|
||||
info.id = Some(catalog_key.to_string());
|
||||
info.base_url = base_url.to_string();
|
||||
(
|
||||
catalog_key.to_string(),
|
||||
ModelEntry {
|
||||
info,
|
||||
api_key: None,
|
||||
env_key: None,
|
||||
api_base_url: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A `SessionActor` on a session-based ACP method with a live Kimi primary,
|
||||
/// whose live catalog holds `catalog` and whose SELECTED model is the catalog
|
||||
/// key `selected` (the picker's own notion of "current"). `wire_key` is the
|
||||
/// already-correctly-resolved provider credential sitting in chat state.
|
||||
///
|
||||
/// The per-model BYOK memo is pinned to `NotByok` on purpose: that is what a
|
||||
/// fetched registry model actually resolves to (`resolve_model_auth_facts` only
|
||||
/// ever sees `default_models.json` + `[model.*]`), and pinning it keeps the test
|
||||
/// independent of the developer's on-disk `~/.kigi/config.toml`.
|
||||
pub(super) async fn actor_with_catalog(
|
||||
catalog: Vec<(String, ModelEntry)>,
|
||||
selected: &str,
|
||||
wire_key: &str,
|
||||
) -> (
|
||||
tempfile::TempDir,
|
||||
Arc<SessionActor>,
|
||||
mpsc::UnboundedReceiver<PersistenceMsg>,
|
||||
) {
|
||||
let (dir, am) = kimi_primary();
|
||||
let (gateway_tx, _gateway_rx) = mpsc::unbounded_channel();
|
||||
let (persistence_tx, persistence_rx) = mpsc::unbounded_channel();
|
||||
let mut actor = create_test_actor(50_000, 200_000, 85, gateway_tx, persistence_tx).await;
|
||||
actor.auth_manager = Some(am);
|
||||
actor.auth_method_id = test_auth_method_id("cached_token");
|
||||
|
||||
let mut selected_entry = None;
|
||||
for (key, entry) in catalog {
|
||||
if key == selected {
|
||||
selected_entry = Some(entry.clone());
|
||||
}
|
||||
actor.models_manager.insert_test_entry(key, entry);
|
||||
}
|
||||
let selected_entry = selected_entry.expect("the selected key must be in the catalog");
|
||||
actor
|
||||
.models_manager
|
||||
.set_current_model_id(acp::ModelId::new(selected.to_string()));
|
||||
|
||||
let slug = selected_entry.info().model.clone();
|
||||
actor
|
||||
.chat_state_handle
|
||||
.update_sampling_config(kigi_sampling_types::SamplingConfig {
|
||||
base_url: selected_entry.info().base_url.clone(),
|
||||
model: slug.clone(),
|
||||
max_completion_tokens: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(200_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
stream_tool_calls: None,
|
||||
});
|
||||
actor
|
||||
.chat_state_handle
|
||||
.update_credentials(kigi_chat_state::Credentials {
|
||||
api_key: Some(wire_key.to_string()),
|
||||
auth_type: kigi_chat_state::AuthType::SessionToken,
|
||||
..Default::default()
|
||||
});
|
||||
actor.model_auth_facts.replace(Some((
|
||||
slug,
|
||||
ModelAuthFacts {
|
||||
byok: ModelByok::NotByok,
|
||||
auth_scheme: Default::default(),
|
||||
},
|
||||
)));
|
||||
(dir, Arc::new(actor), persistence_rx)
|
||||
}
|
||||
|
||||
/// Single-entry convenience over [`actor_with_catalog`].
|
||||
pub(super) async fn actor_on_managed_model(
|
||||
catalog_key: &str,
|
||||
slug: &str,
|
||||
base_url: &str,
|
||||
wire_key: &str,
|
||||
) -> (
|
||||
tempfile::TempDir,
|
||||
Arc<SessionActor>,
|
||||
mpsc::UnboundedReceiver<PersistenceMsg>,
|
||||
) {
|
||||
actor_with_catalog(
|
||||
vec![managed_entry(catalog_key, slug, base_url)],
|
||||
catalog_key,
|
||||
wire_key,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// THE leak test, at the wire. A `deepseek/deepseek-chat` turn on a session
|
||||
/// (`cached_token`) method with a live Kimi primary must send DeepSeek's own key
|
||||
/// — the Kimi subscription bearer must not appear anywhere in the request.
|
||||
///
|
||||
/// Revert-to-red: dropping `endpoint_takes_session_credential` from
|
||||
/// `session_token_auth_gate` puts `Bearer <KIMI_TOKEN>` on this request.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn deepseek_turn_under_a_kimi_session_sends_no_kimi_bearer_on_the_wire() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/chat/completions"))
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": "cmpl-1",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "deepseek-chat",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": { "role": "assistant", "content": "ok" },
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let uri = server.uri();
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let (_dir, actor, _rx) = actor_on_managed_model(
|
||||
"deepseek/deepseek-chat",
|
||||
"deepseek-chat",
|
||||
&uri,
|
||||
"sk-deepseek-provider-key",
|
||||
)
|
||||
.await;
|
||||
|
||||
let cfg = actor.reconstruct_full_config().await;
|
||||
assert!(
|
||||
cfg.bearer_resolver.is_none(),
|
||||
"an API-key platform model must get NO session bearer resolver"
|
||||
);
|
||||
let client =
|
||||
kigi_sampler::SamplingClient::new(cfg).expect("sampling client must construct");
|
||||
let _ = client
|
||||
.chat_completion(kigi_sampling_types::ChatCompletionRequest::new(
|
||||
"deepseek-chat",
|
||||
vec![kigi_sampling_types::ChatRequestMessage::user("hi")],
|
||||
))
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("wiremock records requests");
|
||||
assert_eq!(requests.len(), 1, "exactly one inference request was sent");
|
||||
let auth = requests[0]
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.expect("the request must carry an Authorization header")
|
||||
.to_string();
|
||||
assert!(
|
||||
!auth.contains(KIMI_TOKEN),
|
||||
"the Kimi subscription bearer must never reach a third-party inference host"
|
||||
);
|
||||
assert_eq!(
|
||||
auth, "Bearer sk-deepseek-provider-key",
|
||||
"the correctly-resolved provider key must survive to the wire"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same guard for every other API-key registry platform shape: OpenAI
|
||||
/// (Responses), Anthropic (x-api-key/Messages), Groq, Together and Z.AI CN — all
|
||||
/// classify `NotByok`, all route to a non-first-party host, none may receive a
|
||||
/// session bearer resolver.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn api_key_platform_models_get_no_session_bearer_resolver() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
for (catalog_key, slug, base_url) in [
|
||||
("openai/gpt-5.2", "gpt-5.2", "https://api.openai.com/v1"),
|
||||
(
|
||||
"anthropic/claude-opus-4-8",
|
||||
"claude-opus-4-8",
|
||||
"https://api.anthropic.com/v1",
|
||||
),
|
||||
("groq/llama-4", "llama-4", "https://api.groq.com/openai/v1"),
|
||||
("together/qwen-3", "qwen-3", "https://api.together.xyz/v1"),
|
||||
(
|
||||
"zai-coding-cn/glm-5",
|
||||
"glm-5",
|
||||
"https://open.bigmodel.cn/api/paas/v4",
|
||||
),
|
||||
] {
|
||||
let (_dir, actor, _rx) =
|
||||
actor_on_managed_model(catalog_key, slug, base_url, "sk-provider-key").await;
|
||||
let cfg = actor.reconstruct_full_config().await;
|
||||
assert!(
|
||||
cfg.bearer_resolver.is_none(),
|
||||
"{catalog_key}: an API-key platform must get no session bearer resolver"
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.api_key.as_deref(),
|
||||
Some("sk-provider-key"),
|
||||
"{catalog_key}: the provider key must stay on the config"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// C2 at the resolver channel: a `[model.*]` entry has NO platform
|
||||
/// (`info.id == None`), which used to be a blanket allow. Pointed at a
|
||||
/// third-party host it must get no session resolver; pointed at the session's
|
||||
/// own coding endpoint (a `KIGI_CODE_BASE_URL` deployment or a local dev proxy)
|
||||
/// it must keep one — that is why the predicate is not `is_first_party_url`.
|
||||
///
|
||||
/// Revert-to-red: making the `None` arm of `platform_takes_session_credential`
|
||||
/// return `true` again puts a Kimi resolver on the openai.com config.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn config_model_entry_takes_a_session_resolver_only_on_its_own_endpoint() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
for base_url in ["https://api.openai.com/v1", "https://api.deepseek.com/v1"] {
|
||||
let mut info = ModelInfo::fallback("gpt-4o");
|
||||
info.id = None; // a `[model.gpt-4o]` block
|
||||
info.base_url = base_url.to_string();
|
||||
let entry = ModelEntry {
|
||||
info,
|
||||
api_key: None,
|
||||
// An env_key that is NOT set: `has_own_credentials()` probes
|
||||
// `std::env::var` at call time, so this classifies NotByok.
|
||||
env_key: None,
|
||||
api_base_url: None,
|
||||
};
|
||||
let (_dir, actor, _rx) =
|
||||
actor_with_catalog(vec![("gpt-4o".to_string(), entry)], "gpt-4o", "").await;
|
||||
let cfg = actor.reconstruct_full_config().await;
|
||||
assert!(
|
||||
cfg.bearer_resolver.is_none(),
|
||||
"LEAK: a [model.*] block at {base_url} must get no session bearer resolver"
|
||||
);
|
||||
}
|
||||
|
||||
for base_url in [
|
||||
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||||
"http://127.0.0.1:4141/v1",
|
||||
] {
|
||||
let mut info = ModelInfo::fallback("kigi-4.5");
|
||||
info.id = None;
|
||||
info.base_url = base_url.to_string();
|
||||
let entry = ModelEntry {
|
||||
info,
|
||||
api_key: None,
|
||||
env_key: None,
|
||||
api_base_url: None,
|
||||
};
|
||||
let (_dir, actor, _rx) =
|
||||
actor_with_catalog(vec![("kigi-4.5".to_string(), entry)], "kigi-4.5", "").await;
|
||||
let resolver = actor
|
||||
.reconstruct_full_config()
|
||||
.await
|
||||
.bearer_resolver
|
||||
.expect("the session's own endpoint keeps the session resolver");
|
||||
assert_eq!(
|
||||
resolver.current_bearer(),
|
||||
Some(KIMI_TOKEN.to_string()),
|
||||
"{base_url}: a custom deployment / dev proxy is unchanged"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// H5 — the slug collision. A user holding BOTH an xAI API key and a Grok
|
||||
/// subscription has `xai/grok-4.5` AND `xai-grok/grok-4.5` in one catalog, in
|
||||
/// `PlatformId::ALL` order (`Xai`(15) before `XaiGrok`(25)) and with the SAME
|
||||
/// routing slug. `cfg.model` is that bare slug, so the auth layer used to
|
||||
/// first-match the API-key entry: no bearer_resolver, no live refresh (the
|
||||
/// session dies ~1h in with an unrecoverable 401), and — for the Anthropic and
|
||||
/// Codex twins — the OAuth Messages adaptation and the Codex identity headers
|
||||
/// silently dropped.
|
||||
///
|
||||
/// The catalog KEY the picker selected is now authoritative.
|
||||
///
|
||||
/// Revert-to-red: resolving the platform from `find_model_by_id(models, slug)`
|
||||
/// instead of `current_model_id` resolves `xai/grok-4.5` /
|
||||
/// `anthropic/claude-opus-4-8` / `openai/gpt-5.5-codex` and every assertion
|
||||
/// below fails.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn kimi_first_party_model_still_rides_the_primary_session_bearer() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let (_dir, actor, _rx) = actor_on_managed_model(
|
||||
"kimi-code/kimi-for-coding",
|
||||
"kimi-for-coding",
|
||||
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||||
"stale-buffered-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let cfg = actor.reconstruct_full_config().await;
|
||||
let resolver = cfg
|
||||
.bearer_resolver
|
||||
.as_ref()
|
||||
.expect("the subscription model must keep the live session resolver");
|
||||
assert_eq!(
|
||||
resolver.current_bearer(),
|
||||
Some(KIMI_TOKEN.to_string()),
|
||||
"the first-party model resolves the primary session bearer"
|
||||
);
|
||||
|
||||
actor.refresh_token_if_expired().await;
|
||||
assert_eq!(
|
||||
actor
|
||||
.chat_state_handle
|
||||
.get_credentials()
|
||||
.await
|
||||
.api_key
|
||||
.as_deref(),
|
||||
Some(KIMI_TOKEN),
|
||||
"the first-party pre-flight refresh must still heal the stale key"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The persistence half of the defect: `refresh_token_if_expired` used to write
|
||||
/// the Kimi session token into `chat_state` `creds.api_key` for ANY
|
||||
/// session-method turn, from where it propagated to subagents and aux configs.
|
||||
/// A deepseek turn must leave the provider key untouched.
|
||||
///
|
||||
/// M7 rides along: a registry-platform model must not fall into
|
||||
/// `reload_api_key_from_config` at all (a `load_effective_config()` disk read
|
||||
/// per turn plus a permanently false "not found in config.toml" warning), so
|
||||
/// the key is left exactly as resolved.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn preflight_refresh_never_writes_the_kimi_token_into_a_platform_credential() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let (_dir, actor, _rx) = actor_on_managed_model(
|
||||
"deepseek/deepseek-chat",
|
||||
"deepseek-chat",
|
||||
"https://api.deepseek.com/v1",
|
||||
"sk-deepseek-provider-key",
|
||||
)
|
||||
.await;
|
||||
|
||||
actor.refresh_token_if_expired().await;
|
||||
|
||||
assert_eq!(
|
||||
actor
|
||||
.chat_state_handle
|
||||
.get_credentials()
|
||||
.await
|
||||
.api_key
|
||||
.as_deref(),
|
||||
Some("sk-deepseek-provider-key"),
|
||||
"the Kimi session token must never overwrite a platform credential"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Reference in New Issue
Block a user