From e9fa37f6b753cf9e15e5d9aa009f60a0fa6a5257 Mon Sep 17 00:00:00 2001 From: Damon Date: Sun, 19 Jul 2026 23:34:51 -0400 Subject: [PATCH 1/9] feat(cli): P1e cache re-grain (binding-per-row + ciphertext-per-row), per-binding sync, aikey use two-axis Model-mapping-egress CLI (targets develop-v1.0.4): - P1e: managed_virtual_keys_cache -> one row per (vk,protocol,provider) with ciphertext per row; idempotent in-place re-grain migration - per-binding change-password re-encrypt (fixes cross-binding ciphertext clobber; corruption fence proven red) - per-binding sync: apply_snapshot preserve/prune + DeliveryPayload.binding_for pulls each binding's own material - aikey use: two-axis summary (protocol + provider) + fix 'Select protocol(s)' mislabel (provider != protocol) - aikey doctor/test: model-mapping health surface reading /v1/diagnostics/pipeline (P3.5) - fences: change_password_reencrypts_each_binding_independently, regrain_migration_preserves_data_and_is_idempotent Co-Authored-By: Claude Opus 4.8 --- data/provider_fingerprint.yaml | 40 ++- src/commands_account/mod.rs | 129 +++++++- src/commands_internal/parse.rs | 7 +- .../parse/provider_fingerprint.rs | 293 +++++++++++++++++- src/commands_internal/query.rs | 65 ++++ src/commands_project.rs | 40 +++ src/commands_proxy.rs | 101 ++++++ src/main.rs | 9 + src/migrations.rs | 129 +++++++- src/platform_client.rs | 24 ++ src/storage.rs | 188 ++++++++++- src/storage_platform.rs | 62 +++- 12 files changed, 1043 insertions(+), 44 deletions(-) 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 01fc24d..ddcb91a 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -2845,12 +2845,23 @@ 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()); @@ -2951,11 +2962,17 @@ 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 @@ -2964,7 +2981,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 @@ -3407,7 +3428,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.", @@ -3415,8 +3450,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() { @@ -5034,7 +5069,10 @@ pub fn handle_key_use( for (i, p) in providers.iter().enumerate() { println!(" {} {}", format!("[{}]", i + 1).dimmed(), 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)?; @@ -5075,12 +5113,13 @@ pub fn handle_key_use( for (i, p) in providers.iter().enumerate() { println!(" {} {}", format!("[{}]", i + 1).dimmed(), 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()) { @@ -5168,7 +5207,18 @@ 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 _; let mut rows: Vec = Vec::new(); + rows.push(format!( + " {:<12} {:<20} {}", + "PROTOCOL".dimmed(), + "PROVIDER".dimmed(), + "KEY".dimmed() + )); for b in &bindings { if let Some((api_key_var, _)) = provider_env_vars(&b.provider_code) { let display_ref = @@ -5181,9 +5231,19 @@ pub fn handle_key_use( } else { arrow_padded }; + // Provider axis: `code(alias)` — GLM alias muted-in-parens, matching + // the vault chip (zhipu → `zhipu(GLM)`). 🚫 alias never replaces code. + let provider_disp = match crate::provider_registry::lookup(&b.provider_code) + .and_then(|e| e.display_alias) + { + Some(alias) if !alias.is_empty() => format!("{}({})", b.provider_code, alias), + _ => b.provider_code.clone(), + }; + let protocol = resolve_binding_protocol(b); rows.push(format!( - " {:<14} {} {}", - b.provider_code, + " {:<12} {:<20} {} {}", + protocol, + provider_disp, arrow_col, format!("[{}]", b.key_source_type).bright_black() )); @@ -5219,6 +5279,41 @@ 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; + let classifier = crate::commands_internal::parse::provider_fingerprint::instance(); + let via_base_url = |base_url: &str, fallback: &str| -> Option { + classifier + .route_for_base_url(base_url) + .map(|r| r.protocol.clone()) + .filter(|p| !p.is_empty()) + .or_else(|| (!fallback.is_empty()).then(|| fallback.to_string())) + }; + let resolved = 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() + .and_then(|vk| via_base_url(&vk.base_url, &vk.protocol_type)), + // Personal keys: resolve via the entry's stored base_url. + _ => storage::get_entry_base_url(&b.key_source_ref) + .ok() + .flatten() + .and_then(|url| via_base_url(&url, "")), + }; + // Honest fallback when the endpoint can't be resolved (e.g. an OAuth binding + // with no cached base_url): the provider's canonical family label. Best-effort + // only — the route row is authoritative whenever present. + resolved.unwrap_or_else(|| crate::provider_registry::family_of(&b.provider_code).to_string()) +} + /// Classify the `aikey use` summary line so it never overpromises /// (use-effectiveness self-check, 2026-07-10). /// 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,14 +169,50 @@ 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, } } + /// 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 列表。 /// /// - family ∈ aggregator_families → vec![] (聚合网关,UI multi-select 让用户手选) @@ -161,6 +239,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 +287,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 @@ -385,6 +491,74 @@ 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. +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. +fn role_of_model(model: &str) -> Option { + let lower = model.to_ascii_lowercase(); + for role in KNOWN_ROLES { + if lower == role + || lower.contains(&format!("-{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 +625,123 @@ 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"); + } + + #[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..3307925 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -407,6 +407,31 @@ 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 +508,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..1f922b4 100644 --- a/src/commands_proxy.rs +++ b/src/commands_proxy.rs @@ -2075,6 +2075,107 @@ 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, 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, 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, +} + +impl Default for RegistryProvenanceWire { + fn default() -> Self { + Self { digest: String::new(), route_rows: 0, providers_with_model_map: Vec::new() } + } +} +impl Default for MappingHealthWire { + fn default() -> Self { + Self { + status: String::new(), + reason: String::new(), + applied: 0, + rejected: 0, + passthrough_missing: 0, + } + } +} + +/// 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/main.rs b/src/main.rs index 8974077..6d1d6db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2340,6 +2340,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 if let Some(ref alias) = alias { // ── Single-alias mode: resolve across personal/team/OAuth ── @@ -2426,6 +2429,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) ── @@ -2501,6 +2507,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 d9ce44c..2e4970b 100644 --- a/src/platform_client.rs +++ b/src/platform_client.rs @@ -394,6 +394,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/storage.rs b/src/storage.rs index 76a569b..8d83151 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1057,10 +1057,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 +1081,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 +1092,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 +1113,8 @@ 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 +2275,155 @@ 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_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> { From 631a7ad90c54b3930ff5d3d9232e2166a3e6ff22 Mon Sep 17 00:00:00 2001 From: Damon Date: Sun, 19 Jul 2026 23:50:42 -0400 Subject: [PATCH 2/9] test(cli): live e2e for `aikey use` two-axis summary + provider(s) mislabel fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the REAL compiled aikey binary against a real seeded vault.db (team VK written via the same storage::upsert_virtual_key_cache the member-rail sync uses). Closes the one verification gap on e9fa37f — the two-axis activation summary and the "Select protocol(s)"->"Select provider(s)" mislabel fix were previously covered only by lib/unit tests, never exercised end-to-end. - Live A: `aikey use glm-team` renders PROTOCOL and PROVIDER as SEPARATE columns (anthropic | zhipu(GLM)) — asserts the axes never re-collapse. - Live B: `aikey use multi-team` under a PTY renders the multi-provider prompt as "Select provider(s) to set as Primary" — asserts the fixed label and the absence of the old "Select protocol(s)" wording. No live platform backend required (P7 infra blocked); the cache row is seeded locally through the real sync write path. Co-Authored-By: Claude Opus 4.8 --- tests/e2e_use_two_axis_live.rs | 219 +++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 tests/e2e_use_two_axis_live.rs 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); +} From 8e2644dffb663a137434b3eddbe1a9386a4cd6c6 Mon Sep 17 00:00:00 2001 From: Damon Date: Mon, 20 Jul 2026 00:10:39 -0400 Subject: [PATCH 3/9] feat(cli): two-axis `aikey list` (PROTOCOL + PROVIDER) + multi-binding collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aikey list` showed a single column mislabeled PROTOCOLS that actually held providers, and — after the P1e per-binding cache re-grain — rendered a team VK with N bindings as N duplicate alias rows. - Add a PROVIDER column (`code(alias)`, e.g. `zhipu(GLM)`) and fix the PROTOCOL column to show the wire protocol (same endpoint-truth as the vault two-axis read model + `aikey use`). - Group managed cache rows by virtual_key_id so a team VK is ONE row; each multi-valued axis renders `primary (+N more)` (primary = the active-binding value, else the first). Team count now counts distinct VKs. - Status: post-P1e ciphertext is per-binding, so "delivered" = ANY binding has local material. - JSON: managed_keys now include protocol_type + base_url. - New live e2e `tests/e2e_list_two_axis_live.rs` drives the real binary against a seeded multi-binding VK and asserts one grouped row + the `(+N more)` collapse. Co-Authored-By: Claude Opus 4.8 --- src/main.rs | 187 +++++++++++++++++++++++++++----- tests/e2e_list_two_axis_live.rs | 148 +++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 30 deletions(-) create mode 100644 tests/e2e_list_two_axis_live.rs diff --git a/src/main.rs b/src/main.rs index 6d1d6db..41b015c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -527,6 +527,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, @@ -539,12 +544,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 { + match crate::provider_registry::lookup(code).and_then(|e| e.display_alias) { + Some(a) if !a.is_empty() => format!("{}({})", code, a), + _ => code.to_string(), + } + }; + // 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, @@ -557,14 +604,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() @@ -574,10 +618,28 @@ 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, + protocol: collapse_axis(&proto_vals), + providers: collapse_axis(&prov_vals), primary_for: pf.join(","), has_primary: !pf.is_empty(), status: String::new(), // valid → not displayed @@ -589,26 +651,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" @@ -618,9 +727,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| { @@ -637,11 +746,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. @@ -653,46 +762,54 @@ 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(), + protocol: collapse_axis(&proto_vals), + providers: collapse_axis(&prov_vals), primary_for: pf.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; @@ -713,27 +830,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!( @@ -744,13 +869,15 @@ fn run_unified_list( rows.push(format!( "{}", format!( - " {: 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); +} From 5131a0706c64b104997f2a7f9e62bb386ee38abb Mon Sep 17 00:00:00 2001 From: Damon Date: Mon, 20 Jul 2026 08:41:43 -0400 Subject: [PATCH 4/9] fix(parse): anchor + deterministic role_of_model, mirror pkg (Go/Rust parity) Removes the loose contains("-{role}") arm (kept exact/prefix/suffix/infix), documents the fixed-order KNOWN_ROLES determinism + the ASCII case-fold parity note with Go. Adds role_of_model_anchored + resolve_model_anchored_roles vectors (incl. the claude-haikuish-1 negative) via a #[cfg(test)] from_yaml_str ctor. Co-Authored-By: Claude Opus 4.8 --- .../parse/provider_fingerprint.rs | 94 ++++++++++++++++++- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/src/commands_internal/parse/provider_fingerprint.rs b/src/commands_internal/parse/provider_fingerprint.rs index 41ab5a2..46fc756 100644 --- a/src/commands_internal/parse/provider_fingerprint.rs +++ b/src/commands_internal/parse/provider_fingerprint.rs @@ -179,6 +179,27 @@ impl FingerprintClassifier { } } + /// 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 @@ -535,7 +556,11 @@ fn path_prefix_matches(prefix: &str, path: &str) -> bool { path.starts_with(&format!("{prefix}/")) } -/// P1 / design D-3: known role families a model_map `match` may name. +/// 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 { @@ -544,14 +569,24 @@ fn is_role_token(s: &str) -> bool { /// 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.contains(&format!("-{role}-")) || lower.starts_with(&format!("{role}-")) || lower.ends_with(&format!("-{role}")) - || lower.contains(&format!("-{role}")) + || lower.contains(&format!("-{role}-")) { return Some(role.to_string()); } @@ -728,6 +763,59 @@ mod tests { 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 From 4c9eac1a8d0e7ed0c5e9fecb19225efc158266cb Mon Sep 17 00:00:00 2001 From: Damon Date: Mon, 20 Jul 2026 22:38:20 -0400 Subject: [PATCH 5/9] style(cli): rustfmt the feature branch to satisfy the fmt-check release fence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop-v1.0.4 enforces `make fmt-check` (rustfmt clean: 0 violations there). This branch had 44 violations across 7 tracked files, so release.sh Step "CLI fmt tests" failed and no cluster bundle was produced. Formatting only — no logic change; build + provider_fingerprint parity (24) + both two-axis live e2e tests still pass. Co-Authored-By: Claude Opus 4.8 --- src/commands_account/mod.rs | 15 ++- .../parse/provider_fingerprint.rs | 49 +++++++--- src/commands_internal/query.rs | 12 ++- src/commands_proxy.rs | 6 +- src/main.rs | 26 +++-- src/storage.rs | 45 +++++++-- tests/e2e_list_two_axis_live.rs | 95 +++++++++++++++---- 7 files changed, 194 insertions(+), 54 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index ddcb91a..6607aeb 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -2858,10 +2858,13 @@ fn apply_snapshot_to_cache( for item in items { // 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(); + 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()); @@ -2971,7 +2974,9 @@ fn apply_snapshot_to_cache( // 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); + 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 diff --git a/src/commands_internal/parse/provider_fingerprint.rs b/src/commands_internal/parse/provider_fingerprint.rs index 46fc756..86650e0 100644 --- a/src/commands_internal/parse/provider_fingerprint.rs +++ b/src/commands_internal/parse/provider_fingerprint.rs @@ -187,8 +187,7 @@ impl FingerprintClassifier { 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())?; + let compiled = Regex::new(&format!("(?s){}", p.regex)).map_err(|e| e.to_string())?; entries.push((p, compiled)); } Ok(Self { @@ -678,9 +677,15 @@ mod tests { 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/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"); + assert_eq!( + route_path_of("https://open.bigmodel.cn/api/paas?x=1"), + "/api/paas" + ); } #[test] @@ -774,10 +779,13 @@ mod tests { 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")); + // 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. + // NEGATIVE: the removed loose contains("-haiku") would have mis-matched. assert_eq!(role_of_model("claude-haikuish-1"), None); } @@ -803,13 +811,28 @@ provider_model_maps: "#; 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", "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-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) diff --git a/src/commands_internal/query.rs b/src/commands_internal/query.rs index 3307925..944f3ad 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -415,7 +415,11 @@ fn team_protocol_source<'a>(provider_code: &'a str, protocol_type: &'a str) -> O /// 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 { +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) @@ -2054,7 +2058,11 @@ mod two_axis_bindings_tests { #[test] fn glm_anthropic_endpoint_two_axis() { // zhipu credential whose base_url is GLM's anthropic endpoint. - let b = two_axis_bindings("zhipu", "openai_compatible", "https://open.bigmodel.cn/api/anthropic"); + let b = two_axis_bindings( + "zhipu", + "openai_compatible", + "https://open.bigmodel.cn/api/anthropic", + ); let arr = b.as_array().expect("bindings is an array"); assert_eq!(arr.len(), 1, "D-13: array from the start"); // protocol from the ROUTE ROW (anthropic), NOT the stale stored protocol_type diff --git a/src/commands_proxy.rs b/src/commands_proxy.rs index 1f922b4..34b7e91 100644 --- a/src/commands_proxy.rs +++ b/src/commands_proxy.rs @@ -2117,7 +2117,11 @@ pub struct PipelineDiagnosticsWire { impl Default for RegistryProvenanceWire { fn default() -> Self { - Self { digest: String::new(), route_rows: 0, providers_with_model_map: Vec::new() } + Self { + digest: String::new(), + route_rows: 0, + providers_with_model_map: Vec::new(), + } } } impl Default for MappingHealthWire { diff --git a/src/main.rs b/src/main.rs index dc92f99..c493771 100644 --- a/src/main.rs +++ b/src/main.rs @@ -568,7 +568,11 @@ fn run_unified_list( .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())) + .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 @@ -791,19 +795,23 @@ fn run_unified_list( } let all_data: Vec<&RowData> = personal_rows.iter().chain(team_rows.iter()).collect(); - let headers = ["ALIAS", "PROTOCOL", "PROVIDER", "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_proto = headers[1].len().max( - all_data - .iter() - .map(|r| r.protocol.len()) - .max() - .unwrap_or(0), - ) + pad; + 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() diff --git a/src/storage.rs b/src/storage.rs index 8d83151..3da4a28 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1114,7 +1114,13 @@ pub fn change_password( "UPDATE managed_virtual_keys_cache SET provider_key_nonce = ?, provider_key_ciphertext = ? WHERE virtual_key_id = ? AND protocol_type = ? AND provider_code = ?", - params![new_nonce, new_ciphertext, vk_id, protocol_type, 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))?; } @@ -2297,8 +2303,20 @@ mod tests { // 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), + ( + "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 @@ -2335,7 +2353,11 @@ mod tests { .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("zhipu").as_slice(), + glm_key, + "GLM binding key must survive rotation intact" + ); assert_eq!( read_binding("anthropic").as_slice(), official_key, @@ -2403,7 +2425,10 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(pk_after, 3, "re-grain to composite (vk, protocol, provider) PK"); + assert_eq!( + pk_after, 3, + "re-grain to composite (vk, protocol, provider) PK" + ); let (ct, ver): (Vec, i64) = conn .query_row( @@ -2413,13 +2438,19 @@ mod tests { |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!( + 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)) + .query_row("SELECT COUNT(*) FROM managed_virtual_keys_cache", [], |r| { + r.get(0) + }) .unwrap(); assert_eq!(count, 1, "idempotent re-run leaves data untouched"); } diff --git a/tests/e2e_list_two_axis_live.rs b/tests/e2e_list_two_axis_live.rs index 9a7a729..4129091 100644 --- a/tests/e2e_list_two_axis_live.rs +++ b/tests/e2e_list_two_axis_live.rs @@ -91,13 +91,18 @@ fn list_shows_two_axes_and_collapses_multi_binding_vk() { .stdin(Stdio::null()) .output() .expect("aikey add"); - assert!(out.status.success(), "add failed: {}", String::from_utf8_lossy(&out.stderr)); + 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 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(); @@ -105,25 +110,64 @@ fn list_shows_two_axes_and_collapses_multi_binding_vk() { // (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(); + "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"), + ( + "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(); + 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 out = base_cmd(&tmp, &vault) + .arg("list") + .stdin(Stdio::null()) + .output() + .expect("aikey list"); let text = format!( "{}{}", String::from_utf8_lossy(&out.stdout), @@ -133,15 +177,32 @@ fn list_shows_two_axes_and_collapses_multi_binding_vk() { 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}"); + 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}"); + 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 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}"); + 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); From 51f6a810a89988b3a62375d0a954df3c38bd1ec7 Mon Sep 17 00:00:00 2001 From: Damon Date: Mon, 20 Jul 2026 22:40:46 -0400 Subject: [PATCH 6/9] fix(cli): derive Default on diagnostics wire types (clippy fence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make lint` (= cargo clippy -D warnings, enforced by develop-v1.0.4) failed with 2 derivable_impls errors on RegistryProvenanceWire / MappingHealthWire — the manual Default impls produce exactly what derive(Default) does. Replaced with #[derive(Default)] and dropped the hand-written impls. No behavior change; clippy + fmt clean, parity (24) + both two-axis live e2e still pass. Co-Authored-By: Claude Opus 4.8 --- src/commands_proxy.rs | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/src/commands_proxy.rs b/src/commands_proxy.rs index 34b7e91..dcb8e96 100644 --- a/src/commands_proxy.rs +++ b/src/commands_proxy.rs @@ -2082,7 +2082,7 @@ pub fn fetch_egress_state() -> Result, String> { // (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, serde::Deserialize)] +#[derive(Debug, Default, serde::Deserialize)] pub struct MappingHealthWire { /// "ok" | "degraded" | "inactive". #[serde(default)] @@ -2097,7 +2097,7 @@ pub struct MappingHealthWire { pub passthrough_missing: i64, } -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, Default, serde::Deserialize)] pub struct RegistryProvenanceWire { #[serde(default)] pub digest: String, @@ -2115,27 +2115,6 @@ pub struct PipelineDiagnosticsWire { pub model_mapping: MappingHealthWire, } -impl Default for RegistryProvenanceWire { - fn default() -> Self { - Self { - digest: String::new(), - route_rows: 0, - providers_with_model_map: Vec::new(), - } - } -} -impl Default for MappingHealthWire { - fn default() -> Self { - Self { - status: String::new(), - reason: String::new(), - applied: 0, - rejected: 0, - passthrough_missing: 0, - } - } -} - /// 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 { From c4957c8dec46bd56b87ee9b9e445e2322007f8fb Mon Sep 17 00:00:00 2001 From: Damon Date: Tue, 21 Jul 2026 22:32:24 -0400 Subject: [PATCH 7/9] fix(cli): bootstrap the vault on first run instead of claiming it is corrupted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a machine with no vault, `aikey key sync` (and `aikey use` / the web-session path, which share the same helper) died with: Error: Salt not found in vault. Vault may be corrupted. right after walking the user through "No vault found — setting up for the first time", setting a master password, confirming it, and choosing a session backend. Nothing was corrupted: the vault had never been created, so the message sent users hunting for damaged data — or deleting a vault — over a fresh install. Cause: two ways to reach a vault key had drifted apart. - `executor::derive_vault_key` → `VaultContext::new`, which auto-initializes. - `commands_account::derive_vault_key` (a private duplicate) read `storage::get_salt()` straight away. The first-run password prompt (`prompt_vault_password_fresh`'s no-vault branch) only COLLECTS a password — it never writes the vault — so whichever path ran first had to bootstrap. The duplicate didn't, and every fresh install that hit `key sync` before any VaultContext-based command got the corruption error. `AIKEY_MASTER_PASSWORD` doesn't help: it unlocks, it does not initialize. Fix: extract the bootstrap out of `VaultContext::new` into `executor::ensure_vault_initialized` (idempotent; no-op once master_salt exists) and call it from both paths. One definition of "first run" — a second inline salt-generation is exactly how this drifted the first time. Fences (both verified red without the fix): - derive_vault_key_bootstraps_a_brand_new_vault — from a dir with NO vault, deriving must succeed and persist master_salt. - bootstrap_is_idempotent_and_never_resalts — a second call must not re-salt; re-salting would change the derived key and orphan every existing entry. Verified on the real binary against staging master2 (v1.0.1-alpha.8): fresh HOME → `key sync` now prints "Sync complete", login completes, and the two-axis output works end to end — PROTOCOL PROVIDER KEY anthropic anthropic(claude) → key-admin-anthropic-2providers [team] anthropic zhipu(GLM) → key-admin-anthropic-2providers [team] `make fmt-check` + `make lint` (the release.sh gates) clean; full lib suite 1091 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 --- src/commands_account/mod.rs | 82 +++++++++++++++++++++++++++++++++++++ src/executor.rs | 58 ++++++++++++++++---------- 2 files changed, 119 insertions(+), 21 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 8bebb1a..f4f0a5e 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -5736,6 +5736,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)?; @@ -6268,6 +6279,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/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()?; From 50e1010e621cecadb61c40fb3d2b2f8f87b0d133 Mon Sep 17 00:00:00 2001 From: Damon Date: Tue, 21 Jul 2026 22:54:56 -0400 Subject: [PATCH 8/9] refactor(cli): render the provider axis identically everywhere (one labeller) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the two-axis work. `aikey list` printed the PROVIDER column as `zhipu(GLM)` but USING FOR — the SAME axis — as a bare `zhipu`, and the `aikey use` title / `key sync` auto-activate lines did the same. Worth stating plainly, because my first read of this was wrong: those bare lists were NOT mixing the two axes. `ProviderBinding.provider_code` is the provider axis throughout, so `USING FOR: anthropic,zhipu` was two PROVIDERS, and "Primary for zhipu" is accurate. The defect is that Anthropic's provider code is spelled exactly like the anthropic wire protocol, so an unlabelled list of providers reads as though a protocol had been mixed in — which is precisely how I misread it. Labelling the axis consistently removes the ambiguity without changing any semantics. - New `provider_registry::display_label(code)` — canonical `code(alias)`, bare code when unknown/alias-less, empty stays empty. The alias never replaces the code (the code is what bindings/events/config speak). - Five render sites now share it: `aikey list` PROVIDER + USING FOR columns, `aikey use` summary rows + title, `key sync` auto-activate lines. Two of them carried private copies of the same match expression; those are gone. Fences: aliased code keeps its code, unknown code falls back to bare (custom providers must never blank a cell), empty stays empty (a "()" would materialize a bogus cell). Verified on the real binary against staging: USING FOR now reads `anthropic(claude),zhipu(GLM)` and the use title `Primary for zhipu(GLM)`. fmt + lint clean; lib suite 1094 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 --- src/commands_account/mod.rs | 15 ++++---- src/main.rs | 20 ++++++----- src/provider_registry.rs | 72 +++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index f4f0a5e..3c6e416 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -3953,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) ); } } @@ -5242,12 +5242,7 @@ pub fn handle_key_use( }; // Provider axis: `code(alias)` — GLM alias muted-in-parens, matching // the vault chip (zhipu → `zhipu(GLM)`). 🚫 alias never replaces code. - let provider_disp = match crate::provider_registry::lookup(&b.provider_code) - .and_then(|e| e.display_alias) - { - Some(alias) if !alias.is_empty() => format!("{}({})", b.provider_code, alias), - _ => b.provider_code.clone(), - }; + let provider_disp = crate::provider_registry::display_label(&b.provider_code); let protocol = resolve_binding_protocol(b); rows.push(format!( " {:<12} {:<20} {} {}", @@ -5265,7 +5260,11 @@ pub fn handle_key_use( let title = format!( "Set '{}' as Primary for {}", display_name, - target_providers.join(", ") + target_providers + .iter() + .map(|p| crate::provider_registry::display_label(p)) + .collect::>() + .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 diff --git a/src/main.rs b/src/main.rs index c493771..ba955ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -577,12 +577,8 @@ fn run_unified_list( }; // 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 { - match crate::provider_registry::lookup(code).and_then(|e| e.display_alias) { - Some(a) if !a.is_empty() => format!("{}({})", code, a), - _ => code.to_string(), - } - }; + 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) { @@ -653,7 +649,11 @@ fn run_unified_list( alias: entry.alias.clone(), protocol: collapse_axis(&proto_vals), providers: collapse_axis(&prov_vals), - primary_for: pf.join(","), + primary_for: pf + .iter() + .map(|c| provider_label(c)) + .collect::>() + .join(","), has_primary: !pf.is_empty(), status: String::new(), // valid → not displayed created: entry @@ -785,7 +785,11 @@ fn run_unified_list( alias: display, protocol: collapse_axis(&proto_vals), providers: collapse_axis(&prov_vals), - primary_for: pf.join(","), + primary_for: pf + .iter() + .map(|c| provider_label(c)) + .collect::>() + .join(","), has_primary: !pf.is_empty(), status, created: format_date(rep.synced_at), 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(""), ""); + } +} From e4058a2faa80c74311ee8e86d05f60a26ddb1f81 Mon Sep 17 00:00:00 2001 From: Damon Date: Wed, 22 Jul 2026 10:12:53 -0400 Subject: [PATCH 9/9] fix(cli): one protocol resolver, labelled provider picker, non-bricking vault ACL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects from Windows real-machine verification. 1. `aikey use` printed a PROVIDER in its PROTOCOL column. `resolve_binding_protocol` fell back to `provider_registry::family_of()` whenever the entry had no stored base_url — every key made by `aikey add --provider x` without `--base-url` — so the column read `zhipu` / `openai`, and disagreed with `aikey list`, which has a third step: the provider's own route row. Both now call one `provider_fingerprint::protocol_for()`; it resolves endpoint → declared → provider route, and returns empty rather than ever naming the other axis. The summary's PROTOCOL column was also a fixed `{:<12}`, too narrow for `openai_compatible` (17 chars) — both axis columns are auto-width now. 2. The interactive multi-provider picker printed bare codes while the title, the table and `aikey list` all said `zhipu(GLM)`. Both call sites now use the shared labeller, so the list you choose FROM and the confirmation you get BACK spell the provider the same way. 3. `enforce_owner_only` ran `icacls /inheritance:r` FIRST and granted after. On a path with no explicit ACEs yet that leaves an EMPTY DACL, so any failure in the grant loop left a file nobody — SYSTEM included — could open or delete. Running as a service, USERNAME is the machine account (`HOST$`), its grant fails, and vault.db was left 0 bytes with an empty DACL: that vault path was then permanently poisoned, every later run dying at "unable to open database file" unable even to remove it. Reproduced on Windows; recovery needed `takeown`. Grants now come first and `/inheritance:r` last, so a failure leaves the path still-inherited and recoverable instead of unreachable. A current-user grant that can't resolve is a warning, not a fatal — SYSTEM and Administrators are already granted and SYSTEM is the identity such a process runs as. Vault init also removes a 0-byte vault.db (never a real SQLite DB) and cleans up a half-created file on any error in the init path. `aikey add` as LocalSystem now succeeds where it used to abort. Co-Authored-By: Claude Opus 4.8 --- src/commands_account/mod.rs | 97 +++++++--- .../parse/provider_fingerprint.rs | 77 ++++++++ src/storage.rs | 99 +++++++--- src/storage_acl.rs | 170 +++++++++--------- 4 files changed, 311 insertions(+), 132 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 3c6e416..dc0f8de 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -5076,7 +5076,15 @@ 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) + ); } // P1f.11② (§19.9): this list is `supported_providers` (PROVIDER codes), // not protocols — the old "Select protocol(s)" mislabel conflated the two @@ -5120,7 +5128,15 @@ 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) + ); } // P1f.11② (§19.9): PROVIDER codes, not protocols — fix the mislabel. print!("Select provider(s) to set as Primary (comma-separated): "); @@ -5221,12 +5237,42 @@ pub fn handle_key_use( // 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!( - " {:<12} {:<20} {}", + " {: String { use crate::credential_type::CredentialType; - let classifier = crate::commands_internal::parse::provider_fingerprint::instance(); - let via_base_url = |base_url: &str, fallback: &str| -> Option { - classifier - .route_for_base_url(base_url) - .map(|r| r.protocol.clone()) - .filter(|p| !p.is_empty()) - .or_else(|| (!fallback.is_empty()).then(|| fallback.to_string())) - }; - let resolved = match b.key_source_type { + 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() - .and_then(|vk| via_base_url(&vk.base_url, &vk.protocol_type)), + .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() - .and_then(|url| via_base_url(&url, "")), + _ => ( + storage::get_entry_base_url(&b.key_source_ref) + .ok() + .flatten() + .unwrap_or_default(), + String::new(), + ), }; - // Honest fallback when the endpoint can't be resolved (e.g. an OAuth binding - // with no cached base_url): the provider's canonical family label. Best-effort - // only — the route row is authoritative whenever present. - resolved.unwrap_or_else(|| crate::provider_registry::family_of(&b.provider_code).to_string()) + // 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 diff --git a/src/commands_internal/parse/provider_fingerprint.rs b/src/commands_internal/parse/provider_fingerprint.rs index 86650e0..dd8e0d8 100644 --- a/src/commands_internal/parse/provider_fingerprint.rs +++ b/src/commands_internal/parse/provider_fingerprint.rs @@ -362,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` / diff --git a/src/storage.rs b/src/storage.rs index 3da4a28..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(()) } 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(()) }