update
This commit is contained in:
@@ -259,6 +259,46 @@ edges stay deterministic Rust. The harness appends a terminal
|
|||||||
token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
|
token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
|
||||||
`platform.oauth().is_some()` → `platform.base_url()` (kimi-code alone →
|
`platform.oauth().is_some()` → `platform.base_url()` (kimi-code alone →
|
||||||
`proxy_url()`). Tokens/codes/verifiers are NEVER logged.
|
`proxy_url()`). Tokens/codes/verifiers are NEVER logged.
|
||||||
|
- INFERENCE-AUTH INVARIANT (security): a SESSION bearer may ride a request ONLY
|
||||||
|
when the endpoint's platform `uses_oauth()` — `kimi-code` (the primary
|
||||||
|
session) or one of the four subscription-OAuth platforms (their OWN pooled
|
||||||
|
`AuthManager` via `oauth_registry::manager_for_model`, which is why they keep
|
||||||
|
a live `bearer_resolver` and mid-session refresh despite non-first-party base
|
||||||
|
URLs). Every API-key registry platform is refused, because
|
||||||
|
`manager_for_model` falls through to the PRIMARY manager for a non-OAuth key
|
||||||
|
and `SamplingClient::post` REPLACES the request's auth header from the
|
||||||
|
resolver. A model with NO platform (a bare slug / `[model.*]` entry) is
|
||||||
|
decided by the ENDPOINT — `util::is_effective_coding_endpoint_url` (the
|
||||||
|
effective `KIGI_CODE_BASE_URL` deployment, loopback dev proxies, and the
|
||||||
|
compiled production endpoint), never a blanket allow: BYOK is
|
||||||
|
`has_own_credentials()`, which probes `std::env::var` at call time, so a
|
||||||
|
`[model.*]` block with an unset `env_key` classifies `NotByok`. Both are
|
||||||
|
`auth_method::platform_takes_session_credential(platform, base_url)`, the
|
||||||
|
single predicate enforced at BOTH channels that reach the wire — the
|
||||||
|
`bearer_resolver` (outer term of `auth_method::session_token_auth_gate`) and
|
||||||
|
the `api_key` (`MvpAgent::session_token_for_model`,
|
||||||
|
`oauth_registry::session_key_for_endpoint` for the aux / summary /
|
||||||
|
subagent-override paths, and `sampler_turn::aux_bearer_resolver` for the
|
||||||
|
stamped aux configs).
|
||||||
|
- MODEL→PLATFORM LOOKUP (security): `SamplingConfig::model` is the BARE routing
|
||||||
|
slug, and duplicate slugs across platforms are BY DESIGN — an API-key platform
|
||||||
|
and its subscription-OAuth twin list identical ids (`xai`/`xai-grok`,
|
||||||
|
`anthropic`/`claude-pro-max`, `openai`/`openai-codex`), with the API-key
|
||||||
|
platform FIRST in `PlatformId::ALL`. The auth layer therefore resolves the
|
||||||
|
platform from the catalog KEY the picker selected
|
||||||
|
(`ModelsManager::current_model_id`), via
|
||||||
|
`agent::models::managed_key_for_slug`/`platform_for_slug`; anything else falls
|
||||||
|
back to the picker's own `resolve_catalog_key`, and
|
||||||
|
`config::find_model_by_id`'s slug scan takes the LAST match so the two can
|
||||||
|
never disagree. Resolving the wrong twin costs the OAuth platform its live
|
||||||
|
`bearer_resolver` (unrecoverable 401 ~1h in), its Messages adaptation and its
|
||||||
|
Copilot/Codex identity headers.
|
||||||
|
- CATALOG VISIBILITY: `platform_wire_model_to_entry` stamps
|
||||||
|
`supported_in_api = platform != KimiCode`. `ModelInfo::visible_for_auth`
|
||||||
|
reads only the PRIMARY manager's auth mode, so gating the other OAuth
|
||||||
|
platforms on it would hide every model from a user who signed in with ONLY a
|
||||||
|
Claude Pro/Max, ChatGPT, Copilot, or Grok subscription. Only `kimi-code`
|
||||||
|
rides the primary session, so only it may be gated on it.
|
||||||
- Model metadata (context window, thinking levels) comes from the provider
|
- Model metadata (context window, thinking levels) comes from the provider
|
||||||
wire when served; metadata-poor listings are enriched from models.dev
|
wire when served; metadata-poor listings are enriched from models.dev
|
||||||
(`kigi-models/src/enrichment.rs` — bundled raw snapshot regenerated by
|
(`kigi-models/src/enrichment.rs` — bundled raw snapshot regenerated by
|
||||||
|
|||||||
@@ -8,19 +8,14 @@
|
|||||||
autonomous, self-verifying agent loops — planned, parallelized,
|
autonomous, self-verifying agent loops — planned, parallelized,
|
||||||
adversarially verified, and merged back, end to end.</p>
|
adversarially verified, and merged back, end to end.</p>
|
||||||
|
|
||||||
**Kigi** started as an unofficial Kimi Code CLI community build, a
|
**Kigi** is a coding agent that lives in your terminal. It reads the repo,
|
||||||
terminal-based AI coding agent re-targeted at the Kimi Code subscription API,
|
writes the patch, runs the tests, and keeps going while you do something else.
|
||||||
built on the Apache-2.0 sources of
|
Full-screen, headless in CI with `-p`, or docked in your editor over ACP.
|
||||||
[xai-org/grok-build](https://github.com/xai-org/grok-build). It first shipped
|
|
||||||
wired to Kimi Code and the Moonshot open platform. By request, it now works
|
|
||||||
with 25 providers: OpenAI, Anthropic, Google, xAI, Groq, Cerebras, OpenRouter,
|
|
||||||
MiniMax, Z.AI, Qwen, Xiaomi, and more. The full list is under
|
|
||||||
[Providers and API keys](#providers-and-api-keys).
|
|
||||||
|
|
||||||
It runs as a full-screen TUI that understands your codebase, edits files,
|
**Already paying for Claude Pro/Max, ChatGPT Plus/Pro, GitHub Copilot, or
|
||||||
executes shell commands, searches the web, and manages long-running tasks,
|
Grok? Sign in and use it.** No API key, no second bill. Rather bring your own
|
||||||
interactively, headlessly for scripting/CI, or embedded in editors via the
|
key? OpenAI, Anthropic, Google, DeepSeek, Groq, Moonshot and
|
||||||
Agent Client Protocol (ACP).
|
[two dozen more](#providers-and-api-keys) are wired in.
|
||||||
|
|
||||||
[Installation](#installation) ·
|
[Installation](#installation) ·
|
||||||
[Graph engineering](#graph-engineering) ·
|
[Graph engineering](#graph-engineering) ·
|
||||||
@@ -39,10 +34,6 @@ Agent Client Protocol (ACP).
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Prebuilt single-file binaries for macOS (arm64/x86_64), Linux (arm64/x86_64),
|
|
||||||
and Windows (x86_64) are published on
|
|
||||||
[GitHub Releases](https://github.com/ZacharyZhang-NY/Kigi-CLI/releases):
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# macOS / Linux
|
# macOS / Linux
|
||||||
curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.sh | bash
|
||||||
@@ -54,24 +45,19 @@ irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1
|
|||||||
```
|
```
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
kigi --version # kigi 0.1.1 … unofficial Kimi Code CLI community build
|
kigi login # pick a provider, sign in
|
||||||
kigi login # sign in with your Kimi Code subscription (device-code flow)
|
kigi # go
|
||||||
kigi # start the TUI
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer verifies every download against the release's `SHA256SUMS`,
|
Single file, no runtime. macOS and Linux on arm64/x86_64, Windows on x86_64,
|
||||||
installs into `~/.kigi/bin/kigi` (`%USERPROFILE%\.kigi\bin\kigi.exe` on
|
checksummed against the release's `SHA256SUMS`. `kigi update` handles upgrades.
|
||||||
Windows), persists the PATH line for you, and **enables graph engineering
|
|
||||||
by default** (`KIGI_GRAPH=1`; see [Graph engineering](#graph-engineering)
|
|
||||||
to disable). Later releases arrive through the
|
|
||||||
built-in self-updater (`kigi update`, gated by `KIGI_AUTO_UPDATE`), which
|
|
||||||
pulls from the same GitHub Releases feed.
|
|
||||||
|
|
||||||
## Graph engineering
|
## Graph engineering
|
||||||
|
|
||||||
Kigi is the first CLI to ship *graph engineering* as a first-class command:
|
Every other agent runs a loop: think, act, repeat — one thread, one thing at a
|
||||||
where a loop drives one agent, a graph is the programmable organization
|
time. `/graph` runs a dependency graph instead. Work that doesn't block other
|
||||||
connecting many.
|
work happens at the same time, in separate worktrees, and nothing merges until
|
||||||
|
something else has tried to tear it apart.
|
||||||
|
|
||||||
```
|
```
|
||||||
/graph <objective> [--budget <tokens>] # decompose + run fully autonomously
|
/graph <objective> [--budget <tokens>] # decompose + run fully autonomously
|
||||||
@@ -81,49 +67,46 @@ connecting many.
|
|||||||
/graph clear # abandon the graph
|
/graph clear # abandon the graph
|
||||||
```
|
```
|
||||||
|
|
||||||
One `/graph <objective>` runs the whole closed loop: a planner subagent
|
One command runs the whole thing, start to finish:
|
||||||
decomposes the objective into a validated dependency DAG; independent
|
|
||||||
nodes fan out as parallel workers in isolated git worktrees, each gated
|
|
||||||
by an adversarial verifier and merged back three-way; out-of-scope
|
|
||||||
discoveries (`DISCOVERED:`) replan the graph append-only; a topology
|
|
||||||
optimizer prunes false dependencies at plan boundaries; and a terminal
|
|
||||||
verification node re-checks the *whole* objective before the graph
|
|
||||||
completes. State follows your repo in `.kigi/graph.jsonl`, so a fresh
|
|
||||||
session — or a teammate — can `/graph resume` where you left off.
|
|
||||||
|
|
||||||
The installer enables it by default. To disable:
|
- A planner breaks your objective into a dependency DAG, then validates it.
|
||||||
|
- Independent nodes fan out as parallel workers, each in its own git worktree.
|
||||||
|
- Every node has to get past an adversarial verifier before it merges back.
|
||||||
|
- Find something out of scope? Say `DISCOVERED:` and the graph replans —
|
||||||
|
append-only, so nothing already agreed on gets rewritten.
|
||||||
|
- Between passes, a topology optimizer drops dependencies that were never real.
|
||||||
|
- A final node re-checks the *whole* objective before the graph is allowed to
|
||||||
|
call itself done.
|
||||||
|
|
||||||
```sh
|
State lives in `.kigi/graph.jsonl`, next to your code. Close the laptop, come
|
||||||
# macOS / Linux
|
back tomorrow, `/graph resume`. A teammate can pick it up from the same file.
|
||||||
echo 'export KIGI_GRAPH=0' >> ~/.zshrc # or ~/.bashrc / ~/.bash_profile
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
On by default. `KIGI_GRAPH=0` turns it off; `KIGI_GRAPH_CONCURRENCY` (default
|
||||||
# Windows PowerShell
|
3) controls how many nodes run at once.
|
||||||
[Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')
|
|
||||||
```
|
|
||||||
|
|
||||||
(One-off instead: `KIGI_GRAPH=0 kigi`.) Tuning knobs:
|
|
||||||
`KIGI_GRAPH_CONCURRENCY` (parallel nodes, default 3),
|
|
||||||
`KIGI_GRAPH_NODE_ROUNDS` (worker↔verifier rounds per node, default 3),
|
|
||||||
`KIGI_GRAPH_REPLAN_CAP` (replan passes, default 3),
|
|
||||||
`KIGI_GRAPH_OPTIMIZER=0` (disable the optimizer pass).
|
|
||||||
|
|
||||||
## Providers and API keys
|
## Providers and API keys
|
||||||
|
|
||||||
Kigi ships a fixed registry of 25 platforms: the Kimi Code subscription plus
|
29 platforms ship compiled in: 5 you sign into, 24 you hand a key. Nothing is
|
||||||
24 API-key providers. There is no dynamic provider registration; each is a
|
registered at runtime — if it's not in this list, it's not there.
|
||||||
compiled-in spec.
|
|
||||||
|
|
||||||
**Kimi Code** (the original target) uses subscription OAuth, not an API key:
|
**Sign in with a subscription you already pay for.** Run `kigi login` and pick.
|
||||||
|
Each provider's token is stored under its own key, and one provider's
|
||||||
|
credentials are never sent to another.
|
||||||
|
|
||||||
| Platform id | Base URL | Auth |
|
| Platform id | Provider | Sign-in |
|
||||||
| ----------- | -------------------------------- | ------------------------------------------- |
|
| ---------------- | ------------------------- | ------------------------------------------- |
|
||||||
| `kimi-code` | `https://api.kimi.com/coding/v1` | Kimi Code subscription OAuth (`kigi login`) |
|
| `kimi-code` | Kimi Code (original target)| Subscription OAuth (device code) |
|
||||||
|
| `claude-pro-max` | Claude Pro/Max | Subscription OAuth (browser, PKCE) |
|
||||||
|
| `openai-codex` | ChatGPT Plus/Pro (Codex) | Subscription OAuth (browser, PKCE) |
|
||||||
|
| `github-copilot` | GitHub Copilot | Subscription OAuth (device code) |
|
||||||
|
| `xai-grok` | xAI Grok | Subscription OAuth (device code) |
|
||||||
|
|
||||||
**API-key providers.** Set the provider's env var, or put the key in
|
You get whatever models your plan actually serves — the list is fetched at
|
||||||
`~/.kigi/config.toml` under `[platforms.<id>]`. The environment wins, a
|
sign-in, not hardcoded. (ChatGPT/Codex is the exception: its backend publishes
|
||||||
platform-scoped name beats a generic one, and keys are never logged.
|
no model endpoint, so those four are compiled in.)
|
||||||
|
|
||||||
|
**API-key providers.** Export the env var, or drop the key in
|
||||||
|
`~/.kigi/config.toml`. Keys are never logged.
|
||||||
|
|
||||||
| Provider | Platform id | API key env |
|
| Provider | Platform id | API key env |
|
||||||
| ------------------------- | ---------------------- | ----------------------------------------------- |
|
| ------------------------- | ---------------------- | ----------------------------------------------- |
|
||||||
@@ -166,24 +149,11 @@ api_key = "sk-..."
|
|||||||
api_key = "xai-..."
|
api_key = "xai-..."
|
||||||
```
|
```
|
||||||
|
|
||||||
On login and on startup Kigi syncs each configured platform's model list
|
Model lists sync on startup. Pick one with `/model`, set its thinking level
|
||||||
from `GET {base}/models` and shows the merged catalog in the model picker
|
with `/effort`.
|
||||||
(catalog keys are `{platform_id}/{model_id}`). Model metadata (context
|
|
||||||
window, thinking levels) comes from the live listing when the provider
|
|
||||||
serves it, otherwise from a bundled models.dev snapshot. Models that
|
|
||||||
advertise selectable thinking levels (e.g. K3's `low`/`high`/`max`) expose
|
|
||||||
them in `/model` and `/effort`. If the sync fails, the last cached catalog is
|
|
||||||
used; with no cache, a small built-in fallback list applies. Model selection
|
|
||||||
resolves as `--model` CLI flag > `KIGI_DEFAULT_MODEL` > `[models] default`
|
|
||||||
in config.toml > server-delivered list > built-in fallback.
|
|
||||||
|
|
||||||
Each platform's base URL can be re-pointed for dev/test with
|
Web `search`/`fetch` need a Kimi Code subscription; API-key sessions run
|
||||||
`KIGI_<PLATFORM>_BASE_URL` (e.g. `KIGI_CODE_BASE_URL`,
|
without them, same as the official client.
|
||||||
`KIGI_MOONSHOT_CN_BASE_URL`, `KIGI_OPENAI_BASE_URL`).
|
|
||||||
|
|
||||||
The web `search`/`fetch` tools ride the Kimi Code subscription services and
|
|
||||||
are present only on OAuth sessions. API-key-only sessions run without them,
|
|
||||||
matching the official client.
|
|
||||||
|
|
||||||
## Building from source
|
## Building from source
|
||||||
|
|
||||||
@@ -199,19 +169,15 @@ launcher at `bin/protoc`; install dotslash (`brew install dotslash` or
|
|||||||
|
|
||||||
## Coexistence with the official Kimi CLI
|
## Coexistence with the official Kimi CLI
|
||||||
|
|
||||||
Kigi is not affiliated with Moonshot AI or xAI, and it coexists with the
|
Kigi started as an unofficial Kimi Code CLI — a community fork of
|
||||||
official `kimi` CLI on the same machine: independent binary name,
|
[xai-org/grok-build](https://github.com/xai-org/grok-build), not affiliated
|
||||||
independent config directory (`~/.kigi`), independent keyring credentials
|
with Moonshot AI or xAI. It keeps its own binary, its own `~/.kigi`, its own
|
||||||
(service `kigi`), and a `KIGI_*` environment-variable namespace. Nothing
|
keyring entry, and its own `KIGI_*` env vars, and never touches what the
|
||||||
the official client installs or stores is ever read at runtime or written.
|
official `kimi` CLI installed. `kigi import-kimi` copies your old config over
|
||||||
On first launch Kigi offers a **one-time, strictly read-only** import of
|
once, read-only.
|
||||||
your existing `~/.kimi` configuration (MCP servers, custom providers,
|
|
||||||
default model) via `kigi import-kimi` — file contents and mtimes under
|
|
||||||
`~/.kimi` are left untouched, verified by tests.
|
|
||||||
|
|
||||||
Kigi is **zero-telemetry**: the only outbound connections are the
|
**Zero telemetry.** It talks to the APIs you configured, GitHub Releases, and
|
||||||
inference/auth APIs you configure, GitHub Releases for updates, and MCP
|
your own MCP servers. Nothing else.
|
||||||
servers you add.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -302,14 +302,26 @@ impl ModelByok {
|
|||||||
/// buffered token on every turn and 401s with `bad-credentials` until restart.
|
/// buffered token on every turn and 401s with `bad-credentials` until restart.
|
||||||
/// It refreshes when `endpoint_is_first_party` — the request targets the
|
/// It refreshes when `endpoint_is_first_party` — the request targets the
|
||||||
/// first-party API, where sending the session token cannot leak to a
|
/// first-party API, where sending the session token cannot leak to a
|
||||||
/// third-party BYOK endpoint. A definite `NotByok` always refreshes (it only
|
/// third-party BYOK endpoint. A definite `NotByok` refreshes (within the
|
||||||
/// ever routes to the session endpoint); a definite `Byok` never does.
|
/// session's own providers); a definite `Byok` never does.
|
||||||
|
///
|
||||||
|
/// `endpoint_takes_session_credential` ([`platform_takes_session_credential`])
|
||||||
|
/// is the outer, non-negotiable guard and the reason the `NotByok` arm is safe:
|
||||||
|
/// it is `false` for every API-key registry platform (deepseek, openai,
|
||||||
|
/// anthropic, moonshot-*, …), whose models classify `NotByok` (they carry no
|
||||||
|
/// `[model.*]` key) yet route to a THIRD-PARTY inference host while
|
||||||
|
/// `oauth_registry::manager_for_model` falls through to the primary (Kimi)
|
||||||
|
/// manager. Without this term the mainstream "session auth + API-key-platform
|
||||||
|
/// model" configuration stamps the user's Kimi subscription bearer on every
|
||||||
|
/// request to that host.
|
||||||
pub fn session_token_auth_gate(
|
pub fn session_token_auth_gate(
|
||||||
is_session_based_method: bool,
|
is_session_based_method: bool,
|
||||||
model_byok: ModelByok,
|
model_byok: ModelByok,
|
||||||
endpoint_is_first_party: bool,
|
endpoint_is_first_party: bool,
|
||||||
|
endpoint_takes_session_credential: bool,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
is_session_based_method
|
is_session_based_method
|
||||||
|
&& endpoint_takes_session_credential
|
||||||
&& match model_byok {
|
&& match model_byok {
|
||||||
ModelByok::NotByok => true,
|
ModelByok::NotByok => true,
|
||||||
ModelByok::Byok => false,
|
ModelByok::Byok => false,
|
||||||
@@ -317,6 +329,42 @@ pub fn session_token_auth_gate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a session bearer may EVER be stamped on a request routed to
|
||||||
|
/// `platform` at `base_url` — i.e. whether the credential
|
||||||
|
/// `oauth_registry::manager_for_model` resolves for such a model belongs to the
|
||||||
|
/// host that receives it.
|
||||||
|
///
|
||||||
|
/// - a `uses_oauth` platform: `kimi-code` rides the PRIMARY session; each of the
|
||||||
|
/// four subscription-OAuth platforms (claude-pro-max, openai-codex,
|
||||||
|
/// github-copilot, xai-grok) rides its OWN pooled `AuthManager`. In both cases
|
||||||
|
/// the resolved bearer belongs to the host being called, and mid-session
|
||||||
|
/// refresh/401-recovery must stay live — so this is `true` even though those
|
||||||
|
/// four have non-first-party base URLs.
|
||||||
|
/// - every API-key registry platform: its credential is that platform's API key
|
||||||
|
/// (resolved into the catalog entry), never a session bearer, and
|
||||||
|
/// `manager_for_model` has no platform manager to route to. `false`.
|
||||||
|
/// - `None` — a bare slug or a `[model.*]` config entry, which carries no
|
||||||
|
/// platform at all — is decided by the ENDPOINT, never blanket-allowed: BYOK
|
||||||
|
/// is `has_own_credentials()`, which probes `std::env::var` at call time, so a
|
||||||
|
/// `[model.gpt-4o]` block with `base_url = "https://api.openai.com/v1"` and an
|
||||||
|
/// unset / mistyped `env_key` classifies `NotByok` and would otherwise hand
|
||||||
|
/// the Kimi subscription bearer to `api.openai.com`. Only the SESSION's own
|
||||||
|
/// coding endpoint qualifies: [`crate::util::is_effective_coding_endpoint_url`]
|
||||||
|
/// = the *effective* `KIGI_CODE_BASE_URL` deployment (so custom Kimi
|
||||||
|
/// deployments keep the session bearer) plus loopback (local dev proxies and
|
||||||
|
/// test mocks) plus the compiled production endpoint. Deliberately NOT
|
||||||
|
/// `is_first_party_url`, which is production-only and would break every
|
||||||
|
/// `KIGI_CODE_BASE_URL` deployment.
|
||||||
|
pub fn platform_takes_session_credential(
|
||||||
|
platform: Option<kigi_models::PlatformId>,
|
||||||
|
base_url: &str,
|
||||||
|
) -> bool {
|
||||||
|
match platform {
|
||||||
|
Some(platform) => platform.uses_oauth(),
|
||||||
|
None => crate::util::is_effective_coding_endpoint_url(base_url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const AUTH_ERROR_SESSION_EXPIRED: &str =
|
pub const AUTH_ERROR_SESSION_EXPIRED: &str =
|
||||||
"Session expired. Run `kigi login` to re-authenticate.";
|
"Session expired. Run `kigi login` to re-authenticate.";
|
||||||
|
|
||||||
@@ -683,15 +731,117 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_token_auth_gate_matrix() {
|
fn session_token_auth_gate_matrix() {
|
||||||
// Session method + NotByok → refresh.
|
// Session method + NotByok → refresh (endpoint takes the session cred).
|
||||||
assert!(session_token_auth_gate(true, ModelByok::NotByok, false));
|
assert!(session_token_auth_gate(
|
||||||
|
true,
|
||||||
|
ModelByok::NotByok,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
// Session method + Byok → never.
|
// Session method + Byok → never.
|
||||||
assert!(!session_token_auth_gate(true, ModelByok::Byok, true));
|
assert!(!session_token_auth_gate(true, ModelByok::Byok, true, true));
|
||||||
// Session method + Unknown → only on first-party endpoints.
|
// Session method + Unknown → only on first-party endpoints.
|
||||||
assert!(session_token_auth_gate(true, ModelByok::Unknown, true));
|
assert!(session_token_auth_gate(
|
||||||
assert!(!session_token_auth_gate(true, ModelByok::Unknown, false));
|
true,
|
||||||
|
ModelByok::Unknown,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!session_token_auth_gate(
|
||||||
|
true,
|
||||||
|
ModelByok::Unknown,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
// Non-session method → never.
|
// Non-session method → never.
|
||||||
assert!(!session_token_auth_gate(false, ModelByok::NotByok, true));
|
assert!(!session_token_auth_gate(
|
||||||
|
false,
|
||||||
|
ModelByok::NotByok,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
// An endpoint that does not take the session credential (every API-key
|
||||||
|
// registry platform) is refused on EVERY arm — this is the outer guard
|
||||||
|
// that keeps the Kimi subscription bearer off third-party hosts.
|
||||||
|
for byok in [ModelByok::NotByok, ModelByok::Byok, ModelByok::Unknown] {
|
||||||
|
for first_party in [false, true] {
|
||||||
|
assert!(
|
||||||
|
!session_token_auth_gate(true, byok, first_party, false),
|
||||||
|
"byok={byok:?} first_party={first_party}: an API-key-platform \
|
||||||
|
endpoint must never receive a session bearer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The platform → "may a session bearer ride here?" classification.
|
||||||
|
/// `kimi-code` rides the PRIMARY session; the four subscription-OAuth
|
||||||
|
/// platforms ride their OWN pooled managers (so they keep a live
|
||||||
|
/// bearer_resolver despite non-first-party base URLs); every API-key
|
||||||
|
/// registry platform is refused, whatever the endpoint.
|
||||||
|
#[test]
|
||||||
|
fn platform_takes_session_credential_matrix() {
|
||||||
|
let first_party = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url;
|
||||||
|
for id in [
|
||||||
|
"kimi-code",
|
||||||
|
"claude-pro-max",
|
||||||
|
"openai-codex",
|
||||||
|
"github-copilot",
|
||||||
|
"xai-grok",
|
||||||
|
] {
|
||||||
|
let platform = kigi_models::PlatformId::parse(id).expect("known platform");
|
||||||
|
for url in [first_party, "https://api.anthropic.com/v1"] {
|
||||||
|
assert!(
|
||||||
|
platform_takes_session_credential(Some(platform), url),
|
||||||
|
"{id} rides a session credential (primary or its own pool)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for platform in kigi_models::PlatformId::ALL {
|
||||||
|
if platform.uses_oauth() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for url in [first_party, "https://api.deepseek.com/v1"] {
|
||||||
|
assert!(
|
||||||
|
!platform_takes_session_credential(Some(platform), url),
|
||||||
|
"{} is an API-key platform — no session bearer may ride to it",
|
||||||
|
platform.as_str()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C2 regression: a platform-less model (a bare slug or a `[model.*]` entry)
|
||||||
|
/// is decided by the ENDPOINT, never blanket-allowed. A `[model.gpt-4o]`
|
||||||
|
/// block whose `env_key` is unset classifies `NotByok`, so before this the
|
||||||
|
/// `None` arm handed the Kimi subscription bearer to `api.openai.com`.
|
||||||
|
/// Custom `KIGI_CODE_BASE_URL` deployments and local dev proxies must still
|
||||||
|
/// keep it (that is why the predicate is not `is_first_party_url`).
|
||||||
|
#[test]
|
||||||
|
fn platform_less_model_takes_the_session_credential_only_on_its_own_endpoint() {
|
||||||
|
for url in [
|
||||||
|
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||||||
|
"http://127.0.0.1:8080/v1",
|
||||||
|
"http://localhost:3000/v1",
|
||||||
|
"http://[::1]:9000/v1",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
platform_takes_session_credential(None, url),
|
||||||
|
"{url} is the session's own endpoint (or a local proxy) — unchanged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for url in [
|
||||||
|
"https://api.openai.com/v1",
|
||||||
|
"https://api.deepseek.com/v1",
|
||||||
|
"https://api.anthropic.com/v1",
|
||||||
|
"https://api.moonshot.cn/v1",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!platform_takes_session_credential(None, url),
|
||||||
|
"LEAK: {url} is a third-party host — no session bearer may ride there"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RAII guard restoring an env var on drop (panic-safe).
|
/// RAII guard restoring an env var on drop (panic-safe).
|
||||||
|
|||||||
@@ -2779,13 +2779,20 @@ pub fn default_model_entries(endpoints: &EndpointsConfig) -> IndexMap<String, Mo
|
|||||||
}
|
}
|
||||||
/// Resolve a model against the available model map.
|
/// Resolve a model against the available model map.
|
||||||
/// Checks the map key (id) first, then falls back to a slug scan.
|
/// Checks the map key (id) first, then falls back to a slug scan.
|
||||||
|
///
|
||||||
|
/// The slug scan takes the LAST match, exactly like the picker's
|
||||||
|
/// [`crate::agent::models::resolve_catalog_key`]. Duplicate slugs across
|
||||||
|
/// platforms are by design (`moonshot-cn` and `moonshot-ai` list the same ids;
|
||||||
|
/// so do every API-key platform and its subscription-OAuth twin), and a
|
||||||
|
/// first-match scan here made the auth layer resolve a DIFFERENT entry than the
|
||||||
|
/// one the picker selected — the H5 slug collision. One direction, one answer.
|
||||||
pub fn find_model_by_id<'a>(
|
pub fn find_model_by_id<'a>(
|
||||||
models: &'a IndexMap<String, ModelEntry>,
|
models: &'a IndexMap<String, ModelEntry>,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
) -> Option<&'a ModelEntry> {
|
) -> Option<&'a ModelEntry> {
|
||||||
models
|
models
|
||||||
.get(model_id)
|
.get(model_id)
|
||||||
.or_else(|| models.values().find(|m| m.model == model_id))
|
.or_else(|| models.values().rev().find(|m| m.model == model_id))
|
||||||
}
|
}
|
||||||
/// Whether the EFFECTIVE Auto-mode classifier model supports reasoning effort:
|
/// Whether the EFFECTIVE Auto-mode classifier model supports reasoning effort:
|
||||||
/// the model actually routed to (`aux_model` when the aux sampler resolved) else
|
/// the model actually routed to (`aux_model` when the aux sampler resolved) else
|
||||||
@@ -4643,14 +4650,22 @@ reasoning_effort = "low"
|
|||||||
});
|
});
|
||||||
(dir, manager)
|
(dir, manager)
|
||||||
}
|
}
|
||||||
/// LEAK 1a (aux/summary model): the aux `session_key` is resolved by the aux
|
/// LEAK 1a (aux/summary model `api_key` channel): the aux `session_key` is
|
||||||
/// model's OWN platform (as `build_summary_client` / `resolve_aux_sampler_config`
|
/// resolved by the aux model's OWN platform AND endpoint (as
|
||||||
/// now do). A grok (oauth-platform) aux model's resolved sampler `api_key` is
|
/// `build_summary_client` / `resolve_aux_sampler_config` now do), so it is
|
||||||
/// therefore grok's own token or `None` — NEVER the primary Kimi key — while a
|
/// never the primary Kimi key on a host that does not own it.
|
||||||
/// first-party / non-oauth aux model still gets the primary (byte-identical).
|
///
|
||||||
|
/// - a grok (oauth-platform) aux model → grok's own pooled token or `None`;
|
||||||
|
/// - a `moonshot-cn` (API-key platform) aux model → NO session key at all
|
||||||
|
/// (pre-fix it received `kimi-tok`, which `resolve_credentials` then
|
||||||
|
/// stamped as the `api_key` on an `api.moonshot.cn` request);
|
||||||
|
/// - a `kimi-code` aux model → the primary, byte-identical.
|
||||||
|
///
|
||||||
|
/// Revert-to-red: dropping the `platform_takes_session_credential` term
|
||||||
|
/// from `session_key_for_endpoint` makes the moonshot assertion see
|
||||||
|
/// `Some("kimi-tok")`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn aux_model_session_key_is_platform_scoped_never_leaking_kimi() {
|
async fn aux_model_session_key_is_platform_scoped_never_leaking_kimi() {
|
||||||
let home = tempfile::tempdir().unwrap();
|
|
||||||
let (_kd, kimi) = kimi_primary("kimi-tok");
|
let (_kd, kimi) = kimi_primary("kimi-tok");
|
||||||
let endpoints = EndpointsConfig::default();
|
let endpoints = EndpointsConfig::default();
|
||||||
// A grok aux catalog entry (managed id → oauth platform), no own key.
|
// A grok aux catalog entry (managed id → oauth platform), no own key.
|
||||||
@@ -4658,9 +4673,9 @@ reasoning_effort = "low"
|
|||||||
grok.info.id = Some("xai-grok/grok-4-latest".to_string());
|
grok.info.id = Some("xai-grok/grok-4-latest".to_string());
|
||||||
let mut grok_catalog = IndexMap::new();
|
let mut grok_catalog = IndexMap::new();
|
||||||
grok_catalog.insert("grok".to_string(), grok);
|
grok_catalog.insert("grok".to_string(), grok);
|
||||||
let grok_key = crate::auth::oauth_registry::session_key_for_model(
|
let grok_key = crate::auth::oauth_registry::session_key_for_catalog_model(
|
||||||
home.path(),
|
&grok_catalog,
|
||||||
"xai-grok/grok-4-latest",
|
"grok",
|
||||||
Some(&kimi),
|
Some(&kimi),
|
||||||
);
|
);
|
||||||
let grok_cfg = resolve_aux_model_sampling_config(
|
let grok_cfg = resolve_aux_model_sampling_config(
|
||||||
@@ -4675,10 +4690,11 @@ reasoning_effort = "low"
|
|||||||
Some("kimi-tok"),
|
Some("kimi-tok"),
|
||||||
"a grok aux model must never receive the primary Kimi session token",
|
"a grok aux model must never receive the primary Kimi session token",
|
||||||
);
|
);
|
||||||
// A non-oauth aux catalog entry still resolves to the primary token.
|
// An API-key registry platform gets NO session key — the pre-fix
|
||||||
|
// behaviour handed it the primary Kimi token on api.moonshot.cn.
|
||||||
let mut k2 = test_model_entry(
|
let mut k2 = test_model_entry(
|
||||||
"kimi-k2-0905-preview",
|
"kimi-k2-0905-preview",
|
||||||
"https://vendor/v1",
|
"https://api.moonshot.cn/v1",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -4686,23 +4702,43 @@ reasoning_effort = "low"
|
|||||||
k2.info.id = Some("moonshot-cn/kimi-k2".to_string());
|
k2.info.id = Some("moonshot-cn/kimi-k2".to_string());
|
||||||
let mut k2_catalog = IndexMap::new();
|
let mut k2_catalog = IndexMap::new();
|
||||||
k2_catalog.insert("k2".to_string(), k2);
|
k2_catalog.insert("k2".to_string(), k2);
|
||||||
let k2_key = crate::auth::oauth_registry::session_key_for_model(
|
assert_eq!(
|
||||||
home.path(),
|
crate::auth::oauth_registry::session_key_for_catalog_model(
|
||||||
"moonshot-cn/kimi-k2",
|
&k2_catalog,
|
||||||
|
"k2",
|
||||||
|
Some(&kimi),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
"LEAK: an API-key-platform aux model must receive no session token",
|
||||||
|
);
|
||||||
|
// The first-party subscription channel is byte-identical.
|
||||||
|
let mut kimi_code = test_model_entry(
|
||||||
|
"kimi-for-coding",
|
||||||
|
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
kimi_code.info.id = Some("kimi-code/kimi-for-coding".to_string());
|
||||||
|
let mut kimi_catalog = IndexMap::new();
|
||||||
|
kimi_catalog.insert("kimi-for-coding".to_string(), kimi_code);
|
||||||
|
let kimi_key = crate::auth::oauth_registry::session_key_for_catalog_model(
|
||||||
|
&kimi_catalog,
|
||||||
|
"kimi-for-coding",
|
||||||
Some(&kimi),
|
Some(&kimi),
|
||||||
);
|
);
|
||||||
let k2_cfg = resolve_aux_model_sampling_config(
|
let kimi_cfg = resolve_aux_model_sampling_config(
|
||||||
"k2",
|
"kimi-for-coding",
|
||||||
&k2_catalog,
|
&kimi_catalog,
|
||||||
&endpoints,
|
&endpoints,
|
||||||
k2_key.as_deref(),
|
kimi_key.as_deref(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.expect("non-oauth aux resolves via the primary session token");
|
.expect("a kimi-code aux model resolves via the primary session token");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
k2_cfg.api_key.as_deref(),
|
kimi_cfg.api_key.as_deref(),
|
||||||
Some("kimi-tok"),
|
Some("kimi-tok"),
|
||||||
"a non-oauth aux model must still receive the primary session token",
|
"the first-party subscription aux path must be byte-identical",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1917,6 +1917,51 @@ pub(crate) fn resolve_catalog_key(
|
|||||||
.map(|(key, _)| acp::ModelId::new(key.clone()))
|
.map(|(key, _)| acp::ModelId::new(key.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The managed catalog key (`{platform}/{model}`) a routing slug belongs to.
|
||||||
|
///
|
||||||
|
/// H5: `SamplingConfig::model` is the BARE routing slug, never the catalog key,
|
||||||
|
/// and duplicate slugs across platforms are BY DESIGN — the registry guarantees
|
||||||
|
/// an API-key platform and its subscription-OAuth twin list the SAME ids
|
||||||
|
/// (`xai`/`xai-grok`, `anthropic`/`claude-pro-max`, `openai`/`openai-codex`),
|
||||||
|
/// and `PlatformId::ALL` orders every API-key platform FIRST. A slug scan
|
||||||
|
/// therefore resolves the WRONG platform for a user holding both credentials:
|
||||||
|
/// the OAuth twin loses its live `bearer_resolver` (no mid-session refresh → an
|
||||||
|
/// unrecoverable 401 ~1h in), its Messages adaptation and its Copilot/Codex
|
||||||
|
/// identity headers.
|
||||||
|
///
|
||||||
|
/// `current_key` — [`ModelsManager::current_model_id`], the catalog key the
|
||||||
|
/// picker actually selected — is therefore authoritative whenever it names this
|
||||||
|
/// slug. Anything else (aux models, subagent overrides, unlisted slugs) falls
|
||||||
|
/// back to the picker's OWN lookup, [`resolve_catalog_key`], so the auth layer
|
||||||
|
/// and the picker can never resolve different entries.
|
||||||
|
pub(crate) fn managed_key_for_slug(
|
||||||
|
models: &IndexMap<String, ModelEntry>,
|
||||||
|
current_key: Option<&str>,
|
||||||
|
slug: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
if let Some(entry) = current_key.and_then(|key| models.get(key))
|
||||||
|
&& (entry.info.model == slug || current_key == Some(slug))
|
||||||
|
{
|
||||||
|
return entry.info.id.clone();
|
||||||
|
}
|
||||||
|
let key = resolve_catalog_key(models, &acp::ModelId::new(slug.to_string()))?;
|
||||||
|
models.get(key.0.as_ref())?.info.id.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registry platform a routing slug resolves to, via [`managed_key_for_slug`].
|
||||||
|
/// `None` for a bare / `[model.*]` / unlisted model. Single definition shared by
|
||||||
|
/// the session actor's inference-auth chokepoints and the aux/summary paths, so
|
||||||
|
/// the gate, the manager and the wire adaptations can never disagree.
|
||||||
|
pub(crate) fn platform_for_slug(
|
||||||
|
models: &IndexMap<String, ModelEntry>,
|
||||||
|
current_key: Option<&str>,
|
||||||
|
slug: &str,
|
||||||
|
) -> Option<kigi_models::PlatformId> {
|
||||||
|
let key = managed_key_for_slug(models, current_key, slug);
|
||||||
|
kigi_models::parse_managed_model_key(key.as_deref().unwrap_or(slug))
|
||||||
|
.map(|(platform, _)| platform)
|
||||||
|
}
|
||||||
|
|
||||||
/// Catalog key for a persisted session model id, restricted to **selectable**
|
/// Catalog key for a persisted session model id, restricted to **selectable**
|
||||||
/// entries. A selectable exact-key match wins (as in [`resolve_catalog_key`]);
|
/// entries. A selectable exact-key match wins (as in [`resolve_catalog_key`]);
|
||||||
/// otherwise the last selectable entry whose routing slug matches `id`, so a
|
/// otherwise the last selectable entry whose routing slug matches `id`, so a
|
||||||
@@ -3668,6 +3713,52 @@ mod tests {
|
|||||||
assert!(!info.visible_for_auth(false));
|
assert!(!info.visible_for_auth(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SHIP-BLOCKER regression: a user who signed in with ONLY a Claude Pro/Max
|
||||||
|
/// subscription has no PRIMARY (Kimi) session, so `is_session_auth()` is
|
||||||
|
/// false. Stamping `supported_in_api = !uses_oauth()` therefore hid every
|
||||||
|
/// one of their models — `available()` returned an empty picker and the
|
||||||
|
/// whole subscription-OAuth feature was dead for its target user. The
|
||||||
|
/// claude-pro-max entry must be visible with no primary session at all.
|
||||||
|
#[test]
|
||||||
|
fn claude_pro_max_only_user_sees_their_models_in_the_picker() {
|
||||||
|
let wire: kigi_models::WireModel =
|
||||||
|
serde_json::from_value(serde_json::json!({ "id": "claude-opus-4-8" }))
|
||||||
|
.expect("wire model fixture");
|
||||||
|
let entry_config = crate::agent::models_fetch::platform_wire_model_to_entry(
|
||||||
|
kigi_models::PlatformId::ClaudeProMax,
|
||||||
|
wire,
|
||||||
|
"https://api.anthropic.com/v1",
|
||||||
|
);
|
||||||
|
let entry = ModelEntry::from_config_entry(&entry_config);
|
||||||
|
let key = entry
|
||||||
|
.info
|
||||||
|
.id
|
||||||
|
.clone()
|
||||||
|
.expect("platform entries carry a managed catalog key");
|
||||||
|
assert_eq!(key, "claude-pro-max/claude-opus-4-8");
|
||||||
|
let mut catalog = IndexMap::new();
|
||||||
|
catalog.insert(key.clone(), entry);
|
||||||
|
|
||||||
|
// Empty home ⇒ the primary AuthManager holds no credential at all.
|
||||||
|
let home = tempfile::tempdir().expect("tempdir");
|
||||||
|
let mgr = ModelsManager::new(
|
||||||
|
None,
|
||||||
|
catalog,
|
||||||
|
acp::ModelId::new(Arc::from(key.clone())),
|
||||||
|
Arc::new(AuthManager::new(home.path(), KimiCodeConfig::default())),
|
||||||
|
config::Config::default(),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!mgr.is_session_auth(),
|
||||||
|
"a claude-pro-max-only user has no PRIMARY (Kimi) OAuth session"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mgr.available()
|
||||||
|
.contains_key(&acp::ModelId::new(Arc::from(key.clone()))),
|
||||||
|
"the claude-pro-max model must reach the picker without a primary session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── duplicate model slug re-keying (A/B experiment "auto" alias) ──
|
// ── duplicate model slug re-keying (A/B experiment "auto" alias) ──
|
||||||
|
|
||||||
fn make_entry_config(model: &str, name: Option<&str>) -> config::ModelEntryConfig {
|
fn make_entry_config(model: &str, name: Option<&str>) -> config::ModelEntryConfig {
|
||||||
|
|||||||
@@ -645,9 +645,15 @@ pub(crate) fn platform_wire_model_to_entry(
|
|||||||
inference_idle_timeout_secs: None,
|
inference_idle_timeout_secs: None,
|
||||||
max_retries: None,
|
max_retries: None,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
// Subscription models require the OAuth session; open-platform
|
// `supported_in_api: false` hides a model unless the PRIMARY session is
|
||||||
// models are usable by API-key users.
|
// an OAuth session (`ModelInfo::visible_for_auth`). Only `kimi-code`
|
||||||
supported_in_api: !platform.uses_oauth(),
|
// rides that primary session, so only it may be gated on it. Every
|
||||||
|
// other OAuth platform (claude-pro-max, openai-codex, github-copilot,
|
||||||
|
// xai-grok) carries its OWN pooled credential, and its models only
|
||||||
|
// enter the catalog once THAT provider is signed in — gating them on
|
||||||
|
// the Kimi session would hide every model from a user who signed in
|
||||||
|
// with only a Claude/ChatGPT/Copilot/Grok subscription.
|
||||||
|
supported_in_api: platform != kigi_models::PlatformId::KimiCode,
|
||||||
supports_backend_search: false,
|
supports_backend_search: false,
|
||||||
compactions_remaining: None,
|
compactions_remaining: None,
|
||||||
compaction_at_tokens: None,
|
compaction_at_tokens: None,
|
||||||
@@ -898,6 +904,16 @@ fn get_string_map(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Whether a freshly-fetched platform entry shows up in the picker for a
|
||||||
|
/// user whose PRIMARY session is NOT an OAuth session (`is_session_auth ==
|
||||||
|
/// false`): an API-key user, or — the case that made this a ship-blocker —
|
||||||
|
/// someone who signed in with ONLY a Claude Pro/Max, ChatGPT, Copilot, or
|
||||||
|
/// Grok subscription. `ModelInfo::visible_for_auth` is the picker's real
|
||||||
|
/// predicate (`agent/models.rs` → `available()`).
|
||||||
|
fn visible_to_non_primary_session_user(entry: &crate::agent::config::ModelEntryConfig) -> bool {
|
||||||
|
crate::agent::config::ModelEntry::from_config_entry(entry).visible_for_auth(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// OpenAI-cycle e2e (mock wire): a polluted bare-id `/models` listing +
|
/// OpenAI-cycle e2e (mock wire): a polluted bare-id `/models` listing +
|
||||||
/// a models.dev refresh produce a catalog with ONLY chat models, enriched
|
/// a models.dev refresh produce a catalog with ONLY chat models, enriched
|
||||||
/// context windows / efforts, and the Responses backend — the full
|
/// context windows / efforts, and the Responses backend — the full
|
||||||
@@ -1277,8 +1293,14 @@ mod tests {
|
|||||||
"enrichment fills the context window from models.dev anthropic"
|
"enrichment fills the context window from models.dev anthropic"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!opus.supported_in_api,
|
opus.supported_in_api,
|
||||||
"subscription (uses_oauth) models require the OAuth session"
|
"claude-pro-max carries its OWN pooled credential — it must NOT be \
|
||||||
|
gated on the primary (Kimi) session"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
visible_to_non_primary_session_user(opus),
|
||||||
|
"a Claude-Pro/Max-only user has no primary OAuth session; their \
|
||||||
|
models must still appear in the picker"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1409,8 +1431,14 @@ mod tests {
|
|||||||
"context window comes from models.dev github-copilot enrichment"
|
"context window comes from models.dev github-copilot enrichment"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!entry.supported_in_api,
|
entry.supported_in_api,
|
||||||
"subscription (uses_oauth) models require the OAuth session"
|
"github-copilot carries its OWN pooled credential — it must NOT be \
|
||||||
|
gated on the primary (Kimi) session"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
visible_to_non_primary_session_user(entry),
|
||||||
|
"a Copilot-only user has no primary OAuth session; their models \
|
||||||
|
must still appear in the picker"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1484,8 +1512,14 @@ mod tests {
|
|||||||
assert_eq!(sol.context_window.get(), 272_000);
|
assert_eq!(sol.context_window.get(), 272_000);
|
||||||
assert_eq!(sol.name.as_deref(), Some("GPT-5.6-Sol"));
|
assert_eq!(sol.name.as_deref(), Some("GPT-5.6-Sol"));
|
||||||
assert!(
|
assert!(
|
||||||
!sol.supported_in_api,
|
sol.supported_in_api,
|
||||||
"subscription (uses_oauth) models require the OAuth session"
|
"openai-codex carries its OWN pooled credential — it must NOT be \
|
||||||
|
gated on the primary (Kimi) session"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
visible_to_non_primary_session_user(sol),
|
||||||
|
"a ChatGPT/Codex-only user has no primary OAuth session; their \
|
||||||
|
models must still appear in the picker"
|
||||||
);
|
);
|
||||||
assert!(sol.supports_reasoning_effort);
|
assert!(sol.supports_reasoning_effort);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -3563,8 +3597,14 @@ mod tests {
|
|||||||
"an OAuth channel carries no api-key env"
|
"an OAuth channel carries no api-key env"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!entry.supported_in_api,
|
entry.supported_in_api,
|
||||||
"subscription models require the OAuth session (not the public API)"
|
"xai-grok carries its OWN pooled credential — it must NOT be gated \
|
||||||
|
on the primary (Kimi) session"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
visible_to_non_primary_session_user(entry),
|
||||||
|
"a Grok-only user has no primary OAuth session; their models must \
|
||||||
|
still appear in the picker"
|
||||||
);
|
);
|
||||||
// Passthrough dialect (identical to the API-key xai wire).
|
// Passthrough dialect (identical to the API-key xai wire).
|
||||||
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
|
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
|
||||||
|
|||||||
@@ -25,17 +25,19 @@ impl MvpAgent {
|
|||||||
primary: &SamplingConfig,
|
primary: &SamplingConfig,
|
||||||
) -> Result<(OaiCompatClient, String), acp::Error> {
|
) -> Result<(OaiCompatClient, String), acp::Error> {
|
||||||
let slug = self.resolve_session_summary_model();
|
let slug = self.resolve_session_summary_model();
|
||||||
// Resolve the aux token by the summary model's OWN platform: a grok
|
let models = self.models_manager.models();
|
||||||
// (oauth-platform) summary model draws its pooled grok token or `None`
|
// Resolve the aux token by the summary model's OWN platform AND
|
||||||
// — NEVER the primary Kimi session token (which `resolve_credentials`
|
// endpoint: a grok (oauth-platform) summary model draws its pooled grok
|
||||||
// would otherwise stamp onto an api.x.ai request). A first-party /
|
// token or `None`, and an API-key registry platform draws NOTHING —
|
||||||
// non-oauth summary model still gets the primary (byte-identical).
|
// NEVER the primary Kimi session token (which `resolve_credentials`
|
||||||
let session_key = crate::auth::oauth_registry::session_key_for_model(
|
// would otherwise stamp onto an api.x.ai / api.deepseek.com request).
|
||||||
&crate::util::kigi_home::kigi_home(),
|
// 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,
|
&slug,
|
||||||
Some(&self.auth_manager),
|
Some(&self.auth_manager),
|
||||||
);
|
);
|
||||||
let models = self.models_manager.models();
|
|
||||||
let endpoints = self.models_manager.endpoints();
|
let endpoints = self.models_manager.endpoints();
|
||||||
let alpha_test_key = self.cfg.borrow().endpoints.alpha_test_key.clone();
|
let alpha_test_key = self.cfg.borrow().endpoints.alpha_test_key.clone();
|
||||||
let config = match crate::agent::config::resolve_aux_model_sampling_config(
|
let config = match crate::agent::config::resolve_aux_model_sampling_config(
|
||||||
@@ -47,7 +49,17 @@ impl MvpAgent {
|
|||||||
) {
|
) {
|
||||||
Some(mut cfg) => {
|
Some(mut cfg) => {
|
||||||
cfg.attribution_callback = primary.attribution_callback.clone();
|
cfg.attribution_callback = primary.attribution_callback.clone();
|
||||||
cfg.bearer_resolver = primary.bearer_resolver.clone();
|
// H4: the SESSION model's bearer_resolver must not ride to a
|
||||||
|
// summary model on a different provider —
|
||||||
|
// `SamplingClient::post` REPLACES the request's auth header
|
||||||
|
// from it, overwriting the summary model's own resolved key on
|
||||||
|
// ITS host. Route through the one aux-resolver decision.
|
||||||
|
cfg.bearer_resolver =
|
||||||
|
crate::session::acp_session::sampler_turn::aux_bearer_resolver(
|
||||||
|
primary.bearer_resolver.clone(),
|
||||||
|
crate::agent::models::platform_for_slug(&models, None, &slug),
|
||||||
|
&cfg.base_url,
|
||||||
|
);
|
||||||
cfg.max_retries = primary.max_retries;
|
cfg.max_retries = primary.max_retries;
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
@@ -669,31 +681,51 @@ impl MvpAgent {
|
|||||||
);
|
);
|
||||||
Ok(entry.clone())
|
Ok(entry.clone())
|
||||||
}
|
}
|
||||||
/// Resolve the SESSION token for `model` by the model's OWN platform — the
|
/// Resolve the SESSION token for `model` by the model's OWN platform AND
|
||||||
/// single guard against the api_key-channel token leak.
|
/// endpoint — the single guard against the api_key-channel token leak.
|
||||||
///
|
///
|
||||||
/// An oauth-platform model (xai-grok) draws its session token from ITS OWN
|
/// - an oauth-platform model (xai-grok, claude-pro-max, github-copilot,
|
||||||
/// process-global pool manager (build-on-demand from the on-disk grok token,
|
/// openai-codex) draws its session token from ITS OWN process-global pool
|
||||||
/// proactively refreshed), INDEPENDENT of the primary `auth_method`; when
|
/// manager (built on demand from the on-disk token, proactively
|
||||||
/// that provider has no stored session the token is `None` — NEVER the
|
/// refreshed), INDEPENDENT of the primary `auth_method`; when that
|
||||||
/// primary Kimi key. Every other model (first-party / Kimi) uses the primary
|
/// provider has no stored session the token is `None` — NEVER the primary
|
||||||
/// session manager, and only under a session-based auth method —
|
/// Kimi key;
|
||||||
/// byte-identical to the pre-fix path. SECURITY: the resolved token is never
|
/// - an endpoint that does not take a session credential at all
|
||||||
/// logged.
|
/// ([`crate::agent::auth_method::platform_takes_session_credential`] —
|
||||||
|
/// every API-key registry platform, and any `[model.*]` block pointed at a
|
||||||
|
/// third-party host) gets `None`. This is C1: `resolve_credentials` takes
|
||||||
|
/// the `else if let Some(key) = session_key` arm and sets
|
||||||
|
/// `api_key = <Kimi bearer>` with the THIRD-PARTY `base_url`, which
|
||||||
|
/// `SamplingClient` then builds into `Authorization: Bearer …`. It is
|
||||||
|
/// reachable with ZERO configuration: `default_models.json` bundles
|
||||||
|
/// `moonshot-cn/*` + `moonshot-ai/*` entries that a Kimi-subscription user
|
||||||
|
/// sees on first launch / offline;
|
||||||
|
/// - the first-party subscription channel (kimi-code, a `KIGI_CODE_BASE_URL`
|
||||||
|
/// deployment, a loopback proxy) uses the primary session manager, and
|
||||||
|
/// only under a session-based auth method — byte-identical to the pre-fix
|
||||||
|
/// path.
|
||||||
|
///
|
||||||
|
/// SECURITY: the resolved token is never logged.
|
||||||
fn session_token_for_model(&self, model: &ModelEntry) -> Option<crate::auth::KimiAuth> {
|
fn session_token_for_model(&self, model: &ModelEntry) -> Option<crate::auth::KimiAuth> {
|
||||||
if let Some(oauth) = model
|
let info = model.info();
|
||||||
.info()
|
let platform = info
|
||||||
.id
|
.id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(kigi_models::parse_managed_model_key)
|
.and_then(kigi_models::parse_managed_model_key)
|
||||||
.and_then(|(platform, _)| platform.oauth())
|
.map(|(platform, _)| platform);
|
||||||
{
|
if let Some(oauth) = platform.and_then(kigi_models::PlatformId::oauth) {
|
||||||
return crate::auth::oauth_registry::global_manager_for(
|
return crate::auth::oauth_registry::global_manager_for(
|
||||||
&crate::util::kigi_home::kigi_home(),
|
&crate::auth::oauth_registry::pool_home(),
|
||||||
oauth,
|
oauth,
|
||||||
)
|
)
|
||||||
.current_or_expired();
|
.current_or_expired();
|
||||||
}
|
}
|
||||||
|
if !crate::agent::auth_method::platform_takes_session_credential(
|
||||||
|
platform,
|
||||||
|
&info.base_url,
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if self.is_session_based_auth() {
|
if self.is_session_based_auth() {
|
||||||
self.auth_manager.current_or_expired()
|
self.auth_manager.current_or_expired()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1197,18 +1197,26 @@ async fn prepare_sampling_config_never_stamps_kimi_key_on_grok_model() {
|
|||||||
|
|
||||||
let endpoints = EndpointsConfig::default();
|
let endpoints = EndpointsConfig::default();
|
||||||
|
|
||||||
// First-party Kimi model (non-oauth platform): the primary session key IS
|
// First-party SUBSCRIPTION model (kimi-code): the primary session key IS its
|
||||||
// its api_key — the byte-identical primary path, and proof the Kimi token is
|
// 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
|
// live (so it WOULD leak if mis-routed onto a grok request). This assertion
|
||||||
// also confirms the session-based primary path is active.
|
// 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());
|
// This used to use `moonshot-cn/kimi-k2-0905-preview` and assert the SAME
|
||||||
|
// thing, which encoded the C1 defect: moonshot-cn is an API-key registry
|
||||||
|
// platform on `api.moonshot.cn`, NOT first-party, so "must carry the primary
|
||||||
|
// session key" was asserting the leak. `api_key_channel_leak_tests` now pins
|
||||||
|
// the opposite for every moonshot entry.
|
||||||
|
let mut kimi_model = ModelEntry::fallback("kimi-for-coding", &endpoints);
|
||||||
|
kimi_model.info.id = Some("kimi-code/kimi-for-coding".to_string());
|
||||||
|
kimi_model.info.base_url = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url.to_string();
|
||||||
assert!(!kimi_model.has_own_credentials());
|
assert!(!kimi_model.has_own_credentials());
|
||||||
let kimi_cfg = agent.prepare_sampling_config_for_model(&kimi_model, None);
|
let kimi_cfg = agent.prepare_sampling_config_for_model(&kimi_model, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
kimi_cfg.api_key.as_deref(),
|
kimi_cfg.api_key.as_deref(),
|
||||||
Some(KIMI_KEY),
|
Some(KIMI_KEY),
|
||||||
"a first-party Kimi model must carry the primary session key (primary path unchanged)"
|
"the first-party subscription model must carry the primary session key \
|
||||||
|
(primary path unchanged)"
|
||||||
);
|
);
|
||||||
|
|
||||||
// xai-grok model (oauth platform): the session token resolves from its OWN
|
// xai-grok model (oauth platform): the session token resolves from its OWN
|
||||||
@@ -1280,6 +1288,9 @@ async fn ensure_plugin_registry_lazily_populates_snapshot() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
mod subagent_spawn_context_tests;
|
mod subagent_spawn_context_tests;
|
||||||
|
/// LEAK guard for the `api_key` channel (C1/C2), through the real
|
||||||
|
/// `prepare_sampling_config_for_model` resolution path.
|
||||||
|
mod api_key_channel_leak_tests;
|
||||||
/// No load in flight and no session → the wait returns immediately
|
/// No load in flight and no session → the wait returns immediately
|
||||||
/// (the caller then surfaces "unknown session id" exactly as before).
|
/// (the caller then surfaces "unknown session id" exactly as before).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
//! LEAK GUARD (`api_key` channel) — C1/C2, driven through the REAL resolution
|
||||||
|
//! path, `MvpAgent::prepare_sampling_config_for_model`.
|
||||||
|
//!
|
||||||
|
//! This is the channel the `bearer_resolver` guard does NOT close, and the one
|
||||||
|
//! the first round of leak tests assumed away by hand-stamping a provider key
|
||||||
|
//! into chat state. The chain: `session_token_for_model` used to fall through to
|
||||||
|
//! `self.auth_manager.current_or_expired()` (the primary Kimi bearer) for every
|
||||||
|
//! non-OAuth model, `resolve_credentials` then took its
|
||||||
|
//! `else if let Some(key) = session_key` arm and set `api_key = <Kimi token>`
|
||||||
|
//! with the THIRD-PARTY `base_url`, and `SamplingClient` builds
|
||||||
|
//! `Authorization: Bearer <api_key>` straight into `default_headers` — which
|
||||||
|
//! `post()` only overrides when a resolver exists, so `bearer_resolver: None`
|
||||||
|
//! does not save it.
|
||||||
|
//!
|
||||||
|
//! Nothing here stamps a credential by hand: every assertion reads what the
|
||||||
|
//! resolution path actually produced.
|
||||||
|
|
||||||
|
use super::super::*;
|
||||||
|
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::{Config as AgentConfig, EndpointsConfig, EnvKeys, ModelEntry};
|
||||||
|
use crate::auth::{AuthManager, AuthMode, KimiAuth, KimiCodeConfig};
|
||||||
|
use kigi_test_support::EnvGuard;
|
||||||
|
|
||||||
|
const KIMI_TOKEN: &str = "kimi-subscription-token-DO-NOT-LEAK";
|
||||||
|
|
||||||
|
/// Ambient BYOK env vars unset, so a model with no resolvable credential ends up
|
||||||
|
/// with `api_key == None` rather than a global-key fallback that could mask the
|
||||||
|
/// leak under test. Every test holding these must be `#[serial]`.
|
||||||
|
fn without_ambient_byok_env() -> [EnvGuard; 3] {
|
||||||
|
[
|
||||||
|
EnvGuard::unset(HOUSE_API_KEY_ENV_VAR),
|
||||||
|
EnvGuard::unset(XAI_API_KEY_ENV_VAR),
|
||||||
|
EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An `MvpAgent` on a session-based (`cached_token`) ACP method holding a live
|
||||||
|
/// Kimi subscription bearer — the mainstream configuration in which the leak
|
||||||
|
/// fires. `(tempdir, agent)`; the tempdir is the auth store and is returned so
|
||||||
|
/// the caller keeps it alive.
|
||||||
|
fn kimi_session_agent() -> (tempfile::TempDir, MvpAgent) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let auth_manager = std::sync::Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
|
||||||
|
auth_manager.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()
|
||||||
|
});
|
||||||
|
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let agent = MvpAgent::new(
|
||||||
|
GatewaySender::new(tx),
|
||||||
|
&AgentConfig::default(),
|
||||||
|
auth_manager,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("valid test config");
|
||||||
|
agent.set_auth_method(acp::AuthMethodId::new(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||||
|
(dir, agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A catalog entry as `resolve_model_list` builds one for a fetched registry
|
||||||
|
/// model: managed catalog key, platform base URL, no credential of its own.
|
||||||
|
fn platform_entry(catalog_key: &str, slug: &str, base_url: &str) -> ModelEntry {
|
||||||
|
let mut entry = ModelEntry::fallback(slug, &EndpointsConfig::default());
|
||||||
|
entry.info.id = Some(catalog_key.to_string());
|
||||||
|
entry.info.base_url = base_url.to_string();
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C1, the ZERO-CONFIGURATION repro. `default_models.json` bundles
|
||||||
|
/// `moonshot-cn/*` and `moonshot-ai/*` entries with `api_key: None`, and
|
||||||
|
/// `resolve_model_list` keeps the bundled defaults whenever no catalog fetch has
|
||||||
|
/// succeeded — so on first launch / offline a Kimi-subscription user sees them
|
||||||
|
/// in the picker with no configuration whatsoever. Selecting one used to send
|
||||||
|
/// `Authorization: Bearer <Kimi OAuth token>` to `api.moonshot.cn`, which is NOT
|
||||||
|
/// first-party.
|
||||||
|
///
|
||||||
|
/// Revert-to-red: dropping the `platform_takes_session_credential` guard from
|
||||||
|
/// `session_token_for_model` makes every `api_key` below `Some(KIMI_TOKEN)`.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn bundled_default_moonshot_models_never_carry_the_kimi_bearer() {
|
||||||
|
let _env = without_ambient_byok_env();
|
||||||
|
let (_dir, agent) = kimi_session_agent();
|
||||||
|
|
||||||
|
let bundled = crate::agent::config::default_model_entries(&EndpointsConfig::default());
|
||||||
|
let moonshot: Vec<_> = bundled
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, _)| key.starts_with("moonshot-cn/") || key.starts_with("moonshot-ai/"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
moonshot.len(),
|
||||||
|
4,
|
||||||
|
"default_models.json still bundles the four moonshot open-platform entries"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (key, entry) in moonshot {
|
||||||
|
assert!(
|
||||||
|
!entry.has_own_credentials(),
|
||||||
|
"{key}: the bundled entry carries no credential of its own"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!crate::util::is_effective_coding_endpoint_url(&entry.info().base_url),
|
||||||
|
"{key}: routes to a third-party host ({})",
|
||||||
|
entry.info().base_url
|
||||||
|
);
|
||||||
|
let cfg = agent.prepare_sampling_config_for_model(entry, None);
|
||||||
|
assert_ne!(
|
||||||
|
cfg.api_key.as_deref(),
|
||||||
|
Some(KIMI_TOKEN),
|
||||||
|
"LEAK: selecting the bundled {key} sent the Kimi subscription bearer to {}",
|
||||||
|
entry.info().base_url
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.base_url,
|
||||||
|
entry.info().base_url,
|
||||||
|
"{key}: still routes to its own host (the fix must not reroute traffic)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C1 across the API-key registry platform shapes a fetched catalog produces.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn api_key_platform_models_never_carry_the_kimi_bearer_as_api_key() {
|
||||||
|
let _env = without_ambient_byok_env();
|
||||||
|
let (_dir, agent) = kimi_session_agent();
|
||||||
|
|
||||||
|
for (catalog_key, slug, base_url) in [
|
||||||
|
("deepseek/deepseek-chat", "deepseek-chat", "https://api.deepseek.com/v1"),
|
||||||
|
("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"),
|
||||||
|
("xai/grok-4.5", "grok-4.5", "https://api.x.ai/v1"),
|
||||||
|
] {
|
||||||
|
let entry = platform_entry(catalog_key, slug, base_url);
|
||||||
|
let cfg = agent.prepare_sampling_config_for_model(&entry, None);
|
||||||
|
assert_ne!(
|
||||||
|
cfg.api_key.as_deref(),
|
||||||
|
Some(KIMI_TOKEN),
|
||||||
|
"LEAK: {catalog_key} carried the primary Kimi session bearer to {base_url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C2, the `[model.*]` repro. A `[model.gpt-4o]` block has `info.id == None`, so
|
||||||
|
/// it has no platform at all — which used to be a blanket allow. BYOK is
|
||||||
|
/// `has_own_credentials()`, which probes `std::env::var` AT CALL TIME, so an
|
||||||
|
/// unset (or mistyped) `env_key` classifies the model NotByok and the Kimi
|
||||||
|
/// bearer went to `api.openai.com` on BOTH channels.
|
||||||
|
///
|
||||||
|
/// Revert-to-red: making the `None` arm of `platform_takes_session_credential`
|
||||||
|
/// return `true` again makes `api_key` here `Some(KIMI_TOKEN)`.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn config_model_with_an_unset_env_key_never_carries_the_kimi_bearer() {
|
||||||
|
let _env = without_ambient_byok_env();
|
||||||
|
let _typo = EnvGuard::unset("OPENAI_API_KEY_TYPO");
|
||||||
|
let (_dir, agent) = kimi_session_agent();
|
||||||
|
|
||||||
|
let mut entry = ModelEntry::fallback("gpt-4o", &EndpointsConfig::default());
|
||||||
|
entry.info.id = None; // a `[model.gpt-4o]` config block
|
||||||
|
entry.info.base_url = "https://api.openai.com/v1".to_string();
|
||||||
|
entry.env_key = Some(EnvKeys::single("OPENAI_API_KEY_TYPO"));
|
||||||
|
assert!(
|
||||||
|
!entry.has_own_credentials(),
|
||||||
|
"the env var is unset, so this classifies NotByok — the precondition of the defect"
|
||||||
|
);
|
||||||
|
|
||||||
|
let cfg = agent.prepare_sampling_config_for_model(&entry, None);
|
||||||
|
assert_ne!(
|
||||||
|
cfg.api_key.as_deref(),
|
||||||
|
Some(KIMI_TOKEN),
|
||||||
|
"LEAK: a [model.*] block with an unset env_key sent the Kimi bearer to api.openai.com"
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.api_key, None, "no credential resolves — fail fast");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first-party subscription channel must stay BYTE-IDENTICAL: `kimi-code/*`
|
||||||
|
/// (and a `[model.*]` block on the session's own coding endpoint, including a
|
||||||
|
/// `KIGI_CODE_BASE_URL` deployment / a local dev proxy) still carries the
|
||||||
|
/// primary session key. This assertion is also what proves the Kimi token is
|
||||||
|
/// live in the tests above — it WOULD leak if the guard were missing.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn the_first_party_subscription_channel_still_carries_the_session_key() {
|
||||||
|
let _env = without_ambient_byok_env();
|
||||||
|
let (_dir, agent) = kimi_session_agent();
|
||||||
|
|
||||||
|
let kimi = platform_entry(
|
||||||
|
"kimi-code/kimi-for-coding",
|
||||||
|
"kimi-for-coding",
|
||||||
|
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
agent
|
||||||
|
.prepare_sampling_config_for_model(&kimi, None)
|
||||||
|
.api_key
|
||||||
|
.as_deref(),
|
||||||
|
Some(KIMI_TOKEN),
|
||||||
|
"the kimi-code subscription channel must be unchanged"
|
||||||
|
);
|
||||||
|
|
||||||
|
for base_url in [
|
||||||
|
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||||||
|
"http://127.0.0.1:4141/v1",
|
||||||
|
"http://localhost:8080/v1",
|
||||||
|
] {
|
||||||
|
let mut bare = ModelEntry::fallback("kigi-4.5", &EndpointsConfig::default());
|
||||||
|
bare.info.id = None;
|
||||||
|
bare.info.base_url = base_url.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
agent
|
||||||
|
.prepare_sampling_config_for_model(&bare, None)
|
||||||
|
.api_key
|
||||||
|
.as_deref(),
|
||||||
|
Some(KIMI_TOKEN),
|
||||||
|
"{base_url}: a custom deployment / local proxy keeps the session key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A subscription-OAuth model draws its `api_key` from ITS OWN pooled manager,
|
||||||
|
/// never the Kimi primary — and never falls back to it when that provider has no
|
||||||
|
/// stored session (the pool home is an empty TempDir under `cfg(test)`).
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn oauth_platform_models_never_carry_the_kimi_bearer_as_api_key() {
|
||||||
|
let _env = without_ambient_byok_env();
|
||||||
|
let (_dir, agent) = kimi_session_agent();
|
||||||
|
|
||||||
|
for (catalog_key, slug, base_url) in [
|
||||||
|
("xai-grok/grok-4-latest", "grok-4-latest", "https://api.x.ai/v1"),
|
||||||
|
(
|
||||||
|
"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"),
|
||||||
|
(
|
||||||
|
"openai-codex/gpt-5.5",
|
||||||
|
"gpt-5.5",
|
||||||
|
"https://chatgpt.com/backend-api/codex",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let entry = platform_entry(catalog_key, slug, base_url);
|
||||||
|
let cfg = agent.prepare_sampling_config_for_model(&entry, None);
|
||||||
|
assert_ne!(
|
||||||
|
cfg.api_key.as_deref(),
|
||||||
|
Some(KIMI_TOKEN),
|
||||||
|
"LEAK: {catalog_key} carried the primary Kimi session bearer to {base_url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// H5 at the api_key channel: `resolve_model_id` (the picker's own lookup) must
|
||||||
|
/// hand `prepare_sampling_config_for_model` the entry the user SELECTED, even
|
||||||
|
/// when an API-key platform and its subscription-OAuth twin list the same
|
||||||
|
/// routing slug in `PlatformId::ALL` order. Selecting the OAuth twin by catalog
|
||||||
|
/// key must not resolve the API-key twin — and vice versa.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn dual_credential_slug_collision_resolves_the_selected_catalog_key() {
|
||||||
|
let _env = without_ambient_byok_env();
|
||||||
|
let (_dir, agent) = kimi_session_agent();
|
||||||
|
|
||||||
|
// API-key platform FIRST, exactly as `PlatformId::ALL` orders them.
|
||||||
|
for key in ["xai/grok-4.5", "xai-grok/grok-4.5"] {
|
||||||
|
agent.models_manager.insert_test_entry(
|
||||||
|
key,
|
||||||
|
platform_entry(key, "grok-4.5", "https://api.x.ai/v1"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in ["xai/grok-4.5", "xai-grok/grok-4.5"] {
|
||||||
|
let resolved = agent
|
||||||
|
.resolve_model_id(&acp::ModelId::new(key))
|
||||||
|
.expect("both twins resolve");
|
||||||
|
assert_eq!(
|
||||||
|
resolved.info().id.as_deref(),
|
||||||
|
Some(key),
|
||||||
|
"selecting {key} must resolve THAT catalog entry, not its slug twin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the bare slug resolves the same entry the picker's `resolve_catalog_key`
|
||||||
|
// does — one direction, one answer (the auth layer used to first-match).
|
||||||
|
let by_slug = agent
|
||||||
|
.resolve_model_id(&acp::ModelId::new("grok-4.5"))
|
||||||
|
.expect("the bare slug resolves");
|
||||||
|
let models = agent.models_manager.models();
|
||||||
|
let picker_key = crate::agent::models::resolve_catalog_key(
|
||||||
|
&models,
|
||||||
|
&acp::ModelId::new("grok-4.5"),
|
||||||
|
)
|
||||||
|
.expect("the picker resolves the bare slug");
|
||||||
|
assert_eq!(
|
||||||
|
by_slug.info().id.as_deref(),
|
||||||
|
Some(picker_key.0.as_ref()),
|
||||||
|
"the auth layer and the picker must resolve the SAME entry for one slug"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
crate::agent::config::find_model_by_id(&models, "grok-4.5")
|
||||||
|
.and_then(|e| e.info().id.as_deref()),
|
||||||
|
Some(picker_key.0.as_ref()),
|
||||||
|
"find_model_by_id must agree with resolve_catalog_key by construction"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1003,16 +1003,22 @@ fn resolve_model_override_to_config(
|
|||||||
} else {
|
} else {
|
||||||
acp::ModelId::new(entry.info().model.clone())
|
acp::ModelId::new(entry.info().model.clone())
|
||||||
};
|
};
|
||||||
// Resolve the child's session token by the OVERRIDE model's OWN platform,
|
// Resolve the child's session token by the OVERRIDE model's OWN platform
|
||||||
// not the parent's primary auth: a grok (oauth-platform) override draws its
|
// AND endpoint, not the parent's primary auth: a grok (oauth-platform)
|
||||||
// pooled grok token or `None` — NEVER the primary Kimi session token (which
|
// override draws its pooled grok token or `None`, and an API-key registry
|
||||||
// `resolve_credentials` would otherwise stamp onto the child's api.x.ai
|
// platform / a third-party `[model.*]` host draws NOTHING — NEVER the
|
||||||
// credentials, leaking it in the logout-mid-session edge). A first-party /
|
// primary Kimi session token, which `resolve_credentials` would otherwise
|
||||||
// non-oauth override still resolves to the primary (byte-identical).
|
// stamp onto the child's api.x.ai / api.moonshot.cn credentials. The
|
||||||
let managed_key = entry.info().id.as_deref().unwrap_or(model_id);
|
// first-party subscription channel still resolves to the primary
|
||||||
let session_key = crate::auth::oauth_registry::session_key_for_model(
|
// (byte-identical).
|
||||||
&crate::util::kigi_home::kigi_home(),
|
let session_key = crate::auth::oauth_registry::session_key_for_endpoint(
|
||||||
managed_key,
|
entry
|
||||||
|
.info()
|
||||||
|
.id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(kigi_models::parse_managed_model_key)
|
||||||
|
.map(|(platform, _)| platform),
|
||||||
|
&entry.info().base_url,
|
||||||
Some(&ctx.auth_manager),
|
Some(&ctx.auth_manager),
|
||||||
);
|
);
|
||||||
let has_session_key = session_key.is_some();
|
let has_session_key = session_key.is_some();
|
||||||
|
|||||||
@@ -3131,19 +3131,25 @@ fn fresh_tool_model_rejects_unavailable_exact_key_over_visible_slug_collision()
|
|||||||
"validation must inspect the unavailable exact-key entry selected by execution"
|
"validation must inspect the unavailable exact-key entry selected by execution"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/// Validation must inspect the SAME slug-collision entry execution selects.
|
||||||
|
/// Both go through `find_model_by_id`, whose slug scan takes the LAST match —
|
||||||
|
/// aligned with the picker's `resolve_catalog_key` so the auth layer and the
|
||||||
|
/// picker can never resolve different platforms for one slug (the H5 collision).
|
||||||
|
/// So a blocked LAST entry must be rejected even though an available earlier one
|
||||||
|
/// shares the slug.
|
||||||
#[test]
|
#[test]
|
||||||
fn fresh_tool_model_rejects_unavailable_first_slug_collision() {
|
fn fresh_tool_model_rejects_unavailable_last_slug_collision() {
|
||||||
let mut models = indexmap::IndexMap::new();
|
let mut models = indexmap::IndexMap::new();
|
||||||
let mut unavailable_first = test_model_entry("shared-routing-slug");
|
models.insert("visible-first".to_string(), test_model_entry("shared-routing-slug"));
|
||||||
unavailable_first.info.user_selectable = false;
|
let mut unavailable_last = test_model_entry("shared-routing-slug");
|
||||||
models.insert("blocked-first".to_string(), unavailable_first);
|
unavailable_last.info.user_selectable = false;
|
||||||
models.insert("visible-second".to_string(), test_model_entry("shared-routing-slug"));
|
models.insert("blocked-last".to_string(), unavailable_last);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
super::handle_request::task_model_override_error(Some("shared-routing-slug"),
|
super::handle_request::task_model_override_error(Some("shared-routing-slug"),
|
||||||
ModelOverrideProvenance::Tool, false, & models, false,).as_deref(),
|
ModelOverrideProvenance::Tool, false, & models, false,).as_deref(),
|
||||||
Some("Unknown Task.model slug 'shared-routing-slug'. Valid model slugs: \
|
Some("Unknown Task.model slug 'shared-routing-slug'. Valid model slugs: \
|
||||||
visible-second. Omit `model` to inherit the parent model."),
|
visible-first. Omit `model` to inherit the parent model."),
|
||||||
"validation must inspect the first routing-slug entry selected by execution"
|
"validation must inspect the last routing-slug entry selected by execution"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2511,14 +2511,48 @@ async fn subagent_override_grok_model_never_leaks_kimi_session_token() {
|
|||||||
"a grok override must never receive the primary Kimi session token",
|
"a grok override must never receive the primary Kimi session token",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/// Byte-identical guard: a non-oauth override with a Kimi primary still resolves
|
/// Byte-identical guard: an override on the SESSION's own first-party endpoint
|
||||||
/// to the primary session token — passes both before and after the fix (the
|
/// (the kimi-code subscription channel) still resolves to the primary session
|
||||||
/// non-oauth path is unchanged).
|
/// token — the primary path is unchanged.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn subagent_override_non_oauth_model_still_gets_primary_token() {
|
async fn subagent_override_first_party_model_still_gets_primary_token() {
|
||||||
|
let (_kd, manager) = kimi_primary_with_token("kimi-secret");
|
||||||
|
let mut entry = test_model_entry("kimi-for-coding");
|
||||||
|
entry.info.id = Some("kimi-code/kimi-for-coding".to_string());
|
||||||
|
entry.info.base_url = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url.to_string();
|
||||||
|
let mut models = indexmap::IndexMap::new();
|
||||||
|
models.insert("kfc".to_string(), entry);
|
||||||
|
let mut ctx = ctx_with_toggle(HashMap::new());
|
||||||
|
ctx.available_models = models;
|
||||||
|
ctx.auth = Some(crate::auth::KimiAuth {
|
||||||
|
key: "kimi-secret".to_string(),
|
||||||
|
auth_mode: crate::auth::AuthMode::OAuth,
|
||||||
|
..crate::auth::KimiAuth::test_default()
|
||||||
|
});
|
||||||
|
ctx.auth_manager = manager;
|
||||||
|
let (config, _model_id) = resolve_model_override_to_config("kfc", &ctx)
|
||||||
|
.expect("first-party override resolves to a config");
|
||||||
|
assert_eq!(
|
||||||
|
config.api_key.as_deref(),
|
||||||
|
Some("kimi-secret"),
|
||||||
|
"a first-party override must still receive the primary session token",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/// LEAK guard (C1, subagent-override `api_key` channel): an API-key registry
|
||||||
|
/// platform override must NOT receive the parent's primary Kimi session token —
|
||||||
|
/// `resolve_credentials` would stamp it as the child's `api_key` on
|
||||||
|
/// `api.moonshot.cn`. This test previously asserted the opposite
|
||||||
|
/// (`subagent_override_non_oauth_model_still_gets_primary_token`), which encoded
|
||||||
|
/// the defect.
|
||||||
|
///
|
||||||
|
/// Revert-to-red: dropping the `platform_takes_session_credential` term from
|
||||||
|
/// `oauth_registry::session_key_for_endpoint` makes `api_key` `Some("kimi-secret")`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subagent_override_api_key_platform_never_gets_the_primary_token() {
|
||||||
let (_kd, manager) = kimi_primary_with_token("kimi-secret");
|
let (_kd, manager) = kimi_primary_with_token("kimi-secret");
|
||||||
let mut entry = test_model_entry("kimi-k2-0905-preview");
|
let mut entry = test_model_entry("kimi-k2-0905-preview");
|
||||||
entry.info.id = Some("moonshot-cn/kimi-k2".to_string());
|
entry.info.id = Some("moonshot-cn/kimi-k2".to_string());
|
||||||
|
entry.info.base_url = "https://api.moonshot.cn/v1".to_string();
|
||||||
let mut models = indexmap::IndexMap::new();
|
let mut models = indexmap::IndexMap::new();
|
||||||
models.insert("k2".to_string(), entry);
|
models.insert("k2".to_string(), entry);
|
||||||
let mut ctx = ctx_with_toggle(HashMap::new());
|
let mut ctx = ctx_with_toggle(HashMap::new());
|
||||||
@@ -2529,12 +2563,12 @@ async fn subagent_override_non_oauth_model_still_gets_primary_token() {
|
|||||||
..crate::auth::KimiAuth::test_default()
|
..crate::auth::KimiAuth::test_default()
|
||||||
});
|
});
|
||||||
ctx.auth_manager = manager;
|
ctx.auth_manager = manager;
|
||||||
let (config, _model_id) =
|
let (config, _model_id) = resolve_model_override_to_config("k2", &ctx)
|
||||||
resolve_model_override_to_config("k2", &ctx).expect("non-oauth override resolves to a config");
|
.expect("an API-key-platform override still resolves to a config");
|
||||||
assert_eq!(
|
assert_ne!(
|
||||||
config.api_key.as_deref(),
|
config.api_key.as_deref(),
|
||||||
Some("kimi-secret"),
|
Some("kimi-secret"),
|
||||||
"a non-oauth override must still receive the primary session token",
|
"LEAK: an API-key-platform override must never receive the primary Kimi session token",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/// An unresolvable `AgentDefinition.model` pin (model absent from
|
/// An unresolvable `AgentDefinition.model` pin (model absent from
|
||||||
|
|||||||
@@ -13,11 +13,10 @@
|
|||||||
//! generic-oauth scope, each wired with the SAME lifecycle as the primary Kimi
|
//! generic-oauth scope, each wired with the SAME lifecycle as the primary Kimi
|
||||||
//! manager (`configure_refresher()` + `start_proactive_refresh()`) so the
|
//! manager (`configure_refresher()` + `start_proactive_refresh()`) so the
|
||||||
//! on-disk token stays fresh and a 401 recovers via the provider's own manager.
|
//! on-disk token stays fresh and a 401 recovers via the provider's own manager.
|
||||||
//! Managers are built ON DEMAND: the first grok turn (or model switch) reads the
|
//! Managers are built ON DEMAND from the on-disk token ([`global_manager_for`]),
|
||||||
//! on-disk token via [`global_manager_for`], so a login that lands AFTER a
|
//! so a login landing AFTER a session spawned self-heals — no frozen per-session
|
||||||
//! session spawned self-heals — there is no frozen per-session snapshot to go
|
//! snapshot. [`manager_for_model`] routes a managed catalog key to the pool
|
||||||
//! stale. [`manager_for_model`] routes a managed catalog key to the pool (oauth
|
//! (oauth platform) or to the session's primary (everything else).
|
||||||
//! platform) or to the session's primary (everything else).
|
|
||||||
//!
|
//!
|
||||||
//! SECURITY: access/refresh tokens and resolved bearers are NEVER logged here.
|
//! SECURITY: access/refresh tokens and resolved bearers are NEVER logged here.
|
||||||
|
|
||||||
@@ -39,6 +38,31 @@ fn oauth_manager_pool() -> &'static Mutex<HashMap<&'static str, Arc<AuthManager>
|
|||||||
POOL.get_or_init(|| Mutex::new(HashMap::new()))
|
POOL.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kigi home every OAuth-pool call site resolves from. Single definition so
|
||||||
|
/// the pool, the aux/summary token routing and the session's inference manager
|
||||||
|
/// can never read different homes.
|
||||||
|
///
|
||||||
|
/// Production: [`crate::util::kigi_home::kigi_home`]. LIB TESTS: a
|
||||||
|
/// process-lifetime `TempDir`, unconditionally — the pool is process-global and
|
||||||
|
/// every manager it builds starts a never-cancelled proactive-refresh loop, so
|
||||||
|
/// a unit test resolving the real `~/.kigi` would read the developer's stored
|
||||||
|
/// OAuth tokens and, 60 s later, fire REAL refresh requests against them.
|
||||||
|
/// Deliberately not a per-test opt-in that can be forgotten: `kigi_home()` is
|
||||||
|
/// itself a `OnceLock` an earlier test has usually already resolved to the real
|
||||||
|
/// home, so setting `KIGI_SHARE_DIR` in a test cannot pin it after the fact.
|
||||||
|
pub(crate) fn pool_home() -> std::path::PathBuf {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
static TEST_HOME: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||||
|
TEST_HOME
|
||||||
|
.get_or_init(|| tempfile::tempdir().expect("tempdir for the test OAuth pool"))
|
||||||
|
.path()
|
||||||
|
.to_path_buf()
|
||||||
|
}
|
||||||
|
#[cfg(not(test))]
|
||||||
|
crate::util::kigi_home::kigi_home()
|
||||||
|
}
|
||||||
|
|
||||||
/// Get-or-create the process-global manager for `oauth`, wiring the same
|
/// Get-or-create the process-global manager for `oauth`, wiring the same
|
||||||
/// refresher + proactive-refresh lifecycle as the primary Kimi manager the
|
/// refresher + proactive-refresh lifecycle as the primary Kimi manager the
|
||||||
/// FIRST time a scope is seen. The manager reads the on-disk token at
|
/// FIRST time a scope is seen. The manager reads the on-disk token at
|
||||||
@@ -93,27 +117,70 @@ pub(crate) fn manager_for_model(
|
|||||||
primary.cloned()
|
primary.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The SESSION token (the raw bearer/key string) that governs INFERENCE auth
|
/// The SESSION token (the raw bearer/key string) that may ride an INFERENCE
|
||||||
/// for `managed_key`, resolved by the model's OWN platform. Thin wrapper over
|
/// request routed to `platform` at `base_url`. Used by the aux-model, summary
|
||||||
/// [`manager_for_model`] used by the aux-model and subagent-override wire paths
|
/// and subagent-override wire paths, where the result is stamped straight into
|
||||||
/// so a `{platform}/{model}` key never receives the primary token of a
|
/// [`crate::agent::config::resolve_credentials`] as the request's `api_key`.
|
||||||
/// DIFFERENT provider.
|
|
||||||
///
|
///
|
||||||
/// A generic device-code OAuth platform (xai-grok) draws its token from ITS OWN
|
/// - a generic device-code OAuth platform (xai-grok, claude-pro-max,
|
||||||
/// pooled manager; when that provider has no stored session the result is
|
/// github-copilot, openai-codex) draws from ITS OWN pooled manager; when that
|
||||||
/// `None` — NEVER the primary Kimi key. Every other key routes to `primary` and
|
/// provider has no stored session the result is `None` — never `primary`;
|
||||||
/// yields the primary's current-or-expired token, byte-identical to reading it
|
/// - `kimi-code`, and a platform-less model whose endpoint IS the session's own
|
||||||
/// directly. SECURITY: the resolved token is never logged.
|
/// coding endpoint (incl. a `KIGI_CODE_BASE_URL` deployment or a loopback
|
||||||
pub(crate) fn session_key_for_model(
|
/// proxy), yield the primary's current-or-expired token — byte-identical to
|
||||||
kigi_home: &Path,
|
/// reading it directly;
|
||||||
managed_key: &str,
|
/// - every API-key registry platform, and every `[model.*]` block pointed at a
|
||||||
|
/// third-party host, yields `None`. Handing them `primary` put the user's
|
||||||
|
/// Kimi subscription bearer on `api.deepseek.com` / `api.moonshot.cn` / …
|
||||||
|
/// as the request's `api_key`.
|
||||||
|
///
|
||||||
|
/// SECURITY: the resolved token is never logged.
|
||||||
|
pub(crate) fn session_key_for_endpoint(
|
||||||
|
platform: Option<kigi_models::PlatformId>,
|
||||||
|
base_url: &str,
|
||||||
primary: Option<&Arc<AuthManager>>,
|
primary: Option<&Arc<AuthManager>>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
manager_for_model(kigi_home, managed_key, primary)
|
if let Some(oauth) = platform.and_then(kigi_models::PlatformId::oauth) {
|
||||||
|
return global_manager_for(&pool_home(), oauth)
|
||||||
|
.current_or_expired()
|
||||||
|
.map(|a| a.key);
|
||||||
|
}
|
||||||
|
if !crate::agent::auth_method::platform_takes_session_credential(platform, base_url) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
primary
|
||||||
.and_then(|am| am.current_or_expired())
|
.and_then(|am| am.current_or_expired())
|
||||||
.map(|a| a.key)
|
.map(|a| a.key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`session_key_for_endpoint`] for the catalog model whose routing slug (or
|
||||||
|
/// catalog key) is `slug`.
|
||||||
|
///
|
||||||
|
/// A slug absent from the catalog keeps the pre-registry behaviour: the aux
|
||||||
|
/// resolver's Tier-2 fallback builds its entry against
|
||||||
|
/// `EndpointsConfig::resolve_inference_base_url` (first-party), so the primary
|
||||||
|
/// still governs.
|
||||||
|
pub(crate) fn session_key_for_catalog_model(
|
||||||
|
models: &indexmap::IndexMap<String, crate::agent::config::ModelEntry>,
|
||||||
|
slug: &str,
|
||||||
|
primary: Option<&Arc<AuthManager>>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let Some(entry) = crate::agent::config::find_model_by_id(models, slug) else {
|
||||||
|
return primary
|
||||||
|
.and_then(|am| am.current_or_expired())
|
||||||
|
.map(|a| a.key);
|
||||||
|
};
|
||||||
|
let info = entry.info();
|
||||||
|
session_key_for_endpoint(
|
||||||
|
info.id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(kigi_models::parse_managed_model_key)
|
||||||
|
.map(|(platform, _)| platform),
|
||||||
|
&info.base_url,
|
||||||
|
primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -159,6 +226,20 @@ mod tests {
|
|||||||
.expect("openai-codex carries an OAuthConfig")
|
.expect("openai-codex carries an OAuthConfig")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `session_key_for_endpoint` for a managed catalog key, resolving the
|
||||||
|
/// platform and its base URL from the registry exactly as the catalog entry
|
||||||
|
/// would.
|
||||||
|
fn session_key_for_key(
|
||||||
|
managed_key: &str,
|
||||||
|
primary: Option<&Arc<AuthManager>>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let platform = kigi_models::parse_managed_model_key(managed_key).map(|(p, _)| p);
|
||||||
|
let base_url = platform
|
||||||
|
.map(kigi_models::PlatformId::base_url)
|
||||||
|
.unwrap_or_default();
|
||||||
|
session_key_for_endpoint(platform, &base_url, primary)
|
||||||
|
}
|
||||||
|
|
||||||
/// An `openai-codex/<model>` turn resolves to the process-global pooled
|
/// An `openai-codex/<model>` turn resolves to the process-global pooled
|
||||||
/// openai-codex manager (its OWN `oauth/openai-codex` scope), NEVER the
|
/// openai-codex manager (its OWN `oauth/openai-codex` scope), NEVER the
|
||||||
/// primary Kimi manager — the same leak-safe routing as the other OAuth
|
/// primary Kimi manager — the same leak-safe routing as the other OAuth
|
||||||
@@ -187,7 +268,7 @@ mod tests {
|
|||||||
"openai-codex and claude-pro-max must not share a pooled manager"
|
"openai-codex and claude-pro-max must not share a pooled manager"
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
session_key_for_model(home.path(), "openai-codex/gpt-5.5", Some(&kimi)),
|
session_key_for_key("openai-codex/gpt-5.5", Some(&kimi)),
|
||||||
Some("kimi-tok".to_string()),
|
Some("kimi-tok".to_string()),
|
||||||
"an openai-codex model must never receive the primary Kimi token"
|
"an openai-codex model must never receive the primary Kimi token"
|
||||||
);
|
);
|
||||||
@@ -218,7 +299,7 @@ mod tests {
|
|||||||
// Fail-fast: even with a Kimi primary, a copilot turn never yields the
|
// Fail-fast: even with a Kimi primary, a copilot turn never yields the
|
||||||
// Kimi bearer — it draws from the copilot pool (its own token, or None).
|
// Kimi bearer — it draws from the copilot pool (its own token, or None).
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
session_key_for_model(home.path(), "github-copilot/gpt-4.1", Some(&kimi)),
|
session_key_for_key("github-copilot/gpt-4.1", Some(&kimi)),
|
||||||
Some("kimi-tok".to_string()),
|
Some("kimi-tok".to_string()),
|
||||||
"a github-copilot model must never receive the primary Kimi token"
|
"a github-copilot model must never receive the primary Kimi token"
|
||||||
);
|
);
|
||||||
@@ -256,9 +337,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn session_key_for_claude_pro_max_is_never_the_kimi_primary() {
|
async fn session_key_for_claude_pro_max_is_never_the_kimi_primary() {
|
||||||
let (_kd, kimi) = primary_with_token("kimi-tok");
|
let (_kd, kimi) = primary_with_token("kimi-tok");
|
||||||
let home = tempfile::tempdir().unwrap();
|
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
session_key_for_model(home.path(), "claude-pro-max/claude-opus-4-8", Some(&kimi)),
|
session_key_for_key("claude-pro-max/claude-opus-4-8", Some(&kimi)),
|
||||||
Some("kimi-tok".to_string()),
|
Some("kimi-tok".to_string()),
|
||||||
"a claude-pro-max model must never receive the primary Kimi session token"
|
"a claude-pro-max model must never receive the primary Kimi session token"
|
||||||
);
|
);
|
||||||
@@ -342,22 +422,62 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `session_key_for_model`: a non-oauth / bare key yields the primary Kimi
|
/// `session_key_for_endpoint`: the endpoints that genuinely ride the
|
||||||
/// token exactly as reading it directly would — byte-identical to the
|
/// PRIMARY session — `kimi-code` (the subscription channel) and a
|
||||||
/// pre-fix aux/override wire path (no runtime / pool touched).
|
/// platform-less model routed at the session's own coding endpoint (a
|
||||||
|
/// `KIGI_CODE_BASE_URL` deployment or a loopback dev proxy) — yield the
|
||||||
|
/// primary token exactly as reading it directly would. No runtime / pool
|
||||||
|
/// touched.
|
||||||
#[test]
|
#[test]
|
||||||
fn session_key_for_non_oauth_is_the_primary_token() {
|
fn session_key_for_the_sessions_own_endpoint_is_the_primary_token() {
|
||||||
let (_kd, kimi) = primary_with_token("kimi-tok");
|
let (_kd, kimi) = primary_with_token("kimi-tok");
|
||||||
let home = tempfile::tempdir().unwrap();
|
assert_eq!(
|
||||||
for key in ["moonshot-cn/kimi-k2", "kimi-k2-0905-preview"] {
|
session_key_for_key("kimi-code/kimi-for-coding", Some(&kimi)),
|
||||||
|
Some("kimi-tok".to_string()),
|
||||||
|
"kimi-code rides the primary session, unchanged"
|
||||||
|
);
|
||||||
|
for url in [
|
||||||
|
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||||||
|
"http://127.0.0.1:4000/v1",
|
||||||
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session_key_for_model(home.path(), key, Some(&kimi)),
|
session_key_for_endpoint(None, url, Some(&kimi)),
|
||||||
Some("kimi-tok".to_string()),
|
Some("kimi-tok".to_string()),
|
||||||
"{key} (non-oauth) must yield the primary token unchanged"
|
"{url}: a platform-less model on the session's own endpoint is unchanged"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LEAK guard (aux / summary / subagent-override `api_key` channel): an
|
||||||
|
/// API-key registry platform, and a `[model.*]` block pointed at a
|
||||||
|
/// third-party host, must yield NO session token. Handing them the primary
|
||||||
|
/// stamped the user's Kimi subscription bearer onto `api.moonshot.cn` /
|
||||||
|
/// `api.deepseek.com` as the request's `api_key` — the channel the
|
||||||
|
/// `bearer_resolver` guard alone does not close.
|
||||||
|
///
|
||||||
|
/// Revert-to-red: dropping the `platform_takes_session_credential` term
|
||||||
|
/// from `session_key_for_endpoint` returns `Some("kimi-tok")` here.
|
||||||
|
#[test]
|
||||||
|
fn session_key_for_a_third_party_endpoint_is_never_the_primary_token() {
|
||||||
|
let (_kd, kimi) = primary_with_token("kimi-tok");
|
||||||
|
for key in [
|
||||||
|
"moonshot-cn/kimi-k2",
|
||||||
|
"deepseek/deepseek-chat",
|
||||||
|
"openai/gpt-5",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
session_key_for_key(key, Some(&kimi)),
|
||||||
|
None,
|
||||||
|
"LEAK: {key} is an API-key platform — no session token may ride there"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
session_key_for_endpoint(None, "https://api.openai.com/v1", Some(&kimi)),
|
||||||
|
None,
|
||||||
|
"LEAK: a [model.*] block on a third-party host gets no session token"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// LEAK guard (aux-model + subagent-override token routing): a grok key with
|
/// LEAK guard (aux-model + subagent-override token routing): a grok key with
|
||||||
/// a Kimi primary NEVER yields the primary Kimi token — it draws from the
|
/// a Kimi primary NEVER yields the primary Kimi token — it draws from the
|
||||||
/// pooled xai manager (its own token, or `None`). This is the exact source
|
/// pooled xai manager (its own token, or `None`). This is the exact source
|
||||||
@@ -365,15 +485,14 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn session_key_for_grok_is_never_the_kimi_primary() {
|
async fn session_key_for_grok_is_never_the_kimi_primary() {
|
||||||
let (_kd, kimi) = primary_with_token("kimi-tok");
|
let (_kd, kimi) = primary_with_token("kimi-tok");
|
||||||
let home = tempfile::tempdir().unwrap();
|
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
session_key_for_model(home.path(), "xai-grok/grok-4-latest", Some(&kimi)),
|
session_key_for_key("xai-grok/grok-4-latest", Some(&kimi)),
|
||||||
Some("kimi-tok".to_string()),
|
Some("kimi-tok".to_string()),
|
||||||
"a grok aux/override model must never receive the primary Kimi session token"
|
"a grok aux/override model must never receive the primary Kimi session token"
|
||||||
);
|
);
|
||||||
// Even with `None` primary the routing is unchanged: grok → pool, never a panic.
|
// Even with `None` primary the routing is unchanged: grok → pool, never a panic.
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
session_key_for_model(home.path(), "xai-grok/grok-4-fast", None),
|
session_key_for_key("xai-grok/grok-4-fast", None),
|
||||||
Some("kimi-tok".to_string()),
|
Some("kimi-tok".to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ use prompt_build::*;
|
|||||||
mod session_mode;
|
mod session_mode;
|
||||||
use session_mode::*;
|
use session_mode::*;
|
||||||
#[path = "acp_session_impl/sampler_turn.rs"]
|
#[path = "acp_session_impl/sampler_turn.rs"]
|
||||||
mod sampler_turn;
|
pub(crate) mod sampler_turn;
|
||||||
use sampler_turn::*;
|
use sampler_turn::*;
|
||||||
#[path = "acp_session_impl/tool_dispatch.rs"]
|
#[path = "acp_session_impl/tool_dispatch.rs"]
|
||||||
mod tool_dispatch;
|
mod tool_dispatch;
|
||||||
@@ -1256,6 +1256,17 @@ mod rewind_synthetic_turn_tests;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "acp_session_tests/rewrite_zero_turn_prefix_tests.rs"]
|
#[path = "acp_session_tests/rewrite_zero_turn_prefix_tests.rs"]
|
||||||
mod rewrite_zero_turn_prefix_tests;
|
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.
|
/// Pins the `SubagentFinished` usage-fold attribution gate.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "acp_session_tests/subagent_usage_fold_tests.rs"]
|
#[path = "acp_session_tests/subagent_usage_fold_tests.rs"]
|
||||||
|
|||||||
@@ -634,14 +634,12 @@ impl SessionActor {
|
|||||||
&active_session_config,
|
&active_session_config,
|
||||||
Some(self.max_retries),
|
Some(self.max_retries),
|
||||||
);
|
);
|
||||||
// A grok (oauth-platform) image-describe model must not inherit the
|
// An image-describe model on another provider must not inherit the
|
||||||
// session (Kimi) bearer_resolver stamped by `finalize_*`; re-point it at
|
// 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 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 {
|
if aux_resolved {
|
||||||
self.repoint_aux_bearer_resolver_for_oauth(
|
self.repoint_aux_bearer_resolver(&mut sampler_config, &self.image_description_model);
|
||||||
&mut sampler_config,
|
|
||||||
&self.image_description_model,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let client = kigi_sampler::SamplingClient::new(sampler_config).map_err(|e| {
|
let client = kigi_sampler::SamplingClient::new(sampler_config).map_err(|e| {
|
||||||
acp::Error::internal_error().data(format!(
|
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
|
/// BYOK status still refresh against the first-party cli-chat-proxy hosts without
|
||||||
/// risking a session-token leak to a third-party BYOK endpoint.
|
/// risking a session-token leak to a third-party BYOK endpoint.
|
||||||
endpoint_is_first_party: bool,
|
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 {
|
impl SessionTokenAuthGate {
|
||||||
/// Single place `is_session_based` / `endpoint_is_first_party` are derived,
|
/// 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(
|
fn new(
|
||||||
auth_method_id: Option<&acp::AuthMethodId>,
|
auth_method_id: Option<&acp::AuthMethodId>,
|
||||||
model_byok: crate::agent::auth_method::ModelByok,
|
model_byok: crate::agent::auth_method::ModelByok,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
|
model_platform: Option<kigi_models::PlatformId>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
is_session_based: auth_method_id
|
is_session_based: auth_method_id
|
||||||
.is_some_and(crate::agent::auth_method::is_session_based_method),
|
.is_some_and(crate::agent::auth_method::is_session_based_method),
|
||||||
model_byok,
|
model_byok,
|
||||||
endpoint_is_first_party: crate::util::is_first_party_url(base_url),
|
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 {
|
fn active(self) -> bool {
|
||||||
@@ -57,6 +74,7 @@ impl SessionTokenAuthGate {
|
|||||||
self.is_session_based,
|
self.is_session_based,
|
||||||
self.model_byok,
|
self.model_byok,
|
||||||
self.endpoint_is_first_party,
|
self.endpoint_is_first_party,
|
||||||
|
self.endpoint_takes_session_credential,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +145,43 @@ fn auth_manager_bearer_resolver(
|
|||||||
) -> kigi_sampler::SharedBearerResolver {
|
) -> kigi_sampler::SharedBearerResolver {
|
||||||
std::sync::Arc::new(AuthManagerBearerResolver(am))
|
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 {
|
impl SessionActor {
|
||||||
pub(super) async fn prepare_tool_definitions_timed(&self) -> (Vec<ToolDefinition>, u64) {
|
pub(super) async fn prepare_tool_definitions_timed(&self) -> (Vec<ToolDefinition>, u64) {
|
||||||
let mcp_wait_start = std::time::Instant::now();
|
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 {
|
fn auth_gate(&self, model_id: &str, base_url: &str) -> SessionTokenAuthGate {
|
||||||
let byok = self.model_auth_facts(model_id).byok;
|
let byok = self.model_auth_facts(model_id).byok;
|
||||||
let auth_method = self.auth_method_id.load();
|
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
|
/// The [`AuthManager`](crate::auth::AuthManager) that governs INFERENCE auth
|
||||||
/// for the model whose routing slug is `model` (the sampling config's
|
/// for the model whose routing slug is `model` (the sampling config's
|
||||||
@@ -238,26 +298,30 @@ impl SessionActor {
|
|||||||
// and resolves to the primary.
|
// and resolves to the primary.
|
||||||
let managed_key = self.managed_key_for_model(model);
|
let managed_key = self.managed_key_for_model(model);
|
||||||
crate::auth::oauth_registry::manager_for_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),
|
managed_key.as_deref().unwrap_or(model),
|
||||||
self.auth_manager.as_ref(),
|
self.auth_manager.as_ref(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/// Recover the managed catalog key (`{platform}/{model}`) for a routing slug
|
/// Recover the managed catalog key (`{platform}/{model}`) for a routing slug
|
||||||
/// from the live catalog. `None` for a bare / config / unlisted model.
|
/// 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> {
|
fn managed_key_for_model(&self, model: &str) -> Option<String> {
|
||||||
let models = self.models_manager.models();
|
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
|
/// The registry platform the routing slug `model` belongs to, from the SAME
|
||||||
/// OAuth platform (xai-grok). The gate for re-pointing an aux model's
|
/// lookup [`Self::auth_manager_for_model`] routes on. `None` for a bare /
|
||||||
/// bearer_resolver away from the session (Kimi) resolver — a first-party /
|
/// `[model.*]` / unlisted model.
|
||||||
/// non-oauth model returns `false` and keeps the stamped session resolver.
|
fn model_platform(&self, model: &str) -> Option<kigi_models::PlatformId> {
|
||||||
fn model_is_oauth_platform(&self, model: &str) -> bool {
|
let models = self.models_manager.models();
|
||||||
let managed_key = self.managed_key_for_model(model);
|
let current = self.models_manager.current_model_id();
|
||||||
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model))
|
crate::agent::models::platform_for_slug(&models, Some(current.0.as_ref()), model)
|
||||||
.and_then(|(platform, _)| platform.oauth())
|
|
||||||
.is_some()
|
|
||||||
}
|
}
|
||||||
/// Whether `model` routes to the Claude Pro/Max OAuth-Messages platform
|
/// Whether `model` routes to the Claude Pro/Max OAuth-Messages platform
|
||||||
/// (claude-pro-max) — the gate for the sampler's OAuth Messages adaptation
|
/// (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
|
/// which is ChatCompletions) returns `false`, keeping the API-key Anthropic
|
||||||
/// / MiniMax Messages requests byte-identical.
|
/// / MiniMax Messages requests byte-identical.
|
||||||
fn model_is_anthropic_oauth(&self, model: &str) -> bool {
|
fn model_is_anthropic_oauth(&self, model: &str) -> bool {
|
||||||
let managed_key = self.managed_key_for_model(model);
|
self.model_platform(model).is_some_and(|platform| {
|
||||||
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model)).is_some_and(
|
platform.oauth().is_some()
|
||||||
|(platform, _)| {
|
&& platform.wire_api() == kigi_models::PlatformWireApi::Messages
|
||||||
platform.oauth().is_some()
|
})
|
||||||
&& platform.wire_api() == kigi_models::PlatformWireApi::Messages
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/// Whether `model` routes to the GitHub Copilot ChatCompletions platform
|
/// Whether `model` routes to the GitHub Copilot ChatCompletions platform
|
||||||
/// (github-copilot) — the gate for the sampler's editor-identity headers +
|
/// (github-copilot) — the gate for the sampler's editor-identity headers +
|
||||||
/// `X-Initiator`. Every other model returns `false`, keeping the other
|
/// `X-Initiator`. Every other model returns `false`, keeping the other
|
||||||
/// ChatCompletions providers byte-identical.
|
/// ChatCompletions providers byte-identical.
|
||||||
fn model_is_github_copilot(&self, model: &str) -> bool {
|
fn model_is_github_copilot(&self, model: &str) -> bool {
|
||||||
let managed_key = self.managed_key_for_model(model);
|
self.model_platform(model)
|
||||||
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model))
|
.is_some_and(kigi_models::PlatformId::sends_copilot_editor_headers)
|
||||||
.is_some_and(|(platform, _)| platform.sends_copilot_editor_headers())
|
|
||||||
}
|
}
|
||||||
/// Whether `model` routes to the ChatGPT/Codex Responses platform
|
/// Whether `model` routes to the ChatGPT/Codex Responses platform
|
||||||
/// (openai-codex) — the gate for the sampler's Codex identity headers
|
/// (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
|
/// returns `false`, keeping the API-key `openai` Responses request
|
||||||
/// byte-identical.
|
/// byte-identical.
|
||||||
fn model_is_openai_codex(&self, model: &str) -> bool {
|
fn model_is_openai_codex(&self, model: &str) -> bool {
|
||||||
let managed_key = self.managed_key_for_model(model);
|
self.model_platform(model)
|
||||||
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model))
|
.is_some_and(kigi_models::PlatformId::sends_codex_responses_headers)
|
||||||
.is_some_and(|(platform, _)| platform.sends_codex_responses_headers())
|
|
||||||
}
|
}
|
||||||
/// LEAK guard for the stamped aux paths (auto-mode classifier, image
|
/// LEAK guard for the stamped aux paths (auto-mode classifier, image
|
||||||
/// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`]
|
/// describe). Applies [`aux_bearer_resolver`] to the config
|
||||||
/// has copied the SESSION model's `bearer_resolver` onto an aux
|
/// [`crate::agent::config::stamp_session_local_sampler_fields`] just stamped
|
||||||
/// `SamplerConfig`, re-point it at the AUX model's OWN platform manager when
|
/// the SESSION model's `bearer_resolver` onto.
|
||||||
/// the aux model is an oauth platform (xai-grok) — so a grok aux model never
|
pub(super) fn repoint_aux_bearer_resolver(
|
||||||
/// 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(
|
|
||||||
&self,
|
&self,
|
||||||
cfg: &mut kigi_sampler::SamplerConfig,
|
cfg: &mut kigi_sampler::SamplerConfig,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
) {
|
) {
|
||||||
if self.model_is_oauth_platform(slug)
|
cfg.bearer_resolver = aux_bearer_resolver(
|
||||||
&& let Some(manager) = self.auth_manager_for_model(slug)
|
cfg.bearer_resolver.take(),
|
||||||
{
|
self.model_platform(slug),
|
||||||
cfg.bearer_resolver = Some(auth_manager_bearer_resolver(manager));
|
&cfg.base_url,
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
/// Emit a unified-log breadcrumb whenever the session-token refresh gate is
|
/// Emit a unified-log breadcrumb whenever the session-token refresh gate is
|
||||||
/// evaluated with an **`Unknown`** per-model BYOK status on a session-based
|
/// evaluated with an **`Unknown`** per-model BYOK status on a session-based
|
||||||
@@ -330,8 +385,9 @@ impl SessionActor {
|
|||||||
let ctx = serde_json::json!(
|
let ctx = serde_json::json!(
|
||||||
{ "site" : site, "model_byok" : gate.model_byok.as_str(), "is_session_based"
|
{ "site" : site, "model_byok" : gate.model_byok.as_str(), "is_session_based"
|
||||||
: gate.is_session_based, "endpoint_is_first_party" : gate
|
: gate.is_session_based, "endpoint_is_first_party" : gate
|
||||||
.endpoint_is_first_party, "refresh_active" : refresh_active, "base_url" :
|
.endpoint_is_first_party, "endpoint_takes_session_credential" : gate
|
||||||
base_url, }
|
.endpoint_takes_session_credential, "refresh_active" : refresh_active,
|
||||||
|
"base_url" : base_url, }
|
||||||
);
|
);
|
||||||
let sid = Some(self.session_info.id.0.as_ref());
|
let sid = Some(self.session_info.id.0.as_ref());
|
||||||
if refresh_active {
|
if refresh_active {
|
||||||
@@ -385,8 +441,12 @@ impl SessionActor {
|
|||||||
let creds = self.chat_state_handle.get_credentials().await;
|
let creds = self.chat_state_handle.get_credentials().await;
|
||||||
let model_facts = self.model_auth_facts(cfg.model.as_str());
|
let model_facts = self.model_auth_facts(cfg.model.as_str());
|
||||||
let auth_method = self.auth_method_id.load();
|
let auth_method = self.auth_method_id.load();
|
||||||
let gate =
|
let gate = SessionTokenAuthGate::new(
|
||||||
SessionTokenAuthGate::new(auth_method.as_deref(), model_facts.byok, &cfg.base_url);
|
auth_method.as_deref(),
|
||||||
|
model_facts.byok,
|
||||||
|
&cfg.base_url,
|
||||||
|
self.model_platform(cfg.model.as_str()),
|
||||||
|
);
|
||||||
let use_bearer_resolver = gate.active();
|
let use_bearer_resolver = gate.active();
|
||||||
self.log_auth_gate_unknown("reconstruct_full_config", gate, &cfg.base_url);
|
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
|
// Resolve the bearer from the ACTIVE model's OWN manager: a grok model
|
||||||
@@ -589,17 +649,18 @@ impl SessionActor {
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
) -> Option<kigi_sampler::SamplerConfig> {
|
) -> Option<kigi_sampler::SamplerConfig> {
|
||||||
let creds = self.chat_state_handle.get_credentials().await;
|
let creds = self.chat_state_handle.get_credentials().await;
|
||||||
// Resolve the aux token by the aux model's OWN platform: a grok
|
let models = self.models_manager.models();
|
||||||
// (oauth-platform) aux model draws its pooled grok token or `None` —
|
// Resolve the aux token by the aux model's OWN platform AND endpoint: a
|
||||||
// NEVER the primary Kimi session token (which `resolve_credentials`
|
// grok (oauth-platform) aux model draws its pooled grok token or `None`,
|
||||||
// would otherwise stamp onto an api.x.ai request). A first-party /
|
// and an API-key registry platform draws NOTHING — NEVER the primary
|
||||||
// non-oauth aux model still gets the primary (byte-identical).
|
// Kimi session token (which `resolve_credentials` would otherwise stamp
|
||||||
let session_key = crate::auth::oauth_registry::session_key_for_model(
|
// onto an api.x.ai / api.deepseek.com request). The first-party
|
||||||
&crate::util::kigi_home::kigi_home(),
|
// subscription channel still gets the primary (byte-identical).
|
||||||
|
let session_key = crate::auth::oauth_registry::session_key_for_catalog_model(
|
||||||
|
&models,
|
||||||
slug,
|
slug,
|
||||||
self.auth_manager.as_ref(),
|
self.auth_manager.as_ref(),
|
||||||
);
|
);
|
||||||
let models = self.models_manager.models();
|
|
||||||
let endpoints = self.models_manager.endpoints();
|
let endpoints = self.models_manager.endpoints();
|
||||||
crate::agent::config::resolve_aux_model_sampling_config(
|
crate::agent::config::resolve_aux_model_sampling_config(
|
||||||
slug,
|
slug,
|
||||||
@@ -625,10 +686,11 @@ impl SessionActor {
|
|||||||
&active_session_config,
|
&active_session_config,
|
||||||
Some(self.max_retries),
|
Some(self.max_retries),
|
||||||
);
|
);
|
||||||
// LEAK 1b: a grok aux classifier must not inherit the SESSION model's
|
// LEAK 1b: the aux classifier must not inherit the SESSION model's
|
||||||
// (Kimi) bearer_resolver stamped above; re-point it at grok's own
|
// (Kimi) bearer_resolver stamped above — re-point it at an OAuth aux
|
||||||
// manager (its pooled token, or None). No-op for a non-oauth aux.
|
// model's own manager, or clear it for an API-key-platform / third-party
|
||||||
self.repoint_aux_bearer_resolver_for_oauth(&mut cfg, slug);
|
// aux endpoint. No-op for the first-party subscription channel.
|
||||||
|
self.repoint_aux_bearer_resolver(&mut cfg, slug);
|
||||||
let model = cfg.model.clone();
|
let model = cfg.model.clone();
|
||||||
let client = kigi_sampler::SamplingClient::new(cfg)
|
let client = kigi_sampler::SamplingClient::new(cfg)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -774,6 +836,8 @@ impl SessionActor {
|
|||||||
session_id = % self.session_info.id.0, is_session_based = gate
|
session_id = % self.session_info.id.0, is_session_based = gate
|
||||||
.is_session_based, model_byok = gate.model_byok.as_str(),
|
.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,
|
||||||
"auth recovery: sampler 401 not refreshable (api-key auth) — surfacing 401",
|
"auth recovery: sampler 401 not refreshable (api-key auth) — surfacing 401",
|
||||||
);
|
);
|
||||||
kigi_log::unified_log::warn(
|
kigi_log::unified_log::warn(
|
||||||
@@ -783,7 +847,9 @@ impl SessionActor {
|
|||||||
{ "kind" : error.kind.as_str(), "status_code" : error
|
{ "kind" : error.kind.as_str(), "status_code" : error
|
||||||
.status_code, "is_session_based" : gate.is_session_based,
|
.status_code, "is_session_based" : gate.is_session_based,
|
||||||
"model_byok" : gate.model_byok.as_str(),
|
"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)
|
.map(|c| c.model)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let Some(ref key) = current_key else { return };
|
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(¤t_model_id).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(new_key) = self.reload_api_key_from_config(¤t_model_id) else {
|
let Some(new_key) = self.reload_api_key_from_config(¤t_model_id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1161,7 +1236,7 @@ mod bearer_resolver_tests {
|
|||||||
.oauth()
|
.oauth()
|
||||||
.expect("xai-grok carries an OAuthConfig");
|
.expect("xai-grok carries an OAuthConfig");
|
||||||
let grok = crate::auth::oauth_registry::global_manager_for(
|
let grok = crate::auth::oauth_registry::global_manager_for(
|
||||||
&crate::util::kigi_home::kigi_home(),
|
&crate::auth::oauth_registry::pool_home(),
|
||||||
oauth,
|
oauth,
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
|
|||||||
+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};
|
use crate::agent::auth_method::{ModelByok, session_token_auth_gate as gate};
|
||||||
// Non-session methods never refresh, regardless of BYOK status or endpoint.
|
// Non-session methods never refresh, regardless of BYOK status or endpoint.
|
||||||
for fp in [false, true] {
|
for fp in [false, true] {
|
||||||
assert!(!gate(false, ModelByok::NotByok, fp));
|
assert!(!gate(false, ModelByok::NotByok, fp, true));
|
||||||
assert!(!gate(false, ModelByok::Byok, fp));
|
assert!(!gate(false, ModelByok::Byok, fp, true));
|
||||||
assert!(!gate(false, ModelByok::Unknown, fp));
|
assert!(!gate(false, ModelByok::Unknown, fp, true));
|
||||||
// Session method: a definite classification ignores the endpoint —
|
// Session method on an endpoint that DOES take the session credential
|
||||||
// NotByok always refreshes (only ever routes to the session endpoint),
|
// (kimi-code, an OAuth platform's own pool, or a bare / [model.*]
|
||||||
// a genuine per-model Byok never does.
|
// model): a definite classification ignores the endpoint — NotByok
|
||||||
assert!(gate(true, ModelByok::NotByok, fp));
|
// refreshes, a genuine per-model Byok never does.
|
||||||
assert!(!gate(true, ModelByok::Byok, fp));
|
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
|
// Session method + Unknown BYOK: refresh only against a first-party xAI
|
||||||
// host, so a transiently-unclassifiable config can't demote a live session
|
// 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
|
// (the stale-token 401 regression) yet the session token never leaks to a
|
||||||
// third-party BYOK endpoint. This arm was unconditionally `false` pre-fix.
|
// third-party BYOK endpoint. This arm was unconditionally `false` pre-fix.
|
||||||
assert!(gate(true, ModelByok::Unknown, true));
|
assert!(gate(true, ModelByok::Unknown, true, true));
|
||||||
assert!(!gate(true, ModelByok::Unknown, false));
|
assert!(!gate(true, ModelByok::Unknown, false, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-fix, the gate read `auth_type` and skipped recovery here, 401'ing every
|
/// 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