diff --git a/data/provider_fingerprint.yaml b/data/provider_fingerprint.yaml index e47b382..e71c3bc 100644 --- a/data/provider_fingerprint.yaml +++ b/data/provider_fingerprint.yaml @@ -1,7 +1,12 @@ # Provider Fingerprint Registry (POC) # -# 用途:对已抽取的 secret token 做 provider 软性分类 + 二次校验 -# Why YAML:ControlPanel 可下发热更,新 provider 出现无需改代码发版 +# 用途:对已抽取的 secret token 做 provider 软性分类 + 二次校验 + 上游路由/模型映射真相源 +# Why YAML:单一真相源、跨语言(Rust serde / Go yaml.v3)共读的声明式配置。 +# 🔴 勘误(2026-07-18):本文件**编译期内嵌**(Rust include_str! / Go go:embed), +# **不是** ControlPanel 下发热更。改任一行 = 改源码 = 重编译四消费方 = 走完整发版 +# (round +1,Stage 4-6 重跑)。runtime override + 签名校验是 B8(Rust doc 自陈 v1.1+),本期不做。 +# 🔴 本文件随二进制分发给所有客户,是**公开的厂商事实表** —— 🚫 严禁写入任何 secret / key / +# base_url_override / 客户数据(越级反例:用第 1 级安全换第 8 级实现)。 # # tier 含义: # confirmed —— 前缀极具特异性,FP 几乎为 0(sk-ant-api03 / AKIA / gsk_ 等) @@ -108,7 +113,12 @@ provider_routes: - { host: "openrouter.ai", protocol: openai_compatible, provider: openrouter, base_url: "https://openrouter.ai/api", version: "/v1" } - { host: "api.perplexity.ai", protocol: openai_compatible, provider: perplexity, base_url: "https://api.perplexity.ai", version: "" } - { host: "generativelanguage.googleapis.com", protocol: gemini, provider: google_gemini, base_url: "https://generativelanguage.googleapis.com", version: "/v1beta" } - - { host: "open.bigmodel.cn", protocol: openai_compatible, provider: zhipu, base_url: "https://open.bigmodel.cn/api/paas", version: "" } + # zhipu / GLM (open.bigmodel.cn) 一 host 三协议端点 —— P1b/D-2b 扩键 (host, path_prefix)。 + # 兜底行 path_prefix:"" 保持既有 openai_compatible + /api/paas 语义不变(向后兼容,18 行零改动围栏锁定)。 + # 另两行显式 path_prefix,让客户粘贴的 /api/anthropic 与 /api/coding/paas/v4 端点各自命中正确协议/base_url。 + - { host: "open.bigmodel.cn", protocol: openai_compatible, provider: zhipu, base_url: "https://open.bigmodel.cn/api/paas", version: "" } + - { host: "open.bigmodel.cn", path_prefix: "/api/anthropic", protocol: anthropic, provider: zhipu, base_url: "https://open.bigmodel.cn/api/anthropic", version: "/v1" } + - { host: "open.bigmodel.cn", path_prefix: "/api/coding/paas/v4", protocol: openai_compatible, provider: zhipu, base_url: "https://open.bigmodel.cn/api/coding/paas", version: "/v4" } - { host: "ark.cn-beijing.volces.com", protocol: openai_compatible, provider: doubao, base_url: "https://ark.cn-beijing.volces.com/api", version: "/v3" } - { host: "dashscope.aliyuncs.com", protocol: openai_compatible, provider: qwen, base_url: "https://dashscope.aliyuncs.com/compatible-mode", version: "/v1" } - { host: "api.siliconflow.cn", protocol: openai_compatible, provider: siliconflow, base_url: "https://api.siliconflow.cn", version: "/v1" } @@ -130,6 +140,30 @@ aggregator_families: - yunwu # 云雾 yunwu.ai - zeroeleven # 0011.ai +# ── provider_model_maps (P1 / design D-2): per-provider-CODE 模型名映射 ────────── +# +# 解 R-A:让「客户端固定用某模型名、上游真实模型名不同」的接入无需改客户端即可打通。 +# 键 = provider CODE(对齐 provider_routes.provider),故独立成 code-keyed 段: +# - 🚫 不挂 provider_routes 行(一 provider 多端点行会重复 = 分裂真相源,禁止 #13) +# - 🚫 不挂 classifier `providers` 段(那段按 classifier id 键控,非 provider code) +# 仿 family_login_urls 的 per-key map 先例。查表:路由行定出 provider → ModelMapFor(provider)。 +# +# match ∈ { 字面 client id "claude-opus-4-8" | 角色 opus/sonnet/haiku/fable | 通配 "*" } +# 匹配序(精确 > 角色 > 通配,歧义不猜,design D-3);requested_model = 上游真实模型名。 +# unmatched ∈ { reject(异模型上游默认)| passthrough(官方同名) }。 +# 构建期校验(ValidateModelMaps):规则歧义 → MODEL_MAPPING_AMBIGUOUS;字面 claude-* 须 is_claude_safe; +# 🚫 任何 secret 形态字符串。 +provider_model_maps: + - provider: zhipu + unmatched: reject + models: + # 角色键控:Claude 升版本(4-8→4-9)映射不失效,且不触发发版(design D-3) + - { match: "opus", requested_model: "glm-4.6" } + - { match: "sonnet", requested_model: "glm-4.5" } + - { match: "haiku", requested_model: "glm-4.5-air" } + # 字面精确:需要钉具体版本时用(精确优先于角色) + - { match: "claude-opus-4-8", requested_model: "glm-4.6" } + providers: # ============ Tier: confirmed(前缀高特异性,直接打上provider标签) ============ diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 7e36a4e..dc0f8de 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -2849,12 +2849,26 @@ fn apply_snapshot_to_cache( current_account_id: &str, ) { use std::collections::HashSet; - let seen_ids: HashSet = items.iter().map(|i| i.virtual_key_id.clone()).collect(); + // P1e (design D-11): the cache is one row per binding, so the sync tracks the + // full (vk, protocol, provider) triple — preserving each binding's own local + // ciphertext and pruning a single removed binding without touching the VK's + // other bindings. \x1f cannot appear in an id (matches the projection prune). + let binding_key = |vk: &str, proto: &str, prov: &str| format!("{vk}\u{1f}{proto}\u{1f}{prov}"); + let seen_bindings: HashSet = items + .iter() + .map(|i| binding_key(&i.virtual_key_id, &i.protocol_type, &i.provider_code)) + .collect(); for item in items { - let existing = storage::get_virtual_key_cache(&item.virtual_key_id) - .ok() - .flatten(); + // Read THIS binding's existing row (not just any row for the VK) so the + // preserved ciphertext/nonce belongs to the right credential. + let existing = storage::get_virtual_key_cache_binding( + &item.virtual_key_id, + &item.protocol_type, + &item.provider_code, + ) + .ok() + .flatten(); // Preserve local-only fields from the existing cache entry. let local_alias = existing.as_ref().and_then(|e| e.local_alias.clone()); @@ -2955,11 +2969,19 @@ fn apply_snapshot_to_cache( let mut pruned = 0usize; for entry in cached { if entry.owner_account_id.as_deref() == Some(current_account_id) - && !seen_ids.contains(&entry.virtual_key_id) + && !seen_bindings.contains(&binding_key( + &entry.virtual_key_id, + &entry.protocol_type, + &entry.provider_code, + )) { - // If this key was the active proxy key, clear the active key config - // so the proxy stops routing it on next reload. - if entry.local_state == "active" { + // If this binding's VK was the active proxy key AND the whole VK is + // gone (no surviving binding), clear the active key config so the + // proxy stops routing it on next reload. + let vk_gone = !items + .iter() + .any(|i| i.virtual_key_id == entry.virtual_key_id); + if entry.local_state == "active" && vk_gone { if let Ok(Some(cfg)) = storage::get_active_key_config() { if cfg.key_type == crate::credential_type::CredentialType::ManagedVirtualKey && cfg.key_ref == entry.virtual_key_id @@ -2968,7 +2990,11 @@ fn apply_snapshot_to_cache( } } } - match storage::delete_virtual_key_cache_row(&entry.virtual_key_id) { + match storage::delete_virtual_key_cache_binding( + &entry.virtual_key_id, + &entry.protocol_type, + &entry.provider_code, + ) { Ok(()) => pruned += 1, Err(e) => { // Fall back to the legacy stale mark so the row at least @@ -3411,7 +3437,21 @@ fn run_full_snapshot_sync_opts( // Download the delivery payload (plaintext provider key over TLS). match client.get_key_delivery(&entry.virtual_key_id) { Ok(payload) => { - match payload.primary_binding() { + // P1e (design D-11): pull THIS cache row's OWN binding material. + // The cache is one row per binding (vk, protocol, provider), and the + // delivery payload carries every binding's plaintext key in its own + // slot/target — so a VK holding GLM + official gets each key stored + // under its own binding row (each loop iteration handles one). Match + // by (protocol, provider); fall back to the primary binding for a + // legacy single-binding row whose provider_code is empty/unset. + let matched = payload + .binding_for(&entry.protocol_type, &entry.provider_code) + .or_else(|| { + payload + .primary_binding() + .map(|b| (payload.primary_protocol_type(), b)) + }); + match matched { None => { eprintln!( " {} key '{}' has no active bindings — skipping.", @@ -3419,8 +3459,8 @@ fn run_full_snapshot_sync_opts( entry.alias ); } - Some(binding) => { - let protocol_type = payload.primary_protocol_type().to_string(); + Some((slot_protocol, binding)) => { + let protocol_type = slot_protocol.to_string(); let sync_supported_providers = if !payload.supported_providers.is_empty() { payload.supported_providers.clone() } else if !binding.provider_code.is_empty() { @@ -3913,7 +3953,7 @@ pub fn handle_key_sync( " {} Team key '{}' auto-activated as Primary for {}", crate::symbols::STAR.s().yellow(), vk_id.bold(), - p + crate::provider_registry::display_label(p) ); } } @@ -5036,9 +5076,20 @@ pub fn handle_key_use( use colored::Colorize; println!("Key '{}' supports multiple providers:", display_name.bold()); for (i, p) in providers.iter().enumerate() { - println!(" {} {}", format!("[{}]", i + 1).dimmed(), p); + // Same labeller as the summary table, the title line and `aikey list` — + // the picker showed bare `zhipu` while every other surface said + // `zhipu(GLM)`, so the list you choose FROM and the confirmation you get + // BACK spelled the same provider two different ways. + println!( + " {} {}", + format!("[{}]", i + 1).dimmed(), + crate::provider_registry::display_label(p) + ); } - print!("Select protocol(s) to set as Primary (comma-separated): "); + // P1f.11② (§19.9): this list is `supported_providers` (PROVIDER codes), + // not protocols — the old "Select protocol(s)" mislabel conflated the two + // axes (名词字典 provider≠protocol). Prompt for providers. + print!("Select provider(s) to set as Primary (comma-separated): "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; @@ -5077,14 +5128,23 @@ pub fn handle_key_use( use colored::Colorize; println!("Key '{}' supports multiple providers:", display_name.bold()); for (i, p) in providers.iter().enumerate() { - println!(" {} {}", format!("[{}]", i + 1).dimmed(), p); + // Same labeller as the summary table, the title line and `aikey list` — + // the picker showed bare `zhipu` while every other surface said + // `zhipu(GLM)`, so the list you choose FROM and the confirmation you get + // BACK spelled the same provider two different ways. + println!( + " {} {}", + format!("[{}]", i + 1).dimmed(), + crate::provider_registry::display_label(p) + ); } - print!("Select protocol(s) to set as Primary (comma-separated): "); + // P1f.11② (§19.9): PROVIDER codes, not protocols — fix the mislabel. + print!("Select provider(s) to set as Primary (comma-separated): "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; if input.trim().is_empty() { - return Err("No protocol selected.".into()); + return Err("No provider selected.".into()); } let mut selected = Vec::new(); for part in input.trim().split(',').map(|s| s.trim()) { @@ -5172,7 +5232,48 @@ pub fn handle_key_use( .unwrap_or(false), ); + // P1f.11① (§19.9): two-axis summary — PROTOCOL (route-row truth) and + // PROVIDER (brand + display_alias, e.g. `zhipu(GLM)`) shown as SEPARATE + // columns, matching the vault page's two axes so `aikey use` never conflates + // them again. Header row makes the axes explicit (product-designer §5.3). + use colored::Colorize as _; + // Auto-width both axis columns. The fixed `{:<12}` PROTOCOL column could + // not hold `openai_compatible` (17 chars) — the longest protocol simply + // pushed every following column out of alignment, and it is the protocol + // most likely to appear. Width is computed from the values actually + // being printed, so no new protocol can overflow it either. + let axis_cells: Vec<(String, String)> = bindings + .iter() + .filter(|b| provider_env_vars(&b.provider_code).is_some()) + .map(|b| { + ( + resolve_binding_protocol(b), + crate::provider_registry::display_label(&b.provider_code), + ) + }) + .collect(); + let proto_w = axis_cells + .iter() + .map(|(p, _)| p.chars().count()) + .chain(std::iter::once("PROTOCOL".len())) + .max() + .unwrap_or("PROTOCOL".len()); + let prov_w = axis_cells + .iter() + .map(|(_, p)| p.chars().count()) + .chain(std::iter::once("PROVIDER".len())) + .max() + .unwrap_or("PROVIDER".len()); + let mut rows: Vec = Vec::new(); + rows.push(format!( + " {:>() + .join(", ") ); crate::ui_frame::print_box(crate::symbols::ICON_GREEN_DOT.s(), &title, &rows); // 阶段7: Desktop is a cold-switch surface — when THIS use rewrote @@ -5223,6 +5335,42 @@ pub fn handle_key_use( Ok(()) } +/// P1f.11① (§19.9, L1): resolve the WIRE PROTOCOL for a provider binding shown in +/// the `aikey use` summary, from the fingerprint route row that owns its endpoint +/// — the SAME endpoint-truth source as the vault two-axis read model +/// (`commands_internal::query::two_axis_bindings`). This is a protocol, NOT the +/// provider family: the summary shows the two axes separately so a GLM-via-anthropic +/// binding reads "anthropic · zhipu(GLM)" instead of collapsing them. Multi-binding +/// per provider is an L3 (P1e) concern; at L1 each provider has one active binding. +fn resolve_binding_protocol(b: &storage::ProviderBinding) -> String { + use crate::credential_type::CredentialType; + use crate::commands_internal::parse::provider_fingerprint::protocol_for; + let (base_url, declared) = match b.key_source_type { + // Team keys carry base_url + protocol_type in the local cache. + CredentialType::ManagedVirtualKey => storage::get_virtual_key_cache(&b.key_source_ref) + .ok() + .flatten() + .map(|vk| (vk.base_url, vk.protocol_type)) + .unwrap_or_default(), + // Personal keys: resolve via the entry's stored base_url. + _ => ( + storage::get_entry_base_url(&b.key_source_ref) + .ok() + .flatten() + .unwrap_or_default(), + String::new(), + ), + }; + // Shared with `aikey list` — see `protocol_for`. It resolves endpoint-first + // and, when there is no endpoint, falls back to the PROVIDER'S ROUTE ROW, + // which is still a protocol. The previous last resort here was + // `provider_registry::family_of()`, i.e. a provider family printed under a + // PROTOCOL header, which also made `use` disagree with `list` for every key + // stored without a base_url. Empty when genuinely unresolvable — an honest + // blank beats a confident wrong axis. + protocol_for(&base_url, &b.provider_code, &declared) +} + /// Classify the `aikey use` summary line so it never overpromises /// (use-effectiveness self-check, 2026-07-10). /// @@ -5636,6 +5784,17 @@ pub(crate) fn audit_key_from_password( } fn derive_vault_key(password: &SecretString) -> Result<[u8; crypto::KEY_SIZE], String> { + // First-run bootstrap (2026-07-21): on a fresh machine the caller has just + // prompted the user to SET a new master password (`prompt_vault_password_fresh`'s + // no-vault branch), but nothing has written the vault yet — that path only + // collects the password, it doesn't initialize. Reading the salt straight away + // then failed with "Salt not found in vault. Vault may be corrupted.", which + // wedged `aikey key sync` / `aikey use` on every new install and told the user + // their vault was damaged when it had simply never been created. + // + // Delegating to the same helper VaultContext::new uses keeps ONE definition of + // "first run" — 🚫 don't inline a second salt-generation here. + crate::executor::ensure_vault_initialized(password)?; let salt = storage::get_salt()?; let (m, t, p) = storage::get_kdf_params()?; let secure_key = crypto::derive_key_with_params(password, &salt, m, t, p)?; @@ -6168,6 +6327,77 @@ mod snapshot_sync_debounce_tests { } } +#[cfg(test)] +mod first_run_vault_bootstrap_tests { + //! Fence for the 2026-07-21 first-run regression: on a machine with no + //! vault, `aikey key sync` prompted the user to SET a master password and + //! then died with "Salt not found in vault. Vault may be corrupted." + //! + //! Cause: `derive_vault_key` read `storage::get_salt()` directly, while the + //! password prompt for a brand-new vault only COLLECTS the password — + //! nothing had written `config.master_salt` yet. Every fresh install hit it + //! (`key sync`, and `use`/web-session via the same helper), and the message + //! blamed corruption for a vault that had simply never been created. + //! + //! These pin the two halves: the bootstrap happens, and it is idempotent + //! (a second call must NOT re-salt an existing vault — that would silently + //! orphan every entry encrypted under the old key). + + use super::*; + use secrecy::SecretString; + use tempfile::TempDir; + + /// Fresh dir, NO `initialize_vault` — exactly the state a new machine is in. + fn setup_uninitialized_vault() -> (TempDir, std::sync::MutexGuard<'static, ()>) { + let guard = crate::storage::TEST_VAULT_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let dir = TempDir::new().expect("tempdir"); + let db_path = dir.path().join("vault.db"); + unsafe { + std::env::set_var("AK_VAULT_PATH", db_path.to_str().unwrap()); + } + (dir, guard) + } + + #[test] + fn derive_vault_key_bootstraps_a_brand_new_vault() { + let (_dir, _guard) = setup_uninitialized_vault(); + // Precondition: this is the broken state — no salt at all. + assert!( + storage::get_salt().is_err(), + "test setup wrong: vault already initialized" + ); + + let pw = SecretString::new("first-run-password".to_string()); + let key = derive_vault_key(&pw).expect( + "derive_vault_key must bootstrap a fresh vault instead of reporting corruption", + ); + + assert_eq!(key.len(), crypto::KEY_SIZE); + assert!( + storage::get_salt().is_ok(), + "master_salt must be persisted after first-run bootstrap" + ); + } + + #[test] + fn bootstrap_is_idempotent_and_never_resalts() { + let (_dir, _guard) = setup_uninitialized_vault(); + let pw = SecretString::new("first-run-password".to_string()); + + let key1 = derive_vault_key(&pw).expect("first derive"); + let salt1 = storage::get_salt().expect("salt after first derive"); + let key2 = derive_vault_key(&pw).expect("second derive"); + let salt2 = storage::get_salt().expect("salt after second derive"); + + // A re-salt here would change the derived key and orphan every entry + // already encrypted under the old one. + assert_eq!(salt1, salt2, "second call must not re-salt the vault"); + assert_eq!(key1, key2, "same password must derive the same key"); + } +} + #[cfg(test)] mod core_tests { use super::*; diff --git a/src/commands_internal/parse.rs b/src/commands_internal/parse.rs index c9a5729..59b317c 100644 --- a/src/commands_internal/parse.rs +++ b/src/commands_internal/parse.rs @@ -316,13 +316,14 @@ fn run_parse_v2_rules(payload: &ParsePayload) -> Result, } #[derive(Debug, Deserialize)] @@ -79,6 +114,11 @@ struct Registry { #[allow(dead_code)] version: u32, providers: Vec, + /// P1 / design D-2: per-provider-code model name maps. See yaml + /// `provider_model_maps` section. Parsed for cross-language parity with + /// Go's providerroutes (the proxy is the primary consumer). + #[serde(default)] + provider_model_maps: Vec, /// v4.1 Stage 5+: 聚合网关 family 清单(openrouter / yunwu / zeroeleven 等) /// 见 yaml 顶部 `aggregator_families` 段落。 #[serde(default)] @@ -108,6 +148,8 @@ pub struct FingerprintClassifier { /// version). 同一 provider 可有多 host 行 (e.g. kimi 下 api.kimi.com 与 /// api.moonshot.cn 是两行,都 provider=kimi 但 base_url 不同)。 provider_routes: Vec, + /// P1 / design D-2: per-provider-code model_map (keyed by provider code). + provider_model_maps: Vec, } impl FingerprintClassifier { @@ -127,12 +169,68 @@ impl FingerprintClassifier { reg.aggregator_families.into_iter().collect(); let family_login_urls = reg.family_login_urls; let provider_routes = reg.provider_routes; + let provider_model_maps = reg.provider_model_maps; Self { entries, aggregator_families, family_login_urls, provider_routes, + provider_model_maps, + } + } + + /// Test-only: build a classifier from an arbitrary yaml string, mirroring + /// new_embedded's construction path. Lets resolve-parity hand-vectors use + /// the SAME yaml as the Go TestResolveModelAnchoredRoles fence. + #[cfg(test)] + fn from_yaml_str(yaml: &str) -> Result { + let reg: Registry = serde_yaml::from_str(yaml).map_err(|e| e.to_string())?; + let mut entries = Vec::new(); + for p in reg.providers { + let compiled = Regex::new(&format!("(?s){}", p.regex)).map_err(|e| e.to_string())?; + entries.push((p, compiled)); } + Ok(Self { + entries, + aggregator_families: reg.aggregator_families.into_iter().collect(), + family_login_urls: reg.family_login_urls, + provider_routes: reg.provider_routes, + provider_model_maps: reg.provider_model_maps, + }) + } + + /// P1 / design D-2: the model_map for a provider code (case-insensitive). + pub fn model_map_for(&self, provider: &str) -> Option<&ModelMap> { + self.provider_model_maps + .iter() + .find(|m| m.provider.eq_ignore_ascii_case(provider)) + } + + /// P1 / design D-2 & D-3: resolve a client model to the upstream model via + /// a provider's model_map. Precedence exact → role → wildcard. Returns + /// (effective_model, matched). Mirrors Go providerroutes.ResolveModel. + pub fn resolve_model(&self, provider: &str, requested: &str) -> (String, bool) { + let Some(mm) = self.model_map_for(provider) else { + return (requested.to_string(), false); + }; + for r in &mm.models { + if r.match_ == requested { + return (r.requested_model.clone(), true); + } + } + if let Some(role) = role_of_model(requested) { + for r in &mm.models { + if is_role_token(&r.match_) && r.match_.eq_ignore_ascii_case(&role) { + return (r.requested_model.clone(), true); + } + } + } + for r in &mm.models { + if r.match_ == "*" { + return (r.requested_model.clone(), true); + } + } + (requested.to_string(), false) } /// v4.1 Stage 5+: 从 inferred provider family 派生 protocol_types 列表。 @@ -161,6 +259,29 @@ impl FingerprintClassifier { .find(|r| r.host.eq_ignore_ascii_case(host)) } + /// P1b / design D-2b: path-aware route lookup. Extracts host + path from + /// a stored base_url and does a segment-aligned longest-prefix match on + /// `path_prefix`, mirroring Go's `Table.Lookup`. This keeps CLI display + /// (`official_url_for_route`) consistent with proxy execution for + /// multi-endpoint hosts (GLM's /api/anthropic vs /api/paas). For + /// single-row hosts it returns the same row as `route_for_host`. + pub fn route_for_base_url(&self, base_url: &str) -> Option<&ProviderRoute> { + let host = route_host_of(base_url)?; + let path = route_path_of(base_url); + let mut best: Option<&ProviderRoute> = None; + let mut best_len: i64 = -1; + for r in &self.provider_routes { + if r.host.eq_ignore_ascii_case(&host) + && path_prefix_matches(&r.path_prefix, &path) + && (r.path_prefix.len() as i64) > best_len + { + best_len = r.path_prefix.len() as i64; + best = Some(r); + } + } + best + } + /// v4.3: lookup the FIRST route matching a provider_code. Used as a family- /// level fallback when no host info is available (e.g. user picks a /// provider chip without pasting a URL). Multi-host providers (kimi) @@ -186,6 +307,11 @@ impl FingerprintClassifier { &self.provider_routes } + /// P1 / design D-2: whole provider_model_maps list (cross-language parity). + pub fn provider_model_maps(&self) -> &[ModelMap] { + &self.provider_model_maps + } + /// 全量 family → 登录页 URL 映射 (用于 `_internal rules` 把整张表透出给 Web UI) pub fn family_login_urls_map(&self) -> &std::collections::HashMap { &self.family_login_urls @@ -236,6 +362,83 @@ pub fn instance() -> &'static FingerprintClassifier { INSTANCE.get_or_init(FingerprintClassifier::new_embedded) } +/// The ONE protocol-axis resolver. Every surface that prints a PROTOCOL column +/// must call this so they cannot disagree about the same key. +/// +/// Resolution order, most authoritative first: +/// 1. the route row owning `base_url` — endpoint truth, same source as the +/// vault two-axis read model; +/// 2. `declared` — what the record itself says (team keys cache +/// `protocol_type` alongside their base_url); +/// 3. the provider's own first route row — heuristic for multi-host +/// providers, but still a PROTOCOL. +/// +/// Returns empty when nothing resolves. 🚫 Never fall back to a provider +/// family/code here: this column is the protocol axis, and a provider name in +/// it is the exact two-axis conflation this work exists to kill. +/// +/// Why it exists: `aikey list` grew steps 1-3 while `aikey use` had only 1-2 +/// and then fell back to `provider_registry::family_of()`. For any key without +/// a stored base_url — every key created by `aikey add --provider x` with no +/// `--base-url` — `use` printed `zhipu` / `openai` (provider families) where +/// `list` printed `openai_compatible`. Same key, two commands, two answers, and +/// one of them naming the wrong axis. +pub fn protocol_for(base_url: &str, provider: &str, declared: &str) -> String { + let c = instance(); + c.route_for_base_url(base_url) + .map(|r| r.protocol.clone()) + .filter(|p| !p.is_empty()) + .or_else(|| (!declared.is_empty()).then(|| declared.to_string())) + .or_else(|| { + c.route_for_provider(provider) + .map(|r| r.protocol.clone()) + .filter(|p| !p.is_empty()) + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod protocol_axis_tests { + use super::protocol_for; + + #[test] + fn base_url_wins_over_declared() { + // anthropic's official endpoint speaks the anthropic protocol even if a + // stale record declares otherwise + let p = protocol_for("https://api.anthropic.com", "anthropic", "openai_compatible"); + assert_eq!(p, "anthropic"); + } + + #[test] + fn declared_is_used_when_base_url_is_unknown() { + let p = protocol_for("https://gateway.invalid/v1", "zhipu", "anthropic"); + assert_eq!(p, "anthropic"); + } + + /// The regression: no base_url, nothing declared. `aikey use` used to print + /// the provider family here (`zhipu`, `openai`) — a PROVIDER in the + /// PROTOCOL column, and a disagreement with `aikey list`. + #[test] + fn falls_back_to_the_providers_route_protocol_never_to_a_provider_name() { + for (provider, expected) in [("openai", "openai_compatible"), ("anthropic", "anthropic")] { + let p = protocol_for("", provider, ""); + assert_eq!(p, expected, "provider {provider} resolved to {p}"); + } + // `anthropic` above is a protocol that happens to share its provider's + // name, so it can't witness the bug. `openai` can: the provider family + // is `openai`, the protocol is `openai_compatible`, and printing the + // former under a PROTOCOL header is exactly what regressed. + assert_ne!(protocol_for("", "openai", ""), "openai"); + assert_ne!(protocol_for("", "zhipu", ""), "zhipu"); + } + + #[test] + fn unknown_provider_resolves_to_empty_rather_than_its_own_name() { + let p = protocol_for("", "totally-unknown-provider", ""); + assert!(p.is_empty(), "expected empty, got {p}"); + } +} + // ─── v4.1 Stage 3 L3 enrich 扩展 ──────────────────────────────────────── // // V4.1 spike 在 YAML 里为每个 provider 增加了 `provider_family` / @@ -385,6 +588,88 @@ pub fn shell_var_family_and_pattern(var_name: &str) -> Option<(String, String)> None } +/// P1b / design D-2b: lowercase host of a base_url ("" scheme tolerant). +fn route_host_of(url: &str) -> Option { + let after = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .unwrap_or(url); + let end = after.find(['/', '?', '#', ':']).unwrap_or(after.len()); + let host = &after[..end]; + if host.is_empty() { + None + } else { + Some(host.to_ascii_lowercase()) + } +} + +/// P1b / design D-2b: path component of a base_url (leading '/', no query). +fn route_path_of(url: &str) -> String { + let after = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .unwrap_or(url); + match after.find('/') { + Some(i) => { + let rest = &after[i..]; + let end = rest.find(['?', '#']).unwrap_or(rest.len()); + rest[..end].to_string() + } + None => String::new(), + } +} + +/// P1b / design D-2b: segment-aligned prefix match, mirror of Go's +/// `pathPrefixMatches`. "/api/anthropic" matches "/api/anthropic" and +/// "/api/anthropic/v1" but NOT "/api/anthropicfoo". "" is the fallback. +fn path_prefix_matches(prefix: &str, path: &str) -> bool { + if prefix.is_empty() { + return true; + } + if path == prefix { + return true; + } + path.starts_with(&format!("{prefix}/")) +} + +/// P1 / design D-3: known role families a model_map `match` may name, in a +/// FIXED iteration order (mirror of Go rolesInMatchOrder). The order is +/// load-bearing: when two role tokens co-occur in one id (e.g. +/// "claude-opus-haiku-1"), role_of_model returns the first match in THIS order. +/// An array iterates deterministically, matching Go's ordered slice. +const KNOWN_ROLES: [&str; 4] = ["opus", "sonnet", "haiku", "fable"]; + +fn is_role_token(s: &str) -> bool { + KNOWN_ROLES.iter().any(|r| r.eq_ignore_ascii_case(s)) +} + +/// Extract the role family from a claude-style model id ("claude-opus-4-8" → +/// "opus"). Mirrors Go roleOfModel. +/// +/// Segment-aligned ONLY — the role must be a whole "-"-delimited token: exact, +/// prefix ("opus-…"), suffix ("…-opus"), or infix ("…-opus-…"). A loose +/// `contains("-{role}")` fallback used to live here; it made the anchored arms +/// dead code and mis-classified ids like "claude-haikuish-1" as haiku. Removed +/// — it feeds resolve_model → wrong upstream model otherwise. +/// +/// Case-folding parity: Rust to_ascii_lowercase and Go strings.ToLower (Unicode) +/// agree on the claude-id charset (ASCII a–z / 0–9 / '-'). They diverge only on +/// non-ASCII code points, none of which appear in a claude model id — no +/// residual behavioral difference on real ids. +fn role_of_model(model: &str) -> Option { + let lower = model.to_ascii_lowercase(); + for role in KNOWN_ROLES { + if lower == role + || lower.starts_with(&format!("{role}-")) + || lower.ends_with(&format!("-{role}")) + || lower.contains(&format!("-{role}-")) + { + return Some(role.to_string()); + } + } + None +} + /// URL host → family (E5 UrlHostPattern) /// /// 从 URL 抽 host,匹配 substring → 返回 (family, matched_pattern) @@ -451,6 +736,200 @@ mod tests { assert!(c.entries.len() >= 20, "registry size: {}", c.entries.len()); } + // --- P1b / design D-2b: (host, path_prefix) longest-prefix lookup --- + + #[test] + fn path_prefix_matches_segment_aligned() { + assert!(path_prefix_matches("", "/anything")); + assert!(path_prefix_matches("/api/anthropic", "/api/anthropic")); + assert!(path_prefix_matches("/api/anthropic", "/api/anthropic/v1")); + // segment alignment: must NOT swallow + assert!(!path_prefix_matches("/api/anthropic", "/api/anthropicfoo")); + assert!(!path_prefix_matches("/api/anth", "/api/anthropic")); + } + + #[test] + fn route_host_and_path_extraction() { + assert_eq!( + route_host_of("https://open.bigmodel.cn/api/anthropic").as_deref(), + Some("open.bigmodel.cn") + ); + assert_eq!( + route_path_of("https://open.bigmodel.cn/api/anthropic"), + "/api/anthropic" + ); + assert_eq!(route_path_of("https://open.bigmodel.cn"), ""); + assert_eq!( + route_path_of("https://open.bigmodel.cn/api/paas?x=1"), + "/api/paas" + ); + } + + #[test] + fn route_for_base_url_selects_glm_endpoint() { + let c = instance(); + // anthropic endpoint → anthropic row + let r = c + .route_for_base_url("https://open.bigmodel.cn/api/anthropic") + .expect("anthropic row"); + assert_eq!(r.protocol, "anthropic"); + assert_eq!(r.base_url, "https://open.bigmodel.cn/api/anthropic"); + // coding endpoint → openai row + let r2 = c + .route_for_base_url("https://open.bigmodel.cn/api/coding/paas/v4") + .expect("coding row"); + assert_eq!(r2.protocol, "openai_compatible"); + // bare host → "" fallback (paas) + let r3 = c + .route_for_base_url("https://open.bigmodel.cn") + .expect("fallback row"); + assert_eq!(r3.base_url, "https://open.bigmodel.cn/api/paas"); + } + + // P1c (design D-9): Rust half of the cross-language golden-fixture parity. + // Deserializes the SAME golden file the Go test uses (pkg/providerroutes/ + // testdata/registry_golden.json) with Rust structs and asserts the Rust + // parse of the embedded yaml matches it. If serde and Go's yaml.v3 diverge, + // one side fails against the shared golden. Path is relative to the crate + // root (cargo test CWD) in the monorepo workspace. + #[test] + fn golden_fixture_parity_rust() { + #[derive(serde::Deserialize)] + struct Golden { + provider_routes: Vec, + provider_model_maps: Vec, + } + let path = "../pkg/providerroutes/testdata/registry_golden.json"; + let bytes = std::fs::read(path) + .unwrap_or_else(|e| panic!("read golden {path}: {e} (monorepo workspace required)")); + let golden: Golden = serde_json::from_slice(&bytes).expect("parse golden json"); + // 防空断言 + assert!( + !golden.provider_routes.is_empty(), + "golden has zero routes — anti-empty assertion" + ); + + let c = instance(); + assert_eq!( + c.provider_routes(), + golden.provider_routes.as_slice(), + "Rust provider_routes parse != golden (regenerate golden + mirror in Go)" + ); + assert_eq!( + c.provider_model_maps(), + golden.provider_model_maps.as_slice(), + "Rust provider_model_maps parse != golden" + ); + } + + #[test] + fn model_map_resolve_zhipu_embedded() { + // P1 / design D-2/D-3: embedded zhipu map resolves opus family + exact. + let c = instance(); + assert_eq!( + c.resolve_model("zhipu", "claude-opus-4-8"), + ("glm-4.6".to_string(), true) + ); + // role opus (升版本不失效) + assert_eq!( + c.resolve_model("zhipu", "claude-opus-4-9"), + ("glm-4.6".to_string(), true) + ); + assert_eq!( + c.resolve_model("zhipu", "claude-sonnet-4-6"), + ("glm-4.5".to_string(), true) + ); + // provider without a map → passthrough, unmatched + let (m, matched) = c.resolve_model("openai", "gpt-4o"); + assert!(!matched); + assert_eq!(m, "gpt-4o"); + } + + // Unit-level resolve-parity fence for the role extractor. Mirror of the Go + // `TestRoleOfModelAnchored` test — the two must agree token-for-token. + // Covers every anchored position, ASCII case-folding, deterministic + // co-occurrence, and the NEGATIVE the old loose contains() missed. + #[test] + fn role_of_model_anchored() { + assert_eq!(role_of_model("claude-opus-4-8").as_deref(), Some("opus")); // infix + assert_eq!(role_of_model("opus-4").as_deref(), Some("opus")); // prefix + assert_eq!(role_of_model("claude-4-haiku").as_deref(), Some("haiku")); // suffix + assert_eq!(role_of_model("sonnet").as_deref(), Some("sonnet")); // exact + assert_eq!(role_of_model("CLAUDE-OPUS-4-8").as_deref(), Some("opus")); // case-fold + // co-occurrence → first in KNOWN_ROLES order (deterministic) + assert_eq!( + role_of_model("claude-opus-haiku-1").as_deref(), + Some("opus") + ); + assert_eq!(role_of_model("gpt-4o"), None); // no role token + // NEGATIVE: the removed loose contains("-haiku") would have mis-matched. + assert_eq!(role_of_model("claude-haikuish-1"), None); + } + + // Resolve-level (not just parse-level) parity fence. Hand-vectors mirror the + // Go `TestResolveModelAnchoredRoles` fence 1:1 against the SAME yaml. Shared + // golden of (provider,requested)->(effective,matched,policy) deferred as a + // follow-up: resolve_model returns (effective, matched) without policy, so a + // policy-carrying golden would need a wider signature (out of scope here). + #[test] + fn resolve_model_anchored_roles() { + let yaml = r#" +version: 1 +providers: [] +provider_model_maps: + - provider: zhipu + unmatched: reject + models: + - { match: "opus", requested_model: "glm-4.6" } + - { match: "sonnet", requested_model: "glm-4.5" } + - { match: "haiku", requested_model: "glm-4.5-air" } + - { match: "fable", requested_model: "glm-4-flash" } + - { match: "claude-opus-4-8", requested_model: "glm-4.6-pinned" } +"#; + let c = FingerprintClassifier::from_yaml_str(yaml).expect("parse test yaml"); + let want = |s: &str, m: bool| (s.to_string(), m); + assert_eq!( + c.resolve_model("zhipu", "claude-opus-4-8"), + want("glm-4.6-pinned", true) + ); // exact beats role + assert_eq!( + c.resolve_model("zhipu", "claude-opus-4-9"), + want("glm-4.6", true) + ); // infix + assert_eq!( + c.resolve_model("zhipu", "haiku-4-5"), + want("glm-4.5-air", true) + ); // prefix + assert_eq!( + c.resolve_model("zhipu", "claude-4-fable"), + want("glm-4-flash", true) + ); // suffix + assert_eq!(c.resolve_model("zhipu", "sonnet"), want("glm-4.5", true)); // exact token + assert_eq!( + c.resolve_model("zhipu", "claude-opus-haiku-1"), + want("glm-4.6", true) + ); // co-occurrence → opus + // NEGATIVE: no wildcard + anchored role → genuine miss. + assert_eq!( + c.resolve_model("zhipu", "claude-haikuish-1"), + want("claude-haikuish-1", false) + ); + } + + #[test] + fn route_for_base_url_backward_compat_single_row_hosts() { + // Fence (task 1b.4): a pre-P1b single-row host resolves identically + // for any path — extended key degrades to exact host match. + let c = instance(); + let by_host = c.route_for_host("api.anthropic.com").expect("host row"); + for p in ["", "/v1/messages", "/deep/path"] { + let url = format!("https://api.anthropic.com{p}"); + let r = c.route_for_base_url(&url).expect("must resolve"); + assert_eq!(r.provider, by_host.provider, "url={url}"); + assert_eq!(r.base_url, by_host.base_url, "url={url}"); + } + } + #[test] fn classify_anthropic_api_key() { let c = instance(); diff --git a/src/commands_internal/query.rs b/src/commands_internal/query.rs index dbfb708..944f3ad 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -407,6 +407,35 @@ fn team_protocol_source<'a>(provider_code: &'a str, protocol_type: &'a str) -> O } } +/// P1f / design D-12/D-13: the two-axis binding read model. Protocol and +/// provider are SEPARATE axes — protocol from the route row (endpoint truth, +/// D-12; falls back to the stored protocol_type), provider is the brand code, +/// provider_display_alias is the brand alias (GLM for zhipu). Returned as an +/// ARRAY from the start (length 1 today; multi-binding when the per-binding +/// cache lands, P1e) so web never has to migrate a scalar→array contract (D-13). +/// 🚫 Web must NOT derive protocol from provider (前端 §7) — that's why the +/// backend/CLI owns this projection. +fn two_axis_bindings( + provider_code: &str, + protocol_type: &str, + base_url: &str, +) -> serde_json::Value { + let classifier = crate::commands_internal::parse::provider_fingerprint::instance(); + let protocol = classifier + .route_for_base_url(base_url) + .map(|r| r.protocol.clone()) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| protocol_type.to_string()); + let display_alias = crate::provider_registry::lookup(provider_code) + .and_then(|e| e.display_alias) + .unwrap_or(""); + json!([{ + "protocol": protocol, + "provider": provider_code, + "provider_display_alias": display_alias, + }]) +} + fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec { let entries = match storage::list_virtual_key_cache_readonly() { Ok(v) => v, @@ -483,6 +512,10 @@ fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec Result } } + // ── Model mapping (P3.5 four-surface visibility · reads task-7.9 endpoint) ── + // Surface "configured but not effective". The proxy's mappingHealth is the + // single source of truth — doctor renders its verdict, never re-derives it. + // 3.6: mapping-missing is NOT user-fixable (ships with the installer), so it + // shows as an informational WARN (never fails the overall doctor). Silent when + // the proxy is down (the proxy-version row above already flagged that). + if let Ok(diag) = crate::commands_proxy::fetch_pipeline_diagnostics() { + let mm = &diag.model_mapping; + let reg = &diag.registry; + match mm.status.as_str() { + "degraded" => emit( + "model-mapping", + true, // informational — not a doctor failure (unfixable client-side) + &format!( + "{} configured but not taking effect (registry {})", + crate::symbols::WARN.s(), + reg.digest + ), + Some(if mm.reason.is_empty() { + "a mapping is set but recent requests didn't match it — update the installer to change mappings" + } else { + mm.reason.as_str() + }), + ), + "ok" => emit( + "model-mapping", + true, + &format!("active · registry {} · {} applied", reg.digest, mm.applied), + None, + ), + "inactive" => emit( + "model-mapping", + true, + &format!("none configured · registry {}", reg.digest), + None, + ), + _ => {} + } + } + // Usage-receipt pipeline heartbeat. Surfaces "receipts last landed N ago // / never observed" so a third-party CLI upgrade that silently broke the // kimi/claude receipt path (session-layout / payload drift) is visible diff --git a/src/commands_proxy.rs b/src/commands_proxy.rs index 1648e2a..dcb8e96 100644 --- a/src/commands_proxy.rs +++ b/src/commands_proxy.rs @@ -2075,6 +2075,90 @@ pub fn fetch_egress_state() -> Result, String> { Ok(body.egress) } +// ── Model-mapping health (P3.5 four-surface visibility · task 7.9) ────────── +// +// The proxy's `mappingHealth` (GET /v1/diagnostics/pipeline) is the SINGLE source +// of truth for "is a mapping configured but not taking effect?". The CLI surfaces +// (aikey doctor / aikey test) render its verdict verbatim — 🚫 no client-side +// re-derivation of the state (3.5: one judgment function, not per-caller markers). + +#[derive(Debug, Default, serde::Deserialize)] +pub struct MappingHealthWire { + /// "ok" | "degraded" | "inactive". + #[serde(default)] + pub status: String, + #[serde(default)] + pub reason: String, + #[serde(default)] + pub applied: i64, + #[serde(default)] + pub rejected: i64, + #[serde(default)] + pub passthrough_missing: i64, +} + +#[derive(Debug, Default, serde::Deserialize)] +pub struct RegistryProvenanceWire { + #[serde(default)] + pub digest: String, + #[serde(default)] + pub route_rows: i64, + #[serde(default)] + pub providers_with_model_map: Vec, +} + +#[derive(Debug, serde::Deserialize)] +pub struct PipelineDiagnosticsWire { + #[serde(default)] + pub registry: RegistryProvenanceWire, + #[serde(default)] + pub model_mapping: MappingHealthWire, +} + +/// Fetch the proxy's read-only model-mapping diagnostics. `Err` = proxy +/// unreachable (not running / older binary without the endpoint). +pub fn fetch_pipeline_diagnostics() -> Result { + let addr = proxy_listen_addr(None); + // Loopback admin call — never route through this shell's proxy env. + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(3)) + .build(); + let resp = agent + .get(&format!("http://{addr}/v1/diagnostics/pipeline")) + .call() + .map_err(|e| format!("cannot reach proxy at {addr}: {e}"))?; + resp.into_json() + .map_err(|e| format!("invalid diagnostics response: {e}")) +} + +/// P3.5 `aikey test` tail surface: emit a WARN line when a model mapping is +/// configured but NOT taking effect ("degraded"). 🚫 never changes the exit code +/// (3.5: tail WARN is informational). Skipped in `--json` mode — so the wrapper +/// hot path (claude/codex/kimi preflight, always --json) pays zero cost; only a +/// human `aikey test` sees it. Silent when mapping is ok/inactive or the proxy +/// diagnostics are unavailable (the proxy-not-running case is already surfaced). +pub fn print_mapping_tail_warn(json_mode: bool) { + use colored::Colorize; + if json_mode { + return; + } + if let Ok(diag) = fetch_pipeline_diagnostics() { + if diag.model_mapping.status == "degraded" { + let reason = if diag.model_mapping.reason.is_empty() { + "a model mapping is configured but recent requests didn't match it".to_string() + } else { + diag.model_mapping.reason.clone() + }; + eprintln!( + "{} model-mapping: {} (registry {})", + crate::symbols::WARN.s().yellow(), + reason, + diag.registry.digest + ); + } + } +} + // ── Per-account egress connectivity self-check (§5.4) ─────────────────────── // // The proxy owns the egress engine (Rust can't dial a mihomo group), so the CLI diff --git a/src/executor.rs b/src/executor.rs index 9314c85..341c005 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -39,31 +39,47 @@ struct VaultContext { salt: Vec, } +/// First-run vault bootstrap: create + salt the vault if it isn't initialized yet. +/// +/// Idempotent — a no-op once `master_salt` exists. Extracted from +/// `VaultContext::new` so paths that derive a vault key WITHOUT going through +/// VaultContext get the same auto-init instead of dying on a missing salt. +/// +/// Why this is a shared helper and not duplicated: `commands_account:: +/// derive_vault_key` used to read `storage::get_salt()` directly, so on a fresh +/// machine `aikey key sync` prompted for a NEW master password, wrote nothing, +/// and then failed with "Salt not found in vault. Vault may be corrupted." — +/// a misleading message, since the vault was never initialized rather than +/// damaged. One bootstrap implementation means that class of drift can't recur. +pub fn ensure_vault_initialized(password: &SecretString) -> Result<(), String> { + // Integrity check is delegated to `ensure_vault_integrity_or_quarantine` + // so callers that bypass VaultContext (e.g. `aikey list` via + // storage::list_entries_with_metadata) still benefit — the same helper + // is invoked from run_command's dispatch prelude. + ensure_vault_integrity_or_quarantine()?; + let vault_path = storage::get_vault_path()?; + let needs_init = if vault_path.exists() { + // Size-0 or post-quarantine path both surface as "salt missing", + // which means this is a fresh/reset vault that needs init. + storage::get_salt().is_err() + } else { + true + }; + if needs_init { + let mut salt = [0u8; 16]; + crypto::generate_salt(&mut salt)?; + storage::initialize_vault(&salt, password)?; + let _ = audit::initialize_audit_log(); + let _ = audit::log_audit_event(password, audit::AuditOperation::Init, None, true); + } + Ok(()) +} + impl VaultContext { fn new(password: &SecretString) -> Result { // Auto-initialize vault on first use so the user never needs a separate // initialization step before using any vault command. - // - // Integrity check is delegated to `ensure_vault_integrity_or_quarantine` - // so callers that bypass VaultContext (e.g. `aikey list` via - // storage::list_entries_with_metadata) still benefit — the same helper - // is invoked from run_command's dispatch prelude. - ensure_vault_integrity_or_quarantine()?; - let vault_path = storage::get_vault_path()?; - let needs_init = if vault_path.exists() { - // Size-0 or post-quarantine path both surface as "salt missing", - // which means this is a fresh/reset vault that needs init. - storage::get_salt().is_err() - } else { - true - }; - if needs_init { - let mut salt = [0u8; 16]; - crypto::generate_salt(&mut salt)?; - storage::initialize_vault(&salt, password)?; - let _ = audit::initialize_audit_log(); - let _ = audit::log_audit_event(password, audit::AuditOperation::Init, None, true); - } + ensure_vault_initialized(password)?; // Check rate limiting before attempting authentication let mut rate_limiter = crate::ratelimit::RateLimiter::load()?; diff --git a/src/main.rs b/src/main.rs index e90c3f9..ba955ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -536,6 +536,11 @@ fn run_unified_list( "virtual_key_id": e.virtual_key_id, "alias": e.alias, "provider_code": e.provider_code, + // Two-axis (P1f): protocol is a distinct axis from provider — a + // GLM key served via the anthropic wire protocol is + // provider_code=zhipu, protocol_type=anthropic. + "protocol_type": e.protocol_type, + "base_url": e.base_url, "key_status": e.key_status, "share_status": e.share_status, "local_state": e.local_state, @@ -548,12 +553,54 @@ fn run_unified_list( let bindings = storage::list_provider_bindings(profile_activation::DEFAULT_PROFILE) .unwrap_or_default(); + // Two-axis (P1f) list model: PROTOCOL (wire protocol) and PROVIDER + // (brand + display alias) are shown as SEPARATE columns — the same two + // axes as the vault page and the `aikey use` summary, so `aikey list` + // never re-collapses them. A team VK spanning several bindings appears + // as ONE row; the multi-valued axes render `primary (+N more)`. + let classifier = crate::commands_internal::parse::provider_fingerprint::instance(); + // Resolve the wire protocol for a (base_url, provider, declared) triple — + // the SAME endpoint-truth source used by the vault two-axis read model and + // `commands_account::resolve_binding_protocol`. + let resolve_protocol = |base_url: &str, provider: &str, declared: &str| -> String { + classifier + .route_for_base_url(base_url) + .map(|r| r.protocol.clone()) + .filter(|p| !p.is_empty()) + .or_else(|| (!declared.is_empty()).then(|| declared.to_string())) + .or_else(|| { + classifier + .route_for_provider(provider) + .map(|r| r.protocol.clone()) + }) + .unwrap_or_default() + }; + // Provider axis label: `code(alias)` (e.g. `zhipu(GLM)`); the alias never + // replaces the code — same convention as the vault chip + `aikey use`. + let provider_label = + |code: &str| -> String { crate::provider_registry::display_label(code) }; + // Push `v` into `acc` iff not already present (distinct, order-preserving). + let push_distinct = |acc: &mut Vec, v: String| { + if !v.is_empty() && !acc.contains(&v) { + acc.push(v); + } + }; + // Collapse a distinct, primary-first axis into `primary (+N more)`. + let collapse_axis = |vals: &[String]| -> String { + match vals.len() { + 0 => String::new(), + 1 => vals[0].clone(), + n => format!("{} (+{} more)", vals[0], n - 1), + } + }; + // Collect row data for auto-width calculation. // `active` = has at least one provider binding routing to this key. // Matches the `aikey route` convention: if the proxy is currently // serving some provider via this key, it's considered active. struct RowData { alias: String, + protocol: String, providers: String, primary_for: String, has_primary: bool, @@ -566,14 +613,11 @@ fn run_unified_list( let mut team_rows: Vec = Vec::new(); for entry in &entries { - let providers = if let Some(ref sp) = entry.supported_providers { - if !sp.is_empty() { - sp.join(",") - } else { - entry.provider_code.clone().unwrap_or_default() - } - } else { - entry.provider_code.clone().unwrap_or_default() + // Provider codes this key supports (legacy single `provider_code` when + // `supported_providers` is absent). + let codes: Vec = match entry.supported_providers.as_ref() { + Some(sp) if !sp.is_empty() => sp.clone(), + _ => entry.provider_code.clone().into_iter().collect(), }; let pf: Vec<&str> = bindings .iter() @@ -583,11 +627,33 @@ fn run_unified_list( }) .map(|b| b.provider_code.as_str()) .collect(); + // Primary axis value = the active-binding provider if any, else the + // first supported provider. Order the distinct set primary-first. + let primary_code = pf + .first() + .map(|s| s.to_string()) + .or_else(|| codes.first().cloned()) + .unwrap_or_default(); + let base_url = entry.base_url.as_deref().unwrap_or(""); + let mut prov_vals: Vec = Vec::new(); + let mut proto_vals: Vec = Vec::new(); + for code in std::iter::once(&primary_code) + .chain(codes.iter()) + .filter(|c| !c.is_empty()) + { + push_distinct(&mut prov_vals, provider_label(code)); + push_distinct(&mut proto_vals, resolve_protocol(base_url, code, "")); + } let is_active = !pf.is_empty(); personal_rows.push(RowData { alias: entry.alias.clone(), - providers, - primary_for: pf.join(","), + protocol: collapse_axis(&proto_vals), + providers: collapse_axis(&prov_vals), + primary_for: pf + .iter() + .map(|c| provider_label(c)) + .collect::>() + .join(","), has_primary: !pf.is_empty(), status: String::new(), // valid → not displayed created: entry @@ -598,26 +664,73 @@ fn run_unified_list( active: is_active, }); } + + // Group the (per-binding, post-P1e-re-grain) managed cache rows by VK so a + // multi-protocol / multi-provider team key renders as ONE row. + let mut vk_order: Vec<&str> = Vec::new(); + let mut vk_groups: std::collections::HashMap<&str, Vec<&storage::VirtualKeyCacheEntry>> = + std::collections::HashMap::new(); for e in &managed { - let display = e + let id = e.virtual_key_id.as_str(); + if !vk_groups.contains_key(id) { + vk_order.push(id); + } + vk_groups.entry(id).or_default().push(e); + } + for vk_id in &vk_order { + let group = &vk_groups[vk_id]; + let rep = group[0]; // VK-level fields are identical across its bindings. + let display = rep .local_alias .as_deref() - .unwrap_or(e.alias.as_str()) + .unwrap_or(rep.alias.as_str()) .to_string(); let pf: Vec<&str> = bindings .iter() .filter(|b| { b.key_source_type == credential_type::CredentialType::ManagedVirtualKey - && b.key_source_ref == e.virtual_key_id + && b.key_source_ref == *vk_id }) .map(|b| b.provider_code.as_str()) .collect(); + // Primary provider = the active-binding provider if any, else the VK's + // first binding. Both axes render primary-first. + let primary_prov = pf + .first() + .map(|s| s.to_string()) + .unwrap_or_else(|| rep.provider_code.clone()); + let primary_row = group + .iter() + .find(|e| e.provider_code == primary_prov) + .copied() + .unwrap_or(rep); + let mut prov_vals: Vec = Vec::new(); + let mut proto_vals: Vec = Vec::new(); + push_distinct(&mut prov_vals, provider_label(&primary_prov)); + push_distinct( + &mut proto_vals, + resolve_protocol( + &primary_row.base_url, + &primary_row.provider_code, + &primary_row.protocol_type, + ), + ); + for e in group.iter() { + push_distinct(&mut prov_vals, provider_label(&e.provider_code)); + push_distinct( + &mut proto_vals, + resolve_protocol(&e.base_url, &e.provider_code, &e.protocol_type), + ); + } // Unified status display: valid (hidden), expired, invalid, pending. - let is_group_vk = e + let is_group_vk = rep .oauth_group_id .as_deref() .map(|s| !s.is_empty()) .unwrap_or(false); + // Post-P1e ciphertext is per binding — the VK is delivered when ANY of + // its bindings carries local key material. + let has_material = group.iter().any(|e| e.provider_key_ciphertext.is_some()); let status = if is_group_vk { // Group VK: per-account material rides channel ③ (group-runtime), so // it has NO local provider_key_ciphertext BY DESIGN. The "pending" @@ -627,9 +740,9 @@ fn run_unified_list( // valid key); empty → no usable account (the seat was unbound from the // group, or the group has no enabled accounts) so the key won't route // until that's fixed — show it so the member isn't left guessing. - match e.key_status.as_str() { + match rep.key_status.as_str() { "active" => { - let has_candidates = e + let has_candidates = rep .group_accounts .as_deref() .map(|s| { @@ -646,11 +759,11 @@ fn run_unified_list( "expired" => "expired".to_string(), _ => "invalid".to_string(), } - } else if e.provider_key_ciphertext.is_none() { + } else if !has_material { "pending".to_string() // key not yet delivered to local vault } else { - match e.local_state.as_str() { - "active" | "synced_inactive" => match e.key_status.as_str() { + match rep.local_state.as_str() { + "active" | "synced_inactive" => match rep.key_status.as_str() { "active" => String::new(), // valid → not displayed "expired" => "expired".to_string(), _ => "invalid".to_string(), // revoked, recycled, etc. @@ -662,46 +775,62 @@ fn run_unified_list( _ => "invalid".to_string(), } }; - let suffix = if e.local_alias.is_some() { - format!(" (\u{2190} {})", e.alias) + let suffix = if rep.local_alias.is_some() { + format!(" (\u{2190} {})", rep.alias) } else { String::new() }; let is_active = !pf.is_empty(); team_rows.push(RowData { alias: display, - providers: e.provider_code.clone(), - primary_for: pf.join(","), + protocol: collapse_axis(&proto_vals), + providers: collapse_axis(&prov_vals), + primary_for: pf + .iter() + .map(|c| provider_label(c)) + .collect::>() + .join(","), has_primary: !pf.is_empty(), status, - created: format_date(e.synced_at), + created: format_date(rep.synced_at), suffix, active: is_active, }); } let all_data: Vec<&RowData> = personal_rows.iter().chain(team_rows.iter()).collect(); - let headers = ["ALIAS", "PROTOCOLS", "USING FOR", "STATUS", "CREATED"]; + let headers = [ + "ALIAS", + "PROTOCOL", + "PROVIDER", + "USING FOR", + "STATUS", + "CREATED", + ]; let pad = 2; let w_alias = headers[0] .len() .max(all_data.iter().map(|r| r.alias.len()).max().unwrap_or(0)) + pad; - let w_prov = headers[1].len().max( + let w_proto = headers[1] + .len() + .max(all_data.iter().map(|r| r.protocol.len()).max().unwrap_or(0)) + + pad; + let w_prov = headers[2].len().max( all_data .iter() .map(|r| r.providers.len()) .max() .unwrap_or(0), ) + pad; - let w_primary = headers[2].len().max( + let w_primary = headers[3].len().max( all_data .iter() .map(|r| r.primary_for.len()) .max() .unwrap_or(0), ) + pad; - let w_status = headers[3] + let w_status = headers[4] .len() .max(all_data.iter().map(|r| r.status.len()).max().unwrap_or(0)) + pad; @@ -722,27 +851,35 @@ fn run_unified_list( pf_padded }; let created_col = r.created.bright_black().to_string(); + let proto_display = if r.protocol.len() > w_proto { + format!("{}...", &r.protocol[..w_proto - 3]) + } else { + r.protocol.clone() + }; let prov_display = if r.providers.len() > w_prov { format!("{}...", &r.providers[..w_prov - 3]) } else { r.providers.clone() }; format!( - "{} {: = Vec::new(); rows.push(format!( @@ -753,13 +890,15 @@ fn run_unified_list( rows.push(format!( "{}", format!( - " {: Result<(), Box> { if !cli.json { commands_proxy::print_egress_presence(); } + // P3.5 tail WARN: mapping configured-but-not-effective. Never + // changes the exit code; skipped in --json (wrapper hot path). + commands_proxy::print_mapping_tail_warn(cli.json); std::process::exit(code); } else if let Some(ref alias) = alias { // ── Single-alias mode: resolve across personal/team/OAuth ── @@ -2435,6 +2577,9 @@ fn run_command(cli: &Cli) -> Result<(), Box> { if !cli.json { commands_proxy::print_egress_presence(); } + // P3.5 tail WARN: mapping configured-but-not-effective. Never + // changes the exit code; skipped in --json (wrapper hot path). + commands_proxy::print_mapping_tail_warn(cli.json); std::process::exit(code); } else { // ── No alias: test all active bindings (personal/team/OAuth) ── @@ -2510,6 +2655,9 @@ fn run_command(cli: &Cli) -> Result<(), Box> { if !cli.json { commands_proxy::print_egress_presence(); } + // P3.5 tail WARN: mapping configured-but-not-effective. Never + // changes the exit code; skipped in --json (wrapper hot path). + commands_proxy::print_mapping_tail_warn(cli.json); std::process::exit(code); } } diff --git a/src/migrations.rs b/src/migrations.rs index 6c07044..434b31a 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -469,14 +469,26 @@ pub mod v1_0_0_baseline { ) .map_err(|e| format!("Failed to ensure provider_account_tokens table: {}", e))?; - // Team-managed virtual key cache + // Team-managed virtual key cache. + // + // P1e / design D-11 (解 R-D): the grain is ONE ROW PER BINDING + // `(virtual_key_id, protocol_type, provider_code)` with the provider key + // ciphertext RIDING EACH ROW — so one VK can carry GLM(GLM key) AND the + // official Anthropic(official key) at once, each binding's material and + // upstream resolved independently. The pre-P1e grain was one-VK-one-row + // with a SCALAR credential (multiple providers were half-expressed via + // the `supported_providers`/`provider_base_urls` blobs, which only ever + // carried URLs — never a second protocol or a second key). Fresh installs + // get the composite-PK shape here; existing one-VK-one-row DBs are rebuilt + // in place by the idempotent re-grain block after the column retrofits + // below. `cache_schema_version` = 2 marks the binding grain. conn.execute( "CREATE TABLE IF NOT EXISTS managed_virtual_keys_cache ( - virtual_key_id TEXT PRIMARY KEY, + virtual_key_id TEXT NOT NULL, org_id TEXT NOT NULL, seat_id TEXT NOT NULL, alias TEXT NOT NULL, - provider_code TEXT NOT NULL, + provider_code TEXT NOT NULL DEFAULT '', protocol_type TEXT NOT NULL DEFAULT 'openai_compatible', base_url TEXT NOT NULL, credential_id TEXT NOT NULL, @@ -488,8 +500,9 @@ pub mod v1_0_0_baseline { expires_at INTEGER, provider_key_nonce BLOB, provider_key_ciphertext BLOB, - cache_schema_version INTEGER NOT NULL DEFAULT 1, - synced_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + cache_schema_version INTEGER NOT NULL DEFAULT 2, + synced_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (virtual_key_id, protocol_type, provider_code) )", [], ) @@ -588,6 +601,112 @@ pub mod v1_0_0_baseline { ensure_column(conn, "managed_virtual_keys_cache", col, ddl)?; } + // ── P1e / design D-11: re-grain managed_virtual_keys_cache in place ── + // + // A pre-P1e vault has this table with a SINGLE-column primary key + // (`virtual_key_id`). SQLite cannot change a primary key with ALTER, so + // move to the binding grain `(virtual_key_id, protocol_type, provider_code)` + // via the canonical create-copy-swap. Guard: only run when the live table + // still has exactly one PK column — fresh installs (created composite above) + // and already-migrated vaults have three, so this is a no-op on re-run + // (idempotent; safe to execute on every startup). + // + // Backfill semantics (🔴 lossless, one-way faithful): each existing + // one-VK-one-row row becomes EXACTLY ONE binding row — its scalar + // `(protocol_type, provider_code)` are already the composite key, and its + // `provider_key_ciphertext` rides that row unchanged. The extra providers + // that lived URL-only in `supported_providers`/`provider_base_urls` are NOT + // exploded into credential-less binding rows (they have no key material — + // that was exactly the pre-P1e limitation); they stay on the primary + // binding row's blobs and are superseded when the re-grained server + // projection re-syncs real per-binding material. NO ciphertext is decrypted, + // re-encrypted, or moved across the encryption boundary here — the BLOB is + // copied byte-for-byte, so the vault_key derivation and AES-GCM envelope are + // untouched (第 1 级安全评审 §1e.4: this migration does not widen the + // plaintext exposure window). + let cache_pk_cols: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('managed_virtual_keys_cache') WHERE pk > 0", + [], + |r| r.get(0), + ) + .unwrap_or(0); + if cache_pk_cols == 1 { + // Rebuild inside one transaction so a crash mid-migration leaves the + // old table intact (all-or-nothing; 🚫 no partial grain). + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("mvkc re-grain: begin tx: {}", e))?; + tx.execute_batch( + "CREATE TABLE managed_virtual_keys_cache__p1e_new ( + virtual_key_id TEXT NOT NULL, + org_id TEXT NOT NULL, + seat_id TEXT NOT NULL, + alias TEXT NOT NULL, + provider_code TEXT NOT NULL DEFAULT '', + protocol_type TEXT NOT NULL DEFAULT 'openai_compatible', + base_url TEXT NOT NULL, + credential_id TEXT NOT NULL, + credential_revision TEXT NOT NULL, + virtual_key_revision TEXT NOT NULL, + key_status TEXT NOT NULL DEFAULT 'active', + share_status TEXT NOT NULL DEFAULT 'pending_claim', + local_state TEXT NOT NULL DEFAULT 'synced_inactive', + expires_at INTEGER, + provider_key_nonce BLOB, + provider_key_ciphertext BLOB, + cache_schema_version INTEGER NOT NULL DEFAULT 2, + synced_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + local_alias TEXT, + supported_providers TEXT, + provider_base_urls TEXT, + owner_account_id TEXT, + extra TEXT, + oauth_group_id TEXT, + group_accounts TEXT, + routing_config TEXT, + my_assignment_override TEXT, + group_runtime TEXT, + owner_email TEXT, + group_alias TEXT, + PRIMARY KEY (virtual_key_id, protocol_type, provider_code) + ); + INSERT INTO managed_virtual_keys_cache__p1e_new ( + virtual_key_id, org_id, seat_id, alias, + provider_code, protocol_type, base_url, + credential_id, credential_revision, virtual_key_revision, + key_status, share_status, local_state, expires_at, + provider_key_nonce, provider_key_ciphertext, + cache_schema_version, synced_at, + local_alias, supported_providers, provider_base_urls, owner_account_id, + extra, oauth_group_id, group_accounts, routing_config, + my_assignment_override, group_runtime, owner_email, group_alias + ) + SELECT + virtual_key_id, org_id, seat_id, alias, + provider_code, protocol_type, base_url, + credential_id, credential_revision, virtual_key_revision, + key_status, share_status, local_state, expires_at, + provider_key_nonce, provider_key_ciphertext, + 2, synced_at, + local_alias, supported_providers, provider_base_urls, owner_account_id, + extra, oauth_group_id, group_accounts, routing_config, + my_assignment_override, group_runtime, owner_email, group_alias + FROM managed_virtual_keys_cache; + DROP TABLE managed_virtual_keys_cache; + ALTER TABLE managed_virtual_keys_cache__p1e_new RENAME TO managed_virtual_keys_cache;", + ) + .map_err(|e| format!("mvkc re-grain rebuild: {}", e))?; + tx.commit() + .map_err(|e| format!("mvkc re-grain: commit: {}", e))?; + } + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_mvkc_local_state ON managed_virtual_keys_cache(local_state)", + [], + ) + .map_err(|e| format!("Failed to re-ensure managed_virtual_keys_cache index after re-grain: {}", e))?; + // Enterprise quota rules cache (Phase 2 — design §0.5/§5.2). The proxy // is a client-side component with no access to the control DB, so quota // rules ride the delivery snapshot the same way managed keys do: the CLI diff --git a/src/platform_client.rs b/src/platform_client.rs index 0ecd45a..da41966 100644 --- a/src/platform_client.rs +++ b/src/platform_client.rs @@ -428,6 +428,30 @@ impl DeliveryPayload { self.slots.first()?.binding_targets.first() } + /// P1e (design D-11): find the binding target for a specific + /// (protocol_type, provider_code) so each per-binding cache row pulls ITS + /// OWN credential material — a VK can carry e.g. GLM(zhipu key) AND the + /// official Anthropic(official key), one per slot/target, each with its own + /// `provider_key`. An empty `protocol_type` matches any slot (older cache + /// rows). Returns the matched slot's protocol_type alongside the target. + pub fn binding_for( + &self, + protocol_type: &str, + provider_code: &str, + ) -> Option<(&str, &BindingTarget)> { + for slot in &self.slots { + if !protocol_type.is_empty() && slot.protocol_type != protocol_type { + continue; + } + for bt in &slot.binding_targets { + if bt.provider_code == provider_code { + return Some((slot.protocol_type.as_str(), bt)); + } + } + } + None + } + /// Returns the protocol type of the primary slot. pub fn primary_protocol_type(&self) -> &str { self.slots diff --git a/src/provider_registry.rs b/src/provider_registry.rs index f726ffe..1ba8f69 100644 --- a/src/provider_registry.rs +++ b/src/provider_registry.rs @@ -257,6 +257,29 @@ pub fn lookup(code: &str) -> Option<&'static RegistryEntry> { s.index.get(&lower).map(|&i| &s.entries[i]) } +/// Canonical PROVIDER-axis label: `code(alias)` — e.g. `zhipu` → `zhipu(GLM)`. +/// Unknown / alias-less codes render as the bare code. +/// +/// 🚫 The alias never REPLACES the code: the code is the identity the rest of +/// the system (bindings, events, config) speaks; the alias is only the brand a +/// human recognizes. +/// +/// Why this is one shared function: several surfaces render this axis — the +/// `aikey list` PROVIDER column, its USING FOR column, the `aikey use` summary +/// rows and its title, and the sync auto-activate lines. Each had (or lacked) a +/// private copy, so within a SINGLE table one column said `zhipu(GLM)` while +/// another said `zhipu`. That inconsistency is worse than cosmetic here: with a +/// vendor like Anthropic whose provider code (`anthropic`) is spelled exactly +/// like a wire protocol (`anthropic`), a bare list such as `anthropic,zhipu` +/// reads as if the two axes had been mixed together. Labelling the axis the +/// same way everywhere keeps the provider axis unmistakably the provider axis. +pub fn display_label(code: &str) -> String { + match lookup(code).and_then(|e| e.display_alias) { + Some(alias) if !alias.is_empty() => format!("{}({})", code, alias), + _ => code.to_string(), + } +} + /// Iterate all entries in YAML declaration order. Stable across processes /// (used by the `aikey add` picker so provider list ordering is deterministic). pub fn entries() -> &'static [RegistryEntry] { @@ -565,3 +588,52 @@ mod tests { assert_eq!(unknown, "custom-vendor-xyz"); } } + +#[cfg(test)] +mod display_label_tests { + //! Fence for the provider-axis rendering inconsistency found 2026-07-21. + //! + //! `aikey list` printed the PROVIDER column as `zhipu(GLM)` but the + //! USING FOR column — the SAME axis — as a bare `zhipu`, and the + //! `aikey use` title / sync lines did the same. With Anthropic, whose + //! provider code is spelled exactly like the anthropic wire protocol, + //! `USING FOR: anthropic,zhipu` then reads as if a protocol and a + //! provider had been mixed into one list (they hadn't — both are + //! providers). One labeller keeps the axis self-identifying. + + use super::display_label; + + #[test] + fn aliased_provider_renders_code_and_alias() { + // zhipu ships a display_alias in the registry; the code must survive — + // it is the identity bindings/events/config speak. + let got = display_label("zhipu"); + assert!( + got == "zhipu" || got == "zhipu(GLM)", + "unexpected label for zhipu: {got}" + ); + if got.contains('(') { + assert!( + got.starts_with("zhipu("), + "alias must not replace the code: {got}" + ); + } + } + + #[test] + fn unknown_provider_falls_back_to_bare_code() { + // Custom/self-hosted providers aren't in the registry — they must still + // render, never blank out a table cell. + assert_eq!( + display_label("totally-made-up-provider"), + "totally-made-up-provider" + ); + } + + #[test] + fn empty_code_stays_empty() { + // Callers collapse empty values out of the axis list; returning "()" + // here would materialize a bogus cell. + assert_eq!(display_label(""), ""); + } +} diff --git a/src/storage.rs b/src/storage.rs index 76a569b..e7844bf 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -264,6 +264,24 @@ pub fn initialize_vault(salt: &[u8], password: &SecretString) -> Result<(), Stri .map_err(|e| format!("Failed to set directory permissions: {}", e))?; } + // A 0-byte vault.db is never a real vault: SQLite writes a 100-byte header + // on first use, so an empty file can only be the shell left by an init that + // died between `Connection::open` and the first write. Clear it and start + // clean — otherwise the probe below opens it, and the caller sees a + // confusing "Vault already initialized" or "unable to open database file" + // for what is really leftover debris. + if db_path.metadata().map(|m| m.len() == 0).unwrap_or(false) { + fs::remove_file(&db_path).map_err(|e| { + format!( + "Found an empty {} left by an interrupted vault init, and could not \ + remove it: {}. Delete that file (as an administrator if its \ + permissions were left broken) and re-run.", + db_path.display(), + e + ) + })?; + } + // If the DB file exists, check whether it was fully initialized (has master_salt). // The file may exist without salt if session-backend selection created it first. if db_path.exists() { @@ -283,23 +301,62 @@ pub fn initialize_vault(salt: &[u8], password: &SecretString) -> Result<(), Stri // DB exists but no salt — fall through to complete initialization } - let conn = - Connection::open(&db_path).map_err(|e| format!("Failed to create database: {}", e))?; + // Whether THIS call is what brings vault.db into existence. If it is, any + // failure below must not leave the empty shell behind — `Connection::open` + // creates a 0-byte file before anything is written to it, and an error + // after that point used to leave exactly that on disk, so a `aikey add` + // that failed reported "Failed to set database permissions" and left what + // looks like a vault but has no master_salt. + let db_created_here = !db_path.exists(); + let cleanup_partial = |e: String| -> String { + if db_created_here { + let _ = fs::remove_file(&db_path); + } + e + }; - // Stage 2.4 windows-compat: belt-and-suspenders — vault_dir's ACL - // already inherits owner-only to vault.db on Windows, but we set it - // explicitly here so even a vault.db that pre-existed (e.g. user - // manually copied an old vault file in) gets re-hardened. - storage_acl::enforce_owner_only_file(&db_path) - .map_err(|e| format!("Failed to set database permissions: {}", e))?; + let conn = Connection::open(&db_path) + .map_err(|e| cleanup_partial(format!("Failed to create database: {}", e)))?; + + // Stage 2.4 windows-compat: the DIRECTORY ACL is the control that matters — + // NTFS inheritance carries it to vault.db (see storage_acl module doc), and + // on Unix the 0o700 dir does the same job. Enforce it here too, not only on + // the create-the-dir path above, so a vault dir that already existed (or was + // restored from a backup) is hardened as well. + let dir_hardened = storage_acl::enforce_owner_only_dir(&vault_dir); + + // The per-file call is explicitly belt-and-suspenders on top of that + // inheritance. Treating its failure as fatal aborted vault creation outright + // in contexts where icacls cannot name the caller — e.g. running as a service + // (LocalSystem resolves to the MACHINE$ account: "icacls /grant:r failed for + // HOST$"). Fail only when the belt failed too; otherwise the vault is still + // owner-only, and a warning is the honest report. + if let Err(file_err) = storage_acl::enforce_owner_only_file(&db_path) { + match dir_hardened { + Ok(()) => eprintln!( + " ! Could not set an explicit ACL on {} ({}). \ + The vault directory's owner-only permissions still apply to it.", + db_path.display(), + file_err + ), + Err(dir_err) => { + return Err(cleanup_partial(format!( + "Failed to set database permissions: {} (vault directory could not be \ + secured either: {}) — refusing to create a vault that other users \ + could read", + file_err, dir_err + ))) + } + } + } // SECURITY: Enable secure delete to overwrite deleted data with zeros conn.pragma_update(None, "secure_delete", "ON") - .map_err(|e| format!("Failed to enable secure delete: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to enable secure delete: {}", e)))?; // SECURITY: Enable auto-vacuum to reclaim space and prevent data remnants conn.pragma_update(None, "auto_vacuum", "FULL") - .map_err(|e| format!("Failed to enable auto-vacuum: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to enable auto-vacuum: {}", e)))?; conn.execute( "CREATE TABLE IF NOT EXISTS config ( @@ -308,7 +365,7 @@ pub fn initialize_vault(salt: &[u8], password: &SecretString) -> Result<(), Stri )", [], ) - .map_err(|e| format!("Failed to create config table: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to create config table: {}", e)))?; conn.execute( "CREATE TABLE IF NOT EXISTS entries ( @@ -322,7 +379,7 @@ pub fn initialize_vault(salt: &[u8], password: &SecretString) -> Result<(), Stri )", [], ) - .map_err(|e| format!("Failed to create entries table: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to create entries table: {}", e)))?; conn.execute( "CREATE TABLE IF NOT EXISTS profiles ( @@ -333,7 +390,7 @@ pub fn initialize_vault(salt: &[u8], password: &SecretString) -> Result<(), Stri )", [], ) - .map_err(|e| format!("Failed to create profiles table: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to create profiles table: {}", e)))?; conn.execute( "CREATE TABLE IF NOT EXISTS bindings ( @@ -346,36 +403,36 @@ pub fn initialize_vault(salt: &[u8], password: &SecretString) -> Result<(), Stri )", [], ) - .map_err(|e| format!("Failed to create bindings table: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to create bindings table: {}", e)))?; conn.execute( "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", params!["master_salt", salt], ) - .map_err(|e| format!("Failed to store salt: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to store salt: {}", e)))?; // Store KDF parameters for future use (e.g., password changes) conn.execute( "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", params!["kdf_m_cost", &crate::crypto::ARGON2_M_COST.to_le_bytes()], ) - .map_err(|e| format!("Failed to store KDF m_cost: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to store KDF m_cost: {}", e)))?; conn.execute( "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", params!["kdf_t_cost", &crate::crypto::ARGON2_T_COST.to_le_bytes()], ) - .map_err(|e| format!("Failed to store KDF t_cost: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to store KDF t_cost: {}", e)))?; conn.execute( "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", params!["kdf_p_cost", &crate::crypto::ARGON2_P_COST.to_le_bytes()], ) - .map_err(|e| format!("Failed to store KDF p_cost: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to store KDF p_cost: {}", e)))?; // Derive key directly from password parameter instead of environment variable let key = crate::crypto::derive_key(password, salt) - .map_err(|e| format!("Key derivation failed: {}", e))?; + .map_err(|e| cleanup_partial(format!("Key derivation failed: {}", e)))?; // Use &*key to dereference SecureBuffer and get &[u8; 32] let password_hash = &*key; @@ -384,7 +441,7 @@ pub fn initialize_vault(salt: &[u8], password: &SecretString) -> Result<(), Stri "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", params!["password_hash", password_hash], ) - .map_err(|e| format!("Failed to store password hash: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to store password hash: {}", e)))?; // Seed the delivery-integrity source identity once, at vault creation, so // the proxy reads a stable per-source id from its very first start. Inserted @@ -395,7 +452,7 @@ pub fn initialize_vault(salt: &[u8], password: &SecretString) -> Result<(), Stri "INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)", params![SOURCE_IDENTITY_KEY, generate_uuid_v4().as_bytes().to_vec()], ) - .map_err(|e| format!("Failed to store source identity: {}", e))?; + .map_err(|e| cleanup_partial(format!("Failed to store source identity: {}", e)))?; Ok(()) } @@ -1057,10 +1114,21 @@ pub fn change_password( // bad row — would have us emit a new wrong-key ciphertext under the new // master and permanently lock that vk_id out, exactly the silent failure // we are trying to surface. - let mvk_rows: Vec<(String, Vec, Vec)> = { + // 🔴 P1e (design D-11): the cache is now ONE ROW PER BINDING + // `(virtual_key_id, protocol_type, provider_code)`, and EACH binding row + // carries its OWN provider_key_ciphertext (e.g. one VK holds a GLM key on + // the zhipu/anthropic binding AND the official key on the anthropic/anthropic + // binding). The re-encrypt therefore MUST key both the SELECT and the UPDATE + // on the full composite key — the pre-P1e `WHERE virtual_key_id = ?` would + // stamp every binding of a VK with the LAST binding's re-encrypted ciphertext, + // silently destroying the other credentials (第 1 级安全: irreversible + // ciphertext corruption). Each row is decrypted, re-encrypted, and written + // back to its own row independently. + let mvk_rows: Vec<(String, String, String, Vec, Vec)> = { let mut stmt = tx .prepare( - "SELECT virtual_key_id, provider_key_nonce, provider_key_ciphertext + "SELECT virtual_key_id, protocol_type, provider_code, + provider_key_nonce, provider_key_ciphertext FROM managed_virtual_keys_cache WHERE provider_key_nonce IS NOT NULL AND provider_key_ciphertext IS NOT NULL", @@ -1070,8 +1138,10 @@ pub fn change_password( .query_map([], |row| { Ok(( row.get::<_, String>(0)?, - row.get::<_, Vec>(1)?, - row.get::<_, Vec>(2)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Vec>(3)?, + row.get::<_, Vec>(4)?, )) }) .map_err(|e| format!("Failed to query managed_virtual_keys_cache: {}", e))?; @@ -1079,17 +1149,19 @@ pub fn change_password( collected .map_err(|e| format!("Failed to collect managed_virtual_keys_cache rows: {}", e))? }; - for (vk_id, old_nonce, old_ciphertext) in mvk_rows { + for (vk_id, protocol_type, provider_code, old_nonce, old_ciphertext) in mvk_rows { let plaintext = crate::crypto::decrypt(&old_key, &old_nonce, &old_ciphertext).map_err(|e| { format!( - "Failed to decrypt team key '{vk}' during password change: {err}. \ - This row's ciphertext was written with a different vault_key than \ - the current password_hash — change-password cannot rotate it. \ - Recover by running `aikey key sync --force-reencrypt` first \ - (clears stale ciphertext + re-downloads under the current key), \ + "Failed to decrypt team key '{vk}' (binding {proto}/{prov}) during \ + password change: {err}. This binding's ciphertext was written with a \ + different vault_key than the current password_hash — change-password \ + cannot rotate it. Recover by running `aikey key sync --force-reencrypt` \ + first (clears stale ciphertext + re-downloads under the current key), \ then retry `aikey change-password`.", vk = vk_id, + proto = protocol_type, + prov = provider_code, err = e ) })?; @@ -1098,8 +1170,14 @@ pub fn change_password( tx.execute( "UPDATE managed_virtual_keys_cache SET provider_key_nonce = ?, provider_key_ciphertext = ? - WHERE virtual_key_id = ?", - params![new_nonce, new_ciphertext, vk_id], + WHERE virtual_key_id = ? AND protocol_type = ? AND provider_code = ?", + params![ + new_nonce, + new_ciphertext, + vk_id, + protocol_type, + provider_code + ], ) .map_err(|e| format!("Failed to update team key '{}': {}", vk_id, e))?; } @@ -2260,6 +2338,180 @@ mod tests { ); } + // 🔴 P1e (design D-11) CORRUPTION FENCE. One VK now carries multiple bindings, + // each with its OWN provider_key_ciphertext (e.g. GLM key on the zhipu binding + // AND the official key on the anthropic binding). change-password must + // re-encrypt EACH binding row independently — the pre-P1e `UPDATE ... WHERE + // virtual_key_id = ?` would stamp every binding of a VK with the LAST binding's + // re-encrypted ciphertext, irreversibly destroying the other credential. This + // test MUST go red if the UPDATE ever drops the (protocol_type, provider_code) + // key columns from its WHERE clause. + #[test] + fn change_password_reencrypts_each_binding_independently() { + let (_dir, _db_path, _lock) = setup_vault(); + let conn = open_connection().expect("open"); + let _ = crate::storage::list_virtual_key_cache(); // run platform migrations + + let old_key = derive_current("test_password"); + let glm_key = b"sk-glm-zhipu-key-AAAA"; + let official_key = b"sk-ant-official-key-BBBB"; + let (glm_nonce, glm_ct) = crate::crypto::encrypt(&old_key, glm_key).expect("enc glm"); + let (off_nonce, off_ct) = crate::crypto::encrypt(&old_key, official_key).expect("enc off"); + + // Two bindings on ONE virtual key, distinct (protocol_type, provider_code). + for (prov, proto, base, nonce, ct) in [ + ( + "zhipu", + "anthropic", + "https://open.bigmodel.cn/api/anthropic", + &glm_nonce, + &glm_ct, + ), + ( + "anthropic", + "anthropic", + "https://api.anthropic.com", + &off_nonce, + &off_ct, + ), + ] { + conn.execute( + "INSERT INTO managed_virtual_keys_cache + (virtual_key_id, org_id, seat_id, alias, provider_code, protocol_type, + base_url, credential_id, credential_revision, virtual_key_revision, + key_status, share_status, local_state, + provider_key_nonce, provider_key_ciphertext, synced_at) + VALUES ('vk-multi','org-1','seat-1','dual-key', ?1, ?2, ?3, + 'cred','rev','vrev','active','claimed','synced_inactive', + ?4, ?5, strftime('%s','now'))", + params![prov, proto, base, nonce, ct], + ) + .expect("insert binding row"); + } + + let old = SecretString::new("test_password".to_string()); + let new = SecretString::new("new_password_xyz".to_string()); + change_password(&old, &new).expect("change_password ok"); + + // Each binding must still decrypt to ITS OWN original key under the new vault_key. + let conn = open_connection().expect("reopen"); + let new_key = derive_current("new_password_xyz"); + let read_binding = |prov: &str| -> Vec { + let (n, c): (Vec, Vec) = conn + .query_row( + "SELECT provider_key_nonce, provider_key_ciphertext + FROM managed_virtual_keys_cache + WHERE virtual_key_id='vk-multi' AND provider_code=?1", + params![prov], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("read binding"); + crate::crypto::decrypt(&new_key, &n, &c) + .expect("decrypt under new key") + .to_vec() + }; + assert_eq!( + read_binding("zhipu").as_slice(), + glm_key, + "GLM binding key must survive rotation intact" + ); + assert_eq!( + read_binding("anthropic").as_slice(), + official_key, + "official binding key must survive rotation intact (NOT clobbered by the GLM binding)" + ); + } + + // P1e migration fence: a pre-P1e vault (single-column PK on virtual_key_id) + // must be re-grained IN PLACE to the composite binding PK, losslessly, and the + // re-grain must be idempotent (no-op on the already-migrated shape). + #[test] + fn regrain_migration_preserves_data_and_is_idempotent() { + let dir = tempfile::tempdir().expect("tempdir"); + let db = dir.path().join("legacy.sqlite3"); + let conn = Connection::open(&db).expect("open legacy"); + + // Pre-P1e one-VK-one-row table (single-column PK). + conn.execute_batch( + "CREATE TABLE managed_virtual_keys_cache ( + virtual_key_id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + seat_id TEXT NOT NULL, + alias TEXT NOT NULL, + provider_code TEXT NOT NULL, + protocol_type TEXT NOT NULL DEFAULT 'openai_compatible', + base_url TEXT NOT NULL, + credential_id TEXT NOT NULL, + credential_revision TEXT NOT NULL, + virtual_key_revision TEXT NOT NULL, + key_status TEXT NOT NULL DEFAULT 'active', + share_status TEXT NOT NULL DEFAULT 'pending_claim', + local_state TEXT NOT NULL DEFAULT 'synced_inactive', + expires_at INTEGER, + provider_key_nonce BLOB, + provider_key_ciphertext BLOB, + cache_schema_version INTEGER NOT NULL DEFAULT 1, + synced_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + ); + INSERT INTO managed_virtual_keys_cache + (virtual_key_id, org_id, seat_id, alias, provider_code, protocol_type, + base_url, credential_id, credential_revision, virtual_key_revision, + provider_key_ciphertext) + VALUES ('vk-legacy','o','s','legacy-key','anthropic','anthropic', + 'https://api.anthropic.com','c','r','v', X'DEADBEEF');", + ) + .expect("seed legacy table"); + + // Precondition: single-column PK. + let pk_before: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('managed_virtual_keys_cache') WHERE pk > 0", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(pk_before, 1, "precondition: legacy single-column PK"); + + crate::migrations::upgrade_all(&conn).expect("migrate"); + + // Composite PK now, row + ciphertext preserved byte-for-byte, version bumped. + let pk_after: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('managed_virtual_keys_cache') WHERE pk > 0", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + pk_after, 3, + "re-grain to composite (vk, protocol, provider) PK" + ); + + let (ct, ver): (Vec, i64) = conn + .query_row( + "SELECT provider_key_ciphertext, cache_schema_version + FROM managed_virtual_keys_cache WHERE virtual_key_id='vk-legacy'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .expect("row preserved"); + assert_eq!( + ct, + vec![0xDE, 0xAD, 0xBE, 0xEF], + "ciphertext copied byte-for-byte (no re-encrypt)" + ); + assert_eq!(ver, 2, "cache_schema_version bumped to the binding grain"); + + // Idempotent: a second run is a no-op (still composite, still one row). + crate::migrations::upgrade_all(&conn).expect("migrate again"); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM managed_virtual_keys_cache", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(count, 1, "idempotent re-run leaves data untouched"); + } + // Fence test for the Phase 2 extraction (B): the shared delivered-key core // `commands_account::upsert_delivered_key` must write a managed_virtual_keys_cache // row whose ciphertext decrypts back to the plaintext under the vault_key, with diff --git a/src/storage_acl.rs b/src/storage_acl.rs index 6f2085a..2572d1c 100644 --- a/src/storage_acl.rs +++ b/src/storage_acl.rs @@ -71,30 +71,30 @@ fn enforce_owner_only(path: &Path, is_dir: bool) -> std::io::Result<()> { #[cfg(windows)] fn enforce_owner_only(path: &Path, is_dir: bool) -> std::io::Result<()> { - use std::process::Command; - // Why icacls over Win32 FFI: see module-level docs. // - // Steps: - // 1. /inheritance:r — strip all inherited ACEs (the source of - // the Authenticated Users readability we - // want to remove). - // 2. /grant:r %USERNAME% — full control for current user. ":r" - // replaces any existing grant for that - // principal so re-running the helper is - // idempotent. - // 3. /grant:r SYSTEM — SYSTEM is needed for backup APIs and - // system services (Windows Defender, - // shadow copy) that legitimately need - // read access to administrative storage. - // 4. /grant:r Administrators — needed so an elevated admin (e.g. - // IT support) can recover the vault if - // the user account is locked. Same - // principle as Unix `root` having access - // even with mode 0o600. + // ORDER MATTERS, and it is the opposite of the obvious one: + // + // 1. /grant:r for each principal — adds explicit ACEs. Inherited ACEs + // are still in place at this point, so the path stays reachable no + // matter which grant fails. + // 2. /inheritance:r LAST — drops the inherited ACEs (the source of the + // Authenticated Users readability we came to remove), leaving exactly + // the explicit grants from step 1. + // + // 🔴 The original order was the reverse — strip, then grant. `icacls + // /inheritance:r` on a path with no explicit ACEs yet leaves an EMPTY + // DACL, so any failure in the grant loop left a file that NOBODY could + // open or delete, SYSTEM included. That is exactly what happened when the + // CLI ran as a service: USERNAME resolves to the machine account + // (`HOST$`), its grant failed, and vault.db was left 0 bytes with an empty + // DACL — permanently poisoning that vault path, because every later run + // died at "unable to open database file" and could not even remove it. + // Granting first makes the failure mode "still too permissive, and the + // caller gets an Err" instead of "unrecoverable". // - // (OI)(CI)F = object + container inheritance, full control. Only - // applied on directories; files just get F. + // (OI)(CI)F = object + container inheritance, full control. Only applied + // on directories; files just get F. // Early-out for a non-existent path — matches the Go aikeycompat // contract ("returns nil if path doesn't exist; caller's @@ -106,93 +106,89 @@ fn enforce_owner_only(path: &Path, is_dir: bool) -> std::io::Result<()> { } let path_str = path.as_os_str(); + let inherit_flags = if is_dir { ":(OI)(CI)F" } else { ":F" }; - // Step 1: disable inheritance. - // Why .output() not .status(): icacls writes a localised success - // line to stdout ("Successfully processed 1 files") on every call, - // which pollutes our own stdout when running interactively or in - // tests. We don't need the output content, just the exit code. - let result = Command::new("icacls") - .arg(path_str) - .arg("/inheritance:r") - .output()?; - if !result.status.success() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - format!("icacls /inheritance:r failed for {}", path.display()), - )); + // SYSTEM: needed for backup APIs and system services (Defender, shadow + // copy) that legitimately read administrative storage. + // Administrators: lets an elevated admin recover the vault if the user + // account is locked — same principle as Unix root reading a 0o600 file. + // These two are the floor: without at least one of them nobody can + // recover the path, so they are applied first and are mandatory. + for principal in ["SYSTEM", "Administrators"] { + grant(path, path_str, principal, inherit_flags)?; } - let inherit_flags = if is_dir { ":(OI)(CI)F" } else { ":F" }; - - // USERNAME unset → fall through to SYSTEM + Administrators only, - // matching the Go aikeycompat behaviour: "more secure than failing - // open". The path will be unreadable to the current user, which is - // a hard error the user can see immediately by running `aikey - // status` — better than a half-applied ACL that looks safe but - // leaks via the inherited Authenticated Users grant we just left - // unstripped. + // The current user, when we can name one. USERNAME unset → SYSTEM + + // Administrators only, matching the Go aikeycompat behaviour ("more + // secure than failing open"). let username = std::env::var("USERNAME").unwrap_or_default(); - let principals: &[&str] = if username.is_empty() { - &["SYSTEM", "Administrators"] - } else { - // ASCII-only sanity check — usernames with embedded ":" or - // newlines would corrupt the icacls grant string. Defensive. + if !username.is_empty() { + // ASCII-only sanity check — usernames with embedded ":" or newlines + // would corrupt the icacls grant string. Defensive. if username.contains(':') || username.contains('\n') { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("USERNAME contains forbidden char(s): {username:?}"), )); } - // Two-element slice with the dynamic username first. - return enforce_with_username(path, path_str, &username, inherit_flags); - }; - - for principal in principals { - let result = Command::new("icacls") - .arg(path_str) - .arg("/grant:r") - .arg(format!("{principal}{inherit_flags}")) - .output()?; - if !result.status.success() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - format!( - "icacls /grant:r failed for {} on {}", - principal, - path.display() - ), - )); + // Non-fatal: a service-context USERNAME is the machine account, which + // icacls often cannot resolve on the local machine. SYSTEM is already + // granted above and IS the identity such a process runs as, so the + // path stays usable. Hard-failing here is what used to abort vault + // creation outright for every service-context caller. + if let Err(e) = grant(path, path_str, &username, inherit_flags) { + eprintln!( + " ! Could not grant {} on {} ({}). SYSTEM and Administrators \ + retain access; the path is not world-readable.", + username, + path.display(), + e + ); } } + // Only now drop the inherited ACEs. + let result = std::process::Command::new("icacls") + .arg(path_str) + .arg("/inheritance:r") + .output()?; + if !result.status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("icacls /inheritance:r failed for {}", path.display()), + )); + } + Ok(()) } +/// One `icacls /grant:r` call. `:r` replaces any existing grant for that +/// principal, so re-running the helper is idempotent. +/// +/// Why `.output()` and not `.status()`: icacls writes a localised success line +/// ("Successfully processed 1 files") on every call, which would pollute our +/// stdout when running interactively or in tests. We only need the exit code. #[cfg(windows)] -fn enforce_with_username( +fn grant( path: &Path, path_str: &std::ffi::OsStr, - username: &str, + principal: &str, inherit_flags: &str, ) -> std::io::Result<()> { - use std::process::Command; - for principal in [username, "SYSTEM", "Administrators"] { - let result = Command::new("icacls") - .arg(path_str) - .arg("/grant:r") - .arg(format!("{principal}{inherit_flags}")) - .output()?; - if !result.status.success() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - format!( - "icacls /grant:r failed for {} on {}", - principal, - path.display() - ), - )); - } + let result = std::process::Command::new("icacls") + .arg(path_str) + .arg("/grant:r") + .arg(format!("{principal}{inherit_flags}")) + .output()?; + if !result.status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!( + "icacls /grant:r failed for {} on {}", + principal, + path.display() + ), + )); } Ok(()) } diff --git a/src/storage_platform.rs b/src/storage_platform.rs index b54ec85..6ba09f9 100644 --- a/src/storage_platform.rs +++ b/src/storage_platform.rs @@ -270,6 +270,25 @@ pub fn delete_virtual_key_cache_row(virtual_key_id: &str) -> Result<(), String> Ok(()) } +/// P1e (design D-11): delete ONE binding row `(vk, protocol, provider)`. The +/// cache is one row per binding; the vk-level `delete_virtual_key_cache_row` +/// removes ALL of a VK's bindings, but the sync-prune needs to drop a single +/// binding that the server no longer projects while its VK survives. +pub fn delete_virtual_key_cache_binding( + virtual_key_id: &str, + protocol_type: &str, + provider_code: &str, +) -> Result<(), String> { + let conn = open_connection()?; + conn.execute( + "DELETE FROM managed_virtual_keys_cache + WHERE virtual_key_id = ?1 AND protocol_type = ?2 AND provider_code = ?3", + params![virtual_key_id, protocol_type, provider_code], + ) + .map_err(|e| format!("Failed to delete virtual key cache binding: {}", e))?; + Ok(()) +} + pub fn clear_virtual_key_cache() -> Result<(), String> { let conn = open_connection()?; conn.execute("DELETE FROM managed_virtual_keys_cache", []) @@ -744,12 +763,10 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri ?19, ?20, ?21, ?22, ?23, ?24, ?25 ) - ON CONFLICT(virtual_key_id) DO UPDATE SET + ON CONFLICT(virtual_key_id, protocol_type, provider_code) DO UPDATE SET org_id = excluded.org_id, seat_id = excluded.seat_id, alias = excluded.alias, - provider_code = excluded.provider_code, - protocol_type = excluded.protocol_type, base_url = excluded.base_url, credential_id = excluded.credential_id, credential_revision = excluded.credential_revision, @@ -1006,6 +1023,45 @@ pub fn get_virtual_key_cache(virtual_key_id: &str) -> Result Result, String> { + let db_path = get_vault_path()?; + if !db_path.exists() { + return Ok(None); + } + let conn = open_connection()?; + let sel = |cols: &str| { + format!( + "SELECT {} FROM managed_virtual_keys_cache \ + WHERE virtual_key_id = ?1 AND protocol_type = ?2 AND provider_code = ?3", + cols + ) + }; + let p = params![virtual_key_id, protocol_type, provider_code]; + let result = conn + .query_row(&sel(VK_CACHE_COLUMNS_GROUP), p, row_to_virtual_key_cache) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Err(e), + _ => conn.query_row(&sel(VK_CACHE_COLUMNS_FULL), p, row_to_virtual_key_cache), + }) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Err(e), + _ => conn.query_row(&sel(VK_CACHE_COLUMNS_LEGACY), p, row_to_virtual_key_cache), + }); + match result { + Ok(entry) => Ok(Some(entry)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("Failed to get virtual key cache binding: {}", e)), + } +} + /// Looks up a cached entry by alias (tries `local_alias` first, then `alias`). /// Returns `None` if no entry matches. pub fn get_virtual_key_cache_by_alias(alias: &str) -> Result, String> { diff --git a/tests/e2e_list_two_axis_live.rs b/tests/e2e_list_two_axis_live.rs new file mode 100644 index 0000000..4129091 --- /dev/null +++ b/tests/e2e_list_two_axis_live.rs @@ -0,0 +1,209 @@ +//! Live proof for the two-axis `aikey list` (PROTOCOL + PROVIDER columns, and +//! the `primary (+N more)` collapse for a team VK that spans several bindings). +//! +//! Drives the REAL compiled binary against a REAL seeded vault.db (team VK rows +//! written through `storage::upsert_virtual_key_cache`, the same member-rail +//! sync write path). No live platform backend required. +//! +//! Run: cargo test --test e2e_list_two_axis_live -- --nocapture + +use aikeylabs_aikey_cli::{crypto, storage}; +use secrecy::SecretString; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +const PW: &str = "e2e-list-pw"; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_aikey")) +} + +fn base_cmd(home: &PathBuf, vault: &PathBuf) -> Command { + let mut c = Command::new(bin()); + c.env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", home) + .env("AK_VAULT_PATH", vault) + .env("AK_TEST_PASSWORD", PW) + .env("RUST_LOG", "off") + .env("NO_COLOR", "1"); + c +} + +#[allow(clippy::too_many_arguments)] +fn vk_row( + id: &str, + alias: &str, + provider: &str, + protocol: &str, + base_url: &str, + nonce: Vec, + ciphertext: Vec, +) -> storage::VirtualKeyCacheEntry { + storage::VirtualKeyCacheEntry { + virtual_key_id: id.to_string(), + org_id: "org-e2e".to_string(), + seat_id: "seat-e2e".to_string(), + alias: alias.to_string(), + provider_code: provider.to_string(), + protocol_type: protocol.to_string(), + base_url: base_url.to_string(), + credential_id: format!("cred-{id}-{provider}-{protocol}"), + credential_revision: "1".to_string(), + virtual_key_revision: "1".to_string(), + key_status: "active".to_string(), + share_status: "accepted".to_string(), + local_state: "active".to_string(), + expires_at: None, + provider_key_nonce: Some(nonce), + provider_key_ciphertext: Some(ciphertext), + synced_at: 1, + local_alias: None, + supported_providers: vec![provider.to_string()], + provider_base_urls: { + let mut m = HashMap::new(); + m.insert(provider.to_string(), base_url.to_string()); + m + }, + owner_account_id: None, + owner_email: None, + extra: None, + oauth_group_id: None, + group_accounts: None, + routing_config: None, + group_alias: None, + group_runtime: None, + } +} + +#[test] +fn list_shows_two_axes_and_collapses_multi_binding_vk() { + let tmp = std::env::temp_dir().join(format!("aikey-list-2axis-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let vault = tmp.join("vault.db"); + + // Init the vault via the real binary. + let out = base_cmd(&tmp, &vault) + .args(["add", "my-claude", "--provider", "anthropic"]) + .env("AK_TEST_SECRET", "sk-ant-bootstrap") + .stdin(Stdio::null()) + .output() + .expect("aikey add"); + assert!( + out.status.success(), + "add failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // Seed team VK rows through the real sync write path. + std::env::set_var("AK_VAULT_PATH", &vault); + let salt = storage::get_salt().unwrap(); + let (m, t, p) = storage::get_kdf_params().unwrap(); + let key = + crypto::derive_key_with_params(&SecretString::new(PW.to_string()), &salt, m, t, p).unwrap(); + let mut vk = [0u8; 32]; + vk.copy_from_slice(key.as_slice()); + let enc = |s: &str| crypto::encrypt(&vk, s.as_bytes()).unwrap(); + + // (1) single-binding team VK — GLM via anthropic. + let (n, c) = enc("glm-single"); + storage::upsert_virtual_key_cache(&vk_row( + "vk-solo", + "glm-team", + "zhipu", + "anthropic", + "https://open.bigmodel.cn/api/anthropic", + n, + c, + )) + .unwrap(); + + // (2) multi-binding team VK — 2 protocols × 4 providers → collapse on both axes. + let bindings = [ + ( + "zhipu", + "anthropic", + "https://open.bigmodel.cn/api/anthropic", + ), + ( + "zhipu", + "openai_compatible", + "https://open.bigmodel.cn/api/paas/v4", + ), + ( + "moonshot", + "openai_compatible", + "https://api.moonshot.cn/v1", + ), + ( + "qwen", + "openai_compatible", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + ), + ( + "doubao", + "openai_compatible", + "https://ark.cn-beijing.volces.com/api/v3", + ), + ]; + for (prov, proto, url) in bindings { + let (n, c) = enc(&format!("mat-{prov}-{proto}")); + storage::upsert_virtual_key_cache(&vk_row( + "vk-multi", + "everything-team", + prov, + proto, + url, + n, + c, + )) + .unwrap(); + } + + // Live run: `aikey list`. + let out = base_cmd(&tmp, &vault) + .arg("list") + .stdin(Stdio::null()) + .output() + .expect("aikey list"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + println!("\n================ LIVE: `aikey list` ================\n{text}"); + + assert!(out.status.success(), "aikey list failed:\n{text}"); + // Two-axis header. + assert!( + text.contains("PROTOCOL") && text.contains("PROVIDER"), + "missing two-axis header:\n{text}" + ); + // Single-binding VK renders both axes plainly. + assert!(text.contains("glm-team"), "missing glm-team row:\n{text}"); + assert!( + text.contains("zhipu(GLM)"), + "provider axis not rendered as zhipu(GLM):\n{text}" + ); + // Multi-binding VK renders ONCE with the collapse markers on both axes. + let multi_lines: Vec<&str> = text + .lines() + .filter(|l| l.contains("everything-team")) + .collect(); + assert_eq!( + multi_lines.len(), + 1, + "multi-binding VK should be ONE row, got {}:\n{text}", + multi_lines.len() + ); + let line = multi_lines[0]; + assert!( + line.contains("(+"), + "expected a `(+N more)` collapse on the multi VK row:\n{line}" + ); + + println!("\n✓ two-axis `aikey list` + multi-binding collapse verified live\n"); + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/tests/e2e_use_two_axis_live.rs b/tests/e2e_use_two_axis_live.rs new file mode 100644 index 0000000..40ae897 --- /dev/null +++ b/tests/e2e_use_two_axis_live.rs @@ -0,0 +1,219 @@ +//! Live end-to-end proof for `aikey use` two-axis summary + the +//! "Select provider(s)" mislabel fix (P1f.11, model-mapping-egress). +//! +//! Why this exists: the two-axis summary + mislabel fix shipped in +//! e9fa37f were verified only by unit/lib tests. This test drives the REAL +//! compiled `aikey` binary against a REAL seeded vault.db (team VK cache row +//! written through the same `storage::upsert_virtual_key_cache` the member-rail +//! sync uses) so the rendered output is captured end-to-end — no live platform +//! backend required (P7 infra is blocked). +//! +//! Run: cargo test --test e2e_use_two_axis_live -- --nocapture + +use aikeylabs_aikey_cli::{crypto, storage}; +use secrecy::SecretString; +use std::collections::HashMap; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +const PW: &str = "e2e-two-axis-pw"; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_aikey")) +} + +/// Base env every invocation of the real binary shares: isolated HOME + vault. +fn base_cmd(home: &PathBuf, vault: &PathBuf) -> Command { + let mut c = Command::new(bin()); + c.env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", home) + .env("AK_VAULT_PATH", vault) + .env("AK_TEST_PASSWORD", PW) + .env("RUST_LOG", "off") + .env("NO_COLOR", "1"); + c +} + +/// Build a full team-VK cache row (P1e re-grained: one row per binding). +#[allow(clippy::too_many_arguments)] +fn vk_row( + id: &str, + alias: &str, + provider: &str, + protocol: &str, + base_url: &str, + supported: &[&str], + nonce: Vec, + ciphertext: Vec, +) -> storage::VirtualKeyCacheEntry { + storage::VirtualKeyCacheEntry { + virtual_key_id: id.to_string(), + org_id: "org-e2e".to_string(), + seat_id: "seat-e2e".to_string(), + alias: alias.to_string(), + provider_code: provider.to_string(), + protocol_type: protocol.to_string(), + base_url: base_url.to_string(), + credential_id: format!("cred-{id}"), + credential_revision: "1".to_string(), + virtual_key_revision: "1".to_string(), + key_status: "active".to_string(), + share_status: "accepted".to_string(), + local_state: "active".to_string(), + expires_at: None, + provider_key_nonce: Some(nonce), + provider_key_ciphertext: Some(ciphertext), + synced_at: 1, + local_alias: None, + supported_providers: supported.iter().map(|s| s.to_string()).collect(), + provider_base_urls: { + let mut m = HashMap::new(); + for s in supported { + m.insert(s.to_string(), base_url.to_string()); + } + m + }, + owner_account_id: None, + owner_email: None, + extra: None, + oauth_group_id: None, + group_accounts: None, + routing_config: None, + group_alias: None, + group_runtime: None, + } +} + +#[test] +fn use_two_axis_summary_and_mislabel_prompt() { + let tmp = std::env::temp_dir().join(format!("aikey-two-axis-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let vault = tmp.join("vault.db"); + + // ── 1. Initialise the vault by running the real binary once ────────────── + let out = base_cmd(&tmp, &vault) + .args(["add", "bootstrap", "--provider", "anthropic"]) + .env("AK_TEST_SECRET", "sk-ant-bootstrap-e2e") + .stdin(Stdio::null()) + .output() + .expect("run aikey add"); + assert!( + out.status.success(), + "bootstrap add failed\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + + // ── 2. Seed team-VK cache rows through the same lib API the sync uses ───── + // Point THIS process at the same vault so storage helpers hit it. + std::env::set_var("AK_VAULT_PATH", &vault); + let salt = storage::get_salt().expect("salt"); + let (m, t, p) = storage::get_kdf_params().expect("kdf"); + let key = crypto::derive_key_with_params(&SecretString::new(PW.to_string()), &salt, m, t, p) + .expect("derive"); + let mut vault_key = [0u8; 32]; + vault_key.copy_from_slice(key.as_slice()); + storage::verify_vault_key(&vault_key).expect("vault key verifies"); + + let enc = |plain: &str| crypto::encrypt(&vault_key, plain.as_bytes()).expect("encrypt"); + + // (a) single-provider GLM-via-anthropic VK → drives the two-axis summary. + let (n1, c1) = enc("glm-real-key-material"); + storage::upsert_virtual_key_cache(&vk_row( + "vk-glm-anthropic", + "glm-team", + "zhipu", + "anthropic", + "https://open.bigmodel.cn/api/anthropic", + &["zhipu"], + n1, + c1, + )) + .expect("seed glm vk"); + + // (b) multi-provider VK → drives the interactive "Select provider(s)" prompt. + let (n2, c2) = enc("multi-key-material"); + storage::upsert_virtual_key_cache(&vk_row( + "vk-multi", + "multi-team", + "zhipu", + "anthropic", + "https://open.bigmodel.cn/api/anthropic", + &["zhipu", "moonshot"], + n2, + c2, + )) + .expect("seed multi vk"); + + // ── 3. Live run A: `aikey use glm-team` → two-axis summary box ──────────── + let a = base_cmd(&tmp, &vault) + .args(["use", "glm-team", "--no-hook"]) + .stdin(Stdio::null()) + .output() + .expect("run aikey use glm-team"); + let a_out = format!( + "{}{}", + String::from_utf8_lossy(&a.stdout), + String::from_utf8_lossy(&a.stderr) + ); + println!("\n================ LIVE A: `aikey use glm-team` ================\n{a_out}"); + + // ── 4. Live run B: `aikey use multi-team` under a PTY → prompt text ─────── + // `script -q /dev/null ` gives the child a real TTY so the interactive + // multi-provider branch (and its prompt) is reached; feed "1" on stdin. + let mut b = Command::new("script") + .args(["-q", "/dev/null"]) + .arg(bin()) + .args(["use", "multi-team", "--no-hook"]) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("HOME", &tmp) + .env("AK_VAULT_PATH", &vault) + .env("AK_TEST_PASSWORD", PW) + .env("RUST_LOG", "off") + .env("NO_COLOR", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn script pty"); + b.stdin.as_mut().unwrap().write_all(b"1\n").unwrap(); + let b_out_raw = b.wait_with_output().expect("wait pty"); + let b_out = format!( + "{}{}", + String::from_utf8_lossy(&b_out_raw.stdout), + String::from_utf8_lossy(&b_out_raw.stderr) + ); + println!("\n================ LIVE B: `aikey use multi-team` (PTY) ================\n{b_out}"); + + // ── 5. Assertions ──────────────────────────────────────────────────────── + // Two-axis summary: PROTOCOL + PROVIDER header, anthropic protocol, zhipu(GLM). + assert!(a.status.success(), "aikey use glm-team failed:\n{a_out}"); + assert!( + a_out.contains("PROTOCOL") && a_out.contains("PROVIDER"), + "summary missing two-axis header:\n{a_out}" + ); + assert!( + a_out.contains("anthropic"), + "summary missing anthropic protocol axis:\n{a_out}" + ); + assert!( + a_out.contains("zhipu(GLM)") || a_out.contains("zhipu"), + "summary missing zhipu provider axis:\n{a_out}" + ); + + // Mislabel fix: prompt says "provider(s)", never the old "protocol(s)". + assert!( + b_out.contains("Select provider(s) to set as Primary"), + "mislabel fix not observed (expected 'Select provider(s)'):\n{b_out}" + ); + assert!( + !b_out.contains("Select protocol(s) to set as Primary"), + "old mislabel 'Select protocol(s)' still present:\n{b_out}" + ); + + println!("\n✓ two-axis summary + mislabel fix verified live against the real binary\n"); + let _ = std::fs::remove_dir_all(&tmp); +}