This commit is contained in:
2026-07-22 11:08:54 -04:00
parent 422e241e13
commit 2d00a4e6e6
19 changed files with 1955 additions and 296 deletions
@@ -135,7 +135,7 @@ use prompt_build::*;
mod session_mode;
use session_mode::*;
#[path = "acp_session_impl/sampler_turn.rs"]
mod sampler_turn;
pub(crate) mod sampler_turn;
use sampler_turn::*;
#[path = "acp_session_impl/tool_dispatch.rs"]
mod tool_dispatch;
@@ -1256,6 +1256,17 @@ mod rewind_synthetic_turn_tests;
#[cfg(test)]
#[path = "acp_session_tests/rewrite_zero_turn_prefix_tests.rs"]
mod rewrite_zero_turn_prefix_tests;
/// The same guard for the model→platform lookup (the dual-credential slug
/// collision) and for the stamped aux/summary configs.
#[cfg(test)]
#[path = "acp_session_tests/session_bearer_leak_platform_tests.rs"]
mod session_bearer_leak_platform_tests;
/// LEAK guard: the primary Kimi subscription bearer must never ride a request
/// to an API-key registry platform's host, while the subscription-OAuth
/// platforms keep a live resolver from their OWN pooled manager.
#[cfg(test)]
#[path = "acp_session_tests/session_bearer_leak_tests.rs"]
mod session_bearer_leak_tests;
/// Pins the `SubagentFinished` usage-fold attribution gate.
#[cfg(test)]
#[path = "acp_session_tests/subagent_usage_fold_tests.rs"]
@@ -634,14 +634,12 @@ impl SessionActor {
&active_session_config,
Some(self.max_retries),
);
// A grok (oauth-platform) image-describe model must not inherit the
// session (Kimi) bearer_resolver stamped by `finalize_*`; re-point it at
// grok's own manager. No-op for a first-party / non-oauth model.
// An image-describe model on another provider must not inherit the
// session (Kimi) bearer_resolver stamped by `finalize_*`: re-point it at
// an OAuth model's own manager, or clear it for an API-key-platform /
// third-party endpoint. No-op for the first-party subscription channel.
if aux_resolved {
self.repoint_aux_bearer_resolver_for_oauth(
&mut sampler_config,
&self.image_description_model,
);
self.repoint_aux_bearer_resolver(&mut sampler_config, &self.image_description_model);
}
let client = kigi_sampler::SamplingClient::new(sampler_config).map_err(|e| {
acp::Error::internal_error().data(format!(
@@ -36,20 +36,37 @@ struct SessionTokenAuthGate {
/// BYOK status still refresh against the first-party cli-chat-proxy hosts without
/// risking a session-token leak to a third-party BYOK endpoint.
endpoint_is_first_party: bool,
/// Whether this model's platform is one whose endpoint accepts a session
/// bearer at all (see
/// [`crate::agent::auth_method::platform_takes_session_credential`]). False
/// for every API-key registry platform, which keeps the primary Kimi bearer
/// off `api.deepseek.com` / `api.openai.com` / … .
endpoint_takes_session_credential: bool,
}
impl SessionTokenAuthGate {
/// Single place `is_session_based` / `endpoint_is_first_party` are derived,
/// so all call sites assemble the gate identically.
/// so all call sites assemble the gate identically. `model_platform` is the
/// registry platform the model routes to (`None` for a bare / `[model.*]`
/// entry) — it MUST be derived from the same lookup
/// ([`SessionActor::managed_key_for_model`]) that
/// [`SessionActor::auth_manager_for_model`] uses, so the gate's verdict and
/// the manager actually wrapped as the bearer resolver can never disagree.
fn new(
auth_method_id: Option<&acp::AuthMethodId>,
model_byok: crate::agent::auth_method::ModelByok,
base_url: &str,
model_platform: Option<kigi_models::PlatformId>,
) -> Self {
Self {
is_session_based: auth_method_id
.is_some_and(crate::agent::auth_method::is_session_based_method),
model_byok,
endpoint_is_first_party: crate::util::is_first_party_url(base_url),
endpoint_takes_session_credential:
crate::agent::auth_method::platform_takes_session_credential(
model_platform,
base_url,
),
}
}
fn active(self) -> bool {
@@ -57,6 +74,7 @@ impl SessionTokenAuthGate {
self.is_session_based,
self.model_byok,
self.endpoint_is_first_party,
self.endpoint_takes_session_credential,
)
}
}
@@ -127,6 +145,43 @@ fn auth_manager_bearer_resolver(
) -> kigi_sampler::SharedBearerResolver {
std::sync::Arc::new(AuthManagerBearerResolver(am))
}
/// The `bearer_resolver` an AUX / summary `SamplerConfig` may carry, given the
/// SESSION model's resolver (`stamped`) and the AUX model's own platform +
/// endpoint. ONE decision shared by image-describe, the auto-mode classifier
/// (via [`SessionActor::repoint_aux_bearer_resolver`]) and
/// `MvpAgent::build_summary_client`.
///
/// [`crate::agent::config::stamp_session_local_sampler_fields`] copies the
/// session resolver onto every aux config, and `SamplingClient::post` REPLACES
/// the request's auth header from it — so an aux model on a DIFFERENT provider
/// would have its own correctly-resolved key overwritten by the session bearer
/// on the AUX host (H3/H4). Resolve by the aux model instead:
/// - an OAuth platform → a live resolver over ITS OWN pooled manager (so a grok
/// / claude-pro-max / copilot / codex aux model keeps mid-session refresh);
/// - `kimi-code`, or a platform-less model on the session's own coding endpoint
/// → the stamped session resolver, byte-identical;
/// - every API-key registry platform, and any `[model.*]` block pointed at a
/// third-party host → `None`, so the aux model's own key survives to the wire.
///
/// SECURITY: no token is logged.
pub(crate) fn aux_bearer_resolver(
stamped: Option<kigi_sampler::SharedBearerResolver>,
platform: Option<kigi_models::PlatformId>,
base_url: &str,
) -> Option<kigi_sampler::SharedBearerResolver> {
if let Some(oauth) = platform.and_then(kigi_models::PlatformId::oauth) {
return Some(auth_manager_bearer_resolver(
crate::auth::oauth_registry::global_manager_for(
&crate::auth::oauth_registry::pool_home(),
oauth,
),
));
}
if crate::agent::auth_method::platform_takes_session_credential(platform, base_url) {
return stamped;
}
None
}
impl SessionActor {
pub(super) async fn prepare_tool_definitions_timed(&self) -> (Vec<ToolDefinition>, u64) {
let mcp_wait_start = std::time::Instant::now();
@@ -206,7 +261,12 @@ impl SessionActor {
fn auth_gate(&self, model_id: &str, base_url: &str) -> SessionTokenAuthGate {
let byok = self.model_auth_facts(model_id).byok;
let auth_method = self.auth_method_id.load();
SessionTokenAuthGate::new(auth_method.as_deref(), byok, base_url)
SessionTokenAuthGate::new(
auth_method.as_deref(),
byok,
base_url,
self.model_platform(model_id),
)
}
/// The [`AuthManager`](crate::auth::AuthManager) that governs INFERENCE auth
/// for the model whose routing slug is `model` (the sampling config's
@@ -238,26 +298,30 @@ impl SessionActor {
// and resolves to the primary.
let managed_key = self.managed_key_for_model(model);
crate::auth::oauth_registry::manager_for_model(
&crate::util::kigi_home::kigi_home(),
&crate::auth::oauth_registry::pool_home(),
managed_key.as_deref().unwrap_or(model),
self.auth_manager.as_ref(),
)
}
/// Recover the managed catalog key (`{platform}/{model}`) for a routing slug
/// from the live catalog. `None` for a bare / config / unlisted model.
///
/// H5: the catalog KEY the picker selected
/// ([`crate::agent::models::ModelsManager::current_model_id`]) is
/// authoritative — `model` is the ambiguous bare slug. See
/// [`crate::agent::models::managed_key_for_slug`].
fn managed_key_for_model(&self, model: &str) -> Option<String> {
let models = self.models_manager.models();
crate::agent::config::find_model_by_id(&models, model).and_then(|e| e.info().id.clone())
let current = self.models_manager.current_model_id();
crate::agent::models::managed_key_for_slug(&models, Some(current.0.as_ref()), model)
}
/// Whether the aux/session model `model` routes to a generic device-code
/// OAuth platform (xai-grok). The gate for re-pointing an aux model's
/// bearer_resolver away from the session (Kimi) resolver — a first-party /
/// non-oauth model returns `false` and keeps the stamped session resolver.
fn model_is_oauth_platform(&self, model: &str) -> bool {
let managed_key = self.managed_key_for_model(model);
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model))
.and_then(|(platform, _)| platform.oauth())
.is_some()
/// The registry platform the routing slug `model` belongs to, from the SAME
/// lookup [`Self::auth_manager_for_model`] routes on. `None` for a bare /
/// `[model.*]` / unlisted model.
fn model_platform(&self, model: &str) -> Option<kigi_models::PlatformId> {
let models = self.models_manager.models();
let current = self.models_manager.current_model_id();
crate::agent::models::platform_for_slug(&models, Some(current.0.as_ref()), model)
}
/// Whether `model` routes to the Claude Pro/Max OAuth-Messages platform
/// (claude-pro-max) — the gate for the sampler's OAuth Messages adaptation
@@ -266,22 +330,18 @@ impl SessionActor {
/// which is ChatCompletions) returns `false`, keeping the API-key Anthropic
/// / MiniMax Messages requests byte-identical.
fn model_is_anthropic_oauth(&self, model: &str) -> bool {
let managed_key = self.managed_key_for_model(model);
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model)).is_some_and(
|(platform, _)| {
platform.oauth().is_some()
&& platform.wire_api() == kigi_models::PlatformWireApi::Messages
},
)
self.model_platform(model).is_some_and(|platform| {
platform.oauth().is_some()
&& platform.wire_api() == kigi_models::PlatformWireApi::Messages
})
}
/// Whether `model` routes to the GitHub Copilot ChatCompletions platform
/// (github-copilot) — the gate for the sampler's editor-identity headers +
/// `X-Initiator`. Every other model returns `false`, keeping the other
/// ChatCompletions providers byte-identical.
fn model_is_github_copilot(&self, model: &str) -> bool {
let managed_key = self.managed_key_for_model(model);
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model))
.is_some_and(|(platform, _)| platform.sends_copilot_editor_headers())
self.model_platform(model)
.is_some_and(kigi_models::PlatformId::sends_copilot_editor_headers)
}
/// Whether `model` routes to the ChatGPT/Codex Responses platform
/// (openai-codex) — the gate for the sampler's Codex identity headers
@@ -289,28 +349,23 @@ impl SessionActor {
/// returns `false`, keeping the API-key `openai` Responses request
/// byte-identical.
fn model_is_openai_codex(&self, model: &str) -> bool {
let managed_key = self.managed_key_for_model(model);
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model))
.is_some_and(|(platform, _)| platform.sends_codex_responses_headers())
self.model_platform(model)
.is_some_and(kigi_models::PlatformId::sends_codex_responses_headers)
}
/// LEAK guard for the stamped aux paths (auto-mode classifier, image
/// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`]
/// has copied the SESSION model's `bearer_resolver` onto an aux
/// `SamplerConfig`, re-point it at the AUX model's OWN platform manager when
/// the aux model is an oauth platform (xai-grok) — so a grok aux model never
/// inherits the live Kimi session bearer (→ api.x.ai). No-op for a
/// first-party / non-oauth aux model (keeps the stamped session resolver →
/// byte-identical). SECURITY: no token is logged.
pub(super) fn repoint_aux_bearer_resolver_for_oauth(
/// describe). Applies [`aux_bearer_resolver`] to the config
/// [`crate::agent::config::stamp_session_local_sampler_fields`] just stamped
/// the SESSION model's `bearer_resolver` onto.
pub(super) fn repoint_aux_bearer_resolver(
&self,
cfg: &mut kigi_sampler::SamplerConfig,
slug: &str,
) {
if self.model_is_oauth_platform(slug)
&& let Some(manager) = self.auth_manager_for_model(slug)
{
cfg.bearer_resolver = Some(auth_manager_bearer_resolver(manager));
}
cfg.bearer_resolver = aux_bearer_resolver(
cfg.bearer_resolver.take(),
self.model_platform(slug),
&cfg.base_url,
);
}
/// Emit a unified-log breadcrumb whenever the session-token refresh gate is
/// evaluated with an **`Unknown`** per-model BYOK status on a session-based
@@ -330,8 +385,9 @@ impl SessionActor {
let ctx = serde_json::json!(
{ "site" : site, "model_byok" : gate.model_byok.as_str(), "is_session_based"
: gate.is_session_based, "endpoint_is_first_party" : gate
.endpoint_is_first_party, "refresh_active" : refresh_active, "base_url" :
base_url, }
.endpoint_is_first_party, "endpoint_takes_session_credential" : gate
.endpoint_takes_session_credential, "refresh_active" : refresh_active,
"base_url" : base_url, }
);
let sid = Some(self.session_info.id.0.as_ref());
if refresh_active {
@@ -385,8 +441,12 @@ impl SessionActor {
let creds = self.chat_state_handle.get_credentials().await;
let model_facts = self.model_auth_facts(cfg.model.as_str());
let auth_method = self.auth_method_id.load();
let gate =
SessionTokenAuthGate::new(auth_method.as_deref(), model_facts.byok, &cfg.base_url);
let gate = SessionTokenAuthGate::new(
auth_method.as_deref(),
model_facts.byok,
&cfg.base_url,
self.model_platform(cfg.model.as_str()),
);
let use_bearer_resolver = gate.active();
self.log_auth_gate_unknown("reconstruct_full_config", gate, &cfg.base_url);
// Resolve the bearer from the ACTIVE model's OWN manager: a grok model
@@ -589,17 +649,18 @@ impl SessionActor {
slug: &str,
) -> Option<kigi_sampler::SamplerConfig> {
let creds = self.chat_state_handle.get_credentials().await;
// Resolve the aux token by the aux model's OWN platform: a grok
// (oauth-platform) aux model draws its pooled grok token or `None` —
// NEVER the primary Kimi session token (which `resolve_credentials`
// would otherwise stamp onto an api.x.ai request). A first-party /
// non-oauth aux model still gets the primary (byte-identical).
let session_key = crate::auth::oauth_registry::session_key_for_model(
&crate::util::kigi_home::kigi_home(),
let models = self.models_manager.models();
// Resolve the aux token by the aux model's OWN platform AND endpoint: a
// grok (oauth-platform) aux model draws its pooled grok token or `None`,
// and an API-key registry platform draws NOTHING — NEVER the primary
// Kimi session token (which `resolve_credentials` would otherwise stamp
// onto an api.x.ai / api.deepseek.com request). The first-party
// subscription channel still gets the primary (byte-identical).
let session_key = crate::auth::oauth_registry::session_key_for_catalog_model(
&models,
slug,
self.auth_manager.as_ref(),
);
let models = self.models_manager.models();
let endpoints = self.models_manager.endpoints();
crate::agent::config::resolve_aux_model_sampling_config(
slug,
@@ -625,10 +686,11 @@ impl SessionActor {
&active_session_config,
Some(self.max_retries),
);
// LEAK 1b: a grok aux classifier must not inherit the SESSION model's
// (Kimi) bearer_resolver stamped above; re-point it at grok's own
// manager (its pooled token, or None). No-op for a non-oauth aux.
self.repoint_aux_bearer_resolver_for_oauth(&mut cfg, slug);
// LEAK 1b: the aux classifier must not inherit the SESSION model's
// (Kimi) bearer_resolver stamped above re-point it at an OAuth aux
// model's own manager, or clear it for an API-key-platform / third-party
// aux endpoint. No-op for the first-party subscription channel.
self.repoint_aux_bearer_resolver(&mut cfg, slug);
let model = cfg.model.clone();
let client = kigi_sampler::SamplingClient::new(cfg)
.map_err(|e| {
@@ -774,6 +836,8 @@ impl SessionActor {
session_id = % self.session_info.id.0, is_session_based = gate
.is_session_based, model_byok = gate.model_byok.as_str(),
endpoint_is_first_party = gate.endpoint_is_first_party,
endpoint_takes_session_credential = gate
.endpoint_takes_session_credential,
"auth recovery: sampler 401 not refreshable (api-key auth) — surfacing 401",
);
kigi_log::unified_log::warn(
@@ -783,7 +847,9 @@ impl SessionActor {
{ "kind" : error.kind.as_str(), "status_code" : error
.status_code, "is_session_based" : gate.is_session_based,
"model_byok" : gate.model_byok.as_str(),
"endpoint_is_first_party" : gate.endpoint_is_first_party, }
"endpoint_is_first_party" : gate.endpoint_is_first_party,
"endpoint_takes_session_credential" : gate
.endpoint_takes_session_credential, }
)),
);
}
@@ -1036,6 +1102,15 @@ impl SessionActor {
.map(|c| c.model)
.unwrap_or_default();
let Some(ref key) = current_key else { return };
// M7: a registry-platform model's key comes from that platform's
// credential resolved into its catalog entry — it is NEVER a
// `[model.*]` block. With the session gate now inactive for every
// API-key platform, those turns all fell through to here and paid a
// `load_effective_config()` disk read PER TURN, then logged a
// permanently false "Model not found in config.toml [model.*]" warning.
if self.model_platform(&current_model_id).is_some() {
return;
}
let Some(new_key) = self.reload_api_key_from_config(&current_model_id) else {
return;
};
@@ -1161,7 +1236,7 @@ mod bearer_resolver_tests {
.oauth()
.expect("xai-grok carries an OAuthConfig");
let grok = crate::auth::oauth_registry::global_manager_for(
&crate::util::kigi_home::kigi_home(),
&crate::auth::oauth_registry::pool_home(),
oauth,
);
assert_ne!(
@@ -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
@@ -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;
}