From 58a9c43a3205e61765bb16ad7e36cae4ed448612 Mon Sep 17 00:00:00 2001 From: "335923591@qq.com" <335923591@qq.com> Date: Thu, 25 Jun 2026 14:20:28 +0800 Subject: [PATCH 01/42] feat(vault): seat_group cache columns + retrofit migrations Co-Authored-By: Claude Opus 4.8 --- src/commands_account/mod.rs | 117 +++++++++++++++++++++++++++++++++ src/migrations.rs | 65 ++++++++++++++++++ src/platform_client.rs | 14 ++++ src/storage_platform.rs | 127 +++++++++++++++++++++++++----------- 4 files changed, 284 insertions(+), 39 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index bb50494..abd2e48 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -2730,6 +2730,15 @@ fn apply_snapshot_to_cache( // requirement. Putting any other value here would be // misleading, not destructive. extra: None, + // N6: fold the seat-group candidate set from the snapshot. These ARE + // server-owned (in the upsert's DO UPDATE SET). group_accounts is + // stored as raw JSON text; None for a direct-bind VK. + seat_group_id: item.seat_group_id.clone(), + group_accounts: item + .group_accounts + .as_ref() + .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string())), + routing_config: item.routing_config.clone(), }; let _ = storage::upsert_virtual_key_cache(&entry); @@ -2968,6 +2977,14 @@ pub(crate) fn upsert_delivered_key( ) -> Result<(), String> { let (nonce, ciphertext) = crypto::encrypt(vault_key, plaintext_provider_key.as_bytes()) .map_err(|e| format!("encrypt: {}", e))?; + // N6: this is a direct-bind key-delivery path (single credential ciphertext), + // NOT the seat-group structural sync — but it shares upsert_virtual_key_cache, + // whose DO UPDATE SET now includes the server-owned seat_group columns. Carry + // forward the existing row's group fields so accepting a key never wipes the + // candidate set a prior snapshot sync folded in. + let existing = storage::get_virtual_key_cache(&dk.virtual_key_id) + .ok() + .flatten(); let entry = VirtualKeyCacheEntry { virtual_key_id: dk.virtual_key_id.clone(), org_id: dk.org_id.clone(), @@ -2993,6 +3010,11 @@ pub(crate) fn upsert_delivered_key( // Sync writers MUST always pass extra: None; upsert ignores this // field. See doc on VirtualKeyCacheEntry::extra. extra: None, + // Carry forward server-synced group fields (this path isn't authoritative + // over them) so a key-accept doesn't clobber them to NULL. + seat_group_id: existing.as_ref().and_then(|e| e.seat_group_id.clone()), + group_accounts: existing.as_ref().and_then(|e| e.group_accounts.clone()), + routing_config: existing.as_ref().and_then(|e| e.routing_config.clone()), }; storage::upsert_virtual_key_cache(&entry) } @@ -3405,6 +3427,13 @@ pub fn sync_managed_key_metadata() -> bool { // Sync writers MUST always pass extra: None; upsert ignores // this field. See doc on VirtualKeyCacheEntry::extra. extra: None, + // N6: this lightweight metadata sync (KeyItem) does NOT carry seat-group + // data — carry forward existing values so it doesn't clobber what the + // full snapshot sync folded in. The full sync (apply_snapshot_to_cache) + // is authoritative over these. + seat_group_id: existing.as_ref().and_then(|e| e.seat_group_id.clone()), + group_accounts: existing.as_ref().and_then(|e| e.group_accounts.clone()), + routing_config: existing.as_ref().and_then(|e| e.routing_config.clone()), }; let _ = storage::upsert_virtual_key_cache(&entry); @@ -5385,6 +5414,94 @@ mod core_tests { [0x42u8; 32] } + // ── N6: seat-group fold + extra fence ───────────────────────────────────── + + fn vk_entry( + vk: &str, + seat_group_id: Option<&str>, + group_accounts: Option<&str>, + routing_config: Option<&str>, + ) -> storage::VirtualKeyCacheEntry { + storage::VirtualKeyCacheEntry { + virtual_key_id: vk.into(), + org_id: "org-1".into(), + seat_id: "seat-1".into(), + alias: "k".into(), + provider_code: "anthropic".into(), + protocol_type: "anthropic".into(), + base_url: String::new(), + credential_id: String::new(), + credential_revision: String::new(), + virtual_key_revision: "r1".into(), + key_status: "active".into(), + share_status: "claimed".into(), + local_state: "synced_inactive".into(), + expires_at: None, + provider_key_nonce: None, + provider_key_ciphertext: None, + synced_at: 0, + local_alias: None, + supported_providers: vec![], + provider_base_urls: std::collections::HashMap::new(), + owner_account_id: Some("acct-1".into()), + extra: None, + seat_group_id: seat_group_id.map(|s| s.to_string()), + group_accounts: group_accounts.map(|s| s.to_string()), + routing_config: routing_config.map(|s| s.to_string()), + } + } + + #[test] + fn n6_group_fields_fold_and_extra_survives_sync() { + let (_dir, _guard) = setup_vault(); + + // First sync: a group-bound VK with a candidate set + routing config. + let ga = r#"[{"account_id":"acc-A","identity":"a@t.com","provider_code":"anthropic","priority":1,"assigned":true}]"#; + let rc = r#"{"exhaustion_signals":["unified_rate_limited"]}"#; + storage::upsert_virtual_key_cache(&vk_entry("vk-1", Some("grp-1"), Some(ga), Some(rc))).unwrap(); + + let got = storage::get_virtual_key_cache("vk-1").unwrap().unwrap(); + assert_eq!(got.seat_group_id.as_deref(), Some("grp-1"), "seat_group_id folded"); + assert!(got.group_accounts.as_deref().unwrap().contains("acc-A"), "group_accounts folded"); + assert_eq!(got.routing_config.as_deref(), Some(rc), "routing_config folded"); + + // User records a connectivity-test result into the user-owned `extra` blob. + { + let conn = crate::storage::open_connection().unwrap(); + conn.execute( + "UPDATE managed_virtual_keys_cache SET extra = ?1 WHERE virtual_key_id = ?2", + rusqlite::params![r#"{"last_test":{"ok":true}}"#, "vk-1"], + ) + .unwrap(); + } + + // Second sync: candidate set changed (server authoritative) — must UPDATE + // group fields BUT leave `extra` intact (the 2026-05-22 fence invariant). + let ga2 = r#"[{"account_id":"acc-B","identity":"b@t.com","provider_code":"anthropic","priority":1,"assigned":true}]"#; + let rc2 = r#"{"reject_ratio":5}"#; + storage::upsert_virtual_key_cache(&vk_entry("vk-1", Some("grp-2"), Some(ga2), Some(rc2))).unwrap(); + + let got = storage::get_virtual_key_cache("vk-1").unwrap().unwrap(); + assert_eq!(got.seat_group_id.as_deref(), Some("grp-2"), "seat_group_id updated by sync"); + assert_eq!(got.routing_config.as_deref(), Some(rc2), "routing_config updated by sync"); + assert!(got.group_accounts.as_deref().unwrap().contains("acc-B"), "group_accounts updated"); + assert!(!got.group_accounts.as_deref().unwrap().contains("acc-A"), "old candidate replaced"); + // The critical fence: sync touching group fields must NOT wipe extra. + let extra = got.extra.expect("extra survived sync"); + assert_eq!(extra["last_test"]["ok"], serde_json::json!(true), "user-owned extra preserved"); + } + + #[test] + fn n6_direct_bind_vk_has_no_group_fields() { + let (_dir, _guard) = setup_vault(); + // A direct-bind VK syncs with no seat group → all group columns NULL. + storage::upsert_virtual_key_cache(&vk_entry("vk-2", None, None, None)).unwrap(); + let got = storage::get_virtual_key_cache("vk-2").unwrap().unwrap(); + assert_eq!(got.seat_group_id, None); + assert_eq!(got.group_accounts, None); + assert_eq!(got.routing_config, None); + } + // ── apply_quota_snapshot_to_cache (Phase 2 Stage 2b) ────────────────────── #[test] diff --git a/src/migrations.rs b/src/migrations.rs index 648d83b..a46a3a0 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -418,6 +418,40 @@ pub mod v1_0_0_baseline { "extra", "ALTER TABLE managed_virtual_keys_cache ADD COLUMN extra TEXT", ), + // 2026-06-24 (master v1.0.1-alpha.3): seat_group fold. When a VK's + // binding target is a seat_group, the whole group folds into THIS + // row — no separate client cache tables (技术方案 §2.3). + // seat_group_id != NULL marks a group-backed VK + // group_accounts candidate-list metadata JSON (from the + // materialized view; structural sync) + // routing_config group hash/schedule/util_cap knobs JSON + // my_assignment_override seat's current routed account per protocol + // JSON {protocol:account_id} — written by the + // routing-override POLL, NOT structural sync; + // empty = pure pkg/seatassign hash default + // group_runtime JSON {account_id:{token ciphertext, + // window_max_util_pct, window_reset_at}} via + // channel ③ (volatile; NEVER refresh_token) + ( + "seat_group_id", + "ALTER TABLE managed_virtual_keys_cache ADD COLUMN seat_group_id TEXT", + ), + ( + "group_accounts", + "ALTER TABLE managed_virtual_keys_cache ADD COLUMN group_accounts TEXT", + ), + ( + "routing_config", + "ALTER TABLE managed_virtual_keys_cache ADD COLUMN routing_config TEXT", + ), + ( + "my_assignment_override", + "ALTER TABLE managed_virtual_keys_cache ADD COLUMN my_assignment_override TEXT", + ), + ( + "group_runtime", + "ALTER TABLE managed_virtual_keys_cache ADD COLUMN group_runtime TEXT", + ), ] { ensure_column(conn, "managed_virtual_keys_cache", col, ddl)?; } @@ -1474,6 +1508,37 @@ mod tests { upgrade_all(&conn).expect("second upgrade_all must be idempotent"); } + /// N0 (master v1.0.1-alpha.3 seat_group fold): the 5 seat_group columns are + /// retrofitted onto managed_virtual_keys_cache by upgrade_all — group data + /// folds into the VK row, no separate client cache tables (技术方案 §2.3). + #[test] + fn seat_group_fold_columns_retrofit_on_managed_virtual_keys_cache() { + let conn = Connection::open_in_memory().expect("open"); + upgrade_all(&conn).expect("upgrade_all"); + for col in [ + "seat_group_id", + "group_accounts", + "routing_config", + "my_assignment_override", + "group_runtime", + ] { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('managed_virtual_keys_cache') WHERE name=?1", + [col], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + n, 1, + "managed_virtual_keys_cache.{} missing after upgrade_all", + col + ); + } + // Idempotent: ensure_column absorbs the duplicate-column error on re-run. + upgrade_all(&conn).expect("second upgrade_all must be idempotent"); + } + // T26 + cycle test were both deleted 2026-05-06: they exercised the // multi-version chain (rollback to v1.0.2-alpha then re-upgrade // through v1.0.3, v1.0.4) which no longer exists post-fold. After diff --git a/src/platform_client.rs b/src/platform_client.rs index 193e23b..0fc7336 100644 --- a/src/platform_client.rs +++ b/src/platform_client.rs @@ -117,6 +117,20 @@ pub struct ManagedKeySnapshotItem { #[serde(default)] pub expires_at: Option, pub sync_version: i64, + /// Seat-group binding target (N6). Present only when the VK's binding targets + /// a seat_group instead of a single credential. `None` for direct-bind VKs. + #[serde(default)] + pub seat_group_id: Option, + /// Seat's ranked candidate set for a group-bound VK (N6): array of + /// `{account_id, identity, provider_code, priority, assigned}`. `None`/absent + /// for direct-bind VKs. Stored verbatim into the cache as JSON text. + #[serde(default)] + pub group_accounts: Option, + /// The group's routing knobs JSON (exhaustion_signals / util_cap / ratios), + /// for the proxy's offline routing (N6 follow-up). `"{}"`/absent for + /// direct-bind VKs. Stored verbatim into the cache. + #[serde(default)] + pub routing_config: Option, } /// Returned by GET /accounts/me/managed-keys-snapshot. diff --git a/src/storage_platform.rs b/src/storage_platform.rs index 05be131..ee26d99 100644 --- a/src/storage_platform.rs +++ b/src/storage_platform.rs @@ -576,6 +576,21 @@ pub struct VirtualKeyCacheEntry { /// `INSERT ... ON CONFLICT(virtual_key_id) DO UPDATE SET ` exactly to enforce this. pub extra: Option, + /// Seat-group binding target (N6, server-owned). Folded from the snapshot's + /// `seat_group_id`. `None` for direct-bind VKs. Unlike `extra`, this IS in + /// the upsert's DO UPDATE SET (server is authoritative). + pub seat_group_id: Option, + /// Seat's ranked candidate set for a group-bound VK (N6, server-owned), + /// stored as raw JSON text `[{account_id, identity, provider_code, priority, + /// assigned}]`. Folded from the snapshot. `None` for direct-bind VKs. The + /// volatile token/window material is NOT here — it rides `group_runtime` + /// (channel ③, N7), a separate column this writer does not touch. + pub group_accounts: Option, + /// The group's routing knobs JSON (N6, server-owned), folded from the + /// snapshot. `None`/`"{}"` for direct-bind VKs. The proxy reads it to + /// classify exhaustion (exhaustion_signals) + apply switch/cooldown knobs. + /// In the upsert's DO UPDATE SET (server authoritative). + pub routing_config: Option, } impl VirtualKeyCacheEntry { @@ -634,7 +649,8 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri provider_key_nonce, provider_key_ciphertext, cache_schema_version, synced_at, local_alias, supported_providers, - provider_base_urls, owner_account_id + provider_base_urls, owner_account_id, + seat_group_id, group_accounts, routing_config ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, @@ -644,7 +660,8 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri ?15, ?16, 1, strftime('%s', 'now'), ?17, ?18, - ?19, ?20 + ?19, ?20, + ?21, ?22, ?23 ) ON CONFLICT(virtual_key_id) DO UPDATE SET org_id = excluded.org_id, @@ -666,9 +683,15 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri local_alias = excluded.local_alias, supported_providers = excluded.supported_providers, provider_base_urls = excluded.provider_base_urls, - owner_account_id = excluded.owner_account_id + owner_account_id = excluded.owner_account_id, + seat_group_id = excluded.seat_group_id, + group_accounts = excluded.group_accounts, + routing_config = excluded.routing_config /* extra: DELIBERATELY OMITTED — user-owned column. See doc comment above and on VirtualKeyCacheEntry::extra. */ + /* my_assignment_override / group_runtime / routing_config: OMITTED — + written by other channels (routing poll N12 / channel ③ N7), not + this structural sync. Same fence rationale as extra. */ /* cache_schema_version: omitted because it's a constant. */", params![ entry.virtual_key_id, @@ -691,6 +714,9 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri supported_providers_json, provider_base_urls_json, entry.owner_account_id, + entry.seat_group_id, + entry.group_accounts, + entry.routing_config, ], ) .map_err(|e| format!("Failed to upsert virtual key cache: {}", e))?; @@ -729,15 +755,38 @@ pub fn list_virtual_key_cache_readonly() -> Result, St // ── managed_virtual_keys_cache column cascade ────────────────────────── // -// FULL — post 2026-05-22 vaults (has `extra` column) -// LEGACY — pre-2026-05-22 (no extra column; project literal NULL) +// Three tiers, tried newest→oldest so each vault generation reads as many +// real columns as it has, projecting literal NULL for columns it predates: +// GROUP — post seat_group (v1.0.1-alpha.3): + seat_group_id + group_accounts +// FULL — post 2026-05-22 (has `extra`, but no seat_group columns) +// LEGACY — pre-2026-05-22 (no extra either) // -// Why a cascade rather than relying purely on ensure_column to backfill: -// `list_virtual_key_cache_readonly` opens the DB read-only (no migrations -// applied), and there is no out-of-process write path that materialises -// the column for read-only callers. The fallback prepare lets the -// read-only client serve old vaults uniformly while the next write opens -// a write conn and triggers ensure_column. +// CRITICAL: the fixed column ORDER is shared by row_to_virtual_key_cache's +// index-based reads — extra is ALWAYS index 21, seat_group_id 22, +// group_accounts 23. The middle (FULL) tier projects NULL for 22/23 but a +// REAL extra at 21, so a vault that has extra but not yet the seat_group +// columns still reads its `extra` (no regression — the old 2-tier cascade +// would have dropped to LEGACY and lost extra). Why a cascade at all: +// `list_virtual_key_cache_readonly` opens read-only (no migrations), so a +// vault not yet write-migrated must still be served. +// Columns 0..=20 are identical across tiers; only the trailing 21/22/23/24 +// (extra / seat_group_id / group_accounts / routing_config) differ. Fixed +// indices are shared by row_to_virtual_key_cache's index reads — the middle +// (FULL) tier keeps a REAL extra at 21 and NULLs the seat_group columns, so a +// vault with extra but not yet the seat_group migration still reads its extra +// (no LEGACY-fallthrough regression). Kept as full literals (no string-concat +// crate) for a single grep-able source per tier. +// +// Newest: real extra + real seat_group columns. +const VK_CACHE_COLUMNS_GROUP: &str = "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, \ + synced_at, local_alias, supported_providers, \ + provider_base_urls, owner_account_id, extra, seat_group_id, group_accounts, routing_config"; +// Middle: real extra, no seat_group columns yet (project NULL at 22/23). const VK_CACHE_COLUMNS_FULL: &str = "virtual_key_id, org_id, seat_id, alias, \ provider_code, protocol_type, base_url, \ credential_id, credential_revision, virtual_key_revision, \ @@ -745,7 +794,8 @@ const VK_CACHE_COLUMNS_FULL: &str = "virtual_key_id, org_id, seat_id, alias, \ expires_at, \ provider_key_nonce, provider_key_ciphertext, \ synced_at, local_alias, supported_providers, \ - provider_base_urls, owner_account_id, extra"; + provider_base_urls, owner_account_id, extra, NULL, NULL, NULL"; +// Oldest: no extra, no seat_group columns. const VK_CACHE_COLUMNS_LEGACY: &str = "virtual_key_id, org_id, seat_id, alias, \ provider_code, protocol_type, base_url, \ credential_id, credential_revision, virtual_key_revision, \ @@ -753,7 +803,7 @@ const VK_CACHE_COLUMNS_LEGACY: &str = "virtual_key_id, org_id, seat_id, alias, \ expires_at, \ provider_key_nonce, provider_key_ciphertext, \ synced_at, local_alias, supported_providers, \ - provider_base_urls, owner_account_id, NULL"; + provider_base_urls, owner_account_id, NULL, NULL, NULL, NULL"; /// Single mapping from a SELECT row (in the column order declared by /// `VK_CACHE_COLUMNS_*` above) to a struct. Centralised here so adding @@ -791,21 +841,19 @@ fn row_to_virtual_key_cache(row: &rusqlite::Row) -> rusqlite::Result>(22).unwrap_or(None), + group_accounts: row.get::<_, Option>(23).unwrap_or(None), + routing_config: row.get::<_, Option>(24).unwrap_or(None), }) } fn query_virtual_key_cache(conn: &Connection) -> Result, String> { + let order = " FROM managed_virtual_keys_cache ORDER BY COALESCE(local_alias, alias)"; let mut stmt = conn - .prepare(&format!( - "SELECT {} FROM managed_virtual_keys_cache ORDER BY COALESCE(local_alias, alias)", - VK_CACHE_COLUMNS_FULL - )) - .or_else(|_| { - conn.prepare(&format!( - "SELECT {} FROM managed_virtual_keys_cache ORDER BY COALESCE(local_alias, alias)", - VK_CACHE_COLUMNS_LEGACY - )) - }) + .prepare(&format!("SELECT {}{}", VK_CACHE_COLUMNS_GROUP, order)) + .or_else(|_| conn.prepare(&format!("SELECT {}{}", VK_CACHE_COLUMNS_FULL, order))) + .or_else(|_| conn.prepare(&format!("SELECT {}{}", VK_CACHE_COLUMNS_LEGACY, order))) .map_err(|e| format!("Failed to prepare list query: {}", e))?; let rows = stmt @@ -823,25 +871,23 @@ pub fn get_virtual_key_cache(virtual_key_id: &str) -> Result Err(e), - _ => conn.query_row( - &format!( - "SELECT {} FROM managed_virtual_keys_cache WHERE virtual_key_id = ?1", - VK_CACHE_COLUMNS_LEGACY - ), - params![virtual_key_id], - row_to_virtual_key_cache, - ), + _ => conn.query_row(&sel(VK_CACHE_COLUMNS_FULL), params![virtual_key_id], row_to_virtual_key_cache), + }) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Err(e), + _ => conn.query_row(&sel(VK_CACHE_COLUMNS_LEGACY), params![virtual_key_id], row_to_virtual_key_cache), }); match result { Ok(entry) => Ok(Some(entry)), @@ -1753,6 +1799,9 @@ mod key_material_reachable_tests { provider_base_urls: std::collections::HashMap::new(), owner_account_id: None, extra: None, + seat_group_id: None, + group_accounts: None, + routing_config: None, } } From 3343aa7c94a4e40194314572c754615444600dd5 Mon Sep 17 00:00:00 2001 From: "335923591@qq.com" <335923591@qq.com> Date: Fri, 26 Jun 2026 11:09:02 +0800 Subject: [PATCH 02/42] feat: seat-group (OAuth account pool) end-to-end + live-E2E fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] skip claim/delivery for group VKs during sync (no static material → was a spurious 403 + "0 downloaded"); project seat_group_id + parsed group_accounts into the internal team-records emit for the /user web. Refs: - CLI sync group-VK delivery 403 bugfix: https://github.com/aikeylabs/workflow/blob/3eb36d250c0f02ed28ba4753b6fa468d1fcf1b59/CI/bugfix/2026-06-26-cli-sync-group-vk-delivery-403.md - seat-group / OAuth account pool requirement spec: https://github.com/aikeylabs/workflow/blob/3eb36d250c0f02ed28ba4753b6fa468d1fcf1b59/CI/requirements/2026-06-23-oauth-account-pool.md --- src/commands_account/mod.rs | 10 ++++++++++ src/commands_internal/query.rs | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index abd2e48..b4ffcf7 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -3135,6 +3135,16 @@ fn run_full_snapshot_sync_opts( let mut had_download_error = false; for entry in &cached { + // Group VKs carry NO static key material — their per-account material is + // pulled by the proxy via channel ③ (group-runtime), not CLI claim/delivery. + // The master delivery endpoint denies group VKs (403 access_denied), which + // otherwise surfaces as a spurious "could not fetch key" + "0 downloaded" + // during sync. Skip claim/delivery entirely for them; their metadata + // (seat_group_id / group_accounts / routing_config) was already folded into + // the cache by apply_snapshot_to_cache above. + if entry.seat_group_id.is_some() { + continue; + } // Needs claim: pending_claim but not yet claimed on server. let needs_claim = entry.share_status == "pending_claim" && entry.key_status == "active"; // Needs download: claimed (or about to be) AND either we have no local diff --git a/src/commands_internal/query.rs b/src/commands_internal/query.rs index 7f2717d..6532eec 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -275,6 +275,17 @@ fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec(s).ok()), }) }) .collect() From 21986e3c4cd801024763c5bc549b1e7059d2035c Mon Sep 17 00:00:00 2001 From: "335923591@qq.com" <335923591@qq.com> Date: Fri, 26 Jun 2026 15:31:54 +0800 Subject: [PATCH 03/42] feat: seat-group invite-time group join + master OAuth egress proxy + group-VK fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] connectivity probe handles group VKs — empty VK-level provider_code falls back to supported_providers, and group VKs are no longer skipped on absent local ciphertext (material is proxy-side via channel ③). Refs: - group-VK connectivity-test 503 bugfix: https://github.com/aikeylabs/workflow/blob/9dbdc448e094f7719d9cceb2e4138fe68e015276/CI/bugfix/2026-06-26-vault-test-group-vk-and-error-code.md - seat-group / OAuth pool requirement spec: https://github.com/aikeylabs/workflow/blob/9dbdc448e094f7719d9cceb2e4138fe68e015276/CI/requirements/2026-06-23-oauth-account-pool.md --- src/connectivity/targets.rs | 48 ++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/connectivity/targets.rs b/src/connectivity/targets.rs index 878781e..5bbb173 100644 --- a/src/connectivity/targets.rs +++ b/src/connectivity/targets.rs @@ -234,10 +234,22 @@ pub fn targets_from_alias( return Vec::new(); } // Team keys have a single authoritative provider; honour the override - // only when the user supplied one (keeps probe URL consistent). + // only when the user supplied one (keeps probe URL consistent). GROUP + // VKs (seat_group_id set) carry an EMPTY VK-level provider_code BY DESIGN + // (provider lives per-account in group_accounts) — fall back to the VK's + // supported_providers (synced from the group's account set) so the probe + // URL gets the right /. Same root cause as the proxy-side + // 2026-06-25-group-vk-empty-provider-code-502 fix, applied here on the + // CLI probe side. let provider = provider_override .map(|p| p.to_lowercase()) - .unwrap_or_else(|| vk.provider_code.clone()); + .unwrap_or_else(|| { + if !vk.provider_code.is_empty() { + vk.provider_code.clone() + } else { + vk.supported_providers.first().cloned().unwrap_or_default() + } + }); // Cluster form-①: the provider key material stays central on the hub- // resolved node, so this VK's local cache entry has // provider_key_ciphertext = None BY DESIGN. Probe the SAME way a real @@ -261,9 +273,17 @@ pub fn targets_from_alias( &provider, )]; } - if vk.provider_key_ciphertext.is_none() { - // Non-cluster team VK with no local material (never delivered) — - // genuinely unprobeable. Caller surfaces I_CREDENTIAL_NOT_FOUND. + // GROUP VKs have NO local key material (provider_key_ciphertext = None) + // BY DESIGN — the per-account credential is pulled by the proxy via + // channel ③ (group-runtime) and injected at route time, never stored + // locally. So the ciphertext guard (which means "direct-bind VK not yet + // delivered → unprobeable") must NOT apply to group VKs: probe them + // through the proxy with the aikey_team_ bearer exactly like a + // real group request (the proxy resolves the account + injects its key). + if vk.seat_group_id.is_none() && vk.provider_key_ciphertext.is_none() { + // Non-cluster, direct-bind team VK with no local material (never + // delivered) — genuinely unprobeable. Caller surfaces + // I_CREDENTIAL_NOT_FOUND. return Vec::new(); } return vec![team_target(&vk.virtual_key_id, &provider, proxy_port)]; @@ -370,9 +390,11 @@ pub fn targets_from_all_keys(proxy_port: u16) -> (Vec, Vec (Vec, Vec Date: Fri, 26 Jun 2026 16:41:28 +0800 Subject: [PATCH 04/42] fix: group-VK no-local-material handling + reject OAuth direct-bind + loopback probe hijack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] group VKs carry no local key material by design — exempt them in `aikey use` and key_material_reachable (was a misleading "key not delivered"); by-alias VK cache lookup now tries GROUP columns first (seat_group_id was always None on the alias path); loopback probes bypass any external HTTP proxy (NO_PROXY 127.0.0.1) so a non-loopback proxy can't hijack 127.0.0.1 to the wrong aikey-proxy → false 401 (affects all VKs behind such a proxy). Refs: - group-VK + loopback-hijack + path-strip root-cause chain (§78+): https://github.com/aikeylabs/workflow/blob/6306efcd67b597c44e3c87a0e16da61bd6bd9514/CI/bugfix/2026-06-26-vault-test-group-vk-and-error-code.md - seat-group / OAuth pool requirement spec: https://github.com/aikeylabs/workflow/blob/6306efcd67b597c44e3c87a0e16da61bd6bd9514/CI/requirements/2026-06-23-oauth-account-pool.md --- src/commands_account/mod.rs | 12 +++++++++++- src/connectivity/runtime.rs | 32 +++++++++++++++++++++++++++++--- src/storage_platform.rs | 33 +++++++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index b4ffcf7..9247bb2 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -4424,7 +4424,17 @@ pub fn handle_key_use( if on_cluster && entry.provider_key_ciphertext.is_none() { eprintln!(" Key '{}' → cluster node (key stays central)", entry.alias); } - if entry.provider_key_ciphertext.is_none() && !on_cluster { + // Group VKs (seat_group_id set) carry NO local key material BY DESIGN — the + // per-account credential is pulled by the proxy via channel ③ (group runtime), + // so `use` (set active routing) works without local ciphertext, exactly like a + // cluster central key. Without this exemption a group VK fell into the "not + // delivered" sync below, the sync produced no ciphertext (it never will), and + // `aikey use` errored with "downloaded no key material — admin may need to + // attach a credential" — misleading, the VK routes fine. Mirrors the web + // set-route fix (key_material_reachable) + connectivity-probe / proxy + // group-route fixes (the same "组 VK 无本地物料是设计" systemic root cause; this + // is the CLI public-command path, separate from vault_op's web path). (2026-06-26) + if entry.provider_key_ciphertext.is_none() && !on_cluster && entry.seat_group_id.is_none() { // Why: key material is NULL when the VK was synced but not yet delivered // (share_status=pending_claim). Auto-trigger a full snapshot sync (which // includes key material download) instead of forcing a separate command. diff --git a/src/connectivity/runtime.rs b/src/connectivity/runtime.rs index 2dc6c1e..45cbe10 100644 --- a/src/connectivity/runtime.rs +++ b/src/connectivity/runtime.rs @@ -498,14 +498,31 @@ where // short-circuit returned api_status=999 before the real probe ran. // Removed in same PR. See bugfix/2026-04-29-oauth-probe-tier2-503.md. - let agent = build_proxy_aware_agent(Duration::from_secs(10)); - // Is this probe flowing through our own local aikey-proxy? If so the // X-Aikey-Probe header suppresses usage-event logging on the proxy side // (see proxy/middleware.go::isAikeyProbe). Tagging the header for // upstream-direct probes would be harmless but misleading, so gate it. let via_aikey_proxy = base_url.contains("127.0.0.1") || base_url.contains("localhost"); + // Loopback probes (through our local aikey-proxy on 127.0.0.1) MUST bypass any + // external HTTP proxy. build_proxy_aware_agent honors http(s)_proxy but NOT + // NO_PROXY, so a non-loopback proxy (e.g. a host-level Clash at 192.168.64.1 + // as a VM sees it) makes ureq send the 127.0.0.1:27200 probe THROUGH that + // proxy — which resolves 127.0.0.1 to ITSELF and hijacks the probe to the + // WRONG aikey-proxy instance (the host's), whose registry doesn't have this + // VK → spurious 401 "Route token not found in registry" even though the real + // route works. NO_PROXY=127.0.0.1 is the standard signal; honor it by using a + // direct agent for loopback targets. Only upstream-direct probes (non-loopback + // base_url) keep the proxy-aware agent. + // bugfix: 2026-06-26-connectivity-probe-loopback-proxy-hijack. + let agent = if via_aikey_proxy { + ureq::AgentBuilder::new() + .timeout(Duration::from_secs(10)) + .build() + } else { + build_proxy_aware_agent(Duration::from_secs(10)) + }; + // ── Phase 3: API probe ─────────────────────────────────────────────── // GET — lightweight, no side effects. Treats ANY HTTP response // (incl. 401/403) as "reachable" since the question here is auth @@ -651,7 +668,16 @@ where let (chat_url, body) = build_chat_probe(provider_code, base_url, api_key, kind); let (chat_auth_key, chat_auth_val) = probe_auth(provider_code, api_key); - let chat_agent = build_proxy_aware_agent(Duration::from_secs(15)); + // Same loopback-bypass as the API probe above: a loopback aikey-proxy target + // must NOT traverse an external HTTP proxy (NO_PROXY=127.0.0.1), else a + // non-loopback proxy hijacks 127.0.0.1 to the wrong aikey-proxy → false 401. + let chat_agent = if via_aikey_proxy { + ureq::AgentBuilder::new() + .timeout(Duration::from_secs(15)) + .build() + } else { + build_proxy_aware_agent(Duration::from_secs(15)) + }; let chat_start = Instant::now(); let mut req = chat_agent .post(&chat_url) diff --git a/src/storage_platform.rs b/src/storage_platform.rs index ee26d99..806cb89 100644 --- a/src/storage_platform.rs +++ b/src/storage_platform.rs @@ -608,7 +608,16 @@ impl VirtualKeyCacheEntry { /// diverging again. `claimed` is required so an unclaimed (pending_claim) seat /// is never treated as usable just because the client is on a cluster. pub fn key_material_reachable(&self, on_cluster: bool) -> bool { - self.provider_key_ciphertext.is_some() || (on_cluster && self.share_status == "claimed") + // Group VKs (seat_group_id set) carry NO local key material BY DESIGN — + // the per-account credential is pulled by the proxy via channel ③ (group + // runtime), so routing works without local ciphertext, exactly like a + // cluster central key. Without this, `use` / web set-route 422'd a group + // VK with I_KEY_NOT_DELIVERED ("该密钥尚未下发") even though it routes + // fine. Same empty-local-material root cause as the connectivity-probe + // ciphertext guard (targets.rs) and the proxy group-route path. (2026-06-26) + self.provider_key_ciphertext.is_some() + || (on_cluster && self.share_status == "claimed") + || self.seat_group_id.is_some() } } @@ -906,15 +915,35 @@ pub fn get_virtual_key_cache_by_alias(alias: &str) -> Result`) saw a VK with + // no group membership and fell into the "key not delivered" error, because + // the group exemptions all key off seat_group_id. The by-id path (web + // set-route passes the vk_id) read GROUP and worked, which masked this on the + // alias path. (2026-06-26) let result = conn .query_row( &format!( "SELECT {} FROM managed_virtual_keys_cache {}", - VK_CACHE_COLUMNS_FULL, where_clause + VK_CACHE_COLUMNS_GROUP, where_clause ), params![alias], row_to_virtual_key_cache, ) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Err(e), + _ => conn.query_row( + &format!( + "SELECT {} FROM managed_virtual_keys_cache {}", + VK_CACHE_COLUMNS_FULL, where_clause + ), + params![alias], + row_to_virtual_key_cache, + ), + }) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Err(e), _ => conn.query_row( From 80d0bf9a990b60fb75ea7c84439af557a3deedb5 Mon Sep 17 00:00:00 2001 From: "335923591@qq.com" <335923591@qq.com> Date: Fri, 26 Jun 2026 21:00:47 +0800 Subject: [PATCH 05/42] =?UTF-8?q?refactor:=20rename=20seat=5Fgroup=20?= =?UTF-8?q?=E2=86=92=20oauth=5Fgroup=20across=20all=20repos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] rename vault cache column / fields seat_group_id → oauth_group_id + retrofit migration; sync/probe/use paths updated. Refs: - oauth_group rename plan (旧→新 mapping, P1–P7): https://github.com/aikeylabs/roadmap20260320/blob/b8f2ab45258cc310f3b90eb03b6b931dcf8e66c6/技术实现/阶段7-企业生产版/20260626-oauth_group-重命名重构-分阶段实施计划.md - seat-group / OAuth pool requirement spec: https://github.com/aikeylabs/workflow/blob/7fc6a809a6af982c001188449dead5cbc3df7682/CI/requirements/2026-06-23-oauth-account-pool.md --- src/commands_account/mod.rs | 26 +++++++++--------- src/commands_internal/query.rs | 4 +-- src/connectivity/targets.rs | 8 +++--- src/main.rs | 35 ++++++++++++++++++++++++- src/migrations.rs | 16 ++++++------ src/platform_client.rs | 4 +-- src/storage_platform.rs | 48 +++++++++++++++++----------------- 7 files changed, 87 insertions(+), 54 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 9247bb2..c57d722 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -2733,7 +2733,7 @@ fn apply_snapshot_to_cache( // N6: fold the seat-group candidate set from the snapshot. These ARE // server-owned (in the upsert's DO UPDATE SET). group_accounts is // stored as raw JSON text; None for a direct-bind VK. - seat_group_id: item.seat_group_id.clone(), + oauth_group_id: item.oauth_group_id.clone(), group_accounts: item .group_accounts .as_ref() @@ -2979,7 +2979,7 @@ pub(crate) fn upsert_delivered_key( .map_err(|e| format!("encrypt: {}", e))?; // N6: this is a direct-bind key-delivery path (single credential ciphertext), // NOT the seat-group structural sync — but it shares upsert_virtual_key_cache, - // whose DO UPDATE SET now includes the server-owned seat_group columns. Carry + // whose DO UPDATE SET now includes the server-owned oauth_group columns. Carry // forward the existing row's group fields so accepting a key never wipes the // candidate set a prior snapshot sync folded in. let existing = storage::get_virtual_key_cache(&dk.virtual_key_id) @@ -3012,7 +3012,7 @@ pub(crate) fn upsert_delivered_key( extra: None, // Carry forward server-synced group fields (this path isn't authoritative // over them) so a key-accept doesn't clobber them to NULL. - seat_group_id: existing.as_ref().and_then(|e| e.seat_group_id.clone()), + oauth_group_id: existing.as_ref().and_then(|e| e.oauth_group_id.clone()), group_accounts: existing.as_ref().and_then(|e| e.group_accounts.clone()), routing_config: existing.as_ref().and_then(|e| e.routing_config.clone()), }; @@ -3140,9 +3140,9 @@ fn run_full_snapshot_sync_opts( // The master delivery endpoint denies group VKs (403 access_denied), which // otherwise surfaces as a spurious "could not fetch key" + "0 downloaded" // during sync. Skip claim/delivery entirely for them; their metadata - // (seat_group_id / group_accounts / routing_config) was already folded into + // (oauth_group_id / group_accounts / routing_config) was already folded into // the cache by apply_snapshot_to_cache above. - if entry.seat_group_id.is_some() { + if entry.oauth_group_id.is_some() { continue; } // Needs claim: pending_claim but not yet claimed on server. @@ -3441,7 +3441,7 @@ pub fn sync_managed_key_metadata() -> bool { // data — carry forward existing values so it doesn't clobber what the // full snapshot sync folded in. The full sync (apply_snapshot_to_cache) // is authoritative over these. - seat_group_id: existing.as_ref().and_then(|e| e.seat_group_id.clone()), + oauth_group_id: existing.as_ref().and_then(|e| e.oauth_group_id.clone()), group_accounts: existing.as_ref().and_then(|e| e.group_accounts.clone()), routing_config: existing.as_ref().and_then(|e| e.routing_config.clone()), }; @@ -4424,7 +4424,7 @@ pub fn handle_key_use( if on_cluster && entry.provider_key_ciphertext.is_none() { eprintln!(" Key '{}' → cluster node (key stays central)", entry.alias); } - // Group VKs (seat_group_id set) carry NO local key material BY DESIGN — the + // Group VKs (oauth_group_id set) carry NO local key material BY DESIGN — the // per-account credential is pulled by the proxy via channel ③ (group runtime), // so `use` (set active routing) works without local ciphertext, exactly like a // cluster central key. Without this exemption a group VK fell into the "not @@ -4434,7 +4434,7 @@ pub fn handle_key_use( // set-route fix (key_material_reachable) + connectivity-probe / proxy // group-route fixes (the same "组 VK 无本地物料是设计" systemic root cause; this // is the CLI public-command path, separate from vault_op's web path). (2026-06-26) - if entry.provider_key_ciphertext.is_none() && !on_cluster && entry.seat_group_id.is_none() { + if entry.provider_key_ciphertext.is_none() && !on_cluster && entry.oauth_group_id.is_none() { // Why: key material is NULL when the VK was synced but not yet delivered // (share_status=pending_claim). Auto-trigger a full snapshot sync (which // includes key material download) instead of forcing a separate command. @@ -5438,7 +5438,7 @@ mod core_tests { fn vk_entry( vk: &str, - seat_group_id: Option<&str>, + oauth_group_id: Option<&str>, group_accounts: Option<&str>, routing_config: Option<&str>, ) -> storage::VirtualKeyCacheEntry { @@ -5465,7 +5465,7 @@ mod core_tests { provider_base_urls: std::collections::HashMap::new(), owner_account_id: Some("acct-1".into()), extra: None, - seat_group_id: seat_group_id.map(|s| s.to_string()), + oauth_group_id: oauth_group_id.map(|s| s.to_string()), group_accounts: group_accounts.map(|s| s.to_string()), routing_config: routing_config.map(|s| s.to_string()), } @@ -5481,7 +5481,7 @@ mod core_tests { storage::upsert_virtual_key_cache(&vk_entry("vk-1", Some("grp-1"), Some(ga), Some(rc))).unwrap(); let got = storage::get_virtual_key_cache("vk-1").unwrap().unwrap(); - assert_eq!(got.seat_group_id.as_deref(), Some("grp-1"), "seat_group_id folded"); + assert_eq!(got.oauth_group_id.as_deref(), Some("grp-1"), "oauth_group_id folded"); assert!(got.group_accounts.as_deref().unwrap().contains("acc-A"), "group_accounts folded"); assert_eq!(got.routing_config.as_deref(), Some(rc), "routing_config folded"); @@ -5502,7 +5502,7 @@ mod core_tests { storage::upsert_virtual_key_cache(&vk_entry("vk-1", Some("grp-2"), Some(ga2), Some(rc2))).unwrap(); let got = storage::get_virtual_key_cache("vk-1").unwrap().unwrap(); - assert_eq!(got.seat_group_id.as_deref(), Some("grp-2"), "seat_group_id updated by sync"); + assert_eq!(got.oauth_group_id.as_deref(), Some("grp-2"), "oauth_group_id updated by sync"); assert_eq!(got.routing_config.as_deref(), Some(rc2), "routing_config updated by sync"); assert!(got.group_accounts.as_deref().unwrap().contains("acc-B"), "group_accounts updated"); assert!(!got.group_accounts.as_deref().unwrap().contains("acc-A"), "old candidate replaced"); @@ -5517,7 +5517,7 @@ mod core_tests { // A direct-bind VK syncs with no seat group → all group columns NULL. storage::upsert_virtual_key_cache(&vk_entry("vk-2", None, None, None)).unwrap(); let got = storage::get_virtual_key_cache("vk-2").unwrap().unwrap(); - assert_eq!(got.seat_group_id, None); + assert_eq!(got.oauth_group_id, None); assert_eq!(got.group_accounts, None); assert_eq!(got.routing_config, None); } diff --git a/src/commands_internal/query.rs b/src/commands_internal/query.rs index 6532eec..b4374cd 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -275,13 +275,13 @@ fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec. Same root cause as the proxy-side @@ -280,7 +280,7 @@ pub fn targets_from_alias( // delivered → unprobeable") must NOT apply to group VKs: probe them // through the proxy with the aikey_team_ bearer exactly like a // real group request (the proxy resolves the account + injects its key). - if vk.seat_group_id.is_none() && vk.provider_key_ciphertext.is_none() { + if vk.oauth_group_id.is_none() && vk.provider_key_ciphertext.is_none() { // Non-cluster, direct-bind team VK with no local material (never // delivered) — genuinely unprobeable. Caller surfaces // I_CREDENTIAL_NOT_FOUND. @@ -392,9 +392,9 @@ pub fn targets_from_all_keys(proxy_port: u16) -> (Vec, Vec { + let has_candidates = e + .group_accounts + .as_deref() + .map(|s| { + let t = s.trim(); + !t.is_empty() && t != "[]" && t != "null" + }) + .unwrap_or(false); + if has_candidates { + String::new() + } else { + "no access".to_string() + } + } + "expired" => "expired".to_string(), + _ => "invalid".to_string(), + } + } else if e.provider_key_ciphertext.is_none() { "pending".to_string() // key not yet delivered to local vault } else { match e.local_state.as_str() { diff --git a/src/migrations.rs b/src/migrations.rs index a46a3a0..f874851 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -418,10 +418,10 @@ pub mod v1_0_0_baseline { "extra", "ALTER TABLE managed_virtual_keys_cache ADD COLUMN extra TEXT", ), - // 2026-06-24 (master v1.0.1-alpha.3): seat_group fold. When a VK's - // binding target is a seat_group, the whole group folds into THIS + // 2026-06-24 (master v1.0.1-alpha.3): oauth_group fold. When a VK's + // binding target is a oauth_group, the whole group folds into THIS // row — no separate client cache tables (技术方案 §2.3). - // seat_group_id != NULL marks a group-backed VK + // oauth_group_id != NULL marks a group-backed VK // group_accounts candidate-list metadata JSON (from the // materialized view; structural sync) // routing_config group hash/schedule/util_cap knobs JSON @@ -433,8 +433,8 @@ pub mod v1_0_0_baseline { // window_max_util_pct, window_reset_at}} via // channel ③ (volatile; NEVER refresh_token) ( - "seat_group_id", - "ALTER TABLE managed_virtual_keys_cache ADD COLUMN seat_group_id TEXT", + "oauth_group_id", + "ALTER TABLE managed_virtual_keys_cache ADD COLUMN oauth_group_id TEXT", ), ( "group_accounts", @@ -1508,15 +1508,15 @@ mod tests { upgrade_all(&conn).expect("second upgrade_all must be idempotent"); } - /// N0 (master v1.0.1-alpha.3 seat_group fold): the 5 seat_group columns are + /// N0 (master v1.0.1-alpha.3 oauth_group fold): the 5 oauth_group columns are /// retrofitted onto managed_virtual_keys_cache by upgrade_all — group data /// folds into the VK row, no separate client cache tables (技术方案 §2.3). #[test] - fn seat_group_fold_columns_retrofit_on_managed_virtual_keys_cache() { + fn oauth_group_fold_columns_retrofit_on_managed_virtual_keys_cache() { let conn = Connection::open_in_memory().expect("open"); upgrade_all(&conn).expect("upgrade_all"); for col in [ - "seat_group_id", + "oauth_group_id", "group_accounts", "routing_config", "my_assignment_override", diff --git a/src/platform_client.rs b/src/platform_client.rs index 0fc7336..f7ebc93 100644 --- a/src/platform_client.rs +++ b/src/platform_client.rs @@ -118,9 +118,9 @@ pub struct ManagedKeySnapshotItem { pub expires_at: Option, pub sync_version: i64, /// Seat-group binding target (N6). Present only when the VK's binding targets - /// a seat_group instead of a single credential. `None` for direct-bind VKs. + /// a oauth_group instead of a single credential. `None` for direct-bind VKs. #[serde(default)] - pub seat_group_id: Option, + pub oauth_group_id: Option, /// Seat's ranked candidate set for a group-bound VK (N6): array of /// `{account_id, identity, provider_code, priority, assigned}`. `None`/absent /// for direct-bind VKs. Stored verbatim into the cache as JSON text. diff --git a/src/storage_platform.rs b/src/storage_platform.rs index 806cb89..d7557d4 100644 --- a/src/storage_platform.rs +++ b/src/storage_platform.rs @@ -577,9 +577,9 @@ pub struct VirtualKeyCacheEntry { /// sync-authoritative fields>` exactly to enforce this. pub extra: Option, /// Seat-group binding target (N6, server-owned). Folded from the snapshot's - /// `seat_group_id`. `None` for direct-bind VKs. Unlike `extra`, this IS in + /// `oauth_group_id`. `None` for direct-bind VKs. Unlike `extra`, this IS in /// the upsert's DO UPDATE SET (server is authoritative). - pub seat_group_id: Option, + pub oauth_group_id: Option, /// Seat's ranked candidate set for a group-bound VK (N6, server-owned), /// stored as raw JSON text `[{account_id, identity, provider_code, priority, /// assigned}]`. Folded from the snapshot. `None` for direct-bind VKs. The @@ -608,7 +608,7 @@ impl VirtualKeyCacheEntry { /// diverging again. `claimed` is required so an unclaimed (pending_claim) seat /// is never treated as usable just because the client is on a cluster. pub fn key_material_reachable(&self, on_cluster: bool) -> bool { - // Group VKs (seat_group_id set) carry NO local key material BY DESIGN — + // Group VKs (oauth_group_id set) carry NO local key material BY DESIGN — // the per-account credential is pulled by the proxy via channel ③ (group // runtime), so routing works without local ciphertext, exactly like a // cluster central key. Without this, `use` / web set-route 422'd a group @@ -617,7 +617,7 @@ impl VirtualKeyCacheEntry { // ciphertext guard (targets.rs) and the proxy group-route path. (2026-06-26) self.provider_key_ciphertext.is_some() || (on_cluster && self.share_status == "claimed") - || self.seat_group_id.is_some() + || self.oauth_group_id.is_some() } } @@ -659,7 +659,7 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri cache_schema_version, synced_at, local_alias, supported_providers, provider_base_urls, owner_account_id, - seat_group_id, group_accounts, routing_config + oauth_group_id, group_accounts, routing_config ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, @@ -693,7 +693,7 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri supported_providers = excluded.supported_providers, provider_base_urls = excluded.provider_base_urls, owner_account_id = excluded.owner_account_id, - seat_group_id = excluded.seat_group_id, + oauth_group_id = excluded.oauth_group_id, group_accounts = excluded.group_accounts, routing_config = excluded.routing_config /* extra: DELIBERATELY OMITTED — user-owned column. See doc @@ -723,7 +723,7 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri supported_providers_json, provider_base_urls_json, entry.owner_account_id, - entry.seat_group_id, + entry.oauth_group_id, entry.group_accounts, entry.routing_config, ], @@ -766,27 +766,27 @@ pub fn list_virtual_key_cache_readonly() -> Result, St // // Three tiers, tried newest→oldest so each vault generation reads as many // real columns as it has, projecting literal NULL for columns it predates: -// GROUP — post seat_group (v1.0.1-alpha.3): + seat_group_id + group_accounts -// FULL — post 2026-05-22 (has `extra`, but no seat_group columns) +// GROUP — post oauth_group (v1.0.1-alpha.3): + oauth_group_id + group_accounts +// FULL — post 2026-05-22 (has `extra`, but no oauth_group columns) // LEGACY — pre-2026-05-22 (no extra either) // // CRITICAL: the fixed column ORDER is shared by row_to_virtual_key_cache's -// index-based reads — extra is ALWAYS index 21, seat_group_id 22, +// index-based reads — extra is ALWAYS index 21, oauth_group_id 22, // group_accounts 23. The middle (FULL) tier projects NULL for 22/23 but a -// REAL extra at 21, so a vault that has extra but not yet the seat_group +// REAL extra at 21, so a vault that has extra but not yet the oauth_group // columns still reads its `extra` (no regression — the old 2-tier cascade // would have dropped to LEGACY and lost extra). Why a cascade at all: // `list_virtual_key_cache_readonly` opens read-only (no migrations), so a // vault not yet write-migrated must still be served. // Columns 0..=20 are identical across tiers; only the trailing 21/22/23/24 -// (extra / seat_group_id / group_accounts / routing_config) differ. Fixed +// (extra / oauth_group_id / group_accounts / routing_config) differ. Fixed // indices are shared by row_to_virtual_key_cache's index reads — the middle -// (FULL) tier keeps a REAL extra at 21 and NULLs the seat_group columns, so a -// vault with extra but not yet the seat_group migration still reads its extra +// (FULL) tier keeps a REAL extra at 21 and NULLs the oauth_group columns, so a +// vault with extra but not yet the oauth_group migration still reads its extra // (no LEGACY-fallthrough regression). Kept as full literals (no string-concat // crate) for a single grep-able source per tier. // -// Newest: real extra + real seat_group columns. +// Newest: real extra + real oauth_group columns. const VK_CACHE_COLUMNS_GROUP: &str = "virtual_key_id, org_id, seat_id, alias, \ provider_code, protocol_type, base_url, \ credential_id, credential_revision, virtual_key_revision, \ @@ -794,8 +794,8 @@ const VK_CACHE_COLUMNS_GROUP: &str = "virtual_key_id, org_id, seat_id, alias, \ expires_at, \ provider_key_nonce, provider_key_ciphertext, \ synced_at, local_alias, supported_providers, \ - provider_base_urls, owner_account_id, extra, seat_group_id, group_accounts, routing_config"; -// Middle: real extra, no seat_group columns yet (project NULL at 22/23). + provider_base_urls, owner_account_id, extra, oauth_group_id, group_accounts, routing_config"; +// Middle: real extra, no oauth_group columns yet (project NULL at 22/23). const VK_CACHE_COLUMNS_FULL: &str = "virtual_key_id, org_id, seat_id, alias, \ provider_code, protocol_type, base_url, \ credential_id, credential_revision, virtual_key_revision, \ @@ -804,7 +804,7 @@ const VK_CACHE_COLUMNS_FULL: &str = "virtual_key_id, org_id, seat_id, alias, \ provider_key_nonce, provider_key_ciphertext, \ synced_at, local_alias, supported_providers, \ provider_base_urls, owner_account_id, extra, NULL, NULL, NULL"; -// Oldest: no extra, no seat_group columns. +// Oldest: no extra, no oauth_group columns. const VK_CACHE_COLUMNS_LEGACY: &str = "virtual_key_id, org_id, seat_id, alias, \ provider_code, protocol_type, base_url, \ credential_id, credential_revision, virtual_key_revision, \ @@ -850,8 +850,8 @@ fn row_to_virtual_key_cache(row: &rusqlite::Row) -> rusqlite::Result>(22).unwrap_or(None), + // 22/23/24: oauth_group columns (NULL in the FULL/LEGACY tiers → None). + oauth_group_id: row.get::<_, Option>(22).unwrap_or(None), group_accounts: row.get::<_, Option>(23).unwrap_or(None), routing_config: row.get::<_, Option>(24).unwrap_or(None), }) @@ -915,13 +915,13 @@ pub fn get_virtual_key_cache_by_alias(alias: &str) -> Result`) saw a VK with // no group membership and fell into the "key not delivered" error, because - // the group exemptions all key off seat_group_id. The by-id path (web + // the group exemptions all key off oauth_group_id. The by-id path (web // set-route passes the vk_id) read GROUP and worked, which masked this on the // alias path. (2026-06-26) let result = conn @@ -1828,7 +1828,7 @@ mod key_material_reachable_tests { provider_base_urls: std::collections::HashMap::new(), owner_account_id: None, extra: None, - seat_group_id: None, + oauth_group_id: None, group_accounts: None, routing_config: None, } From eefdf4ee6edb9a8f3acf5d9cfabfeef53f8e6f0d Mon Sep 17 00:00:00 2001 From: "335923591@qq.com" <335923591@qq.com> Date: Fri, 26 Jun 2026 21:25:12 +0800 Subject: [PATCH 06/42] =?UTF-8?q?refactor:=20complete=20seat=5Fgroup=20?= =?UTF-8?q?=E2=86=92=20oauth=5Fgroup=20rename=20(comments=20/=20strings=20?= =?UTF-8?q?/=20test=20names=20/=20trial=20migrate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] comment-only seat-group → oauth-group cleanup (field names already renamed). Refs: - oauth_group rename plan (旧→新 mapping, P1–P7): https://github.com/aikeylabs/roadmap20260320/blob/d5280ecb78d50d2539da97123d8b9a6957e74eb2/技术实现/阶段7-企业生产版/20260626-oauth_group-重命名重构-分阶段实施计划.md --- src/commands_account/mod.rs | 8 ++++---- src/platform_client.rs | 2 +- src/storage_platform.rs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index c57d722..728f840 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -2730,7 +2730,7 @@ fn apply_snapshot_to_cache( // requirement. Putting any other value here would be // misleading, not destructive. extra: None, - // N6: fold the seat-group candidate set from the snapshot. These ARE + // N6: fold the oauth-group candidate set from the snapshot. These ARE // server-owned (in the upsert's DO UPDATE SET). group_accounts is // stored as raw JSON text; None for a direct-bind VK. oauth_group_id: item.oauth_group_id.clone(), @@ -2978,7 +2978,7 @@ pub(crate) fn upsert_delivered_key( let (nonce, ciphertext) = crypto::encrypt(vault_key, plaintext_provider_key.as_bytes()) .map_err(|e| format!("encrypt: {}", e))?; // N6: this is a direct-bind key-delivery path (single credential ciphertext), - // NOT the seat-group structural sync — but it shares upsert_virtual_key_cache, + // NOT the oauth-group structural sync — but it shares upsert_virtual_key_cache, // whose DO UPDATE SET now includes the server-owned oauth_group columns. Carry // forward the existing row's group fields so accepting a key never wipes the // candidate set a prior snapshot sync folded in. @@ -3437,7 +3437,7 @@ pub fn sync_managed_key_metadata() -> bool { // Sync writers MUST always pass extra: None; upsert ignores // this field. See doc on VirtualKeyCacheEntry::extra. extra: None, - // N6: this lightweight metadata sync (KeyItem) does NOT carry seat-group + // N6: this lightweight metadata sync (KeyItem) does NOT carry oauth-group // data — carry forward existing values so it doesn't clobber what the // full snapshot sync folded in. The full sync (apply_snapshot_to_cache) // is authoritative over these. @@ -5434,7 +5434,7 @@ mod core_tests { [0x42u8; 32] } - // ── N6: seat-group fold + extra fence ───────────────────────────────────── + // ── N6: oauth-group fold + extra fence ───────────────────────────────────── fn vk_entry( vk: &str, diff --git a/src/platform_client.rs b/src/platform_client.rs index f7ebc93..0b7bd3d 100644 --- a/src/platform_client.rs +++ b/src/platform_client.rs @@ -117,7 +117,7 @@ pub struct ManagedKeySnapshotItem { #[serde(default)] pub expires_at: Option, pub sync_version: i64, - /// Seat-group binding target (N6). Present only when the VK's binding targets + /// Oauth-group binding target (N6). Present only when the VK's binding targets /// a oauth_group instead of a single credential. `None` for direct-bind VKs. #[serde(default)] pub oauth_group_id: Option, diff --git a/src/storage_platform.rs b/src/storage_platform.rs index d7557d4..b9da4eb 100644 --- a/src/storage_platform.rs +++ b/src/storage_platform.rs @@ -576,7 +576,7 @@ pub struct VirtualKeyCacheEntry { /// `INSERT ... ON CONFLICT(virtual_key_id) DO UPDATE SET ` exactly to enforce this. pub extra: Option, - /// Seat-group binding target (N6, server-owned). Folded from the snapshot's + /// Oauth-group binding target (N6, server-owned). Folded from the snapshot's /// `oauth_group_id`. `None` for direct-bind VKs. Unlike `extra`, this IS in /// the upsert's DO UPDATE SET (server is authoritative). pub oauth_group_id: Option, From 7bdb95d8c54f46fa228f9062215abccc2e019e04 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 28 Jun 2026 08:57:01 -0700 Subject: [PATCH 07/42] fix: update oauth group key cache handling --- src/commands_account/mod.rs | 54 +++++++++++++++++++++++++++++-------- src/storage_platform.rs | 18 ++++++++++--- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 728f840..9df4e5a 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -4434,7 +4434,8 @@ pub fn handle_key_use( // set-route fix (key_material_reachable) + connectivity-probe / proxy // group-route fixes (the same "组 VK 无本地物料是设计" systemic root cause; this // is the CLI public-command path, separate from vault_op's web path). (2026-06-26) - if entry.provider_key_ciphertext.is_none() && !on_cluster && entry.oauth_group_id.is_none() { + if entry.provider_key_ciphertext.is_none() && !on_cluster && entry.oauth_group_id.is_none() + { // Why: key material is NULL when the VK was synced but not yet delivered // (share_status=pending_claim). Auto-trigger a full snapshot sync (which // includes key material download) instead of forcing a separate command. @@ -5478,12 +5479,24 @@ mod core_tests { // First sync: a group-bound VK with a candidate set + routing config. let ga = r#"[{"account_id":"acc-A","identity":"a@t.com","provider_code":"anthropic","priority":1,"assigned":true}]"#; let rc = r#"{"exhaustion_signals":["unified_rate_limited"]}"#; - storage::upsert_virtual_key_cache(&vk_entry("vk-1", Some("grp-1"), Some(ga), Some(rc))).unwrap(); + storage::upsert_virtual_key_cache(&vk_entry("vk-1", Some("grp-1"), Some(ga), Some(rc))) + .unwrap(); let got = storage::get_virtual_key_cache("vk-1").unwrap().unwrap(); - assert_eq!(got.oauth_group_id.as_deref(), Some("grp-1"), "oauth_group_id folded"); - assert!(got.group_accounts.as_deref().unwrap().contains("acc-A"), "group_accounts folded"); - assert_eq!(got.routing_config.as_deref(), Some(rc), "routing_config folded"); + assert_eq!( + got.oauth_group_id.as_deref(), + Some("grp-1"), + "oauth_group_id folded" + ); + assert!( + got.group_accounts.as_deref().unwrap().contains("acc-A"), + "group_accounts folded" + ); + assert_eq!( + got.routing_config.as_deref(), + Some(rc), + "routing_config folded" + ); // User records a connectivity-test result into the user-owned `extra` blob. { @@ -5499,16 +5512,35 @@ mod core_tests { // group fields BUT leave `extra` intact (the 2026-05-22 fence invariant). let ga2 = r#"[{"account_id":"acc-B","identity":"b@t.com","provider_code":"anthropic","priority":1,"assigned":true}]"#; let rc2 = r#"{"reject_ratio":5}"#; - storage::upsert_virtual_key_cache(&vk_entry("vk-1", Some("grp-2"), Some(ga2), Some(rc2))).unwrap(); + storage::upsert_virtual_key_cache(&vk_entry("vk-1", Some("grp-2"), Some(ga2), Some(rc2))) + .unwrap(); let got = storage::get_virtual_key_cache("vk-1").unwrap().unwrap(); - assert_eq!(got.oauth_group_id.as_deref(), Some("grp-2"), "oauth_group_id updated by sync"); - assert_eq!(got.routing_config.as_deref(), Some(rc2), "routing_config updated by sync"); - assert!(got.group_accounts.as_deref().unwrap().contains("acc-B"), "group_accounts updated"); - assert!(!got.group_accounts.as_deref().unwrap().contains("acc-A"), "old candidate replaced"); + assert_eq!( + got.oauth_group_id.as_deref(), + Some("grp-2"), + "oauth_group_id updated by sync" + ); + assert_eq!( + got.routing_config.as_deref(), + Some(rc2), + "routing_config updated by sync" + ); + assert!( + got.group_accounts.as_deref().unwrap().contains("acc-B"), + "group_accounts updated" + ); + assert!( + !got.group_accounts.as_deref().unwrap().contains("acc-A"), + "old candidate replaced" + ); // The critical fence: sync touching group fields must NOT wipe extra. let extra = got.extra.expect("extra survived sync"); - assert_eq!(extra["last_test"]["ok"], serde_json::json!(true), "user-owned extra preserved"); + assert_eq!( + extra["last_test"]["ok"], + serde_json::json!(true), + "user-owned extra preserved" + ); } #[test] diff --git a/src/storage_platform.rs b/src/storage_platform.rs index b9da4eb..e0b0e4d 100644 --- a/src/storage_platform.rs +++ b/src/storage_platform.rs @@ -889,14 +889,26 @@ pub fn get_virtual_key_cache(virtual_key_id: &str) -> Result Err(e), - _ => conn.query_row(&sel(VK_CACHE_COLUMNS_FULL), params![virtual_key_id], row_to_virtual_key_cache), + _ => conn.query_row( + &sel(VK_CACHE_COLUMNS_FULL), + params![virtual_key_id], + row_to_virtual_key_cache, + ), }) .or_else(|e| match e { rusqlite::Error::QueryReturnedNoRows => Err(e), - _ => conn.query_row(&sel(VK_CACHE_COLUMNS_LEGACY), params![virtual_key_id], row_to_virtual_key_cache), + _ => conn.query_row( + &sel(VK_CACHE_COLUMNS_LEGACY), + params![virtual_key_id], + row_to_virtual_key_cache, + ), }); match result { Ok(entry) => Ok(Some(entry)), From 51c0447c1bbb3f36e6d74e95a3914ad38633daa4 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 30 Jun 2026 00:35:54 -0700 Subject: [PATCH 08/42] feat: OAuth account pool per-member token model + seat-group integration [aikey-cli] connectivity persistence/runtime updates Co-Authored-By: Claude Opus 4.8 --- src/connectivity/mod.rs | 10 ++- src/connectivity/persist.rs | 64 ++++++++++++++++-- src/connectivity/runtime.rs | 125 +++++++----------------------------- src/main.rs | 5 +- 4 files changed, 90 insertions(+), 114 deletions(-) diff --git a/src/connectivity/mod.rs b/src/connectivity/mod.rs index 95ac304..39eca30 100644 --- a/src/connectivity/mod.rs +++ b/src/connectivity/mod.rs @@ -422,8 +422,10 @@ pub struct SuiteOutcome { pub proxy: Option, /// Targets we could not construct — printed in the "cannot test" block. pub build_errors: Vec, - /// True if any row's chat probe returned 2xx. Drives `aikey add`'s - /// "Add anyway? [y/N]" prompt. + /// True if any row reached the deepest enabled connectivity gate. + /// Historically this meant Chat 2xx; with Chat skipped by policy it + /// means API success. Kept under the old field name for caller + /// compatibility. pub any_chat_ok: bool, /// JSON payload for `--json` mode; empty in interactive mode. pub json_results: Vec, @@ -868,6 +870,8 @@ mod connectivity_suite_tests { chat_ok: false, chat_ms: 0, chat_status: None, + chat_skipped: false, + chat_skip_reason: None, chat_body_snippet: None, }; // Ping(DIRECT) must not participate in success bookkeeping — @@ -895,6 +899,8 @@ mod connectivity_suite_tests { chat_ok: false, chat_ms: 0, chat_status: None, + chat_skipped: false, + chat_skip_reason: None, chat_body_snippet: None, }; assert!( diff --git a/src/connectivity/persist.rs b/src/connectivity/persist.rs index 3090786..4db58b2 100644 --- a/src/connectivity/persist.rs +++ b/src/connectivity/persist.rs @@ -72,7 +72,8 @@ pub struct PersistedTestResult { /// credential, in input order (groups are visited in first-seen order). /// /// Aggregation rules per credential: -/// - status = "pass" if any row has api_ok = true, else "fail" +/// - status = "pass" if any row reaches the deepest enabled probe: +/// Chat when it ran, otherwise API when Chat is skipped /// - latency_ms = on pass: min api_ms across passing rows in the group /// on fail: max ping_ms in the group (omitted if 0) /// - error_code / error_message: from the first failing row in the @@ -127,13 +128,13 @@ pub fn aggregate_test_outcome(outcome: &SuiteOutcome) -> Vec Vec Vec, + pub chat_skipped: bool, + pub chat_skip_reason: Option, /// First ~512 chars of the API response body when the probe got an HTTP /// status (success or error). Used by api_status_hint to disambiguate /// "upstream rejected the key" from "local proxy didn't recognize the @@ -487,6 +489,8 @@ where on_phase(ProbePhase::Api, ProbeStage::Started); on_phase(ProbePhase::Api, ProbeStage::Finished(&result)); on_phase(ProbePhase::Chat, ProbeStage::Started); + result.chat_skipped = true; + result.chat_skip_reason = Some("skipped because proxy ping failed".to_string()); on_phase(ProbePhase::Chat, ProbeStage::Finished(&result)); return result; } @@ -647,108 +651,16 @@ where // a trivial Chat Started/Finished pair so the column animation can // step out of the API column and land on the line cleanly. on_phase(ProbePhase::Chat, ProbeStage::Started); + result.chat_skipped = true; + result.chat_skip_reason = Some("skipped because API probe failed".to_string()); on_phase(ProbePhase::Chat, ProbeStage::Finished(&result)); return result; } // ── Phase 4: Chat probe ────────────────────────────────────────────── - // Minimal completion request (max_tokens=1). Short-circuit on API - // failure prevents this from hammering an upstream that just rejected - // our auth — some providers count that against rate limits. - // - // Bugfix 20260523: previously this branch used `is_via_proxy` (a - // proxy-variable that's TRUE for ANY credential routed through - // aikey-proxy) as a stand-in for "is OAuth flow". That misjudged - // personal API keys whose base_url legitimately points at the local - // proxy (e.g. routed to a custom anthropic gateway like aicoding) — - // they got the OAuth-only `?beta=true` query and the gateway returned - // 404. Fix: drive protocol addons off `kind == OAuth` via a config - // table (see protocol_addons.rs), kept as the single source of truth - // for future proxy outbound-transform middleware too. - let (chat_url, body) = build_chat_probe(provider_code, base_url, api_key, kind); - let (chat_auth_key, chat_auth_val) = probe_auth(provider_code, api_key); - - // Same loopback-bypass as the API probe above: a loopback aikey-proxy target - // must NOT traverse an external HTTP proxy (NO_PROXY=127.0.0.1), else a - // non-loopback proxy hijacks 127.0.0.1 to the wrong aikey-proxy → false 401. - let chat_agent = if via_aikey_proxy { - ureq::AgentBuilder::new() - .timeout(Duration::from_secs(15)) - .build() - } else { - build_proxy_aware_agent(Duration::from_secs(15)) - }; - let chat_start = Instant::now(); - let mut req = chat_agent - .post(&chat_url) - .set("Content-Type", "application/json"); - // Google uses ?key= in URL; skip header auth. Others use header. - if provider_code != "google" { - req = req.set(chat_auth_key, &chat_auth_val); - } - if provider_code == "anthropic" { - req = req.set("anthropic-version", "2023-06-01"); - } - if via_aikey_proxy { - req = req.set("X-Aikey-Probe", "1"); - } - // Why: KIMI Coding API (api.kimi.com/coding/v1) requires a User-Agent - // matching its coding-agent whitelist (e.g. "claude-code", "kimi-cli"). - // Without it, KIMI returns access_terminated_error (HTTP 403). - // We use "claude-code/1.0 (aikey)" to satisfy the whitelist while - // identifying ourselves. This only affects the connectivity probe. - // - // 2026-05-08 Kimi 双平台拆分: 'kimi_code' 是新 canonical code (api.kimi.com - // 上游),沿用同样的 coding-agent whitelist 要求;'kimi' 是 deprecated alias - // 仍兼容老 vault 数据 / 任何残留的 'kimi' 字面值。Moonshot (api.moonshot.cn) - // 不在 whitelist 范围内,**不**注入此 header,避免污染请求形态。 - if provider_code == "kimi_code" || provider_code == "kimi" { - req = req.set("User-Agent", "claude-code/1.0"); - } on_phase(ProbePhase::Chat, ProbeStage::Started); - let chat_result = req.send_string(&body.to_string()); - let chat_ms = chat_start.elapsed().as_millis(); - - let (chat_ok, chat_status, chat_body_snippet) = match chat_result { - Ok(r) => { - let s = r.status(); - let body = r.into_string().ok().map(truncate_body_snippet); - (s >= 200 && s < 300, Some(s), body) - } - Err(ureq::Error::Status(code, response)) => { - let body = response.into_string().ok().map(truncate_body_snippet); - // 429 = auth passed but rate limited → connectivity OK. - // Claude OAuth returns 429 as business rejection when persona - // headers are incomplete, but also for genuine rate limits. - // Either way, the key is valid and the provider is reachable. - // - // 404 = endpoint reachable, content-level rejection → also OK. - // Reported in the wild 2026-04-25: - // - Anthropic personal: probe model `claude-haiku-4-5-20251001` - // isn't available on every account tier → 404 "Model not - // found", but auth itself succeeded (otherwise we'd see 401). - // - Kimi Coding API at `/coding/v1/chat/completions`: probe - // model `moonshot-v1-8k` isn't a coding-tier model → 404, - // same shape. - // - OpenAI OAuth via Codex proxy: `/v1/chat/completions` - // doesn't exist there (Codex uses Responses API) → 404, - // same shape — actual usage works regardless. - // In all three the request reached the upstream and the upstream - // answered authoritatively; flagging this as red "fail" with - // `chat HTTP 404 — unexpected` (the previous behavior) misled - // users into thinking their key was broken when it wasn't. - // Treating 404 as OK with a "reachable, model unavailable" hint - // keeps the column green and points at the real fix (use a - // different model name) without falsely signalling auth failure. - let ok = code == 429 || code == 404; - (ok, Some(code), body) - } - Err(_) => (false, None, None), - }; - result.chat_ok = chat_ok; - result.chat_ms = chat_ms; - result.chat_status = chat_status; - result.chat_body_snippet = chat_body_snippet; + result.chat_skipped = true; + result.chat_skip_reason = Some("skipped by connectivity policy".to_string()); on_phase(ProbePhase::Chat, ProbeStage::Finished(&result)); result @@ -1420,9 +1332,8 @@ pub fn chat_status_hint(status: u16, body: Option<&str>) -> String { 400 => "bad request".to_string(), 401 => "invalid key".to_string(), 403 => "forbidden".to_string(), - // 404 paired with `chat_ok = true` per the match arm in - // `test_provider_connectivity_with_progress`: route + auth worked, - // upstream rejected the specific (model, endpoint) combination. + // Legacy Chat probe mapping: route + auth worked, upstream rejected + // the specific (model, endpoint) combination. // Most often a probe-time model-name mismatch — the user's key is // fine, real usage with their own model name will succeed. 404 => "reachable, model unavailable".to_string(), @@ -1505,6 +1416,8 @@ pub fn run_connectivity_suite( let r = test_provider_connectivity(&t.provider_code, &t.base_url, &t.bearer, t.kind); if r.chat_ok { any_chat_ok = true; + } else if r.chat_skipped && r.api_ok { + any_chat_ok = true; } if r.ping_ok { any_reachable = true; @@ -1540,6 +1453,8 @@ pub fn run_connectivity_suite( "chat_ok": r.chat_ok, "chat_ms": r.chat_ms, "chat_status": r.chat_status, + "chat_skipped": r.chat_skipped, + "chat_skip_reason": r.chat_skip_reason.as_deref(), "chat_body_snippet": trunc(&r.chat_body_snippet), })); rows.push((t.clone(), r)); @@ -1812,10 +1727,14 @@ pub fn run_connectivity_suite( } } 3 => { - // Chat — em-dash if ping or api failed; otherwise green - // ok with chat hint or red fail with HTTP-status hint. + // Chat — this phase is intentionally skipped by policy + // once Ping/API have supplied the connectivity signal. if !r.ping_ok || !r.api_ok { "\u{2014}".dimmed().to_string() + } else if r.chat_skipped { + format!("{: diff --git a/src/main.rs b/src/main.rs index 6badb9a..98a4c68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1601,7 +1601,7 @@ fn run_command(cli: &Cli) -> Result<(), Box> { let outcome = commands_project::run_connectivity_suite(targets, opts, false); if !outcome.any_chat_ok { eprintln!(); - eprint!(" \u{25c6} No chat test passed. Add anyway? [y/N] (default N): "); + eprint!(" \u{25c6} No API connectivity test passed. Add anyway? [y/N] (default N): "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; @@ -2100,8 +2100,7 @@ fn run_command(cli: &Cli) -> Result<(), Box> { // - else → 1 (couldn't reach upstream via proxy) // // Across multiple rows (e.g. a personal key bound to N providers), - // success on ANY counts as overall success — matches the - // `any_chat_ok` semantics used by `aikey add`. + // success on ANY counts as overall success. fn exit_code_from_outcome(outcome: &commands_project::SuiteOutcome) -> i32 { if outcome.rows.iter().any(|(_, r)| r.api_ok) { return EXIT_OK; From a84f425bd1828035475383367746ae89624fd2cc Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 30 Jun 2026 06:34:12 -0700 Subject: [PATCH 09/42] fix(cli): strip trailing slash from browse base URL (root cause of ak web double-slash white-screen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_browse_base_url now trims a trailing slash from both the AIKEY_WEB_URL override and the control_url fallback. A stored control_url like `http://host:3000/` joined with the leading-slash deep-link path `/go/` produced `http://host:3000//go/overview`; the browser reads the leading `//` as a PROTOCOL-RELATIVE URL (`http://go/...`) and history.replaceState rejects it with a cross-origin SecurityError → SPA white-screen. Normalizing at READ time also fixes installs that already stored a trailing-slash control_url, with no re-login or migration. This is the root-cause fix; aikey-control's collapseLeadingSlashes is the front-end defense-in-depth. Adds regression test. Refs: - ak web double-slash SecurityError bugfix: https://github.com/aikeylabs/workflow/blob/2b72651400abb27b90684b500d736fa231c68d16/CI/bugfix/2026-06-30-ak-web-double-slash-securityerror.md Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands_account/mod.rs | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 9df4e5a..a16cb94 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -2184,9 +2184,13 @@ fn read_install_state() -> Option { /// from `control_url` so cookie domain / /// CORS / CSP stay aligned with the host /// the login flow wrote — see `host_loopback_parts`). -/// 2. Env var `AIKEY_WEB_URL` → use as-is +/// 2. Env var `AIKEY_WEB_URL` → use as-is (trailing slash trimmed) /// 3. Auto-detect: if control_url is loopback, probe dev-server ports -/// 4. Fall back to the stored `control_url` +/// 4. Fall back to the stored `control_url` (trailing slash trimmed) +/// +/// CONTRACT: the returned base NEVER has a trailing slash, so callers can join +/// it with a leading-slash path (`/go/`) without producing a `//` that +/// the browser would treat as a protocol-relative (cross-origin) URL. fn resolve_browse_base_url(control_url: &str, explicit_port: Option) -> String { use std::net::TcpStream; use std::time::Duration; @@ -2209,7 +2213,11 @@ fn resolve_browse_base_url(control_url: &str, explicit_port: Option) -> Str // 2. Env var override if let Ok(url) = std::env::var("AIKEY_WEB_URL") { if !url.is_empty() { - return url; + // Strip any trailing slash: the caller joins this base with a + // leading-slash path (`/go/`), so a trailing slash here would + // produce `//go/...` — a PROTOCOL-RELATIVE URL the browser resolves to + // a foreign origin (`http://go/...`) and rejects in history.replaceState. + return url.trim_end_matches('/').to_string(); } } @@ -2239,8 +2247,15 @@ fn resolve_browse_base_url(control_url: &str, explicit_port: Option) -> Str } } - // 4. Fall back to control_url - control_url.to_string() + // 4. Fall back to control_url. Strip any trailing slash so the deep link + // joins cleanly with the leading-slash `/go/` path — a stored + // control_url like `http://host:3000/` would otherwise yield + // `http://host:3000//go/overview`, which the browser treats as a + // protocol-relative URL (`http://go/overview`) and rejects with a + // SecurityError in the SPA's history.replaceState (cross-origin). + // Normalizing at READ time also fixes installs that already stored a + // trailing-slash control_url, with no re-login/migration. + control_url.trim_end_matches('/').to_string() } /// Extracts `(scheme, host)` from a URL like `http://127.0.0.1:3000/foo` or @@ -6836,6 +6851,17 @@ mod browse_url_tests { let got = resolve_browse_base_url("https://team.example.com", None); assert_eq!(got, "https://team.example.com"); } + + #[test] + fn resolve_browse_base_url_strips_trailing_slash_on_fallback() { + // Regression: a LAN control_url stored WITH a trailing slash must not + // leak it into the base — otherwise `base + /go/` becomes + // `http://host:3000//go/overview`, a protocol-relative URL the SPA's + // history.replaceState rejects with a SecurityError (cross-origin). + let got = resolve_browse_base_url("http://192.168.0.240:3000/", None); + assert_eq!(got, "http://192.168.0.240:3000"); + } + } // ============================================================================ From c589db16b7e5d8f2fbb9c75b48b19132989d972d Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 30 Jun 2026 23:47:22 -0700 Subject: [PATCH 10/42] feat: OAuth account-pool per-member token model + hot-swappable egress proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] _internal query merges group snapshot with live runtime (login_status/current_routed) for vault Refs: - Enterprise OAuth-pool user guide: https://github.com/aikeylabs/workflow/blob/edca0c93c646713c09c6893a76560c2df8a71edb/Docs/enterprise-delivery/OAuth账号池-使用引导.md - Update spec (vault login-status + current-routed): https://github.com/aikeylabs/roadmap20260320/blob/d3bfe4840becdd5bb9a60143577b184613678875/技术实现/update/20260630-保管库-登录态与当前路由账号实时显示.md - Update spec (seat-enroll auto-issue group VK): https://github.com/aikeylabs/roadmap20260320/blob/d3bfe4840becdd5bb9a60143577b184613678875/技术实现/update/20260630-席位组-入组自动签发组VK.md --- src/commands_account/mod.rs | 11 ++ src/commands_internal/query.rs | 261 ++++++++++++++++++++++++++++++++- src/migrations.rs | 7 + src/storage_platform.rs | 50 ++++++- 4 files changed, 315 insertions(+), 14 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index a16cb94..bcc3a86 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -2738,6 +2738,9 @@ fn apply_snapshot_to_cache( supported_providers: item.supported_providers.clone(), provider_base_urls: item.provider_base_urls.clone(), owner_account_id: Some(current_account_id.to_string()), + owner_email: None, // upsert stamps the current account's email + group_runtime: None, // proxy-owned (channel ③) — never written from here + // Sync writers MUST always set extra: None. The value is // ignored by upsert (extra is omitted from the UPSERT's // DO UPDATE SET — see upsert_virtual_key_cache doc); the @@ -3022,6 +3025,9 @@ pub(crate) fn upsert_delivered_key( supported_providers: dk.supported_providers.clone(), provider_base_urls: dk.provider_base_urls.clone(), owner_account_id: dk.owner_account_id.clone(), + owner_email: None, // upsert stamps the current account's email + group_runtime: None, // proxy-owned (channel ③) — never written from here + // Sync writers MUST always pass extra: None; upsert ignores this // field. See doc on VirtualKeyCacheEntry::extra. extra: None, @@ -3449,6 +3455,9 @@ pub fn sync_managed_key_metadata() -> bool { supported_providers, provider_base_urls, owner_account_id: Some(acc.account_id.clone()), + owner_email: Some(acc.email.clone()), // owner email for /user/vault + group_runtime: None, // proxy-owned (channel ③) — never written from here + // Sync writers MUST always pass extra: None; upsert ignores // this field. See doc on VirtualKeyCacheEntry::extra. extra: None, @@ -5480,6 +5489,8 @@ mod core_tests { supported_providers: vec![], provider_base_urls: std::collections::HashMap::new(), owner_account_id: Some("acct-1".into()), + owner_email: Some("acct-1@test".into()), + group_runtime: None, extra: None, oauth_group_id: oauth_group_id.map(|s| s.to_string()), group_accounts: group_accounts.map(|s| s.to_string()), diff --git a/src/commands_internal/query.rs b/src/commands_internal/query.rs index b4374cd..39b6f91 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -211,6 +211,96 @@ pub(crate) fn team_effective_status( } } +/// Merge the proxy's LIVE per-account status (the `group_runtime` column's PLAINTEXT +/// `needs_login` + `is_current_routed` flags — the encrypted secret is ignored) onto +/// the key-sync candidate snapshot (`group_accounts`), so /user/vault reflects login +/// + routing within the proxy's 60s poll WITHOUT a manual `aikey key sync` (C1+C2, +/// 2026-06-30). Precedence (owner-approved): the always-on proxy rail is authoritative +/// for the logged-in-vs-not axis and for `current_routed`; the key-sync snapshot is the +/// FALLBACK for an account the proxy hasn't polled yet, and it keeps the finer +/// `auth_failed`/`revoked` reason when the account is unusable. Returns None when there +/// are no parseable candidates (direct-bind VK → the web renders no group panel). +fn merge_group_accounts_live( + group_accounts: Option<&str>, + group_runtime: Option<&str>, +) -> Option { + let mut cands: Vec = serde_json::from_str(group_accounts?).ok()?; + // Proxy's live material map: account_id -> {needs_login, is_current_routed, ...}. + // Absent/unparseable → empty → every account falls back to its snapshot value. + let runtime: serde_json::Map = group_runtime + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + for c in cands.iter_mut() { + let Some(obj) = c.as_object_mut() else { continue }; + let account_id = obj + .get("account_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let live = runtime.get(&account_id); + // current_routed: proxy-only signal (the snapshot has no equivalent) → false + // when the proxy hasn't polled this account/VK yet. + let is_current_routed = live + .and_then(|m| m.get("is_current_routed")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + obj.insert("current_routed".into(), serde_json::Value::Bool(is_current_routed)); + // login_status: proxy rail is authoritative for logged-in-vs-not; only override + // when the proxy HAS material for this account (else keep the snapshot value). + if let Some(m) = live { + let needs_login = m + .get("needs_login") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let snapshot = obj + .get("login_status") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let merged = if !needs_login { + "logged_in" // fresh: member has a usable token now + } else if snapshot == "auth_failed" || snapshot == "revoked" { + snapshot.as_str() // unusable — keep the finer reason from the snapshot + } else { + "needs_login" + }; + obj.insert( + "login_status".into(), + serde_json::Value::String(merged.to_string()), + ); + } + } + Some(serde_json::Value::Array(cands)) +} + +/// Whether the group VK's CURRENT ROUTED account — the one the remote scheduling +/// engine selected — is logged in. Drives the group-VK usability overlay on +/// effective_status (owner rule 2026-06-30): a claimed+active group VK is only usable +/// when the account it actually routes to has a token; otherwise a request goes to +/// that account and fails (GROUP_NO_CANDIDATES for an empty set, LOGIN_REQUIRED when +/// the routed account is needs_login) — so it must read inactive (hides the web `use` +/// button + matches aikey use/list). +/// +/// Routed account = the candidate the proxy flagged `current_routed` (engine override +/// ?? rank-0). Before the proxy has stamped it (brief post-sync window), fall back to +/// the static default (`assigned` = master rank-0) — which IS the routed account when +/// there is no engine override. Empty set / no routed account resolvable → false +/// (unusable). Being logged into a DIFFERENT, non-routed account does NOT count: the +/// engine decides where traffic goes, and that account is what needs a token. +fn routed_candidate_logged_in(merged: Option<&serde_json::Value>) -> bool { + let Some(arr) = merged.and_then(|v| v.as_array()) else { + return false; + }; + let flagged = |c: &&serde_json::Value, key: &str| { + c.get(key).and_then(|b| b.as_bool()) == Some(true) + }; + let routed = arr + .iter() + .find(|c| flagged(c, "current_routed")) + .or_else(|| arr.iter().find(|c| flagged(c, "assigned"))); + routed.and_then(|c| c.get("login_status").and_then(|s| s.as_str())) == Some("logged_in") +} + fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec { let entries = match storage::list_virtual_key_cache_readonly() { Ok(v) => v, @@ -221,8 +311,26 @@ fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec Vec(s).ok()), + "group_accounts": merged_accounts, + // owner_email: the owning account's email (stamped by sync). Lets + // /user/vault show "Owner: " in the drawer — persists for a + // group VK left behind after that account logs out. + "owner_email": t.owner_email, }) }) .collect() @@ -1123,6 +1234,140 @@ fn handle_list_metadata_locked(env: StdinEnvelope) { )); } +#[cfg(test)] +mod merge_group_accounts_live_tests { + use super::*; + + // Two candidates: a1 (assigned, snapshot says needs_login) and a2. + const SNAPSHOT: &str = r#"[ + {"account_id":"a1","identity":"a1@x","assigned":true,"login_status":"needs_login"}, + {"account_id":"a2","identity":"a2@x","assigned":false,"login_status":"needs_login"} + ]"#; + + fn login_status(v: &serde_json::Value, account_id: &str) -> String { + v.as_array() + .unwrap() + .iter() + .find(|c| c["account_id"] == account_id) + .unwrap()["login_status"] + .as_str() + .unwrap() + .to_string() + } + fn current_routed(v: &serde_json::Value, account_id: &str) -> bool { + v.as_array() + .unwrap() + .iter() + .find(|c| c["account_id"] == account_id) + .unwrap()["current_routed"] + .as_bool() + .unwrap() + } + + // C1 core: proxy's fresh needs_login=false OVERRIDES the stale snapshot + // "needs_login" → "logged_in". This is the exact reported bug (logged in but + // vault shows 待登录). Break the override (keep snapshot) and this fails. + #[test] + fn proxy_fresh_login_overrides_stale_snapshot() { + let runtime = r#"{"a1":{"needs_login":false,"is_current_routed":true},"a2":{"needs_login":true}}"#; + let merged = merge_group_accounts_live(Some(SNAPSHOT), Some(runtime)).unwrap(); + assert_eq!(login_status(&merged, "a1"), "logged_in", "fresh token → logged_in"); + assert_eq!(login_status(&merged, "a2"), "needs_login", "still no token"); + } + + // C2 core: is_current_routed from the proxy is surfaced as current_routed. + #[test] + fn current_routed_flag_surfaced() { + let runtime = r#"{"a1":{"needs_login":false,"is_current_routed":true},"a2":{"needs_login":false}}"#; + let merged = merge_group_accounts_live(Some(SNAPSHOT), Some(runtime)).unwrap(); + assert!(current_routed(&merged, "a1"), "a1 is the routed account"); + assert!(!current_routed(&merged, "a2"), "a2 is not routed"); + } + + // Fallback: proxy hasn't polled (no group_runtime) → snapshot login_status kept, + // current_routed defaults false (unknown, not wrongly true). + #[test] + fn no_runtime_falls_back_to_snapshot() { + let merged = merge_group_accounts_live(Some(SNAPSHOT), None).unwrap(); + assert_eq!(login_status(&merged, "a1"), "needs_login", "snapshot fallback"); + assert!(!current_routed(&merged, "a1"), "unknown routed → false, not true"); + } + + // Finer failure reason preserved: proxy says needs_login=true but snapshot has the + // specific "revoked" → keep "revoked" (unusable either way, but more informative). + #[test] + fn unusable_keeps_finer_snapshot_reason() { + let snap = r#"[{"account_id":"a1","assigned":true,"login_status":"revoked"}]"#; + let runtime = r#"{"a1":{"needs_login":true}}"#; + let merged = merge_group_accounts_live(Some(snap), Some(runtime)).unwrap(); + assert_eq!(login_status(&merged, "a1"), "revoked"); + } + + // Direct-bind VK (no candidates) → None (web renders no group panel). + #[test] + fn direct_bind_returns_none() { + assert!(merge_group_accounts_live(None, None).is_none()); + assert!(merge_group_accounts_live(Some("not json"), None).is_none()); + } +} + +#[cfg(test)] +mod routed_candidate_logged_in_tests { + use super::*; + use serde_json::json; + + // Owner rule (2026-06-30): usable iff the CURRENT ROUTED account (engine pick) is + // logged in. current_routed=a1 logged_in → usable. + #[test] + fn routed_account_logged_in_is_usable() { + let m = json!([ + {"account_id":"a1","assigned":true,"current_routed":true,"login_status":"logged_in"}, + {"account_id":"a2","assigned":false,"current_routed":false,"login_status":"needs_login"} + ]); + assert!(routed_candidate_logged_in(Some(&m))); + } + + // STRICT: logged into a NON-routed account does NOT count. routed (a1) needs_login, + // a2 logged_in but not routed → NOT usable. 能红: a looser "any logged in" gate + // would wrongly pass this. + #[test] + fn logged_into_non_routed_account_is_not_usable() { + let m = json!([ + {"account_id":"a1","assigned":true,"current_routed":true,"login_status":"needs_login"}, + {"account_id":"a2","assigned":false,"current_routed":false,"login_status":"logged_in"} + ]); + assert!(!routed_candidate_logged_in(Some(&m))); + } + + // Fallback: proxy hasn't stamped current_routed yet → use the static default + // (assigned = rank-0). assigned a1 logged_in → usable. + #[test] + fn falls_back_to_assigned_when_no_current_routed() { + let m = json!([ + {"account_id":"a1","assigned":true,"login_status":"logged_in"}, + {"account_id":"a2","assigned":false,"login_status":"needs_login"} + ]); + assert!(routed_candidate_logged_in(Some(&m))); + } + + // 无可用账号: empty candidate set (seat unbound) → not usable. + #[test] + fn empty_candidate_set_is_not_usable() { + assert!(!routed_candidate_logged_in(Some(&json!([])))); + assert!(!routed_candidate_logged_in(None)); + } + + // All needs_login (member signed into nothing) → routed account needs_login → not usable. + #[test] + fn all_needs_login_is_not_usable() { + let m = json!([ + {"account_id":"a1","assigned":true,"login_status":"needs_login"}, + {"account_id":"a2","assigned":false,"login_status":"needs_login"} + ]); + assert!(!routed_candidate_logged_in(Some(&m))); + } +} + #[cfg(test)] mod active_binding_refs_tests { use super::*; diff --git a/src/migrations.rs b/src/migrations.rs index f874851..86b0a86 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -452,6 +452,13 @@ pub mod v1_0_0_baseline { "group_runtime", "ALTER TABLE managed_virtual_keys_cache ADD COLUMN group_runtime TEXT", ), + ( + // owner_email: the owner account's email, stamped by key sync + // (parallel to owner_account_id) so /user/vault can show + // "Owner: " — persists after that account logs out. + "owner_email", + "ALTER TABLE managed_virtual_keys_cache ADD COLUMN owner_email TEXT", + ), ] { ensure_column(conn, "managed_virtual_keys_cache", col, ddl)?; } diff --git a/src/storage_platform.rs b/src/storage_platform.rs index e0b0e4d..d7c2d02 100644 --- a/src/storage_platform.rs +++ b/src/storage_platform.rs @@ -563,6 +563,12 @@ pub struct VirtualKeyCacheEntry { /// The `account_id` that last synced/accepted this key. `None` for pre-v0.8 rows. /// Used to scope-disable keys when the user switches to a different account. pub owner_account_id: Option, + /// The owner account's EMAIL, stamped by the sync (parallel to + /// `owner_account_id`). Lets /user/vault show "Owner: " — especially + /// for a group VK left behind after that account logs out (the leftover row + /// keeps its email because another account's sync doesn't touch it). `None` + /// for rows synced before this column / pre-login rows. + pub owner_email: Option, /// Generic per-key extension JSON blob — same shape contract as /// `storage::SecretMetadata::extra`. Stores connectivity-test results /// at `$.last_test` (written by `_internal vault-op record_test_result` @@ -591,6 +597,16 @@ pub struct VirtualKeyCacheEntry { /// classify exhaustion (exhaustion_signals) + apply switch/cooldown knobs. /// In the upsert's DO UPDATE SET (server authoritative). pub routing_config: Option, + /// READ-ONLY here (C1/C2 display, 2026-06-30): the channel-③ per-account + /// material JSON `{account_id:{..., needs_login, is_current_routed}}`, written + /// SOLELY by the proxy's 60s group-runtime poll (this CLI never writes it — it + /// is NOT in the upsert). The query layer reads the two PLAINTEXT flags + /// (`needs_login`, `is_current_routed`; the secret is encrypted and untouched) + /// to project a LIVE `login_status` + `current_routed` onto each `group_accounts` + /// candidate — fresher than the key-sync snapshot, so /user/vault reflects + /// login/routing without a manual `aikey key sync`. `None` for direct-bind VKs + /// or when the proxy hasn't polled yet (→ fall back to the snapshot value). + pub group_runtime: Option, } impl VirtualKeyCacheEntry { @@ -644,6 +660,16 @@ impl VirtualKeyCacheEntry { /// the key is accepted. pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), String> { let conn = open_connection()?; + // owner_email is stamped as the CURRENT logged-in account's email — every sync + // writes the current account's own keys (all-keys is account-scoped), so the + // owner is always this account. Explicit entry.owner_email wins (e.g. a caller + // that already knows it); otherwise resolve from platform_account. Persisting + // it (parallel to owner_account_id) lets /user/vault show "Owner: " even + // after the account logs out — another account's sync won't touch this row. + let owner_email = entry + .owner_email + .clone() + .or_else(|| get_platform_account().ok().flatten().map(|a| a.email)); let supported_providers_json = serde_json::to_string(&entry.supported_providers).unwrap_or_else(|_| "[]".to_string()); let provider_base_urls_json = @@ -659,7 +685,7 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri cache_schema_version, synced_at, local_alias, supported_providers, provider_base_urls, owner_account_id, - oauth_group_id, group_accounts, routing_config + oauth_group_id, group_accounts, routing_config, owner_email ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, @@ -670,7 +696,7 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri 1, strftime('%s', 'now'), ?17, ?18, ?19, ?20, - ?21, ?22, ?23 + ?21, ?22, ?23, ?24 ) ON CONFLICT(virtual_key_id) DO UPDATE SET org_id = excluded.org_id, @@ -695,7 +721,12 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri owner_account_id = excluded.owner_account_id, oauth_group_id = excluded.oauth_group_id, group_accounts = excluded.group_accounts, - routing_config = excluded.routing_config + routing_config = excluded.routing_config, + owner_email = excluded.owner_email + /* owner_email: synced column (parallel to owner_account_id) — the + key sync stamps the CURRENT account's email so a group VK still + shows its owner in /user/vault after that account logs out (the + leftover row isn't overwritten by another account's sync). */ /* extra: DELIBERATELY OMITTED — user-owned column. See doc comment above and on VirtualKeyCacheEntry::extra. */ /* my_assignment_override / group_runtime / routing_config: OMITTED — @@ -726,6 +757,7 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri entry.oauth_group_id, entry.group_accounts, entry.routing_config, + owner_email, ], ) .map_err(|e| format!("Failed to upsert virtual key cache: {}", e))?; @@ -794,7 +826,7 @@ const VK_CACHE_COLUMNS_GROUP: &str = "virtual_key_id, org_id, seat_id, alias, \ expires_at, \ provider_key_nonce, provider_key_ciphertext, \ synced_at, local_alias, supported_providers, \ - provider_base_urls, owner_account_id, extra, oauth_group_id, group_accounts, routing_config"; + provider_base_urls, owner_account_id, extra, oauth_group_id, group_accounts, routing_config, owner_email, group_runtime"; // Middle: real extra, no oauth_group columns yet (project NULL at 22/23). const VK_CACHE_COLUMNS_FULL: &str = "virtual_key_id, org_id, seat_id, alias, \ provider_code, protocol_type, base_url, \ @@ -803,7 +835,7 @@ const VK_CACHE_COLUMNS_FULL: &str = "virtual_key_id, org_id, seat_id, alias, \ expires_at, \ provider_key_nonce, provider_key_ciphertext, \ synced_at, local_alias, supported_providers, \ - provider_base_urls, owner_account_id, extra, NULL, NULL, NULL"; + provider_base_urls, owner_account_id, extra, NULL, NULL, NULL, NULL, NULL"; // Oldest: no extra, no oauth_group columns. const VK_CACHE_COLUMNS_LEGACY: &str = "virtual_key_id, org_id, seat_id, alias, \ provider_code, protocol_type, base_url, \ @@ -812,7 +844,7 @@ const VK_CACHE_COLUMNS_LEGACY: &str = "virtual_key_id, org_id, seat_id, alias, \ expires_at, \ provider_key_nonce, provider_key_ciphertext, \ synced_at, local_alias, supported_providers, \ - provider_base_urls, owner_account_id, NULL, NULL, NULL, NULL"; + provider_base_urls, owner_account_id, NULL, NULL, NULL, NULL, NULL, NULL"; /// Single mapping from a SELECT row (in the column order declared by /// `VK_CACHE_COLUMNS_*` above) to a struct. Centralised here so adding @@ -854,6 +886,10 @@ fn row_to_virtual_key_cache(row: &rusqlite::Row) -> rusqlite::Result>(22).unwrap_or(None), group_accounts: row.get::<_, Option>(23).unwrap_or(None), routing_config: row.get::<_, Option>(24).unwrap_or(None), + // 25: owner_email (NULL in FULL/LEGACY tiers → None). + owner_email: row.get::<_, Option>(25).unwrap_or(None), + // 26: group_runtime (proxy-owned, read-only here — NULL in FULL/LEGACY → None). + group_runtime: row.get::<_, Option>(26).unwrap_or(None), }) } @@ -1839,6 +1875,8 @@ mod key_material_reachable_tests { supported_providers: vec![], provider_base_urls: std::collections::HashMap::new(), owner_account_id: None, + owner_email: None, + group_runtime: None, extra: None, oauth_group_id: None, group_accounts: None, From c603ccb407f7e2eba724dfa52d009249b0849d09 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 1 Jul 2026 01:58:05 -0700 Subject: [PATCH 11/42] fix: group-route degrade returns fail-fast status + buildBaseEvent nil-resp guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] simplify any_chat_ok branch in connectivity runtime (behavior-equivalent) Refs: - Bugfix: proxy buildBaseEvent nil-resp panic: https://github.com/aikeylabs/workflow/blob/38364749b315fae856c6e7e896799c2aa3e95130/CI/bugfix/20260701-proxy-buildBaseEvent-nil-resp-panic.md - Bugfix: team-oauth still-syncing misleading copy: https://github.com/aikeylabs/workflow/blob/38364749b315fae856c6e7e896799c2aa3e95130/CI/bugfix/20260630-团队oauth-still-syncing-误导文案.md --- src/connectivity/runtime.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/connectivity/runtime.rs b/src/connectivity/runtime.rs index 4c41d94..58618fc 100644 --- a/src/connectivity/runtime.rs +++ b/src/connectivity/runtime.rs @@ -1414,9 +1414,7 @@ pub fn run_connectivity_suite( if json_mode { for t in &targets { let r = test_provider_connectivity(&t.provider_code, &t.base_url, &t.bearer, t.kind); - if r.chat_ok { - any_chat_ok = true; - } else if r.chat_skipped && r.api_ok { + if r.chat_ok || (r.chat_skipped && r.api_ok) { any_chat_ok = true; } if r.ping_ok { @@ -1803,9 +1801,7 @@ pub fn run_connectivity_suite( if r.ping_ok { any_reachable = true; } - if r.chat_ok { - any_chat_ok = true; - } else if r.chat_skipped && r.api_ok { + if r.chat_ok || (r.chat_skipped && r.api_ok) { any_chat_ok = true; } From 1a1b02e624c073b5127d43c05c6c72c09b2844db Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 1 Jul 2026 09:52:29 -0700 Subject: [PATCH 12/42] feat: employee OAuth self-contribute (email+password) + multi-group VK disambiguation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] client cache carries group_alias so aikey use / vault can label which group each VK belongs to Refs: - Spec: RW8 reversal (self-add email+password): https://github.com/aikeylabs/roadmap20260320/blob/342a092b7865b07e16f406d75c763f0b830d1c37/技术实现/update/20260701-员工OAuth自助录入-反转RW8-email密码版.md - Spec: multi-OAuth-group one-VK-each + group-name passthrough: https://github.com/aikeylabs/roadmap20260320/blob/342a092b7865b07e16f406d75c763f0b830d1c37/技术实现/update/20260701-多OAuth组-每组一VK可选与组名透传.md - Bugfix: proxy detachedTransport drops window-cap pre-cut: https://github.com/aikeylabs/workflow/blob/edbb75c0b09b6e8a8f92627068d6a9c60750716c/CI/bugfix/20260701-proxy-detachedTransport-drops-window-cap-precut-dead.md --- src/commands_account/mod.rs | 4 ++++ src/commands_internal/query.rs | 4 ++++ src/migrations.rs | 8 ++++++++ src/platform_client.rs | 6 ++++++ src/storage_platform.rs | 26 ++++++++++++++++++++------ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index bcc3a86..91d576e 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -2757,6 +2757,7 @@ fn apply_snapshot_to_cache( .as_ref() .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string())), routing_config: item.routing_config.clone(), + group_alias: item.group_alias.clone(), }; let _ = storage::upsert_virtual_key_cache(&entry); @@ -3036,6 +3037,7 @@ pub(crate) fn upsert_delivered_key( oauth_group_id: existing.as_ref().and_then(|e| e.oauth_group_id.clone()), group_accounts: existing.as_ref().and_then(|e| e.group_accounts.clone()), routing_config: existing.as_ref().and_then(|e| e.routing_config.clone()), + group_alias: existing.as_ref().and_then(|e| e.group_alias.clone()), }; storage::upsert_virtual_key_cache(&entry) } @@ -3468,6 +3470,7 @@ pub fn sync_managed_key_metadata() -> bool { oauth_group_id: existing.as_ref().and_then(|e| e.oauth_group_id.clone()), group_accounts: existing.as_ref().and_then(|e| e.group_accounts.clone()), routing_config: existing.as_ref().and_then(|e| e.routing_config.clone()), + group_alias: existing.as_ref().and_then(|e| e.group_alias.clone()), }; let _ = storage::upsert_virtual_key_cache(&entry); @@ -5491,6 +5494,7 @@ mod core_tests { owner_account_id: Some("acct-1".into()), owner_email: Some("acct-1@test".into()), group_runtime: None, + group_alias: None, extra: None, oauth_group_id: oauth_group_id.map(|s| s.to_string()), group_accounts: group_accounts.map(|s| s.to_string()), diff --git a/src/commands_internal/query.rs b/src/commands_internal/query.rs index 39b6f91..55e4dd2 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -392,6 +392,10 @@ fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec" in the drawer — persists for a diff --git a/src/migrations.rs b/src/migrations.rs index 86b0a86..e741ae3 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -459,6 +459,14 @@ pub mod v1_0_0_baseline { "owner_email", "ALTER TABLE managed_virtual_keys_cache ADD COLUMN owner_email TEXT", ), + ( + // group_alias: the OAuth group's name (server-synced from the + // managed-keys-snapshot, parallel to oauth_group_id/routing_config) so + // /user/vault + `aikey use` can label WHICH group a VK belongs to — a + // member in multiple groups gets one VK per group (2026-07-01). + "group_alias", + "ALTER TABLE managed_virtual_keys_cache ADD COLUMN group_alias TEXT", + ), ] { ensure_column(conn, "managed_virtual_keys_cache", col, ddl)?; } diff --git a/src/platform_client.rs b/src/platform_client.rs index 0b7bd3d..f2f085d 100644 --- a/src/platform_client.rs +++ b/src/platform_client.rs @@ -131,6 +131,12 @@ pub struct ManagedKeySnapshotItem { /// direct-bind VKs. Stored verbatim into the cache. #[serde(default)] pub routing_config: Option, + /// The OAuth group's human-facing name (oauth_group.alias), so /user/vault + + /// `aikey use` can label WHICH group a VK belongs to — a member in multiple + /// groups gets one VK per group and picks by name (2026-07-01). `None`/empty for + /// direct-bind VKs or an unnamed group. + #[serde(default)] + pub group_alias: Option, } /// Returned by GET /accounts/me/managed-keys-snapshot. diff --git a/src/storage_platform.rs b/src/storage_platform.rs index d7c2d02..3939a26 100644 --- a/src/storage_platform.rs +++ b/src/storage_platform.rs @@ -597,6 +597,12 @@ pub struct VirtualKeyCacheEntry { /// classify exhaustion (exhaustion_signals) + apply switch/cooldown knobs. /// In the upsert's DO UPDATE SET (server authoritative). pub routing_config: Option, + /// The OAuth group's human-facing name (server-synced from the snapshot, + /// parallel to oauth_group_id/routing_config — in the upsert's DO UPDATE SET). + /// `None`/empty for direct-bind VKs or an unnamed group. Lets /user/vault + + /// `aikey use` label WHICH group a VK belongs to, so a member in multiple groups + /// can pick by name (2026-07-01, multi-group disambiguation). + pub group_alias: Option, /// READ-ONLY here (C1/C2 display, 2026-06-30): the channel-③ per-account /// material JSON `{account_id:{..., needs_login, is_current_routed}}`, written /// SOLELY by the proxy's 60s group-runtime poll (this CLI never writes it — it @@ -685,7 +691,7 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri cache_schema_version, synced_at, local_alias, supported_providers, provider_base_urls, owner_account_id, - oauth_group_id, group_accounts, routing_config, owner_email + oauth_group_id, group_accounts, routing_config, owner_email, group_alias ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, @@ -696,7 +702,7 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri 1, strftime('%s', 'now'), ?17, ?18, ?19, ?20, - ?21, ?22, ?23, ?24 + ?21, ?22, ?23, ?24, ?25 ) ON CONFLICT(virtual_key_id) DO UPDATE SET org_id = excluded.org_id, @@ -722,7 +728,11 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri oauth_group_id = excluded.oauth_group_id, group_accounts = excluded.group_accounts, routing_config = excluded.routing_config, - owner_email = excluded.owner_email + owner_email = excluded.owner_email, + group_alias = excluded.group_alias + /* group_alias: server-synced (parallel to oauth_group_id/routing_config) + — the OAuth group name so /user/vault + aikey use can label which + group a VK routes into (multi-group disambiguation). */ /* owner_email: synced column (parallel to owner_account_id) — the key sync stamps the CURRENT account's email so a group VK still shows its owner in /user/vault after that account logs out (the @@ -758,6 +768,7 @@ pub fn upsert_virtual_key_cache(entry: &VirtualKeyCacheEntry) -> Result<(), Stri entry.group_accounts, entry.routing_config, owner_email, + entry.group_alias, ], ) .map_err(|e| format!("Failed to upsert virtual key cache: {}", e))?; @@ -826,7 +837,7 @@ const VK_CACHE_COLUMNS_GROUP: &str = "virtual_key_id, org_id, seat_id, alias, \ expires_at, \ provider_key_nonce, provider_key_ciphertext, \ synced_at, local_alias, supported_providers, \ - provider_base_urls, owner_account_id, extra, oauth_group_id, group_accounts, routing_config, owner_email, group_runtime"; + provider_base_urls, owner_account_id, extra, oauth_group_id, group_accounts, routing_config, owner_email, group_runtime, group_alias"; // Middle: real extra, no oauth_group columns yet (project NULL at 22/23). const VK_CACHE_COLUMNS_FULL: &str = "virtual_key_id, org_id, seat_id, alias, \ provider_code, protocol_type, base_url, \ @@ -835,7 +846,7 @@ const VK_CACHE_COLUMNS_FULL: &str = "virtual_key_id, org_id, seat_id, alias, \ expires_at, \ provider_key_nonce, provider_key_ciphertext, \ synced_at, local_alias, supported_providers, \ - provider_base_urls, owner_account_id, extra, NULL, NULL, NULL, NULL, NULL"; + provider_base_urls, owner_account_id, extra, NULL, NULL, NULL, NULL, NULL, NULL"; // Oldest: no extra, no oauth_group columns. const VK_CACHE_COLUMNS_LEGACY: &str = "virtual_key_id, org_id, seat_id, alias, \ provider_code, protocol_type, base_url, \ @@ -844,7 +855,7 @@ const VK_CACHE_COLUMNS_LEGACY: &str = "virtual_key_id, org_id, seat_id, alias, \ expires_at, \ provider_key_nonce, provider_key_ciphertext, \ synced_at, local_alias, supported_providers, \ - provider_base_urls, owner_account_id, NULL, NULL, NULL, NULL, NULL, NULL"; + provider_base_urls, owner_account_id, NULL, NULL, NULL, NULL, NULL, NULL, NULL"; /// Single mapping from a SELECT row (in the column order declared by /// `VK_CACHE_COLUMNS_*` above) to a struct. Centralised here so adding @@ -890,6 +901,8 @@ fn row_to_virtual_key_cache(row: &rusqlite::Row) -> rusqlite::Result>(25).unwrap_or(None), // 26: group_runtime (proxy-owned, read-only here — NULL in FULL/LEGACY → None). group_runtime: row.get::<_, Option>(26).unwrap_or(None), + // 27: group_alias (server-synced OAuth group name — NULL in FULL/LEGACY → None). + group_alias: row.get::<_, Option>(27).unwrap_or(None), }) } @@ -1877,6 +1890,7 @@ mod key_material_reachable_tests { owner_account_id: None, owner_email: None, group_runtime: None, + group_alias: None, extra: None, oauth_group_id: None, group_accounts: None, From 0cf352c8cd92ce9536afdbe79dfd45277c630bab Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 1 Jul 2026 21:50:09 -0700 Subject: [PATCH 13/42] =?UTF-8?q?fix:=20unify=20seat=E2=86=92pool-account?= =?UTF-8?q?=20routed=20pick=20behind=20poolroute=20single=20source=20of=20?= =?UTF-8?q?truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] rework merge_group_accounts_live so login_status/current_routed reflect the unified routed pick Refs: - Bugfix: route-drift (part 2, split-truth → poolroute): https://github.com/aikeylabs/workflow/blob/c957d8ae791377abba7b9300ab55cf1a61cdf5e3/CI/bugfix/20260701-分配引擎默认开启-修复加号后路由漂移.md --- src/commands_internal/query.rs | 194 ++++++++++++++++++++++++--------- 1 file changed, 142 insertions(+), 52 deletions(-) diff --git a/src/commands_internal/query.rs b/src/commands_internal/query.rs index 55e4dd2..6c6ec64 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -211,66 +211,104 @@ pub(crate) fn team_effective_status( } } -/// Merge the proxy's LIVE per-account status (the `group_runtime` column's PLAINTEXT -/// `needs_login` + `is_current_routed` flags — the encrypted secret is ignored) onto -/// the key-sync candidate snapshot (`group_accounts`), so /user/vault reflects login -/// + routing within the proxy's 60s poll WITHOUT a manual `aikey key sync` (C1+C2, -/// 2026-06-30). Precedence (owner-approved): the always-on proxy rail is authoritative -/// for the logged-in-vs-not axis and for `current_routed`; the key-sync snapshot is the -/// FALLBACK for an account the proxy hasn't polled yet, and it keeps the finer -/// `auth_failed`/`revoked` reason when the account is unusable. Returns None when there -/// are no parseable candidates (direct-bind VK → the web renders no group panel). +/// Merge the proxy's LIVE per-account material (`group_runtime`, PLAINTEXT flags + +/// display meta — the encrypted secret is ignored) with the key-sync candidate snapshot +/// (`group_accounts`), so /user/vault reflects login + routing + MEMBERSHIP within the +/// proxy's 60s poll WITHOUT a manual `aikey key sync`. +/// +/// Two regimes (owner-approved): +/// - **Fast rail present** (proxy has polled this VK): the rail is AUTHORITATIVE for +/// LIST MEMBERSHIP (C1+C2 extended 2026-07-01) — the candidate list tracks the rail +/// (new accounts appear, removed accounts drop) instead of the possibly-stale +/// key-sync snapshot. Each account's `current_routed` + `login_status` come from the +/// rail; identity/provider/priority prefer the richer key-sync snapshot when the +/// account is in both, and fall back to the rail's own display meta for a fast-rail- +/// only account (added since the last key sync — renders with its email/provider, +/// not a bare UUID). The snapshot keeps supplying the finer `auth_failed`/`revoked` +/// unusable reason. +/// - **Fast rail empty/absent** (proxy hasn't polled yet / direct-bind): fall back to +/// the key-sync snapshot list, `current_routed=false` (no live routing signal). +/// +/// Returns None when there are no parseable snapshot candidates (direct-bind VK → the web +/// renders no group panel). fn merge_group_accounts_live( group_accounts: Option<&str>, group_runtime: Option<&str>, ) -> Option { - let mut cands: Vec = serde_json::from_str(group_accounts?).ok()?; - // Proxy's live material map: account_id -> {needs_login, is_current_routed, ...}. - // Absent/unparseable → empty → every account falls back to its snapshot value. - let runtime: serde_json::Map = group_runtime + use serde_json::Value; + let snapshot: Vec = serde_json::from_str(group_accounts?).ok()?; + + let runtime: serde_json::Map = group_runtime .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); - for c in cands.iter_mut() { - let Some(obj) = c.as_object_mut() else { continue }; - let account_id = obj - .get("account_id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let live = runtime.get(&account_id); - // current_routed: proxy-only signal (the snapshot has no equivalent) → false - // when the proxy hasn't polled this account/VK yet. - let is_current_routed = live - .and_then(|m| m.get("is_current_routed")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - obj.insert("current_routed".into(), serde_json::Value::Bool(is_current_routed)); - // login_status: proxy rail is authoritative for logged-in-vs-not; only override - // when the proxy HAS material for this account (else keep the snapshot value). - if let Some(m) = live { - let needs_login = m - .get("needs_login") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let snapshot = obj - .get("login_status") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let merged = if !needs_login { - "logged_in" // fresh: member has a usable token now - } else if snapshot == "auth_failed" || snapshot == "revoked" { - snapshot.as_str() // unusable — keep the finer reason from the snapshot - } else { - "needs_login" - }; - obj.insert( - "login_status".into(), - serde_json::Value::String(merged.to_string()), - ); + + // No live rail yet → snapshot list, current_routed defaulted false (legacy path). + if runtime.is_empty() { + let mut cands = snapshot; + for c in cands.iter_mut() { + if let Some(obj) = c.as_object_mut() { + obj.entry("current_routed").or_insert(Value::Bool(false)); + } } + return Some(Value::Array(cands)); } - Some(serde_json::Value::Array(cands)) + + // Fast rail authoritative for membership. Index the snapshot for metadata fallback. + let snap_by_id: std::collections::HashMap<&str, &Value> = snapshot + .iter() + .filter_map(|c| c.get("account_id").and_then(|v| v.as_str()).map(|id| (id, c))) + .collect(); + + let live_str = |live: &Value, key: &str| -> String { + live.get(key).and_then(|v| v.as_str()).unwrap_or("").to_string() + }; + + let mut merged: Vec = Vec::with_capacity(runtime.len()); + for (account_id, live) in runtime.iter() { + let snap = snap_by_id.get(account_id.as_str()).copied(); + // Base: the snapshot object (preserves ALL its fields) when the account is in + // both; otherwise a fresh object built from the rail's display meta. + let mut obj = match snap.and_then(|s| s.as_object()) { + Some(o) => o.clone(), + None => { + let mut o = serde_json::Map::new(); + o.insert("account_id".into(), Value::String(account_id.clone())); + o.insert("identity".into(), Value::String(live_str(live, "identity"))); + o.insert("provider_code".into(), Value::String(live_str(live, "provider_code"))); + o.insert("credential_type".into(), Value::String(live_str(live, "credential_type"))); + let prio = live.get("priority").and_then(|v| v.as_i64()).unwrap_or(0); + o.insert("priority".into(), Value::Number(prio.into())); + o.insert("assigned".into(), Value::Bool(false)); // rail has no static default + o + } + }; + // Overlay the rail-authoritative fields (both regimes of `obj`). + let needs_login = live.get("needs_login").and_then(|v| v.as_bool()).unwrap_or(false); + let is_current_routed = live.get("is_current_routed").and_then(|v| v.as_bool()).unwrap_or(false); + obj.insert("current_routed".into(), Value::Bool(is_current_routed)); + let snap_login = obj.get("login_status").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let login_status = if !needs_login { + "logged_in" + } else if snap_login == "auth_failed" || snap_login == "revoked" { + snap_login.as_str() // keep the finer unusable reason from the snapshot + } else { + "needs_login" + }; + obj.insert("login_status".into(), Value::String(login_status.to_string())); + merged.push(Value::Object(obj)); + } + // Deterministic display order: priority asc, then account_id (serde_json::Map's own + // iteration order is not a meaningful candidate order). + merged.sort_by(|a, b| { + let pa = a.get("priority").and_then(|v| v.as_i64()).unwrap_or(0); + let pb = b.get("priority").and_then(|v| v.as_i64()).unwrap_or(0); + pa.cmp(&pb).then_with(|| { + let ia = a.get("account_id").and_then(|v| v.as_str()).unwrap_or(""); + let ib = b.get("account_id").and_then(|v| v.as_str()).unwrap_or(""); + ia.cmp(ib) + }) + }); + Some(Value::Array(merged)) } /// Whether the group VK's CURRENT ROUTED account — the one the remote scheduling @@ -1313,6 +1351,58 @@ mod merge_group_accounts_live_tests { assert!(merge_group_accounts_live(None, None).is_none()); assert!(merge_group_accounts_live(Some("not json"), None).is_none()); } + + fn ids(v: &serde_json::Value) -> Vec { + v.as_array() + .unwrap() + .iter() + .map(|c| c["account_id"].as_str().unwrap().to_string()) + .collect() + } + fn find<'a>(v: &'a serde_json::Value, id: &str) -> &'a serde_json::Value { + v.as_array().unwrap().iter().find(|c| c["account_id"] == id).unwrap() + } + + // 2026-07-01 membership: the fast rail is AUTHORITATIVE for the candidate LIST. An + // account the key-sync snapshot doesn't know yet (a3, added since last sync) but the + // proxy delivers MUST appear — rendered with its rail-supplied identity/provider/ + // priority (not a bare UUID). 能红: the old "iterate the snapshot" merge never adds a3. + #[test] + fn fast_rail_adds_new_account_with_meta() { + let runtime = r#"{ + "a1":{"needs_login":false,"is_current_routed":true}, + "a2":{"needs_login":true}, + "a3":{"needs_login":true,"identity":"a3@x","provider_code":"anthropic","priority":5} + }"#; + let merged = merge_group_accounts_live(Some(SNAPSHOT), Some(runtime)).unwrap(); + assert!(ids(&merged).contains(&"a3".to_string()), "fast-rail-only account must appear: {:?}", ids(&merged)); + let a3 = find(&merged, "a3"); + assert_eq!(a3["identity"], "a3@x", "identity from the rail"); + assert_eq!(a3["provider_code"], "anthropic", "provider_code from the rail"); + assert_eq!(a3["priority"], 5, "priority from the rail"); + assert_eq!(a3["login_status"], "needs_login"); + } + + // Membership: an account still in the (stale) snapshot but NO LONGER delivered by the + // rail (removed from the group) MUST drop from the list. 能红: old merge keeps a2. + #[test] + fn fast_rail_drops_removed_account() { + let runtime = r#"{"a1":{"needs_login":false,"is_current_routed":true}}"#; + let merged = merge_group_accounts_live(Some(SNAPSHOT), Some(runtime)).unwrap(); + assert_eq!(ids(&merged), vec!["a1".to_string()], "a2 (gone from rail) must drop"); + } + + // For an account in BOTH, the richer snapshot metadata is preserved (identity/assigned + // from the snapshot), only the rail-authoritative flags overlay. + #[test] + fn account_in_both_keeps_snapshot_meta() { + let runtime = r#"{"a1":{"needs_login":false,"is_current_routed":true},"a2":{"needs_login":false}}"#; + let merged = merge_group_accounts_live(Some(SNAPSHOT), Some(runtime)).unwrap(); + let a1 = find(&merged, "a1"); + assert_eq!(a1["identity"], "a1@x", "snapshot identity preserved"); + assert_eq!(a1["assigned"], true, "snapshot assigned preserved"); + assert_eq!(a1["login_status"], "logged_in", "rail overlay"); + } } #[cfg(test)] From bdc488ee7e8a02f8fd97ffcb11660df351f19e2d Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 1 Jul 2026 23:09:40 -0700 Subject: [PATCH 14/42] fix: proxy reboot PID-reuse lockout + surface routed OAuth account in usage audit [aikey-cli] reboot PID-reuse lockout fix: pidfile-derived "running" states only produced when the configured port is actually held, so a leftover pidfile whose low PID got reused by a boot daemon falls to read-only OrphanedPort instead of locking the user out; +e2e reboot/PID-reuse cases Refs: - Bugfix: proxy orphaned lockout on reboot PID reuse: https://github.com/aikeylabs/workflow/blob/40eaa28ac5a7ed959fd7306f9c728b3fcc944e07/CI/bugfix/2026-07-01-proxy-orphaned-lockout-reboot-pid-reuse.md --- src/commands_account/mod.rs | 5 +- src/commands_internal/query.rs | 101 ++++++++++--- src/proxy_state.rs | 255 +++++++++++++++++++++++--------- tests/e2e_proxy_lifecycle_v6.rs | 126 ++++++++++++++++ 4 files changed, 390 insertions(+), 97 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 91d576e..9bef756 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -2738,7 +2738,7 @@ fn apply_snapshot_to_cache( supported_providers: item.supported_providers.clone(), provider_base_urls: item.provider_base_urls.clone(), owner_account_id: Some(current_account_id.to_string()), - owner_email: None, // upsert stamps the current account's email + owner_email: None, // upsert stamps the current account's email group_runtime: None, // proxy-owned (channel ③) — never written from here // Sync writers MUST always set extra: None. The value is @@ -3026,7 +3026,7 @@ pub(crate) fn upsert_delivered_key( supported_providers: dk.supported_providers.clone(), provider_base_urls: dk.provider_base_urls.clone(), owner_account_id: dk.owner_account_id.clone(), - owner_email: None, // upsert stamps the current account's email + owner_email: None, // upsert stamps the current account's email group_runtime: None, // proxy-owned (channel ③) — never written from here // Sync writers MUST always pass extra: None; upsert ignores this @@ -6876,7 +6876,6 @@ mod browse_url_tests { let got = resolve_browse_base_url("http://192.168.0.240:3000/", None); assert_eq!(got, "http://192.168.0.240:3000"); } - } // ============================================================================ diff --git a/src/commands_internal/query.rs b/src/commands_internal/query.rs index 6c6ec64..756a4e0 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -256,11 +256,18 @@ fn merge_group_accounts_live( // Fast rail authoritative for membership. Index the snapshot for metadata fallback. let snap_by_id: std::collections::HashMap<&str, &Value> = snapshot .iter() - .filter_map(|c| c.get("account_id").and_then(|v| v.as_str()).map(|id| (id, c))) + .filter_map(|c| { + c.get("account_id") + .and_then(|v| v.as_str()) + .map(|id| (id, c)) + }) .collect(); let live_str = |live: &Value, key: &str| -> String { - live.get(key).and_then(|v| v.as_str()).unwrap_or("").to_string() + live.get(key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() }; let mut merged: Vec = Vec::with_capacity(runtime.len()); @@ -274,8 +281,14 @@ fn merge_group_accounts_live( let mut o = serde_json::Map::new(); o.insert("account_id".into(), Value::String(account_id.clone())); o.insert("identity".into(), Value::String(live_str(live, "identity"))); - o.insert("provider_code".into(), Value::String(live_str(live, "provider_code"))); - o.insert("credential_type".into(), Value::String(live_str(live, "credential_type"))); + o.insert( + "provider_code".into(), + Value::String(live_str(live, "provider_code")), + ); + o.insert( + "credential_type".into(), + Value::String(live_str(live, "credential_type")), + ); let prio = live.get("priority").and_then(|v| v.as_i64()).unwrap_or(0); o.insert("priority".into(), Value::Number(prio.into())); o.insert("assigned".into(), Value::Bool(false)); // rail has no static default @@ -283,10 +296,20 @@ fn merge_group_accounts_live( } }; // Overlay the rail-authoritative fields (both regimes of `obj`). - let needs_login = live.get("needs_login").and_then(|v| v.as_bool()).unwrap_or(false); - let is_current_routed = live.get("is_current_routed").and_then(|v| v.as_bool()).unwrap_or(false); + let needs_login = live + .get("needs_login") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_current_routed = live + .get("is_current_routed") + .and_then(|v| v.as_bool()) + .unwrap_or(false); obj.insert("current_routed".into(), Value::Bool(is_current_routed)); - let snap_login = obj.get("login_status").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let snap_login = obj + .get("login_status") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); let login_status = if !needs_login { "logged_in" } else if snap_login == "auth_failed" || snap_login == "revoked" { @@ -294,7 +317,10 @@ fn merge_group_accounts_live( } else { "needs_login" }; - obj.insert("login_status".into(), Value::String(login_status.to_string())); + obj.insert( + "login_status".into(), + Value::String(login_status.to_string()), + ); merged.push(Value::Object(obj)); } // Deterministic display order: priority asc, then account_id (serde_json::Map's own @@ -329,9 +355,8 @@ fn routed_candidate_logged_in(merged: Option<&serde_json::Value>) -> bool { let Some(arr) = merged.and_then(|v| v.as_array()) else { return false; }; - let flagged = |c: &&serde_json::Value, key: &str| { - c.get(key).and_then(|b| b.as_bool()) == Some(true) - }; + let flagged = + |c: &&serde_json::Value, key: &str| c.get(key).and_then(|b| b.as_bool()) == Some(true); let routed = arr .iter() .find(|c| flagged(c, "current_routed")) @@ -363,7 +388,10 @@ fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec(v: &'a serde_json::Value, id: &str) -> &'a serde_json::Value { - v.as_array().unwrap().iter().find(|c| c["account_id"] == id).unwrap() + v.as_array() + .unwrap() + .iter() + .find(|c| c["account_id"] == id) + .unwrap() } // 2026-07-01 membership: the fast rail is AUTHORITATIVE for the candidate LIST. An @@ -1375,10 +1420,17 @@ mod merge_group_accounts_live_tests { "a3":{"needs_login":true,"identity":"a3@x","provider_code":"anthropic","priority":5} }"#; let merged = merge_group_accounts_live(Some(SNAPSHOT), Some(runtime)).unwrap(); - assert!(ids(&merged).contains(&"a3".to_string()), "fast-rail-only account must appear: {:?}", ids(&merged)); + assert!( + ids(&merged).contains(&"a3".to_string()), + "fast-rail-only account must appear: {:?}", + ids(&merged) + ); let a3 = find(&merged, "a3"); assert_eq!(a3["identity"], "a3@x", "identity from the rail"); - assert_eq!(a3["provider_code"], "anthropic", "provider_code from the rail"); + assert_eq!( + a3["provider_code"], "anthropic", + "provider_code from the rail" + ); assert_eq!(a3["priority"], 5, "priority from the rail"); assert_eq!(a3["login_status"], "needs_login"); } @@ -1389,14 +1441,19 @@ mod merge_group_accounts_live_tests { fn fast_rail_drops_removed_account() { let runtime = r#"{"a1":{"needs_login":false,"is_current_routed":true}}"#; let merged = merge_group_accounts_live(Some(SNAPSHOT), Some(runtime)).unwrap(); - assert_eq!(ids(&merged), vec!["a1".to_string()], "a2 (gone from rail) must drop"); + assert_eq!( + ids(&merged), + vec!["a1".to_string()], + "a2 (gone from rail) must drop" + ); } // For an account in BOTH, the richer snapshot metadata is preserved (identity/assigned // from the snapshot), only the rail-authoritative flags overlay. #[test] fn account_in_both_keeps_snapshot_meta() { - let runtime = r#"{"a1":{"needs_login":false,"is_current_routed":true},"a2":{"needs_login":false}}"#; + let runtime = + r#"{"a1":{"needs_login":false,"is_current_routed":true},"a2":{"needs_login":false}}"#; let merged = merge_group_accounts_live(Some(SNAPSHOT), Some(runtime)).unwrap(); let a1 = find(&merged, "a1"); assert_eq!(a1["identity"], "a1@x", "snapshot identity preserved"); diff --git a/src/proxy_state.rs b/src/proxy_state.rs index 82f3349..eb1277b 100644 --- a/src/proxy_state.rs +++ b/src/proxy_state.rs @@ -9,11 +9,14 @@ //! //! - **I-7a (identity)**: only PIDs whose process executable basename == //! `"aikey-proxy"` may produce `Running` / `Unresponsive`. PIDs that fail -//! identity check fall to `OrphanedPort` (read-only, never killed). +//! identity check fall to `OrphanedPort` when the configured port is +//! actually held, or `Crashed` (stale on-disk files, cleanable) when the +//! port is free. Neither state is ever signalled. //! - **I-7b (ownership)**: only PIDs whose `process_birth_token` matches //! the sidecar `meta.birth_token` may produce `Running` / `Unresponsive`. //! PID recycle to *another* aikey-proxy instance is caught here and -//! demoted to `OrphanedPort`. +//! demoted the same way (port held → `OrphanedPort`, port free → +//! `Crashed`). //! //! These invariants together guarantee that any state Layer 2 will act on //! (`Running` / `Unresponsive` → `kill our PID`) is genuinely **our** @@ -106,9 +109,22 @@ pub enum ProxyState { /// is verified. Unresponsive { pid: u32, port: u16 }, - /// pidfile points at a PID that is no longer alive. The pidfile + any - /// stale sidecar meta should be cleaned by the next `start_proxy` / - /// `stop_proxy`. No process to kill. + /// The on-disk lifecycle files are stale and the configured port is + /// free: the pidfile PID is either no longer alive, or alive but + /// provably NOT our proxy (identity/ownership failed — e.g. the OS + /// recycled the PID to an unrelated process after a reboot). Either + /// way there is nothing of ours running: the pidfile + sidecar meta + /// should be cleaned by the next `start_proxy` / `stop_proxy`, and + /// starting is safe (bind cannot conflict, no signal is ever sent to + /// `stale_pid`). + /// + /// Why "alive but not ours + port free" lands here and not in + /// `OrphanedPort` (2026-07-01 bugfix): after a machine reboot the + /// leftover pidfile's low PID is very likely reused by a boot + /// daemon; classifying that as `OrphanedPort` locked out + /// start/stop/restart/ensure-running even though port 27200 was + /// completely free — the user's only escape was manually deleting + /// `~/.aikey/run/proxy.pid`. Crashed { stale_pid: u32 }, /// **Read-only diagnostic state** — port is owned by some process the @@ -129,22 +145,28 @@ pub enum ProxyState { /// Why a state was classified as `OrphanedPort`. Drives the actionable /// hint we show the user — "stop the other process" vs "your old proxy /// from before the upgrade is in the way" call for very different fixes. +/// All three pidfile-derived reasons are only produced when the +/// configured port is **actually held** by that PID (2026-07-01 bugfix); +/// with the port free the same signals classify as `Crashed` (stale +/// files) instead, so a leftover pidfile can never lock the user out of +/// start/stop/restart. #[derive(Debug, Clone, PartialEq, Eq)] pub enum OrphanReason { /// The PID under our pidfile is alive but its executable is not - /// `aikey-proxy` — the kernel reused the PID for an unrelated process. + /// `aikey-proxy` — the kernel reused the PID for an unrelated + /// process, and that process is now listening on our configured port. PidRecycledToNonProxy, - /// The PID under our pidfile is an `aikey-proxy` process, but no - /// sidecar meta file exists. Most likely a proxy spawned by a - /// pre-Round-5 CLI (legacy upgrade path) — we can't prove ownership, - /// so we don't touch it. + /// The PID under our pidfile is an `aikey-proxy` process holding the + /// port, but no sidecar meta file exists. Most likely a proxy spawned + /// by a pre-Round-5 CLI (legacy upgrade path) — we can't prove + /// ownership, so we don't touch it. LegacyPidfileNoSidecar, - /// The PID under our pidfile is an `aikey-proxy` process AND a sidecar - /// meta exists, but `birth_token` mismatches — the kernel reused the - /// PID for a *different* aikey-proxy instance (e.g., user manually - /// started another proxy, sandbox install, etc.). + /// The PID under our pidfile is an `aikey-proxy` process holding the + /// port AND a sidecar meta exists, but `birth_token` mismatches — the + /// kernel reused the PID for a *different* aikey-proxy instance + /// (e.g., user manually started another proxy, sandbox install, etc.). PidRecycledToDifferentInstance, /// No pidfile, but the configured port is held by some other process. @@ -161,7 +183,8 @@ impl OrphanReason { .unwrap_or_else(|| "unknown owner".to_string()); match self { OrphanReason::PidRecycledToNonProxy => format!( - "pidfile points at PID that has been reused for an unrelated process; \ + "pidfile points at PID that has been reused for an unrelated process, \ + and that process ({owner}) is listening on port {port}; \ run `{}` to inspect", crate::proxy_proc::port_inspect_command(port), ), @@ -463,8 +486,10 @@ fn read_pidfile(path: &std::path::Path) -> Option { /// (`process_birth_token(pid) == sidecar meta.birth_token`) succeed. /// Layer 2 may safely kill PIDs in these states. /// - All identity / ownership failures fall to `OrphanedPort` with a -/// specific [`OrphanReason`] for actionable diagnostics. Layer 2 is -/// forbidden from sending signals to these PIDs. +/// specific [`OrphanReason`] when the configured port is actually +/// held, or to `Crashed` (stale files) when it is free — see +/// [`classify_alive_unowned_pid`]. Layer 2 is forbidden from sending +/// signals to the PIDs in either state. pub fn compute_proxy_state(inputs: &StateInputs) -> ProxyState { let (_host, port) = inputs.host_port(); @@ -483,11 +508,7 @@ pub fn compute_proxy_state(inputs: &StateInputs) -> ProxyState { // 2b. PID is alive. Check identity (I-7a). if !crate::proxy_proc::is_aikey_proxy(pid) { // PID was reused for a non-aikey-proxy process. Not ours. - return ProxyState::OrphanedPort { - port, - owner_pid: Some(pid), - reason: OrphanReason::PidRecycledToNonProxy, - }; + return classify_alive_unowned_pid(pid, port, OrphanReason::PidRecycledToNonProxy); } // 2c. Check ownership (I-7b) — sidecar meta + birth_token. let meta_result = read_meta_at(&inputs.meta_path); @@ -495,11 +516,7 @@ pub fn compute_proxy_state(inputs: &StateInputs) -> ProxyState { Ok(m) => m, Err(MetaError::Read { missing: true, .. }) => { // Legacy: pre-Round-5 CLI started this proxy, no sidecar. - return ProxyState::OrphanedPort { - port, - owner_pid: Some(pid), - reason: OrphanReason::LegacyPidfileNoSidecar, - }; + return classify_alive_unowned_pid(pid, port, OrphanReason::LegacyPidfileNoSidecar); } Err(_) => { // Sidecar present but corrupt / wrong schema. Ownership @@ -507,21 +524,17 @@ pub fn compute_proxy_state(inputs: &StateInputs) -> ProxyState { // legacy upgrade case for the user-facing hint, since // the actionable fix is the same (manual stop + restart // through current CLI). - return ProxyState::OrphanedPort { - port, - owner_pid: Some(pid), - reason: OrphanReason::LegacyPidfileNoSidecar, - }; + return classify_alive_unowned_pid(pid, port, OrphanReason::LegacyPidfileNoSidecar); } }; // pid in meta must match the pidfile pid (sanity — they should // have been written together). If not, the sidecar is stale. if meta.pid != pid { - return ProxyState::OrphanedPort { + return classify_alive_unowned_pid( + pid, port, - owner_pid: Some(pid), - reason: OrphanReason::PidRecycledToDifferentInstance, - }; + OrphanReason::PidRecycledToDifferentInstance, + ); } // birth_token must match the LIVE process's token. This is the // load-bearing check that catches PID-recycle-to-another- @@ -531,19 +544,19 @@ pub fn compute_proxy_state(inputs: &StateInputs) -> ProxyState { Err(_) => { // Could not read live token (process disappeared in the // race, permission denied, ABI mismatch). Conservative. - return ProxyState::OrphanedPort { + return classify_alive_unowned_pid( + pid, port, - owner_pid: Some(pid), - reason: OrphanReason::PidRecycledToDifferentInstance, - }; + OrphanReason::PidRecycledToDifferentInstance, + ); } }; if live_token != meta.birth_token { - return ProxyState::OrphanedPort { + return classify_alive_unowned_pid( + pid, port, - owner_pid: Some(pid), - reason: OrphanReason::PidRecycledToDifferentInstance, - }; + OrphanReason::PidRecycledToDifferentInstance, + ); } // 2d. Identity ✓ + ownership ✓ — we own this PID. Now check // whether the proxy is actually serving requests on the port. @@ -623,6 +636,49 @@ fn classify_dead_pid(stale_pid: u32, port: u16) -> ProxyState { } } +/// Helper: for an ALIVE pidfile PID that failed identity/ownership +/// verification, classify by what actually holds the configured port — +/// symmetric with [`classify_dead_pid`]. +/// +/// Why (2026-07-01 bugfix): the previous behavior returned +/// `OrphanedPort { owner_pid: pidfile_pid }` unconditionally, without +/// probing the port. After a machine reboot the leftover pidfile's low +/// PID is routinely reused by a boot daemon (observed on dev2: `icdd` +/// took PID 789), so start/stop/restart/ensure-running were all refused +/// even though the port was completely free — a main-path lockout whose +/// only escape was manually deleting `~/.aikey/run/proxy.pid`. It also +/// reported that recycled PID as the port "owner", sending users to +/// inspect a port nothing was listening on. +/// +/// - Port free → `Crashed` (stale files; cleanup + start are safe, no +/// signal is ever sent — invariant I-1 holds because Layer 2 never +/// signals `Crashed.stale_pid`). +/// - Port held by the pidfile PID itself → `OrphanedPort` with the +/// caller's reason (the unowned process really is serving our port). +/// - Port held by a third process → `OrphanedPort` with +/// `PortHeldByExternal` and the REAL owner pid, so diagnostics point +/// at the actual listener. +/// - Port probe tooling failed → `Crashed`, same degradation policy as +/// [`classify_dead_pid`] and the no-pidfile branch: better to attempt +/// a start (its own bind surfaces real conflicts) than refuse forever. +fn classify_alive_unowned_pid(pidfile_pid: u32, port: u16, reason: OrphanReason) -> ProxyState { + match crate::proxy_proc::port_owner_pid(port) { + Ok(Some(owner)) if owner == pidfile_pid => ProxyState::OrphanedPort { + port, + owner_pid: Some(owner), + reason, + }, + Ok(Some(owner)) => ProxyState::OrphanedPort { + port, + owner_pid: Some(owner), + reason: OrphanReason::PortHeldByExternal, + }, + Ok(None) | Err(_) => ProxyState::Crashed { + stale_pid: pidfile_pid, + }, + } +} + /// Production wrapper for [`compute_proxy_state`] using the canonical /// `~/.aikey/run/proxy.pid` + `~/.aikey/run/proxy-meta.json` paths. /// @@ -931,60 +987,115 @@ mod tests { ); } - /// pidfile points at PID 1 (init/launchd) → identity check fails - /// → OrphanedPort with PidRecycledToNonProxy reason. Pinned because - /// this is the I-7a check fired in production. + /// **2026-07-01 regression pin (reboot lockout)**: pidfile points at + /// PID 1 (init/launchd — alive, never aikey-proxy) while the + /// configured port is FREE → must classify `Crashed` (stale files, + /// recoverable), NOT `OrphanedPort`. This is exactly the state a + /// machine reboot leaves behind when a boot daemon reuses the + /// leftover pidfile's PID (dev2 incident: `icdd` took PID 789); + /// classifying it OrphanedPort locked out start/stop/restart even + /// though nothing was listening. #[test] - fn state_branch_orphaned_when_pid_recycled_to_non_proxy() { + fn state_recycled_pid_with_free_port_is_crashed_not_orphaned() { let tmp = tempfile::tempdir().unwrap(); let port = pick_free_port_for_state_tests(); let inputs = build_inputs(tmp.path(), port); // PID 1 = init / launchd / SYSTEM — guaranteed alive, never // aikey-proxy. std::fs::write(&inputs.pid_path, "1").unwrap(); + assert_eq!( + compute_proxy_state(&inputs), + ProxyState::Crashed { stale_pid: 1 }, + "recycled pidfile PID + free port must be recoverable (Crashed), not a lockout" + ); + } + + /// pidfile points at an alive non-proxy PID AND that same PID holds + /// the configured port → OrphanedPort with PidRecycledToNonProxy and + /// owner_pid = the real listener. We fake it with our own PID: the + /// cargo test runner is alive, is not aikey-proxy, and holds the + /// listener we bind here. Pinned because this is the I-7a check + /// fired in production, now gated on the port actually being held. + #[test] + fn state_branch_orphaned_when_pid_recycled_to_non_proxy_holds_port() { + let tmp = tempfile::tempdir().unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let inputs = build_inputs(tmp.path(), port); + let me = std::process::id(); + std::fs::write(&inputs.pid_path, me.to_string()).unwrap(); match compute_proxy_state(&inputs) { ProxyState::OrphanedPort { - owner_pid: Some(1), + owner_pid: Some(pid), reason: OrphanReason::PidRecycledToNonProxy, .. - } => {} // expected + } => { + assert_eq!(pid, me, "owner_pid must be the actual port holder"); + } + // lsof missing → port owner lookup degrades → Crashed per + // the documented policy. Acceptable but skip. + ProxyState::Crashed { .. } => { + eprintln!("[skip] lsof not available — port owner lookup degraded"); + } other => panic!("expected OrphanedPort/PidRecycledToNonProxy, got {other:?}"), } + drop(listener); } - /// pidfile points at our self-PID (alive) but no sidecar meta → - /// OrphanedPort with LegacyPidfileNoSidecar reason. This is the - /// upgrade-friction case from Round 5: a pre-Round-5 CLI started - /// the proxy and didn't write sidecar. + /// pidfile points at an alive non-proxy PID while a THIRD process + /// holds the port → OrphanedPort must report the REAL port owner + /// with PortHeldByExternal, not the stale pidfile PID. Before the + /// 2026-07-01 fix, status printed `owner: pid ` and the + /// hint sent users to lsof a port that PID never held. /// - /// Note: self-PID *is* the cargo test runner, not aikey-proxy, so - /// identity check would fail FIRST → PidRecycledToNonProxy. We - /// can't easily fake "alive PID with aikey-proxy basename" in a - /// pure unit test. The semantically-correct branch is exercised - /// in Stage 2 integration tests using the real aikey-proxy - /// binary. Here we just pin that the no-sidecar case eventually - /// lands in OrphanedPort regardless of the exact reason. + /// PID 1 plays the recycled pidfile PID (alive, not aikey-proxy, + /// definitely not holding our ephemeral test port — we hold it). #[test] - fn state_no_sidecar_lands_in_orphanedport() { + fn state_recycled_pid_with_port_held_by_third_reports_real_owner() { let tmp = tempfile::tempdir().unwrap(); - let port = pick_free_port_for_state_tests(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); let inputs = build_inputs(tmp.path(), port); - let me = std::process::id(); - std::fs::write(&inputs.pid_path, me.to_string()).unwrap(); - // No sidecar written. Even though we WANT this to surface as - // LegacyPidfileNoSidecar in the field, here identity will fail - // first (we are the cargo test runner, not aikey-proxy). So - // assert the OrphanedPort outer branch — both reasons produce - // identical Layer 2 behavior (do not touch). + std::fs::write(&inputs.pid_path, "1").unwrap(); match compute_proxy_state(&inputs) { ProxyState::OrphanedPort { owner_pid: Some(pid), + reason: OrphanReason::PortHeldByExternal, .. } => { - assert_eq!(pid, me, "OrphanedPort owner_pid should be the pidfile pid"); + assert_eq!( + pid, + std::process::id(), + "owner_pid must be the actual listener, not the stale pidfile PID" + ); + } + ProxyState::Crashed { .. } => { + eprintln!("[skip] lsof not available — port owner lookup degraded"); } - other => panic!("expected OrphanedPort, got {other:?}"), + other => panic!("expected OrphanedPort/PortHeldByExternal, got {other:?}"), } + drop(listener); + } + + /// pidfile points at our self-PID (alive) with no sidecar meta and + /// the port free → `Crashed` (stale files). In the field the + /// no-sidecar case matters when the legacy proxy still HOLDS the + /// port (→ OrphanedPort/LegacyPidfileNoSidecar, exercised in the + /// e2e suite with the real aikey-proxy binary); with the port free + /// there is nothing serving, so the files are just stale. + #[test] + fn state_no_sidecar_with_free_port_is_crashed() { + let tmp = tempfile::tempdir().unwrap(); + let port = pick_free_port_for_state_tests(); + let inputs = build_inputs(tmp.path(), port); + let me = std::process::id(); + std::fs::write(&inputs.pid_path, me.to_string()).unwrap(); + // No sidecar written; identity fails first anyway (we are the + // cargo test runner, not aikey-proxy). Port free → stale. + assert_eq!( + compute_proxy_state(&inputs), + ProxyState::Crashed { stale_pid: me } + ); } /// No pidfile + something else holding the port → OrphanedPort diff --git a/tests/e2e_proxy_lifecycle_v6.rs b/tests/e2e_proxy_lifecycle_v6.rs index 077fa46..dbfaaf7 100644 --- a/tests/e2e_proxy_lifecycle_v6.rs +++ b/tests/e2e_proxy_lifecycle_v6.rs @@ -15,6 +15,8 @@ //! escalation //! - **E-series** (events log): E1a lifecycle event content //! - **C-series** (cache fast path): C1 ensure-running warm path +//! - **R-series** (reboot recovery, 2026-07-01 bugfix): R1 restart / R2 +//! stop recover from a reboot-recycled pidfile with the port free //! //! ## Test infrastructure //! @@ -1438,6 +1440,130 @@ fn i3_foreground_forwards_sigterm_to_child() { ); } +// ── R series: reboot-recovery regression (2026-07-01 bugfix) ───────── +// +// Scenario: a machine reboot leaves ~/.aikey/run/proxy.pid behind, and a +// boot daemon reuses that PID (alive, NOT aikey-proxy) while the +// configured port stays FREE (observed on dev2: `icdd` took PID 789). +// Old behavior classified this OrphanedPort → start/stop/restart/ +// ensure-running ALL refused; the only escape was manually deleting the +// pidfile. Fixed behavior classifies it Crashed (stale files) and every +// entry point recovers. PID 1 (init/launchd) stands in for the recycled +// PID: guaranteed alive forever and never aikey-proxy. +// +// Bugfix record: workflow/CI/bugfix/ +// 2026-07-01-proxy-orphaned-lockout-reboot-pid-reuse.md (BR-v1.0.1-17) + +/// R1: status on the post-reboot state must say "stale pid file", NOT +/// "orphaned", and `proxy restart` (the exact command the user was +/// locked out of) must recover in one shot without signalling PID 1. +#[test] +fn r1_recycled_pidfile_free_port_restart_recovers() { + let env = match Env::try_new("r1-reboot-restart") { + Some(e) => e, + None => return skip("AIKEY_PROXY_BIN not found"), + }; + + // Fabricate the post-reboot state: pidfile → PID 1, no sidecar meta, + // port free (Env picked a free port and nothing was started). + env.write_pidfile(1); + assert!(!env.meta_path().exists(), "precondition: no sidecar meta"); + + let out = env + .cmd() + .args(["proxy", "status"]) + .output() + .expect("status"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + let lower = combined.to_lowercase(); + assert!( + !lower.contains("orphan"), + "recycled pidfile + FREE port must not be reported orphaned \ + (that was the lockout bug); got:\n{combined}" + ); + assert!( + lower.contains("stale"), + "status should surface the stale pidfile diagnostic; got:\n{combined}" + ); + + // The locked-out user path: restart must clean up + spawn. + let out = env + .cmd() + .args(["proxy", "restart"]) + .output() + .expect("restart"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + assert!( + out.status.success(), + "restart must recover from the post-reboot state; got:\n{combined}" + ); + assert!(env.wait_status(true, 10), "proxy not running after restart"); + + // Pidfile must now belong to the freshly spawned proxy, not PID 1. + let new_pid: u32 = std::fs::read_to_string(env.pid_path()) + .expect("read pidfile") + .trim() + .parse() + .expect("parse pid"); + assert_ne!(new_pid, 1, "pidfile must be rewritten to the new proxy"); + assert!(env.meta_path().exists(), "sidecar meta must be rewritten"); + + // Invariant I-1: PID 1 never received any signal. + let pid1_ret = unsafe { libc::kill(1, 0) }; + let pid1_errno = std::io::Error::last_os_error().raw_os_error(); + assert!( + pid1_ret == 0 || pid1_errno == Some(libc::EPERM), + "PID 1 must still be alive (never signalled); ret={pid1_ret}, errno={pid1_errno:?}" + ); +} + +/// R2: `proxy stop` on the same post-reboot state must exit 0 and clean +/// the stale files (old behavior: non-zero "port not owned by us" and +/// the files stayed, blocking everything downstream). +#[test] +fn r2_recycled_pidfile_free_port_stop_cleans_and_succeeds() { + let env = match Env::try_new("r2-reboot-stop") { + Some(e) => e, + None => return skip("AIKEY_PROXY_BIN not found"), + }; + + env.write_pidfile(1); + + let out = env.cmd().args(["proxy", "stop"]).output().expect("stop"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + assert!( + out.status.success(), + "stop on stale (recycled-pid, free-port) state must succeed; got:\n{combined}" + ); + assert!( + !env.pid_path().exists(), + "stale pidfile must be cleaned by stop" + ); + assert!( + !env.meta_path().exists(), + "stale sidecar meta must be cleaned by stop" + ); + + let pid1_ret = unsafe { libc::kill(1, 0) }; + let pid1_errno = std::io::Error::last_os_error().raw_os_error(); + assert!( + pid1_ret == 0 || pid1_errno == Some(libc::EPERM), + "PID 1 must still be alive (never signalled); ret={pid1_ret}, errno={pid1_errno:?}" + ); +} + // ── Module exports needed for libc::kill access ───────────────────── // Importing libc only on unix targets — these tests are Unix-only by design From 1eb6689c6f2a6f9fd671d63803c6cdacb0deab29 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 3 Jul 2026 02:08:58 -0700 Subject: [PATCH 15/42] fix: routingwire contract + member login prompt, cross-app language handoff, install entry unification statusline: pool-account login-required line (reads proxy bypass state, offline-by-design); browser launch via rundll32 on Windows (cmd /c start splits URLs at '&') Co-Authored-By: Claude Fable 5 --- src/commands_account/mod.rs | 81 +++++++++++++++++++++++++++---- src/commands_statusline.rs | 97 +++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 10 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 9bef756..0671280 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -1068,18 +1068,37 @@ fn finish_login( // Browser helper // --------------------------------------------------------------------------- +/// Returns the `(program, args)` invocation that opens `url` in the default +/// browser on the given OS (`std::env::consts::OS` values), or `None` on +/// unsupported platforms. +/// +/// Why this is a pure function: so unit tests on any host can pin the +/// Windows invocation without cross-compiling (see `browser_launch_tests`). +/// +/// Why Windows uses `rundll32 url.dll,FileProtocolHandler` and MUST NOT go +/// through `cmd /c start`: cmd.exe treats a bare `&` in its command line as +/// a command separator, so login URLs (`?s=...&d=...&email=...`) were +/// truncated at the first `&` — the browser opened with only `s`, the login +/// page failed with "Missing session parameters", and cmd spawned garbage +/// `d=...` / `email=...` commands (bugfix +/// 20260702-windows-login-url-ampersand-truncation). rundll32 receives the +/// URL as a plain argv entry with no shell parsing — same pattern as +/// `try_open_browser` (commands_import.rs) and `open_browser` +/// (commands_auth/mod.rs). +fn browser_launch_command<'a>(os: &str, url: &'a str) -> Option<(&'static str, Vec<&'a str>)> { + match os { + "macos" => Some(("open", vec![url])), + "linux" => Some(("xdg-open", vec![url])), + "windows" => Some(("rundll32", vec!["url.dll,FileProtocolHandler", url])), + _ => None, // unsupported platform — silently skip + } +} + /// Opens a URL in the default system browser (best-effort; failures are ignored). fn open_url_silently(url: &str) { - #[cfg(target_os = "macos")] - let _ = std::process::Command::new("open").arg(url).spawn(); - #[cfg(target_os = "linux")] - let _ = std::process::Command::new("xdg-open").arg(url).spawn(); - #[cfg(target_os = "windows")] - let _ = std::process::Command::new("cmd") - .args(["/c", "start", "", url]) - .spawn(); - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - let _ = url; // unsupported platform — silently skip + if let Some((program, args)) = browser_launch_command(std::env::consts::OS, url) { + let _ = std::process::Command::new(program).args(args).spawn(); + } } /// Delivers the browse URL either by opening the browser (default) or by @@ -5008,6 +5027,48 @@ fn resolve_binding_display_name(source_type: &str, source_ref: &str) -> String { source_ref.to_string() } +#[cfg(test)] +mod browser_launch_tests { + //! Fence for bugfix 20260702-windows-login-url-ampersand-truncation. + //! + //! On Windows the login URL (`?s=...&d=...&email=...`) was launched via + //! `cmd /c start "" `; cmd.exe splits its command line at bare `&`, + //! so the browser opened a URL truncated at the first `&` ("Missing + //! session parameters" on the login page) and cmd tried to run `d=...` / + //! `email=...` as commands. The URL must reach a non-shell launcher as + //! one intact argv entry. + + use super::browser_launch_command; + + const LOGIN_URL: &str = "http://127.0.0.1:3000/auth/cli/login?s=abc&d=def&email=ZmFuZw"; + + #[test] + fn windows_never_routes_through_cmd_start() { + let (program, args) = + browser_launch_command("windows", LOGIN_URL).expect("windows is supported"); + assert_ne!(program, "cmd", "cmd.exe shell-parses bare `&` — truncates the URL"); + assert_eq!(program, "rundll32"); + assert_eq!(args, vec!["url.dll,FileProtocolHandler", LOGIN_URL]); + } + + #[test] + fn unix_launchers_take_url_as_single_arg() { + assert_eq!( + browser_launch_command("macos", LOGIN_URL), + Some(("open", vec![LOGIN_URL])) + ); + assert_eq!( + browser_launch_command("linux", LOGIN_URL), + Some(("xdg-open", vec![LOGIN_URL])) + ); + } + + #[test] + fn unsupported_platform_skips() { + assert_eq!(browser_launch_command("freebsd", LOGIN_URL), None); + } +} + #[cfg(test)] mod provider_mapping_tests { //! Pin the current behavior of provider-code → {env vars, URL path} mapping diff --git a/src/commands_statusline.rs b/src/commands_statusline.rs index 8cd3ef4..ff080bd 100644 --- a/src/commands_statusline.rs +++ b/src/commands_statusline.rs @@ -59,6 +59,19 @@ pub fn run() -> io::Result<()> { return last_active(); } + // Pool-account login pending? Takes precedence over the usage receipt + // (20260703 OAuth组成员登录提示): while the proxy's bypass state file + // exists, every request 401s anyway — a receipt would be stale noise, + // and this row is where the member actually SEES the sign-in link + // (claude's own error line scrolls away; this one persists). The proxy + // clears the file on the next successful group resolve, so the receipt + // comes back on its own after login. One stat() on the hot path. + if let Some(line) = group_login_required_line() { + let mut out = io::stdout().lock(); + write!(out, "{}", line)?; + return Ok(()); + } + let ctx = read_stdin_ctx().unwrap_or_default(); // `scan_wal_backward` walks newest-first with a bounded budget; see @@ -149,6 +162,50 @@ fn env_flag(name: &str) -> bool { ) } +/// Wire shape of the proxy's bypass login-required state file — a cross-repo +/// contract with aikey-proxy `internal/proxy/group_login_state.go` +/// (20260703 OAuth组成员登录提示). Written on OAUTH_GROUP_MEMBER_LOGIN_REQUIRED +/// 401s, removed on the next successful group resolve. +#[derive(Debug, Deserialize)] +struct GroupLoginState { + #[serde(default)] + login_url: String, + /// Unix millis; 0/absent ⇒ malformed writer, ignore the file. + #[serde(default)] + written_at: i64, +} + +/// Same resolution as the proxy writer: $AIKEY_RUN_DIR override (tests), +/// else ~/.aikey/run/ — mirrors `runtime_snapshot_path` in commands_proxy.rs. +fn group_login_state_path() -> PathBuf { + if let Ok(dir) = std::env::var("AIKEY_RUN_DIR") { + return PathBuf::from(dir).join("group-login-required.json"); + } + crate::commands_account::resolve_aikey_dir() + .join("run") + .join("group-login-required.json") +} + +/// Returns the rendered "login required" status row while the proxy's state +/// file exists, or None. Presence-based on purpose: a pending sign-in can +/// legitimately sit for days, and the proxy deterministically removes the +/// file once the member's token lands — no staleness heuristic beats that. +/// Malformed / URL-less content ⇒ None (never render a broken hint). +fn group_login_required_line() -> Option { + let raw = std::fs::read_to_string(group_login_state_path()).ok()?; + let st: GroupLoginState = serde_json::from_str(&raw).ok()?; + if st.written_at <= 0 || st.login_url.is_empty() { + return None; + } + use colored::Colorize; + Some(format!( + "{} {} {}", + "[aikey]".dimmed(), + "⚠ team account sign-in required →".yellow(), + st.login_url.underline() + )) +} + fn read_stdin_ctx() -> io::Result { // Caller (`run`) has already verified stdin is a pipe; we will not block // on TTY input. Keep the read bounded: Claude Code's payload is always @@ -1735,6 +1792,46 @@ fn format_number(n: i64) -> String { mod tests { use super::*; + // group_login_required_line contract tests (20260703 OAuth组成员登录提示). + // NOTE: env-var mutation → run serially per test binary section; each test + // uses its own temp dir and restores nothing (AIKEY_RUN_DIR is test-only). + #[test] + fn group_login_line_renders_url_and_clears_with_file() { + let dir = std::env::temp_dir().join(format!("aikey-sl-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("AIKEY_RUN_DIR", &dir); + + let path = dir.join("group-login-required.json"); + std::fs::write( + &path, + r#"{"provider":"anthropic","account_id":"acc-1","login_url":"http://127.0.0.1:8090/user/team-oauth","written_at":1776439425000}"#, + ) + .unwrap(); + let line = group_login_required_line().expect("state file present → hint rendered"); + assert!( + line.contains("http://127.0.0.1:8090/user/team-oauth"), + "hint must carry the clickable login URL: {line}" + ); + assert!( + line.contains("sign-in required"), + "hint must say WHY the receipt is replaced: {line}" + ); + + // Proxy removed the file after a successful resolve → receipt path resumes. + std::fs::remove_file(&path).unwrap(); + assert!(group_login_required_line().is_none()); + + // Malformed writer (no written_at / empty URL) must never render a + // broken hint — silence over garbage. + std::fs::write(&path, r#"{"login_url":"","written_at":0}"#).unwrap(); + assert!(group_login_required_line().is_none()); + std::fs::write(&path, "not-json").unwrap(); + assert!(group_login_required_line().is_none()); + + std::fs::remove_file(&path).ok(); + std::env::remove_var("AIKEY_RUN_DIR"); + } + fn ev( session_id: &str, model: &str, From 9b1967f8fa8b568489da014f38b5c1fb49106257 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 3 Jul 2026 06:48:36 -0700 Subject: [PATCH 16/42] =?UTF-8?q?fix:=20SyncRail=20framework=20=E2=80=94?= =?UTF-8?q?=20control-plane=20rails=20re-evaluate=20per=20cycle,=20degrade?= =?UTF-8?q?d=20state=20surfaces=20to=20statusline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] statusline surfaces a "control-plane sync degraded" row (reads proxy sync-health.json, renders live outage duration) + a group-login-required row at receipt-less exit points so a dead sync rail can't stay invisible Refs: - Spec: control-plane SyncRail framework design: https://github.com/aikeylabs/roadmap20260320/blob/ed04caeca75a932f53921f7a3c6ef12b8fa95780/技术实现/update/20260703-控制面同步框架SyncRail-技术方案.md - Spec: group routing-override self-heal persistence + settings linkage: https://github.com/aikeylabs/roadmap20260320/blob/ed04caeca75a932f53921f7a3c6ef12b8fa95780/技术实现/update/20260703-组路由override自愈持久化与settings联动.md - Spec: OAuth group-member login prompt (CLI + login_url): https://github.com/aikeylabs/roadmap20260320/blob/ed04caeca75a932f53921f7a3c6ef12b8fa95780/技术实现/update/20260703-OAuth组成员登录提示-CLI显示与login_url.md --- src/commands_account/mod.rs | 31 +++++-- src/commands_statusline.rs | 158 ++++++++++++++++++++++++++++++++++-- 2 files changed, 177 insertions(+), 12 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 0671280..36f88be 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -622,22 +622,43 @@ pub fn handle_set_control_url( // Also update proxy collector_url (nginx proxies collector on same origin). configure_proxy_collector(url, json_mode); + // SyncRail §5.2 (2026-07-03): nudge the running proxy so the URL change + // converges NOW — the reload re-reads aikey-proxy.yaml (reporter creds) + // and kicks the control-plane sync rails (routing/group material re-pull + // against the NEW server, team credential rebuilt). Best-effort by + // design: when the proxy isn't running (or the POST fails) the rails' + // per-cycle URL re-check still self-heals within ≤60s of the next start, + // so this replaces the old "Restart proxy to apply" manual step for BOTH + // entrances (CLI set-url and the Web Settings page, which subprocesses + // this same core — internal-command-reuses-public-core). + let proxy_running = crate::commands_proxy::proxy_is_running_managed(); + if proxy_running { + crate::commands_proxy::try_reload_proxy(); + } + if json_mode { crate::json_output::print_json(serde_json::json!({ "ok": true, "old_url": old_url, "new_url": url, + "proxy_reloaded": proxy_running, })); } else { println!("{} Control URL updated.", "\u{2713}".green()); println!(" {} → {}", old_url.dimmed(), url.bold()); println!(" Proxy collector URL also updated."); println!(); - println!( - " {} Restart proxy to apply: {}", - "\u{2192}".dimmed(), - "aikey proxy restart".bold() - ); + if proxy_running { + println!( + " {} Proxy reloaded — the new URL is live (sync rails re-pull within seconds).", + "\u{2713}".green() + ); + } else { + println!( + " {} Proxy not running — it picks up the new URL on next start.", + "\u{2192}".dimmed() + ); + } } } else { // Not logged in — only save to config.json. diff --git a/src/commands_statusline.rs b/src/commands_statusline.rs index ff080bd..3707416 100644 --- a/src/commands_statusline.rs +++ b/src/commands_statusline.rs @@ -72,16 +72,23 @@ pub fn run() -> io::Result<()> { return Ok(()); } + // Control-plane sync degraded? (SyncRail §5.5) Unlike the login hint this + // does NOT replace the receipt — usage keeps flowing off cached material — + // so it renders as a prefix segment when a receipt exists, or alone when + // none does. Presence-based: the proxy removes the file on recovery. + let sync_warn = sync_health_line(); + let ctx = read_stdin_ctx().unwrap_or_default(); // `scan_wal_backward` walks newest-first with a bounded budget; see // §5.1 of the design doc for why a fixed "tail N" is insufficient. let opts = ScanOptions::default(); let Some(dir) = default_wal_dir() else { - return Ok(()); + return emit_optional_line(sync_warn); }; if !dir.exists() { - return Ok(()); // proxy never wrote a WAL on this machine + // proxy never wrote a WAL on this machine + return emit_optional_line(sync_warn); } let sid = ctx.session_id.as_deref().unwrap_or(""); @@ -129,8 +136,9 @@ pub fn run() -> io::Result<()> { }; let Some(ev) = exact.or(fallback) else { - // Nothing to show — Claude Code hides the row when stdout is empty. - return Ok(()); + // No receipt — Claude Code hides the row when stdout stays empty, but a + // degraded sync rail must still surface. + return emit_optional_line(sync_warn); }; // Freshness guard: even after a match, if the latest event for this @@ -139,7 +147,7 @@ pub fn run() -> io::Result<()> { // has stopped and the previous value is no longer representative. if let Some(age) = ev.age(std::time::SystemTime::now()) { if age > Duration::from_secs(3600) { - return Ok(()); + return emit_optional_line(sync_warn); } } @@ -151,10 +159,23 @@ pub fn run() -> io::Result<()> { use colored::Colorize; let line = render_line(&ev); let mut out = io::stdout().lock(); + if let Some(warn) = sync_warn { + write!(out, "{} {} ", warn, "|".dimmed())?; + } write!(out, "{} {}", "[receipt]".dimmed(), line)?; Ok(()) } +/// Writes an optional status row (used by the receipt-less exit points so a +/// degraded sync rail still surfaces; None keeps stdout empty → row hidden). +fn emit_optional_line(line: Option) -> io::Result<()> { + if let Some(l) = line { + let mut out = io::stdout().lock(); + write!(out, "{}", l)?; + } + Ok(()) +} + fn env_flag(name: &str) -> bool { matches!( std::env::var(name).ok().as_deref(), @@ -206,6 +227,68 @@ fn group_login_required_line() -> Option { )) } +/// Cross-repo contract with aikey-proxy railset.go `syncHealthBody` — the +/// SyncRail framework writes this file on rail state TRANSITIONS only +/// (degraded rails present ⇒ file exists; all healthy ⇒ file removed). +#[derive(serde::Deserialize)] +struct SyncHealthState { + rails: std::collections::HashMap, + written_at: i64, +} + +#[derive(serde::Deserialize)] +struct SyncHealthRail { + state: String, + /// Unix seconds of the first failure in the current streak — the reader + /// renders a LIVE outage duration without the proxy re-writing the file. + failed_since: i64, +} + +fn sync_health_state_path() -> PathBuf { + if let Ok(dir) = std::env::var("AIKEY_RUN_DIR") { + return PathBuf::from(dir).join("sync-health.json"); + } + crate::commands_account::resolve_aikey_dir() + .join("run") + .join("sync-health.json") +} + +/// Returns the rendered "control-plane sync degraded" status row while the +/// proxy's sync-health file exists, or None (SyncRail §5.5, 2026-07-03 +/// incident: two sync rails were silently dead for 7+ hours — this row makes +/// that state visible BEFORE a request fails). Presence-based like the login +/// hint: the proxy removes the file when every rail recovers. Renders the +/// WORST rail (offline > stale) with a live duration; malformed ⇒ None. +fn sync_health_line() -> Option { + let raw = std::fs::read_to_string(sync_health_state_path()).ok()?; + let st: SyncHealthState = serde_json::from_str(&raw).ok()?; + if st.written_at <= 0 || st.rails.is_empty() { + return None; + } + let worst = st + .rails + .values() + .max_by_key(|r| (r.state == "offline", r.failed_since.saturating_neg()))?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs() as i64; + let secs = (now - worst.failed_since).max(0); + let dur = if secs >= 3600 { + format!("{} h", secs / 3600) + } else if secs >= 60 { + format!("{} min", secs / 60) + } else { + format!("{} s", secs) + }; + use colored::Colorize; + Some(format!( + "{} {}", + "[aikey]".dimmed(), + format!("⚠ team sync {} {} — serving cached data", worst.state, dur).yellow() + )) +} + fn read_stdin_ctx() -> io::Result { // Caller (`run`) has already verified stdin is a pipe; we will not block // on TTY input. Keep the read bounded: Claude Code's payload is always @@ -1793,10 +1876,13 @@ mod tests { use super::*; // group_login_required_line contract tests (20260703 OAuth组成员登录提示). - // NOTE: env-var mutation → run serially per test binary section; each test - // uses its own temp dir and restores nothing (AIKEY_RUN_DIR is test-only). + // NOTE: env-var mutation (AIKEY_RUN_DIR is process-global) — every test that + // sets it must hold RUN_DIR_LOCK for its whole body, or parallel test + // threads repoint the dir mid-read (observed flake, 2026-07-03). + static RUN_DIR_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[test] fn group_login_line_renders_url_and_clears_with_file() { + let _guard = RUN_DIR_LOCK.lock().unwrap(); let dir = std::env::temp_dir().join(format!("aikey-sl-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); std::env::set_var("AIKEY_RUN_DIR", &dir); @@ -1832,6 +1918,64 @@ mod tests { std::env::remove_var("AIKEY_RUN_DIR"); } + // sync_health_line contract tests (SyncRail §5.5, 2026-07-03): the proxy + // writes sync-health.json on rail state transitions; the row renders the + // WORST rail with a live duration and disappears with the file. + #[test] + fn sync_health_line_renders_worst_rail_and_clears_with_file() { + let _guard = RUN_DIR_LOCK.lock().unwrap(); + let dir = std::env::temp_dir().join(format!("aikey-sl-sync-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("AIKEY_RUN_DIR", &dir); + + let path = dir.join("sync-health.json"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + std::fs::write( + &path, + format!( + r#"{{"rails":{{"group_runtime":{{"state":"stale","failed_since":{}}},"routing_override":{{"state":"offline","failed_since":{}}}}},"written_at":1776439425000}}"#, + now - 200, + now - 23 * 60, + ), + ) + .unwrap(); + let line = sync_health_line().expect("state file present → warning rendered"); + assert!( + line.contains("offline"), + "worst rail (offline beats stale) must win: {line}" + ); + assert!( + line.contains("23 min"), + "live outage duration must render from failed_since: {line}" + ); + assert!( + line.contains("serving cached data"), + "warning must say the data path still serves (offline-first): {line}" + ); + + // Proxy removed the file on recovery → warning disappears. + std::fs::remove_file(&path).unwrap(); + assert!(sync_health_line().is_none()); + } + + #[test] + fn sync_health_line_malformed_or_empty_is_silent() { + let _guard = RUN_DIR_LOCK.lock().unwrap(); + let dir = std::env::temp_dir().join(format!("aikey-sl-sync-bad-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("AIKEY_RUN_DIR", &dir); + let path = dir.join("sync-health.json"); + + std::fs::write(&path, "{not-json").unwrap(); + assert!(sync_health_line().is_none(), "malformed file must not render"); + + std::fs::write(&path, r#"{"rails":{},"written_at":1}"#).unwrap(); + assert!(sync_health_line().is_none(), "empty rails must not render"); + } + fn ev( session_id: &str, model: &str, From 3158d9ff19b0244635d14f2a5707d42d952f5b28 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 3 Jul 2026 21:05:13 -0700 Subject: [PATCH 17/42] feat: macOS notarization + aikey service/web status; fix delivery-shape 503, pool cooldown persistence, chk_mpb_target guard [aikey-cli] aikey service status / aikey web status commands + local-server probe; doctor/project feature coverage; account cache prune; README (en/zh) Refs: - Requirement: aikey service-status command: https://github.com/aikeylabs/workflow/blob/fe705f43504cb9eaf9d50516984561dce71d37bd/CI/requirements/2026-07-03-service-status-command.md --- README.md | 8 +- README.zh.md | 8 +- src/cli.rs | 83 +++++++++-- src/commands_account/mod.rs | 156 ++++++++++++++++++++- src/commands_project.rs | 234 ++++++++++++++++++++++++++++--- src/commands_proxy.rs | 23 +++ src/commands_service/commands.rs | 173 ++++++++++++++++++++++- src/commands_statusline.rs | 5 +- src/local_server_probe.rs | 130 +++++++++++++++++ src/main.rs | 6 + src/storage_platform.rs | 17 +++ 11 files changed, 798 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 95d059f..8722f87 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Vault is unlocked once per shell session — subsequent `aikey run` calls reuse | `aikey route` | vault.db (read-only) | nothing | stdout (for copying into third-party tool config) | | `aikey test []` | vault.db | nothing (probe-only via `X-Aikey-Probe: 1`) | proxy → upstream `/v1/models` | | `aikey web [page]` | nothing | nothing | spawns browser → `aikey-local-server` | -| `aikey doctor` | proxy port, vault path, hooks | nothing | stdout report | +| `aikey doctor` | edition, proxy, vault, hooks, plugins (trust-local / compliance) | nothing | stdout report (`--detail` adds edition-aware ODS panels) | | `aikey audit status` | collector completeness endpoint (+ proxy local state) | nothing | stdout per-source delivery report | | `aikey audit reconcile` | collector gaps + proxy WAL | known-loss ledger (server) | stdout verdict; re-sends recoverable gaps, confirms losses | @@ -220,6 +220,12 @@ aikey doctor # diagnose PATH / hook / proxy / vau aikey test --all # connectivity test all credentials aikey proxy restart # restart the local proxy +# Service status (which daemons are up) +aikey service status # one line each: web / proxy / trust-local +aikey web status # local web console: running? port? vault state? +aikey proxy status # proxy: running? pid? listen addr? +aikey service status trust-local # a single service in detail + # Delivery audit (financial-grade usage completeness) aikey audit status # per-source: allocated / confirmed / gaps / known-loss / quarantine aikey audit reconcile # actively reconcile now: re-send recoverable gaps, confirm true losses diff --git a/README.zh.md b/README.zh.md index 8e35d18..ef00060 100644 --- a/README.zh.md +++ b/README.zh.md @@ -96,7 +96,7 @@ Vault 每个 shell session 只解锁一次 — 后续的 `aikey run` 用缓存 | `aikey route` | vault.db(read-only) | 无 | stdout(可复制到第三方工具配置) | | `aikey test []` | vault.db | 无(probe-only,带 `X-Aikey-Probe: 1`) | proxy → upstream `/v1/models` | | `aikey web [page]` | 无 | 无 | spawn 浏览器 → `aikey-local-server` | -| `aikey doctor` | proxy 端口 / vault 路径 / hooks | 无 | stdout 诊断报告 | +| `aikey doctor` | 版型 / proxy / vault / hooks / 插件(trust-local / 合规过滤) | 无 | stdout 诊断报告(`--detail` 增加按版型区分的 ODS 面板) | | `aikey audit status` | collector completeness 端点(+ proxy 本地状态)| 无 | stdout per-source 投递报告 | | `aikey audit reconcile` | collector 缺口 + proxy WAL | 已知丢失台账(服务端)| stdout 对账结论;补传可恢复缺口、确认丢失 | @@ -220,6 +220,12 @@ aikey doctor # 诊断 PATH / hook / proxy / vault aikey test --all # 连通性测试所有凭据 aikey proxy restart # 重启 local proxy +# 服务状态(哪些后台进程在运行) +aikey service status # 一行一个: web / proxy / trust-local +aikey web status # 本地 web 控制台: 是否运行? 端口? vault 状态? +aikey proxy status # proxy: 是否运行? pid? 监听地址? +aikey service status trust-local # 单个服务的详细状态 + # 投递审计(财务对账级用量完整性) aikey audit status # 按源看:已分配 / 已确认 / 缺口 / 已知丢失 / 隔离 aikey audit reconcile # 立即主动对账:补传可恢复缺口、确认真实丢失 diff --git a/src/cli.rs b/src/cli.rs index 5eb1bd3..70ac142 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -290,11 +290,12 @@ pub(crate) enum Commands { /// secrets, profile, usage-ledger, bulk-import, quick-import, trust) — /// resolved by the web UI. /// - /// Service control: pass `start`, `stop`, or `restart` instead of a - /// page name to start / stop / restart the local web service + /// Service control: pass `start`, `stop`, `restart`, or `status` + /// instead of a page name to control / inspect the local web service /// (aikey-local-server on Personal, aikey-trial-server on Trial). - /// Production servers are not managed here — use the server-install - /// runbook on the server host instead. + /// `status` reports whether it's running, on which port, and the + /// vault lock state. Production servers are not managed here — use the + /// server-install runbook on the server host instead. page: Option, /// Shortcut for `aikey web import` — opens the Import page directly. #[arg(long)] @@ -536,18 +537,25 @@ pub(crate) enum Commands { #[command(subcommand)] action: AppAction, }, - /// Manage AiKey background services (`start` / `stop` / `restart`). + /// Manage AiKey background services (`start` / `stop` / `restart` / `status`). /// - /// Generic service-control namespace for any AiKey-owned daemon - /// that runs as a user-scope launchd / systemd unit. Today the - /// only registered service is `trust-local` (degrade-detector). - /// Refuses to operate on names not in the whitelist — this is NOT - /// a generic launchd/systemd wrapper. + /// Umbrella service-control namespace for the AiKey-owned daemons. + /// Registered services (closed whitelist): `web` (local-server / + /// trial-server console), `proxy` (aikey-proxy), `trust-local` + /// (degrade-detector observer). Refuses names outside the whitelist — + /// this is NOT a generic launchd/systemd wrapper. + /// + /// The per-service namespaces (`aikey proxy ...`, `aikey web ...`) and + /// this umbrella share ONE implementation core per service, so + /// `aikey service status proxy` and `aikey proxy status` print the same + /// thing. Bare `aikey service status` (no name) prints a one-line + /// summary of every service at once. /// /// Examples: - /// aikey service start trust-local - /// aikey service restart trust-local - /// aikey service stop trust-local + /// aikey service status # all services, one line each + /// aikey service status trust-local # one service, full detail + /// aikey service restart web + /// aikey service stop proxy #[command(display_order = 27)] Service { #[command(subcommand)] @@ -572,6 +580,10 @@ pub(crate) enum ServiceAction { Stop { name: Option }, /// Restart a registered AiKey service. Restart { name: Option }, + /// Show service status. Without a name: one-line summary of every + /// service. With a name (`web` / `proxy` / `trust-local`): full detail + /// for that one — delegates to the same probe as `aikey status`. + Status { name: Option }, } /// Subcommands for `aikey trust` (M4). @@ -1463,6 +1475,7 @@ pub(crate) fn command_name(cmd: Option<&Commands>) -> String { ServiceAction::Start { .. } => "service.start".to_string(), ServiceAction::Stop { .. } => "service.stop".to_string(), ServiceAction::Restart { .. } => "service.restart".to_string(), + ServiceAction::Status { .. } => "service.status".to_string(), }, }, } @@ -2984,5 +2997,49 @@ mod tests { }, }; assert_eq!(command_name(Some(&cmd)), "service.restart"); + + let cmd = Commands::Service { + action: ServiceAction::Status { name: None }, + }; + assert_eq!(command_name(Some(&cmd)), "service.status"); + } + + /// `aikey service status` (no name) parses with name=None so the + /// dispatcher renders the aggregate dashboard. + #[test] + fn aikey_service_status_no_name() { + let cli = Cli::try_parse_from(["aikey", "service", "status"]).expect("parse"); + assert!(matches!( + cli.command, + Some(Commands::Service { + action: ServiceAction::Status { name: None } + }) + )); + } + + /// `aikey service status ` parses the name through for per-service + /// detail (web / proxy / trust-local). + #[test] + fn aikey_service_status_named() { + for svc in ["web", "proxy", "trust-local"] { + let cli = Cli::try_parse_from(["aikey", "service", "status", svc]).expect("parse"); + assert!(matches!( + cli.command, + Some(Commands::Service { action: ServiceAction::Status { ref name } }) + if name.as_deref() == Some(svc) + )); + } + } + + /// `aikey web status` parses as the Web command with page="status" — + /// main.rs intercepts that positional and routes to handle_web_status + /// (read-only) instead of opening a browser page named "status". + #[test] + fn aikey_web_status_parses_as_page_verb() { + let cli = Cli::try_parse_from(["aikey", "web", "status"]).expect("parse"); + assert!(matches!( + cli.command, + Some(Commands::Web { ref page, .. }) if page.as_deref() == Some("status") + )); } } diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 36f88be..8059634 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -1799,6 +1799,32 @@ pub fn handle_web_service(action: &str, json_mode: bool) -> Result<(), Box Result<(), Box> { + let (running, detail) = crate::local_server_probe::status_summary(); + if json_mode { + let edition = crate::local_server_probe::detect_edition().map(|e| e.label()); + crate::json_output::print_json(serde_json::json!({ + "ok": true, + "service": "web", + "running": running, + "edition": edition, + "detail": detail, + })); + } else { + // Reuse the richer multi-line line for the dedicated command — it + // already includes a Start hint on the NOT RUNNING path. + println!("{}", crate::local_server_probe::local_server_status_line()); + } + Ok(()) +} + /// `aikey master [page] [--port PORT]` — open Master Console in the default browser. /// /// Resolves the control panel URL from install-state.json or the stored @@ -2822,16 +2848,24 @@ fn apply_snapshot_to_cache( } } - // Mark stale: keys the current account owns locally but the server no longer returns. - // This includes the currently-active key: if the server has removed it from the - // snapshot, it is no longer valid and must be deactivated immediately. + // Prune: keys the current account owns locally but the server no longer + // returns are DELETED (2026-07-04 self-heal, replaces the old mark-stale). + // WHY delete, not stale: the server is the single authority for the + // owner's keys — a server-deleted key kept as a local `stale` row rendered + // forever as a "revoked / inactive" ghost in /user/vault (the exact user + // confusion on 2026-07-03), and for DETERMINISTIC group-VK aliases the + // ghost also shadowed the freshly re-issued key. Scope guard: ONLY rows + // owned by the CURRENT account are pruned — other accounts' rows keep the + // re-login recovery semantics (see clear_virtual_key_cache's doc note). + // Idempotent: the second sync finds nothing absent to prune. + // This includes the currently-active key: if the server removed it, it is + // no longer valid and must be deactivated immediately. if let Ok(cached) = storage::list_virtual_key_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) - && entry.local_state != "stale" { - let _ = storage::set_virtual_key_local_state(&entry.virtual_key_id, "stale"); // 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" { @@ -2843,8 +2877,24 @@ fn apply_snapshot_to_cache( } } } + match storage::delete_virtual_key_cache_row(&entry.virtual_key_id) { + Ok(()) => pruned += 1, + Err(e) => { + // Fall back to the legacy stale mark so the row at least + // stops being usable; visible per the mandatory-WARN rule. + eprintln!( + "[aikey] warn: prune of server-removed key {} failed ({}); marked stale instead", + entry.virtual_key_id, e + ); + let _ = + storage::set_virtual_key_local_state(&entry.virtual_key_id, "stale"); + } + } } } + if pruned > 0 { + println!(" pruned {pruned} server-removed key(s) from local cache"); + } } } @@ -5067,7 +5117,10 @@ mod browser_launch_tests { fn windows_never_routes_through_cmd_start() { let (program, args) = browser_launch_command("windows", LOGIN_URL).expect("windows is supported"); - assert_ne!(program, "cmd", "cmd.exe shell-parses bare `&` — truncates the URL"); + assert_ne!( + program, "cmd", + "cmd.exe shell-parses bare `&` — truncates the URL" + ); assert_eq!(program, "rundll32"); assert_eq!(args, vec!["url.dll,FileProtocolHandler", LOGIN_URL]); } @@ -7063,3 +7116,94 @@ mod probe_token_tests { assert!(err.contains("http://127.0.0.1:0")); } } + +#[cfg(test)] +mod sync_prune_tests { + // Sync prune self-heal (2026-07-04): keys the server no longer returns for + // the OWNING account are DELETED from the local cache (not stale-marked) — + // a server-deleted key kept as `stale` rendered a permanent ghost row and, + // for deterministic group-VK aliases, shadowed the re-issued key. Other + // accounts' rows keep the re-login recovery semantics. 能红: revert the + // prune to mark-stale and `owner_absent_row_is_deleted` fails. + use super::*; + use crate::storage; + use secrecy::SecretString; + + fn setup_vault() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) { + let guard = crate::test_env_lock::ENV_MUTATION_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::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()); + } + let mut salt = [0u8; 16]; + crate::crypto::generate_salt(&mut salt).expect("salt"); + storage::initialize_vault(&salt, &SecretString::new("test_password".to_string())) + .expect("init vault"); + (dir, guard) + } + + fn cache_entry(vk_id: &str, owner: &str) -> storage::VirtualKeyCacheEntry { + storage::VirtualKeyCacheEntry { + virtual_key_id: vk_id.to_string(), + org_id: "org-1".to_string(), + seat_id: "seat-1".to_string(), + alias: format!("alias-{vk_id}"), + provider_code: "anthropic".to_string(), + protocol_type: "anthropic".to_string(), + base_url: String::new(), + credential_id: "cred-1".to_string(), + credential_revision: "r1".to_string(), + virtual_key_revision: "vr1".to_string(), + key_status: "active".to_string(), + share_status: "claimed".to_string(), + local_state: "synced_inactive".to_string(), + expires_at: None, + provider_key_nonce: None, + provider_key_ciphertext: None, + synced_at: 0, + local_alias: None, + supported_providers: vec!["anthropic".to_string()], + provider_base_urls: Default::default(), + owner_account_id: Some(owner.to_string()), + owner_email: None, + extra: None, + oauth_group_id: None, + group_accounts: None, + routing_config: None, + group_alias: None, + group_runtime: None, + } + } + + #[test] + fn owner_absent_row_is_deleted_other_account_kept_idempotent() { + let (_dir, _lock) = setup_vault(); + storage::upsert_virtual_key_cache(&cache_entry("vk-mine-gone", "acct-A")).unwrap(); + storage::upsert_virtual_key_cache(&cache_entry("vk-other-acct", "acct-B")).unwrap(); + + // Empty snapshot for acct-A → its row must be DELETED; acct-B's row + // (another server session's cache) must be untouched. + apply_snapshot_to_cache(&[], "acct-A"); + + let left = storage::list_virtual_key_cache().unwrap(); + assert!( + !left.iter().any(|e| e.virtual_key_id == "vk-mine-gone"), + "owner's server-removed key must be pruned, not stale-marked: {left:?}" + ); + let other = left + .iter() + .find(|e| e.virtual_key_id == "vk-other-acct") + .expect("other account's row must survive the prune"); + assert_ne!( + other.local_state, "stale", + "other account's row must be untouched" + ); + + // Idempotent: a second pass has nothing to prune and must not error. + apply_snapshot_to_cache(&[], "acct-A"); + assert_eq!(storage::list_virtual_key_cache().unwrap().len(), 1); + } +} diff --git a/src/commands_project.rs b/src/commands_project.rs index 8b730fd..a664d7f 100644 --- a/src/commands_project.rs +++ b/src/commands_project.rs @@ -565,6 +565,75 @@ pub fn handle_provider_ls(json_mode: bool) -> Result<(), Box) -> &'static str { + match edition { + Some(crate::local_server_probe::Edition::Trial) => "Trial (full-trial bundle)", + Some(crate::local_server_probe::Edition::Personal) => "Personal (local-server)", + None => "Personal (CLI-only, no local web service)", + } +} + +/// N/A note for the `--detail` ODS panels on non-Trial editions (B2). Those +/// panels read the Trial-server ODS pipeline (control-trial.db/.log); on +/// Personal / CLI-only that pipeline doesn't exist. Returns "" for Trial +/// (the panel renders real data instead). +fn doctor_detail_ods_na_note(edition: Option) -> &'static str { + match edition { + Some(crate::local_server_probe::Edition::Personal) => { + "not applicable on Personal edition — the ODS pipeline is a Trial-server feature" + } + None => "not applicable (CLI-only host — no Trial-server ODS pipeline)", + Some(crate::local_server_probe::Edition::Trial) => "", + } +} + +/// One optional first-party plugin app that `aikey doctor` reports (A2). +struct DoctorPlugin { + /// Row label shown in the doctor output. + label: &'static str, + /// Binary path relative to `$HOME`. + rel_path: &'static str, + /// `aikey app install ` name (for the "enable" hint). + install_slug: &'static str, + /// Daemon (own port, gets a liveness probe) vs subprocess filter + /// (spawned per-request by the proxy, presence check only). + is_daemon: bool, +} + +/// The optional first-party plugin apps `aikey doctor` reports (A2). +/// +/// Kept as one table so the doctor section, this list, and the app registry +/// (`commands_app::FIRST_PARTY_SLUGS`) stay in lockstep — a slug added there +/// should surface here too (asserted by a unit test). Path note: trust-local +/// installs to the legacy `~/.aikey/bin/`, the compliance apps to the +/// app-manifest convention `~/.aikey/apps//bin/`. +fn doctor_plugin_registry() -> Vec { + vec![ + DoctorPlugin { + label: "trust-local", + rel_path: ".aikey/bin/trust-local", + install_slug: "degrade-detector", + is_daemon: true, + }, + DoctorPlugin { + label: "compliance-detector", + rel_path: ".aikey/apps/ai-compliance-detector/bin/ai-compliance-detector", + install_slug: "ai-compliance-detector", + is_daemon: false, + }, + DoctorPlugin { + label: "compliance-deep-scan", + rel_path: ".aikey/apps/ai-compliance-deep-scan/bin/ai-compliance-deep-scan", + install_slug: "ai-compliance-deep-scan", + is_daemon: false, + }, + ] +} + pub fn handle_doctor(json_mode: bool) -> Result<(), Box> { use colored::Colorize; use std::time::Instant; @@ -597,7 +666,8 @@ pub fn handle_doctor(json_mode: bool) -> Result<(), Box> } } else { let icon = if ok { "✓".green() } else { "✗".red() }; - println!("{} {:<18} {}", icon, label, detail); + // Label column width 20 = longest label (`compliance-deep-scan`). + println!("{} {:<20} {}", icon, label, detail); if let Some(h) = hint { println!(" {}", format!("↳ {}", h).dimmed()); } @@ -632,6 +702,16 @@ pub fn handle_doctor(json_mode: bool) -> Result<(), Box> }; emit("cli version", true, &cli_str, None); + // Edition banner (B1): state up-front which edition doctor is + // diagnosing so every row below has context and support can tell a + // Personal host from a Trial one at a glance. `aikey doctor` is a + // client-side tool — it runs on Personal (local-server or CLI-only) + // and Trial (full-trial) hosts; Production is a server-install + // deployment managed on the server, not via this CLI. detect_edition + // reads install-state.json's installed_components (single source). + let doctor_edition = crate::local_server_probe::detect_edition(); + emit("edition", true, doctor_edition_label(doctor_edition), None); + // Probe proxy /version let proxy_port = crate::commands_proxy::proxy_port(); let proxy_url = format!("http://127.0.0.1:{}/version", proxy_port); @@ -1015,21 +1095,36 @@ pub fn handle_doctor(json_mode: bool) -> Result<(), Box> } } - // ── 7.5. degrade-detector trust-local + rhythm observer ── + // ── 7.5. Optional first-party plugins ──────────────────── // - // Optional service. Two independent checks: - // a) trust-local HTTP /healthz on :8801 — service running? - // b) aikey-proxy's rhythm observer — was it built? (silent - // failure mode is the proxy boots fine but the observer - // dies due to missing DEGRADE_DETECTOR_PROXY_TOKEN / - // ALLOW_DIRECT_AUTH env). doctor used to be blind to this. + // The `aikey app install ` plugins (registry: FIRST_PARTY_SLUGS). + // Two shapes, checked differently: + // • degrade-detector → trust-local: a DAEMON (:8801). Probe /healthz + // for liveness + read the proxy's rhythm-observer build verdict. + // • ai-compliance-detector / -deep-scan: stdin/stdout SUBPROCESSES the + // proxy spawns per request — there is no port to probe, so the only + // honest signal is "binary installed?" (presence = the proxy can + // spawn it). deep-scan is the heavy opt-in semantic layer. // - // Skip the section entirely when trust-local binary isn't - // installed — many users never opt into degrade-detector and - // shouldn't see "✗" rows for a feature they didn't choose. + // Why show not-installed rows now (previously the whole section was + // skipped when trust-local was absent): a health *dashboard* should say + // which optional capabilities exist and which are off, not go silent — + // otherwise a user can't tell "compliance filtering isn't running" from + // "doctor doesn't check it". Not-installed renders dim/informational and + // never bubbles to the overall pass/fail (these are opt-in). { - let trust_local_bin = std::path::Path::new(&std::env::var("HOME").unwrap_or_default()) - .join(".aikey/bin/trust-local"); + let home = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()); + let plugins = doctor_plugin_registry(); + // Paths come from the registry (single source shared with the + // consistency test); trust-local keeps its bespoke daemon+observer + // logic, the subprocess filters loop through a generic presence check. + let trust_local_plugin = plugins + .iter() + .find(|p| p.is_daemon) + .expect("registry has the trust-local daemon entry"); + + // ── degrade-detector / trust-local (daemon) ── + let trust_local_bin = home.join(trust_local_plugin.rel_path); if trust_local_bin.exists() { // (a) trust-local service liveness. let trust_local_url = "http://127.0.0.1:8801/healthz"; @@ -1099,6 +1194,36 @@ pub fn handle_doctor(json_mode: bool) -> Result<(), Box> ); } } + } else { + let hint = format!( + "enable: aikey app install {}", + trust_local_plugin.install_slug + ); + emit( + trust_local_plugin.label, + true, + "not installed (optional degrade-detector plugin)", + Some(&hint), + ); + } + + // ── compliance filters (subprocess, presence-only) ── + // The proxy spawns these per request (stdin/stdout frames), so there + // is no port to probe — presence of the binary is the honest signal + // that the proxy *can* spawn it. Looped from the registry's + // non-daemon entries so adding a filter app is a one-line table edit. + for p in plugins.iter().filter(|p| !p.is_daemon) { + if home.join(p.rel_path).exists() { + emit(p.label, true, "installed (proxy-spawned filter)", None); + } else { + let hint = format!("enable: aikey app install {}", p.install_slug); + emit( + p.label, + true, + "not installed (optional compliance filter)", + Some(&hint), + ); + } } } @@ -1376,7 +1501,8 @@ pub fn handle_doctor(json_mode: bool) -> Result<(), Box> } else { "⚠".yellow() }; - println!("{} {:<18} {}", icon, label, detail); + // Label column width 20 = longest label (`compliance-deep-scan`). + println!("{} {:<20} {}", icon, label, detail); if let Some(ref h) = hint { println!(" {}", format!("↳ Start: {}", h).dimmed()); } @@ -1816,17 +1942,40 @@ pub fn handle_doctor_detail() -> Result<(), Box> { use colored::Colorize; let dim_rule = "─".repeat(52).dimmed(); + // B2: the first two panels read the Trial-server ODS pipeline + // (control-trial.db / .log) and are meaningful ONLY on a Trial host. + // On Personal (local-server or CLI-only) that pipeline doesn't exist, so + // instead of the misleading "trial DB not present — trial-server hasn't + // run" (which implies it *should* have), state clearly that the panel is + // N/A for this edition. The 4xx-captures panel reads aikey-proxy's log, + // which every edition has, so it always runs. + // + // Production note: `aikey doctor` is client-side; a Production server's + // ODS lives in PostgreSQL on the server host, not reachable from this + // CLI — the ODS panels there also render as N/A with a pointer. + let edition = crate::local_server_probe::detect_edition(); + let is_trial = matches!(edition, Some(crate::local_server_probe::Edition::Trial)); + let edition_na_note = doctor_detail_ods_na_note(edition); + println!(); println!("{}", dim_rule); println!("{}", "🔍 Recent failures (last 5 ODS errors)".bold()); - println!("{} {}", dim_rule, "from control-trial.db".dimmed()); - render_recent_failures(); + if is_trial { + println!("{} {}", dim_rule, "from control-trial.db".dimmed()); + render_recent_failures(); + } else { + println!(" {}", edition_na_note.dimmed()); + } println!(); println!("{}", dim_rule); println!("{}", "🔍 Ingest health (signal-based)".bold()); - println!("{} {}", dim_rule, "from control-trial.log".dimmed()); - render_ingest_health(); + if is_trial { + println!("{} {}", dim_rule, "from control-trial.log".dimmed()); + render_ingest_health(); + } else { + println!(" {}", edition_na_note.dimmed()); + } println!(); println!("{}", dim_rule); @@ -2397,6 +2546,55 @@ mod doctor_detail_tests { assert_eq!(format_local_time_hms(0), "?"); assert!(!format_local_time_hms(1_700_000_000_000).contains('?')); } + + // ── B1: edition banner mapping ────────────────────────────────── + #[test] + fn doctor_edition_label_covers_all_editions() { + use crate::local_server_probe::Edition; + assert!(doctor_edition_label(Some(Edition::Trial)).starts_with("Trial")); + assert!(doctor_edition_label(Some(Edition::Personal)).starts_with("Personal")); + // CLI-only host (no local web service installed). + assert!(doctor_edition_label(None).contains("CLI-only")); + } + + // ── B2: --detail ODS panel N/A note by edition ────────────────── + #[test] + fn doctor_detail_ods_na_note_empty_only_for_trial() { + use crate::local_server_probe::Edition; + // Trial renders real ODS data → no N/A note. + assert_eq!(doctor_detail_ods_na_note(Some(Edition::Trial)), ""); + // Non-Trial editions get a clear "not applicable" note (never the + // misleading "trial-server hasn't run" wording). + assert!(doctor_detail_ods_na_note(Some(Edition::Personal)).contains("not applicable")); + assert!(doctor_detail_ods_na_note(None).contains("not applicable")); + } + + // ── A2: plugin registry stays in lockstep with the app registry ── + #[test] + fn doctor_plugin_registry_matches_first_party_slugs() { + use crate::commands_app::FIRST_PARTY_SLUGS; + let plugins = doctor_plugin_registry(); + // Every first-party app must have a doctor row — otherwise a newly + // launched plugin is silently un-diagnosed. Compared as sorted sets + // of install slugs. + let mut doctor_slugs: Vec<&str> = plugins.iter().map(|p| p.install_slug).collect(); + doctor_slugs.sort_unstable(); + let mut app_slugs: Vec<&str> = FIRST_PARTY_SLUGS.to_vec(); + app_slugs.sort_unstable(); + assert_eq!( + doctor_slugs, app_slugs, + "doctor_plugin_registry() and FIRST_PARTY_SLUGS drifted — add the new plugin to doctor" + ); + } + + #[test] + fn doctor_plugin_registry_exactly_one_daemon() { + // trust-local is the only daemon plugin (own port); the compliance + // filters are subprocesses. The §7.5 code `.find(|p| p.is_daemon)` + // relies on this — pin it so a future daemon plugin forces a review. + let plugins = doctor_plugin_registry(); + assert_eq!(plugins.iter().filter(|p| p.is_daemon).count(), 1); + } } // --------------------------------------------------------------------------- diff --git a/src/commands_proxy.rs b/src/commands_proxy.rs index 82f6c4f..c5df3f0 100644 --- a/src/commands_proxy.rs +++ b/src/commands_proxy.rs @@ -1816,6 +1816,29 @@ pub fn doctor_proxy_status() -> (bool, Option) { } } +/// Compact one-line status for the `aikey service status` aggregate. +/// Returns `(running, "")`. Reuses the same `proxy_state()` truth +/// source as `handle_status()` so the aggregate can never disagree with +/// `aikey proxy status` — only the verbosity differs. +pub fn status_summary() -> (bool, String) { + use crate::proxy_state::{proxy_state, ProxyState}; + let addr = proxy_listen_addr(None); + match proxy_state(&addr) { + ProxyState::Running { + pid, listen_addr, .. + } => (true, format!("running on http://{listen_addr} (pid {pid})")), + ProxyState::Stopped => (false, "stopped".to_string()), + ProxyState::Crashed { stale_pid } => (false, format!("stopped (stale pid {stale_pid})")), + ProxyState::Unresponsive { pid, port } => ( + false, + format!("unresponsive on :{port} (pid {pid}, /health not answering)"), + ), + ProxyState::OrphanedPort { port, .. } => { + (false, format!("port :{port} held by a foreign process")) + } + } +} + // ─────────────────────────────────────────────────────────────────────────── // Tests — resolve_config (regression net for the 2026-05-21 cwd-first // deprecation: see config-split-system-user.md v9 + bugfix 20260521). diff --git a/src/commands_service/commands.rs b/src/commands_service/commands.rs index 532a494..17fd194 100644 --- a/src/commands_service/commands.rs +++ b/src/commands_service/commands.rs @@ -31,12 +31,18 @@ pub(crate) fn handle_service( ServiceAction::Start { name } => ("start", name), ServiceAction::Stop { name } => ("stop", name), ServiceAction::Restart { name } => ("restart", name), + ServiceAction::Status { name } => ("status", name), }; let Some(name) = name.as_deref() else { - // Bare `aikey service start` (no name) — print the list and - // exit non-error. Users typically reach this by tab-completing - // and forgetting to pass a name. + // No name given. For the read-only `status` verb this means "show + // every service at once" (the aggregate dashboard). For the + // mutating verbs there's no safe all-services default, so we print + // the supported list instead (users usually land here by tab- + // completing and forgetting the name). + if verb == "status" { + return status_all(json); + } print_supported(json); return Ok(()); }; @@ -87,8 +93,47 @@ fn print_supported(json: bool) { println!(" {:<14} {}", name, label); } println!(); - println!("Usage: aikey service "); + println!("Usage: aikey service "); + } +} + +/// `aikey service status` (no name) — one-line status for every registered +/// service. Each row delegates to that service's own compact probe +/// (`status_summary`), so this aggregate is a pure view: it never owns a +/// second copy of any service's health logic and can't drift from the +/// per-service `status` commands. +fn status_all(json: bool) -> Result<(), Box> { + let rows: Vec<(&str, bool, String)> = vec![ + { + let (r, d) = crate::local_server_probe::status_summary(); + ("web", r, d) + }, + { + let (r, d) = crate::commands_proxy::status_summary(); + ("proxy", r, d) + }, + { + let (r, d) = trust_local::status_summary(); + ("trust-local", r, d) + }, + ]; + + if json { + let payload: Vec<_> = rows + .iter() + .map(|(name, running, detail)| { + serde_json::json!({"name": name, "running": running, "detail": detail}) + }) + .collect(); + println!("{}", serde_json::json!({"services": payload})); + } else { + for (name, running, detail) in &rows { + // Aligned two-column table: fixed-width name, state glyph, detail. + let glyph = if *running { "●" } else { "○" }; + println!("{glyph} {name:<12} {detail}"); + } } + Ok(()) } // ─────────────────────────────────────────────────────────────────── @@ -105,7 +150,63 @@ mod trust_local { const SERVICE_NAME: &str = "aikey.trust-local"; + /// Compact one-line status for the aggregate + the `status` verb. + /// Read-only: a single-shot healthz probe on :8801 (no 30s wait — that + /// belongs to post-start). "not installed" is a status, not an error. + pub(super) fn status_summary() -> (bool, String) { + if !aikey_home_bin_path().exists() { + return (false, "not installed".to_string()); + } + if healthz_once() { + (true, "running on http://127.0.0.1:8801".to_string()) + } else { + (false, "not running".to_string()) + } + } + + /// Single-shot healthz check (no retry loop). Distinct from + /// `probe_healthz`, which waits up to 30s for a service that was just + /// asked to start. + fn healthz_once() -> bool { + ureq::get("http://127.0.0.1:8801/healthz") + .timeout(Duration::from_millis(500)) + .call() + .map(|r| r.status() == 200) + .unwrap_or(false) + } + + fn status_detail(json: bool) -> Result<(), Box> { + let (running, detail) = status_summary(); + if json { + println!( + "{}", + serde_json::json!({ + "ok": true, + "service": SERVICE_NAME, + "running": running, + "detail": detail, + }) + ); + } else if running { + println!("{}: {}", SERVICE_NAME, detail); + } else { + println!("{}: {}", SERVICE_NAME, detail); + if detail == "not installed" { + println!(" Install: aikey app install degrade-detector"); + } else { + println!(" Start: aikey service start trust-local"); + } + } + Ok(()) + } + pub(super) fn dispatch(verb: &str, json: bool) -> Result<(), Box> { + // Read-only status short-circuits before the install check: on a + // host without trust-local, "not installed" is the answer we want to + // print, not a hard error like the mutating verbs raise. + if verb == "status" { + return status_detail(json); + } // Refuse if trust-local binary isn't installed. Since rc.5 it's // installed by default — if it's missing the user either ran // `--no-degrade-detector` at install time or `aikey app @@ -353,6 +454,12 @@ mod web { //! don't replicate any logic. pub(super) fn dispatch(verb: &str, json: bool) -> Result<(), Box> { + // status is read-only and lives in its own handler; the mutating + // verbs go through the existing lifecycle controller. Both spellings + // (`aikey web status` and `aikey service status web`) land here. + if verb == "status" { + return crate::commands_account::handle_web_status(json); + } crate::commands_account::handle_web_service(verb, json) } } @@ -370,10 +477,31 @@ mod proxy { pub(super) fn dispatch( verb: &str, - _json: bool, + json: bool, password_stdin: bool, ) -> Result<(), Box> { match verb { + "status" => { + // Read-only: no vault password. Human path delegates to the + // exact same `handle_status()` as `aikey proxy status` (byte- + // identical output); JSON path reuses `status_summary()`, + // which derives from the same proxy_state() truth source. + if json { + let (running, detail) = crate::commands_proxy::status_summary(); + println!( + "{}", + serde_json::json!({ + "ok": true, + "service": "proxy", + "running": running, + "detail": detail, + }) + ); + } else { + crate::commands_proxy::handle_status()?; + } + Ok(()) + } "start" => { // prompt_vault_password lives in main.rs as a thin // wrapper around executor::prompt_password; we @@ -445,4 +573,39 @@ mod tests { assert!(!is_supported(" web")); assert!(!is_supported("web ")); } + + // ── status verb ──────────────────────────────────────────────── + + #[test] + fn status_summary_shape_per_service() { + // Each service's compact probe returns (bool, non-empty detail). + // We can't assert the running state (depends on the host), but the + // contract that the aggregate relies on is: always a printable + // detail line, never an empty string or a panic. + for (_, detail) in [ + crate::local_server_probe::status_summary(), + crate::commands_proxy::status_summary(), + trust_local::status_summary(), + ] { + assert!(!detail.is_empty(), "status detail must be non-empty"); + } + } + + #[test] + fn trust_local_status_summary_not_installed_is_status_not_error() { + // The whole point of status short-circuiting the install check: + // on a host without the binary, "not installed" is a legitimate + // (running=false) status, never an Err. This test only holds when + // trust-local isn't installed on the test host; when it is, the + // detail is a running/not-running line — either way non-empty and + // no panic. (Kept assertion loose so it passes on both kinds of + // host / CI.) + let (running, detail) = trust_local::status_summary(); + if !running { + assert!( + detail == "not installed" || detail == "not running", + "unexpected trust-local down-detail: {detail}" + ); + } + } } diff --git a/src/commands_statusline.rs b/src/commands_statusline.rs index 3707416..f54131c 100644 --- a/src/commands_statusline.rs +++ b/src/commands_statusline.rs @@ -1970,7 +1970,10 @@ mod tests { let path = dir.join("sync-health.json"); std::fs::write(&path, "{not-json").unwrap(); - assert!(sync_health_line().is_none(), "malformed file must not render"); + assert!( + sync_health_line().is_none(), + "malformed file must not render" + ); std::fs::write(&path, r#"{"rails":{},"written_at":1}"#).unwrap(); assert!(sync_health_line().is_none(), "empty rails must not render"); diff --git a/src/local_server_probe.rs b/src/local_server_probe.rs index 90203d5..0320c3e 100644 --- a/src/local_server_probe.rs +++ b/src/local_server_probe.rs @@ -585,6 +585,104 @@ fn find_listening_pid(port: u16) -> Option { /// - "local-server: running on port N (vault: locked|unlocked)" /// - "local-server: NOT RUNNING on port N\n Start: " /// - "local-server: NOT CONFIGURED — " +/// +/// Compact one-line status for the `aikey service status` aggregate. +/// Returns `(running, "")`. Reuses the same port discovery + +/// `probe_vault_status` truth source as `local_server_status_line()` so the +/// aggregate stays consistent with `aikey web status`; only verbosity differs. +/// A host without local-server installed reports `(false, "not installed")` +/// rather than an error — "not installed" is a legitimate status. +pub fn status_summary() -> (bool, String) { + if !is_local_server_installed() { + return (false, "not installed".to_string()); + } + match read_local_server_port_or_default() { + Err(e) => (false, format!("not configured — {}", e)), + Ok(port) => { + let base = format!("http://127.0.0.1:{}", port); + match probe_vault_status(&base) { + Ok(unlocked) => ( + true, + format!( + "running on http://127.0.0.1:{} (vault: {})", + port, + if unlocked { "unlocked" } else { "locked" } + ), + ), + Err(_) => (false, format!("not running (would use port {})", port)), + } + } + } +} + +// ── launchd supervision self-heal (§S5, 2026-07-04) ───────────────────────── +// Observed live (2026-07-04): `make restart-personal` left the launchd agent +// silently unloaded — 8090 dead, `launchctl print` "Could not find service", +// zero-byte logs — while the binary itself ran fine when started by hand. A +// status probe that already KNOWS the server is down can heal that state +// instead of only printing a hint (owner: recovery must be self-healing and +// idempotent, no manual steps). + +/// Pure decision core (unit-tested): heal ONLY when the plist exists (the +/// installer wired supervision) AND launchd does NOT know the service. A +/// service launchd knows about but that is down is crashed/throttled — its +/// KeepAlive/ThrottleInterval owns the restart; fighting it would flap. +pub(crate) fn launchd_selfheal_applicable( + plist_exists: bool, + service_known_to_launchd: bool, +) -> bool { + plist_exists && !service_known_to_launchd +} + +/// macOS: try to re-bootstrap the local-server launchd agent when the probe +/// says it is down. Best-effort and idempotent (`launchctl bootstrap` of an +/// already-known service just fails → None). Returns the recovered status +/// line on success; None → caller prints the normal NOT RUNNING hint. +#[cfg(target_os = "macos")] +fn try_launchd_selfheal(base: &str, port: u16) -> Option { + use std::process::Command; + let plist = + dirs::home_dir()?.join("Library/LaunchAgents/com.aikeylabs.aikey-local-server.plist"); + let uid = String::from_utf8(Command::new("id").arg("-u").output().ok()?.stdout) + .ok()? + .trim() + .to_string(); + let label = format!("gui/{}/com.aikeylabs.aikey-local-server", uid); + let known = Command::new("launchctl") + .args(["print", &label]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !launchd_selfheal_applicable(plist.exists(), known) { + return None; + } + let ok = Command::new("launchctl") + .args(["bootstrap", &format!("gui/{}", uid), plist.to_str()?]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !ok { + return None; + } + // Give launchd a moment, then re-probe — only claim recovery on evidence. + for _ in 0..10 { + std::thread::sleep(std::time::Duration::from_millis(200)); + if let Ok(unlocked) = probe_vault_status(base) { + return Some(format!( + "local-server: recovered on port {} (vault: {}) — launchd agent was unloaded; re-bootstrapped automatically", + port, + if unlocked { "unlocked" } else { "locked" } + )); + } + } + None +} + +#[cfg(not(target_os = "macos"))] +fn try_launchd_selfheal(_base: &str, _port: u16) -> Option { + None // supervision differs per platform; non-macOS keeps the manual hint +} + pub fn local_server_status_line() -> String { // `_or_default`: if the host has local-server installed but the YAML // hasn't been rendered, falling back to the canonical default port @@ -602,6 +700,11 @@ pub fn local_server_status_line() -> String { if unlocked { "unlocked" } else { "locked" } ), Err(_) => { + // §S5 self-heal: an unloaded launchd agent is recoverable + // right here — only fall back to the hint when it isn't. + if let Some(recovered) = try_launchd_selfheal(&base, port) { + return recovered; + } let hint = start_command_hint(); format!( "local-server: NOT RUNNING on port {}\n Start: {}", @@ -1439,3 +1542,30 @@ mod tests { assert!(line.contains("NOT CONFIGURED"), "got: {}", line); } } + +#[cfg(test)] +mod launchd_selfheal_tests { + use super::launchd_selfheal_applicable; + + // §S5 decision core: heal ONLY the "installer wired supervision but launchd + // lost the service" state. 能红: invert either condition and this fails. + #[test] + fn heals_only_when_plist_exists_and_service_unknown() { + assert!( + launchd_selfheal_applicable(true, false), + "unloaded agent + plist → heal" + ); + assert!( + !launchd_selfheal_applicable(true, true), + "launchd owns a crashed service (KeepAlive/Throttle) → do not fight it" + ); + assert!( + !launchd_selfheal_applicable(false, false), + "no plist → nothing to bootstrap (not installed as agent)" + ); + assert!( + !launchd_selfheal_applicable(false, true), + "inconsistent state → hands off" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 98a4c68..860e80f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3609,6 +3609,12 @@ fn run_command(cli: &Cli) -> Result<(), Box> { // not meaningful for service actions, so we ignore them on // that branch. if let Some(action) = page.as_deref() { + // `status` is read-only (no vault password, no spawn) so it + // routes to its own handler, not handle_web_service. + if action == "status" { + commands_account::handle_web_status(cli.json)?; + return Ok(()); + } if matches!(action, "start" | "stop" | "restart") { commands_account::handle_web_service(action, cli.json)?; return Ok(()); diff --git a/src/storage_platform.rs b/src/storage_platform.rs index 3939a26..1bf53b5 100644 --- a/src/storage_platform.rs +++ b/src/storage_platform.rs @@ -253,6 +253,23 @@ pub fn set_active_key_config(cfg: &ActiveKeyConfig) -> Result<(), String> { /// NOTE: Prefer `disable_keys_for_account_scope` on account switch — /// it keeps the ciphertext rows so the previous account can access them again /// after re-login, while still preventing the new account from using those keys. +/// Deletes ONE row from `managed_virtual_keys_cache` by primary key. +/// Used by the sync prune (2026-07-04 self-heal): a key the server no longer +/// returns for the OWNING account is removed outright — keeping it as a +/// `stale` row rendered a permanent "revoked/inactive" ghost in /user/vault +/// and, for deterministic group-VK aliases, shadowed the re-issued key. +/// Callers are responsible for the owner-scope guard (other accounts' rows +/// keep the re-login recovery semantics documented on clear_virtual_key_cache). +pub fn delete_virtual_key_cache_row(virtual_key_id: &str) -> Result<(), String> { + let conn = open_connection()?; + conn.execute( + "DELETE FROM managed_virtual_keys_cache WHERE virtual_key_id = ?1", + params![virtual_key_id], + ) + .map_err(|e| format!("Failed to delete virtual key cache row: {}", e))?; + Ok(()) +} + pub fn clear_virtual_key_cache() -> Result<(), String> { let conn = open_connection()?; conn.execute("DELETE FROM managed_virtual_keys_cache", []) From d3582c7759dc98b89020bfe15ccba6e667cd31f6 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 4 Jul 2026 03:14:55 -0700 Subject: [PATCH 18/42] feat: OAuth pool codex provider support (R34) + gateway-unreachable degradation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] group-VK usability by routed-account login state (RoutedState: LoggedIn/NeedsLogin/NoCandidate → active/needs_login/inactive, 待登录防呆); help-text reorg + `aikey status`; commands_service status_summary fix Refs: - Spec: 组VK待登录状态防呆: https://github.com/aikeylabs/roadmap20260320/blob/845685224f712c9c1e511f13f8d32d3032b8d567/技术实现/update/20260703-组VK待登录状态防呆.md --- README.md | 2 +- README.zh.md | 2 +- src/cli.rs | 53 ++++++----- src/commands_internal/query.rs | 157 +++++++++++++++++++++++-------- src/commands_service/commands.rs | 8 ++ 5 files changed, 157 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 8722f87..a17d7c9 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ aikey audit status # per-source: allocated / confirmed aikey audit reconcile # actively reconcile now: re-send recoverable gaps, confirm true losses ``` -Run `aikey --help` for the full subcommand list (display order = frequency, frequent first). +Run `aikey --help` for the full subcommand list (alphabetical, with a "Frequently used" shortcut section at the end). ## 10. Error Codes diff --git a/README.zh.md b/README.zh.md index ef00060..4798f6c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -231,7 +231,7 @@ aikey audit status # 按源看:已分配 / 已确认 aikey audit reconcile # 立即主动对账:补传可恢复缺口、确认真实丢失 ``` -`aikey --help` 看全部子命令(显示顺序 = 使用频率,高频在前)。 +`aikey --help` 看全部子命令(按字母序排列,末尾附「Frequently used」高频命令快捷区)。 ## 10. 错误码 diff --git a/src/cli.rs b/src/cli.rs index 70ac142..6d53c20 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1738,46 +1738,53 @@ AiKey - Secure local-first secret management Usage: aikey [OPTIONS] [COMMAND] Commands: + {b}account{r} Manage your aikey account session + {b}activate{r} Temporarily activate a key in the current terminal {b}add{r} Save a new secret to the vault {b}auth{r} Manage provider OAuth accounts (Claude, Codex, Kimi) - {b}list{r} Show all personal, team, and OAuth keys (alias for `key list`) - {b}test{r} Test whether a stored API key alias is working - {b}use{r} [alias] Select the active key for routing (shortcut for `key use`) - {b}unuse{r} Remove the active binding for one or more providers - {b}activate{r} Temporarily activate a key in the current terminal + {b}change-password{r} Change the vault master password {b}deactivate{r} Restore global settings in the current terminal - {b}route{r} [label] Show proxy route tokens for third-party AI clients - {b}login{r} Log in to aikey service (shortcut for `account login`) - {b}web{r} [page] Open the User Console in your default browser - {b}master{r} [page] Open the Master Console (admin) in your default browser + {b}delete{r} Delete a secret from the vault {b}doctor{r} Check system health, connectivity, and configuration {b}env{r} [command] View or set proxy environment variables - {b}proxy{r} Manage the local proxy process - {b}status{r} Show a summary of gateway, login, keys, and providers - {b}whoami{r} Show your current login, active key, and vault status + {b}export{r} Export secrets to an encrypted backup file {b}get{r} Retrieve a secret and copy it to the clipboard - {b}run{r} -- Run a command with secrets injected as environment variables + {b}help{r} Show this help message or help for a command {b}key{r} Manage API keys (rotate, list, sync, use) - {b}quickstart{r} Show a state-aware landing page with the next most useful commands - {b}project{r} Manage project configuration - {b}logs{r} Show recent activity logs - {b}update{r} Update an existing secret - {b}delete{r} Delete a secret from the vault - {b}export{r} Export secrets to an encrypted backup file - {b}change-password{r} Change the vault master password - {b}account{r} Manage your aikey account session + {b}list{r} Show all personal, team, and OAuth keys (alias for `key list`) + {b}login{r} Log in to aikey service (shortcut for `account login`) {b}logout{r} Log out of the current session (shortcut for `account logout`) + {b}logs{r} Show recent activity logs + {b}master{r} [page] Open the Master Console (admin) in your default browser + {b}project{r} Manage project configuration + {b}proxy{r} Manage the local proxy process + {b}quickstart{r} Show a state-aware landing page with the next most useful commands + {b}route{r} [label] Show proxy route tokens for third-party AI clients + {b}run{r} -- Run a command with secrets injected as environment variables {b}secret{r} Manage secrets and platform-backed secret actions + {b}status{r} Show a summary of gateway, login, keys, and providers {b}statusline{r} Render a one-line usage receipt for Claude Code's status line + {b}test{r} Test whether a stored API key alias is working + {b}unuse{r} Remove the active binding for one or more providers + {b}update{r} Update an existing secret + {b}use{r} [alias] Select the active key for routing (shortcut for `key use`) {b}watch{r} Show a top-style dashboard of recent key usage - {b}help{r} Show this help message or help for a command + {b}web{r} [page] Open the User Console in your default browser + {b}whoami{r} Show your current login, active key, and vault status Options: --password-stdin Read password from stdin instead of prompting --json Output in JSON format (where supported) -V, --version Print version information -h, --help Print help - --detail Print detailed help for all commands" + --detail Print detailed help for all commands + +Frequently used: + {b}use{r} [alias] Select the active key for routing (shortcut for `key use`) + {b}login{r} Log in to aikey service (shortcut for `account login`) + {b}doctor{r} Check system health, connectivity, and configuration + {b}web{r} [page] Open the User Console in your default browser + {b}master{r} [page] Open the Master Console (admin) in your default browser" ); } diff --git a/src/commands_internal/query.rs b/src/commands_internal/query.rs index 756a4e0..16a5a57 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -337,23 +337,36 @@ fn merge_group_accounts_live( Some(Value::Array(merged)) } -/// Whether the group VK's CURRENT ROUTED account — the one the remote scheduling -/// engine selected — is logged in. Drives the group-VK usability overlay on -/// effective_status (owner rule 2026-06-30): a claimed+active group VK is only usable -/// when the account it actually routes to has a token; otherwise a request goes to -/// that account and fails (GROUP_NO_CANDIDATES for an empty set, LOGIN_REQUIRED when -/// the routed account is needs_login) — so it must read inactive (hides the web `use` -/// button + matches aikey use/list). +/// The usability state of a group VK's CURRENT ROUTED account — the one the remote +/// scheduling engine selected. Drives the group-VK overlay on effective_status +/// (owner rule 2026-06-30, refined 2026-07-03): a claimed+active group VK is only +/// usable when the account it actually routes to has a token. The refinement splits +/// the old single "inactive" verdict into two, so the member sees the actionable +/// case distinctly (防呆): +/// - `LoggedIn` → the routed account has a token → VK is `active`. +/// - `NeedsLogin` → the routed account exists but the member hasn't logged in → +/// VK reads `needs_login` (not a scary red `inactive`): a request would return +/// LOGIN_REQUIRED, but the fix is one login, not an admin action. The web vault +/// page shows an amber "待登录" chip + a login CTA (→ /user/team-oauth) instead of +/// a dead red row. +/// - `NoCandidate` → empty set / no routed account resolvable → VK reads `inactive` +/// (genuinely unusable — GROUP_NO_CANDIDATES; nothing to log into). /// /// Routed account = the candidate the proxy flagged `current_routed` (engine override /// ?? rank-0). Before the proxy has stamped it (brief post-sync window), fall back to /// the static default (`assigned` = master rank-0) — which IS the routed account when -/// there is no engine override. Empty set / no routed account resolvable → false -/// (unusable). Being logged into a DIFFERENT, non-routed account does NOT count: the -/// engine decides where traffic goes, and that account is what needs a token. -fn routed_candidate_logged_in(merged: Option<&serde_json::Value>) -> bool { +/// there is no engine override. Being logged into a DIFFERENT, non-routed account does +/// NOT count: the engine decides where traffic goes, and that account is what needs a token. +#[derive(PartialEq, Eq, Debug)] +enum RoutedState { + LoggedIn, + NeedsLogin, + NoCandidate, +} + +fn routed_candidate_state(merged: Option<&serde_json::Value>) -> RoutedState { let Some(arr) = merged.and_then(|v| v.as_array()) else { - return false; + return RoutedState::NoCandidate; }; let flagged = |c: &&serde_json::Value, key: &str| c.get(key).and_then(|b| b.as_bool()) == Some(true); @@ -361,7 +374,37 @@ fn routed_candidate_logged_in(merged: Option<&serde_json::Value>) -> bool { .iter() .find(|c| flagged(c, "current_routed")) .or_else(|| arr.iter().find(|c| flagged(c, "assigned"))); - routed.and_then(|c| c.get("login_status").and_then(|s| s.as_str())) == Some("logged_in") + match routed { + None => RoutedState::NoCandidate, + Some(c) => { + if c.get("login_status").and_then(|s| s.as_str()) == Some("logged_in") { + RoutedState::LoggedIn + } else { + RoutedState::NeedsLogin + } + } + } +} + +/// The protocol source for a team VK's `protocol_family`, priority ordered: +/// VK-level `provider_code` (direct VKs / present) → `protocol_type` (2026-07-03). +/// +/// **Why the protocol_type fallback**: a group VK's `provider_code` is ALWAYS empty +/// (it binds a group, not one provider). Normally the web recovers the protocol from +/// the routed pool account's provider — but when the seat is unbound from the group +/// the candidate set (`group_accounts`) AND `supported_providers` both go empty, so +/// there is nothing account-derived left and the protocol column falls to "unknown" +/// for the orphaned VK. `protocol_type` comes from the VK's group BINDING (not the +/// accounts), so it SURVIVES member removal ("anthropic" stays) — the stable source. +/// `None` only when both are empty (truly no protocol info) → caller renders "unknown". +fn team_protocol_source<'a>(provider_code: &'a str, protocol_type: &'a str) -> Option<&'a str> { + if !provider_code.is_empty() { + Some(provider_code) + } else if !protocol_type.is_empty() { + Some(protocol_type) + } else { + None + } } fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec { @@ -381,18 +424,21 @@ fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec "active", + RoutedState::NeedsLogin => "needs_login", + RoutedState::NoCandidate => "inactive", + } } else { base } @@ -420,7 +466,7 @@ fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec Result<(), Box> { + // `installed` is an explicit field (not inferred from `detail`) so + // consumers — notably the web trust-check banner via the console — + // can distinguish "not installed" from "installed but stopped" + // without string-matching the human detail. Same binary-path truth + // source as status_summary(). Bugfix: + // 20260703-trust-check-web-offline-vs-notinstalled-proactive.md. + let installed = aikey_home_bin_path().exists(); let (running, detail) = status_summary(); if json { println!( @@ -183,6 +190,7 @@ mod trust_local { serde_json::json!({ "ok": true, "service": SERVICE_NAME, + "installed": installed, "running": running, "detail": detail, }) From 28e68244506cbf9ea0140b5aed1d3a4c2e6bfb97 Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 6 Jul 2026 04:10:45 -0700 Subject: [PATCH 19/42] feat: Windows platform-compat hardening + Kimi/Codex CLI integration + cross-app menu fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] Kimi CLI shell integration; codex probe-model self-healing (codex_last_model, self-heals across codex-cli upgrades); unified local-origin console; kimi receipt observability events Refs: - Bugfix: kimi-config-hooks-duplicate-key: https://github.com/aikeylabs/workflow/blob/dceaf38d54b507bfe2145c48fd3713aeea4f4ac9/CI/bugfix/2026-07-04-kimi-config-hooks-duplicate-key.md - Spec: web统一origin本地网关: https://github.com/aikeylabs/roadmap20260320/blob/15c9ff4cb3ddafaf06a960c1fd1e02a344bda2cf/技术实现/update/20260703-web统一origin-本地网关方案.md --- Cargo.lock | 1 + Cargo.toml | 1 + Makefile | 14 + src/commands_account/mod.rs | 111 ++ src/commands_account/shell_integration.rs | 1233 ++++++++++++--------- src/commands_project.rs | 40 + src/commands_service/commands.rs | 46 +- src/commands_statusline.rs | 134 ++- src/connectivity/protocol_addons.rs | 11 +- src/main.rs | 26 +- src/observability.rs | 31 + 11 files changed, 1111 insertions(+), 537 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4318dd6..bdfae9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,7 @@ dependencies = [ "sha2", "shell-words", "tempfile", + "toml_edit", "ureq", "windows-sys 0.52.0", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index 2164408..39fb5a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,6 +136,7 @@ regex = "1.11" # Text pattern matching (commands_internal/parse.rs) bitflags = "2" # LineFlags 属性矩阵 (parse/line_class.rs, v4.1 spike 迁移 Stage 1) crfs = "0.4" # Pure-Rust CRFsuite port (Stage 3 parse Layer 2; ablation-spike verified determinism) serde_yaml = "0.9" # Provider fingerprint registry (Stage 3 parse Layer 3; embedded via include_str!) +toml_edit = "0.19" # Format-preserving TOML parse-merge for third-party CLI config injection (kimi/codex). Already in Cargo.lock as a transitive dep, so promoting it adds zero new transitive deps. Why not string-append: a foreign writer (e.g. kimi's own `hooks = []`) plus our blind append produced duplicate-key invalid TOML — see workflow/CI/bugfix. Why toml_edit over `toml`: preserves the user's comments/key-ordering when we edit their file. md-5 = "0.10" # MD5 for Kimi session_dir lookup (matches kimi-cli's WorkDirMeta hash) hex = "0.4" # Hex encoding for HMAC display bincode = "1.3" # Binary serialization for .akb format diff --git a/Makefile b/Makefile index fd88986..86e7733 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ AIKEY_BUILD_VERSION ?= $(VERSION) BUILD_ENV = AIKEY_BUILD_VERSION=$(AIKEY_BUILD_VERSION) AIKEY_BUILD_REVISION=$(GIT_REVISION)$(GIT_DIRTY) AIKEY_BUILD_ID=$(BUILD_ID) AIKEY_BUILD_TIME=$(BUILD_TIME) .PHONY: all release dev rebuild run test test-integration test-unit test-verbose \ + test-cli-config-injection \ test-import-recall test-proxy-lifecycle-e2e build-mock-proxy \ e2e-import-personal e2e-import-trial e2e-import-production \ security-check-batch-import \ @@ -78,6 +79,18 @@ test-unit: test-verbose: $(CARGO) test -- --nocapture +## Third-party CLI config-injection regression (kimi/codex toml_edit merge). +## Guards against the 2026-07-04 kimi `hooks = []` duplicate-key corruption +## (workflow/CI/bugfix/2026-07-04-kimi-config-hooks-duplicate-key.md): a foreign +## key + our blind append produced invalid TOML. Covers merge/self-heal/idempotency +## for kimi and structural upsert/conflict for codex. +test-cli-config-injection: + $(CARGO) test --lib 'commands_account::shell_integration::hook_tests::kimi_merge' + $(CARGO) test --lib 'commands_account::shell_integration::hook_tests::kimi_remove' + $(CARGO) test --lib 'commands_account::shell_integration::hook_tests::codex_merge' + $(CARGO) test --lib 'commands_account::shell_integration::hook_tests::codex_remove' + $(CARGO) test --lib 'commands_account::shell_integration::hook_tests::codex_conflict' + ## Stage 6 — end-to-end Quick Import smoke (per edition). ## ## These targets assume the corresponding installer has been run already @@ -268,6 +281,7 @@ help: @printf " %-22s %s\n" "make test-unit" "Unit tests only" @printf " %-22s %s\n" "make test-integration" "Integration tests only" @printf " %-22s %s\n" "make test-verbose" "Tests with stdout visible" + @printf " %-22s %s\n" "make test-cli-config-injection" "kimi/codex config-injection merge + self-heal regression (2026-07-04)" @printf " %-22s %s\n" "make test-import-recall" "Stage 3 parse engine recall + fingerprint gates" @printf " %-22s %s\n" "make e2e-import-personal" "Stage 6 e2e: status + aikey import smoke" @printf " %-22s %s\n" "make e2e-import-trial" " (alias — same binary as personal)" diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 8059634..1a07fcd 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -1082,6 +1082,12 @@ fn finish_login( // daemon wires the proxy identically — see persist_control_url_sidecar. persist_control_url_sidecar(&control_url, json_mode); + // Unified-origin landing (方案A, 2026-07-03): the ACTIVATION PAGE swaps + // itself to the local console once it observes the gateway live (its + // polling probe reads /system/team-url). The CLI deliberately does NOT + // also open a tab — two openers meant two console tabs. `aikey web` + // remains the manual entry (P3 gateway branch). + Ok(()) } @@ -1614,6 +1620,37 @@ pub fn handle_browse( } } + // Unified-origin composing gateway (P3, 2026-07-03 design doc + // 20260703-web统一origin-本地网关方案): when the co-installed local-server + // composes the team side on 127.0.0.1, `aikey web` opens the LOCAL + // origin. Team pages/data are reachable there same-origin (which is why + // the 2026-06-01 "logged-in must land on the team panel" pushback no + // longer applies), and this path performs NO remote token refresh — + // refresh hard-blocked `aikey web` whenever the team server's address + // changed (2026-07-02 report: Connection refused on token/refresh). + // OLD local-servers don't advertise the gateway → fall through to the + // pre-existing team-origin flow below, unchanged (progressive + // enhancement, no hard cutover). Explicit --port keeps its meaning. + if port.is_none() { + if let Some(base) = local_gateway_base() { + let url = format!("{}/go/{}", base, alias); + if json_mode { + crate::json_output::print_json(serde_json::json!({ + "ok": true, + "url": &url, + "mode": "local-gateway", + })); + } else if !copy_url { + println!("Opening User Console (unified local origin)..."); + println!(" {}", url); + } + // Local origin needs no auth token in the URL — the gateway + // injects the vault JWT server-side for team requests. + deliver_browse_url(&url, copy_url, json_mode, &url); + return Ok(()); + } + } + // JWT-based browse (team/trial mode). let acc = storage::get_platform_account()?.ok_or("Not logged in. Run 'aikey login' first.")?; @@ -7207,3 +7244,77 @@ mod sync_prune_tests { assert_eq!(storage::list_virtual_key_cache().unwrap().len(), 1); } } + +// --------------------------------------------------------------------------- +// Unified-origin composing gateway probe (P3, 20260703-web统一origin design) +// --------------------------------------------------------------------------- + +/// Decides whether the local console can serve as the unified origin, from +/// the parsed /system/team-url response. Pure so unit tests can pin the +/// gating rule without a live server (same pattern as +/// `browser_launch_command`). +/// +/// Gate = `gateway:true` AND a non-empty team_url: the gateway capability +/// exists only on new local-server binaries, and it composes nothing until +/// the CLI is logged in — both must hold or `aikey web` must keep the +/// pre-gateway team-origin behavior. +fn gateway_base_from_team_url_response(v: &serde_json::Value, port: u16) -> Option { + let gateway = v.get("gateway").and_then(|g| g.as_bool()).unwrap_or(false); + let team = v.get("team_url").and_then(|t| t.as_str()).unwrap_or(""); + if gateway && !team.is_empty() { + Some(format!("http://127.0.0.1:{}", port)) + } else { + None + } +} + +/// Probes the co-installed local-server for the composing gateway. Returns +/// the local console base URL when the RUNNING server advertises it. +/// +/// Why probe at call time instead of trusting install-state: the gateway is +/// login-gated per request inside the server and only exists on new +/// binaries; a 400ms loopback probe is cheap and always current, and a +/// failed probe simply falls back to the old flow (never blocks). +fn local_gateway_base() -> Option { + let port = crate::local_server_probe::read_local_server_port_or_default().unwrap_or(8090); + let url = format!("http://127.0.0.1:{}/system/team-url", port); + let resp = ureq::get(&url) + .timeout(std::time::Duration::from_millis(400)) + .call() + .ok()?; + let v: serde_json::Value = resp.into_json().ok()?; + gateway_base_from_team_url_response(&v, port) +} + +#[cfg(test)] +mod gateway_probe_tests { + use super::gateway_base_from_team_url_response; + use serde_json::json; + + // Fence for the P3 gating rule: `aikey web` may only route to the local + // origin when the server EXPLICITLY advertises the composing gateway AND + // a team login exists. Every other shape (old server without the field, + // logged-out, malformed) must fall back to the team-origin flow. + #[test] + fn routes_local_only_when_gateway_and_logged_in() { + let yes = json!({"team_url": "http://192.168.0.120:3000", "gateway": true}); + assert_eq!( + gateway_base_from_team_url_response(&yes, 8090).as_deref(), + Some("http://127.0.0.1:8090") + ); + } + + #[test] + fn old_server_without_gateway_field_falls_back() { + let old = json!({"team_url": "http://192.168.0.120:3000"}); + assert!(gateway_base_from_team_url_response(&old, 8090).is_none()); + } + + #[test] + fn logged_out_or_malformed_falls_back() { + let out = json!({"team_url": "", "gateway": true}); + assert!(gateway_base_from_team_url_response(&out, 8090).is_none()); + let weird = json!({"gateway": "yes"}); + assert!(gateway_base_from_team_url_response(&weird, 8090).is_none()); + } +} diff --git a/src/commands_account/shell_integration.rs b/src/commands_account/shell_integration.rs index 4b4cf61..5e123d9 100644 --- a/src/commands_account/shell_integration.rs +++ b/src/commands_account/shell_integration.rs @@ -50,21 +50,6 @@ const LEGACY_MARKER: &str = "# managed by aikey"; /// build time depends on the aikey binary absolute path (which is derived /// from `current_exe()` independently of proxy_port). The proxy port is no /// longer embedded in this region — it's only referenced via env vars. -fn build_kimi_managed_region(_proxy_port: u16) -> String { - let hook_cmd = crate::commands_statusline::aikey_statusline_render_kimi_command(); - format!( - "{begin}\n\ -[[hooks]]\n\ -event = \"Stop\"\n\ -command = \"{cmd}\"\n\ -timeout = 5\n\ -{end}", - begin = AIKEY_BEGIN, - cmd = hook_cmd, - end = AIKEY_END, - ) -} - /// Strip `default_model = "..."` lines at the top-level (outside any table) /// that were originally written by old aikey versions when the scaffold still /// included `[models.kimi-k2-5]`. After shrinking the region to hooks-only, @@ -101,17 +86,228 @@ fn strip_legacy_kimi_default_model(content: &str) -> String { out } -/// Replace the aikey-managed region in place. Returns `None` if no region -/// was found (caller handles that as first-time install). -fn replace_managed_region(content: &str, new_region: &str) -> Option { - let begin = content.find(AIKEY_BEGIN)?; - let end_rel = content[begin..].find(AIKEY_END)?; - let end = begin + end_rel + AIKEY_END.len(); - let mut out = String::with_capacity(content.len() + new_region.len()); - out.push_str(&content[..begin]); - out.push_str(new_region); - out.push_str(&content[end..]); - Some(out) +// --------------------------------------------------------------------------- +// Foreign-config TOML merge core (kimi + codex) +// --------------------------------------------------------------------------- +// +// Why this exists (workflow/CI/bugfix/2026-07-04-kimi-config-hooks-duplicate-key.md): +// We write into config files owned by third-party CLIs (kimi's +// `~/.kimi/config.toml`, codex's `~/.codex/config.toml`). The old approach +// string-appended a `[[hooks]]` / `[model_providers.aikey]` block WITHOUT +// parsing the existing TOML. When the third-party tool itself wrote a key of +// the same name (kimi's `save_config` emits a top-level `hooks = []`), the +// append produced TWO `hooks` definitions of incompatible TOML kinds +// (inline array vs array-of-tables) → the tool's own parser aborts with +// `Key "hooks" already exists` and the tool won't start. +// +// Fix: parse the existing file with `toml_edit` (format-preserving), merge our +// keys structurally, and re-serialize. A structural merge is immune to a +// foreign key of any name because we operate on the parsed document, never on +// raw text. `write_verified_toml` adds a belt-and-suspenders re-parse before +// committing so we can never leave a corrupt file behind while reporting ✓. + +/// Substring that identifies a `[[hooks]]` entry as aikey-authored, used to +/// dedupe our own hook across re-runs and to strip it on uninstall. We key on +/// the command's stable tail rather than a text marker comment, because a +/// structural merge doesn't preserve arbitrary comment placement — semantic +/// identity is more robust than a `# BEGIN aikey` text marker. +const KIMI_HOOK_COMMAND_MARKER: &str = "statusline render kimi"; + +/// Parse `content` as TOML, self-healing a file we previously corrupted. +/// +/// Returns `None` only when the file is invalid TOML for a reason we did NOT +/// cause — in that case the caller must NOT touch it (backup + WARN), because +/// blindly rewriting could destroy user content we can't understand. +/// +/// Self-heal path: our historical bug left a `# BEGIN aikey … [[hooks]] … # END +/// aikey` region alongside a foreign `hooks = []`, which is unparseable. We +/// strip our own marker region first (`strip_managed_region`) and retry — after +/// removing our block the remaining file is the tool's own valid TOML, which we +/// then merge into cleanly. This makes existing broken installs self-heal on +/// the next `aikey use`. +fn parse_or_heal_toml(content: &str) -> Option { + if let Ok(doc) = content.parse::() { + return Some(doc); + } + // Attempt 2: strip our own marker region(s) then re-parse. + let mut healed = content.to_string(); + while let Some(stripped) = strip_managed_region(&healed) { + healed = stripped; + } + // Also drop any legacy single-line-marked rows before re-parsing. + let healed: String = healed + .lines() + .filter(|l| !l.contains(LEGACY_MARKER)) + .collect::>() + .join("\n"); + healed.parse::().ok() +} + +/// Layer-0 guardrail: verify the rendered document re-parses before committing. +/// Returns `Ok(())` if it parses (caller writes), `Err(msg)` otherwise (caller +/// must abort the write — never leave a corrupt config while reporting success). +fn verify_toml(rendered: &str) -> Result<(), String> { + rendered + .parse::() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +/// True if a `[[hooks]]` table entry is one aikey wrote (matched by its +/// command tail), so re-runs replace rather than duplicate it. +fn hook_table_is_ours(t: &toml_edit::Table) -> bool { + t.get("command") + .and_then(|i| i.as_str()) + .map(|c| c.contains(KIMI_HOOK_COMMAND_MARKER)) + .unwrap_or(false) +} + +/// Outcome of a structural merge into a third-party config. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum TomlMergeOutcome { + /// The file changed; carries the new full content to write. + Changed(String), + /// The desired state already holds; nothing to write (idempotent no-op). + Unchanged, + /// The existing file is invalid TOML that we did not author; the caller + /// must back it up + WARN and NOT overwrite it. + SkipUnparseable, +} + +/// Pure core: merge aikey's Stop hook into an existing kimi `config.toml` +/// (given as text), returning the new text. No IO. This is what the unit tests +/// exercise directly. +/// +/// Invariants: +/// - Exactly one aikey-authored Stop hook after merge (dedupe by command). +/// - Any non-aikey `hooks` entries the user/kimi wrote are preserved. +/// - A foreign top-level `hooks = []` (kimi's own default) is folded into the +/// array-of-tables instead of colliding with it — the reported bug. +/// - The result always re-parses as valid TOML. +pub(super) fn kimi_merge_hook(existing: &str, hook_cmd: &str) -> TomlMergeOutcome { + use toml_edit::{value, ArrayOfTables, Item, Table, Value}; + + // Legacy cleanup preserved from the string era: drop a top-level + // `default_model = ""` that older aikey versions wrote + // pointing at a `[models.*]` block we no longer create. + let pre = strip_legacy_kimi_default_model(existing); + + let mut doc = match parse_or_heal_toml(&pre) { + Some(d) => d, + None => return TomlMergeOutcome::SkipUnparseable, + }; + + // Collect existing hook entries (from array-of-tables OR an inline array), + // dropping any we previously authored. + let mut kept: Vec = Vec::new(); + match doc.get("hooks") { + Some(Item::ArrayOfTables(aot)) => { + for t in aot.iter() { + if !hook_table_is_ours(t) { + kept.push(t.clone()); + } + } + } + Some(Item::Value(Value::Array(arr))) => { + // kimi's empty default is `hooks = []`; a populated inline array is + // rare but handle it by lifting inline tables into real tables. + for v in arr.iter() { + if let Value::InlineTable(it) = v { + let t = it.clone().into_table(); + if !hook_table_is_ours(&t) { + kept.push(t); + } + } + } + } + _ => {} + } + + // Build our Stop hook table. + let mut ours = Table::new(); + ours["event"] = value("Stop"); + ours["command"] = value(hook_cmd); + ours["timeout"] = value(5i64); + + // Rebuild the array-of-tables: kept entries first, then ours. + let mut aot = ArrayOfTables::new(); + for t in kept { + aot.push(t); + } + aot.push(ours); + + // Remove the old `hooks` key entirely, then insert the fresh AoT. Removing + // first is critical: replacing a top-level `hooks = []` value in place would + // keep its position AMONG the scalar keys, and a `[[hooks]]` header emitted + // mid-scalars would swallow every following scalar into the table. Removing + // + re-inserting gives the AoT an end-of-document position (valid TOML). + doc.as_table_mut().remove("hooks"); + doc["hooks"] = Item::ArrayOfTables(aot); + + let rendered = doc.to_string(); + if rendered == existing { + TomlMergeOutcome::Unchanged + } else { + TomlMergeOutcome::Changed(rendered) + } +} + +/// Pure core: remove aikey's Stop hook(s) from an existing kimi config, +/// preserving every non-aikey hook. `Unchanged` when we authored nothing here. +pub(super) fn kimi_remove_hook(existing: &str) -> TomlMergeOutcome { + use toml_edit::{ArrayOfTables, Item, Table, Value}; + + let mut doc = match parse_or_heal_toml(existing) { + Some(d) => d, + None => return TomlMergeOutcome::SkipUnparseable, + }; + + let mut kept: Vec
= Vec::new(); + let mut removed_ours = false; + match doc.get("hooks") { + Some(Item::ArrayOfTables(aot)) => { + for t in aot.iter() { + if hook_table_is_ours(t) { + removed_ours = true; + } else { + kept.push(t.clone()); + } + } + } + Some(Item::Value(Value::Array(arr))) => { + for v in arr.iter() { + if let Value::InlineTable(it) = v { + let t = it.clone().into_table(); + if hook_table_is_ours(&t) { + removed_ours = true; + } else { + kept.push(t); + } + } + } + } + _ => {} + } + + if !removed_ours { + return TomlMergeOutcome::Unchanged; + } + + doc.as_table_mut().remove("hooks"); + if !kept.is_empty() { + let mut aot = ArrayOfTables::new(); + for t in kept { + aot.push(t); + } + doc["hooks"] = Item::ArrayOfTables(aot); + } + + let rendered = doc.to_string(); + if rendered == existing { + TomlMergeOutcome::Unchanged + } else { + TomlMergeOutcome::Changed(rendered) + } } /// Strip the aikey-managed region (plus one trailing newline, if any) and @@ -324,13 +520,14 @@ pub fn apply_third_party_cli_configs(providers: &[String], proxy_port: u16) { } } -/// Ensure the aikey-managed scaffold exists in `~/.kimi/config.toml`. +/// Ensure aikey's Stop hook exists in `~/.kimi/config.toml`. /// -/// Writes the region only if absent or content-drifted — subsequent `aikey use` -/// calls are no-ops, because the region is token-agnostic (token overrides -/// via `KIMI_API_KEY` env var at runtime). See `build_kimi_managed_region` -/// docstring for why the scaffold is necessary at all. -pub fn configure_kimi_cli(proxy_port: u16) { +/// Structurally merges the hook via `kimi_merge_hook` (toml_edit) — subsequent +/// `aikey use` calls are no-ops when unchanged, because the hook is +/// token-agnostic (token overrides ride the `KIMI_API_KEY` env var at runtime). +/// The merge is immune to kimi's own `hooks = []` because it edits the parsed +/// document, never raw text (see the foreign-config TOML merge core above). +pub fn configure_kimi_cli(_proxy_port: u16) { use colored::Colorize; use std::io::{IsTerminal, Write}; @@ -341,134 +538,89 @@ pub fn configure_kimi_cli(proxy_port: u16) { .unwrap_or_else(|| resolve_user_home().join(".kimi")); let existing = std::fs::read_to_string(&config_path).unwrap_or_default(); - let region = build_kimi_managed_region(proxy_port); - - let has_region = existing.contains(AIKEY_BEGIN); - let has_legacy_only = !has_region && existing.contains(LEGACY_MARKER); - - // Build the desired file contents for each of the three code paths. - let (desired, first_time) = if has_region { - // Managed region already present — in-place replace (no prompt). - // Also strip any legacy `default_model = ""` at the - // top level. Older aikey versions wrote `default_model = "kimi-k2-5"` - // outside the region assuming `[models.kimi-k2-5]` existed inside; - // with the hook-only region, that reference would fail Kimi validation. - match replace_managed_region(&existing, ®ion) { - Some(s) => (strip_legacy_kimi_default_model(&s), false), - None => return, // defensive; should be unreachable because has_region is true - } - } else if has_legacy_only { - // Legacy upgrade path: user has the old `# managed by aikey` single-line - // marker but not the new AIKEY_BEGIN region. Strategy: - // - // 1. If a backup exists → restore it wholesale, then append fresh region. - // This is the clean path and leaves no duplicate TOML keys. - // - // 2. Otherwise → strip any line containing the legacy marker before - // appending. Without this strip, the legacy provider/model blocks - // remain and Kimi's TOML parser rejects the duplicate `[providers.kimi]` - // key. The strip is conservative: we only drop lines that explicitly - // carry the `# managed by aikey` marker (aikey never wrote those - // lines without the marker), preserving any user-added content in - // between. - let (base, stripped_legacy) = match std::fs::read_to_string(&backup_path) { - Ok(s) => (s, false), - Err(_) => { - let sanitized: String = existing - .lines() - .filter(|l| !l.contains(LEGACY_MARKER)) - .collect::>() - .join("\n"); - (sanitized, true) + let hook_cmd = crate::commands_statusline::aikey_statusline_render_kimi_command(); + + // First-time = our hook not yet present (semantic identity, not a text + // marker). Governs the TTY prompt + one-time backup. + let first_time = !existing.contains(KIMI_HOOK_COMMAND_MARKER); + + if first_time && io::stderr().is_terminal() { + // Hook-only footprint: providers/models come from env vars set in + // active.env; only the `[[hooks]]` Stop entry needs file-backed storage. + let rows: Vec = { + let mut r = vec![ + format!("File: {}", display_path(".kimi/config.toml")), + "Add: Stop hook → aikey statusline render kimi".to_string(), + " (provider/models come from env vars)".to_string(), + ]; + if !existing.is_empty() { + r.push(format!( + "Backup: {}", + display_path(".kimi/config.aikey_backup.toml") + )); } + r }; - let mut out = base.trim_end().to_string(); - if !out.is_empty() { - out.push_str("\n\n"); - } - out.push_str(®ion); - out.push('\n'); - if stripped_legacy && io::stderr().is_terminal() { - use colored::Colorize; + crate::ui_frame::eprint_box("\u{2753}", "Configure Kimi CLI", &rows); + eprint!(" Proceed? [Y/n] (default Y): "); + io::stderr().flush().ok(); + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_ok() && input.trim().eq_ignore_ascii_case("n") { eprintln!( - " {} Legacy `# managed by aikey` lines removed before adding new region.", - "!".yellow() + " {}", + "Skipped. Run 'aikey use kimi' again to retry.".dimmed() ); + return; + } + } + + match kimi_merge_hook(&existing, &hook_cmd) { + TomlMergeOutcome::Unchanged => {} + TomlMergeOutcome::SkipUnparseable => { + // Honest failure (失败要显眼): the file is invalid TOML we did NOT + // author, so we refuse to overwrite it and surface a WARN instead of + // silently reporting success. Back it up so the user can recover. + if !existing.is_empty() && !backup_path.exists() { + let _ = std::fs::create_dir_all(&config_dir); + let _ = std::fs::copy(&config_path, &backup_path); + } eprintln!( - " {}", - "(No backup found; duplicate-key conflict prevented.)".dimmed() + " {} ~/.kimi/config.toml is not valid TOML (not written by aikey); \ + left untouched. Backup: {}. Fix it, then run 'aikey use kimi'.", + "!".yellow(), + backup_path.display().to_string().dimmed() ); } - (out, false) - } else { - // First-time install — prompt (TTY only), then backup + append. - // - // Hook-only region: we no longer write `[providers.kimi]`, `[models.*]`, - // or `default_model`. All of those are driven by env vars set in - // active.env (see `provider_extra_env_vars` for the KIMI_MODEL_NAME / - // KIMI_MODEL_MAX_CONTEXT_SIZE pairs). Only `[[hooks]]` Stop genuinely - // requires file-backed storage. - if io::stderr().is_terminal() { - let rows: Vec = { - let mut r = vec![ - format!("File: {}", display_path(".kimi/config.toml")), - format!("Add: Stop hook → aikey statusline render kimi"), - format!(" (provider/models come from env vars)"), - ]; - if !existing.is_empty() { - r.push(format!( - "Backup: {}", - display_path(".kimi/config.aikey_backup.toml") - )); - } - r - }; - crate::ui_frame::eprint_box("\u{2753}", "Configure Kimi CLI", &rows); - eprint!(" Proceed? [Y/n] (default Y): "); - io::stderr().flush().ok(); - - let mut input = String::new(); - if io::stdin().read_line(&mut input).is_ok() && input.trim().eq_ignore_ascii_case("n") { + TomlMergeOutcome::Changed(desired) => { + // Layer-0 guardrail: re-parse before committing. A structural merge + // should always produce valid TOML, but verifying means a future + // regression can never leave a corrupt config behind a green ✓. + if let Err(e) = verify_toml(&desired) { eprintln!( - " {}", - "Skipped. Run 'aikey use kimi' again to retry.".dimmed() + " {} Refusing to write invalid Kimi config ({e}); left untouched.", + "!".yellow() ); return; } - } - - if !existing.is_empty() && !backup_path.exists() { - let _ = std::fs::create_dir_all(&config_dir); - let _ = std::fs::copy(&config_path, &backup_path); - } - - let mut out = existing.trim_end().to_string(); - if !out.is_empty() { - out.push_str("\n\n"); - } - out.push_str(®ion); - out.push('\n'); - (out, true) - }; - - // Idempotent: nothing to do if the file is already what we want. - if desired == existing { - return; - } - - match write_config_atomic(&config_dir, &config_path, &desired) { - Ok(_) => { - if first_time && io::stderr().is_terminal() { - eprintln!( - " {} Kimi CLI auto-configured: {}", - "✓".green().bold(), - config_path.display().to_string().dimmed() - ); + if first_time && !existing.is_empty() && !backup_path.exists() { + let _ = std::fs::create_dir_all(&config_dir); + let _ = std::fs::copy(&config_path, &backup_path); } - } - Err(e) => { - if first_time && io::stderr().is_terminal() { - eprintln!(" {} Could not configure Kimi CLI: {}", "!".yellow(), e); + match write_config_atomic(&config_dir, &config_path, &desired) { + Ok(_) => { + if first_time && io::stderr().is_terminal() { + eprintln!( + " {} Kimi CLI auto-configured: {}", + "✓".green().bold(), + config_path.display().to_string().dimmed() + ); + } + } + Err(e) => { + if first_time && io::stderr().is_terminal() { + eprintln!(" {} Could not configure Kimi CLI: {}", "!".yellow(), e); + } + } } } } @@ -503,24 +655,33 @@ pub fn unconfigure_kimi_cli() { return; }; - // Path 2: strip the new-style managed region if present. - if let Some(stripped) = strip_managed_region(&content) { - let cleaned = stripped.trim_end().to_string(); - if cleaned.is_empty() { - let _ = std::fs::remove_file(&config_path); - } else { - let mut with_nl = cleaned; - with_nl.push('\n'); - let _ = write_config_atomic(&config_dir, &config_path, &with_nl); + // Path 2: structural removal — drop only our Stop hook, keep every other + // key and any non-aikey hooks the user/kimi added. + match kimi_remove_hook(&content) { + TomlMergeOutcome::Changed(out) => { + if out.trim().is_empty() { + let _ = std::fs::remove_file(&config_path); + } else { + let _ = write_config_atomic(&config_dir, &config_path, &out); + } + } + TomlMergeOutcome::Unchanged => {} + TomlMergeOutcome::SkipUnparseable => { + // Foreign-invalid file: fall back to the historical text strip so a + // pre-toml_edit broken install can still be cleaned up. + if let Some(stripped) = strip_managed_region(&content) { + let cleaned = stripped.trim_end().to_string(); + if cleaned.is_empty() { + let _ = std::fs::remove_file(&config_path); + } else { + let mut with_nl = cleaned; + with_nl.push('\n'); + let _ = write_config_atomic(&config_dir, &config_path, &with_nl); + } + } else if content.contains(LEGACY_MARKER) { + let _ = std::fs::remove_file(&config_path); + } } - return; - } - - // Path 3: legacy fallback — file has old-style marker but no region. - // We created the file from scratch when installing the old-style - // config, so drop it wholesale to return Kimi to defaults. - if content.contains(LEGACY_MARKER) { - let _ = std::fs::remove_file(&config_path); } } @@ -583,102 +744,69 @@ fn codex_base_url(proxy_port: u16) -> String { format!("http://127.0.0.1:{}/openai", proxy_port) } -fn build_codex_managed_region(proxy_port: u16) -> String { - let base_url = codex_base_url(proxy_port); - format!( - "{begin}\n\ -[model_providers.aikey]\n\ -name = \"aikey\"\n\ -base_url = \"{base_url}\"\n\ -env_key = \"OPENAI_API_KEY\"\n\ -wire_api = \"responses\"\n\ -requires_openai_auth = false\n\ -{end}", - begin = AIKEY_BEGIN, - end = AIKEY_END, - ) -} - -/// Insert or replace a single-line TOML top-level key with an aikey marker. +/// Pure core: merge aikey's Codex routing into an existing `~/.codex/config.toml` +/// (given as text). Returns `(outcome, model_provider_written)`. No IO. /// -/// Safe to call repeatedly — if the line already exists (with or without our -/// marker) it's replaced in place. Otherwise inserted at the TOML-safe -/// position: immediately before the first `[table]` header (or at end of file -/// if there are no tables), which guarantees the key stays in the top-level -/// scope. -fn upsert_codex_managed_line(content: &str, key: &str, value: &str) -> String { - let new_line = format!("{} = \"{}\" {}", key, value, CODEX_LINE_MARKER); - let key_prefix = format!("{} ", key); - let key_eq = format!("{}=", key); - - // Pass 1: replace in place if the key already exists at top level. - // - // Note: we don't attempt to detect whether the existing key is inside a - // table scope. If the user put `model_provider = "..."` inside some table - // (which would actually be a subkey of that table, not our target), we - // may replace the wrong line. Accept this as a known limitation — - // legitimate Codex configs use top-level-only for these keys. - if content - .lines() - .any(|l| l.trim_start().starts_with(&key_prefix) || l.trim_start().starts_with(&key_eq)) - { - let mut out = String::new(); - for line in content.lines() { - if line.trim_start().starts_with(&key_prefix) || line.trim_start().starts_with(&key_eq) - { - out.push_str(&new_line); - } else { - out.push_str(line); - } - out.push('\n'); - } - return out; - } - - if content.is_empty() { - return format!("{}\n", new_line); - } - - // Pass 2: insert. Find the first `[table]` header line index; insert at - // that position (which places our new line in the top-level scope just - // before any tables). If no table exists, append at end — still top-level. - let lines: Vec<&str> = content.lines().collect(); - let insert_at = lines - .iter() - .position(|l| l.trim_start().starts_with('[')) - .unwrap_or(lines.len()); - - let mut out = String::new(); - for (idx, line) in lines.iter().enumerate() { - if idx == insert_at { - out.push_str(&new_line); - out.push('\n'); - } - out.push_str(line); - out.push('\n'); - } - if insert_at >= lines.len() { - out.push_str(&new_line); - out.push('\n'); - } - out -} +/// Writes three things structurally (immune to duplicate-key corruption because +/// it operates on the parsed document, not raw text): +/// - top-level `openai_base_url` +/// - top-level `model_provider = "aikey"` — SKIPPED if the user set a +/// non-default provider (conflict), matching the prior behavior exactly +/// - `[model_providers.aikey]` table (name/base_url/env_key/wire_api/…) +/// +/// The `[model_providers.aikey]` upsert fixes the latent sibling of the kimi +/// bug: the old code blind-appended this table, so a user who independently +/// defined `[model_providers.aikey]` (no marker) got a duplicate-table → invalid +/// TOML. A structural set replaces in place instead. +pub(super) fn codex_merge(existing: &str, base_url: &str) -> (TomlMergeOutcome, bool) { + use toml_edit::{value, Item, Table}; + + let mut doc = match parse_or_heal_toml(existing) { + Some(d) => d, + None => return (TomlMergeOutcome::SkipUnparseable, false), + }; -/// Append (or replace) the aikey-managed `[model_providers.aikey]` region at -/// the END of the content. Must come after all user tables because TOML table -/// sections don't terminate — anything we append inside a "user table scope" -/// would bind to the wrong table. -fn upsert_codex_region(content: &str, new_region: &str) -> String { - if content.contains(AIKEY_BEGIN) { - return replace_managed_region(content, new_region).unwrap_or_else(|| content.to_string()); - } - let mut out = content.trim_end().to_string(); - if !out.is_empty() { - out.push_str("\n\n"); - } - out.push_str(new_region); - out.push('\n'); - out + // Detect a user-set non-default `model_provider` from the ORIGINAL text + // before we mutate anything (preserves the pre-existing conflict semantics). + let conflict = detect_codex_model_provider_conflict(existing); + let model_provider_written = conflict.is_none(); + + // Top-level scalars. No positioning needed: toml_edit's Document renderer + // structurally emits ALL root-level values before ANY `[table]` section + // (encode.rs Display for Document — root node's get_values() render first, + // then tables sorted by position), so a scalar can never land after a table + // header regardless of insertion order. + doc["openai_base_url"] = value(base_url); + if model_provider_written { + doc["model_provider"] = value("aikey"); + } + + // [model_providers.aikey] table — structural upsert of our fields. + let mut aikey_tbl = Table::new(); + aikey_tbl["name"] = value("aikey"); + aikey_tbl["base_url"] = value(base_url); + aikey_tbl["env_key"] = value("OPENAI_API_KEY"); + aikey_tbl["wire_api"] = value("responses"); + aikey_tbl["requires_openai_auth"] = value(false); + + // Ensure `[model_providers]` is a (possibly implicit) table, then set the + // `aikey` sub-table, replacing any prior definition of it in place. + if !doc.get("model_providers").map(|i| i.is_table()).unwrap_or(false) { + let mut parent = Table::new(); + parent.set_implicit(true); // render as `[model_providers.aikey]`, not `[model_providers]` + doc["model_providers"] = Item::Table(parent); + } + if let Some(parent) = doc["model_providers"].as_table_mut() { + parent.insert("aikey", Item::Table(aikey_tbl)); + } + + let rendered = doc.to_string(); + let outcome = if rendered == existing { + TomlMergeOutcome::Unchanged + } else { + TomlMergeOutcome::Changed(rendered) + }; + (outcome, model_provider_written) } /// Detect whether the user has set a non-default `model_provider` that we @@ -736,18 +864,17 @@ pub fn configure_codex_cli(proxy_port: u16) { // managed VK on a cluster, else local proxy. See codex_base_url. let base_url = codex_base_url(proxy_port); let existing = std::fs::read_to_string(&config_path).unwrap_or_default(); - let region = build_codex_managed_region(proxy_port); - - let has_region = existing.contains(AIKEY_BEGIN); - let has_any_marker = existing.contains(CODEX_LINE_MARKER) || has_region; - // First-time install → prompt (TTY only) + backup. - let first_time = !has_any_marker; + // First-time = our provider block not yet present (structural identity, plus + // legacy markers for pre-toml_edit installs). Governs the prompt + backup. + let first_time = !existing.contains("model_providers.aikey") + && !existing.contains(AIKEY_BEGIN) + && !existing.contains(CODEX_LINE_MARKER); if first_time && io::stderr().is_terminal() { let mut rows: Vec = vec![ format!("File: {}", display_path(".codex/config.toml")), - format!("Add: openai_base_url + [model_providers.aikey]"), - format!(" env_key = \"OPENAI_API_KEY\" (per-shell token)"), + "Add: openai_base_url + [model_providers.aikey]".to_string(), + " env_key = \"OPENAI_API_KEY\" (per-shell token)".to_string(), ]; if !existing.is_empty() { rows.push(format!( @@ -765,54 +892,67 @@ pub fn configure_codex_cli(proxy_port: u16) { } } - // Build desired content via the three upsert passes. - let mut desired = existing.clone(); - desired = upsert_codex_managed_line(&desired, "openai_base_url", &base_url); + let (outcome, model_provider_written) = codex_merge(&existing, &base_url); - // model_provider: conditional on conflict detection. `desired` at this - // point already has `openai_base_url` added but model_provider detection - // looks at existing-like lines by key, so order is fine. - let conflict = detect_codex_model_provider_conflict(&desired); - let model_provider_written = if let Some(other) = conflict { - // Print the conflict warning unconditionally — stderr, non-TTY-gated. - // Rationale: this is operational info the user must see regardless of - // whether they ran us from a TTY or piped us into a log. Silent - // non-overwrite would be worse than a visible warning. - eprintln!( - " {} Detected existing `model_provider = \"{}\"` in ~/.codex/config.toml.", - "!".yellow(), - other - ); - eprintln!( - " {}", - "aikey provider block installed but NOT activated. To route through aikey:".dimmed() - ); - eprintln!( - " {}", - " • Remove that line (aikey will set `model_provider = \"aikey\"` next run), or" - .dimmed() - ); - eprintln!( - " {}", - " • Invoke as: codex -c model_provider=aikey".dimmed() - ); - eprintln!( - " {}", - "Note: the aikey-managed `codex` shell wrapper already injects \ - `-c model_provider=aikey` automatically; this hint is for when \ - you call the codex binary directly via `command codex` or a \ - non-shell launcher." - .dimmed() - ); - false - } else { - desired = upsert_codex_managed_line(&desired, "model_provider", "aikey"); - true - }; + // Preserve the pre-existing conflict messaging: printed unconditionally + // (stderr, non-TTY-gated) when the user has a non-default `model_provider`. + if !model_provider_written { + if let Some(other) = detect_codex_model_provider_conflict(&existing) { + eprintln!( + " {} Detected existing `model_provider = \"{}\"` in ~/.codex/config.toml.", + "!".yellow(), + other + ); + eprintln!( + " {}", + "aikey provider block installed but NOT activated. To route through aikey:" + .dimmed() + ); + eprintln!( + " {}", + " • Remove that line (aikey will set `model_provider = \"aikey\"` next run), or" + .dimmed() + ); + eprintln!( + " {}", + " • Invoke as: codex -c model_provider=aikey".dimmed() + ); + eprintln!( + " {}", + "Note: the aikey-managed `codex` shell wrapper already injects \ + `-c model_provider=aikey` automatically; this hint is for when \ + you call the codex binary directly via `command codex` or a \ + non-shell launcher." + .dimmed() + ); + } + } - desired = upsert_codex_region(&desired, ®ion); + let desired = match outcome { + TomlMergeOutcome::Unchanged => return, + TomlMergeOutcome::SkipUnparseable => { + // Honest failure: never overwrite a foreign-invalid config. + if !existing.is_empty() && !backup_path.exists() { + let _ = std::fs::create_dir_all(&config_dir); + let _ = std::fs::copy(&config_path, &backup_path); + } + eprintln!( + " {} ~/.codex/config.toml is not valid TOML (not written by aikey); \ + left untouched. Backup: {}. Fix it, then run 'aikey use'.", + "!".yellow(), + backup_path.display().to_string().dimmed() + ); + return; + } + TomlMergeOutcome::Changed(d) => d, + }; - if desired == existing { + // Layer-0 guardrail: verify before commit. + if let Err(e) = verify_toml(&desired) { + eprintln!( + " {} Refusing to write invalid Codex config ({e}); left untouched.", + "!".yellow() + ); return; } @@ -847,6 +987,47 @@ pub fn configure_codex_cli(proxy_port: u16) { } } +/// Pure core: remove aikey's Codex routing from an existing config, preserving +/// all user content. Removes `[model_providers.aikey]`, resets `model_provider` +/// if it is our value, and drops `openai_base_url` when it points at our proxy +/// (value ending in `/openai` — the shape `codex_base_url` writes). +pub(super) fn codex_remove(existing: &str) -> TomlMergeOutcome { + let mut doc = match parse_or_heal_toml(existing) { + Some(d) => d, + None => return TomlMergeOutcome::SkipUnparseable, + }; + + // Drop [model_providers.aikey]; if the parent becomes empty, drop it too. + if let Some(parent) = doc.get_mut("model_providers").and_then(|i| i.as_table_mut()) { + parent.remove("aikey"); + if parent.is_empty() { + doc.as_table_mut().remove("model_providers"); + } + } + + // Reset model_provider only if it is ours. + if doc.get("model_provider").and_then(|i| i.as_str()) == Some("aikey") { + doc.as_table_mut().remove("model_provider"); + } + + // Drop openai_base_url only when it is our proxy route (ends with /openai). + let ours = doc + .get("openai_base_url") + .and_then(|i| i.as_str()) + .map(|v| v.ends_with("/openai")) + .unwrap_or(false); + if ours { + doc.as_table_mut().remove("openai_base_url"); + } + + let rendered = doc.to_string(); + if rendered == existing { + TomlMergeOutcome::Unchanged + } else { + TomlMergeOutcome::Changed(rendered) + } +} + /// Restore `~/.codex/config.toml` from the backup created by `configure_codex_cli`. /// /// Priority: (1) restore `config.aikey_backup.toml` wholesale; (2) failing @@ -865,25 +1046,41 @@ pub fn unconfigure_codex_cli() { let Ok(content) = std::fs::read_to_string(&config_path) else { return; }; + let config_dir = config_path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| resolve_user_home().join(".codex")); - // Path 2: strip region + any single-line markers (covers both the new v3 - // codex format and the legacy per-line-marker-only format from before - // Option A). - let stripped_region = strip_managed_region(&content).unwrap_or(content.clone()); - let cleaned: String = stripped_region - .lines() - .filter(|line| !line.contains(CODEX_LINE_MARKER)) - .collect::>() - .join("\n"); - - if cleaned.trim().is_empty() { - let _ = std::fs::remove_file(&config_path); - } else { - let mut final_content = cleaned; - if !final_content.ends_with('\n') { - final_content.push('\n'); + // Path 2: structural removal of our keys/tables, preserving user content. + match codex_remove(&content) { + TomlMergeOutcome::Changed(out) => { + if out.trim().is_empty() { + let _ = std::fs::remove_file(&config_path); + } else { + let _ = write_config_atomic(&config_dir, &config_path, &out); + } + } + TomlMergeOutcome::Unchanged => {} + TomlMergeOutcome::SkipUnparseable => { + // Foreign-invalid file: fall back to the historical text strip + // (region + `# managed by aikey` marker lines) for pre-toml_edit + // installs that can no longer be parsed. + let stripped_region = strip_managed_region(&content).unwrap_or(content.clone()); + let cleaned: String = stripped_region + .lines() + .filter(|line| !line.contains(CODEX_LINE_MARKER)) + .collect::>() + .join("\n"); + if cleaned.trim().is_empty() { + let _ = std::fs::remove_file(&config_path); + } else { + let mut final_content = cleaned; + if !final_content.ends_with('\n') { + final_content.push('\n'); + } + let _ = std::fs::write(&config_path, final_content); + } } - let _ = std::fs::write(&config_path, final_content); } } @@ -3316,55 +3513,145 @@ mod hook_tests { assert!(out.contains("# user code")); } - // ── Kimi marker-region helpers ────────────────────────────────────────── + // ── Kimi hook merge (toml_edit structural; 2026-07-04 hooks-dup fix) ───── + // + // Regression bugfix: workflow/CI/bugfix/2026-07-04-kimi-config-hooks-duplicate-key.md + + const KIMI_HOOK_CMD: &str = "/home/u/.aikey/bin/aikey statusline render kimi"; + + /// A representative slice of kimi-cli's own `save_config` output. The + /// top-level `hooks = []` is the exact key that used to collide with our + /// appended `[[hooks]]`. Mirrors real kimi schema (test-fixture-real-schema). + fn kimi_real_config_with_empty_hooks() -> &'static str { + "default_model = \"\"\n\ +default_thinking = false\n\ +hooks = []\n\ +telemetry = true\n\ +\n\ +[loop_control]\n\ +max_steps_per_turn = 1000\n\ +\n\ +[mcp.client]\n\ +tool_call_timeout_ms = 60000\n" + } + + #[test] + fn kimi_merge_into_empty_hooks_is_valid_and_single_hook() { + // THE regression: kimi wrote `hooks = []`; we must fold our Stop hook + // into it, not emit a second `hooks` of an incompatible TOML kind. + let out = match kimi_merge_hook(kimi_real_config_with_empty_hooks(), KIMI_HOOK_CMD) { + TomlMergeOutcome::Changed(s) => s, + other => panic!("expected Changed, got {other:?}"), + }; + // Re-parses — this is what `kimi` does; before the fix it aborted with + // `Key "hooks" already exists`. + let doc = out.parse::().expect("valid TOML"); + let aot = doc + .get("hooks") + .and_then(|i| i.as_array_of_tables()) + .expect("[[hooks]] array-of-tables"); + assert_eq!(aot.len(), 1, "exactly one hook:\n{out}"); + let ours = aot.get(0).unwrap(); + assert_eq!(ours.get("event").unwrap().as_str().unwrap(), "Stop"); + assert_eq!(ours.get("command").unwrap().as_str().unwrap(), KIMI_HOOK_CMD); + assert!( + !out.contains("hooks = []"), + "empty inline array must be gone:\n{out}" + ); + assert!(out.contains("telemetry = true"), "unrelated keys preserved"); + assert!(out.contains("[mcp.client]"), "unrelated tables preserved"); + } + + #[test] + fn kimi_merge_empty_file_creates_hook() { + let out = match kimi_merge_hook("", KIMI_HOOK_CMD) { + TomlMergeOutcome::Changed(s) => s, + other => panic!("{other:?}"), + }; + out.parse::().expect("valid TOML"); + assert!(out.contains("[[hooks]]")); + assert!(out.contains("statusline render kimi")); + } #[test] - fn kimi_region_contains_only_hook_scaffold() { - // Minimal scaffold: only the Stop hook lives in config.toml. Providers, - // models, and default_model are all env-var-driven (see - // provider_extra_env_vars + active.env writers). - let r = build_kimi_managed_region(27200); - assert!(r.starts_with(AIKEY_BEGIN)); - assert!(r.trim_end().ends_with(AIKEY_END)); - // Hook — the only thing that genuinely needs file storage. - assert!(r.contains("[[hooks]]")); - assert!(r.contains("event = \"Stop\"")); - assert!(r.contains("statusline render kimi")); - // Must NOT contain provider/model scaffolding anymore. - assert!( - !r.contains("[providers.kimi]"), - "region should not include provider block:\n{}", - r - ); - assert!( - !r.contains("[models."), - "region should not include model blocks:\n{}", - r + fn kimi_merge_is_idempotent() { + let once = match kimi_merge_hook(kimi_real_config_with_empty_hooks(), KIMI_HOOK_CMD) { + TomlMergeOutcome::Changed(s) => s, + other => panic!("{other:?}"), + }; + // Re-running over our own output must not rewrite (no `aikey use` churn). + assert_eq!( + kimi_merge_hook(&once, KIMI_HOOK_CMD), + TomlMergeOutcome::Unchanged ); - assert!( - !r.contains("api_key"), - "region should not include api_key:\n{}", - r + } + + #[test] + fn kimi_merge_preserves_foreign_hook() { + let existing = + "[[hooks]]\nevent = \"PreToolUse\"\ncommand = \"user-thing\"\ntimeout = 10\n"; + let out = match kimi_merge_hook(existing, KIMI_HOOK_CMD) { + TomlMergeOutcome::Changed(s) => s, + other => panic!("{other:?}"), + }; + let doc = out.parse::().expect("valid"); + let aot = doc.get("hooks").and_then(|i| i.as_array_of_tables()).unwrap(); + assert_eq!(aot.len(), 2, "user hook + ours:\n{out}"); + assert!(out.contains("user-thing")); + assert!(out.contains(KIMI_HOOK_CMD)); + } + + #[test] + fn kimi_merge_self_heals_previously_corrupted_file() { + // Exact shape of the shipped bug: kimi's `hooks = []` PLUS our old + // appended marker region with `[[hooks]]` = invalid TOML. parse_or_heal + // strips our region, then the merge folds cleanly into a single hook. + let broken = format!( + "default_model = \"\"\nhooks = []\n\n{AIKEY_BEGIN}\n[[hooks]]\nevent = \"Stop\"\ncommand = \"{KIMI_HOOK_CMD}\"\ntimeout = 5\n{AIKEY_END}\n" ); assert!( - !r.contains("base_url"), - "region should not include base_url:\n{}", - r + broken.parse::().is_err(), + "fixture must reproduce the broken (duplicate-key) state" ); + let out = match kimi_merge_hook(&broken, KIMI_HOOK_CMD) { + TomlMergeOutcome::Changed(s) => s, + other => panic!("expected self-heal, got {other:?}"), + }; + let doc = out.parse::().expect("healed valid TOML"); + let aot = doc.get("hooks").and_then(|i| i.as_array_of_tables()).unwrap(); + assert_eq!(aot.len(), 1, "one merged hook after heal:\n{out}"); } #[test] - fn kimi_region_is_port_agnostic() { - // Port no longer appears in the region (base_url is env-var-driven). - let a = build_kimi_managed_region(27200); - let b = build_kimi_managed_region(19999); + fn kimi_merge_skips_foreign_unparseable() { + // Invalid for a reason we did NOT cause → leave it alone (no overwrite). + let foreign_bad = "not valid = = toml [[[\n"; assert_eq!( - a, b, - "region content must not depend on proxy_port:\nA={}\nB={}", - a, b + kimi_merge_hook(foreign_bad, KIMI_HOOK_CMD), + TomlMergeOutcome::SkipUnparseable + ); + } + + #[test] + fn kimi_remove_hook_drops_only_ours() { + let existing = format!( + "[[hooks]]\nevent = \"Stop\"\ncommand = \"{KIMI_HOOK_CMD}\"\ntimeout = 5\n\n[[hooks]]\nevent = \"PreToolUse\"\ncommand = \"user\"\ntimeout = 3\n" ); - assert!(!a.contains("27200")); - assert!(!a.contains("19999")); + let out = match kimi_remove_hook(&existing) { + TomlMergeOutcome::Changed(s) => s, + other => panic!("{other:?}"), + }; + let doc = out.parse::().unwrap(); + let aot = doc.get("hooks").and_then(|i| i.as_array_of_tables()).unwrap(); + assert_eq!(aot.len(), 1); + assert!(out.contains("user")); + assert!(!out.contains(KIMI_HOOK_CMD)); + } + + #[test] + fn kimi_remove_hook_noop_when_none_ours() { + let existing = "[[hooks]]\nevent = \"Stop\"\ncommand = \"user\"\ntimeout = 3\n"; + assert_eq!(kimi_remove_hook(existing), TomlMergeOutcome::Unchanged); } #[test] @@ -3395,33 +3682,11 @@ mod hook_tests { assert!(out.contains("default_model = \"kimi-k2-5\"")); } - #[test] - fn kimi_replace_region_preserves_surrounding_content() { - let before = "default_thinking = false\n\n"; - let old_region = format!("{AIKEY_BEGIN}\n[providers.kimi]\napi_key = \"old\"\n{AIKEY_END}"); - let after = "\n\n# user-added provider below\n[providers.custom]\nkey = \"keep-me\"\n"; - let existing = format!("{before}{old_region}{after}"); - - let new_region = build_kimi_managed_region(27200); - let out = replace_managed_region(&existing, &new_region).unwrap(); - - assert!(out.starts_with("default_thinking = false\n\n")); - assert!(!out.contains("api_key = \"old\"")); - assert!(out.contains("[[hooks]]")); - assert!(out.contains("[providers.custom]")); - assert!(out.contains("keep-me")); - } - - #[test] - fn kimi_replace_region_returns_none_without_markers() { - let plain = "default_thinking = false\n[providers.custom]\nkey = \"k\"\n"; - let region = build_kimi_managed_region(27200); - assert!(replace_managed_region(plain, ®ion).is_none()); - } - #[test] fn kimi_strip_region_removes_region_and_one_trailing_newline() { - let region = build_kimi_managed_region(27200); + // strip_managed_region is retained for self-heal + uninstall fallback. + let region = + format!("{AIKEY_BEGIN}\n[[hooks]]\nevent = \"Stop\"\ntimeout = 5\n{AIKEY_END}"); let existing = format!("default_thinking = false\n\n{region}\n\n# user\n"); let out = strip_managed_region(&existing).unwrap(); assert!(out.starts_with("default_thinking = false\n\n")); @@ -3436,107 +3701,103 @@ mod hook_tests { assert!(strip_managed_region(plain).is_none()); } - #[test] - fn kimi_strip_then_replace_round_trip_is_identity() { - // Stripping a region and re-inserting the same region should - // produce the original file modulo a trailing-newline variation. - let region = build_kimi_managed_region(27200); - let original = format!("user_key = \"a\"\n\n{region}\n"); - let stripped = strip_managed_region(&original).unwrap(); - let mut rebuilt = stripped.trim_end().to_string(); - rebuilt.push_str("\n\n"); - rebuilt.push_str(®ion); - rebuilt.push('\n'); - assert_eq!(rebuilt, original); - } - - // ── Codex managed region (Option A — env-var-driven custom provider) ── + // ── Codex merge (toml_edit structural) ─────────────────────────────────── #[test] - fn codex_region_contains_custom_provider_with_env_key() { - let r = build_codex_managed_region(27200); - assert!(r.starts_with(AIKEY_BEGIN)); - assert!(r.trim_end().ends_with(AIKEY_END)); - assert!(r.contains("[model_providers.aikey]")); - assert!(r.contains("name = \"aikey\"")); - assert!(r.contains("base_url = \"http://127.0.0.1:27200/openai\"")); - // THE critical assertion: per-shell token via env_key. - assert!(r.contains("env_key = \"OPENAI_API_KEY\"")); - assert!(r.contains("wire_api = \"responses\"")); - // Must bypass the auth.json / ChatGPT login path. - assert!(r.contains("requires_openai_auth = false")); + fn codex_merge_into_config_with_tables_keeps_scalars_above_tables() { + // Sibling ordering hazard: adding top-level scalars to a config that + // already has [tables] must keep them above any table header (else the + // scalar would be parsed into the table). + let existing = "model = \"gpt-5\"\n[projects.x]\ntrust = \"full\"\n"; + let (outcome, mp) = codex_merge(existing, "http://127.0.0.1:27200/openai"); + let out = match outcome { + TomlMergeOutcome::Changed(s) => s, + o => panic!("{o:?}"), + }; + out.parse::().expect("valid TOML"); + assert!(mp, "no conflict → model_provider written"); + let base_pos = out.find("openai_base_url").unwrap(); + let table_pos = out.find("[projects.x]").unwrap(); + assert!(base_pos < table_pos, "scalar must precede table:\n{out}"); + assert!(out.contains("[model_providers.aikey]")); + assert!(out.contains("env_key = \"OPENAI_API_KEY\"")); + assert!(out.contains("http://127.0.0.1:27200/openai")); + assert!(out.contains("requires_openai_auth = false")); } #[test] - fn codex_region_respects_proxy_port() { - let r = build_codex_managed_region(19999); - assert!(r.contains("127.0.0.1:19999")); - assert!(!r.contains(":27200")); + fn codex_merge_respects_port() { + let (outcome, _) = codex_merge("", "http://127.0.0.1:19999/openai"); + let out = match outcome { + TomlMergeOutcome::Changed(s) => s, + o => panic!("{o:?}"), + }; + assert!(out.contains("127.0.0.1:19999")); } #[test] - fn codex_upsert_line_on_empty_file_creates_line() { - let out = upsert_codex_managed_line("", "openai_base_url", "http://x/y"); + fn codex_merge_upserts_existing_aikey_table_no_duplicate() { + // Latent sibling of the kimi bug: a pre-existing [model_providers.aikey] + // (user-defined, no marker) must be replaced in place, not duplicated + // into invalid TOML. + let existing = + "[model_providers.aikey]\nname = \"aikey\"\nbase_url = \"http://old\"\nenv_key = \"X\"\n"; + let (outcome, _) = codex_merge(existing, "http://127.0.0.1:27200/openai"); + let out = match outcome { + TomlMergeOutcome::Changed(s) => s, + o => panic!("{o:?}"), + }; + out.parse::() + .expect("valid — must not duplicate the table"); assert_eq!( - out, - "openai_base_url = \"http://x/y\" # managed by aikey\n" + out.matches("[model_providers.aikey]").count(), + 1, + "duplicate provider table:\n{out}" ); + assert!(out.contains("http://127.0.0.1:27200/openai")); + assert!(!out.contains("http://old")); } #[test] - fn codex_upsert_line_replaces_existing_in_place() { - let existing = "model = \"gpt-5\"\nopenai_base_url = \"OLD\"\n[projects.x]\n"; - let out = upsert_codex_managed_line(existing, "openai_base_url", "NEW"); - assert!(out.contains("openai_base_url = \"NEW\" # managed by aikey")); - assert!(!out.contains("\"OLD\"")); - assert!(out.contains("model = \"gpt-5\"")); - assert!(out.contains("[projects.x]")); - } - - #[test] - fn codex_upsert_line_no_duplicate_when_other_keys_exist() { - // Regression: when `openai_base_url` already exists AND there's a - // different top-level key (`model_provider = "ollama"`) before any - // table, upsert must not also prepend a new line — it should JUST - // replace the existing one. - let existing = "model = \"gpt-5\"\nopenai_base_url = \"OLD\" # managed by aikey\nmodel_provider = \"ollama\"\n[projects.x]\n"; - let out = upsert_codex_managed_line(existing, "openai_base_url", "NEW"); - // Exactly one openai_base_url line - let count = out - .lines() - .filter(|l| l.trim_start().starts_with("openai_base_url")) - .count(); - assert_eq!(count, 1, "got duplicate openai_base_url:\n{}", out); - // Exactly one model_provider line (user's, untouched here) - let mp_count = out - .lines() - .filter(|l| { - l.trim_start().starts_with("model_provider ") - || l.trim_start().starts_with("model_provider=") - }) - .count(); - assert_eq!(mp_count, 1, "model_provider duplicated:\n{}", out); - } - - #[test] - fn codex_upsert_line_inserts_before_first_table() { - // TOML constraint: top-level keys must precede any [table] header. - let existing = "model = \"gpt-5\"\n[projects.x]\ntrust = \"full\"\n"; - let out = upsert_codex_managed_line(existing, "model_provider", "aikey"); - let key_pos = out.find("model_provider").unwrap(); - let table_pos = out.find("[projects.x]").unwrap(); + fn codex_merge_skips_model_provider_on_conflict_but_installs_block() { + let existing = "model_provider = \"ollama\"\n"; + let (outcome, mp) = codex_merge(existing, "http://127.0.0.1:27200/openai"); + let out = match outcome { + TomlMergeOutcome::Changed(s) => s, + o => panic!("{o:?}"), + }; + assert!(!mp, "must not overwrite the user's model_provider"); + assert!( + out.contains("model_provider = \"ollama\""), + "user provider preserved:\n{out}" + ); assert!( - key_pos < table_pos, - "model_provider must come before table header:\n{}", - out + out.contains("[model_providers.aikey]"), + "provider block still installed:\n{out}" ); + out.parse::().expect("valid TOML"); } #[test] - fn codex_upsert_line_prepends_when_file_is_all_tables() { - let existing = "[projects.x]\ntrust = \"full\"\n"; - let out = upsert_codex_managed_line(existing, "model_provider", "aikey"); - assert!(out.starts_with("model_provider = \"aikey\" # managed by aikey")); + fn codex_remove_strips_our_keys_only() { + let (installed, _) = codex_merge( + "model = \"gpt-5\"\n[projects.x]\ntrust = \"full\"\n", + "http://127.0.0.1:27200/openai", + ); + let installed = match installed { + TomlMergeOutcome::Changed(s) => s, + o => panic!("{o:?}"), + }; + let out = match codex_remove(&installed) { + TomlMergeOutcome::Changed(s) => s, + o => panic!("{o:?}"), + }; + out.parse::().expect("valid TOML"); + assert!(!out.contains("[model_providers.aikey]")); + assert!(!out.contains("model_provider = \"aikey\"")); + assert!(!out.contains("openai_base_url")); + assert!(out.contains("model = \"gpt-5\""), "user content preserved"); + assert!(out.contains("[projects.x]")); } #[test] @@ -3569,28 +3830,6 @@ mod hook_tests { assert_eq!(detect_codex_model_provider_conflict(content), None); } - #[test] - fn codex_upsert_region_appends_to_end() { - let existing = "model = \"gpt-5\"\n[projects.x]\ntrust = \"full\"\n"; - let region = build_codex_managed_region(27200); - let out = upsert_codex_region(existing, ®ion); - assert!(out.ends_with(&format!("{}\n", AIKEY_END))); - assert!(out.contains("model = \"gpt-5\"")); - assert!(out.contains("[projects.x]")); - } - - #[test] - fn codex_upsert_region_replaces_existing() { - let old_region = - format!("{AIKEY_BEGIN}\n[model_providers.aikey]\napi_key = \"old\"\n{AIKEY_END}"); - let existing = format!("model = \"gpt-5\"\n\n{old_region}\n"); - let new_region = build_codex_managed_region(27200); - let out = upsert_codex_region(&existing, &new_region); - assert!(!out.contains("api_key = \"old\"")); - assert!(out.contains("env_key = \"OPENAI_API_KEY\"")); - assert!(out.contains("model = \"gpt-5\"")); - } - // ── Stage 4 (active-state cross-shell sync, 2026-04-27) regression ────── // // Drift detector lives in hook.zsh / hook.bash and depends on two diff --git a/src/commands_project.rs b/src/commands_project.rs index a664d7f..f237d6e 100644 --- a/src/commands_project.rs +++ b/src/commands_project.rs @@ -758,6 +758,46 @@ pub fn handle_doctor(json_mode: bool) -> Result<(), Box> } } + // 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 + // here instead of nowhere. Informational (ok=true): a stale/absent + // heartbeat can also just mean the tool wasn't used, so we never fail + // doctor on it — but we point at the WARN log for the drift signature. + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let fmt_age = |ts: i64| -> String { + let s = (now - ts).max(0); + if s >= 86400 { + format!("{}d ago", s / 86400) + } else if s >= 3600 { + format!("{}h ago", s / 3600) + } else if s >= 60 { + format!("{}m ago", s / 60) + } else { + format!("{}s ago", s) + } + }; + let parts: Vec = ["kimi", "claude"] + .iter() + .map(|tool| match crate::commands_statusline::receipt_last_ok(tool) { + Some(ts) => format!("{}: {}", tool, fmt_age(ts)), + None => format!("{}: never", tool), + }) + .collect(); + emit( + "usage receipts", + true, + &parts.join(" · "), + Some( + "if a tool you use shows 'never'/very stale, grep ~/.aikey/logs/aikey-cli/current.jsonl for cli.receipt.* WARNs (may signal a kimi/claude upgrade broke the pipeline)", + ), + ); + } + // Probe backend services (docker or native). // control/collector/query = server-mode docker services. Probed // silently — failures here aren't actionable for Personal users. diff --git a/src/commands_service/commands.rs b/src/commands_service/commands.rs index 2f7a1ae..07d7eeb 100644 --- a/src/commands_service/commands.rs +++ b/src/commands_service/commands.rs @@ -322,31 +322,43 @@ mod trust_local { run("systemctl", &["--user", verb, SERVICE_NAME]) } - /// Windows: drive the NSSM-wrapped service via Windows SCM. + /// Windows: drive the trust-local per-user ScheduledTask via `schtasks`. /// - /// install_service.ps1 registers the service via `nssm install`, which - /// produces a real Windows service that responds to `sc.exe` SCM calls. - /// We use `sc.exe` (Windows-bundled) rather than `nssm.exe` directly to - /// avoid hard-depending on NSSM being on PATH from this CLI's perspective - /// (nssm is installed to System32 by setup-aliyun-win-build.ps1, but if - /// an end-user installed trust-local via the published install_service.ps1 - /// it lands wherever the script placed it). + /// 2026-07-06: trust-local migrated from an NSSM-wrapped Windows service to + /// a per-user ScheduledTask (install_service.ps1), matching the aikey-proxy + /// + console migration in local-install.ps1. Root cause: nssm.exe is NOT + /// shipped in the offline package (only on the build box), so the sc.exe + /// service never registered on real end-user machines and this command + /// errored with "sc.exe start aikey.trust-local: ". + /// The task is registered under the same identifier as SERVICE_NAME. + /// `schtasks` is Windows-bundled (no nssm dependency). + /// start -> /Run (launch the task's action now) + /// stop -> /End (terminate the running task instance) fn sc_action(verb: &str) -> Result<(), String> { - run("sc.exe", &[verb, SERVICE_NAME]) + let sw = match verb { + "start" => "/Run", + "stop" => "/End", + other => return Err(format!("unknown service verb '{}'", other)), + }; + run("schtasks", &[sw, "/TN", SERVICE_NAME]) } - /// Returns true when `sc.exe query ` reports `STATE : - /// 1 STOPPED`. Used by the restart loop to wait for an async `sc.exe - /// stop` to actually finish before re-firing `sc.exe start` (otherwise - /// start races stop and returns 1056 / 1062). + /// Returns true when trust-local is NOT running. Used by the restart loop + /// to wait for an async `/End` to finish before re-firing `/Run`. + /// + /// We probe the process image name via `tasklist` rather than parsing + /// `schtasks /Query` Status, because the Status strings ("Running" / + /// "Ready" / …) are LOCALIZED (e.g. "正在运行" / "就绪" on zh-CN Windows) and + /// a substring match would break off English hosts. The image name + /// `trust-local.exe` is not localized. fn sc_is_stopped() -> bool { - match std::process::Command::new("sc.exe") - .args(["query", SERVICE_NAME]) + match std::process::Command::new("tasklist") + .args(["/FI", "IMAGENAME eq trust-local.exe", "/FO", "CSV", "/NH"]) .output() { Ok(out) => { - let s = String::from_utf8_lossy(&out.stdout); - s.contains("STOPPED") + let s = String::from_utf8_lossy(&out.stdout).to_lowercase(); + !s.contains("trust-local.exe") } Err(_) => false, } diff --git a/src/commands_statusline.rs b/src/commands_statusline.rs index f54131c..5a7e505 100644 --- a/src/commands_statusline.rs +++ b/src/commands_statusline.rs @@ -163,6 +163,8 @@ pub fn run() -> io::Result<()> { write!(out, "{} {} ", warn, "|".dimmed())?; } write!(out, "{} {}", "[receipt]".dimmed(), line)?; + // Receipt delivered → advance the health heartbeat. + record_receipt_ok("claude"); Ok(()) } @@ -289,6 +291,66 @@ fn sync_health_line() -> Option { )) } +// ── Usage-receipt pipeline heartbeat (health-signal-surface) ──────────────── +// +// Why: the receipt render path couples to third-party CLIs' session layout and +// Stop-hook payload shape. Those break silently on a tool upgrade — the render +// just `return Ok(())`s and receipts quietly stop. A bare log line is not enough +// (health-signal-surface.md: pipeline health "必须暴露一个可读取的健康端点"). +// So on every SUCCESSFUL delivery we stamp a per-tool heartbeat file; `aikey +// doctor` reads it to surface "receipts last landed Nh ago / never observed", +// which is how an operator notices a tool upgrade silently killed the pipeline. +// +// Per-tool files + atomic last-writer-wins write (no read-modify-write) so +// concurrent hook subprocesses never corrupt or race the state. + +fn unix_now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn receipt_health_path(tool: &str) -> PathBuf { + let base = if let Ok(dir) = std::env::var("AIKEY_RUN_DIR") { + PathBuf::from(dir) + } else { + crate::commands_account::resolve_aikey_dir().join("run") + }; + base.join(format!("receipt-health-{tool}.json")) +} + +/// Stamp the tool's last-successful-receipt timestamp. Called only after a +/// receipt actually landed (not on the common "no new events" no-op), so a +/// stale heartbeat genuinely means "delivery stopped", not "user idle". +fn record_receipt_ok(tool: &str) { + write_receipt_ok_at(&receipt_health_path(tool), tool, unix_now_secs()); +} + +/// Path-explicit core (testable without env). Atomic tmp+rename, last-writer- +/// wins, no read-modify-write → concurrent hook subprocesses can't corrupt it. +fn write_receipt_ok_at(path: &Path, tool: &str, ts: i64) { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let body = format!("{{\"last_ok_at\":{ts},\"tool\":\"{tool}\"}}\n"); + let tmp = path.with_extension(format!("json.{}.tmp", std::process::id())); + if std::fs::write(&tmp, body).is_ok() { + let _ = std::fs::rename(&tmp, path); + } +} + +/// Read a tool's last-successful-receipt unix timestamp (None = never observed). +pub(crate) fn receipt_last_ok(tool: &str) -> Option { + read_receipt_ok_at(&receipt_health_path(tool)) +} + +fn read_receipt_ok_at(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + let v: serde_json::Value = serde_json::from_str(&raw).ok()?; + v.get("last_ok_at").and_then(|x| x.as_i64()) +} + fn read_stdin_ctx() -> io::Result { // Caller (`run`) has already verified stdin is a pipe; we will not block // on TTY input. Keep the read bounded: Claude Code's payload is always @@ -298,7 +360,18 @@ fn read_stdin_ctx() -> io::Result { if buf.trim().is_empty() { return Ok(ClaudeCodeCtx::default()); } - serde_json::from_str(&buf).or_else(|_| Ok(ClaudeCodeCtx::default())) + serde_json::from_str(&buf).or_else(|_| { + // Non-empty stdin that won't parse = Claude Code changed its statusLine + // payload shape (or a foreign caller). Degrade to default so the row + // just hides, but log it — a silent default here is exactly how a Claude + // upgrade would invisibly kill the receipt row. + crate::observability::log_warn_event( + crate::observability::EVENT_RECEIPT_CLAUDE_PAYLOAD_UNRECOGNIZED, + "claude statusLine stdin payload did not parse; rendering empty receipt row", + Some(crate::observability::ERRCODE_CLAUDE_STATUSLINE_PAYLOAD_UNRECOGNIZED), + ); + Ok(ClaudeCodeCtx::default()) + }) } /// `aikey statusline last-active` — scan the WAL for the newest event and @@ -405,9 +478,25 @@ pub fn render_kimi() -> io::Result<()> { return Ok(()); } let Ok(ctx) = serde_json::from_str::(&buf) else { + // Non-empty payload that won't parse = kimi changed its Stop-hook JSON. + // No-op (must not crash the hook) but log it, else a kimi upgrade would + // invisibly kill receipts. + if !buf.trim().is_empty() { + crate::observability::log_warn_event( + crate::observability::EVENT_RECEIPT_KIMI_PAYLOAD_UNRECOGNIZED, + "kimi Stop-hook stdin payload did not parse; skipping receipt", + Some(crate::observability::ERRCODE_KIMI_STOP_PAYLOAD_UNRECOGNIZED), + ); + } return Ok(()); }; if ctx.session_id.is_empty() || ctx.cwd.is_empty() { + // Parsed, but the fields we key on are absent = kimi renamed/moved them. + crate::observability::log_warn_event( + crate::observability::EVENT_RECEIPT_KIMI_PAYLOAD_UNRECOGNIZED, + "kimi Stop-hook payload missing session_id/cwd; skipping receipt", + Some(crate::observability::ERRCODE_KIMI_STOP_PAYLOAD_UNRECOGNIZED), + ); return Ok(()); } @@ -420,7 +509,18 @@ pub fn render_kimi() -> io::Result<()> { } let session_dir = kimi_session_dir(&ctx.cwd, &ctx.session_id); if !session_dir.exists() { - // Kimi session dir gone (user closed Kimi mid-turn?); skip silently. + // We derived kimi's session dir via its `WorkDirMeta` md5(cwd) formula + // but it isn't there. Usually benign (session closed mid-turn), but it + // is ALSO the signature of kimi-cli changing that derivation on upgrade + // — the exact silent-drift class as the config `hooks=[]` bug. Log at + // WARN (not a health-degrade, to avoid crying wolf on closed sessions); + // a persistent stream of these in the log is the drift tell. + crate::observability::log_warn_event( + crate::observability::EVENT_RECEIPT_KIMI_SESSION_DIR_MISSING, + "kimi session dir (md5(cwd)/session_id) not found; skipping receipt \ + (session closed, or kimi-cli session layout changed on upgrade)", + Some(crate::observability::ERRCODE_KIMI_SESSION_DIR_MISSING), + ); return Ok(()); } @@ -528,6 +628,9 @@ pub fn render_kimi() -> io::Result<()> { return Ok(()); } + // Receipt delivered → advance the health heartbeat (read by `aikey doctor`). + record_receipt_ok("kimi"); + // 8. Advance watermark. Any error here is non-fatal: next turn will // re-aggregate these events (at-least-once display). let _ = write_watermark_in( @@ -2427,6 +2530,33 @@ mod tests { ); } + #[test] + fn receipt_heartbeat_round_trip_and_absent() { + let dir = std::env::temp_dir().join(format!( + "aikey-test-receipt-{}-{}", + std::process::id(), + rand::random::() + )); + let path = dir.join("receipt-health-kimi.json"); + + // Absent → None (never observed). + assert_eq!(read_receipt_ok_at(&path), None); + + // Write then read back the exact timestamp. + write_receipt_ok_at(&path, "kimi", 1_720_000_000); + assert_eq!(read_receipt_ok_at(&path), Some(1_720_000_000)); + + // Last-writer-wins overwrite (idempotent stamp on each success). + write_receipt_ok_at(&path, "kimi", 1_720_000_500); + assert_eq!(read_receipt_ok_at(&path), Some(1_720_000_500)); + + // Malformed content → None (never render a broken signal). + std::fs::write(&path, "not json").unwrap(); + assert_eq!(read_receipt_ok_at(&path), None); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn watermark_round_trip() { let dir = std::env::temp_dir().join(format!( diff --git a/src/connectivity/protocol_addons.rs b/src/connectivity/protocol_addons.rs index 2b649ad..5096f03 100644 --- a/src/connectivity/protocol_addons.rs +++ b/src/connectivity/protocol_addons.rs @@ -109,10 +109,13 @@ pub fn oauth_addons_for(provider_code: &str) -> Option { // completions,且必须用 store=false + stream=true + Codex-specific // 模型 (ChatGPT 账户只支持 Codex-specific 模型)。 // - // Model resolution = codex_probe_model() — see that function for - // the single-truth chain. Short version: read the user's own - // ~/.codex/config.toml so codex-cli updates are auto-tracked, - // fall back to a hardcoded recent model only if config is missing. + // Model resolution = codex_probe_model() — see that function for the + // single-truth chain. Short version: use the model the proxy last + // observed on a real Codex OAuth request (~/.aikey/state/codex_last_model, + // self-healing across codex-cli upgrades with no aikey release), falling + // back to the CODEX_PROBE_FALLBACK_MODEL constant only before the proxy + // has ever seen a request. (This does NOT read ~/.codex/config.toml — + // an earlier version of this comment claimed it did; it never has.) "openai" => Some(ProtocolAddons { query: &[], body_inject: || vec![], diff --git a/src/main.rs b/src/main.rs index 860e80f..88fbfc1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -287,35 +287,27 @@ fn main() -> Result<(), Box> { eprintln!(); eprintln!(" {}", "Get started:".bold()); eprintln!( - " aikey quickstart {}", - "See what to do next (state-aware)".dimmed() + " aikey web {}", + "Open the User Console in the browser".dimmed() ); eprintln!( - " aikey add {}", - "Add a personal API key to the local vault".dimmed() + " aikey login {}", + "Log in to the aikey service".dimmed() ); eprintln!( - " aikey auth login {}", - "Sign in with an OAuth provider account".dimmed() + " aikey use {}", + "Select the active key for routing".dimmed() ); eprintln!( - " aikey list {}", + " aikey list {}", "Show your keys (personal, team, OAuth)".dimmed() ); - eprintln!( - " aikey route {}", - "Print proxy config for AI clients".dimmed() - ); - eprintln!( - " aikey web {}", - "Open the User Console in the browser".dimmed() - ); eprintln!(); eprintln!(" {}", "Run 'aikey --help' for all commands.".dimmed()); // Blink runs AFTER the full screen is painted so the user sees // banner + hints together instead of being held by the animation. - // 10 = blank + "Get started" + 6 commands + blank + hint. - cli::animate_banner_blink(10); + // 8 = blank + "Get started" + 4 commands + blank + hint. + cli::animate_banner_blink(8); std::process::exit(1); } } diff --git a/src/observability.rs b/src/observability.rs index c49e777..05ac08b 100644 --- a/src/observability.rs +++ b/src/observability.rs @@ -252,6 +252,37 @@ pub const EVENT_CLI_PROXY_REQUEST_STARTED: &str = "cli.proxy.request.started"; pub const EVENT_CLI_PROXY_REQUEST_COMPLETED: &str = "cli.proxy.request.completed"; pub const EVENT_CLI_PROXY_REQUEST_FAILED: &str = "cli.proxy.request.failed"; +// Usage-receipt pipeline degradation signals. These fire when a third-party CLI +// (kimi / claude) upgrade changes a contract we depend on (Stop-hook payload +// shape, session-dir derivation), so a break becomes visible in the log instead +// of a silent `return Ok(())`. See workflow/versions/compatible/ for the full +// coupling inventory. Paired with the `receipt-health-.json` heartbeat so +// the state is externally readable (health-signal-surface principle). +pub const EVENT_RECEIPT_KIMI_PAYLOAD_UNRECOGNIZED: &str = "cli.receipt.kimi_payload_unrecognized"; +pub const EVENT_RECEIPT_KIMI_SESSION_DIR_MISSING: &str = "cli.receipt.kimi_session_dir_missing"; +pub const EVENT_RECEIPT_CLAUDE_PAYLOAD_UNRECOGNIZED: &str = + "cli.receipt.claude_payload_unrecognized"; + +// UPPER_SNAKE error codes (logging-conventions.md). +pub const ERRCODE_KIMI_STOP_PAYLOAD_UNRECOGNIZED: &str = "KIMI_STOP_PAYLOAD_UNRECOGNIZED"; +pub const ERRCODE_KIMI_SESSION_DIR_MISSING: &str = "KIMI_SESSION_DIR_MISSING"; +pub const ERRCODE_CLAUDE_STATUSLINE_PAYLOAD_UNRECOGNIZED: &str = + "CLAUDE_STATUSLINE_PAYLOAD_UNRECOGNIZED"; + +/// Convenience: log a WARN with an event name + optional error code. Used by +/// degrade-to-default code paths that must not stay silent (logging-conventions: +/// "解析失败、字段缺失、shape 不匹配回落默认值必须配 WARN 日志"). +pub fn log_warn_event(event_name: &str, message: &str, error_code: Option<&str>) { + write_log( + Level::Warn, + message, + Some(event_name), + error_code, + None, + BTreeMap::new(), + ); +} + // ── Helpers ─────────────────────────────────────────────────────────────────── /// Returns the current time as an ISO 8601 string (RFC 3339 format). From 46e3c2ae19427893f3fa985fbebd67c33e10e7aa Mon Sep 17 00:00:00 2001 From: raysonmeng Date: Mon, 6 Jul 2026 10:43:42 -0500 Subject: [PATCH 20/42] fix(cli): accept statusless exchange response in aikey login --token /v1/auth/cli/login/exchange returns {access_token,refresh_token,...} with no status field; the CLI reused the poll DTO (status required) so serde failed with 'missing field status' and the copy-paste fallback login never wrote the vault. Parse exchange with a dedicated statusless DTO, normalized to approved client-side. Server protocol unchanged. Bugfix: workflow/CI/bugfix/2026-07-06-cli-login-token-exchange-missing-status.md Co-Authored-By: Claude Opus 4.8 (1M context) --- src/platform_client.rs | 105 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/src/platform_client.rs b/src/platform_client.rs index f2f085d..88bf8ef 100644 --- a/src/platform_client.rs +++ b/src/platform_client.rs @@ -50,7 +50,7 @@ pub struct StartSessionResponse { pub expires_in_seconds: u64, } -/// Returned by POST /v1/auth/cli/login/poll and /v1/auth/cli/login/exchange. +/// Returned by POST /v1/auth/cli/login/poll. /// /// `status` is one of: "pending" | "approved" | "denied" | "expired" | "token_claimed" /// Token fields are non-None when `status == "approved"`. @@ -64,6 +64,32 @@ pub struct PollResponse { pub account: Option, } +/// Returned by POST /v1/auth/cli/login/exchange. +/// +/// The copy-paste fallback exchange is not a polling endpoint: success is +/// represented by HTTP 200 plus the token payload, with no `status` field. +#[derive(Debug, Deserialize)] +struct LoginTokenExchangeResponse { + pub access_token: String, + pub refresh_token: String, + pub token_type: Option, + pub expires_in: Option, + pub account: AccountInfo, +} + +impl From for PollResponse { + fn from(value: LoginTokenExchangeResponse) -> Self { + PollResponse { + status: "approved".to_string(), + access_token: Some(value.access_token), + refresh_token: Some(value.refresh_token), + token_type: value.token_type, + expires_in: value.expires_in, + account: Some(value.account), + } + } +} + /// Returned by POST /v1/auth/cli/token/refresh #[derive(Debug, Deserialize)] pub struct RefreshResponse { @@ -442,7 +468,8 @@ impl PlatformClient { .set("Content-Type", "application/json") .send_json(&body) .map_err(|e| format!("exchange request failed: {}", e))?; - resp.into_json::() + resp.into_json::() + .map(PollResponse::from) .map_err(|e| format!("failed to parse exchange response: {}", e)) } @@ -727,6 +754,60 @@ mod cluster_resolve_tests { format!("http://{}", addr) } + fn mock_control_draining_request(status: u16, body: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + if let Ok((mut s, _)) = listener.accept() { + let mut buf = Vec::new(); + let mut tmp = [0u8; 512]; + let mut header_end = None; + while header_end.is_none() { + match s.read(&mut tmp) { + Ok(0) | Err(_) => break, + Ok(n) => { + buf.extend_from_slice(&tmp[..n]); + header_end = buf.windows(4).position(|w| w == b"\r\n\r\n"); + } + } + } + if let Some(pos) = header_end { + let headers = String::from_utf8_lossy(&buf[..pos + 4]).to_ascii_lowercase(); + let content_length = headers + .lines() + .find_map(|line| { + line.strip_prefix("content-length:") + .and_then(|v| v.trim().parse::().ok()) + }) + .unwrap_or(0); + let already_read = buf.len().saturating_sub(pos + 4); + let mut remaining = content_length.saturating_sub(already_read); + while remaining > 0 { + match s.read(&mut tmp) { + Ok(0) | Err(_) => break, + Ok(n) => remaining = remaining.saturating_sub(n), + } + } + } + let reason = match status { + 200 => "OK", + 401 => "Unauthorized", + 404 => "Not Found", + _ => "X", + }; + let _ = write!( + s, + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + status, + reason, + body.len(), + body + ); + } + }); + format!("http://{}", addr) + } + // 2026-06-12 fence (L8 次生缺口): the three-way resolution semantics. // If a refactor ever collapses 401 back into "not a cluster", a dead // token during sync will again clear persisted cluster routing and @@ -771,4 +852,24 @@ mod cluster_resolve_tests { other => panic!("transport error must be Unknown, got {:?}", other), } } + + #[test] + fn exchange_login_token_accepts_statusless_token_body() { + let base = mock_control_draining_request( + 200, + r#"{"access_token":"jwt-123","refresh_token":"rt-456","token_type":"Bearer","expires_in":3600,"account":{"account_id":"acct-1","email":"admin@aikey.local"}}"#, + ); + + let resp = PlatformClient::exchange_login_token(&base, "session-1", "login-token-1") + .expect("statusless exchange token payload should parse"); + + assert_eq!(resp.status, "approved"); + assert_eq!(resp.access_token.as_deref(), Some("jwt-123")); + assert_eq!(resp.refresh_token.as_deref(), Some("rt-456")); + assert_eq!(resp.token_type.as_deref(), Some("Bearer")); + assert_eq!(resp.expires_in, Some(3600)); + let account = resp.account.expect("account should be preserved"); + assert_eq!(account.account_id, "acct-1"); + assert_eq!(account.email, "admin@aikey.local"); + } } From 9e69970e6ffc0fbcfbb71a178781c09a1f895380 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 00:36:05 -0700 Subject: [PATCH 21/42] fix(proxy): unattended password resolution for `aikey proxy start` (service path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit launchd / systemd / the new Windows ScheduledTask all run exactly `aikey proxy start --foreground` with no TTY, but this dispatch only consulted the TTL-gated session cache (try_get) before falling into the interactive prompt — so a boot-after-idle service start hung/errored instead of unlocking. L9 fix A (try_get_unattended, 2026-06-11) was wired into ensure_proxy_for_use only; this extends the same no-TTY raw-store read + verify-then-invalidate discipline to the explicit command, and fails fast with actionable guidance when no credential source exists (previously the no-TTY prompt could hang a service task forever). Env vars (AIKEY_MASTER_PASSWORD / AK_TEST_PASSWORD) keep precedence; interactive behavior is byte-identical. Found by 2026-07-07 Windows/Mac parity audit (P1-1 leg). Co-Authored-By: Claude Fable 5 --- src/main.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index 88fbfc1..2252a6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1681,6 +1681,7 @@ fn run_command(cli: &Cli) -> Result<(), Box> { source_ref: alias, providers: &resolved_providers, }, + commands_account::audit_key_from_password(&password).as_ref(), ) .unwrap_or_default(); let newly_primary = lifecycle.newly_primary; @@ -1872,8 +1873,11 @@ fn run_command(cli: &Cli) -> Result<(), Box> { } // Single funnel — runs reconcile per event, refresh + apply once. if !events.is_empty() { - let outcomes = - commands_account::apply_credential_lifecycle_batch(&events).unwrap_or_default(); + let outcomes = commands_account::apply_credential_lifecycle_batch( + &events, + commands_account::audit_key_from_password(&password).as_ref(), + ) + .unwrap_or_default(); // Walk outcomes in lockstep with successful per_alias rows // and copy reconcile_actions back so the UX renderer below // can show them per-alias. @@ -2977,6 +2981,7 @@ fn run_command(cli: &Cli) -> Result<(), Box> { source_type: "personal", source_ref: name, }, + commands_account::audit_key_from_password(&password).as_ref(), ) .unwrap_or_default(); let actions = outcome.reconcile_actions; @@ -3299,8 +3304,11 @@ fn run_command(cli: &Cli) -> Result<(), Box> { } }) .collect(); - commands_account::apply_credential_lifecycle_batch(&events) - .map_err(|e| format!("Failed to apply use: {}", e))?; + commands_account::apply_credential_lifecycle_batch( + &events, + commands_account::try_audit_key_from_session().as_ref(), + ) + .map_err(|e| format!("Failed to apply use: {}", e))?; if !*no_hook { commands_account::ensure_shell_hook(false); } @@ -3669,10 +3677,55 @@ fn run_command(cli: &Cli) -> Result<(), Box> { Commands::Proxy { action } => { match action { ProxyAction::Start { config, foreground } => { - let password = prompt_vault_password(cli.password_stdin, cli.json)?; - // Verify the password is valid before handing it to the proxy. - executor::list_secrets(&password) - .map_err(|e| format!("vault authentication failed: {}", e))?; + // Unattended service-start path (L9 fix A extended, parity + // audit 2026-07-07 P1-1): launchd / systemd / Windows + // ScheduledTask run exactly `aikey proxy start --foreground` + // with no TTY. `prompt_vault_password` only consults the + // TTL-gated cache (`try_get`), so a boot-after-idle service + // start fell through to the interactive prompt — which + // hangs or errors with no terminal. Mirror + // `ensure_proxy_for_use`'s no-TTY branch: read the raw + // session store (TTL deliberately skipped, see + // try_get_unattended docs) and fail fast with actionable + // guidance instead of prompting. Env vars still win (they + // are checked first inside prompt_vault_password, and the + // env-set case never enters this branch). + use std::io::IsTerminal; + let env_pw_set = ["AIKEY_MASTER_PASSWORD", "AK_TEST_PASSWORD"] + .iter() + .any(|v| std::env::var(v).map(|s| !s.is_empty()).unwrap_or(false)); + let unattended = + !io::stderr().is_terminal() && !cli.password_stdin && !env_pw_set; + let password = if unattended { + match session::try_get_unattended() { + Some(pw) => pw, + None => { + return Err("unattended proxy start: no master password available. \ + Set AIKEY_MASTER_PASSWORD in the service environment, or run \ + `aikey proxy start` interactively once to populate the session cache." + .into()); + } + } + } else { + prompt_vault_password(cli.password_stdin, cli.json)? + }; + // Verify the password is valid before handing it to the + // proxy. On the unattended path a stale cache (master + // password changed) must invalidate, same discipline as + // ensure_proxy_for_use — otherwise every service start + // re-fails on the same dead cache entry. + if let Err(e) = executor::list_secrets(&password) { + if unattended { + session::invalidate(); + return Err(format!( + "unattended proxy start: cached vault password rejected ({}). \ + Run `aikey proxy start` interactively to refresh it.", + e + ) + .into()); + } + return Err(format!("vault authentication failed: {}", e).into()); + } // Why: default is background (detach=true) so the terminal is not blocked. // Use --foreground for debugging or when running under a process manager. commands_proxy::handle_start( @@ -5185,13 +5238,18 @@ fn pick_providers_interactively( // ciphertext by design); `key_material_reachable` treats it as usable so the // picker surfaces it, matching `aikey use ` (2026-06-15 central-key fix). let on_cluster = crate::commands_account::read_cluster_node().is_some(); + // 2026-07-06 (update/20260706-绑定材料守卫与Web解锁态全量sync.md): material- + // unreachable team keys are no longer HIDDEN — they show as dimmed, + // non-selectable "pending" rows. Hiding them made the picker show + // "nothing selected" while the same key was the active binding and the + // web vault labeled it IN USE. Visibility set = web vault's; only + // SELECTABILITY is gated on material. let usable_team: Vec<_> = team .iter() .filter(|e| { e.key_status == "active" && !e.local_state.starts_with("disabled_by_") && e.local_state != "stale" - && e.key_material_reachable(on_cluster) }) .collect(); for e in &usable_team { @@ -5295,6 +5353,7 @@ fn pick_providers_interactively( source_type: "personal".to_string(), source_ref: e.alias.clone(), display_type: None, + pending: false, }); } } @@ -5306,16 +5365,25 @@ fn pick_providers_interactively( vec![e.provider_code.clone()] }; if providers.iter().any(|p| p.to_lowercase() == *prov) { + let pending = !e.key_material_reachable(on_cluster); let label = e .local_alias .as_deref() .unwrap_or(e.alias.as_str()) .to_string(); + let label = if pending { + // Inline tag so the width calc (visible_len on label) + // sizes the box for it; renderer adds dim styling. + format!("{} \x1b[2;33m(pending sync)\x1b[0m", label) + } else { + label + }; candidates.push(ui_select::KeyCandidate { label, source_type: "team".to_string(), source_ref: e.virtual_key_id.clone(), display_type: None, + pending, }); } } @@ -5361,6 +5429,7 @@ fn pick_providers_interactively( source_type: "personal_oauth_account".to_string(), // DB value source_ref: acct.provider_account_id.clone(), display_type: Some(source_display), // UI: "oauth" or "oauth(f)" + pending: false, }); } } From 3ff977c32ed57d9c07031ca9460ce6678e8a7344 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 00:48:15 -0700 Subject: [PATCH 22/42] feat: vault binding-material guard + web unlock-state full sync + Windows/Mac parity (alpha4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] binding-material guard + bind audit (WARN on write-fail); web unlock-state CLI side; profile-activation / ui-select / shell-integration adjustments Refs: - Spec: 绑定材料守卫 + Web 解锁态全量 sync: https://github.com/aikeylabs/roadmap20260320/blob/6dfff14/技术实现/update/20260706-绑定材料守卫与Web解锁态全量sync.md - Designpattern: web-read-triggers-nonblocking-sync: https://github.com/aikeylabs/workflow/blob/260d6d4c52036d36c12bc8e360ad4861bc7d59cd/CI/designpattern/web-read-triggers-nonblocking-sync.md --- src/audit.rs | 65 ++++- src/commands_account/lifecycle/event.rs | 16 +- src/commands_account/mod.rs | 303 +++++++++++++++++++++- src/commands_account/shell_integration.rs | 35 ++- src/commands_auth/mod.rs | 4 + src/commands_internal/query.rs | 208 ++++++++++++++- src/commands_internal/vault_op.rs | 13 +- src/commands_project.rs | 10 +- src/observability.rs | 15 ++ src/profile_activation.rs | 68 ++++- src/ui_select.rs | 51 +++- 11 files changed, 746 insertions(+), 42 deletions(-) diff --git a/src/audit.rs b/src/audit.rs index de3b7e0..b34deb7 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -25,6 +25,13 @@ pub enum AuditOperation { Export, Import, Exec, + /// A provider-binding write (Primary switch / auto-assign / reconcile + /// replacement). Added 2026-07-06: the incident binding write had NO audit + /// row, making "who bound this key" unanswerable — every write through + /// `write_bindings_canonical` now signs one Bind row when the caller holds + /// a `VerifiedVaultKey`. Pure enum addition — operation is a TEXT column, + /// no migration; `verify_audit_log` recomputes HMAC over the raw string. + Bind, } impl AuditOperation { @@ -39,10 +46,37 @@ impl AuditOperation { AuditOperation::Export => "export", AuditOperation::Import => "import", AuditOperation::Exec => "exec", + AuditOperation::Bind => "bind", } } } +/// A 32-byte vault key PROVEN to match this vault's stored password_hash. +/// +/// Why a newtype (2026-07-06, B-2 protocol hardening): audit rows are +/// HMAC-signed with an audit_key derived from the vault key. A row signed with +/// a WRONG key doesn't fail at write time — it surfaces later as a bogus +/// "tampered" verdict from `verify_audit_log`, poisoning the whole chain's +/// trustworthiness. The ONLY constructor runs `storage::verify_vault_key`, so +/// the type system guarantees every signer input was checked against the vault +/// (one cheap SQLite read per command; same chokepoint contract as the +/// 2026-05-11 snapshot-sync strict-verify fix). +pub struct VerifiedVaultKey([u8; 32]); + +impl VerifiedVaultKey { + /// Verifies `key` against the vault's stored password_hash. Err when the + /// key doesn't match (stale web session, wrong derivation) — callers must + /// surface that, not sign with it. + pub fn new(key: [u8; 32]) -> Result { + storage::verify_vault_key(&key)?; + Ok(Self(key)) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + /// Initialize audit log table pub fn initialize_audit_log() -> Result<(), String> { let conn = storage::open_connection()?; @@ -198,6 +232,21 @@ pub fn log_audit_event( /// Returns the number of entries verified and any tampered entries. pub fn verify_audit_log(password: &SecretString) -> Result<(usize, Vec), String> { let audit_key = derive_audit_key(password)?; + // V2 acceptance (bugfix 2026-07-06, exposed by the B-2 bind-audit tests): + // rows written through `log_audit_event_from_vault_key` (the `_internal` + // web-bridge path since 2026-05, and B-2 bind rows) are signed with + // HMAC(vault_key, "AK_AUDIT_V2") — a DIFFERENT key than V1's + // Argon2(password, AK_AUDIT_SALT_V1). The verifier only ever tried V1, so + // every V2-signed row read as "tampered" (false positive). A row now + // verifies if EITHER derivation matches; both keys come from the same + // password, so the tamper-evidence property is unchanged. Best-effort on + // the V2 key: absent salt/kdf params (pre-vault state) just skips V2. + let audit_key_v2: Option<[u8; 32]> = (|| { + let salt = storage::get_salt().ok()?; + let (m, t, p) = storage::get_kdf_params().ok()?; + let vault_key = crypto::derive_key_with_params(password, &salt, m, t, p).ok()?; + derive_audit_key_from_vault_key(&vault_key).ok() + })(); let conn = storage::open_connection()?; let mut stmt = conn @@ -232,16 +281,24 @@ pub fn verify_audit_log(password: &SecretString) -> Result<(usize, Vec), St .get(5) .map_err(|e| format!("Failed to get hmac: {}", e))?; - // Recompute HMAC - let computed_hmac = compute_audit_hmac( + // Recompute HMAC — V1 first (bulk of historic rows), then V2 + // (vault_key-signed rows from the web bridge / B-2 bind rows). + let v1_hmac = compute_audit_hmac( &*audit_key, timestamp, &operation, alias.as_deref(), success != 0, )?; - - if computed_hmac != stored_hmac { + let mut matches = v1_hmac == stored_hmac; + if !matches { + if let Some(k2) = &audit_key_v2 { + let v2_hmac = + compute_audit_hmac(k2, timestamp, &operation, alias.as_deref(), success != 0)?; + matches = v2_hmac == stored_hmac; + } + } + if !matches { tampered_ids.push(id); } diff --git a/src/commands_account/lifecycle/event.rs b/src/commands_account/lifecycle/event.rs index 6b19410..09cebce 100644 --- a/src/commands_account/lifecycle/event.rs +++ b/src/commands_account/lifecycle/event.rs @@ -97,8 +97,9 @@ pub struct LifecycleOutcome { /// that only do one write per command. pub fn apply_credential_lifecycle( event: CredentialLifecycleEvent, + audit: Option<&crate::audit::VerifiedVaultKey>, ) -> Result { - let mut outcomes = apply_credential_lifecycle_batch(&[event])?; + let mut outcomes = apply_credential_lifecycle_batch(&[event], audit)?; Ok(outcomes.pop().unwrap_or_default()) } @@ -112,8 +113,13 @@ pub fn apply_credential_lifecycle( /// On any per-event error: returns immediately without running the tail /// (DB writes that already landed stay; tail not run, so caller's UX may /// observe stale active.env until next operation). +/// `audit`: a verified vault key when the calling command holds one — every +/// binding write in the batch then signs a tamper-evident `bind` audit row +/// (B-2, 2026-07-06). `None` keeps the pre-B-2 behavior (observability events +/// only) for contexts without key material. pub fn apply_credential_lifecycle_batch( events: &[CredentialLifecycleEvent<'_>], + audit: Option<&crate::audit::VerifiedVaultKey>, ) -> Result, String> { let mut outcomes: Vec = Vec::with_capacity(events.len()); let mut any_binding_touched = false; @@ -128,7 +134,7 @@ pub fn apply_credential_lifecycle_batch( } => { if !providers.is_empty() { let primaries = - auto_assign_primaries_for_key(source_type, source_ref, providers) + auto_assign_primaries_for_key(source_type, source_ref, providers, audit) .unwrap_or_default(); outcome.newly_primary = primaries; // Treat presence of providers as a binding touch so @@ -147,6 +153,7 @@ pub fn apply_credential_lifecycle_batch( providers, source_type, source_ref, + audit, )?; outcome.newly_primary = providers.to_vec(); any_binding_touched = true; @@ -156,8 +163,9 @@ pub fn apply_credential_lifecycle_batch( source_type, source_ref, } => { - let actions = reconcile_provider_primary_after_key_removal(source_type, source_ref) - .unwrap_or_default(); + let actions = + reconcile_provider_primary_after_key_removal(source_type, source_ref, audit) + .unwrap_or_default(); if !actions.is_empty() { any_binding_touched = true; } diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 1a07fcd..49335ce 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -3658,6 +3658,13 @@ pub fn handle_key_sync( // Force a full sync by resetting local_seen_sync_version to 0. storage::set_local_seen_sync_version(0); let downloaded = run_full_snapshot_sync(password)?; + // B-2 (2026-07-06): sign post-sync auto-assign binding writes. The full + // sync above already strict-verified this password's derived key, so + // VerifiedVaultKey::new cannot fail here except on a concurrent password + // change — in which case signing is correctly skipped (best-effort None). + let audit_key = derive_vault_key(password) + .ok() + .and_then(|k| crate::audit::VerifiedVaultKey::new(k).ok()); // v1.0.2: reconcile provider primaries after sync. let cached = storage::list_virtual_key_cache().unwrap_or_default(); @@ -3676,9 +3683,11 @@ pub fn handle_key_sync( }) .filter(|(_, p)| !p.is_empty()) .collect(); - let reconciled = - crate::profile_activation::reconcile_provider_primaries_after_team_key_sync(&synced_keys) - .unwrap_or_default(); + let reconciled = crate::profile_activation::reconcile_provider_primaries_after_team_key_sync( + &synced_keys, + audit_key.as_ref(), + ) + .unwrap_or_default(); if !reconciled.is_empty() { let _ = crate::profile_activation::refresh_implicit_profile_activation(); } @@ -4297,6 +4306,7 @@ pub(crate) fn write_bindings_canonical( providers: &[String], key_type_str: &str, key_ref: &str, + audit: Option<&crate::audit::VerifiedVaultKey>, ) -> Result<(), String> { for raw_provider in providers { let raw = raw_provider.to_lowercase(); @@ -4315,6 +4325,23 @@ pub(crate) fn write_bindings_canonical( key_ref, ) .map_err(|e| format!("set_provider_binding: {}", e))?; + // B-2 (2026-07-06): sign one tamper-evident `bind` audit row per + // binding write when the caller holds a verified vault key — the + // incident write (post-sync auto-assign) was invisible in audit_log + // AND the internal log. BEST-EFFORT by design: the binding write above + // already landed; an audit insert failure must never fail activation + // (fail-visible via WARN instead). `None` (keyless automatic contexts) + // still leaves the observability event emitted by the caller. + if let Some(vk) = audit { + if let Err(e) = crate::audit::log_audit_event_from_vault_key( + vk.as_bytes(), + crate::audit::AuditOperation::Bind, + Some(&format!("{}:{}:{}", canonical, key_type_str, key_ref)), + true, + ) { + eprintln!("[aikey] warning: bind audit row not written: {}", e); + } + } } Ok(()) } @@ -4863,11 +4890,14 @@ pub fn handle_key_use( // refresh → apply_third_party_cli_configs. Drive off the refreshed // binding set so switching one provider away from kimi/codex correctly // unconfigures the corresponding toml region. - let _lifecycle = apply_credential_lifecycle(CredentialLifecycleEvent::Switched { - source_type: key_type.as_str(), - source_ref: &key_ref, - providers: &target_providers, - }) + let _lifecycle = apply_credential_lifecycle( + CredentialLifecycleEvent::Switched { + source_type: key_type.as_str(), + source_ref: &key_ref, + providers: &target_providers, + }, + try_audit_key_from_session().as_ref(), + ) .map_err(|e| format!("Failed to apply use: {}", e))?; // ── 6. Shell hook (one-time, first use) ─────────────────────────────────── @@ -5119,6 +5149,27 @@ pub fn handle_key_alias( /// Derives the vault AES key from the master password. /// Uses the same salt + KDF parameters stored in the vault DB. +/// B-2 (2026-07-06): best-effort audit signer for binding writes in commands +/// that don't already hold the master password. Reads the CACHED session +/// password (keychain/file) — non-interactive by contract, NEVER prompts +/// (interaction-simplicity-first: audit must not add a password prompt). +/// None ⇒ the binding write proceeds unsigned (observability events only). +pub(crate) fn try_audit_key_from_session() -> Option { + let pw = crate::session::try_get()?; + let key = derive_vault_key(&pw).ok()?; + crate::audit::VerifiedVaultKey::new(key).ok() +} + +/// B-2 sibling for commands that already hold the master password (add / +/// delete / key sync): derive + verify, best-effort (None on mismatch — the +/// command's own executor already failed loudly in that case). +pub(crate) fn audit_key_from_password( + password: &SecretString, +) -> Option { + let key = derive_vault_key(password).ok()?; + crate::audit::VerifiedVaultKey::new(key).ok() +} + fn derive_vault_key(password: &SecretString) -> Result<[u8; crypto::KEY_SIZE], String> { let salt = storage::get_salt()?; let (m, t, p) = storage::get_kdf_params()?; @@ -6166,6 +6217,7 @@ mod core_tests { &["claude".to_string()], "personal_oauth_account", "acct-xyz", + None, ) .expect("write ok"); let bindings = storage::list_provider_bindings_readonly("default").unwrap(); @@ -6194,6 +6246,7 @@ mod core_tests { &["codex".to_string()], "personal_oauth_account", "fresh-uuid", + None, ) .unwrap(); @@ -6226,10 +6279,11 @@ mod core_tests { // ④ 跨 family 不受影响(anthropic 不被 mutex 触动) let (_dir, _lock) = setup_vault(); // 先写 anthropic 作为 cross-family 不受影响的对照 - write_bindings_canonical(&["anthropic".to_string()], "personal", "k-claude").unwrap(); + write_bindings_canonical(&["anthropic".to_string()], "personal", "k-claude", None).unwrap(); // 然后顺序写入 kimi family 两个成员 - write_bindings_canonical(&["moonshot".to_string()], "personal", "k-moonshot").unwrap(); - write_bindings_canonical(&["kimi".to_string()], "personal", "k-kimi").unwrap(); + write_bindings_canonical(&["moonshot".to_string()], "personal", "k-moonshot", None) + .unwrap(); + write_bindings_canonical(&["kimi".to_string()], "personal", "k-kimi", None).unwrap(); let bindings = storage::list_provider_bindings_readonly("default").unwrap(); // ① + ② 最后写入的 'kimi' 经 alias → kimi_code,独占 family @@ -6255,8 +6309,8 @@ mod core_tests { #[test] fn write_bindings_canonical_upserts_same_canonical() { let (_dir, _lock) = setup_vault(); - write_bindings_canonical(&["anthropic".to_string()], "personal", "first").unwrap(); - write_bindings_canonical(&["anthropic".to_string()], "personal", "second").unwrap(); + write_bindings_canonical(&["anthropic".to_string()], "personal", "first", None).unwrap(); + write_bindings_canonical(&["anthropic".to_string()], "personal", "second", None).unwrap(); let bindings = storage::list_provider_bindings_readonly("default").unwrap(); let anthropic_rows: Vec<_> = bindings .iter() @@ -6270,6 +6324,229 @@ mod core_tests { assert_eq!(anthropic_rows[0].key_source_ref, "second"); } + // ── binding material guard (2026-07-06 incident) ───────────────────────── + // + // A post-`aikey key sync` reconcile auto-bound a team VK with NO local + // provider_key_ciphertext as the anthropic Primary. Result: proxy 503s, + // the picker hides the key (material filter), the web vault shows it as + // "IN USE" — three surfaces disagreeing about the active key. The guard: + // automatic binding fills (auto_assign / removal-reconcile replacement) + // must skip material-unreachable team VKs and rather leave the slot empty. + // See update/20260706-绑定材料守卫与Web解锁态全量sync.md. + // + // NOTE: these tests assume the non-cluster environment (no + // ~/.aikey/active-cluster.json) — `key_material_reachable(false)` requires + // local ciphertext unless the VK is a group VK. + + fn guard_vk(vk: &str, group: Option<&str>, ciphertext: Option<&[u8]>) -> () { + let mut e = vk_entry(vk, group, None, None); + e.provider_key_ciphertext = ciphertext.map(|c| c.to_vec()); + e.provider_key_nonce = ciphertext.map(|_| vec![0u8; 12]); + storage::upsert_virtual_key_cache(&e).unwrap(); + } + + #[test] + fn auto_assign_skips_material_unreachable_team_vk() { + let (_dir, _lock) = setup_vault(); + // Direct-bind VK, no local ciphertext, no group → unreachable. + guard_vk("vk-nomat", None, None); + + let assigned = crate::profile_activation::auto_assign_primaries_for_key( + "team", + "vk-nomat", + &["anthropic".to_string()], + None, + ) + .expect("auto_assign must not error"); + assert!(assigned.is_empty(), "unreachable VK must not be promoted"); + let bindings = storage::list_provider_bindings_readonly("default").unwrap(); + assert!( + bindings.iter().all(|b| b.key_source_ref != "vk-nomat"), + "no binding may reference the unreachable VK, got: {:?}", + bindings + ); + } + + #[test] + fn auto_assign_allows_group_vk_without_ciphertext() { + let (_dir, _lock) = setup_vault(); + // Group VK: no local material BY DESIGN (proxy channel ③) → reachable. + guard_vk("vk-grp", Some("grp-1"), None); + + let assigned = crate::profile_activation::auto_assign_primaries_for_key( + "team", + "vk-grp", + &["anthropic".to_string()], + None, + ) + .expect("auto_assign ok"); + assert_eq!(assigned, vec!["anthropic".to_string()]); + } + + #[test] + fn auto_assign_allows_team_vk_with_local_ciphertext() { + let (_dir, _lock) = setup_vault(); + guard_vk("vk-mat", None, Some(b"cipher")); + + let assigned = crate::profile_activation::auto_assign_primaries_for_key( + "team", + "vk-mat", + &["anthropic".to_string()], + None, + ) + .expect("auto_assign ok"); + assert_eq!(assigned, vec!["anthropic".to_string()]); + } + + /// Pins the exact incident path: `aikey key sync`'s post-sync reconcile + /// wrapper must not bind a material-unreachable VK even when it is the + /// only anthropic candidate — an empty slot is the correct outcome. + #[test] + fn post_sync_reconcile_does_not_bind_material_unreachable_vk() { + let (_dir, _lock) = setup_vault(); + guard_vk("vk-nomat", None, None); + + let reconciled = + crate::profile_activation::reconcile_provider_primaries_after_team_key_sync( + &[("vk-nomat".to_string(), vec!["anthropic".to_string()])], + None, + ) + .expect("reconcile ok"); + assert!(reconciled.is_empty(), "nothing may be promoted"); + let bindings = storage::list_provider_bindings_readonly("default").unwrap(); + assert!( + bindings.iter().all(|b| b.provider_code != "anthropic"), + "anthropic slot must stay EMPTY rather than bind an unusable key" + ); + } + + /// Removal-reconcile replacement search must skip the unreachable VK and + /// promote the reachable one, regardless of cache iteration order. + #[test] + fn removal_reconcile_replacement_skips_unreachable_vk() { + let (_dir, _lock) = setup_vault(); + // Current primary that is about to be removed. + guard_vk("vk-old", None, Some(b"cipher-old")); + write_bindings_canonical(&["anthropic".to_string()], "team", "vk-old", None).unwrap(); + // Two candidates: unreachable direct-bind VK (inserted first) and a + // reachable group VK. + guard_vk("vk-nomat", None, None); + guard_vk("vk-grp", Some("grp-1"), None); + + let actions = crate::profile_activation::reconcile_provider_primary_after_key_removal( + "team", "vk-old", None, + ) + .expect("reconcile ok"); + let anthropic = actions + .iter() + .find(|a| a.provider_code == "anthropic") + .expect("anthropic action"); + match &anthropic.outcome { + crate::profile_activation::ReconcileOutcome::Replaced { new_source_ref, .. } => { + assert_eq!( + new_source_ref, "vk-grp", + "replacement must be the reachable VK, not the material-less one" + ); + } + other => panic!("expected Replaced, got {:?}", other), + } + } + + // ── B-2 bind audit rows (2026-07-06) ───────────────────────────────────── + // + // Every binding write through write_bindings_canonical must sign a + // tamper-evident `bind` audit row when the caller holds a VerifiedVaultKey. + // The incident write (post-sync auto-assign) had NO trace in audit_log — + // root-causing required timestamp cross-referencing across three stores. + + fn verified_key() -> crate::audit::VerifiedVaultKey { + // setup_vault stored password_hash = derived key of "test_password"; + // derive the same way the CLI does and let the newtype verify it. + let pw = SecretString::new("test_password".to_string()); + let key = derive_vault_key(&pw).expect("derive"); + crate::audit::VerifiedVaultKey::new(key).expect("verified") + } + + fn bind_audit_rows() -> Vec { + let conn = storage::open_connection().expect("open"); + let mut stmt = conn + .prepare("SELECT alias FROM audit_log WHERE operation = 'bind' ORDER BY id") + .expect("prepare"); + let rows = stmt + .query_map([], |r| r.get::<_, String>(0)) + .expect("query") + .filter_map(|r| r.ok()) + .collect(); + rows + } + + #[test] + fn bind_write_with_verified_key_signs_audit_row() { + let (_dir, _lock) = setup_vault(); + crate::audit::initialize_audit_log().expect("audit table"); + let vk = verified_key(); + write_bindings_canonical(&["anthropic".to_string()], "personal", "k-1", Some(&vk)) + .expect("write"); + let rows = bind_audit_rows(); + assert_eq!(rows, vec!["anthropic:personal:k-1".to_string()]); + // Chain integrity: the signed row must VERIFY (a wrong-key signature + // would surface as a tampered entry and poison the whole chain). + let pw = SecretString::new("test_password".to_string()); + let (verified, tampered) = crate::audit::verify_audit_log(&pw).expect("verify"); + assert!(verified >= 1, "bind row must be part of the verified chain"); + assert!( + tampered.is_empty(), + "bind row signed with VerifiedVaultKey must not read as tampered: {:?}", + tampered + ); + } + + #[test] + fn bind_write_without_key_stays_unsigned_but_succeeds() { + let (_dir, _lock) = setup_vault(); + crate::audit::initialize_audit_log().expect("audit table"); + write_bindings_canonical(&["anthropic".to_string()], "personal", "k-2", None) + .expect("keyless binding write must not fail"); + assert!( + bind_audit_rows().is_empty(), + "no VerifiedVaultKey → no audit row (observability event only)" + ); + // The binding itself must still land (audit is an overlay, never a gate). + let bindings = storage::list_provider_bindings_readonly("default").unwrap(); + assert!(bindings.iter().any(|b| b.key_source_ref == "k-2")); + } + + #[test] + fn verified_vault_key_rejects_wrong_key() { + let (_dir, _lock) = setup_vault(); + let err = crate::audit::VerifiedVaultKey::new([0x41u8; 32]); + assert!( + err.is_err(), + "a non-matching key must NOT be constructible — it would sign rows that read as tampered" + ); + } + + #[test] + fn auto_assign_with_verified_key_signs_bind_row() { + let (_dir, _lock) = setup_vault(); + crate::audit::initialize_audit_log().expect("audit table"); + guard_vk("vk-audit", None, Some(b"cipher")); + let vk = verified_key(); + let assigned = crate::profile_activation::auto_assign_primaries_for_key( + "team", + "vk-audit", + &["anthropic".to_string()], + Some(&vk), + ) + .expect("auto_assign"); + assert_eq!(assigned, vec!["anthropic".to_string()]); + assert_eq!( + bind_audit_rows(), + vec!["anthropic:team:vk-audit".to_string()], + "the previously-invisible auto-assign write must now leave a signed audit row" + ); + } + // ── snapshot sync verify (2026-05-11 fix) ──────────────────────────────── /// Critical defense-in-depth: `run_full_snapshot_sync_with_vault_key` diff --git a/src/commands_account/shell_integration.rs b/src/commands_account/shell_integration.rs index 5e123d9..fbce967 100644 --- a/src/commands_account/shell_integration.rs +++ b/src/commands_account/shell_integration.rs @@ -791,7 +791,11 @@ pub(super) fn codex_merge(existing: &str, base_url: &str) -> (TomlMergeOutcome, // Ensure `[model_providers]` is a (possibly implicit) table, then set the // `aikey` sub-table, replacing any prior definition of it in place. - if !doc.get("model_providers").map(|i| i.is_table()).unwrap_or(false) { + if !doc + .get("model_providers") + .map(|i| i.is_table()) + .unwrap_or(false) + { let mut parent = Table::new(); parent.set_implicit(true); // render as `[model_providers.aikey]`, not `[model_providers]` doc["model_providers"] = Item::Table(parent); @@ -998,7 +1002,10 @@ pub(super) fn codex_remove(existing: &str) -> TomlMergeOutcome { }; // Drop [model_providers.aikey]; if the parent becomes empty, drop it too. - if let Some(parent) = doc.get_mut("model_providers").and_then(|i| i.as_table_mut()) { + if let Some(parent) = doc + .get_mut("model_providers") + .and_then(|i| i.as_table_mut()) + { parent.remove("aikey"); if parent.is_empty() { doc.as_table_mut().remove("model_providers"); @@ -3553,7 +3560,10 @@ tool_call_timeout_ms = 60000\n" assert_eq!(aot.len(), 1, "exactly one hook:\n{out}"); let ours = aot.get(0).unwrap(); assert_eq!(ours.get("event").unwrap().as_str().unwrap(), "Stop"); - assert_eq!(ours.get("command").unwrap().as_str().unwrap(), KIMI_HOOK_CMD); + assert_eq!( + ours.get("command").unwrap().as_str().unwrap(), + KIMI_HOOK_CMD + ); assert!( !out.contains("hooks = []"), "empty inline array must be gone:\n{out}" @@ -3595,7 +3605,10 @@ tool_call_timeout_ms = 60000\n" other => panic!("{other:?}"), }; let doc = out.parse::().expect("valid"); - let aot = doc.get("hooks").and_then(|i| i.as_array_of_tables()).unwrap(); + let aot = doc + .get("hooks") + .and_then(|i| i.as_array_of_tables()) + .unwrap(); assert_eq!(aot.len(), 2, "user hook + ours:\n{out}"); assert!(out.contains("user-thing")); assert!(out.contains(KIMI_HOOK_CMD)); @@ -3617,8 +3630,13 @@ tool_call_timeout_ms = 60000\n" TomlMergeOutcome::Changed(s) => s, other => panic!("expected self-heal, got {other:?}"), }; - let doc = out.parse::().expect("healed valid TOML"); - let aot = doc.get("hooks").and_then(|i| i.as_array_of_tables()).unwrap(); + let doc = out + .parse::() + .expect("healed valid TOML"); + let aot = doc + .get("hooks") + .and_then(|i| i.as_array_of_tables()) + .unwrap(); assert_eq!(aot.len(), 1, "one merged hook after heal:\n{out}"); } @@ -3642,7 +3660,10 @@ tool_call_timeout_ms = 60000\n" other => panic!("{other:?}"), }; let doc = out.parse::().unwrap(); - let aot = doc.get("hooks").and_then(|i| i.as_array_of_tables()).unwrap(); + let aot = doc + .get("hooks") + .and_then(|i| i.as_array_of_tables()) + .unwrap(); assert_eq!(aot.len(), 1); assert!(out.contains("user")); assert!(!out.contains(KIMI_HOOK_CMD)); diff --git a/src/commands_auth/mod.rs b/src/commands_auth/mod.rs index a6ed8d1..75b217c 100644 --- a/src/commands_auth/mod.rs +++ b/src/commands_auth/mod.rs @@ -505,6 +505,7 @@ fn submit_code_and_finish( source_ref: account_id, providers: &[provider.to_string()], }, + crate::commands_account::try_audit_key_from_session().as_ref(), ); } @@ -606,6 +607,7 @@ fn poll_login_status( "personal_oauth_account", account_id, &[provider.to_string()], + crate::commands_account::try_audit_key_from_session().as_ref(), ); let _ = crate::profile_activation::refresh_implicit_profile_activation(); } @@ -1115,6 +1117,7 @@ fn handle_use( source_ref: &target.provider_account_id, providers: &[target.provider.clone()], }, + crate::commands_account::try_audit_key_from_session().as_ref(), )?; // Update global active_key_config (legacy single-active concept, @@ -1212,6 +1215,7 @@ fn handle_logout( source_type: "personal_oauth_account", source_ref: &acct.provider_account_id, }, + crate::commands_account::try_audit_key_from_session().as_ref(), ); // If this was the *global* active credential (legacy single-active concept, diff --git a/src/commands_internal/query.rs b/src/commands_internal/query.rs index 16a5a57..dbfb708 100644 --- a/src/commands_internal/query.rs +++ b/src/commands_internal/query.rs @@ -412,6 +412,10 @@ fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec v, Err(_) => return Vec::new(), }; + // Same cluster-awareness as the picker / use / set-route paths — on a + // cluster a claimed central key routes via the node and IS usable + // without local ciphertext (key_material_reachable's contract). + let on_cluster = crate::commands_account::read_cluster_node().is_some(); entries .into_iter() .map(|t| { @@ -439,6 +443,17 @@ fn team_records_for_emit(active_team: &ActiveBindingMap) -> Vec "needs_login", RoutedState::NoCandidate => "inactive", } + } else if base == "active" && !t.key_material_reachable(on_cluster) { + // 2026-07-06 (update/20260706-绑定材料守卫与Web解锁态全量sync.md): + // direct-bind VK whose provider key material never arrived + // locally (and can't route via a cluster node). Without this + // overlay the web chip said "active" — and the IN USE badge + // (binding-driven, deliberately decoupled from usability) + // could claim a key the proxy 503s on. Same predicate as the + // picker's pending rows, so both surfaces tell one story. + // Actionable like needs_login: unlock+reload or `aikey key + // sync` downloads the material and this flips back to active. + "pending_download" } else { base } @@ -590,6 +605,7 @@ pub fn handle(env: StdinEnvelope) { "list_personal_with_masked" => handle_list_personal_with_masked(env), "list_oauth" => handle_list_oauth(env), "list_metadata_locked" => handle_list_metadata_locked(env), + "snapshot_sync" => handle_snapshot_sync(env), other => emit_error( req_id, "I_UNKNOWN_ACTION", @@ -598,6 +614,77 @@ pub fn handle(env: StdinEnvelope) { } } +// ========== snapshot_sync ========== + +/// `snapshot_sync`: pull the account's managed-keys snapshot from Control and +/// upsert the local VK metadata cache (`managed_virtual_keys_cache`). +/// +/// # Why this exists +/// The Web vault page reads the LOCAL VK cache. That cache is only written by +/// `run_snapshot_sync` — historically triggered by user CLI commands (`aikey +/// list`) or the `aikey agent` daemon. The always-on aikey-proxy has rails for +/// the DYNAMIC columns (group_runtime / quota / routing) but NONE that pull the +/// structural VK-row snapshot, so a newly-issued team/group VK never lands in +/// the cache until the CLI happens to run — the Web page then shows a stale list +/// (missing key / "unknown" protocol). This action lets the local-server refresh +/// the cache itself, decoupled from the CLI/agent. +/// +/// # Contract +/// - Reuses the public sync cores (no parallel impl) — the +/// `_internal must reuse public core` rule. +/// - Two tiers keyed by the envelope's `vault_key_hex` (2026-07-06, +/// update/20260706-绑定材料守卫与Web解锁态全量sync.md): +/// - absent / placeholder → metadata-only `run_snapshot_sync` (works +/// locked; no provider secret material to encrypt); +/// - real verifying vault_key (unlocked web session) → FULL +/// `run_full_snapshot_sync_with_vault_key` (claim + key-material +/// download), so a web page load self-heals a missing +/// `provider_key_ciphertext` instead of leaving the key stuck in +/// "pending download" until someone runs `aikey key sync` in a +/// terminal. Material MUST be encrypted with the vault key before it +/// touches disk — that is WHY the locked tier cannot download it. +/// - a NON-verifying non-placeholder key is an error envelope, not a +/// silent downgrade (fail-visible; a stale session cookie should +/// surface, and the full core strict-verifies anyway). +/// - Best-effort by design: the caller (local-server) fires this in the +/// BACKGROUND and ignores the result. It must NEVER be on the vault read's +/// critical path — offline use must return the local cache instantly. Errors +/// (offline / Control down) come back as a status envelope, not a panic. +/// - Version-gated: metadata tier inside `run_snapshot_sync` (fast +/// sync-version check); the full tier's material step is gated by the +/// separate `last_material_sync_version` marker + ciphertext-absence check. +/// Whether the envelope carries a REAL vault_key (unlocked web session) as +/// opposed to "deliberately none": empty string (Go read-path convention) or +/// the all-zero PlaceholderHex (Go cli.PlaceholderHex). Only a real key +/// selects the full sync tier; a real-but-wrong key must ERROR (fail-visible), +/// never silently downgrade to metadata-only — that split lives in the caller. +fn envelope_has_real_vault_key(vault_key_hex: &str) -> bool { + !vault_key_hex.is_empty() && !vault_key_hex.chars().all(|c| c == '0') +} + +fn handle_snapshot_sync(env: StdinEnvelope) { + let req_id = env.request_id.clone(); + if envelope_has_real_vault_key(&env.vault_key_hex) { + let key = match verify_key(&env) { + Ok(k) => k, + Err((code, msg)) => return emit_error(req_id, code, msg), + }; + return match crate::commands_account::run_full_snapshot_sync_with_vault_key(&key) { + Ok(downloaded) => emit(&ResultEnvelope::ok( + req_id, + json!({ "synced": true, "downloaded": downloaded, "full": true }), + )), + Err(e) => emit_error(req_id, "I_SNAPSHOT_SYNC_FAILED", e), + }; + } + match crate::commands_account::run_snapshot_sync() { + // `applied` = true when a newer snapshot was pulled + written; false when + // already up-to-date or not logged in. Both are success for the caller. + Ok(applied) => emit(&ResultEnvelope::ok(req_id, json!({ "synced": applied }))), + Err(e) => emit_error(req_id, "I_SNAPSHOT_SYNC_FAILED", e), + } +} + // ========== helpers ========== /// 校验 vault_key(与 vault_op.rs 同款逻辑,但不依赖 prepare_vault 因为 query 有的 action 不需要 key) @@ -1515,7 +1602,10 @@ mod team_protocol_source_tests { // Direct VK: provider_code present → use it. #[test] fn provider_code_wins_when_present() { - assert_eq!(team_protocol_source("anthropic", "openai_compatible"), Some("anthropic")); + assert_eq!( + team_protocol_source("anthropic", "openai_compatible"), + Some("anthropic") + ); } // Orphaned group VK (seat unbound): provider_code empty → fall back to the stable, @@ -1981,3 +2071,119 @@ mod protocol_family_of_tests { assert_eq!(protocol_family_of(Some("Moonshot")), "kimi"); } } + +#[cfg(test)] +mod snapshot_sync_tier_tests { + use super::envelope_has_real_vault_key; + + // Two-tier snapshot_sync dispatch (2026-07-06, + // update/20260706-绑定材料守卫与Web解锁态全量sync.md): the web bridge sends + // "" or the all-zero PlaceholderHex when locked (→ metadata-only tier) and + // the session's real vault_key when unlocked (→ full tier with key-material + // download). Misclassifying the placeholder as "real" would make every + // locked page load fail with I_VAULT_KEY_INVALID; misclassifying a real + // key as "none" would silently never download material. + + #[test] + fn empty_hex_is_not_a_real_key() { + assert!(!envelope_has_real_vault_key("")); + } + + #[test] + fn all_zero_placeholder_is_not_a_real_key() { + // Byte-identical to Go's cli.PlaceholderHex. + let placeholder = "0".repeat(64); + assert!(!envelope_has_real_vault_key(&placeholder)); + } + + #[test] + fn real_key_hex_selects_full_tier() { + let key = "42".repeat(32); + assert!(envelope_has_real_vault_key(&key)); + } +} + +#[cfg(test)] +mod team_status_overlay_tests { + //! pending_download overlay (2026-07-06, + //! update/20260706-绑定材料守卫与Web解锁态全量sync.md): a direct-bind VK whose + //! key material never arrived locally must NOT read "active" on the web + //! vault — the proxy 503s on it. Regression pin for the display-split + //! incident (web IN USE + chip active vs picker hides vs 503). + use super::*; + use crate::storage; + + fn setup_vault() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) { + let guard = crate::storage::TEST_VAULT_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::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()); + } + let mut salt = [0u8; 16]; + crate::crypto::generate_salt(&mut salt).expect("salt"); + let pw = secrecy::SecretString::new("test_password".to_string()); + storage::initialize_vault(&salt, &pw).expect("init vault"); + (dir, guard) + } + + fn vk(id: &str, ciphertext: Option<&[u8]>) -> storage::VirtualKeyCacheEntry { + storage::VirtualKeyCacheEntry { + virtual_key_id: id.into(), + org_id: "org-1".into(), + seat_id: "seat-1".into(), + alias: id.into(), + provider_code: "anthropic".into(), + protocol_type: "anthropic".into(), + base_url: String::new(), + credential_id: "cred-1".into(), + credential_revision: "r1".into(), + virtual_key_revision: "vr1".into(), + key_status: "active".into(), + share_status: "claimed".into(), + local_state: "synced_inactive".into(), + expires_at: None, + provider_key_nonce: ciphertext.map(|_| vec![0u8; 12]), + provider_key_ciphertext: ciphertext.map(|c| c.to_vec()), + synced_at: 0, + local_alias: None, + supported_providers: vec!["anthropic".into()], + provider_base_urls: std::collections::HashMap::new(), + owner_account_id: Some("acct-1".into()), + owner_email: None, + group_runtime: None, + group_alias: None, + extra: None, + oauth_group_id: None, + group_accounts: None, + routing_config: None, + } + } + + fn emitted_status(vk_id: &str) -> String { + let records = team_records_for_emit(&HashMap::new()); + records + .iter() + .find(|r| r["virtual_key_id"] == vk_id) + .expect("record present")["effective_status"] + .as_str() + .expect("status string") + .to_string() + } + + #[test] + fn direct_bind_vk_without_material_reads_pending_download() { + let (_dir, _guard) = setup_vault(); + storage::upsert_virtual_key_cache(&vk("vk-nomat", None)).unwrap(); + assert_eq!(emitted_status("vk-nomat"), "pending_download"); + } + + #[test] + fn direct_bind_vk_with_material_reads_active() { + let (_dir, _guard) = setup_vault(); + storage::upsert_virtual_key_cache(&vk("vk-mat", Some(b"cipher"))).unwrap(); + assert_eq!(emitted_status("vk-mat"), "active"); + } +} diff --git a/src/commands_internal/vault_op.rs b/src/commands_internal/vault_op.rs index d3479bd..d285a73 100644 --- a/src/commands_internal/vault_op.rs +++ b/src/commands_internal/vault_op.rs @@ -1007,6 +1007,7 @@ fn handle_add(env: StdinEnvelope) { source_ref: &outcome.alias, providers: &outcome.providers, }, + crate::audit::VerifiedVaultKey::new(key).ok().as_ref(), ) .unwrap_or_default(); let newly_primary = lifecycle.newly_primary.clone(); @@ -1216,9 +1217,11 @@ fn handle_batch_import(env: StdinEnvelope) { }, ) .collect(); - let lifecycle_outcomes = - crate::commands_account::apply_credential_lifecycle_batch(&lifecycle_events) - .unwrap_or_default(); + let lifecycle_outcomes = crate::commands_account::apply_credential_lifecycle_batch( + &lifecycle_events, + crate::audit::VerifiedVaultKey::new(key).ok().as_ref(), + ) + .unwrap_or_default(); let total_newly_primary: Vec = lifecycle_outcomes .iter() .flat_map(|o| o.newly_primary.clone()) @@ -1368,6 +1371,7 @@ fn handle_delete(env: StdinEnvelope) { source_type: "personal", source_ref: &payload.alias, }, + crate::audit::VerifiedVaultKey::new(key).ok().as_ref(), ); let audit_logged = try_log_audit(&key, AuditOperation::Delete, Some(&payload.alias), true); @@ -1448,6 +1452,7 @@ fn handle_delete_target(env: StdinEnvelope) { source_type: "personal", source_ref: &payload.id, }, + crate::audit::VerifiedVaultKey::new(key).ok().as_ref(), ) .unwrap_or_default(); let audit_logged = try_log_audit(&key, AuditOperation::Delete, Some(&payload.id), true); @@ -1522,6 +1527,7 @@ fn handle_delete_target(env: StdinEnvelope) { source_type: "personal_oauth_account", source_ref: &payload.id, }, + crate::audit::VerifiedVaultKey::new(key).ok().as_ref(), ) .unwrap_or_default(); let audit_logged = try_log_audit(&key, AuditOperation::Delete, Some(&payload.id), true); @@ -2511,6 +2517,7 @@ fn handle_use(env: StdinEnvelope) { source_ref: &canonical_key_ref, providers: &providers, }, + crate::audit::VerifiedVaultKey::new(key).ok().as_ref(), ) { Ok(o) => o, Err(e) => { diff --git a/src/commands_project.rs b/src/commands_project.rs index f237d6e..d0bca52 100644 --- a/src/commands_project.rs +++ b/src/commands_project.rs @@ -783,10 +783,12 @@ pub fn handle_doctor(json_mode: bool) -> Result<(), Box> }; let parts: Vec = ["kimi", "claude"] .iter() - .map(|tool| match crate::commands_statusline::receipt_last_ok(tool) { - Some(ts) => format!("{}: {}", tool, fmt_age(ts)), - None => format!("{}: never", tool), - }) + .map( + |tool| match crate::commands_statusline::receipt_last_ok(tool) { + Some(ts) => format!("{}: {}", tool, fmt_age(ts)), + None => format!("{}: never", tool), + }, + ) .collect(); emit( "usage receipts", diff --git a/src/observability.rs b/src/observability.rs index 05ac08b..01b58d2 100644 --- a/src/observability.rs +++ b/src/observability.rs @@ -263,7 +263,22 @@ pub const EVENT_RECEIPT_KIMI_SESSION_DIR_MISSING: &str = "cli.receipt.kimi_sessi pub const EVENT_RECEIPT_CLAUDE_PAYLOAD_UNRECOGNIZED: &str = "cli.receipt.claude_payload_unrecognized"; +// Binding auto-write visibility (2026-07-06 incident): an automatic post-sync +// reconcile bound a material-unreachable team VK as a provider Primary with no +// trace in audit_log or the internal log — root-causing required timestamp +// cross-referencing across three stores. Every non-user-initiated binding write +// now emits an event, and the material guard's skip decision logs at WARN. +// audit_log is deliberately NOT used here: its rows are HMAC-signed with a +// vault_key-derived audit_key, and most automatic binding paths have no key +// context (unsigned rows would trip verify_audit_log as tampering). +// See roadmap20260320/技术实现/update/20260706-绑定材料守卫与Web解锁态全量sync.md. +pub const EVENT_CLI_BINDING_AUTO_ASSIGNED: &str = "cli.binding.auto_assigned"; +pub const EVENT_CLI_BINDING_RECONCILED: &str = "cli.binding.reconciled"; +pub const EVENT_CLI_BINDING_AUTO_ASSIGN_SKIPPED: &str = + "cli.binding.auto_assign_skipped_unreachable"; + // UPPER_SNAKE error codes (logging-conventions.md). +pub const ERRCODE_BINDING_MATERIAL_UNREACHABLE: &str = "BINDING_MATERIAL_UNREACHABLE"; pub const ERRCODE_KIMI_STOP_PAYLOAD_UNRECOGNIZED: &str = "KIMI_STOP_PAYLOAD_UNRECOGNIZED"; pub const ERRCODE_KIMI_SESSION_DIR_MISSING: &str = "KIMI_SESSION_DIR_MISSING"; pub const ERRCODE_CLAUDE_STATUSLINE_PAYLOAD_UNRECOGNIZED: &str = diff --git a/src/profile_activation.rs b/src/profile_activation.rs index 134cc6a..07f794f 100644 --- a/src/profile_activation.rs +++ b/src/profile_activation.rs @@ -372,6 +372,7 @@ pub fn auto_assign_primaries_for_key( key_source_type: &str, key_source_ref: &str, providers: &[String], + audit: Option<&crate::audit::VerifiedVaultKey>, ) -> Result, String> { // 2026-05-08 Kimi family fill-empty-only(详见 update/20260508-Kimi-family // 互斥-active-env统一KIMI写入.md 决策 #2.1):family 内已有任何 primary @@ -386,6 +387,40 @@ pub fn auto_assign_primaries_for_key( .unwrap_or(false) }); + // Material guard (2026-07-06): a team VK whose key material is NOT reachable + // (no local ciphertext, not a claimed cluster central key, not a group VK) + // must never be auto-promoted to Primary. A Primary binding's semantic is + // "the proxy can serve requests with it" — binding an unreachable key gives + // 503 NO_ACTIVE_KEY at request time while the picker hides the key and the + // web vault still shows it as IN USE (the 2026-07-06 display-split incident). + // Better to leave the slot EMPTY (fail-visible, next reachable key or an + // explicit `aikey use` fills it) than to bind an unusable key. Explicit user + // switches are NOT gated here — `use`/web set-route have their own guard + // with a hard error (I_KEY_NOT_DELIVERED); automatic fills skip silently + // except for the WARN below. Same predicate as the picker / web set-route + // (`key_material_reachable`) so the three paths cannot diverge. + if key_source_type == "team" { + let on_cluster = crate::commands_account::read_cluster_node().is_some(); + let reachable = storage::get_virtual_key_cache(key_source_ref) + .ok() + .flatten() + .map(|e| e.key_material_reachable(on_cluster)) + .unwrap_or(false); + if !reachable { + let msg = format!( + "auto-assign skipped: team key {} has no reachable key material (run `aikey key sync`)", + key_source_ref + ); + crate::observability::log_warn_event( + crate::observability::EVENT_CLI_BINDING_AUTO_ASSIGN_SKIPPED, + &msg, + Some(crate::observability::ERRCODE_BINDING_MATERIAL_UNREACHABLE), + ); + eprintln!("[aikey] warning: {}", msg); + return Ok(Vec::new()); + } + } + let mut newly_assigned: Vec = Vec::new(); for raw in providers { @@ -406,7 +441,18 @@ pub fn auto_assign_primaries_for_key( &[canonical.clone()], key_source_type, key_source_ref, + audit, )?; + // Traceability (2026-07-06): automatic binding writes bypass the + // user-facing audit chain, so they MUST leave a structured event — + // this write was previously invisible in every log store. + crate::observability::log_event( + crate::observability::EVENT_CLI_BINDING_AUTO_ASSIGNED, + &format!( + "auto-assigned {} ({}) as primary for {}", + key_source_ref, key_source_type, canonical + ), + ); newly_assigned.push(canonical); } } @@ -425,11 +471,12 @@ pub fn auto_assign_primaries_for_key( /// on a batch of team keys. pub fn reconcile_provider_primaries_after_team_key_sync( synced_keys: &[(String, Vec)], // (virtual_key_id, supported_providers) + audit: Option<&crate::audit::VerifiedVaultKey>, ) -> Result)>, String> { let mut results: Vec<(String, Vec)> = Vec::new(); for (vk_id, providers) in synced_keys { - let assigned = auto_assign_primaries_for_key("team", vk_id, providers)?; + let assigned = auto_assign_primaries_for_key("team", vk_id, providers, audit)?; if !assigned.is_empty() { results.push((vk_id.clone(), assigned)); } @@ -449,6 +496,7 @@ pub fn reconcile_provider_primaries_after_team_key_sync( pub fn reconcile_provider_primary_after_key_removal( key_source_type: &str, key_source_ref: &str, + audit: Option<&crate::audit::VerifiedVaultKey>, ) -> Result, String> { // Remove all bindings referencing this key. let affected_providers = @@ -468,7 +516,17 @@ pub fn reconcile_provider_primary_after_key_removal( &[provider.clone()], &src_type, &src_ref, + audit, )?; + // Traceability (2026-07-06): automatic binding writes must + // leave a structured event (see auto_assign counterpart). + crate::observability::log_event( + crate::observability::EVENT_CLI_BINDING_RECONCILED, + &format!( + "reconcile promoted {} ({}) as primary for {} after removal of {} ({})", + src_ref, src_type, provider, key_source_ref, key_source_type + ), + ); actions.push(ReconcileAction { provider_code: provider.clone(), outcome: ReconcileOutcome::Replaced { @@ -990,6 +1048,11 @@ fn find_replacement_candidate( // Search team keys. let vk_entries = storage::list_virtual_key_cache().unwrap_or_default(); + // Material guard (2026-07-06): same predicate as the picker / web set-route / + // auto_assign — a replacement Primary must be servable by the proxy NOW. + // Promoting a material-unreachable VK here recreates the display-split + // incident (web "IN USE" vs picker hides it vs proxy 503). + let on_cluster = crate::commands_account::read_cluster_node().is_some(); for vk in &vk_entries { if vk.virtual_key_id == excluded_ref && excluded_type == "team" { continue; @@ -1001,6 +1064,9 @@ fn find_replacement_candidate( if vk.key_status != "active" { continue; } + if !vk.key_material_reachable(on_cluster) { + continue; + } let providers = if !vk.supported_providers.is_empty() { &vk.supported_providers } else if !vk.provider_code.is_empty() { diff --git a/src/ui_select.rs b/src/ui_select.rs index abc4467..7958f34 100644 --- a/src/ui_select.rs +++ b/src/ui_select.rs @@ -805,6 +805,13 @@ pub struct KeyCandidate { pub source_type: String, // DB value: "personal", "team", "personal_oauth_account" pub source_ref: String, pub display_type: Option, // UI display override (e.g., "oauth(f)"). None → auto from source_type. + /// Key material not reachable (team VK pending download / not claimed). + /// Rendered dimmed and NOT selectable — but still VISIBLE, and still shows + /// the radio/dot when it is the current binding. WHY show instead of hide + /// (2026-07-06, update/20260706-绑定材料守卫与Web解锁态全量sync.md): hiding + /// made the picker show "nothing selected" while the web vault showed the + /// same key as IN USE — two surfaces disagreeing about the active key. + pub pending: bool, } #[derive(Clone)] pub struct ProviderGroup { @@ -855,10 +862,11 @@ fn fallback_provider_tree( eprintln!(" {} \u{2192} {}", g.provider_code, cur); for (i, c) in g.candidates.iter().enumerate() { eprintln!( - " {} {} [{}]", + " {} {} [{}]{}", if g.selected == Some(i) { "(*)" } else { "( )" }, c.label, - c.source_type + c.source_type, + if c.pending { " (pending download)" } else { "" } ); } } @@ -877,7 +885,14 @@ fn fallback_provider_tree( if let Ok(n) = num.trim().parse::() { if let Some(g) = groups.iter_mut().find(|g| g.provider_code == prov.trim()) { if n >= 1 && n <= g.candidates.len() { - g.selected = Some(n - 1); + if g.candidates[n - 1].pending { + eprintln!( + " '{}' is pending download (run `aikey key sync` first).", + g.candidates[n - 1].label + ); + } else { + g.selected = Some(n - 1); + } } } } @@ -912,6 +927,18 @@ pub(crate) fn family_aware_toggle_expanded(groups: &mut Vec, gi: /// picker 层 family-mutex 视觉一致 (DB 层互斥仍由 set_provider_binding transaction 兜底)。 /// Caller 是 Space 键 on Candidate row。 pub(crate) fn family_aware_select(groups: &mut Vec, gi: usize, ci: usize) { + // Pending (material-unreachable) candidates are visible but not selectable — + // selecting one would recreate the "IN USE but proxy 503s" state the + // 2026-07-06 binding material guard exists to prevent. Space is a no-op; + // the row's dim style + "pending" tag tells the user why. + if groups[gi] + .candidates + .get(ci) + .map(|c| c.pending) + .unwrap_or(false) + { + return; + } let target_fam = crate::provider_registry::family_of(&groups[gi].provider_code); for (other_gi, og) in groups.iter_mut().enumerate() { if other_gi != gi && crate::provider_registry::family_of(&og.provider_code) == target_fam { @@ -1007,13 +1034,22 @@ pub(crate) fn format_tree_row( TreeRow::Candidate(gi, ci) => { let g = &groups[*gi]; let c = &g.candidates[*ci]; + // Pending selected state renders YELLOW, not green: "this IS your + // current binding but it cannot serve requests yet" (material not + // downloaded). Green would claim health it doesn't have. let radio = if g.selected == Some(*ci) { - "\x1b[32m(*)\x1b[0m" + if c.pending { + "\x1b[33m(*)\x1b[0m" + } else { + "\x1b[32m(*)\x1b[0m" + } } else { "( )" }; let label_raw = if is_cursor { format!("\x1b[1m{}\x1b[0m", c.label) + } else if c.pending { + format!("\x1b[2m{}\x1b[0m", c.label) } else { c.label.clone() }; @@ -1029,7 +1065,11 @@ pub(crate) fn format_tree_row( // This ensures the dot column is aligned regardless of type length. let type_padded = pad_visible(&format!("\x1b[90m{}\x1b[0m", display_type), type_col_w); let dot = if g.selected == Some(*ci) { - " \x1b[32m\u{25cf}\x1b[0m" + if c.pending { + " \x1b[33m\u{25cf}\x1b[0m" + } else { + " \x1b[32m\u{25cf}\x1b[0m" + } } else { "" }; @@ -1260,6 +1300,7 @@ mod family_grouping_tests { source_type: "personal".to_string(), source_ref: format!("k{}", i), display_type: None, + pending: false, }) .collect(), selected: None, From 08a0eb55a2041f891315801daf1cd61ff27bf7b3 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 00:50:54 -0700 Subject: [PATCH 23/42] fix(login): never block on stdin without a TTY (observed 1h+ hang) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unguarded stdin reads in `aikey login` hung forever when the process was spawned without a terminal (script / service / web bridge): 1. The Control Panel URL prompt — now behaves like json_mode with no TTY: takes the default and says so, instead of reading a silent pipe. 2. The post-expiry "Paste token" fallback — the poll loop runs the full server-issued expires_in window (~1h), then `if !json_mode` dropped into read_line with no TTY check. This is the exact shape of the live `aikey login` process found hung >1h on the Windows box. Now gated on stdin being a terminal; the existing non-interactive error path ("Use --token for non-interactive login") takes over. Parity audit 2026-07-07 P2-5. Interactive behavior unchanged. Co-Authored-By: Claude Fable 5 --- src/commands_account/mod.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 49335ce..f2c2be3 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -727,6 +727,14 @@ pub fn handle_login( eprintln!(" Control Panel: {}", default_url); } default_url + } else if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { + // No TTY (spawned from a script / service / web bridge): reading + // stdin here blocks forever on a silent pipe — observed live as an + // `aikey login` process hung >1h on Windows (parity audit 2026-07-07 + // P2-5). Behave like json_mode: take the default without prompting; + // a wrong default fails loudly downstream instead of hanging here. + eprintln!(" Control Panel: {} (no TTY — using default; pass --control-url to override)", default_url); + default_url } else { print!("Control Panel URL [{}]: ", default_url); io::stdout().flush()?; @@ -850,7 +858,12 @@ pub fn handle_login( std::thread::sleep(poll_interval); if SystemTime::now() > deadline { - if !json_mode { + // TTY guard (parity audit 2026-07-07 P2-5): without it, a + // scripted/service-spawned login that outlived the poll window + // fell into the paste-token read_line below and blocked forever + // on a silent stdin (observed live: `aikey login` hung >1h on + // Windows after the ~1h poll window expired). + if !json_mode && std::io::IsTerminal::is_terminal(&std::io::stdin()) { eprintln!(); eprintln!(" {}", "Session expired.".yellow()); eprintln!( From 85693ccebc12451d06ac5326abac8672892c8f68 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 00:59:45 -0700 Subject: [PATCH 24/42] feat: vault binding-material guard + web unlock-state full sync + Windows/Mac parity (alpha4) [aikey-cli] app install + watch command adjustments (Windows/Mac parity) --- src/commands_app/install.rs | 7 +++++-- src/commands_internal/internal_log.rs | 13 +++++++++++++ src/commands_watch.rs | 10 +++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/commands_app/install.rs b/src/commands_app/install.rs index efb6e00..446a270 100644 --- a/src/commands_app/install.rs +++ b/src/commands_app/install.rs @@ -901,8 +901,11 @@ fn cache_manifest(slug: &str, manifest: &Manifest) -> Result<(), Box Result> { - let home = std::env::var("HOME").map_err(|_| "HOME not set")?; - Ok(PathBuf::from(home).join(".aikey").join("apps-cache")) + // resolve_aikey_dir (HOME → USERPROFILE → dirs), NOT env::var("HOME"): + // native Windows has no HOME, so the old direct read hard-errored + // ("HOME not set") and `aikey app` was unusable there (parity audit + // 2026-07-07 P2-1 point fix). + Ok(crate::commands_account::resolve_aikey_dir().join("apps-cache")) } // --------------------------------------------------------------------------- diff --git a/src/commands_internal/internal_log.rs b/src/commands_internal/internal_log.rs index 6adecaf..790c27c 100644 --- a/src/commands_internal/internal_log.rs +++ b/src/commands_internal/internal_log.rs @@ -95,6 +95,10 @@ impl Writer { fn append(&mut self, line: &str) { self.rotate_if_needed(); + // Detect first creation BEFORE the open below creates the file, so + // the Windows ACL pass runs exactly once per file (icacls is ~50ms — + // fine at creation, unacceptable per append). + let newly_created = !self.path.exists(); let mut opts = OpenOptions::new(); opts.create(true).append(true); #[cfg(unix)] @@ -105,6 +109,15 @@ impl Writer { Ok(f) => f, Err(_) => return, // logging failures never propagate to the caller }; + // Windows analog of the 0o600 above (parity audit 2026-07-07 P3): + // without it the sensitive payload log inherited the default + // profile-dir ACL and was readable by other local users. On Unix the + // helper just re-applies the 0600 already set at open (harmless); + // errors are ignored by the same "logging never breaks the caller" + // rule. + if newly_created { + let _ = crate::storage_acl::enforce_owner_only_file(&self.path); + } let bytes = line.as_bytes(); if file.write_all(bytes).is_err() { return; diff --git a/src/commands_watch.rs b/src/commands_watch.rs index b377750..8faec48 100644 --- a/src/commands_watch.rs +++ b/src/commands_watch.rs @@ -1275,11 +1275,11 @@ fn spawn_wal_watcher( /// file / dead process both produce alive=false; the TUI renders the /// difference visually. fn probe_proxy() -> (Option, bool) { - let Some(home) = std::env::var_os("HOME") else { - return (None, false); - }; - let pid_path = PathBuf::from(home) - .join(".aikey") + // resolve_aikey_dir (HOME → USERPROFILE → dirs), NOT env::var("HOME"): + // native Windows has no HOME, so the old direct read returned early and + // `aikey watch` permanently rendered the proxy as dead there (parity + // audit 2026-07-07 P2-1 point fix). + let pid_path = crate::commands_account::resolve_aikey_dir() .join("run") .join("proxy.pid"); let Ok(content) = std::fs::read_to_string(&pid_path) else { From 39ff1940d1a80d78c996416d19aec4f17c2f57dd Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 01:32:34 -0700 Subject: [PATCH 25/42] fix(proxy): stop condemning an elevated-session proxy as OrphanedPort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live incident (2026-07-07, Windows box): a proxy started from an admin/SSH (elevated) session is healthy, with pidfile + sidecar meta correctly naming it — yet every NORMAL console's status / ensure-running / wrapper preflight reported "orphaned (port held by something we cannot manage)" and refused to route. Reproduced 100%: same second, same pid — elevated probe says running(healthy), Medium-IL probe says orphaned. Any customer who installs from an admin PowerShell hits this on every regular terminal afterwards. Root cause: Windows process_alive treated EVERY OpenProcess failure as "process dead", but ERROR_ACCESS_DENIED means "exists, no permission" (an elevated process's DACL grants only Administrators+SYSTEM; the filtered token's Administrators group is deny-only). The unix branch already handles the analogous EPERM as alive — the Windows port simply dropped that semantic. Layer 1 then took classify_dead_pid → port held by the "dead" pid → OrphanedPort/PortHeldByExternal, whose hint sent users hunting a nonexistent rogue listener. Fix, in three aligned pieces: - process_alive (Windows): ERROR_ACCESS_DENIED → alive (EPERM parity). - New proxy_proc::process_open_denied() — one neutral interface, per-OS bodies (EPERM / ERROR_ACCESS_DENIED) — so the decision tree can tell "identity UNVERIFIABLE (permission)" from "verified NOT ours". - New OrphanReason::OwnershipUnverifiablePermission + quorum path: when identity is unverifiable by permission but the readable evidence agrees (meta parses ∧ meta.pid == pidfile pid ∧ port owner == pid ∧ /health 200) classify Running — status shows the truth and preflight routes again. Anything short of the quorum → OrphanedPort with an elevation-aware hint (manage/stop from an elevated terminal, then `aikey proxy start` here), never the misleading external-holder text. birth_token stays uncompared (that is the very permission denied); kill paths remain physically blocked by the same OS permission, so the weaker classification cannot make Layer 2 signal a stranger. Fences: hint-wording pins, quorum-miss classification, unix EPERM alignment probe against pid 1, open-denied-false-for-self. Co-Authored-By: Claude Fable 5 --- src/proxy_proc.rs | 90 ++++++++++++++++++++++++++++++- src/proxy_state.rs | 128 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/src/proxy_proc.rs b/src/proxy_proc.rs index 71900d3..f9966f9 100644 --- a/src/proxy_proc.rs +++ b/src/proxy_proc.rs @@ -56,7 +56,9 @@ pub fn process_alive(pid: u32) -> bool { } #[cfg(windows)] { - use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_ACCESS_DENIED, INVALID_HANDLE_VALUE, + }; use windows_sys::Win32::System::Threading::{ OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, }; @@ -64,7 +66,17 @@ pub fn process_alive(pid: u32) -> bool { // failure; we check before CloseHandle. let h = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if h == 0 || h == INVALID_HANDLE_VALUE as isize { - return false; + // ERROR_ACCESS_DENIED = the PID exists but we may not open it — + // the canonical case is an ELEVATED proxy probed from a + // non-elevated session (an elevated process's DACL grants only + // Administrators+SYSTEM, and the caller's Administrators group + // is deny-only in a filtered token). This is the exact analog + // of the unix EPERM branch above: alive, just not ours to + // introspect. Treating it as dead sent Layer 1 down + // classify_dead_pid → OrphanedPort/PortHeldByExternal for a + // perfectly healthy proxy (live incident 2026-07-07, Windows + // box: admin-SSH-started proxy vs user's normal console). + return unsafe { GetLastError() } == ERROR_ACCESS_DENIED; } unsafe { CloseHandle(h) }; true @@ -76,6 +88,55 @@ pub fn process_alive(pid: u32) -> bool { } } +/// True when `pid` exists but this session lacks permission to open it for +/// introspection — the "privileged process probed from an unprivileged +/// session" shape: +/// +/// - **Windows**: `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` fails +/// with `ERROR_ACCESS_DENIED` (elevated/admin-session proxy vs the +/// user's normal console — live incident 2026-07-07). +/// - **Unix**: `kill(pid, 0)` fails with `EPERM` (e.g. a proxy started +/// under sudo probed from the user's shell). +/// +/// Callers use this to tell "identity UNVERIFIABLE (permission)" apart +/// from "identity verified as NOT ours (pid recycled)": the former gets +/// an elevation-aware hint and — with pidfile/meta/port/health quorum — +/// may still classify as Running; the latter stays a hard OrphanedPort. +pub fn process_open_denied(pid: u32) -> bool { + #[cfg(unix)] + { + let ret = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if ret == 0 { + return false; + } + matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) + } + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_ACCESS_DENIED, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: same OpenProcess probe pattern as process_alive above. + let h = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if h == 0 || h == INVALID_HANDLE_VALUE as isize { + return unsafe { GetLastError() } == ERROR_ACCESS_DENIED; + } + unsafe { CloseHandle(h) }; + false + } + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + false + } +} + /// Read the executable path of the given PID. /// /// Returns `None` when: @@ -581,6 +642,31 @@ mod tests { assert!(p.is_absolute(), "expected absolute path, got {p:?}"); } + /// Self-PID is always openable — process_open_denied must be false, or + /// the elevated-proxy quorum path would hijack ordinary classification. + #[test] + fn process_open_denied_false_for_self() { + assert!(!process_open_denied(std::process::id())); + } + + /// Unix leg of the 2026-07-07 elevated-proxy incident fix: pid 1 + /// (launchd/systemd) exists but kill(1,0) from an unprivileged test + /// runner yields EPERM — that must read as alive + open-denied, the + /// same semantics the Windows branch now applies to + /// ERROR_ACCESS_DENIED. Skipped when running as root (kill succeeds). + #[cfg(unix)] + #[test] + fn process_open_denied_detects_privileged_pid1() { + if unsafe { libc::geteuid() } == 0 { + return; // root can signal pid 1 — the EPERM shape doesn't exist + } + assert!(process_alive(1), "pid 1 must classify as alive"); + assert!( + process_open_denied(1), + "unprivileged probe of pid 1 must report open-denied (EPERM)" + ); + } + /// Self-PID is definitely not aikey-proxy (we are the test runner). /// Pinning so a future regression that always returns true would be /// caught immediately. diff --git a/src/proxy_state.rs b/src/proxy_state.rs index eb1277b..f4ecd6f 100644 --- a/src/proxy_state.rs +++ b/src/proxy_state.rs @@ -171,6 +171,17 @@ pub enum OrphanReason { /// No pidfile, but the configured port is held by some other process. PortHeldByExternal, + + /// The PID under our pidfile is alive but this session LACKS + /// PERMISSION to introspect it (identity/birth_token unverifiable) — + /// canonically a proxy started from an elevated/admin session probed + /// from the user's normal console (Windows), or a sudo-started proxy + /// probed from the user's shell (Unix). Distinct from + /// `PidRecycledToNonProxy` because the process may well be OUR + /// healthy proxy; the hint must say "manage it from an elevated + /// terminal", not "an unrelated program took the port". + /// Live incident 2026-07-07 (Windows box, admin-SSH-started proxy). + OwnershipUnverifiablePermission, } impl OrphanReason { @@ -209,6 +220,21 @@ impl OrphanReason { "port {port} is held by {owner}, which is not an aikey-proxy we manage. \ Stop that listener or change `listen.port` in aikey-proxy.yaml" ), + OrphanReason::OwnershipUnverifiablePermission => format!( + "{owner} matches our pidfile but this session lacks permission to verify it — \ + it was likely started from an elevated/admin session. Manage it from an \ + elevated terminal (`aikey proxy status` / `aikey proxy stop` there), or stop \ + it there ({kill_cmd}) and re-run `aikey proxy start` from this session", + kill_cmd = owner_pid + .map(crate::proxy_proc::kill_command_hint) + .unwrap_or_else(|| { + if cfg!(windows) { + "taskkill /F /PID ".into() + } else { + "sudo kill ".into() + } + }), + ), } } } @@ -507,6 +533,14 @@ pub fn compute_proxy_state(inputs: &StateInputs) -> ProxyState { } // 2b. PID is alive. Check identity (I-7a). if !crate::proxy_proc::is_aikey_proxy(pid) { + // Distinguish "verified as NOT ours" from "UNVERIFIABLE due to + // permissions" (elevated-session proxy probed from a normal + // console — live incident 2026-07-07). The latter may still be + // our healthy proxy; decide by pidfile/meta/port/health quorum + // instead of condemning it to a misleading OrphanedPort. + if crate::proxy_proc::process_open_denied(pid) { + return classify_permission_denied_pid(pid, port, &inputs.meta_path, inputs); + } // PID was reused for a non-aikey-proxy process. Not ours. return classify_alive_unowned_pid(pid, port, OrphanReason::PidRecycledToNonProxy); } @@ -661,6 +695,54 @@ fn classify_dead_pid(stale_pid: u32, port: u16) -> ProxyState { /// - Port probe tooling failed → `Crashed`, same degradation policy as /// [`classify_dead_pid`] and the no-pidfile branch: better to attempt /// a start (its own bind surfaces real conflicts) than refuse forever. +/// Helper: pidfile PID is alive but this session LACKS PERMISSION to +/// introspect it (see [`crate::proxy_proc::process_open_denied`]). +/// +/// Why not a flat OrphanedPort (2026-07-07 live incident): a proxy started +/// from an elevated/admin session is healthy and correctly recorded in +/// pidfile + sidecar meta, yet every normal-console `status` / +/// `ensure-running` / wrapper preflight condemned it as "port held by +/// something we cannot manage" and refused to route — a main-path lockout +/// for any customer who installs from an admin PowerShell. +/// +/// Decision: accept as `Running` on a strict quorum of the evidence this +/// session CAN still read — +/// meta parses ∧ meta.pid == pidfile pid ∧ port owner == that pid ∧ +/// /health answers 200. +/// The birth_token can't be compared (that's exactly the permission that +/// was denied), so this is weaker than the verified path; the quorum makes +/// the impostor scenario require a recycled PID that (a) our meta+pidfile +/// both name, (b) binds our configured port, and (c) speaks our /health — +/// while Layer 2's kill paths are still physically blocked by the same OS +/// permission that blocked verification (stop surfaces its own OS error). +/// Anything short of the quorum → OrphanedPort with the elevation-aware +/// hint, never the misleading "not an aikey-proxy we manage". +fn classify_permission_denied_pid( + pid: u32, + port: u16, + meta_path: &std::path::Path, + inputs: &StateInputs, +) -> ProxyState { + let meta_pid_matches = matches!(read_meta_at(meta_path), Ok(m) if m.pid == pid); + let port_owner = crate::proxy_proc::port_owner_pid(port).ok().flatten(); + if meta_pid_matches && port_owner == Some(pid) { + let healthy = + crate::proxy_proc::http_health_ok(port, std::time::Duration::from_millis(500)); + if healthy { + return ProxyState::Running { + pid, + port, + listen_addr: inputs.listen_addr.clone(), + }; + } + } + ProxyState::OrphanedPort { + port, + owner_pid: port_owner.or(Some(pid)), + reason: OrphanReason::OwnershipUnverifiablePermission, + } +} + fn classify_alive_unowned_pid(pidfile_pid: u32, port: u16, reason: OrphanReason) -> ProxyState { match crate::proxy_proc::port_owner_pid(port) { Ok(Some(owner)) if owner == pidfile_pid => ProxyState::OrphanedPort { @@ -754,6 +836,52 @@ mod tests { ); } + #[test] + fn orphan_reason_hint_for_permission_denied_names_elevation_not_external() { + // 2026-07-07 live incident: an elevated-session proxy probed from a + // normal console must NOT be described as "not an aikey-proxy we + // manage" — that sent users hunting a nonexistent rogue listener. + // Pin that the hint names the elevation situation + both remedies. + let r = OrphanReason::OwnershipUnverifiablePermission; + let hint = r.hint(27200, Some(21860)); + assert!(hint.contains("21860"), "hint must name the pid"); + assert!( + hint.contains("elevated"), + "hint must attribute the failure to session elevation, got: {hint}" + ); + assert!( + hint.contains("aikey proxy start"), + "hint must include the recovery command for THIS session" + ); + assert!( + !hint.contains("not an aikey-proxy we manage"), + "must not reuse the misleading external-holder wording" + ); + } + + #[test] + fn permission_denied_pid_without_quorum_is_orphaned_with_permission_reason() { + // Quorum fails here (no meta file, no port listener) → must fall to + // OrphanedPort carrying the elevation-aware reason, never Running + // and never PortHeldByExternal. + let dir = std::env::temp_dir().join(format!("aikey_permdenied_test_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let inputs = StateInputs { + pid_path: dir.join("proxy.pid"), + meta_path: dir.join("proxy-meta.json"), // absent → meta_pid_matches = false + listen_addr: "127.0.0.1:1".to_string(), // port 1: nothing listens + }; + let state = classify_permission_denied_pid(999_999, 1, &inputs.meta_path, &inputs); + match state { + ProxyState::OrphanedPort { reason, owner_pid, .. } => { + assert_eq!(reason, OrphanReason::OwnershipUnverifiablePermission); + assert_eq!(owner_pid, Some(999_999), "falls back to the pidfile pid as owner"); + } + other => panic!("expected OrphanedPort, got {other:?}"), + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn orphan_reason_hint_for_legacy_pidfile_explains_upgrade_friction() { // Legacy upgrade is the most user-confusing OrphanedPort cause — From 695561a1b6958e624eedfe9447aad89397a4a851 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 01:34:45 -0700 Subject: [PATCH 26/42] fix(proxy): permission-denied branch must preserve the reboot-lockout rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regression pins from the 2026-07-01 fix caught it immediately: the new open-denied path classified a recycled PRIVILEGED pid (init/launchd after reboot — exactly an open-denied pid) with a FREE port as OrphanedPort, re-introducing the start/stop lockout. Rework classify_permission_denied_pid to be port-occupancy first, mirroring classify_alive_unowned_pid: port free → Crashed (recoverable); held by a third pid → PortHeldByExternal naming the real listener; only when the denied pid itself holds the port does the elevated-proxy quorum run. Fences updated + new arm test. Co-Authored-By: Claude Fable 5 --- src/proxy_state.rs | 104 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 24 deletions(-) diff --git a/src/proxy_state.rs b/src/proxy_state.rs index f4ecd6f..7526c1e 100644 --- a/src/proxy_state.rs +++ b/src/proxy_state.rs @@ -717,29 +717,46 @@ fn classify_dead_pid(stale_pid: u32, port: u16) -> ProxyState { /// permission that blocked verification (stop surfaces its own OS error). /// Anything short of the quorum → OrphanedPort with the elevation-aware /// hint, never the misleading "not an aikey-proxy we manage". +/// Port-occupancy first, mirroring [`classify_alive_unowned_pid`] — the +/// 2026-07-01 reboot-lockout rules MUST survive this branch too (a +/// rebooted machine routinely leaves the pidfile naming a PRIVILEGED +/// recycled pid like init/launchd, which is exactly an open-denied pid): +/// - port free → `Crashed` (stale files, recoverable — anything else +/// re-introduces the start/stop lockout the 2026-07-01 fix removed); +/// - port held by a third pid → `PortHeldByExternal` naming the real +/// listener; +/// - only when the denied pid ITSELF holds the port does the +/// elevated-proxy question exist at all → quorum decides. fn classify_permission_denied_pid( pid: u32, port: u16, meta_path: &std::path::Path, inputs: &StateInputs, ) -> ProxyState { - let meta_pid_matches = matches!(read_meta_at(meta_path), Ok(m) if m.pid == pid); - let port_owner = crate::proxy_proc::port_owner_pid(port).ok().flatten(); - if meta_pid_matches && port_owner == Some(pid) { - let healthy = - crate::proxy_proc::http_health_ok(port, std::time::Duration::from_millis(500)); - if healthy { - return ProxyState::Running { - pid, + match crate::proxy_proc::port_owner_pid(port).ok().flatten() { + Some(owner) if owner == pid => { + let meta_pid_matches = matches!(read_meta_at(meta_path), Ok(m) if m.pid == pid); + if meta_pid_matches + && crate::proxy_proc::http_health_ok(port, std::time::Duration::from_millis(500)) + { + return ProxyState::Running { + pid, + port, + listen_addr: inputs.listen_addr.clone(), + }; + } + ProxyState::OrphanedPort { port, - listen_addr: inputs.listen_addr.clone(), - }; + owner_pid: Some(pid), + reason: OrphanReason::OwnershipUnverifiablePermission, + } } - } - ProxyState::OrphanedPort { - port, - owner_pid: port_owner.or(Some(pid)), - reason: OrphanReason::OwnershipUnverifiablePermission, + Some(owner) => ProxyState::OrphanedPort { + port, + owner_pid: Some(owner), + reason: OrphanReason::PortHeldByExternal, + }, + None => ProxyState::Crashed { stale_pid: pid }, } } @@ -860,25 +877,64 @@ mod tests { } #[test] - fn permission_denied_pid_without_quorum_is_orphaned_with_permission_reason() { - // Quorum fails here (no meta file, no port listener) → must fall to - // OrphanedPort carrying the elevation-aware reason, never Running - // and never PortHeldByExternal. - let dir = std::env::temp_dir().join(format!("aikey_permdenied_test_{}", std::process::id())); + fn permission_denied_pid_with_free_port_stays_crashed_not_lockout() { + // The open-denied branch must PRESERVE the 2026-07-01 reboot-lockout + // rules: a rebooted machine routinely leaves the pidfile naming a + // privileged recycled pid (init/launchd — exactly an open-denied + // pid) with the port FREE. That must classify Crashed (recoverable), + // never OrphanedPort — otherwise start/stop lock out again. + let dir = + std::env::temp_dir().join(format!("aikey_permdenied_test_{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let inputs = StateInputs { pid_path: dir.join("proxy.pid"), - meta_path: dir.join("proxy-meta.json"), // absent → meta_pid_matches = false + meta_path: dir.join("proxy-meta.json"), // absent listen_addr: "127.0.0.1:1".to_string(), // port 1: nothing listens }; let state = classify_permission_denied_pid(999_999, 1, &inputs.meta_path, &inputs); + assert_eq!( + state, + ProxyState::Crashed { stale_pid: 999_999 }, + "free port + open-denied pid must stay recoverable" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn permission_denied_pid_holding_port_without_meta_is_permission_orphan() { + // The denied pid ITSELF holds the port but the quorum is incomplete + // (no meta) → OrphanedPort with the elevation-aware reason, never + // Running and never the misleading PortHeldByExternal. We bind a + // listener from this test and claim its OWN pid... we cannot make + // our own pid open-denied, so exercise the arm via the classifier + // directly with the listener's real owner pid = our pid. + let dir = + std::env::temp_dir().join(format!("aikey_permdenied_hold_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let inputs = StateInputs { + pid_path: dir.join("proxy.pid"), + meta_path: dir.join("proxy-meta.json"), // absent → quorum incomplete + listen_addr: format!("127.0.0.1:{port}"), + }; + let state = + classify_permission_denied_pid(std::process::id(), port, &inputs.meta_path, &inputs); match state { - ProxyState::OrphanedPort { reason, owner_pid, .. } => { + ProxyState::OrphanedPort { + reason, owner_pid, .. + } => { assert_eq!(reason, OrphanReason::OwnershipUnverifiablePermission); - assert_eq!(owner_pid, Some(999_999), "falls back to the pidfile pid as owner"); + assert_eq!(owner_pid, Some(std::process::id())); } - other => panic!("expected OrphanedPort, got {other:?}"), + ProxyState::Crashed { .. } => { + // Port-owner tooling unavailable (no lsof) — degraded env, + // same skip policy as the neighboring port-owner tests. + eprintln!("[skip] port owner lookup degraded"); + } + other => panic!("expected OrphanedPort/OwnershipUnverifiablePermission, got {other:?}"), } + drop(listener); let _ = std::fs::remove_dir_all(&dir); } From ed753adeb14c71d33a7abaa83d924ca6ef49fd73 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 01:35:17 -0700 Subject: [PATCH 27/42] fix(proxy-lifecycle): add CREATE_NO_WINDOW to the background proxy spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lateral sweep of the 2026-07-07 window-flash class: this was the only in-tree spawn that both omits window suppression and creates a LONG-LIVED console-subsystem child (aikey-proxy). If the spawning aikey process ever runs console-less (web-bridge / doctor auto-restart / service contexts), the proxy would materialize a persistent terminal window on the desktop. CREATE_NO_WINDOW composes with the existing CREATE_NEW_PROCESS_GROUP and — unlike the deliberately-rejected DETACHED_PROCESS — keeps handle inheritance, so the stderr→startup-log redirection is unaffected. Co-Authored-By: Claude Fable 5 --- src/proxy_lifecycle.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/proxy_lifecycle.rs b/src/proxy_lifecycle.rs index 13b7d4f..df53595 100644 --- a/src/proxy_lifecycle.rs +++ b/src/proxy_lifecycle.rs @@ -1157,7 +1157,19 @@ fn start_proxy_locked_inner( { use std::os::windows::process::CommandExt; const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; - cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); + // CREATE_NO_WINDOW (2026-07-07 lateral sweep, window-flash class): + // aikey-proxy is a console-subsystem child. When THIS aikey process + // itself runs console-less (spawned by the web bridge / a service + // context), a child without this flag materializes a visible + // terminal window — and since the proxy is long-lived, it would sit + // on the desktop permanently. Unlike DETACHED_PROCESS (rejected + // above), CREATE_NO_WINDOW keeps handle inheritance intact, so the + // stderr→startup-log redirection still works; when run from a real + // terminal the child gets its own windowless console instead of the + // user's (no output was ever expected there — stderr goes to the + // log). Composes with CREATE_NEW_PROCESS_GROUP. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW); } let child = cmd From 93bfadbc9fac83c7139028d7992ebdecc7455a04 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 01:48:00 -0700 Subject: [PATCH 28/42] fix(cli): guard remaining non-TTY stdin reads that could hang automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lateral sweep of the P2-5 hang class (unguarded io::stdin().read_line on a silent-but-open stdin) found two live HANG-RISK sites; both now bail cleanly when stdin is not a terminal: 1. ui_select fallback_select / fallback_multi_select — the fallback is entered precisely when stderr is NOT a tty, then read stdin unconditionally. A scripted `aikey use` / `aikey auth login` with no explicit argument hung forever. Returns Cancelled with an actionable hint; piped-selection was never a supported contract (all documented automation passes explicit args, and interactive pickers elsewhere are already gated on stdin tty — see `aikey add`). 2. prompt_display_identity — the device_code OAuth flow reaches this prompt with no earlier tty-gated read to fail first. Skips (display name is optional; --alias remains the non-interactive path). Found by the 2026-07-07 lateral sweep; interactive behavior unchanged. Co-Authored-By: Claude Fable 5 --- src/commands_auth/mod.rs | 14 ++++++++++++++ src/ui_select.rs | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/commands_auth/mod.rs b/src/commands_auth/mod.rs index 75b217c..eb02a1f 100644 --- a/src/commands_auth/mod.rs +++ b/src/commands_auth/mod.rs @@ -900,6 +900,20 @@ fn set_display_identity( fn prompt_display_identity(base: &str, account_id: &str) -> Result<(), Box> { const MAX_DISPLAY_LEN: usize = 256; + // No stdin terminal → skip instead of read_line (lateral sweep + // 2026-07-07, same class as the P2-5 login hang). The device_code OAuth + // flow reaches this prompt with no earlier tty-gated read, so a + // scripted login hung forever here on a silent-but-open stdin. Skipping + // matches the documented non-interactive contract (display name is + // optional; pass --alias to set it without interaction). + { + use std::io::IsTerminal; + if !std::io::stdin().is_terminal() { + eprintln!(" non-interactive session: display name skipped (use --alias to set one)"); + return Ok(()); + } + } + eprint!("\nEnter a display name for this account (e.g. email or alias, Enter to skip): "); io::stderr().flush()?; diff --git a/src/ui_select.rs b/src/ui_select.rs index 7958f34..31ee77e 100644 --- a/src/ui_select.rs +++ b/src/ui_select.rs @@ -83,6 +83,22 @@ fn fallback_select( items: &[String], selectable: &[bool], ) -> Result> { + // No stdin terminal → don't block on read_line (lateral sweep + // 2026-07-07, same class as the P2-5 login hang): this fallback is + // reached precisely when stderr is NOT a tty, so an automation-spawned + // `aikey use` / `aikey auth login` with no explicit argument landed + // here and hung forever on a silent-but-open stdin. Piped-selection is + // not a supported contract (all documented automation passes explicit + // args; interactive pickers are gated on stdin tty elsewhere, see + // main.rs `aikey add`). Cancelled keeps the callers' existing + // "nothing chosen" handling. + { + use std::io::IsTerminal; + if !io::stdin().is_terminal() { + eprintln!("[aikey] non-interactive session: pass the selection explicitly (e.g. an alias / provider argument) instead of the picker"); + return Ok(SelectResult::Cancelled); + } + } eprintln!("Select a key (enter number):"); for (i, item) in items.iter().enumerate() { if selectable[i] { @@ -488,6 +504,15 @@ pub(crate) fn apply_mutex_on_toggle(checked: &mut [bool], idx: usize, mutex_grou fn fallback_multi_select( items: &[String], ) -> Result> { + // Same no-stdin-terminal guard as fallback_select above (2026-07-07 + // lateral sweep) — never block a non-interactive session on read_line. + { + use std::io::IsTerminal; + if !io::stdin().is_terminal() { + eprintln!("[aikey] non-interactive session: pass protocols explicitly instead of the picker"); + return Ok(MultiSelectResult::Cancelled); + } + } eprintln!("Select protocol types (comma-separated numbers):"); for (i, item) in items.iter().enumerate() { eprintln!(" [{}] {}", i + 1, item); From 4dc28a7102beb916145905c5178a76714e31f32c Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 02:15:35 -0700 Subject: [PATCH 29/42] perf(vault): gate pre-dispatch schema replay on a per-build marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault deliberately has no migration ledger — schema convergence = replaying the whole idempotent baseline (CREATE TABLE IF NOT EXISTS / guarded ALTER / INSERT OR IGNORE). Several of those statements are real write attempts that take SQLite's write lock even when they no-op, and the pre-dispatch hook ran the replay on EVERY command. Humans never noticed; the web console's `_internal` bridge children (several per vault-page load + background polls) queued on that write lock — live Windows measurements: 52ms solo vs 1.2-3.3s under normal background concurrency, 2.7-7.2s at the endpoint, i.e. "the vault page crawls". The schema can only change when the binary changes, so convergence is now once-per-build instead of once-per-command: - config['schema.replayed_by'] records ":" of the build that last replayed (revision included so dirty dev builds sharing a version string still re-replay after rebuild). - Pre-dispatch does a READ-ONLY probe of that marker (no write lock); match → skip, anything else (legacy vault, other build, unreadable) → full replay + stamp. First contact of every existing install replays once and then fast-paths. - `aikey db upgrade` still force-replays unconditionally (the manual self-heal escape hatch) and stamps; `aikey db rollback` clears the marker so the next command re-converges, preserving pre-marker semantics for same-binary rollbacks. Trade-off (user-approved 2026-07-07): content self-heal drops from every-command to once-per-build; hand-damaged vault rows between upgrades now need `aikey db upgrade`. Installer stays responsible for server DBs only — vault stays CLI-owned because it doesn't exist at install time and binaries can arrive without the installer (online update / rollback / manual replacement). Fences: marker lifecycle (replay → SkippedFresh → foreign-marker replay → clear re-arms) + legacy no-marker vault converges once then skips. Co-Authored-By: Claude Fable 5 --- src/main.rs | 18 ++++- src/migrations.rs | 176 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2252a6d..e2982b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1088,9 +1088,14 @@ fn run_command(cli: &Cli) -> Result<(), Box> { _ => { if let Ok(vault_path) = storage::get_vault_path() { if vault_path.exists() { - if let Ok(conn) = rusqlite::Connection::open(&vault_path) { - let _ = migrations::upgrade_all(&conn); - } + // Marker-gated: read-only probe when this build already + // replayed, full idempotent replay otherwise. The old + // unconditional upgrade_all took the vault WRITE lock on + // every command — `_internal` bridge children spawned by + // the web console then queued on that lock and the vault + // page crawled (2026-07-07, see ensure_schema_current + // docs). `aikey db upgrade` still force-replays. + let _ = migrations::ensure_schema_current(&vault_path); } } } @@ -1152,6 +1157,9 @@ fn run_command(cli: &Cli) -> Result<(), Box> { let conn = rusqlite::Connection::open(&vault_path) .map_err(|e| format!("Failed to open vault: {}", e))?; migrations::upgrade_all(&conn)?; + // Explicit upgrade counts as this build's replay — stamp the + // marker so the next command takes the read-only fast path. + migrations::stamp_schema_marker(&conn)?; eprintln!("[db upgrade] Done"); } DbAction::Rollback { to } => { @@ -1165,6 +1173,10 @@ fn run_command(cli: &Cli) -> Result<(), Box> { let conn = rusqlite::Connection::open(&vault_path) .map_err(|e| format!("Failed to open vault: {}", e))?; migrations::rollback_to(&conn, &to)?; + // Drop the replay marker so the next command re-converges — + // preserves the pre-marker behavior for same-binary rollbacks + // (the documented flow installs an older binary next anyway). + migrations::clear_schema_marker(&conn); eprintln!("[db rollback] Vault schema rolled back to {}", to); } }, diff --git a/src/migrations.rs b/src/migrations.rs index e741ae3..77ff6da 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -54,6 +54,112 @@ pub fn upgrade_all(conn: &Connection) -> Result<(), String> { Ok(()) } +// --------------------------------------------------------------------------- +// Per-build replay marker (vault-page lock-convoy fix, 2026-07-07) +// --------------------------------------------------------------------------- + +/// `config` table key recording which binary BUILD last replayed the schema. +const SCHEMA_REPLAYED_BY_KEY: &str = "schema.replayed_by"; + +/// Identity of THIS binary for the replay marker. Includes the git revision +/// (not just the version string) so dev/dirty builds sharing a version +/// number still re-replay after a rebuild — a stale skip on a schema- +/// changing dev build would be a debugging nightmare. +fn current_schema_marker() -> String { + format!( + "{}:{}", + env!("CARGO_PKG_VERSION"), + env!("AIKEY_BUILD_REVISION") + ) +} + +/// Outcome of [`ensure_schema_current`] — exposed so tests can assert the +/// fast path actually skipped. +#[derive(Debug, PartialEq, Eq)] +pub enum SchemaEnsure { + /// Marker matched this build — no write connection was opened. + SkippedFresh, + /// Full idempotent replay ran (first contact of this build, or marker + /// unreadable) and the marker was updated. + Replayed, +} + +/// Pre-dispatch schema convergence with a read-only fast path. +/// +/// Why this exists (2026-07-07 vault-page lock convoy, Windows live box): +/// the vault has NO migration ledger — convergence = replaying the whole +/// idempotent baseline. Several statements in that replay (`INSERT OR +/// IGNORE` self-heal rows) are real write attempts that take SQLite's +/// write lock even when they end up ignoring. Running that on EVERY +/// command was fine for humans, but `_internal` bridge children are +/// spawned by the web console many times per page load plus background +/// polls — N concurrent replays queued on the vault write lock inflated a +/// 52ms call to 1.2-3.3s (measured), and the vault page crawled. +/// +/// The schema can only change when the BINARY changes, so the replay is +/// now gated on a per-build marker in the `config` table: +/// - marker == this build (read-only probe, no write lock) → skip; +/// - anything else (missing marker / other build / unreadable / legacy +/// vault without the row) → full replay, then stamp the marker. +/// +/// Trade-off (user-approved 2026-07-07): self-heal frequency drops from +/// "every command" to "once per binary build". Hand-damaged vault content +/// between upgrades now needs an explicit `aikey db upgrade` (which still +/// force-replays unconditionally) instead of healing on the next command. +/// +/// Errors are returned for the caller to ignore-or-log — same "schema +/// convergence must never block the command itself" stance as before. +pub fn ensure_schema_current(vault_path: &std::path::Path) -> Result { + let marker = current_schema_marker(); + + // Fast path: read-only probe. Any failure (no config table yet, BLOB + // value, locked file, corrupt db) simply falls through to the replay, + // which owns real error handling. + let probe = Connection::open_with_flags(vault_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .ok() + .and_then(|conn| { + conn.query_row( + "SELECT CAST(value AS TEXT) FROM config WHERE key = ?1", + [SCHEMA_REPLAYED_BY_KEY], + |row| row.get::<_, String>(0), + ) + .ok() + }); + if probe.as_deref() == Some(marker.as_str()) { + return Ok(SchemaEnsure::SkippedFresh); + } + + let conn = + Connection::open(vault_path).map_err(|e| format!("open vault for schema replay: {}", e))?; + upgrade_all(&conn)?; + stamp_schema_marker(&conn)?; + Ok(SchemaEnsure::Replayed) +} + +/// Record that THIS build has replayed the schema. Also called by the +/// explicit `aikey db upgrade` path so a manual force-replay flips the +/// next command onto the read-only fast path. +pub fn stamp_schema_marker(conn: &Connection) -> Result<(), String> { + conn.execute( + "INSERT OR REPLACE INTO config (key, value) VALUES (?1, ?2)", + rusqlite::params![SCHEMA_REPLAYED_BY_KEY, current_schema_marker()], + ) + .map_err(|e| format!("stamp schema marker: {}", e))?; + Ok(()) +} + +/// Remove the replay marker. Called after `aikey db rollback` so the next +/// command re-converges the schema (preserves the pre-marker behavior +/// where any command after a same-binary rollback immediately re-upgraded; +/// the documented rollback flow installs an older binary next, whose own +/// marker differs anyway). +pub fn clear_schema_marker(conn: &Connection) { + let _ = conn.execute( + "DELETE FROM config WHERE key = ?1", + [SCHEMA_REPLAYED_BY_KEY], + ); +} + /// Rollback vault schema from current state down to target version. /// Walks the registry in reverse, calling rollback() for each version /// that is AFTER the target. Supports crossing multiple versions. @@ -2293,4 +2399,74 @@ mod tests { "duplicate route_token must be rejected by UNIQUE constraint" ); } + + /// 2026-07-07 vault-page lock-convoy fix: the marker lifecycle. + /// First contact replays + stamps; second call takes the read-only + /// fast path (SkippedFresh — this is the load-bearing assertion: it + /// proves no write connection is needed once stamped); clearing the + /// marker (db rollback path) re-arms the replay. + #[test] + fn schema_marker_gates_replay_and_clear_rearms() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault.db"); + // Create the file with a baseline replay so the fast path has a + // config table to probe. + assert_eq!( + ensure_schema_current(&vault).unwrap(), + SchemaEnsure::Replayed, + "first contact must replay" + ); + assert_eq!( + ensure_schema_current(&vault).unwrap(), + SchemaEnsure::SkippedFresh, + "same build must skip via the read-only probe" + ); + // Tampered marker (≈ different build stamped it) → replay again. + { + let conn = Connection::open(&vault).unwrap(); + conn.execute( + "UPDATE config SET value='0.0.0:other' WHERE key='schema.replayed_by'", + [], + ) + .unwrap(); + } + assert_eq!( + ensure_schema_current(&vault).unwrap(), + SchemaEnsure::Replayed, + "foreign marker must trigger a fresh replay" + ); + // Rollback path clears the marker → next command re-converges. + { + let conn = Connection::open(&vault).unwrap(); + clear_schema_marker(&conn); + } + assert_eq!( + ensure_schema_current(&vault).unwrap(), + SchemaEnsure::Replayed, + "cleared marker must re-arm the replay" + ); + } + + /// The replay stays idempotent under the marker scheme: a legacy vault + /// (schema present, no marker row — every pre-fix install looks like + /// this) must replay once without error and then fast-path. + #[test] + fn legacy_vault_without_marker_replays_once_then_skips() { + let dir = tempfile::tempdir().unwrap(); + let vault = dir.path().join("vault.db"); + { + // Simulate a pre-marker vault: full schema, no marker row. + let conn = Connection::open(&vault).unwrap(); + upgrade_all(&conn).unwrap(); + } + assert_eq!( + ensure_schema_current(&vault).unwrap(), + SchemaEnsure::Replayed, + "legacy vault (no marker) must converge via replay" + ); + assert_eq!( + ensure_schema_current(&vault).unwrap(), + SchemaEnsure::SkippedFresh + ); + } } From c4be6865003695e2593b72f67841008aeb99156d Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 02:25:09 -0700 Subject: [PATCH 30/42] fix(env): detect injected provider configs semantically, not by marker comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aikey env` stopped listing the codex/kimi configs it had injected. Root cause is a reader/writer truth-source split: the injectors migrated to structural toml_edit writes that emit NO `# BEGIN aikey` marker comments (kimi keyed on the hook-command tail, codex_merge/codex_remove keyed on our keys), but injected_provider_toml_paths still detected by marker text. Live trigger: codex CLI re-serializes config.toml on its own writes and drops ALL comments — on 2026-07-06 it washed out the legacy marker while every aikey key survived, and the listing went blank. Detection now keys on the same semantic identity the REMOVE paths use: - codex: codex_content_has_aikey mirrors codex_remove's predicates ([model_providers.aikey] / model_provider=="aikey" / openai_base_url ending in /openai); a lockstep test pins the equivalence so display ("wired") and `aikey unuse` ("what gets stripped") can never disagree again. - kimi: KIMI_HOOK_COMMAND_MARKER (the established semantic key). - AIKEY_BEGIN still honored for legacy never-rewritten files. Verified live against the real ~/.codex/config.toml that reproduced the report. Note: write/remove paths were already structural — this was a display-only blindness, no stale-config risk. Co-Authored-By: Claude Fable 5 --- src/commands_account/shell_integration.rs | 118 ++++++++++++++++++++-- 1 file changed, 109 insertions(+), 9 deletions(-) diff --git a/src/commands_account/shell_integration.rs b/src/commands_account/shell_integration.rs index fbce967..521b25c 100644 --- a/src/commands_account/shell_integration.rs +++ b/src/commands_account/shell_integration.rs @@ -370,20 +370,28 @@ pub(crate) fn uninstall_kimi_hook() -> KimiUninstallOutcome { /// its managed region. Used by `aikey env` so users can see at a glance /// which provider CLIs are wired up. /// -/// Detection is by AIKEY_BEGIN marker presence — the same source of truth -/// used by `*_status()` and `unconfigure_*` so display can never disagree -/// with the actual injection state. +/// Detection is SEMANTIC (2026-07-07 fix), not marker-text: the injectors +/// migrated to structural toml_edit writes that emit NO `# BEGIN aikey` +/// comments (kimi hook merge keyed on the command tail; codex_merge / +/// codex_remove keyed on our keys), and third-party CLIs re-serialize +/// their configs dropping ALL comments anyway (observed live: codex +/// rewrote config.toml on 2026-07-06 and the legacy marker vanished while +/// every aikey key survived). Marker-based detection therefore went +/// permanently blind and `aikey env` silently stopped listing injected +/// configs. We now key on the same semantic identity the REMOVE paths use +/// — so display stays in lockstep with what `aikey unuse` would actually +/// strip. AIKEY_BEGIN is still honored for legacy never-rewritten files. /// -/// Files that don't exist or don't contain the marker are omitted; the +/// Files that don't exist or carry nothing of ours are omitted; the /// caller can render "(none)" when the result is empty. pub fn injected_provider_toml_paths() -> Vec<(&'static str, std::path::PathBuf)> { let mut out = Vec::new(); let (kimi, _) = kimi_config_paths(); - if file_has_aikey_marker(&kimi) { + if kimi_config_has_aikey(&kimi) { out.push(("kimi", kimi)); } let (codex, _) = codex_config_paths(); - if file_has_aikey_marker(&codex) { + if codex_config_has_aikey(&codex) { out.push(("codex", codex)); } // Claude lives in a JSON settings file, not a TOML — but it's still a @@ -397,12 +405,45 @@ pub fn injected_provider_toml_paths() -> Vec<(&'static str, std::path::PathBuf)> out } -fn file_has_aikey_marker(p: &std::path::Path) -> bool { +/// Kimi wiring detection: the structural hook merge is keyed on the +/// command's stable tail (KIMI_HOOK_COMMAND_MARKER, see its doc), so that +/// is the semantic identity; AIKEY_BEGIN covers legacy marker-region files. +fn kimi_config_has_aikey(p: &std::path::Path) -> bool { std::fs::read_to_string(p) - .map(|s| s.contains(AIKEY_BEGIN)) + .map(|s| s.contains(KIMI_HOOK_COMMAND_MARKER) || s.contains(AIKEY_BEGIN)) .unwrap_or(false) } +/// Codex wiring detection — file wrapper over `codex_content_has_aikey`. +fn codex_config_has_aikey(p: &std::path::Path) -> bool { + let Ok(content) = std::fs::read_to_string(p) else { + return false; + }; + content.contains(AIKEY_BEGIN) || codex_content_has_aikey(&content) +} + +/// True when the config carries anything `codex_remove` would strip. Keep +/// these three predicates in LOCKSTEP with codex_remove — the test +/// `codex_detection_lockstep_with_remove` pins the equivalence so display +/// ("wired") and removal ("what unuse strips") can never disagree. +pub(super) fn codex_content_has_aikey(content: &str) -> bool { + let Some(doc) = parse_or_heal_toml(content) else { + return false; + }; + let has_table = doc + .get("model_providers") + .and_then(|i| i.as_table()) + .map(|t| t.contains_key("aikey")) + .unwrap_or(false); + let provider_ours = doc.get("model_provider").and_then(|i| i.as_str()) == Some("aikey"); + let base_ours = doc + .get("openai_base_url") + .and_then(|i| i.as_str()) + .map(|v| v.ends_with("/openai")) + .unwrap_or(false); + has_table || provider_ours || base_ours +} + /// Inspect the current Kimi config state for `aikey statusline status`. Does /// not modify anything. Returns (region_present, hook_command_matches_this_bin). pub(crate) fn kimi_status() -> (bool, bool) { @@ -1273,7 +1314,14 @@ pub fn reload_hint_for_shell() -> String { match shell_kind() { ShellKind::Zsh => "source ~/.zshrc".to_string(), ShellKind::Bash => "source ~/.bashrc".to_string(), - ShellKind::PowerShell => ". $PROFILE".to_string(), + // find#11 (2026-07-07): the hook is installed into + // `$PROFILE.CurrentUserAllHosts` (profile.ps1) — see + // shell_integration_windows.rs — but `$PROFILE` alone resolves to + // `$PROFILE.CurrentUserCurrentHost` (Microsoft.PowerShell_profile.ps1), + // a DIFFERENT file that (a) usually does not exist → `. $PROFILE` + // errors "cannot be found", and (b) even if it did, would not contain + // our hook. Dot-source the same AllHosts profile the installer wrote to. + ShellKind::PowerShell => ". $PROFILE.CurrentUserAllHosts".to_string(), ShellKind::Cmd => "open a new cmd.exe window (cmd has no rc reload)".to_string(), // Unknown deliberately avoids suggesting `source ...` (POSIX-only). // Returns the same message the bash/zsh/PS branches' callers @@ -3799,6 +3847,58 @@ tool_call_timeout_ms = 60000\n" out.parse::().expect("valid TOML"); } + #[test] + fn codex_detection_semantic_survives_comment_stripping_rewrite() { + // THE 2026-07-07 regression: codex CLI re-serializes config.toml on + // its own writes (trust_level / personality / …) and drops ALL + // comment lines — the legacy `# BEGIN aikey` marker vanished while + // every aikey key survived, and `aikey env` silently stopped + // listing the codex config. Shape below mirrors the live file. + let marker_less = "model = \"gpt-5.5\"\nopenai_base_url = \"http://127.0.0.1:27200/openai\"\nmodel_provider = \"aikey\"\n\n[projects.\"/x\"]\ntrust_level = \"trusted\"\n\n[model_providers.aikey]\nname = \"aikey\"\nbase_url = \"http://127.0.0.1:27200/openai\"\nenv_key = \"OPENAI_API_KEY\"\n"; + assert!( + codex_content_has_aikey(marker_less), + "structural content without markers must be detected as wired" + ); + + let vanilla = "model = \"gpt-5.5\"\n\n[projects.\"/x\"]\ntrust_level = \"trusted\"\n"; + assert!( + !codex_content_has_aikey(vanilla), + "vanilla codex config must not be reported as wired" + ); + } + + #[test] + fn codex_detection_lockstep_with_remove() { + // Display ("wired") and removal ("what unuse strips") share their + // semantic identity. If someone changes codex_remove's predicates + // without updating codex_content_has_aikey (or vice versa), the + // `aikey env` listing starts lying about injection state again. + let fixtures: &[&str] = &[ + // vanilla — nothing of ours + "model = \"gpt-5\"\n[projects.x]\ntrust = \"full\"\n", + // full structural install, no markers (the live regression shape) + "openai_base_url = \"http://127.0.0.1:27200/openai\"\nmodel_provider = \"aikey\"\n[model_providers.aikey]\nname = \"aikey\"\n", + // only the provider table left behind + "model = \"gpt-5\"\n[model_providers.aikey]\nname = \"aikey\"\n", + // only our proxy base_url left behind + "openai_base_url = \"http://127.0.0.1:27200/openai\"\n", + // only model_provider pointing at us + "model_provider = \"aikey\"\n", + // user's own base_url — NOT ours (doesn't end in /openai) + "openai_base_url = \"https://api.example.com/v1\"\n", + // user's own model_provider — NOT ours + "model_provider = \"ollama\"\n", + ]; + for f in fixtures { + let remove_changes = matches!(codex_remove(f), TomlMergeOutcome::Changed(_)); + assert_eq!( + codex_content_has_aikey(f), + remove_changes, + "detection and codex_remove disagree on fixture:\n{f}" + ); + } + } + #[test] fn codex_remove_strips_our_keys_only() { let (installed, _) = codex_merge( From 86224d80b8e36480f2ed7609176fc00453260746 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 02:32:07 -0700 Subject: [PATCH 31/42] fix(kimi): unify all kimi wiring readers on one semantic predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the 2026-07-07 marker-blindness fix class for the kimi side. Three readers each had their own AIKEY_BEGIN comment-marker checks, which go blind once kimi CLI re-serializes its config (dropping all comments): - `aikey env` listing (fixed in the previous commit, now routed through the shared predicate), - `kimi_status` / `aikey statusline status` — reported "not wired" on a wired install, - `uninstall_kimi_hook`'s gate — the real correctness bug: it returned NothingToRemove and SKIPPED removal entirely while the hook stayed wired (the structural removal underneath keys on the hook-command tail and would have worked fine). New kimi_content_has_aikey (hook-command tail + both legacy markers) is the single truth source for all three; kimi_status gains a pure core (kimi_content_status) so the semantics are unit-testable. One test guards the shared predicate plus the exact-command leg. Co-Authored-By: Claude Fable 5 --- src/commands_account/shell_integration.rs | 67 +++++++++++++++++++++-- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/src/commands_account/shell_integration.rs b/src/commands_account/shell_integration.rs index 521b25c..0391579 100644 --- a/src/commands_account/shell_integration.rs +++ b/src/commands_account/shell_integration.rs @@ -353,8 +353,13 @@ pub(crate) fn uninstall_kimi_hook() -> KimiUninstallOutcome { // never returns Err — the `"."` fallback means we always proceed. let (config_path, backup_path) = kimi_config_paths(); + // Semantic gate (2026-07-07): marker-text-only detection returned + // NothingToRemove — skipping removal entirely — once kimi's own config + // rewrite washed the comment markers out while the hook stayed wired. + // kimi_content_has_aikey also matches the hook-command tail, which is + // what the structural removal below actually keys on. let had_region = std::fs::read_to_string(&config_path) - .map(|c| c.contains(AIKEY_BEGIN) || c.contains(LEGACY_MARKER)) + .map(|c| kimi_content_has_aikey(&c)) .unwrap_or(false); let had_backup = backup_path.exists(); @@ -405,12 +410,25 @@ pub fn injected_provider_toml_paths() -> Vec<(&'static str, std::path::PathBuf)> out } -/// Kimi wiring detection: the structural hook merge is keyed on the +/// Kimi wiring detection — single truth source for ALL kimi readers +/// (`aikey env` listing, `kimi_status` / statusline status, and the +/// `uninstall_kimi_hook` gate). The structural hook merge is keyed on the /// command's stable tail (KIMI_HOOK_COMMAND_MARKER, see its doc), so that -/// is the semantic identity; AIKEY_BEGIN covers legacy marker-region files. +/// is the semantic identity; AIKEY_BEGIN / LEGACY_MARKER cover legacy +/// never-rewritten files. Why one predicate (2026-07-07): the three +/// readers each had their own marker-text checks, and after kimi/codex +/// CLIs re-serialize their configs (dropping all comments) the readers +/// went blind one by one — the uninstall gate even skipped removal while +/// the hook was still wired. +pub(super) fn kimi_content_has_aikey(content: &str) -> bool { + content.contains(KIMI_HOOK_COMMAND_MARKER) + || content.contains(AIKEY_BEGIN) + || content.contains(LEGACY_MARKER) +} + fn kimi_config_has_aikey(p: &std::path::Path) -> bool { std::fs::read_to_string(p) - .map(|s| s.contains(KIMI_HOOK_COMMAND_MARKER) || s.contains(AIKEY_BEGIN)) + .map(|s| kimi_content_has_aikey(&s)) .unwrap_or(false) } @@ -451,7 +469,16 @@ pub(crate) fn kimi_status() -> (bool, bool) { let Ok(content) = std::fs::read_to_string(&config_path) else { return (false, false); }; - if !content.contains(AIKEY_BEGIN) { + kimi_content_status(&content) +} + +/// Pure core of `kimi_status` (2026-07-07, same fix class as the `aikey env` +/// listing): the first gate used to be the AIKEY_BEGIN comment marker, which +/// goes blind once kimi re-serializes its config — statusline status then +/// reported "not wired" on a wired install. Now gated on the shared +/// semantic predicate. +pub(super) fn kimi_content_status(content: &str) -> (bool, bool) { + if !kimi_content_has_aikey(content) { return (false, false); } let expected = crate::commands_statusline::aikey_statusline_render_kimi_command(); @@ -3847,6 +3874,36 @@ tool_call_timeout_ms = 60000\n" out.parse::().expect("valid TOML"); } + #[test] + fn kimi_detection_semantic_survives_comment_stripping_rewrite() { + // Same regression shape as codex (2026-07-07): kimi re-serializes + // its config dropping comment lines; the hook table survives + // without any `# BEGIN aikey` marker. All three readers (env + // listing / statusline status / uninstall gate) share + // kimi_content_has_aikey, so this one test guards them together. + let marker_less = format!( + "default_model = \"k2\"\n\n[[hooks]]\nevent = \"Stop\"\ncommand = \"/usr/local/bin/aikey {KIMI_HOOK_COMMAND_MARKER}\"\ntimeout = 5\n" + ); + assert!( + kimi_content_has_aikey(&marker_less), + "marker-less wired config must be detected" + ); + let (region, _) = kimi_content_status(&marker_less); + assert!(region, "statusline status must see the wired hook"); + + let vanilla = "default_model = \"k2\"\nhooks = []\n"; + assert!(!kimi_content_has_aikey(vanilla)); + assert_eq!(kimi_content_status(vanilla), (false, false)); + + // Exact-command leg: content carrying THIS binary's render command + // must report hook_present=true. + let expected = crate::commands_statusline::aikey_statusline_render_kimi_command(); + let current = format!( + "[[hooks]]\nevent = \"Stop\"\ncommand = \"{expected}\"\ntimeout = 5\n" + ); + assert_eq!(kimi_content_status(¤t), (true, true)); + } + #[test] fn codex_detection_semantic_survives_comment_stripping_rewrite() { // THE 2026-07-07 regression: codex CLI re-serializes config.toml on From 93debef9bb0363333c2b6d929f42fcbc0586eb49 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 02:47:44 -0700 Subject: [PATCH 32/42] =?UTF-8?q?feat:=20Windows/Mac=20parity=20(alpha4)?= =?UTF-8?q?=20=E2=80=94=20installer/service=20hardening=20+=20web=20readin?= =?UTF-8?q?ess=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] account + ui-select adjustments (Windows/Mac parity) Refs: - Spec: windows-mac parity audit: https://github.com/aikeylabs/roadmap20260320/blob/6b3bcc0/技术实现/开源版本方案/20260707-windows-mac-parity-audit.md --- src/commands_account/mod.rs | 21 ++++++++++++++++++--- src/ui_select.rs | 4 +++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index f2c2be3..235f7b1 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -733,7 +733,10 @@ pub fn handle_login( // `aikey login` process hung >1h on Windows (parity audit 2026-07-07 // P2-5). Behave like json_mode: take the default without prompting; // a wrong default fails loudly downstream instead of hanging here. - eprintln!(" Control Panel: {} (no TTY — using default; pass --control-url to override)", default_url); + eprintln!( + " Control Panel: {} (no TTY — using default; pass --control-url to override)", + default_url + ); default_url } else { print!("Control Panel URL [{}]: ", default_url); @@ -1784,7 +1787,16 @@ pub fn handle_web_service(action: &str, json_mode: bool) -> Result<(), Box crate::local_server_probe::wait_for_reachable( port, - std::time::Duration::from_secs(8), + // find#10 (2026-07-07): 8s was too short on Windows. The + // local-server cold start (Go binary + embedded web + + // SQLite open, plus an antivirus scan of a freshly-written + // .exe) routinely exceeds 8s, so `aikey service start web` + // reported "did not come up within 8s" as a FALSE NEGATIVE + // even though the console bound :8090 a few seconds later. + // wait_for_reachable polls every 200ms and returns on the + // FIRST success, so a larger ceiling never slows a fast + // start — it only stops the premature failure verdict. + std::time::Duration::from_secs(25), ) .map(|()| Some(port)), Err(strict_err) => Err(strict_err), @@ -2177,8 +2189,11 @@ fn local_server_preflight_for_browse(json_mode: bool) -> BrowseLocalPreflight { println!("Starting local-server…"); let start_result = crate::local_server_probe::spawn_start_command(); + // find#10 (2026-07-07): 5s was too short on Windows — same cold-start + // false-negative as `aikey service start web` (mod.rs:1787). Poll returns + // on first success, so a larger ceiling only helps slow starts. let wait = - crate::local_server_probe::wait_for_reachable(port, std::time::Duration::from_secs(5)); + crate::local_server_probe::wait_for_reachable(port, std::time::Duration::from_secs(25)); match (start_result, &wait) { (Ok(()), Ok(())) => { diff --git a/src/ui_select.rs b/src/ui_select.rs index 31ee77e..34d986d 100644 --- a/src/ui_select.rs +++ b/src/ui_select.rs @@ -509,7 +509,9 @@ fn fallback_multi_select( { use std::io::IsTerminal; if !io::stdin().is_terminal() { - eprintln!("[aikey] non-interactive session: pass protocols explicitly instead of the picker"); + eprintln!( + "[aikey] non-interactive session: pass protocols explicitly instead of the picker" + ); return Ok(MultiSelectResult::Cancelled); } } From a1a3f0910b3e5ff455602fbee65f788022633d5d Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 05:57:59 -0700 Subject: [PATCH 33/42] fix(vault): schema-marker probe must not use a read-only connection on WAL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Windows regression of the marker fix itself: opening a WAL database with SQLITE_OPEN_READ_ONLY depends on adopting the -shm mapping — the same fragile step behind the Go readers' CANTOPEN(14) class. The RO probe failed routinely on the live box, every command fell through to the full replay, and the write-lock convoy came right back (measured with the marker binary deployed: `_internal rules` median 1.5s, min 53ms ≈ solo-replay cost — the fast path was never taken, while `aikey version` held a rock-steady 25-31ms floor). Probe now uses a default-mode connection: no -shm adoption, still SELECT-only, and WAL readers never block on writers so it remains contention-free; busy_timeout(1s) guards the non-WAL edge. Co-Authored-By: Claude Fable 5 --- src/migrations.rs | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/migrations.rs b/src/migrations.rs index 77ff6da..6c07044 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -112,19 +112,30 @@ pub enum SchemaEnsure { pub fn ensure_schema_current(vault_path: &std::path::Path) -> Result { let marker = current_schema_marker(); - // Fast path: read-only probe. Any failure (no config table yet, BLOB - // value, locked file, corrupt db) simply falls through to the replay, - // which owns real error handling. - let probe = Connection::open_with_flags(vault_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + // Fast path: probe over a NORMAL connection, not SQLITE_OPEN_READ_ONLY. + // + // Why not read-only (2026-07-07 Windows live regression of this very + // fix): opening a WAL database read-only depends on adopting the -shm + // mapping — exactly the fragile step behind the Go side's CANTOPEN(14) + // class on Windows. The RO open failed routinely there, every probe + // fell through to the full replay, and the write-lock convoy this + // marker exists to kill came right back (measured: `_internal rules` + // median 1.5s with the marker binary deployed, min 53ms ≈ solo-replay + // cost — the fast path was never taken). A default-mode connection + // avoids -shm adoption; the probe still only SELECTs, and WAL readers + // never block on writers, so it stays contention-free. busy_timeout + // guards the non-WAL edge. Probe failure of any kind (no config table + // yet, BLOB value, corrupt db) falls through to the replay, which owns + // real error handling. + let probe = Connection::open(vault_path).ok().and_then(|conn| { + let _ = conn.busy_timeout(std::time::Duration::from_millis(1000)); + conn.query_row( + "SELECT CAST(value AS TEXT) FROM config WHERE key = ?1", + [SCHEMA_REPLAYED_BY_KEY], + |row| row.get::<_, String>(0), + ) .ok() - .and_then(|conn| { - conn.query_row( - "SELECT CAST(value AS TEXT) FROM config WHERE key = ?1", - [SCHEMA_REPLAYED_BY_KEY], - |row| row.get::<_, String>(0), - ) - .ok() - }); + }); if probe.as_deref() == Some(marker.as_str()) { return Ok(SchemaEnsure::SkippedFresh); } From c5116ff9b4988a644703670845e5b413122a05b2 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 06:02:47 -0700 Subject: [PATCH 34/42] fix(cli): tolerate BOM'd JSON inputs + never corrupt a UTF-16 $PROFILE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from the 2026-07-07 encoding sweep (zh-CN Windows focus): 1. New crate::strip_bom applied at the three read→serde sites (install-state.json via detect_edition, project config, global config). Shipped PS 5.1 trial installers wrote install-state.json WITH a UTF-8 BOM; serde rejects BOM at byte 0 and the `.ok()?` swallowed the error — a whole trial install silently read as "not installed". Writers are being fixed, but BOM'd files already exist on customer machines, so readers must tolerate them forever. Regression test: detect_edition_tolerates_utf8_bom. 2. H2 guard in the PowerShell $PROFILE wiring: never byte-append UTF-8 onto a profile that isn't valid UTF-8 (UTF-16LE profiles are common on PS 5.1 via `>` redirection). Appending corrupted the file — PS decodes it all as UTF-16, the block turns to garbage, every new session can throw — and since the marker check can't read UTF-16 either, repeated runs kept appending. Now fails safe with a one-line conversion command and re-run guidance. (Full decode-splice-rewrite is a follow-up; the installer's Get-Content/Set-Content path was already safe.) Co-Authored-By: Claude Fable 5 --- .../shell_integration_windows.rs | 25 +++++++++++++++++++ src/config.rs | 2 ++ src/global_config.rs | 4 ++- src/lib.rs | 14 +++++++++++ src/local_server_probe.rs | 24 +++++++++++++++++- src/main.rs | 2 ++ 6 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/commands_account/shell_integration_windows.rs b/src/commands_account/shell_integration_windows.rs index 0e46d5e..1423c68 100644 --- a/src/commands_account/shell_integration_windows.rs +++ b/src/commands_account/shell_integration_windows.rs @@ -283,6 +283,31 @@ pub(super) fn ensure_powershell_hook() -> Option { )); } + // H2 guard (encoding sweep 2026-07-07): NEVER byte-append UTF-8 onto a + // profile we can't read as UTF-8. PS 5.1 commonly produces UTF-16LE + // $PROFILE files (`'x' > $PROFILE` redirection, old Notepad "Unicode"); + // appending UTF-8 bytes onto UTF-16 corrupts the file — PowerShell + // decodes the WHOLE file as UTF-16, the appended block turns into CJK + // garbage, and every new session can throw a parse error. Worse, the + // marker check above can't see markers in such files (read_to_string + // errors → candidate skipped), so repeated runs would keep appending + // and compound the damage. Until a decode-splice-rewrite lands + // (installer's Get-Content/Set-Content path already does this + // correctly), fail safe with actionable guidance. + if let Ok(bytes) = std::fs::read(&target) { + let utf16_bom = bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]); + let undecodable = !bytes.is_empty() && std::str::from_utf8(&bytes).is_err(); + if utf16_bom || undecodable { + return Some(format!( + " {} exists but is not UTF-8 (likely UTF-16 from PowerShell 5.1).\n \ + Appending would corrupt it — hook wiring skipped.\n \ + Convert it once in PowerShell: \x1b[36m(Get-Content $PROFILE -Raw) | Set-Content $PROFILE -Encoding utf8\x1b[0m\n \ + then re-run: \x1b[36maikey hook install\x1b[0m", + target_display, + )); + } + } + // Create parent dir (pwsh 7+ profile dir is often missing on a // freshly-installed pwsh) and append the v3 block. Use OpenOptions // append — never overwrite user content already present. diff --git a/src/config.rs b/src/config.rs index 90d995f..ec9f62f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -156,6 +156,8 @@ impl ProjectConfig { pub fn load(path: &Path) -> Result { let content = fs::read_to_string(path).map_err(|e| format!("Failed to read config file: {}", e))?; + // Tolerate a UTF-8 BOM from Windows editors/tools (see crate::strip_bom docs). + let content = crate::strip_bom(&content).to_string(); let filename = path .file_name() diff --git a/src/global_config.rs b/src/global_config.rs index d885327..de6735f 100644 --- a/src/global_config.rs +++ b/src/global_config.rs @@ -64,7 +64,9 @@ pub fn load_config() -> Result { let content = fs::read_to_string(&path).map_err(|e| format!("Failed to read global config: {}", e))?; - serde_json::from_str(&content).map_err(|e| format!("Failed to parse global config: {}", e)) + // Tolerate a UTF-8 BOM from Windows editors/tools (see crate::strip_bom docs). + serde_json::from_str(crate::strip_bom(&content)) + .map_err(|e| format!("Failed to parse global config: {}", e)) } pub fn save_config(config: &GlobalConfig) -> Result<(), String> { diff --git a/src/lib.rs b/src/lib.rs index 4bc2938..ad10fcc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,6 +59,20 @@ pub mod usage_wal; #[cfg(test)] pub(crate) mod test_env_lock; +/// Strip a leading UTF-8 BOM before handing text to serde/toml parsers. +/// +/// Why (encoding sweep 2026-07-07 H1/L1): Windows tooling — PowerShell +/// 5.1's `Set-Content -Encoding UTF8` in particular — prepends a BOM, and +/// `serde_json::from_str` rejects it at byte 0. The failure mode is nasty +/// because most callers `.ok()?` the parse: a BOM'd install-state.json +/// silently reads as "no install at all" (detect_edition → None → `aikey +/// web` claims the host has no local server). Writers are fixed to emit +/// BOM-less files, but shipped installers already produced BOM'd files on +/// customer machines — readers must tolerate them forever. +pub fn strip_bom(s: &str) -> &str { + s.strip_prefix('\u{feff}').unwrap_or(s) +} + /// Prompts for a hidden input (password / API key), showing a `*` for each /// keystroke in real time. Supports backspace and handles paste gracefully. /// diff --git a/src/local_server_probe.rs b/src/local_server_probe.rs index 0320c3e..57dacc2 100644 --- a/src/local_server_probe.rs +++ b/src/local_server_probe.rs @@ -101,7 +101,10 @@ pub fn detect_edition() -> Option { let home = crate::commands_account::resolve_user_home(); let path = home.join(".aikey/install-state.json"); let raw = fs::read_to_string(&path).ok()?; - let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?; + // strip_bom: shipped PS 5.1 trial installers wrote this file WITH a BOM + // (encoding sweep 2026-07-07 H1) and serde rejects it — without the strip + // a whole trial install reads as "not installed" here. + let parsed: serde_json::Value = serde_json::from_str(crate::strip_bom(&raw)).ok()?; let components = parsed.get("installed_components")?.as_array()?; // Why Trial wins over local-server when both appear: trial-server // is a superset bundle that includes local-server's user-side @@ -1354,6 +1357,25 @@ mod tests { }); } + #[test] + fn detect_edition_tolerates_utf8_bom() { + // Encoding sweep 2026-07-07 H1: shipped PS 5.1 trial installers + // wrote install-state.json WITH a UTF-8 BOM (`Set-Content -Encoding + // UTF8`), serde rejects BOM at byte 0, and the `.ok()?` swallowed + // the error — a whole trial install read as "not installed". + // Readers must tolerate BOM'd files forever (they're already on + // customer machines). + let _g = crate::test_env_lock::ENV_MUTATION_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + write_install_state( + tmp.path(), + "\u{feff}{\"installed_components\":[\"full-trial\"]}", + ); + with_home(tmp.path(), || { + assert_eq!(detect_edition(), Some(Edition::Trial)); + }); + } + #[test] fn detect_edition_prefers_trial_when_both_listed() { // If a host's install-state.json mentions both (unusual but diff --git a/src/main.rs b/src/main.rs index e2982b1..d237738 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,6 +96,8 @@ mod usage_wal; mod test_env_lock; use aikeylabs_aikey_cli::prompt_hidden; +#[allow(unused_imports)] // available as crate::strip_bom to bin-side modules +use aikeylabs_aikey_cli::strip_bom; use clap::Parser; use cli::*; use secrecy::{ExposeSecret, SecretString}; From b333adf05aa537d43e1f787f3a4ce4fb95f8ef1f Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 06:43:07 -0700 Subject: [PATCH 35/42] perf(sync): cross-process debounce for the implicit background snapshot sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE vault-page latency root cause, pinned by startup profiling on the live Windows box: `std::thread::spawn` itself cost ~1.2s there — endpoint AV (Norton) intercepts OS thread creation. Every short-lived `_internal` bridge child called try_background_snapshot_sync, whose in-process SYNC_IN_PROGRESS flag is useless across processes, so every bridge call paid the 1.2s BEFORE the sync's own sync_version fast-check could even run. (Earlier suspects — schema replay write-lock convoy, RO-probe WAL failure — were contributors at most: profiler showed 7ms→1683ms across this single call.) Fix per user decision (2026-07-07): KEEP the implicit async sync — it exists so OAuth-pool material changes propagate promptly — and gate the thread spawn on a cross-process debounce file (~/.aikey/run/snapshot-sync-last-attempt, 30s window). Hot path is a sub-ms stat+read; one process per window pays the spawn and runs the existing sync_version-compare logic unchanged. State file follows enhancement-not-dependency: missing/corrupt/future-clock all read as stale (sync runs); write failures ignored. Live A/B on the Windows box (deployed + verified): - `_internal rules`: 1.1-1.5s per call → 57-103ms after first-in-window - /api/user/vault/status+list concurrent (browser shape): 4.6-5.5s → 264-270ms Fence: debounce lifecycle (fresh/suppressed/elapsed/corrupt/clock-skew). Co-Authored-By: Claude Fable 5 --- src/commands_account/mod.rs | 105 ++++++++++++++++++++++++++++++++++++ src/main.rs | 8 +++ 2 files changed, 113 insertions(+) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 235f7b1..0ceeff3 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -3478,11 +3478,72 @@ pub fn check_sync_version_changed() -> Result { /// /// Non-blocking: the calling command is not delayed. /// All errors are silently suppressed — the local cache remains usable offline. +/// Cross-process debounce window for the implicit background snapshot sync. +/// +/// Why this exists (2026-07-07, profiled live on the Windows box): the sync's +/// own sync_version fast-check runs INSIDE the spawned thread, but +/// `std::thread::spawn` ITSELF cost ~1.2s there — endpoint AV (Norton) +/// intercepts thread creation. Every short-lived `_internal` bridge child +/// paid that before any version check could run, which is exactly the +/// vault-page latency. The in-process SYNC_IN_PROGRESS flag can't help: +/// each bridge call is a NEW process, so every one of them spawned a thread. +/// +/// The debounce is therefore a FILE (cross-process): if any aikey process +/// attempted the sync within the window, later processes skip the spawn +/// entirely (sub-ms stat+read on the hot path). Freshness bound for +/// OAuth-pool material (the reason the implicit sync exists — user decision +/// 2026-07-07: keep it, don't exempt `_internal`) becomes the window below, +/// on top of the server-side page-load trigger which is unaffected. +const SNAPSHOT_SYNC_DEBOUNCE_SECS: u64 = 30; + +fn snapshot_sync_debounce_path() -> std::path::PathBuf { + crate::commands_account::resolve_aikey_dir() + .join("run") + .join("snapshot-sync-last-attempt") +} + +/// True when a sync attempt was recorded within the debounce window. +/// State-file reads follow the "enhancement, not dependency" rule: any +/// IO/parse failure reads as "stale" so the sync still runs. +fn snapshot_sync_recently_attempted(path: &std::path::Path, now_epoch: u64) -> bool { + let Ok(raw) = std::fs::read_to_string(path) else { + return false; + }; + let Ok(last) = raw.trim().parse::() else { + return false; + }; + // `last > now` (clock jumped backwards) reads as stale — never lets a + // future timestamp suppress syncs indefinitely. + now_epoch >= last && now_epoch - last < SNAPSHOT_SYNC_DEBOUNCE_SECS +} + +/// Record "an attempt happened now". Written BEFORE the sync runs so +/// concurrent processes inside the window skip even while the winner is +/// still working; a failed sync simply retries after the window (bounded +/// cadence for a best-effort path). Write failures are ignored. +fn record_snapshot_sync_attempt(path: &std::path::Path, now_epoch: u64) { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(path, now_epoch.to_string()); +} + pub fn try_background_snapshot_sync() { use std::sync::atomic::{AtomicBool, Ordering}; // Static flag: true while a background sync thread is running. static SYNC_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + // Cross-process debounce (see SNAPSHOT_SYNC_DEBOUNCE_SECS): skip the + // expensive thread spawn when any process attempted a sync recently. + let now_epoch = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let debounce = snapshot_sync_debounce_path(); + if snapshot_sync_recently_attempted(&debounce, now_epoch) { + return; + } + // compare_exchange(expected=false, new=true): only one thread wins. if SYNC_IN_PROGRESS .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) @@ -3491,6 +3552,7 @@ pub fn try_background_snapshot_sync() { return; // another sync is already running — skip } + record_snapshot_sync_attempt(&debounce, now_epoch); std::thread::spawn(|| { let _ = run_snapshot_sync(); SYNC_IN_PROGRESS.store(false, Ordering::Release); @@ -5682,6 +5744,49 @@ mod sync_tests { // of bug the 2026-04-24 `_internal must reuse public command core` rule // is designed to prevent. // ============================================================================ +#[cfg(test)] +mod snapshot_sync_debounce_tests { + use super::*; + + // Pins the cross-process debounce contract (2026-07-07 vault-page + // latency): within the window → skip (this is what saves the ~1.2s + // AV-taxed thread::spawn per bridge child); stale / missing / corrupt / + // future-clock states all read as "attempt now" so the sync semantics + // the OAuth-pool design relies on are never silently lost. + #[test] + fn debounce_lifecycle() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("run").join("snapshot-sync-last-attempt"); + let now = 1_000_000u64; + + // Missing file → not recently attempted (sync runs). + assert!(!snapshot_sync_recently_attempted(&path, now)); + + // Recorded just now → suppressed for the window. + record_snapshot_sync_attempt(&path, now); + assert!(snapshot_sync_recently_attempted(&path, now)); + assert!(snapshot_sync_recently_attempted( + &path, + now + SNAPSHOT_SYNC_DEBOUNCE_SECS - 1 + )); + + // Window elapsed → stale again. + assert!(!snapshot_sync_recently_attempted( + &path, + now + SNAPSHOT_SYNC_DEBOUNCE_SECS + )); + + // Corrupt content → stale (enhancement-not-dependency). + std::fs::write(&path, "not-a-number").unwrap(); + assert!(!snapshot_sync_recently_attempted(&path, now)); + + // Future timestamp (clock jumped back) → stale, never a permanent + // suppression. + std::fs::write(&path, (now + 10_000).to_string()).unwrap(); + assert!(!snapshot_sync_recently_attempted(&path, now)); + } +} + #[cfg(test)] mod core_tests { use super::*; diff --git a/src/main.rs b/src/main.rs index d237738..1e9c5e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -314,6 +314,7 @@ fn main() -> Result<(), Box> { } } + // Determine command name for structured log fields (best-effort, no secrets). let cmd_name = command_name(cli.command.as_ref()); @@ -1050,6 +1051,13 @@ fn run_command(cli: &Cli) -> Result<(), Box> { // key state if it has changed since the last local pull. Skipped for proxy // lifecycle and init commands which either predate the vault or manage the // process themselves. + // + // NOT skipped for `_internal` (user decision 2026-07-07): the implicit + // async sync exists to catch OAuth-pool material changes promptly, so + // bridge children keep it. The per-call cost problem (OS thread creation + // costs ~1.2s under endpoint AV like Norton — profiled live) is solved by + // the cross-process debounce INSIDE try_background_snapshot_sync instead + // of exempting commands. match command { Commands::Proxy { .. } | Commands::Init From 30e3d0d90fb4105563e6ff67516d4d81d9e9991e Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 06:49:53 -0700 Subject: [PATCH 36/42] fix: codex OAuth callback error handling + Windows detector/parity fixes [aikey-cli] shell-integration adjustment (Windows/Mac parity) --- src/commands_account/shell_integration.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands_account/shell_integration.rs b/src/commands_account/shell_integration.rs index 0391579..d8a2750 100644 --- a/src/commands_account/shell_integration.rs +++ b/src/commands_account/shell_integration.rs @@ -3898,9 +3898,8 @@ tool_call_timeout_ms = 60000\n" // Exact-command leg: content carrying THIS binary's render command // must report hook_present=true. let expected = crate::commands_statusline::aikey_statusline_render_kimi_command(); - let current = format!( - "[[hooks]]\nevent = \"Stop\"\ncommand = \"{expected}\"\ntimeout = 5\n" - ); + let current = + format!("[[hooks]]\nevent = \"Stop\"\ncommand = \"{expected}\"\ntimeout = 5\n"); assert_eq!(kimi_content_status(¤t), (true, true)); } From 695dbe3d08b530637284378ac7a6710f16248f8c Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 7 Jul 2026 08:39:53 -0700 Subject: [PATCH 37/42] =?UTF-8?q?fix(sync):=20exempt=20=5Finternal=20from?= =?UTF-8?q?=20the=20implicit=20snapshot=20sync=20=E2=80=94=20it=20never=20?= =?UTF-8?q?worked=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evidence-based revision of the earlier "keep it for OAuth-pool freshness" decision, with the freshness objection factually resolved: 1. A bridge child exits ~50ms after dispatch, killing the detached sync thread before its first HTTP round-trip completes — the implicit sync from `_internal` has NEVER delivered a sync. It only paid the thread-creation cost: ~1.2s under endpoint AV (Norton, profiled live), i.e. the entire vault-page latency. 2. Web freshness is genuinely delivered by the server-side explicit trigger (CRUDHandlers.triggerBackgroundSnapshotSync → `_internal query snapshot_sync`, which runs the sync inside the handler and completes) — unaffected. Interactive commands keep the implicit thread+debounce path. 3. A detached worker PROCESS was built, measured, and rejected: AV taxes spawning our unsigned binary the same way (~1.4s — the earlier "process spawn is ~30ms" datum was for signed cmd.exe and does not transfer), plus the parent inherited waits on the worker's 21s connect timeout when the team server is unreachable. Live A/B on the Windows box after deploy: - first-hit `_internal rules` (debounce cleared): 1.2-1.4s → 52-54ms - cold-open status+list concurrent (the exact user scenario of opening the vault page after idle): 4.6-5.5s → 291-325ms Co-Authored-By: Claude Fable 5 --- src/commands_account/mod.rs | 7 +++++++ src/main.rs | 20 ++++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 0ceeff3..7a66dbd 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -3535,6 +3535,13 @@ pub fn try_background_snapshot_sync() { // Cross-process debounce (see SNAPSHOT_SYNC_DEBOUNCE_SECS): skip the // expensive thread spawn when any process attempted a sync recently. + // + // Why thread::spawn and not a detached worker PROCESS (evaluated and + // measured 2026-07-07): endpoint AV taxes creating a process from our + // unsigned binary just as hard as creating a thread (~1.4s vs ~1.2s, + // Norton live box) — the earlier "process creation is ~30ms" datum was + // for SIGNED system binaries (cmd.exe) and does not transfer. A worker + // process buys nothing and adds detach/handle-inheritance hazards. let now_epoch = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) diff --git a/src/main.rs b/src/main.rs index 1e9c5e2..e308967 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1052,17 +1052,25 @@ fn run_command(cli: &Cli) -> Result<(), Box> { // lifecycle and init commands which either predate the vault or manage the // process themselves. // - // NOT skipped for `_internal` (user decision 2026-07-07): the implicit - // async sync exists to catch OAuth-pool material changes promptly, so - // bridge children keep it. The per-call cost problem (OS thread creation - // costs ~1.2s under endpoint AV like Norton — profiled live) is solved by - // the cross-process debounce INSIDE try_background_snapshot_sync instead - // of exempting commands. + // `_internal` is exempt (2026-07-07, evidence-based revision of an + // earlier "keep it for OAuth-pool freshness" decision): a bridge child + // exits ~50ms after dispatch, killing the detached sync thread before + // its first HTTP round-trip can complete — the implicit sync from + // `_internal` NEVER actually delivered a sync; it only paid the thread- + // creation cost (~1.2s under endpoint AV like Norton, profiled live — + // the whole vault-page latency). OAuth-pool freshness for the web is + // genuinely delivered by the server-side explicit trigger + // (aikey-control CRUDHandlers.triggerBackgroundSnapshotSync → + // `_internal query snapshot_sync`, which runs the sync INSIDE the + // handler and completes), plus interactive commands below which keep + // the implicit path. A detached worker PROCESS was also evaluated and + // rejected: AV taxes spawning our unsigned binary the same ~1.4s. match command { Commands::Proxy { .. } | Commands::Init | Commands::Db { .. } | Commands::Version + | Commands::Internal { .. } | Commands::Statusline { action: None } | Commands::Statusline { action: Some(cli::StatuslineAction::Render { .. }), From 417f4368caa1dcd09522f061dcaf813fe488fe98 Mon Sep 17 00:00:00 2001 From: michael <32635391@qq.com> Date: Wed, 8 Jul 2026 03:02:34 -0700 Subject: [PATCH 38/42] fix(windows): resolve PowerShell profile via Documents known folder (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aikey use writes the shell hook into $PROFILE.CurrentUserAllHosts, but the path was hardcoded as \Documents\... — while PowerShell itself derives $PROFILE from the FOLDERID_Documents KNOWN FOLDER, which can be redirected (Parallels/VMware shared profiles, OneDrive folder redirection, roaming profiles). On such machines the hook landed in a profile PowerShell never reads: active.env vars silently reached no shell, and the post-use hint (. $PROFILE.CurrentUserAllHosts) died with CommandNotFoundException (Parallels box, 2026-07-08: interactive Documents = C:\Mac\Home\Documents). Compat layer only (user decision): documents_known_folder() asks SHGetKnownFolderPath(FOLDERID_Documents) — the same API PowerShell's own resolution bottoms out in — and its two profile candidates go FIRST. The hardcoded \Documents candidates are kept unchanged as fallbacks (API failure; hooks written by older builds must stay findable). On non-redirected machines the known folder equals \Documents and dedup collapses the list to exactly the old one — zero behavior change for machines that already work. The value is read live per invocation on purpose: on Parallels it is session-dependent (interactive sessions see the redirected path, SSH sessions the local default); resolving in the same session the user's shells run in is what makes write and read locations agree. Verification: cargo check --target x86_64-pc-windows-gnu green; host cargo test --lib shell_integration 149 passed; deployed to the Parallels test box via make restart-personal-win (binary hash matched). Interactive acceptance (aikey use -> new window sees OPENAI_API_KEY) is tracked in workflow/CI/bugfix/2026-07-08-powershell-profile-documents-redirect.md. Co-authored-by: Michael Co-authored-by: Claude Opus 4.8 --- Cargo.toml | 4 + .../shell_integration_windows.rs | 89 ++++++++++++++++--- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 39fb5a6..d80da45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -179,6 +179,10 @@ windows-sys = { version = "0.52", features = [ "Win32_System_Time", # FileTimeToSystemTime / SystemTimeToTzSpecificLocalTime (commands_statusline.rs event_time_hm) "Win32_System_Console", # GetStdHandle / ReadConsoleW / GetConsoleMode / SetConsoleMode / GetConsoleScreenBufferInfo # (lib.rs prompt_hidden / ui_select.rs raw input / ui_frame.rs term_width — Stage 1 windows-compat) + "Win32_UI_Shell", # SHGetKnownFolderPath(FOLDERID_Documents) — PowerShell $PROFILE must follow Documents + # known-folder REDIRECTION (Parallels shared profile / OneDrive / roaming); compat + # layer only, hardcoded \Documents kept as fallback (bugfix 2026-07-08) + "Win32_System_Com", # CoTaskMemFree for the SHGetKnownFolderPath out-buffer ] } [build-dependencies] diff --git a/src/commands_account/shell_integration_windows.rs b/src/commands_account/shell_integration_windows.rs index 1423c68..6be61c0 100644 --- a/src/commands_account/shell_integration_windows.rs +++ b/src/commands_account/shell_integration_windows.rs @@ -43,9 +43,11 @@ //! The functions here (`v3_rc_block_powershell`, //! `powershell_profile_candidates`, `ensure_powershell_hook`) are //! "PowerShell-specific" in concept but **not** "Windows-only" in -//! compilation: none of them use windows-sys / winapi imports. They -//! just produce PowerShell-syntax strings + paths that happen to -//! describe Windows folder layout. Critically: +//! compilation: almost none of them use windows-sys / winapi imports +//! (sole exception: `documents_known_folder`, individually gated with +//! `#[cfg(windows)]` — see its docstring). The rest just produce +//! PowerShell-syntax strings + paths that happen to describe Windows +//! folder layout. Critically: //! //! - cross-platform tests in `stage3_powershell_hook_tests` reference //! `v3_rc_block_powershell` to assert PowerShell-syntax invariants; @@ -128,10 +130,31 @@ pub(super) fn v3_rc_block_powershell() -> String { /// - PowerShell 7+ on macOS / Linux: `~/.config/powershell/profile.ps1` /// /// We don't spawn a PowerShell subprocess to query the actual value -/// because that costs ~200 ms per `aikey use`. Instead we replicate the -/// path resolution from PowerShell's source (SHGetKnownFolderPath -/// FOLDERID_Documents lookup on Windows; XDG `$HOME/.config/powershell` -/// on Unix-y pwsh 7+). +/// because that costs ~200 ms per `aikey use`. Instead we ask the SAME +/// Win32 API PowerShell's own resolution bottoms out in +/// (SHGetKnownFolderPath FOLDERID_Documents on Windows; XDG +/// `$HOME/.config/powershell` on Unix-y pwsh 7+). +/// +/// COMPAT LAYER (2026-07-08, minimal by user decision): the Documents +/// known folder can be REDIRECTED away from `\Documents` +/// (Parallels/VMware shared profiles, OneDrive folder redirection, +/// roaming profiles) and PowerShell follows the redirect. The previous +/// hardcoded `\Documents` wrote the hook into a profile PowerShell +/// never reads on such machines — active.env vars silently never reached +/// any shell, and the post-`aikey use` hint +/// (`. $PROFILE.CurrentUserAllHosts`) died with CommandNotFoundException +/// (Parallels box, 2026-07-08: interactive Documents = +/// `C:\Mac\Home\Documents`). Known-folder candidates therefore come +/// FIRST; the hardcoded paths are KEPT UNCHANGED as fallbacks (API +/// failure + hooks written by older builds must stay findable). On a +/// non-redirected machine the known folder equals `\Documents`, +/// dedup collapses the list to exactly the old one — zero behavior +/// change for machines that already work. +/// +/// The known-folder value can be SESSION-DEPENDENT (Parallels: +/// interactive sessions see the Mac-shared path, SSH sessions the local +/// default). Reading it live per-invocation makes the write location +/// agree with the shells the user opens next to this `aikey use`. /// /// Returns the candidates in stable preference order. We don't filter /// to existing-parent here because the user might be installing pwsh @@ -148,16 +171,22 @@ pub(super) fn powershell_profile_candidates() -> Vec { #[cfg(windows)] { - out.push( + if let Some(docs) = documents_known_folder() { + out.push(docs.join("PowerShell").join("profile.ps1")); + out.push(docs.join("WindowsPowerShell").join("profile.ps1")); + } + for fb in [ home.join("Documents") .join("PowerShell") .join("profile.ps1"), - ); - out.push( home.join("Documents") .join("WindowsPowerShell") .join("profile.ps1"), - ); + ] { + if !out.contains(&fb) { + out.push(fb); + } + } } #[cfg(not(windows))] { @@ -167,6 +196,44 @@ pub(super) fn powershell_profile_candidates() -> Vec { out } +/// Resolve the Documents known folder via `SHGetKnownFolderPath` — the +/// same resolution PowerShell performs for `$PROFILE`, so redirection +/// (Parallels / OneDrive / roaming) is honored identically. `None` on +/// any API failure; callers fall back to `\Documents`. +/// +/// This is the module's one windows-sys use — kept behind +/// `#[cfg(windows)]` so the module (and its PowerShell-syntax tests) +/// still compiles and runs on macOS / Linux. +#[cfg(windows)] +fn documents_known_folder() -> Option { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::System::Com::CoTaskMemFree; + use windows_sys::Win32::UI::Shell::{FOLDERID_Documents, SHGetKnownFolderPath}; + + unsafe { + let mut pw: windows_sys::core::PWSTR = std::ptr::null_mut(); + // dwFlags = 0 (KF_FLAG_DEFAULT), hToken = 0 (current user; HANDLE + // is isize in windows-sys 0.52) — mirrors PowerShell's + // Environment.GetFolderPath(MyDocuments). + let hr = SHGetKnownFolderPath(&FOLDERID_Documents, 0, 0, &mut pw); + if hr != 0 || pw.is_null() { + return None; + } + let mut len = 0usize; + while *pw.add(len) != 0 { + len += 1; + } + let s = OsString::from_wide(std::slice::from_raw_parts(pw, len)); + CoTaskMemFree(pw as *const core::ffi::c_void); + if s.is_empty() { + None + } else { + Some(std::path::PathBuf::from(s)) + } + } +} + /// Stage 3.2: PowerShell sibling of `ensure_shell_hook`. /// /// Mirrors the bash/zsh contract: From 2ecbb077ee0056d64164fef08adcaa9fd1172ca6 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 8 Jul 2026 05:36:11 -0700 Subject: [PATCH 39/42] =?UTF-8?q?refactor:=20shared=20ModalShell=20UI=20co?= =?UTF-8?q?mponent=20+=20HookWireRcModal;=20activation=20direct-jump=20+?= =?UTF-8?q?=20=E4=BF=A1=E5=88=9B=20OS=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] main.rs adjustment --- src/main.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index e308967..f1c2eae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -314,7 +314,6 @@ fn main() -> Result<(), Box> { } } - // Determine command name for structured log fields (best-effort, no secrets). let cmd_name = command_name(cli.command.as_ref()); From 7399daeda97425082ab8b5697c6bc0740666647b Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 8 Jul 2026 09:14:35 -0700 Subject: [PATCH 40/42] feat: egress auto-follows system proxy (new sysproxy pkg) + moonshot-v1-128k default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] moonshot-v1-128k default (8k breaks kimi-cli: system prompt ~12.4K > 8192); commands_account/proxy + profile-activation test; provider_registry.yaml Refs: - Spec: moonshot 默认模型 8k→128k: https://github.com/aikeylabs/roadmap20260320/blob/051ecf5/技术实现/update/20260708-moonshot-默认模型-8k-改-128k.md --- data/provider_registry.yaml | 31 ++++++-- src/commands_account/mod.rs | 20 +++-- src/commands_proxy.rs | 97 ++++++++++++++++++++++++ src/main.rs | 104 ++++++++++++++++++++++++++ tests/profile_activation_test.rs | 124 +++++++++++++++++++++++++------ 5 files changed, 340 insertions(+), 36 deletions(-) diff --git a/data/provider_registry.yaml b/data/provider_registry.yaml index f3bc6d0..5c85b25 100644 --- a/data/provider_registry.yaml +++ b/data/provider_registry.yaml @@ -129,15 +129,30 @@ providers: # / KIMI_BASE_URL,kimi CLI 上游只读这一组。区分 platform 由 proxy_path 决定: # moonshot 走 /moonshot/v1 → 上游 api.moonshot.cn;kimi_code 走 /kimi/v1 → # 上游 api.kimi.com/coding。 - # ② extra_env_vars KIMI_MODEL_NAME 从 kimi-k2.5 改为 moonshot-v1-8k, + # ② extra_env_vars KIMI_MODEL_NAME 从 kimi-k2.5 改为 moonshot-v1-128k, # Moonshot 上游不支持 kimi-k2.5(原值是从 kimi_code 复制的 bug)。 # ③ MOONSHOT_API_KEY / MOONSHOT_BASE_URL 三层证据(kimi-cli runtime grep / # Moonshot 官方 env-vars 文档 / 项目内 grep)实证无消费方,直接停写。 - # ④ KIMI_MODEL_MAX_CONTEXT_SIZE = 8192(不是 131072): Moonshot 模型族名字直接 - # 编码 context 上限(moonshot-v1-8k=8192 / -32k=32768 / -128k=131072), - # 默认配最便宜的 8k 模型 + 匹配的 8192 context,首单成本最低,长文用户 - # 自行升 -32k/-128k。原 131072 与 8k 模型互相矛盾,会让 kimi-cli 端做错 - # 截断预估。 + # ④ KIMI_MODEL_MAX_CONTEXT_SIZE = 131072(moonshot-v1-128k 上限 128K)。 + # + # 2026-07-08 默认模型 8k→moonshot-v1-128k 反转(详见 update/20260708-moonshot- + # 默认模型-8k-改-kimi-latest.md;bugfix/2026-07-08-moonshot-default-model-8k- + # breaks-kimi-cli.md): + # 决策 #9 原拍板 B(moonshot-v1-8k / 8192,"配最便宜的 8k,长文自行升级")的 + # 前提是"用户从短请求起步"。但 Kimi CLI 光系统提示词就 ~12.4K token > 8192, + # moonshot 协议下从第一句 "hello" 就必然 400 token-limit-exceeded —— 等于 + # 开箱即废,且用户无任何 UI 入口改模型(model 写死在本 registry)。前提不成立, + # 故抬高默认到 128K 上下文模型。 + # ⚠️ 为什么是 moonshot-v1-128k 而不是"更新/更智能"的 kimi-latest / kimi-k2*: + # kimi-latest 等新模型在 api.moonshot.cn 上是**账号权限门控**的 —— 实测某 + # 账号跑 kimi-latest 直接 404 "Not found the model or Permission denied", + # 而基础的 moonshot-v1 家族(8k/32k/128k,同一权限档)所有账号都够得着。 + # DEFAULT 的目标是"最大范围账号开箱可用",要的是**最广兼容**而非**最新**。 + # => 任何"自动追最新模型"的想法(含构建期拉取)在这里都是反模式:越新的 + # 模型越可能权限门控,会让默认对更多账号 404。 + # ⚠️ 未来若见此处想改回 8k 省钱,请先确认 Kimi CLI 最小提示词是否已 <8K, + # 否则会再次退化为开箱即废(regression: provider_extra_env_vars_kimi_ + # family_per_platform_model 围栏测试守住此值)。 family: kimi proxy_path: moonshot/v1 env_api_key: KIMI_API_KEY @@ -146,8 +161,8 @@ providers: picker: true display: kimi(moonshot) extra_env_vars: - - {var: KIMI_MODEL_NAME, value: "moonshot-v1-8k"} - - {var: KIMI_MODEL_MAX_CONTEXT_SIZE, value: "8192"} + - {var: KIMI_MODEL_NAME, value: "moonshot-v1-128k"} + - {var: KIMI_MODEL_MAX_CONTEXT_SIZE, value: "131072"} # ────────────────────────────────────────────────────────────────── # P0 additions (2026-04-24) — widely-adopted OpenAI-compatible Western diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index 7a66dbd..d995114 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -5593,7 +5593,9 @@ mod provider_mapping_tests { // 三个 family 内 provider_code 各自的 KIMI_MODEL_NAME / MAX_CONTEXT_SIZE 不同 // (避免上游 reject + 客户端预估错): // kimi_code → kimi-k2.5 / 131072(api.kimi.com 自家 model,128K context) - // moonshot → moonshot-v1-8k / 8192(模型名编码 context 上限,默认配最便宜) + // moonshot → moonshot-v1-128k / 131072(2026-07-08 从 moonshot-v1-8k/8192 + // 反转,原 8k 默认使 Kimi CLI 开箱即废;选 v1-128k 而非更新的 + // kimi-latest 因后者账号权限门控会 404,基础 v1 家族最广兼容) // kimi(deprecated)→ 与 kimi_code 一致(经 oauth_alias 解析) let kimi_code = provider_extra_env_vars("kimi_code"); assert!(kimi_code @@ -5604,22 +5606,26 @@ mod provider_mapping_tests { .any(|(k, v)| *k == "KIMI_MODEL_MAX_CONTEXT_SIZE" && *v == "131072")); let moonshot = provider_extra_env_vars("moonshot"); + // 2026-07-08 默认模型反转为 moonshot-v1-128k: Kimi CLI 最小提示词 ~12.4K > 8192, + // 原 moonshot-v1-8k 从第一句就 400 token-limit-exceeded(开箱即废)。选基础 v1 + // 家族的 128k 而非更新的 kimi-latest —— 后者在 api.moonshot.cn 账号权限门控, + // 实测 404 Permission denied;DEFAULT 要最广兼容而非最新。 assert!(moonshot .iter() - .any(|(k, v)| *k == "KIMI_MODEL_NAME" && *v == "moonshot-v1-8k")); - // Moonshot 模型族名字直接编码 context 上限: moonshot-v1-8k=8192, -32k=32768, - // -128k=131072。default 配 8k 模型必须配 8192 context, 否则 kimi-cli 端会做错截断预估。 + .any(|(k, v)| *k == "KIMI_MODEL_NAME" && *v == "moonshot-v1-128k")); + // MAX_CONTEXT 匹配 128K 上限,否则 kimi-cli 端会按旧 8192 错误截断。 assert!(moonshot .iter() - .any(|(k, v)| *k == "KIMI_MODEL_MAX_CONTEXT_SIZE" && *v == "8192")); + .any(|(k, v)| *k == "KIMI_MODEL_MAX_CONTEXT_SIZE" && *v == "131072")); // deprecated 'kimi' alias 解析到 kimi_code,继承 kimi-k2.5 / 131072 let kimi_alias = provider_extra_env_vars("kimi"); assert_eq!(kimi_alias, kimi_code); - // 关键不变量:moonshot 的 model 不应该是 kimi-k2.5(pre-fix bug) + // 关键不变量:moonshot 的 model 不应该是 kimi-k2.5(pre-fix bug — 会被 + // api.moonshot.cn reject);现默认 moonshot-v1-128k,仍与 kimi_code 不同。 assert_ne!(moonshot, kimi_code, - "moonshot extras must use moonshot-v1-8k, not kimi-k2.5 (would be rejected by api.moonshot.cn)"); + "moonshot extras must use moonshot-v1-128k, not kimi-k2.5 (would be rejected by api.moonshot.cn)"); } #[test] diff --git a/src/commands_proxy.rs b/src/commands_proxy.rs index c5df3f0..b17910b 100644 --- a/src/commands_proxy.rs +++ b/src/commands_proxy.rs @@ -1839,6 +1839,103 @@ pub fn status_summary() -> (bool, String) { } } +/// Layered egress-proxy decision reported by the RUNNING daemon +/// (GET /admin/upstream-proxy `egress` block, 2026-07-08). Field names mirror +/// aikey-proxy's admin.EgressState wire format. +/// +/// WHY this comes from the daemon and not from this process: the daemon's env +/// is the launchd/systemd spawn snapshot — usually NOT the user's shell env — +/// and the daemon's `effective_*` is computed through the very ProxyFunc its +/// forwarding transport runs, so what we display cannot diverge from what +/// traffic actually does. +#[derive(Debug, Default, serde::Deserialize)] +pub struct EgressState { + #[serde(default)] + pub explicit_url: String, + #[serde(default)] + pub env_authoritative: bool, + #[serde(default)] + pub env_vars: std::collections::BTreeMap, + #[serde(default)] + pub system_supported: bool, + #[serde(default)] + pub system_http: String, + #[serde(default)] + pub system_https: String, + #[serde(default)] + pub system_socks: String, + #[serde(default)] + pub effective_source: String, + #[serde(default)] + pub effective_url: String, +} + +#[derive(serde::Deserialize)] +struct UpstreamProxyResponse { + egress: Option, +} + +/// Fetch the layered egress decision from the running daemon. +/// `Ok(None)` = daemon answered but predates the `egress` block (older +/// binary); `Err` = daemon unreachable (not running / wrong port). +pub fn fetch_egress_state() -> Result, String> { + let addr = proxy_listen_addr(None); + // Plain agent on purpose: this is a loopback admin call and must never be + // routed through any proxy env of THIS shell. + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(3)) + .build(); + let resp = agent + .get(&format!("http://{addr}/admin/upstream-proxy")) + .call() + .map_err(|e| format!("cannot reach proxy admin at {addr}: {e}"))?; + let body: UpstreamProxyResponse = resp + .into_json() + .map_err(|e| format!("invalid /admin/upstream-proxy response: {e}"))?; + Ok(body.egress) +} + +#[cfg(test)] +mod egress_state_tests { + use super::*; + + // Wire-format contract with aikey-proxy's admin.EgressState JSON + // marshaling (internal/admin/upstream_proxy.go) — if a field name drifts + // on either side, this fixture goes red. + #[test] + fn parses_layered_egress_wire_format() { + let body = r#"{ + "url": "", + "egress": { + "explicit_url": "", + "env_authoritative": false, + "system_supported": true, + "system_http": "http://127.0.0.1:7890", + "system_https": "http://127.0.0.1:7890", + "system_socks": "socks5://127.0.0.1:7891", + "effective_source": "system", + "effective_url": "http://127.0.0.1:7890" + } + }"#; + let resp: UpstreamProxyResponse = serde_json::from_str(body).unwrap(); + let eg = resp.egress.expect("egress block present"); + assert!(eg.system_supported); + assert_eq!(eg.effective_source, "system"); + assert_eq!(eg.effective_url, "http://127.0.0.1:7890"); + assert_eq!(eg.system_socks, "socks5://127.0.0.1:7891"); + assert!(eg.env_vars.is_empty()); + } + + // Older daemons answer the legacy {"url"} shape — must parse as None + // (CLI renders an "update aikey-proxy" hint, not an error). + #[test] + fn legacy_shape_without_egress_is_none() { + let resp: UpstreamProxyResponse = + serde_json::from_str(r#"{"url":"http://127.0.0.1:7890"}"#).unwrap(); + assert!(resp.egress.is_none()); + } +} + // ─────────────────────────────────────────────────────────────────────────── // Tests — resolve_config (regression net for the 2026-05-21 cwd-first // deprecation: see config-split-system-user.md v9 + bugfix 20260521). diff --git a/src/main.rs b/src/main.rs index f1c2eae..84ce6d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3502,6 +3502,110 @@ fn run_command(cli: &Cli) -> Result<(), Box> { eprintln!(" {}", "(no proxy env vars detected)".dimmed()); } + // Upstream proxy egress — the DAEMON's layered decision + // (2026-07-08 需求: 逐级显示 user-set > env > system proxy). + // Fetched live from /admin/upstream-proxy because the + // daemon's env is its spawn snapshot, not this shell, and + // its effective value comes from the same resolver the + // forwarding transport uses (display == behavior). + eprintln!(); + eprintln!("{}", "Upstream proxy egress (running daemon):".bold()); + match commands_proxy::fetch_egress_state() { + Ok(Some(eg)) => { + // Layer 1: user-set URL (Settings > Upstream proxy). + let l1 = if eg.explicit_url.is_empty() { + "(not set)".to_string() + } else { + eg.explicit_url.clone() + }; + eprintln!(" 1. User-set (Settings > Upstream proxy) {}", l1.dimmed()); + + // Layer 2: the daemon process env (frozen at spawn). + if eg.env_vars.is_empty() { + eprintln!( + " 2. Daemon env (HTTP(S)_PROXY) {}", + "(not set)".dimmed() + ); + } else { + let vars = eg + .env_vars + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join(" "); + let note = if eg.env_authoritative { + " [authoritative — lower layers skipped]" + } else { + "" + }; + eprintln!( + " 2. Daemon env (HTTP(S)_PROXY) {}{}", + vars.dimmed(), + note.dimmed() + ); + } + + // Layer 3: live OS system proxy. + let l3 = if !eg.system_supported { + "(unsupported on this platform)".to_string() + } else if eg.env_authoritative { + "(not consulted — daemon env takes precedence)".to_string() + } else if eg.system_http.is_empty() + && eg.system_https.is_empty() + && eg.system_socks.is_empty() + { + "(none detected)".to_string() + } else { + let mut parts = Vec::new(); + if !eg.system_https.is_empty() { + parts.push(format!("https={}", eg.system_https)); + } + if !eg.system_http.is_empty() { + parts.push(format!("http={}", eg.system_http)); + } + if !eg.system_socks.is_empty() { + parts.push(format!("socks={}", eg.system_socks)); + } + parts.join(" ") + }; + eprintln!(" 3. OS system proxy (live, auto-refresh) {}", l3.dimmed()); + + // Effective result, as the transport resolves it. + let source = match eg.effective_source.as_str() { + "explicit" => "user-set", + "env" => "daemon env", + "system" => "system proxy", + _ => "direct", + }; + let value = if eg.effective_url.is_empty() { + "direct (no proxy)".to_string() + } else { + eg.effective_url.clone() + }; + eprintln!( + " {} {} {}", + "=> Effective for AI providers".bold(), + value, + format!("[source: {}]", source).dimmed() + ); + } + Ok(None) => { + eprintln!( + " {}", + "(daemon predates layered egress reporting — update aikey-proxy)" + .dimmed() + ); + } + Err(e) => { + eprintln!(" {}", format!("(unavailable: {})", e).dimmed()); + eprintln!( + " {}", + "Start the proxy to see the live decision: aikey proxy start" + .dimmed() + ); + } + } + // Injected provider configs: third-party CLI toml files // (kimi, codex) where aikey has written its managed // region. Hidden when no injection has happened so the diff --git a/tests/profile_activation_test.rs b/tests/profile_activation_test.rs index ef63476..80849da 100644 --- a/tests/profile_activation_test.rs +++ b/tests/profile_activation_test.rs @@ -34,6 +34,50 @@ fn setup() -> TempDir { dir } +/// Registers `vk` in the virtual-key cache WITH local ciphertext, so the +/// 2026-07-06 binding-material guard sees it as reachable. +/// +/// WHY: auto_assign / team-sync reconcile now skip team VKs whose key +/// material is unreachable (no ciphertext / not group / not claimed-on- +/// cluster) — see update/20260706-绑定材料守卫与Web解锁态全量sync.md. The +/// team-key tests below assert the POSITIVE assignment path, so their VKs +/// must be material-reachable; the guard's negative paths are pinned by the +/// dedicated tests in commands_account (auto_assign_skips_material_ +/// unreachable_team_vk and friends). +fn reachable_team_vk(vk: &str) { + let entry = storage::VirtualKeyCacheEntry { + virtual_key_id: vk.into(), + org_id: "org-1".into(), + seat_id: "seat-1".into(), + alias: "k".into(), + provider_code: "anthropic".into(), + protocol_type: "anthropic".into(), + base_url: String::new(), + credential_id: String::new(), + credential_revision: String::new(), + virtual_key_revision: "r1".into(), + key_status: "active".into(), + share_status: "claimed".into(), + local_state: "synced_inactive".into(), + expires_at: None, + provider_key_nonce: Some(vec![0u8; 12]), + provider_key_ciphertext: Some(b"cipher".to_vec()), + synced_at: 0, + local_alias: None, + supported_providers: vec![], + provider_base_urls: std::collections::HashMap::new(), + owner_account_id: Some("acct-1".into()), + owner_email: Some("acct-1@test".into()), + group_runtime: None, + group_alias: None, + extra: None, + oauth_group_id: None, + group_accounts: None, + routing_config: None, + }; + storage::upsert_virtual_key_cache(&entry).expect("upsert vk cache"); +} + // ============================================================================ // auto_assign_primaries_for_key // ============================================================================ @@ -42,11 +86,13 @@ fn setup() -> TempDir { fn auto_assign_fills_empty_providers() { let _dir = setup(); - // Add a key supporting two providers. + // Add a key supporting two providers. audit=None: automatic fills carry + // no vault-key context (same convention as production reconcile callers). let assigned = profile_activation::auto_assign_primaries_for_key( "personal", "my-claude", &["anthropic".into(), "openai".into()], + None, ) .unwrap(); @@ -72,6 +118,7 @@ fn auto_assign_does_not_overwrite_existing_primary() { "personal", "new-key", &["anthropic".into(), "openai".into()], + None, ) .unwrap(); @@ -88,10 +135,16 @@ fn auto_assign_does_not_overwrite_existing_primary() { #[test] fn auto_assign_team_key() { let _dir = setup(); + // Material guard: the VK must be reachable or auto-assign skips it. + reachable_team_vk("vk_abc"); - let assigned = - profile_activation::auto_assign_primaries_for_key("team", "vk_abc", &["google".into()]) - .unwrap(); + let assigned = profile_activation::auto_assign_primaries_for_key( + "team", + "vk_abc", + &["google".into()], + None, + ) + .unwrap(); assert_eq!(assigned, vec!["google"]); @@ -113,13 +166,16 @@ fn team_sync_reconcile_fills_gaps() { // anthropic already has a primary. storage::set_provider_binding(DEFAULT_PROFILE, "anthropic", "personal", "my-claude").unwrap(); - // Sync brings in a team key that supports anthropic + openai. + // Sync brings in a team key (material-reachable) that supports + // anthropic + openai. + reachable_team_vk("vk_team_1"); let synced = vec![( "vk_team_1".to_string(), vec!["anthropic".to_string(), "openai".to_string()], )]; let results = - profile_activation::reconcile_provider_primaries_after_team_key_sync(&synced).unwrap(); + profile_activation::reconcile_provider_primaries_after_team_key_sync(&synced, None) + .unwrap(); // Only openai should be assigned to the team key. assert_eq!(results.len(), 1); @@ -140,12 +196,16 @@ fn team_sync_reconcile_no_op_when_all_taken() { storage::set_provider_binding(DEFAULT_PROFILE, "anthropic", "personal", "a").unwrap(); storage::set_provider_binding(DEFAULT_PROFILE, "openai", "personal", "b").unwrap(); + // Reachable on purpose: the empty result below must come from "all + // provider slots taken", not from the material guard skipping the VK. + reachable_team_vk("vk_x"); let synced = vec![( "vk_x".to_string(), vec!["anthropic".to_string(), "openai".to_string()], )]; let results = - profile_activation::reconcile_provider_primaries_after_team_key_sync(&synced).unwrap(); + profile_activation::reconcile_provider_primaries_after_team_key_sync(&synced, None) + .unwrap(); assert!(results.is_empty()); } @@ -162,9 +222,10 @@ fn removal_clears_binding_when_no_replacement() { // The only personal key — no replacement available. // (We don't add any entries to the entries table, so find_replacement will find nothing.) - let actions = - profile_activation::reconcile_provider_primary_after_key_removal("personal", "only-key") - .unwrap(); + let actions = profile_activation::reconcile_provider_primary_after_key_removal( + "personal", "only-key", None, + ) + .unwrap(); assert_eq!(actions.len(), 1); assert_eq!(actions[0].provider_code, "anthropic"); @@ -192,7 +253,7 @@ fn removal_promotes_replacement_personal_key() { // Remove key-a. let actions = - profile_activation::reconcile_provider_primary_after_key_removal("personal", "key-a") + profile_activation::reconcile_provider_primary_after_key_removal("personal", "key-a", None) .unwrap(); assert_eq!(actions.len(), 1); @@ -227,9 +288,10 @@ fn removal_of_multi_provider_key_reconciles_each_provider() { storage::store_entry("backup-openai", &[0u8; 12], &[1u8; 32]).unwrap(); storage::set_entry_supported_providers("backup-openai", &["openai".into()]).unwrap(); - let actions = - profile_activation::reconcile_provider_primary_after_key_removal("personal", "gateway") - .unwrap(); + let actions = profile_activation::reconcile_provider_primary_after_key_removal( + "personal", "gateway", None, + ) + .unwrap(); assert_eq!(actions.len(), 2); @@ -277,9 +339,10 @@ fn replacement_search_finds_personal_entry_with_raw_oauth_provider_code() { storage::set_provider_binding(DEFAULT_PROFILE, "anthropic", "personal", "primary").unwrap(); - let actions = - profile_activation::reconcile_provider_primary_after_key_removal("personal", "primary") - .unwrap(); + let actions = profile_activation::reconcile_provider_primary_after_key_removal( + "personal", "primary", None, + ) + .unwrap(); assert_eq!(actions.len(), 1); assert_eq!(actions[0].provider_code, "anthropic"); @@ -328,8 +391,11 @@ fn refresh_writes_active_env_for_all_bindings() { // Old: per-credential-type sentinel embedded the alias / vk_id. // New: ANTHROPIC_API_KEY=aikey_active_anthropic (same string regardless of bound alias). // Alias info still surfaces via the separate AIKEY_ACTIVE_KEYS=anthropic=my-claude,... - assert!(contents.contains("ANTHROPIC_API_KEY=\"aikey_active_anthropic\"")); - assert!(contents.contains("OPENAI_API_KEY=\"aikey_active_openai\"")); + // Quoting: export values are SINGLE-quoted via sh_single_quote (injection + // hardening — see shell_quote::active_env_export_line; only AIKEY_ACTIVE_SEQ + // / no_proxy keep double quotes for the precmd grep contract). + assert!(contents.contains("ANTHROPIC_API_KEY='aikey_active_anthropic'")); + assert!(contents.contains("OPENAI_API_KEY='aikey_active_openai'")); assert!( contents.contains("anthropic=my-claude"), "AIKEY_ACTIVE_KEYS must surface the bound personal alias for anthropic" @@ -357,9 +423,25 @@ fn refresh_writes_empty_env_when_no_bindings() { let env_path = std::path::PathBuf::from(&home).join(".aikey/active.env"); let contents = std::fs::read_to_string(&env_path).expect("active.env should exist"); - // Should only contain the header comment. assert!(contents.contains("auto-generated")); - assert!(!contents.contains("API_KEY")); + // No provider var may be EXPORTED with no bindings… + assert!( + !contents + .lines() + .any(|l| l.starts_with("export ") && l.contains("API_KEY")), + "no provider API_KEY may be exported with no bindings, got:\n{}", + contents + ); + // …but registry-known vars ARE actively `unset` (stale-var cleanup: a + // shell that sourced an older active.env must not keep a dead + // KIMI_API_KEY etc. — see append_unset_lines_for_inactive_providers). + assert!( + contents + .lines() + .any(|l| l.starts_with("unset ") && l.contains("API_KEY")), + "registry-known provider vars must be unset for cross-shell cleanup, got:\n{}", + contents + ); } // ============================================================================ From ff5cfa506ce4f15588388690e9e6b878a5bb289c Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 8 Jul 2026 10:48:13 -0700 Subject: [PATCH 41/42] fix: proxy startup/lifecycle hardening (early-SIGTERM, port-drift preflight) + egress two-lane refinement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] proxy lifecycle fixes — early-SIGTERM kills startup, port-drift blocked by OrphanedPort preflight, WARN on drift-meta write-fail; proxy status shows start time Refs: - Bugfix: early-sigterm-kills-proxy-startup: https://github.com/aikeylabs/workflow/blob/8a922c0/CI/bugfix/2026-07-08-early-sigterm-kills-proxy-startup.md - Bugfix: port-drift-blocked-by-orphanedport-preflight: https://github.com/aikeylabs/workflow/blob/8a922c0/CI/bugfix/2026-07-08-port-drift-blocked-by-orphanedport-preflight.md - Spec: proxy-status 显示启动时间: https://github.com/aikeylabs/roadmap20260320/blob/1b5f896/技术实现/update/20260708-proxy-status-显示启动时间.md --- src/commands_proxy.rs | 266 ++++++++++++++++++++++--- src/main.rs | 40 +++- src/proxy_lifecycle.rs | 107 +++++++++- src/usage_wal.rs | 5 +- tests/e2e_route_output.rs | 5 +- tests/e2e_vault_security_regression.rs | 32 ++- 6 files changed, 405 insertions(+), 50 deletions(-) diff --git a/src/commands_proxy.rs b/src/commands_proxy.rs index b17910b..0c068a3 100644 --- a/src/commands_proxy.rs +++ b/src/commands_proxy.rs @@ -65,7 +65,7 @@ fn build_start_options( let config_path = resolve_config(config)?; let listen_addr = proxy_listen_addr(Some(&config_path)); - let (extra_env, env_keys) = match crate::proxy_env::read_proxy_env() { + let (mut extra_env, env_keys) = match crate::proxy_env::read_proxy_env() { Ok(env_map) => { let keys: Vec = env_map.keys().cloned().collect(); let pairs: Vec<(String, String)> = env_map.into_iter().collect(); @@ -80,7 +80,16 @@ fn build_start_options( .into()); } }; - + // AIKEY_PROXYENV_KEYS (2026-07-08 egress precedence refinement): tell the + // daemon WHICH env vars are the user's EXPLICIT aikey config (proxy.env) + // vs accidental shell inheritance (.zshrc exports, stale terminals). Only + // marked proxy vars outrank OS system-proxy detection; inherited ones are + // demoted to a fallback below it. Without this marker the system-proxy + // auto-follow layer never engaged on Clash-style machines (field evidence + // 2026-07-08: both the user's Mac and a Windows box). + extra_env.push(("AIKEY_PROXYENV_KEYS".to_string(), env_keys.join(","))); + + let port_drift_enabled = read_yaml_port_drift_enabled(Some(&config_path)); let opts = crate::proxy_lifecycle::StartOptions { config_path, binary_path, @@ -88,10 +97,52 @@ fn build_start_options( healthy_deadline: crate::proxy_lifecycle::DEFAULT_HEALTHY_DEADLINE, stderr_target, extra_env, + port_drift_enabled, }; Ok((opts, env_keys)) } +/// Whether `listen.port_drift_max` in the proxy yaml enables port drift. +/// Mirrors the Go side's semantics (supervisor.Listen + config defaults): +/// absent → default ON; `0` → coerced to the default (ON); only an explicit +/// negative value means strict/OFF. Read failure → ON (the daemon's own +/// default), so the CLI never refuses a start the daemon itself would accept. +pub(crate) fn read_yaml_port_drift_enabled(config_path: Option<&std::path::Path>) -> bool { + let path = match config_path { + Some(p) => p.to_path_buf(), + None => match resolve_config(None) { + Ok(p) => p, + Err(_) => return true, + }, + }; + let text = match fs::read_to_string(&path) { + Ok(t) => t, + Err(_) => return true, + }; + let mut in_listen = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed == "listen:" { + in_listen = true; + continue; + } + if in_listen { + if let Some(rest) = trimmed.strip_prefix("port_drift_max:") { + if let Ok(n) = rest.trim().parse::() { + return n >= 0; + } + } else if !trimmed.is_empty() + && !trimmed.starts_with('#') + && !trimmed.starts_with(' ') + && !line.starts_with(' ') + { + in_listen = false; + } + } + } + true +} + // --------------------------------------------------------------------------- // Vault change-sequence state helpers // --------------------------------------------------------------------------- @@ -684,7 +735,66 @@ fn read_runtime_actual_addr() -> Option { Some(actual) } -fn runtime_snapshot_path() -> Option { +/// "started" row for `aikey proxy status` (2026-07-08): when the daemon was +/// (re)started, plus a humanized uptime — the quick answer to "did the proxy +/// just restart on me?" (self-heal exit-75 relaunches rewrite the snapshot, +/// so this reflects the CURRENT incarnation, not the first-ever start). +/// +/// Guard: the snapshot pid must match the pid `proxy_state` verified, so a +/// stale proxy-runtime.json from a crashed prior incarnation is never +/// attributed to the running process. +fn runtime_started_row(expect_pid: u32) -> Option { + let path = runtime_snapshot_path()?; + let text = fs::read_to_string(&path).ok()?; + let v: serde_json::Value = serde_json::from_str(&text).ok()?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs() as i64; + started_row_from_snapshot(&v, expect_pid, now) +} + +/// Pure core of runtime_started_row, split out so tests drive it with a +/// fixture snapshot + fixed clock (no env-var / filesystem coupling). +fn started_row_from_snapshot( + v: &serde_json::Value, + expect_pid: u32, + now_secs: i64, +) -> Option { + if v.get("pid")?.as_i64()? != expect_pid as i64 { + return None; + } + let raw = v.get("started_at")?.as_str()?; + let started = crate::usage_wal::parse_rfc3339_secs(raw)?; + // The daemon writes started_at as UTC RFC3339; render "date time UTC" + // (drop fractional seconds) — en-US/UTC locked per code-and-ui-language. + if raw.len() < 19 { + return None; + } + let ts = format!("{} {} UTC", &raw[0..10], &raw[11..19]); + Some(format!( + "started: {} (up {})", + ts, + humanize_uptime(now_secs - started) + )) +} + +/// 3661 → "1h 1m", 90061 → "1d 1h", 42 → "42s". Clock skew (negative) → "0s". +fn humanize_uptime(secs: i64) -> String { + let s = secs.max(0); + let (d, h, m) = (s / 86400, (s % 86400) / 3600, (s % 3600) / 60); + if d > 0 { + format!("{d}d {h}h") + } else if h > 0 { + format!("{h}h {m}m") + } else if m > 0 { + format!("{m}m {}s", s % 60) + } else { + format!("{s}s") + } +} + +pub(crate) fn runtime_snapshot_path() -> Option { if let Ok(dir) = std::env::var("AIKEY_RUN_DIR") { return Some(std::path::PathBuf::from(dir).join("proxy-runtime.json")); } @@ -870,12 +980,21 @@ fn handle_start_foreground( owner_pid, reason, } => { - return Err(format!( - "cannot start in foreground: port {port} is owned by something \ - we cannot manage ({})", - reason.hint(port, owner_pid) - ) - .into()); + // 2026-07-08 (same rule as the detached path, e2e w2b): drift ON → + // the busy configured port is the proxy's problem to solve (it + // binds port+1..+max itself); other shells discover the actual + // port via proxy-runtime.json. Refuse only when drift is OFF. + if !read_yaml_port_drift_enabled(Some(&config_path)) { + return Err(format!( + "cannot start in foreground: port {port} is owned by something \ + we cannot manage ({})", + reason.hint(port, owner_pid) + ) + .into()); + } + eprintln!( + "[aikey] port {port} is busy — starting with port drift (proxy will bind the next free port)" + ); } ProxyState::Crashed { stale_pid: _ } => { // Crashed cleanup happens here while we hold the lock so @@ -930,27 +1049,6 @@ fn handle_start_foreground( .map_err(|e| format!("failed to spawn aikey-proxy: {}", e))?; let pid = child.id(); - // Persist BOTH ownership anchor files so the proxy is recognized - // by Layer 1 as `Running` (not `LegacyPidfileNoSidecar`). - if let Err(e) = persist_ownership_files(pid, &proxy_bin, &config_path, &listen_addr) { - // Spawn succeeded but we can't anchor ownership — the proxy - // would otherwise be unmanageable from other shells. Kill the - // child to avoid leaving an unowned instance. - let _ = child.kill(); - let _ = child.wait(); - return Err(format!("failed to persist ownership files: {e}").into()); - } - - if let Ok(seq) = crate::storage::get_vault_change_seq() { - let _ = crate::storage::set_proxy_loaded_seq(seq); - } - - // Release the lifecycle lock now that ownership files are written. - // The foreground proxy continues running independently; other - // shells' `aikey proxy stop / restart / status` see it as a - // first-class managed instance via the sidecar meta. - drop(_lock); - // **Round-15 install-script fix #1**: forward SIGTERM / SIGINT // from this CLI to the spawned proxy child. Critical for // service-managed deployments (systemd Type=simple, launchd): @@ -961,6 +1059,13 @@ fn handle_start_foreground( // restart, the new instance would see OrphanedPort, and the // service would fail to recover. // + // ORDERING (2026-07-08, e2e i3): installed IMMEDIATELY after spawn, + // BEFORE persist_ownership_files. The ownership meta is the public + // "you may manage me now" signal — a stop/SIGTERM arriving right + // after it appears must already find the forwarder installed, or + // the CLI dies by default disposition (killed-by-signal instead of + // forwarding + clean exit). + // // ctrlc 3.x supports installing a single global handler. We // capture child PID into a local closure context. Any // subsequent installation (also rare in CLI-shell contexts) @@ -969,7 +1074,17 @@ fn handle_start_foreground( // that needs signal forwarding, so first-installation-wins is // fine for our model. let child_pid_for_signal = pid; + // Records that WE forwarded a shutdown signal, so the wait below can tell + // "child killed by the SIGTERM we sent" apart from a real crash. Needed + // because a signal landing in the child's first milliseconds (before the + // Go runtime installs its handler — see aikey-proxy main.go 2026-07-08 + // Notify-first comment) still kills it via default disposition + // ("signal: 15" instead of graceful exit 0); that is an ORDERED shutdown, + // not a failure. e2e i3 pins this. + let forwarded_shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let forwarded_shutdown_w = forwarded_shutdown.clone(); let _ = ctrlc::set_handler(move || { + forwarded_shutdown_w.store(true, std::sync::atomic::Ordering::SeqCst); // Forward SIGTERM to the child. Best-effort: if the child is // already gone, libc::kill returns ESRCH which we silently // ignore. We do NOT exit the parent ourselves — child.wait() @@ -994,6 +1109,27 @@ fn handle_start_foreground( let _ = child_pid_for_signal; }); + // Persist BOTH ownership anchor files so the proxy is recognized + // by Layer 1 as `Running` (not `LegacyPidfileNoSidecar`). + if let Err(e) = persist_ownership_files(pid, &proxy_bin, &config_path, &listen_addr) { + // Spawn succeeded but we can't anchor ownership — the proxy + // would otherwise be unmanageable from other shells. Kill the + // child to avoid leaving an unowned instance. + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("failed to persist ownership files: {e}").into()); + } + + if let Ok(seq) = crate::storage::get_vault_change_seq() { + let _ = crate::storage::set_proxy_loaded_seq(seq); + } + + // Release the lifecycle lock now that ownership files are written. + // The foreground proxy continues running independently; other + // shells' `aikey proxy stop / restart / status` see it as a + // first-class managed instance via the sidecar meta. + drop(_lock); + let status = child.wait()?; // Reverse order of write: pidfile first, sidecar second @@ -1008,6 +1144,19 @@ fn handle_start_foreground( best_effort_remove(&p); } if !status.success() { + // Child died BY the shutdown signal we ourselves forwarded → ordered + // shutdown, not a failure (see forwarded_shutdown comment above). + // Unix-only: on Windows the handler never signals the child (no-op), + // so this branch cannot trigger there. + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if forwarded_shutdown.load(std::sync::atomic::Ordering::SeqCst) + && matches!(status.signal(), Some(libc::SIGTERM) | Some(libc::SIGINT)) + { + return Ok(()); + } + } return Err(format!("aikey-proxy exited with status: {}", status).into()); } Ok(()) @@ -1115,6 +1264,9 @@ pub fn status_rows() -> Vec { } => { rows.push("status: running (healthy)".to_string()); rows.push(format!("pid: {pid}")); + if let Some(row) = runtime_started_row(pid) { + rows.push(row); + } rows.push(format!("listen: http://{listen_addr}")); match proxy_vault_state() { ProxyVaultState::Current => rows.push("vault sync: current".to_string()), @@ -1127,6 +1279,11 @@ pub fn status_rows() -> Vec { ProxyState::Unresponsive { pid, port } => { rows.push("status: unresponsive (port bound, /health not responding)".to_string()); rows.push(format!("pid: {pid}")); + // Uptime disambiguates "hung" from "still initializing": a + // seconds-old start says wait, an hours-old one says restart. + if let Some(row) = runtime_started_row(pid) { + rows.push(row); + } rows.push(format!("listen: http://127.0.0.1:{port}")); rows.push( "hint: could be initializing or hung — wait or `aikey proxy restart`" @@ -1856,6 +2013,11 @@ pub struct EgressState { pub env_authoritative: bool, #[serde(default)] pub env_vars: std::collections::BTreeMap, + /// Layer 4 (2026-07-08 refinement): proxy vars inherited from the spawn + /// shell — consulted only when user-set URL / proxy.env / system proxy + /// are all empty. + #[serde(default)] + pub env_inherited_vars: std::collections::BTreeMap, #[serde(default)] pub system_supported: bool, #[serde(default)] @@ -1895,6 +2057,44 @@ pub fn fetch_egress_state() -> Result, String> { Ok(body.egress) } +#[cfg(test)] +mod started_row_tests { + use super::*; + + fn snap(pid: i64, started_at: &str) -> serde_json::Value { + serde_json::json!({"pid": pid, "started_at": started_at, "listen": {"actual_addr": "127.0.0.1:27200"}}) + } + + #[test] + fn renders_started_time_and_uptime() { + // started 2026-07-08T06:00:00Z, now = +2h13m — Go's fractional + // seconds must be dropped, uptime humanized. + let v = snap(4242, "2026-07-08T06:00:00.123456789Z"); + let now = crate::usage_wal::parse_rfc3339_secs("2026-07-08T08:13:00Z").unwrap(); + assert_eq!( + started_row_from_snapshot(&v, 4242, now).unwrap(), + "started: 2026-07-08 06:00:00 UTC (up 2h 13m)" + ); + } + + #[test] + fn stale_snapshot_pid_mismatch_hides_row() { + // A leftover runtime.json from a previous incarnation (pid recycled) + // must never be attributed to the current process. + let v = snap(1111, "2026-07-08T06:00:00Z"); + assert!(started_row_from_snapshot(&v, 4242, 0).is_none()); + } + + #[test] + fn uptime_humanizes_all_magnitudes() { + assert_eq!(humanize_uptime(42), "42s"); + assert_eq!(humanize_uptime(5 * 60 + 12), "5m 12s"); + assert_eq!(humanize_uptime(2 * 3600 + 13 * 60), "2h 13m"); + assert_eq!(humanize_uptime(3 * 86400 + 4 * 3600), "3d 4h"); + assert_eq!(humanize_uptime(-5), "0s"); // clock skew must not panic + } +} + #[cfg(test)] mod egress_state_tests { use super::*; @@ -1909,6 +2109,7 @@ mod egress_state_tests { "egress": { "explicit_url": "", "env_authoritative": false, + "env_inherited_vars": {"https_proxy": "http://127.0.0.1:7890"}, "system_supported": true, "system_http": "http://127.0.0.1:7890", "system_https": "http://127.0.0.1:7890", @@ -1924,6 +2125,11 @@ mod egress_state_tests { assert_eq!(eg.effective_url, "http://127.0.0.1:7890"); assert_eq!(eg.system_socks, "socks5://127.0.0.1:7891"); assert!(eg.env_vars.is_empty()); + // Layer 4 (2026-07-08): inherited shell env travels separately. + assert_eq!( + eg.env_inherited_vars.get("https_proxy").map(String::as_str), + Some("http://127.0.0.1:7890") + ); } // Older daemons answer the legacy {"url"} shape — must parse as None diff --git a/src/main.rs b/src/main.rs index 84ce6d4..30f92f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3520,10 +3520,14 @@ fn run_command(cli: &Cli) -> Result<(), Box> { }; eprintln!(" 1. User-set (Settings > Upstream proxy) {}", l1.dimmed()); - // Layer 2: the daemon process env (frozen at spawn). + // Layer 2: EXPLICIT aikey env config (proxy.env, + // CLI-marked at spawn). 2026-07-08 refinement: + // inherited shell env is layer 4, BELOW the system + // proxy — otherwise Clash users' .zshrc exports + // permanently masked system-proxy auto-follow. if eg.env_vars.is_empty() { eprintln!( - " 2. Daemon env (HTTP(S)_PROXY) {}", + " 2. proxy.env explicit (HTTP(S)_PROXY) {}", "(not set)".dimmed() ); } else { @@ -3539,7 +3543,7 @@ fn run_command(cli: &Cli) -> Result<(), Box> { "" }; eprintln!( - " 2. Daemon env (HTTP(S)_PROXY) {}{}", + " 2. proxy.env explicit (HTTP(S)_PROXY) {}{}", vars.dimmed(), note.dimmed() ); @@ -3549,7 +3553,7 @@ fn run_command(cli: &Cli) -> Result<(), Box> { let l3 = if !eg.system_supported { "(unsupported on this platform)".to_string() } else if eg.env_authoritative { - "(not consulted — daemon env takes precedence)".to_string() + "(not consulted — proxy.env takes precedence)".to_string() } else if eg.system_http.is_empty() && eg.system_https.is_empty() && eg.system_socks.is_empty() @@ -3570,11 +3574,37 @@ fn run_command(cli: &Cli) -> Result<(), Box> { }; eprintln!(" 3. OS system proxy (live, auto-refresh) {}", l3.dimmed()); + // Layer 4: shell-inherited env — fallback only. + if eg.env_inherited_vars.is_empty() { + eprintln!( + " 4. Inherited shell env (fallback) {}", + "(not set)".dimmed() + ); + } else { + let vars = eg + .env_inherited_vars + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join(" "); + let note = if eg.effective_source == "env_inherited" { + "" + } else { + " [outranked by a higher layer]" + }; + eprintln!( + " 4. Inherited shell env (fallback) {}{}", + vars.dimmed(), + note.dimmed() + ); + } + // Effective result, as the transport resolves it. let source = match eg.effective_source.as_str() { "explicit" => "user-set", - "env" => "daemon env", + "env" => "proxy.env", "system" => "system proxy", + "env_inherited" => "inherited shell env", _ => "direct", }; let value = if eg.effective_url.is_empty() { diff --git a/src/proxy_lifecycle.rs b/src/proxy_lifecycle.rs index df53595..01ed8ee 100644 --- a/src/proxy_lifecycle.rs +++ b/src/proxy_lifecycle.rs @@ -627,6 +627,18 @@ pub struct StartOptions { /// proxy.env). `AIKEY_MASTER_PASSWORD` is always set separately /// from the password parameter. pub extra_env: Vec<(String, String)>, + + /// Whether the proxy config enables port drift (`listen.port_drift_max` + /// absent or >= 0; only an explicit negative value disables — mirrors the + /// Go side's `supervisor.Listen` semantics, see 20260430-端口偏移能力修复). + /// + /// WHY start needs to know (2026-07-08 bugfix, e2e w2b): with drift ON, an + /// externally-held configured port is NOT a refusal condition — we spawn + /// anyway and the proxy binds port+1..+max itself. The 2026-07-01 + /// PID-reuse-lockout rework classified that situation as `OrphanedPort` + /// and start refused before spawn, silently killing the drift capability. + /// Refusal stays for drift-disabled configs (strict legacy). + pub port_drift_enabled: bool, } /// Where to direct the spawned child's stderr. @@ -1065,11 +1077,24 @@ fn start_proxy_locked_inner( reason, } => { *entry_state_label = "OrphanedPort".into(); - return Err(StartError::OrphanedPort { - port, - owner_pid, - reason, - }); + // 2026-07-08 bugfix (e2e w2b, 20260430 §6 acceptance row 2): with + // port drift ENABLED, an externally-held configured port is not a + // refusal — spawn anyway, the proxy binds port+1..+max itself and + // the healthy loop below discovers the actual port via + // proxy-runtime.json. We never signal the holder (o2 invariant + // intact). Refusal stays for drift-disabled configs, preserving + // the 2026-07-01 PID-reuse-lockout protection there. + if !opts.port_drift_enabled { + return Err(StartError::OrphanedPort { + port, + owner_pid, + reason, + }); + } + eprintln!( + "[aikey] port {} is busy — starting with port drift (proxy will bind the next free port)", + port + ); } ProxyState::Stopped => { *entry_state_label = "Stopped".into(); @@ -1212,7 +1237,41 @@ fn start_proxy_locked_inner( stderr_log: stderr_log_path, }); } - if proxy_proc::http_health_ok(port, Duration::from_millis(500)) { + // Drift discovery (2026-07-08, e2e w2b): with drift enabled the child + // may have bound port+k, so probing only the CONFIGURED port would + // time out (or hit the foreign holder). The proxy writes its ACTUAL + // bound addr to proxy-runtime.json right after bind — trust it IFF + // the snapshot's pid is OUR child (a stale file from a previous + // incarnation must not fake a healthy signal). + let drifted_addr = if opts.port_drift_enabled { + runtime_actual_addr_for_pid(child_pid).filter(|a| *a != opts.listen_addr) + } else { + None + }; + let probe_port = drifted_addr.as_deref().map(parse_port).unwrap_or(port); + if proxy_proc::http_health_ok(probe_port, Duration::from_millis(500)) + && port_owned_by(probe_port, child_pid) + { + if let Some(actual) = drifted_addr { + // Re-anchor the ownership meta at the REAL addr so status / + // stop probe the drifted port even if runtime.json vanishes. + if let Err(e) = persist_ownership_files( + child_pid, + &opts.binary_path, + &opts.config_path, + &actual, + ) { + eprintln!( + "[aikey] warning: proxy drifted to {actual} but updating the ownership meta failed: {e}" + ); + } + guard.commit(); + return Ok(RunningState { + pid: child_pid, + port: probe_port, + listen_addr: actual, + }); + } guard.commit(); return Ok(RunningState { pid: child_pid, @@ -1229,6 +1288,42 @@ fn start_proxy_locked_inner( } } +/// Whether `port` is held by `child_pid` — the healthy loop's identity guard +/// (2026-07-08). A bare /health 200 proves *a* proxy answers, not OURS: the +/// spawned child can be a zombie (held un-reaped by StartCleanupGuard, so +/// `process_alive` stays true) while an unrelated healthy proxy squats the +/// probed port — start would then report success for a dead child. Only an +/// affirmative "someone ELSE owns it" vetoes; lookup failure / unknown owner +/// degrades to the pre-check behavior (don't block success on missing lsof). +fn port_owned_by(port: u16, child_pid: u32) -> bool { + match proxy_proc::port_owner_pid(port) { + Ok(Some(owner)) => owner == child_pid, + Ok(None) | Err(_) => true, + } +} + +/// Read the actual bound addr from `proxy-runtime.json` IFF it was written by +/// THIS child (pid match) — the drift-discovery seam of the healthy loop +/// above. Field names mirror aikey-proxy's internal/runtime Snapshot wire +/// format (`pid`, `listen.actual_addr`). +fn runtime_actual_addr_for_pid(pid: u32) -> Option { + let path = crate::commands_proxy::runtime_snapshot_path()?; + let text = std::fs::read_to_string(path).ok()?; + let v: serde_json::Value = serde_json::from_str(&text).ok()?; + if v.get("pid")?.as_u64()? != pid as u64 { + return None; + } + let addr = v + .get("listen") + .and_then(|l| l.get("actual_addr")) + .and_then(|a| a.as_str())?; + if addr.is_empty() { + None + } else { + Some(addr.to_string()) + } +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- diff --git a/src/usage_wal.rs b/src/usage_wal.rs index 2242892..5768bca 100644 --- a/src/usage_wal.rs +++ b/src/usage_wal.rs @@ -669,7 +669,10 @@ fn list_wal_files(dir: &Path) -> io::Result> { /// Best-effort RFC3339 → unix seconds parser. Returns None on any format /// we don't recognize (the WAL should always emit RFC3339 with offset, but /// being defensive keeps a rogue line from aborting the whole scan). -fn parse_rfc3339_secs(s: &str) -> Option { +/// pub(crate): also reused by `aikey proxy status` to parse the daemon's +/// `started_at` from proxy-runtime.json (2026-07-08) — same wire format, +/// one parser (single source of truth, no chrono dependency). +pub(crate) fn parse_rfc3339_secs(s: &str) -> Option { // Accept the common patterns the proxy emits: // 2026-04-13T11:54:40.122225+08:00 // 2026-04-17T15:23:45.123Z diff --git a/tests/e2e_route_output.rs b/tests/e2e_route_output.rs index 4c031a8..4080bef 100644 --- a/tests/e2e_route_output.rs +++ b/tests/e2e_route_output.rs @@ -96,7 +96,10 @@ fn route_table_has_expected_columns() { let stderr = strip_ansi(&String::from_utf8_lossy(&out.stderr)); // Header row is on stderr (table is informational). - for col in &["PROVIDER", "LABEL", "API_KEY", "BASE URL"] { + // "PROTOCOL" (not "PROVIDER") since a737948 2026-04-27: the column shows + // the wire protocol (openai/anthropic/...) — the provider/protocol + // terminology split of the protocol_type domain model. + for col in &["PROTOCOL", "LABEL", "API_KEY", "BASE URL"] { assert!( stderr.contains(col), "route header missing '{}' column:\n{}", diff --git a/tests/e2e_vault_security_regression.rs b/tests/e2e_vault_security_regression.rs index a657fe6..44dcdcf 100644 --- a/tests/e2e_vault_security_regression.rs +++ b/tests/e2e_vault_security_regression.rs @@ -117,17 +117,31 @@ fn wrong_password_rejected_when_password_hash_missing() { ); let stderr = String::from_utf8_lossy(&out.stderr); + // Contract updated 2026-05-11 (008eafe): missing-hash is now a hard + // "vault state inconsistent" refusal for ANY password (fail-loud), + // superseding the 2026-04-11 decrypt-verification message. Either way the + // core security property holds: the wrong password is never accepted. assert!( - stderr.contains("Invalid master password") + stderr.contains("vault state inconsistent") + || stderr.contains("Invalid master password") || stderr.contains("invalid") || stderr.contains("corrupted"), - "rejection message should mention password validity, got:\n{}", + "rejection message should mention password validity or vault inconsistency, got:\n{}", stderr ); } #[test] -fn correct_password_still_works_when_password_hash_missing() { +fn missing_password_hash_refuses_all_passwords_with_guidance() { + // Contract history: + // - 2026-04-11 fix: missing hash → verify by decrypting an entry, so the + // CORRECT password kept working (this test's original expectation). + // - 2026-05-11 (008eafe) superseded it: master_salt present + hash missing + // is an INCONSISTENT vault — ANY password (even the correct one) is + // refused fail-loud with actionable guidance (`aikey init` / restore), + // because silently accepting an unverifiable password is the exact + // shape of the original 2026-04-11 bypass. Downstream code recognizes + // the message (executor.rs), so this is the durable contract. let env = Env::new("pwbypass-right"); env.add_key("correct-pw", "k1", "openai", "sk-1"); @@ -136,8 +150,6 @@ fn correct_password_still_works_when_password_hash_missing() { .execute("DELETE FROM config WHERE key = 'password_hash'", []) .expect("delete hash"); - // Correct password must still work — the fix should verify by decrypting - // an existing entry, not lock the user out. let out = env .cmd_with_password("correct-pw") .args(["add", "k2", "--provider", "anthropic"]) @@ -145,12 +157,18 @@ fn correct_password_still_works_when_password_hash_missing() { .output() .expect("spawn add"); assert!( - out.status.success(), - "correct password MUST still work even with password_hash missing.\n\ + !out.status.success(), + "inconsistent vault (hash missing) must refuse even the correct password.\n\ stdout: {}\nstderr: {}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("vault state inconsistent") && stderr.contains("aikey init"), + "refusal must explain the inconsistency AND give the remediation step, got:\n{}", + stderr + ); } // ── aikey db upgrade/rollback smoke ───────────────────────────────────── From 91bc2abe97ba2ce88c79167a72097c9c4c54fd18 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 12 Jul 2026 22:12:53 -0700 Subject: [PATCH 42/42] =?UTF-8?q?feat:=20hook=20wiring=20visibility=20?= =?UTF-8?q?=E2=80=94=20unwired-hook=20warning=20+=20readiness=20banner;=20?= =?UTF-8?q?Windows=20hook=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [aikey-cli] warn (stderr) when CLI configs still route through aikey but the hook/env channel is unwired (codex would fail on missing OPENAI_API_KEY); hook.bash bootstrap + hook.ps1 ASCII fence; shell_integration unix/windows + hook_op Refs: - Spec: hook 接线可见性升级: https://github.com/aikeylabs/roadmap20260320/blob/ea17cea9b20a733104d868098bfa1a1953b15864/技术实现/update/20260710-hook接线可见性升级.md - Requirement: hook-wiring-visibility: https://github.com/aikeylabs/workflow/blob/847b502ac807ed85ddb7e051328a95120fef7566/CI/requirements/2026-07-10-hook-wiring-visibility.md --- src/commands_account/mod.rs | 193 ++++++++- src/commands_account/shell_integration.rs | 302 +++++++++++-- .../shell_integration_windows.rs | 401 ++++++++++++++---- src/commands_internal/hook_op.rs | 34 +- src/commands_project.rs | 148 +++++-- src/main.rs | 114 ++++- src/templates/hook.bash | 27 +- src/templates/hook.ps1 | 18 +- 8 files changed, 1067 insertions(+), 170 deletions(-) diff --git a/src/commands_account/mod.rs b/src/commands_account/mod.rs index d995114..9a3dd53 100644 --- a/src/commands_account/mod.rs +++ b/src/commands_account/mod.rs @@ -4576,6 +4576,10 @@ pub use lifecycle::*; // sibling, mainly relevant to Windows users), not a compilation gate. // See shell_integration_windows.rs module docstring. mod shell_integration_windows; +// ExecutionPolicy wired-but-dead probe (2026-07-12 X2) — consumed by doctor. +pub use shell_integration_windows::powershell_profile_load_blocked; +// pwsh-7 dual-profile wiring gap probe (2026-07-12 3a) — consumed by doctor. +pub use shell_integration_windows::pwsh_profile_wiring_gap; /// Resolve an OAuth account by `provider_account_id`, `local_alias`, OR /// `display_identity` (email). Returns `None` when no match — caller treats @@ -5028,11 +5032,22 @@ pub fn handle_key_use( // precmd hook picks up the new active.env on the user's next prompt // (free, unconditional), but if they want to use the new key in // *this* prompt they can `source` the file. State that plainly. - let status = if hook_msg.is_some() { - "\u{2192} Shell hook just installed. Open a new terminal or: source ~/.aikey/active.env" - } else { - "\u{2713} Active key updated. Next prompt picks it up automatically.\n To apply right now: source ~/.aikey/active.env" - }; + // + // Use-effectiveness self-check (2026-07-10): "next prompt picks it + // up automatically" is only true when the hook is actually loaded + // in the invoking shell. Classify via the exported + // _AIKEY_HOOK_LOADED_HASH marker + rc wiring state instead of + // asserting it unconditionally — a terminal opened before hook + // install got a claim that silently never came true. + let status = use_status_line( + hook_msg.as_deref(), + std::env::var("_AIKEY_HOOK_LOADED_HASH").is_ok(), + shell_integration::shell_rc_has_aikey_block(), + no_hook + || std::env::var("AIKEY_NO_HOOK") + .map(|v| v == "1") + .unwrap_or(false), + ); let mut rows: Vec = Vec::new(); for b in &bindings { @@ -5055,7 +5070,7 @@ pub fn handle_key_use( } } rows.push(String::new()); - rows.push(status.to_string()); + rows.push(status); let title = format!( "Set '{}' as Primary for {}", @@ -5074,6 +5089,172 @@ pub fn handle_key_use( Ok(()) } +/// Classify the `aikey use` summary line so it never overpromises +/// (use-effectiveness self-check, 2026-07-10). +/// +/// Problem this solves: the old summary claimed "Next prompt picks it up +/// automatically" unconditionally whenever the rc was already wired +/// (AlreadyV3 → `hook_msg == None`). In a terminal opened BEFORE the hook +/// was wired, precmd was never registered there, so the claim silently +/// never came true — the user ran `claude` bare and nothing routed through +/// aikey, with zero feedback. Re-running `aikey use` with the SAME key hit +/// the same silent path. +/// +/// Detection: the hook file exports `_AIKEY_HOOK_LOADED_HASH` at source +/// time (see `hook_content_with_hash_header`), so a child process seeing +/// that env var knows the invoking shell loaded the hook. aikey cannot +/// source the hook into the parent shell on the user's behalf — the honest +/// remediation is a precise, actionable hint. +/// +/// Transitional caveat: shells that loaded a pre-export hook (< 2026-07-10 +/// template) don't expose the marker until the auto-reload picks up the +/// regenerated file at the next prompt; they may see one warning that a +/// fresh prompt (or new terminal) resolves. Self-healing, accepted. +pub(crate) fn use_status_line( + hook_msg: Option<&str>, + hook_loaded_in_shell: bool, + rc_wired: bool, + hook_opted_out: bool, +) -> String { + // Platform-aware immediate-apply hint (2026-07-12, Windows X4/X5): + // `source ~/.aikey/active.env` is a dead instruction on PowerShell/cmd. + let apply_now = shell_integration::apply_now_hint(); + if hook_opted_out { + return format!( + "\u{2713} Active key updated. Shell hook disabled (AIKEY_NO_HOOK) \u{2014} apply manually: {apply_now}" + ); + } + // ensure_shell_hook spoke (installed / migrated / declined / non-TTY + // hint): surface its exact message instead of the old generic "hook + // just installed" line, which mislabeled hints and declines. The + // message text was previously swallowed (only is_some() was checked). + if let Some(msg) = hook_msg { + return format!( + "\u{2192} {}\n To apply right now: {apply_now}", + msg.trim_start() + ); + } + if hook_loaded_in_shell { + return format!( + "\u{2713} Active key updated. Next prompt picks it up automatically.\n To apply right now: {apply_now}" + ); + } + if rc_wired { + // Platform-aware remediation (2026-07-12, found on Windows real-machine + // verification): the first draft hardcoded "~/.zshrc / ~/.bashrc", + // which is a dead instruction for PowerShell users — their hook lives + // in $PROFILE.CurrentUserAllHosts. reload_hint_for_shell() owns the + // per-shell dispatch. + format!( + "\x1b[33m\u{25b2} Active key updated, but aikey env is NOT loaded in this shell (terminal opened before the hook was installed?).\n Apply: open a new terminal, or run: {}\x1b[0m", + shell_integration::reload_hint_for_shell() + ) + } else { + "\x1b[33m\u{25b2} Active key updated, but the shell hook is not wired \u{2014} `claude`/`codex` will NOT route through aikey.\n Fix: run `aikey hook install`, then open a new terminal.\x1b[0m".to_string() + } +} + +#[cfg(test)] +mod use_status_line_tests { + use super::use_status_line; + + #[test] + fn opted_out_is_neutral_and_never_warns() { + let s = use_status_line(None, false, false, true); + assert!(s.contains("AIKEY_NO_HOOK")); + assert!(!s.contains("\u{25b2}"), "opt-out users chose this — no warning"); + } + + #[test] + fn hook_msg_is_surfaced_verbatim_not_swallowed() { + // Regression: the old code only checked is_some() and replaced the + // actual hint ("Skipped...", "needs interactive confirmation...") + // with a generic "hook just installed" line — wrong for declines. + let s = use_status_line(Some(" Skipped. To apply once: source ~/.aikey/hook.zsh"), false, false, false); + assert!(s.contains("Skipped. To apply once")); + } + + #[test] + fn loaded_shell_keeps_autopickup_promise() { + let s = use_status_line(None, true, true, false); + assert!(s.contains("Next prompt picks it up automatically")); + assert!(!s.contains("\u{25b2}")); + } + + #[test] + fn wired_but_stale_shell_warns_with_reload_hint() { + let s = use_status_line(None, false, true, false); + assert!(s.contains("NOT loaded in this shell")); + assert!(s.contains("new terminal")); + } + + #[test] + fn unwired_shell_warns_with_hook_install_hint() { + let s = use_status_line(None, false, false, false); + assert!(s.contains("aikey hook install")); + assert!(s.contains("NOT route through aikey")); + } +} + +/// Post-`aikey hook uninstall` third-party CLI config reconciliation +/// (2026-07-12, user report X8: Windows `hook uninstall` → `codex` dies +/// with "Missing environment variable: OPENAI_API_KEY"). +/// +/// Why: `hook uninstall` removes the env-injection channel but used to +/// leave `~/.codex/config.toml` (`model_provider=aikey`, env_key) and the +/// kimi config pointing at aikey — configs that only work WITH the hook. +/// New terminals then fail with a cryptic upstream error. `aikey unuse` +/// got symmetric cleanup in 2026-05-18 (B1/B2); this is the same lifecycle +/// gap on the hook-uninstall edge. +/// +/// Behavior (user decision 2026-07-12, option b): interactive sessions get +/// a Y/n prompt to strip aikey routing from the affected CLI configs +/// (bindings are KEPT — re-wiring the hook or the next `aikey use` +/// re-configures them). Non-TTY callers get a loud warning + exact +/// commands instead — never a silent config mutation. +pub fn reconcile_cli_configs_after_hook_uninstall() { + use std::io::{IsTerminal, Write}; + + let injected = shell_integration::injected_provider_toml_paths(); + if injected.is_empty() { + return; + } + eprintln!( + "\x1b[33m \u{25b2} These CLI configs still route through aikey, but the hook (env channel) is now unwired:\x1b[0m" + ); + for (label, path) in &injected { + eprintln!("\x1b[33m {:<6} {}\x1b[0m", label, path.display()); + } + eprintln!( + "\x1b[33m In new terminals those CLIs will fail (e.g. codex: Missing environment variable: OPENAI_API_KEY).\x1b[0m" + ); + + if !std::io::stderr().is_terminal() || !std::io::stdin().is_terminal() { + eprintln!( + " Clean them up with: \x1b[36maikey unuse \x1b[0m \u{2014} or re-enable the hook: \x1b[36maikey hook install\x1b[0m" + ); + return; + } + + eprint!(" Remove aikey routing from these CLI configs now? [Y/n] (default Y): "); + let _ = std::io::stderr().flush(); + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_ok() + && matches!(input.trim().to_lowercase().as_str(), "n" | "no") + { + eprintln!( + " Kept. Clean up later with: \x1b[36maikey unuse \x1b[0m \u{2014} or re-enable: \x1b[36maikey hook install\x1b[0m" + ); + return; + } + + // Empty provider set = unconfigure every aikey-managed third-party CLI + // config (same funnel `aikey unuse` drives after removing the last + // binding). Bindings themselves are untouched. + shell_integration::apply_third_party_cli_configs(&[], crate::commands_proxy::proxy_port()); + eprintln!(" \u{2713} aikey routing removed from third-party CLI configs (bindings kept; next `aikey use` re-applies them)."); +} + /// `aikey unuse ` — remove the active binding for one or more /// providers, clearing the corresponding env vars from active.env and toml /// configs (kimi.toml, codex.toml, etc.). diff --git a/src/commands_account/shell_integration.rs b/src/commands_account/shell_integration.rs index d8a2750..4723cba 100644 --- a/src/commands_account/shell_integration.rs +++ b/src/commands_account/shell_integration.rs @@ -1337,6 +1337,23 @@ pub fn shell_kind() -> ShellKind { /// which is a dead instruction for PowerShell / cmd users. Centralising /// here keeps the dispatch logic in one place; if a future shell needs /// special handling we update one fn. +/// "Apply the new active key in THIS shell right now" hint, per shell. +/// +/// Why (2026-07-12, Windows real-machine exploratory testing X4/X5): the +/// `aikey use` summary hardcoded `source ~/.aikey/active.env` — a dead +/// instruction on Windows twice over: PowerShell has no `source`/`~`, and +/// the PS hook reads active.env.flat, not active.env. Dot-sourcing the +/// AllHosts profile reloads the hook, which applies the flat env — that IS +/// the immediate-apply on PowerShell. cmd/Unknown get "open a new +/// terminal" (cmd has no reload mechanism at all). +pub fn apply_now_hint() -> &'static str { + match shell_kind() { + ShellKind::Zsh | ShellKind::Bash => "source ~/.aikey/active.env", + ShellKind::PowerShell => ". $PROFILE.CurrentUserAllHosts", + ShellKind::Cmd | ShellKind::Unknown => "open a new terminal", + } +} + pub fn reload_hint_for_shell() -> String { match shell_kind() { ShellKind::Zsh => "source ~/.zshrc".to_string(), @@ -1728,13 +1745,23 @@ fn hook_content_with_hash_header(kind: HookKind) -> String { // against the on-disk `# Hook-Template-Hash:` — if they diverge, // the user has regenerated the hook file AFTER this shell sourced // it, so the claude/codex wrapper defs in memory are stale. + // + // Why `export` (2026-07-10, use-effectiveness self-check): child + // processes — `aikey use`, `aikey hook status` — read this env var to + // answer "is the hook actually loaded in the invoking shell?". Without + // export the var was shell-local, `aikey hook status` always printed + // "loaded hash: ", and `aikey use` could claim "next prompt + // picks it up automatically" in a shell where precmd was never + // registered (terminal opened before hook install). PowerShell keeps + // the `$script:` var for the in-shell drift detector and adds `$env:` + // for child-process visibility (script scope never reaches children). let header = match kind { HookKind::Zsh | HookKind::Bash => format!( - "# Hook-Template-Hash: {hash}\n_AIKEY_HOOK_LOADED_HASH=\"{hash}\"\n", + "# Hook-Template-Hash: {hash}\nexport _AIKEY_HOOK_LOADED_HASH=\"{hash}\"\n", hash = hash, ), HookKind::PowerShell => format!( - "# Hook-Template-Hash: {hash}\n$script:_aikey_hook_loaded_hash = '{hash}'\n", + "# Hook-Template-Hash: {hash}\n$script:_aikey_hook_loaded_hash = '{hash}'\n$env:_AIKEY_HOOK_LOADED_HASH = '{hash}'\n", hash = hash, ), }; @@ -1793,6 +1820,20 @@ pub(super) fn write_hook_file(home: &str, kind: HookKind) -> io::Result io::Result, new_hash: &str) -> Option { + match old_hash { + Some(h) if h == new_hash => None, + Some(h) => Some(format!( + " \u{2713} Updated ~/.aikey/{filename} (hook template {h} \u{2192} {new_hash}). \ + Running shells auto-reload at the next prompt." + )), + None => Some(format!( + " \u{2713} Rendered ~/.aikey/{filename} (hook template {new_hash})." + )), + } +} + /// Filename for the hook file under `~/.aikey/`. Stage 3.1 windows-compat /// added the PowerShell branch. fn hook_filename_for_kind(kind: HookKind) -> &'static str { @@ -2260,6 +2321,53 @@ pub fn web_install_hook_file_layer1() -> (bool, Option) { } } +/// Read-only hook readiness probe (2026-07-10, GET /api/user/hook/status). +/// +/// Returns `(file_installed, rc_wired, failure_reason)` — the exact triple +/// the Web envelope carries — WITHOUT writing anything. This must NOT be +/// implemented on top of `web_install_hook_file_layer1`: that helper +/// renders `~/.aikey/hook.*` as a side effect, and a GET endpoint that +/// silently writes user files violates the no-covert-writes mandate. +/// +/// Guard order mirrors `web_install_hook_file_layer1` so both entry points +/// classify identically: +/// 1. AIKEY_NO_HOOK opt-out → report REAL file/rc state + `aikey_no_hook` +/// (the front-end maps the reason to the silent `disabled` banner kind; +/// real state keeps `aikey hook status` output truthful). +/// 2. no resolvable home → `home_unset`. +/// 3. unsupported shell → `shell_undetectable`. +/// 4. otherwise: file presence + `shell_rc_has_aikey_block()` (the single +/// rc-wired truth source shared with doctor / `aikey use` / wire-rc). +pub fn hook_status_probe() -> (bool, bool, Option) { + if std::env::var("HOME").is_err() + && std::env::var("USERPROFILE").is_err() + && dirs::home_dir().is_none() + { + return (false, false, Some(HookFailureReason::HomeUnset)); + } + let kind = match shell_kind() { + ShellKind::Zsh => HookKind::Zsh, + ShellKind::Bash => HookKind::Bash, + ShellKind::PowerShell => HookKind::PowerShell, + ShellKind::Cmd | ShellKind::Unknown => { + return (false, false, Some(HookFailureReason::ShellUndetectable)); + } + }; + let file_installed = resolve_aikey_dir() + .join(hook_filename_for_kind(kind)) + .exists(); + let rc_wired = shell_rc_has_aikey_block(); + let opted_out = std::env::var("AIKEY_NO_HOOK") + .map(|v| v == "1") + .unwrap_or(false); + let reason = if opted_out { + Some(HookFailureReason::AikeyNoHook) + } else { + None + }; + (file_installed, rc_wired, reason) +} + /// Result of a successful `write_v3_layers_with_consent` call. /// /// Carries enough info for callers to construct user-facing messages @@ -2769,42 +2877,50 @@ fn wire_powershell_rc_no_tty(home: &str) -> Result<(), HookFailureReason> { cleanup_legacy_hook_files(home); let v3_block = super::shell_integration_windows::v3_rc_block_powershell(); - let candidates = super::shell_integration_windows::powershell_profile_candidates(); + // 3a (2026-07-12): wire EVERY present PowerShell flavor, not the first + // candidate that happens to exist. pwsh 7 and PS 5.1 read different + // profile files; wiring one left the other flavor's sessions hookless + // while OR-logic detectors reported wired. Idempotent per file. + let targets = super::shell_integration_windows::powershell_wire_targets(); + if targets.is_empty() { + return Err(HookFailureReason::IoError); + } + for target in &targets { + wire_one_powershell_profile(target, &v3_block)?; + } + Ok(()) +} - // L2: look for existing marker in any candidate, idempotent rewrite. - for profile in &candidates { - if let Ok(contents) = std::fs::read_to_string(profile) { - if contents.contains(V3_BEGIN) { - if let Some(updated) = - replace_between_markers(&contents, V3_BEGIN, V3_END, &v3_block) - { - if updated != contents { - std::fs::write(profile, updated).map_err(|_| HookFailureReason::IoError)?; - } +/// Idempotently wire ONE PowerShell profile file: replace the v3 block in +/// place when markers exist, else append (creating the parent dir). Shared +/// by the Web wire-rc path and `ensure_powershell_hook`'s consent path. +/// Refuses UTF-16 / non-UTF-8 profiles — byte-appending UTF-8 onto UTF-16 +/// corrupts the whole file (H2 guard, see ensure_powershell_hook). +pub(super) fn wire_one_powershell_profile( + target: &std::path::Path, + v3_block: &str, +) -> Result<(), HookFailureReason> { + if let Ok(bytes) = std::fs::read(target) { + let utf16_bom = bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]); + if utf16_bom || (!bytes.is_empty() && std::str::from_utf8(&bytes).is_err()) { + return Err(HookFailureReason::IoError); + } + } + if let Ok(contents) = std::fs::read_to_string(target) { + if contents.contains(V3_BEGIN) { + if let Some(updated) = replace_between_markers(&contents, V3_BEGIN, V3_END, v3_block) { + if updated != contents { + std::fs::write(target, updated).map_err(|_| HookFailureReason::IoError)?; } - return Ok(()); } + return Ok(()); } } - - // L2 fresh install: pick the candidate whose parent dir exists so we - // write to the PowerShell version the user actually has installed - // (mirrors ensure_powershell_hook line 217+ logic — PS 5.1-only users - // would otherwise get a hook in pwsh 7+'s profile path their sessions - // never source). - let target = candidates - .iter() - .find(|p| p.parent().map(|d| d.exists()).unwrap_or(false)) - .or_else(|| candidates.first()) - .ok_or(HookFailureReason::IoError)?; - - // mkdir -p the parent in case it doesn't exist if let Some(parent) = target.parent() { if !parent.exists() { std::fs::create_dir_all(parent).map_err(|_| HookFailureReason::IoError)?; } } - let existing = std::fs::read_to_string(target).unwrap_or_default(); let new_contents = if existing.is_empty() { format!("{}\n", v3_block) @@ -4033,8 +4149,9 @@ tool_call_timeout_ms = 60000\n" "rendered zsh hook must contain greppable hash header" ); assert!( - rendered.contains(&format!("_AIKEY_HOOK_LOADED_HASH=\"{}\"", hash)), - "rendered zsh hook must assign _AIKEY_HOOK_LOADED_HASH at source time" + rendered.contains(&format!("export _AIKEY_HOOK_LOADED_HASH=\"{}\"", hash)), + "rendered zsh hook must EXPORT _AIKEY_HOOK_LOADED_HASH at source time \ + (child processes like `aikey use` read it to detect hook-loaded shells)" ); } @@ -4047,11 +4164,62 @@ tool_call_timeout_ms = 60000\n" "rendered bash hook must contain greppable hash header" ); assert!( - rendered.contains(&format!("_AIKEY_HOOK_LOADED_HASH=\"{}\"", hash)), - "rendered bash hook must assign _AIKEY_HOOK_LOADED_HASH at source time" + rendered.contains(&format!("export _AIKEY_HOOK_LOADED_HASH=\"{}\"", hash)), + "rendered bash hook must EXPORT _AIKEY_HOOK_LOADED_HASH at source time \ + (child processes like `aikey use` read it to detect hook-loaded shells)" ); } + #[test] + fn hook_ps1_template_is_pure_ascii() { + // F3-family guard (2026-07-12, exploratory finding X6): PowerShell + // 5.1 reads a BOM-less hook.ps1 via the system ANSI codepage + // (cp1252 on EN, cp936/GBK multibyte on zh-CN). Non-ASCII bytes + // can mutate into quote-swallowing sequences and break parsing for + // EVERY new session (see bugfix 20260526-hook-ps1-em-dash-mojibake). + // The 2026-05-26 fix only replaced em/en-dashes and left 981 other + // high bytes; this pins the template to pure ASCII so the entire + // encoding-fallback hazard class is structurally impossible. + // + // Deliberately ps1-ONLY: zsh/bash have no codepage-fallback + // mechanism, and their unicode lives in user-visible output + // strings — ASCII-izing those would degrade UX for zero hazard + // reduction. + for (i, line) in hook_ps1_content().lines().enumerate() { + assert!( + line.bytes().all(|b| b < 128), + "hook.ps1 line {} contains non-ASCII bytes (PS 5.1 codepage hazard): {}", + i + 1, + line + ); + } + } + + #[test] + fn hook_write_notice_silent_only_on_identical_hash() { + // Visible-write contract (2026-07-10): silence is reserved for the + // idempotent no-op; creation and content change must both produce a + // user-visible line. Regression fence for the old always-silent + // overwrite in `aikey use` / the web vault-op funnel. + assert_eq!(hook_write_notice("hook.bash", Some("abc"), "abc"), None); + let updated = hook_write_notice("hook.bash", Some("abc"), "def").expect("must notify"); + assert!(updated.contains("abc") && updated.contains("def")); + let created = hook_write_notice("hook.zsh", None, "def").expect("must notify"); + assert!(created.contains("hook.zsh") && created.contains("def")); + } + + #[test] + fn rendered_hook_ps1_sets_both_script_and_env_loaded_hash() { + // PowerShell needs BOTH scopes: `$script:` for the in-shell drift + // detector (aikey_hook_check_once) and `$env:` so child processes + // (`aikey use` effectiveness check, `aikey hook status`) can see + // that the hook is loaded — script scope never reaches children. + let rendered = hook_content_with_hash_header(HookKind::PowerShell); + let hash = hook_template_hash(HookKind::PowerShell); + assert!(rendered.contains(&format!("$script:_aikey_hook_loaded_hash = '{}'", hash))); + assert!(rendered.contains(&format!("$env:_AIKEY_HOOK_LOADED_HASH = '{}'", hash))); + } + #[test] fn rendered_hook_header_lines_appear_near_top() { // The drift detector greps the on-disk file; if the header sinks too @@ -4067,8 +4235,8 @@ tool_call_timeout_ms = 60000\n" ); assert!( head.iter() - .any(|l| l.starts_with("_AIKEY_HOOK_LOADED_HASH=")), - "_AIKEY_HOOK_LOADED_HASH should appear in the first 5 lines, got:\n{}", + .any(|l| l.starts_with("export _AIKEY_HOOK_LOADED_HASH=")), + "export _AIKEY_HOOK_LOADED_HASH should appear in the first 5 lines, got:\n{}", head.join("\n"), ); } @@ -4162,6 +4330,72 @@ tool_call_timeout_ms = 60000\n" ); } + #[test] + fn hook_bash_precmd_keeps_legacy_label_grace() { + // bash counterpart of the zsh grace-period pin above; was missing + // when the zsh test landed (2026-05-05 era) — added 2026-07-10. + let c = hook_bash_content(); + assert!( + c.contains("AIKEY_ACTIVE_LABEL"), + "legacy grace var must still be checked alongside _AIKEY_EXPLICIT_ALIAS" + ); + } + + // ── source-time bootstrap (zsh v5 2026-05-05, bash v6 2026-07-10) ────── + // + // precmd/PROMPT_COMMAND never fire in shells that don't render a prompt + // (`zsh -ilc 'exec claude'`, `bash -lc 'exec claude'`, CI runners), so + // both templates must source active.env once at hook-source-time, outside + // any function body, mirroring precmd's pin/kill-switch guards. The zsh + // block shipped in 2026-05-05 WITHOUT a pinning test and the bash port + // was silently missed for two months — these tests are the regression + // fence for that exact failure mode. + // Bugfix: workflow/CI/bugfix/2026-05-05-hook-active-env-not-loaded-without-precmd.md + // workflow/CI/bugfix/20260710-hook-bash-missing-bootstrap.md + + fn assert_bootstrap_block(content: &str, shell: &str) { + let marker = "Bootstrap source of active.env"; + let idx = content + .find(marker) + .unwrap_or_else(|| panic!("{shell} template must contain the '{marker}' block")); + // The block sits at top level between the marker comment and the + // wrapper-preflight section — inspect just that slice so a `source` + // inside precmd can't satisfy the assertion by accident. + let tail = &content[idx..]; + let block_end = tail + .find("Wrapper preflight") + .unwrap_or_else(|| panic!("{shell} bootstrap block must precede the wrapper section")); + let block = &tail[..block_end]; + for guard in [ + "AIKEY_AUTO_REFRESH", + "_AIKEY_EXPLICIT_ALIAS", + "AIKEY_ACTIVE_LABEL", + ] { + assert!( + block.contains(guard), + "{shell} bootstrap must mirror precmd guard {guard}" + ); + } + assert!( + block.contains("source ~/.aikey/active.env"), + "{shell} bootstrap must source active.env at hook-source-time" + ); + assert!( + !block.contains("AIKEY_ACTIVE_SEQ"), + "{shell} bootstrap must NOT seq-diff — it sources unconditionally once" + ); + } + + #[test] + fn hook_zsh_bootstrap_sources_active_env_at_source_time() { + assert_bootstrap_block(&hook_zsh_content(), "zsh"); + } + + #[test] + fn hook_bash_bootstrap_sources_active_env_at_source_time() { + assert_bootstrap_block(&hook_bash_content(), "bash"); + } + // ── H1.5 (hook coverage v1, 2026-04-27) regression ───────────────────── // // ensure_shell_hook used to TTY-gate only the prompt, not the rc append diff --git a/src/commands_account/shell_integration_windows.rs b/src/commands_account/shell_integration_windows.rs index 6be61c0..007c578 100644 --- a/src/commands_account/shell_integration_windows.rs +++ b/src/commands_account/shell_integration_windows.rs @@ -267,35 +267,42 @@ pub(super) fn ensure_powershell_hook() -> Option { let v3_block = v3_rc_block_powershell(); - // 2. Look for an existing marker in any candidate profile path. - // Idempotent rewrite when found. - let candidates = powershell_profile_candidates(); - for profile in &candidates { - let contents = match std::fs::read_to_string(profile) { - Ok(c) => c, - Err(_) => continue, - }; - if contents.contains(V3_BEGIN) { - if let Some(updated) = replace_between_markers(&contents, V3_BEGIN, V3_END, &v3_block) { - if updated != contents { - let _ = std::fs::write(profile, updated); + // 2. 3a (2026-07-12): wire EVERY present PowerShell flavor. pwsh 7 and + // PS 5.1 read different profile files; the old "first candidate with + // a marker / first parent dir that exists" logic wired exactly one, + // leaving the other flavor's sessions hookless while the OR-logic + // detectors reported wired. Already-wired targets get an idempotent + // in-place rewrite; missing ones need consent below. + let targets = powershell_wire_targets(); + let mut missing: Vec = Vec::new(); + for target in &targets { + match std::fs::read_to_string(target) { + Ok(c) if c.contains(V3_BEGIN) => { + if let Some(updated) = replace_between_markers(&c, V3_BEGIN, V3_END, &v3_block) { + if updated != c { + let _ = std::fs::write(target, updated); + } } } - return None; + _ => missing.push(target.clone()), } } + if missing.is_empty() { + return None; + } - // 3. No marker found — fresh install. Same H1.5 non-TTY hard - // constraint as bash/zsh: rc-file mutation requires interactive - // confirmation. Without it, piped/CI invocations would silently - // rewrite $PROFILE — exactly the contract surprise H1.5 prevents. + // 3. Unwired flavors remain — fresh install for those. Same H1.5 + // non-TTY hard constraint as bash/zsh: rc-file mutation requires + // interactive confirmation. Without it, piped/CI invocations would + // silently rewrite $PROFILE — exactly the contract surprise H1.5 + // prevents. if !io::stderr().is_terminal() || !io::stdin().is_terminal() { return Some(format!( " Shell hook file rendered, but {} (rc-file) wiring needs interactive confirmation.\n \ Run interactively: \x1b[36maikey hook install\x1b[0m\n \ Or silence this hint: \x1b[36mset AIKEY_NO_HOOK=1\x1b[0m (or `$env:AIKEY_NO_HOOK = '1'` in PowerShell)\n \ To apply right now without rc wiring: \x1b[36m. {}\x1b[0m", - candidates + missing .first() .map(|p| p.display().to_string()) .unwrap_or_else(|| "$PROFILE.CurrentUserAllHosts".to_string()), @@ -303,37 +310,14 @@ pub(super) fn ensure_powershell_hook() -> Option { )); } - // Fresh install: pick the candidate whose PARENT DIR already exists - // (signal that the user actually has that PowerShell version installed). - // Falling back to candidates.first() (pwsh 7+) when no parent exists - // means a fresh-install user gets the modern path — but a PS-5.1-only - // user (no `Documents\PowerShell\`, only `Documents\WindowsPowerShell\`) - // gets the 5.1 path, which is what their actual PS sessions read. - // - // Without this, PS 5.1-only users would silently get a hook installed - // to the pwsh 7+ profile path that their sessions never source — - // hours of "why doesn't aikey use work" debugging. - let target = candidates - .iter() - .find(|p| p.parent().map(|d| d.exists()).unwrap_or(false)) - .cloned() - .or_else(|| candidates.first().cloned()); - let target = match target { - Some(p) => p, - None => { - return Some( - " No PowerShell profile candidate path resolved. Set $PROFILE.CurrentUserAllHosts manually." - .to_string(), - ); - } - }; - let target_display = target.display().to_string(); - - let rows = vec![ - format!("Shell: PowerShell (CurrentUserAllHosts)"), - format!("File: {}", target_display), - format!("Add: . {} (v3)", display_aikey_path("hook.ps1")), - ]; + let mut rows = vec![format!("Shell: PowerShell (CurrentUserAllHosts)")]; + for m in &missing { + rows.push(format!("File: {}", m.display())); + } + rows.push(format!( + "Add: . {} (v3)", + display_aikey_path("hook.ps1") + )); crate::ui_frame::eprint_box("\u{2753}", "Install PowerShell Shell Hook", &rows); eprint!(" Proceed? [Y/n] (default Y): "); { @@ -350,50 +334,291 @@ pub(super) fn ensure_powershell_hook() -> Option { )); } - // H2 guard (encoding sweep 2026-07-07): NEVER byte-append UTF-8 onto a - // profile we can't read as UTF-8. PS 5.1 commonly produces UTF-16LE - // $PROFILE files (`'x' > $PROFILE` redirection, old Notepad "Unicode"); - // appending UTF-8 bytes onto UTF-16 corrupts the file — PowerShell - // decodes the WHOLE file as UTF-16, the appended block turns into CJK - // garbage, and every new session can throw a parse error. Worse, the - // marker check above can't see markers in such files (read_to_string - // errors → candidate skipped), so repeated runs would keep appending - // and compound the damage. Until a decode-splice-rewrite lands - // (installer's Get-Content/Set-Content path already does this - // correctly), fail safe with actionable guidance. - if let Ok(bytes) = std::fs::read(&target) { - let utf16_bom = bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]); - let undecodable = !bytes.is_empty() && std::str::from_utf8(&bytes).is_err(); - if utf16_bom || undecodable { - return Some(format!( - " {} exists but is not UTF-8 (likely UTF-16 from PowerShell 5.1).\n \ - Appending would corrupt it — hook wiring skipped.\n \ - Convert it once in PowerShell: \x1b[36m(Get-Content $PROFILE -Raw) | Set-Content $PROFILE -Encoding utf8\x1b[0m\n \ - then re-run: \x1b[36maikey hook install\x1b[0m", - target_display, - )); + // Consent granted → wire each missing flavor. The shared helper + // carries the H2 UTF-16 guard (encoding sweep 2026-07-07): NEVER + // byte-append UTF-8 onto a profile that isn't UTF-8 — PS 5.1 commonly + // produces UTF-16LE $PROFILE files, and appending corrupts the whole + // file into per-session parse errors. Failures are reported per file + // with the conversion recipe; other flavors still get wired. + let mut failed: Vec = Vec::new(); + for target in &missing { + if super::shell_integration::wire_one_powershell_profile(target, &v3_block).is_err() { + failed.push(target.display().to_string()); } } - - // Create parent dir (pwsh 7+ profile dir is often missing on a - // freshly-installed pwsh) and append the v3 block. Use OpenOptions - // append — never overwrite user content already present. - if let Some(parent) = target.parent() { - let _ = std::fs::create_dir_all(parent); - } - let block = format!("\n{}", v3_block); - let write_result = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&target) - .and_then(|mut f| std::io::Write::write_all(&mut f, block.as_bytes())); - if write_result.is_err() { + if !failed.is_empty() { return Some(format!( - " Could not write to {}. Source {} manually.", - target_display, + " Could not wire: {}\n \ + If the file is UTF-16 (common from PS 5.1 redirection), convert once:\n \ + \x1b[36m(Get-Content -Raw) | Set-Content -Encoding utf8\x1b[0m then re-run \x1b[36maikey hook install\x1b[0m.\n \ + Or source manually: \x1b[36m. {}\x1b[0m", + failed.join(", "), display_aikey_path("hook.ps1"), )); } + let target_display = missing + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + + // ExecutionPolicy wired-but-dead guard (2026-07-12, Windows real-machine + // exploratory testing X2): on default client Windows every policy scope + // is Undefined → effective Restricted → profile.ps1 REFUSES to load, so + // the block we just wired never runs and every detector still reports + // "wired". Surface it at the moment of wiring, with the exact fix. + let mut done = format!(" Shell hook installed in {}", target_display); + if let Some(warn) = powershell_profile_load_blocked() { + done.push_str(&format!("\n{}", warn)); + } + Some(done) +} + +/// pwsh-7 flavor-presence predicate (3a, 2026-07-12): pwsh 7+ keeps its +/// profile in `Documents\PowerShell\` — a DIFFERENT directory from +/// PS 5.1's `Documents\WindowsPowerShell\`. Wiring only one of them leaves +/// the other flavor's sessions hookless while every wired-detector (OR +/// over candidates) reports green. Pure so the decision table is testable +/// cross-platform. +pub(super) fn pwsh7_is_wire_target(profile_dir_exists: bool, pwsh_on_path: bool) -> bool { + profile_dir_exists || pwsh_on_path +} + +/// Is `pwsh` resolvable on PATH? Plain PATH scan (no spawn — this runs on +/// every `aikey use` via ensure_powershell_hook, spawning would add ~100ms). +pub(super) fn pwsh_on_path() -> bool { + let exe = if cfg!(windows) { "pwsh.exe" } else { "pwsh" }; + std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).any(|d| d.join(exe).is_file())) + .unwrap_or(false) +} + +/// The profile files each PRESENT PowerShell flavor will actually load — +/// the write-side counterpart of `powershell_profile_candidates()` (which +/// stays permissive OR-logic for read-side detection). +/// +/// Windows: PS 5.1 ships with the OS → its profile is always a target; +/// pwsh 7+ only when present (dir or PATH). Unix pwsh uses the single +/// XDG path. Order matters for messages: modern flavor first. +pub(super) fn powershell_wire_targets() -> Vec { + let mut out: Vec = Vec::new(); + #[cfg(windows)] + { + let docs = documents_known_folder() + .unwrap_or_else(|| resolve_user_home().join("Documents")); + let pwsh_dir = docs.join("PowerShell"); + if pwsh7_is_wire_target(pwsh_dir.exists(), pwsh_on_path()) { + out.push(pwsh_dir.join("profile.ps1")); + } + out.push(docs.join("WindowsPowerShell").join("profile.ps1")); + } + #[cfg(not(windows))] + { + let _ = pwsh_on_path; // referenced so the fn isn't dead on unix builds + out.push( + resolve_user_home() + .join(".config") + .join("powershell") + .join("profile.ps1"), + ); + } + out +} + +/// doctor probe (3a): pwsh 7+ is present but its profile lacks the v3 +/// block → pwsh sessions silently run hookless. `None` when fine or on +/// non-Windows. +pub fn pwsh_profile_wiring_gap() -> Option { + #[cfg(windows)] + { + let docs = documents_known_folder() + .unwrap_or_else(|| resolve_user_home().join("Documents")); + let pwsh_dir = docs.join("PowerShell"); + if !pwsh7_is_wire_target(pwsh_dir.exists(), pwsh_on_path()) { + return None; + } + let profile = pwsh_dir.join("profile.ps1"); + let wired = std::fs::read_to_string(&profile) + .map(|c| c.contains(V3_BEGIN)) + .unwrap_or(false); + if wired { + None + } else { + Some(profile) + } + } + #[cfg(not(windows))] + { + None + } +} - Some(format!(" Shell hook installed in {}", target_display)) +#[cfg(test)] +mod pwsh_dual_profile_tests { + use super::pwsh7_is_wire_target; + + #[test] + fn pwsh7_targeted_only_when_present() { + // dir exists (pwsh ran at least once) → target + assert!(pwsh7_is_wire_target(true, false)); + // freshly installed pwsh, never launched (no profile dir yet) → PATH wins + assert!(pwsh7_is_wire_target(false, true)); + // no pwsh anywhere → do NOT create Documents\PowerShell for a + // flavor the user doesn't have + assert!(!pwsh7_is_wire_target(false, false)); + } +} + +/// Effective ExecutionPolicy a FRESH PowerShell session would see, computed +/// from `Get-ExecutionPolicy -List` scope pairs. Precedence per PowerShell +/// docs: MachinePolicy > UserPolicy > Process > CurrentUser > LocalMachine — +/// but a fresh session has no Process-scope value, so Process is skipped +/// (the CALLING context often runs under `-ExecutionPolicy Bypass`, which +/// must not mask the user's real steady-state). All-Undefined defaults to +/// Restricted on client SKUs — the case that kills profile loading. +pub(super) fn effective_policy_for_new_session(scopes: &[(String, String)]) -> String { + for wanted in ["MachinePolicy", "UserPolicy", "CurrentUser", "LocalMachine"] { + if let Some((_, v)) = scopes + .iter() + .find(|(s, v)| s == wanted && !v.eq_ignore_ascii_case("Undefined")) + { + return v.clone(); + } + } + "Restricted".to_string() +} + +/// Whether `effective` blocks loading an UNSIGNED profile.ps1 (ours is). +pub(super) fn policy_blocks_profile(effective: &str) -> bool { + effective.eq_ignore_ascii_case("Restricted") || effective.eq_ignore_ascii_case("AllSigned") +} + +/// Windows-only probe: does the current ExecutionPolicy prevent the wired +/// profile (and therefore the aikey hook) from ever loading? Returns a +/// user-facing warning with the scoped fix when blocked, `None` when fine +/// (or on any probe failure — a diagnostics helper must not create noise). +/// GPO-managed scopes (MachinePolicy/UserPolicy) get a "contact IT" variant +/// because `Set-ExecutionPolicy -Scope CurrentUser` cannot override them. +#[cfg(windows)] +pub fn powershell_profile_load_blocked() -> Option { + let out = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-ExecutionPolicy -List | ForEach-Object { $_.Scope.ToString() + '=' + $_.ExecutionPolicy.ToString() }", + ]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let scopes: Vec<(String, String)> = text + .lines() + .filter_map(|l| { + let (s, v) = l.trim().split_once('=')?; + Some((s.to_string(), v.to_string())) + }) + .collect(); + if scopes.is_empty() { + return None; + } + let effective = effective_policy_for_new_session(&scopes); + if !policy_blocks_profile(&effective) { + return None; + } + let gpo_managed = scopes.iter().any(|(s, v)| { + (s == "MachinePolicy" || s == "UserPolicy") && !v.eq_ignore_ascii_case("Undefined") + }); + Some(if gpo_managed { + format!( + " \x1b[33m\u{25b2} ExecutionPolicy '{}' is enforced by Group Policy \u{2014} PowerShell will NOT load the wired profile, so the aikey hook never runs.\n Ask your IT admin to allow RemoteSigned for your user.\x1b[0m", + effective + ) + } else { + format!( + " \x1b[33m\u{25b2} ExecutionPolicy '{}' blocks profile loading \u{2014} the wired aikey hook will NEVER run in new sessions.\n Fix once: \x1b[36mSet-ExecutionPolicy -Scope CurrentUser RemoteSigned\x1b[0m", + effective + ) + }) +} + +/// Non-Windows stub — the policy concept doesn't exist for zsh/bash, and +/// pwsh-on-Unix defaults to Unrestricted. +#[cfg(not(windows))] +pub fn powershell_profile_load_blocked() -> Option { + None +} + +#[cfg(test)] +mod execution_policy_tests { + use super::{effective_policy_for_new_session, policy_blocks_profile}; + + fn pairs(v: &[(&str, &str)]) -> Vec<(String, String)> { + v.iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect() + } + + #[test] + fn all_undefined_defaults_to_restricted_the_dead_hook_case() { + // Factory client Windows: every scope Undefined → Restricted → + // profile never loads. This is exploratory finding X2. + let p = pairs(&[ + ("MachinePolicy", "Undefined"), + ("UserPolicy", "Undefined"), + ("Process", "Undefined"), + ("CurrentUser", "Undefined"), + ("LocalMachine", "Undefined"), + ]); + let eff = effective_policy_for_new_session(&p); + assert_eq!(eff, "Restricted"); + assert!(policy_blocks_profile(&eff)); + } + + #[test] + fn process_scope_bypass_must_not_mask_steady_state() { + // The probe itself often runs under `-ExecutionPolicy Bypass` + // (Process scope). A fresh session won't have that — skip it. + let p = pairs(&[ + ("MachinePolicy", "Undefined"), + ("UserPolicy", "Undefined"), + ("Process", "Bypass"), + ("CurrentUser", "Undefined"), + ("LocalMachine", "Undefined"), + ]); + assert_eq!(effective_policy_for_new_session(&p), "Restricted"); + } + + #[test] + fn current_user_remotesigned_unblocks() { + let p = pairs(&[ + ("MachinePolicy", "Undefined"), + ("UserPolicy", "Undefined"), + ("Process", "Bypass"), + ("CurrentUser", "RemoteSigned"), + ("LocalMachine", "Undefined"), + ]); + let eff = effective_policy_for_new_session(&p); + assert_eq!(eff, "RemoteSigned"); + assert!(!policy_blocks_profile(&eff)); + } + + #[test] + fn gpo_machine_policy_wins_over_current_user() { + let p = pairs(&[ + ("MachinePolicy", "Restricted"), + ("UserPolicy", "Undefined"), + ("CurrentUser", "RemoteSigned"), + ("LocalMachine", "Undefined"), + ]); + let eff = effective_policy_for_new_session(&p); + assert_eq!(eff, "Restricted"); + assert!(policy_blocks_profile(&eff)); + } + + #[test] + fn allsigned_blocks_our_unsigned_profile() { + assert!(policy_blocks_profile("AllSigned")); + assert!(!policy_blocks_profile("Unrestricted")); + assert!(!policy_blocks_profile("Bypass")); + } } diff --git a/src/commands_internal/hook_op.rs b/src/commands_internal/hook_op.rs index 847775e..08612af 100644 --- a/src/commands_internal/hook_op.rs +++ b/src/commands_internal/hook_op.rs @@ -21,12 +21,15 @@ use serde::Deserialize; use super::internal_log; use super::protocol::ResultEnvelope; -use crate::commands_account::{shell_rc_has_aikey_block, wire_rc_with_consent, HookFailureReason}; +use crate::commands_account::{ + hook_status_probe, shell_rc_has_aikey_block, wire_rc_with_consent, HookFailureReason, +}; #[derive(Debug, Deserialize)] struct HookOpEnvelope { - /// "wire-rc" — currently the only action. Future: "uninstall-rc", - /// "status", etc. Keep the field so the same handler can grow. + /// "wire-rc" (write, modal Allow) or "status" (read-only probe for + /// GET /api/user/hook/status). Future: "uninstall-rc". Keep the field + /// so the same handler can grow. action: String, #[serde(default)] request_id: Option, @@ -76,6 +79,7 @@ pub fn handle() { match env.action.as_str() { "wire-rc" => handle_wire_rc(req_id, started), + "status" => handle_status(req_id, started), other => emit_error( req_id, "I_UNKNOWN_ACTION", @@ -85,6 +89,30 @@ pub fn handle() { } } +/// `status` action: read-only readiness probe. Same three-field envelope +/// shape as wire-rc so the SPA's `pickHookReadiness` consumes both without +/// branching. MUST stay read-only — it backs `GET /api/user/hook/status`, +/// which the banner polls on page load; a GET that writes user files would +/// be a covert write (see `hook_status_probe` docs). +fn handle_status(req_id: Option, started: std::time::Instant) { + let (file_installed, rc_wired, reason) = hook_status_probe(); + let result_data = serde_json::json!({ + "hook_file_installed": file_installed, + "hook_rc_wired": rc_wired, + "hook_failure_reason": reason + .map(|r| serde_json::Value::from(r.as_envelope_str())) + .unwrap_or(serde_json::Value::Null), + }); + let result = ResultEnvelope::ok(req_id.clone(), result_data); + internal_log::log_dispatch_success( + ACTION, + req_id.as_deref(), + result.data.as_ref().unwrap_or(&serde_json::Value::Null), + started.elapsed().as_millis(), + ); + emit(&result); +} + /// `wire-rc` action: render hook file (Layer 1) + write rc marker block /// (Layer 2). Always returns the same envelope shape as the vault-op /// hook-status fields so the front-end's `setReadiness` handler is diff --git a/src/commands_project.rs b/src/commands_project.rs index d0bca52..da5ab81 100644 --- a/src/commands_project.rs +++ b/src/commands_project.rs @@ -1089,34 +1089,23 @@ pub fn handle_doctor(json_mode: bool) -> Result<(), Box> // ── 7. Shell hook installed ─────────────────────────────── { - let home = std::env::var("HOME").unwrap_or_default(); - let shell = std::env::var("SHELL").unwrap_or_default(); - let hook_marker = "# aikey shell hook"; - - let rc_file = if shell.contains("zsh") { - Some(format!("{}/.zshrc", home)) - } else if shell.contains("bash") { - // Check .bashrc first, then .bash_profile. - let bashrc = format!("{}/.bashrc", home); - let profile = format!("{}/.bash_profile", home); - if std::path::Path::new(&bashrc).exists() { - Some(bashrc) - } else { - Some(profile) - } - } else { - None - }; - - let installed = rc_file.as_ref().map_or(false, |rc| { - std::fs::read_to_string(rc) - .map(|c| c.contains(hook_marker)) - .unwrap_or(false) - }); + // Wired-state predicate unified to `shell_rc_has_aikey_block()` + // (2026-07-10): the previous inline `# aikey shell hook` grep was + // the LEGACY V1 marker — a truth-source split from the v3 block + // that `aikey use` / the web modal actually write, so this check + // reported "installed" for stale v1 rcs and "not installed" for + // healthy v3 ones. The shared predicate is the same one the web + // envelope's `hook_rc_wired` field uses (vault_op.rs), so doctor, + // `aikey use`, and the web banner can no longer disagree. + let shell_supported = !matches!( + crate::commands_account::shell_kind(), + crate::commands_account::ShellKind::Cmd | crate::commands_account::ShellKind::Unknown + ); + let rc_wired = crate::commands_account::shell_rc_has_aikey_block(); - if installed { + if rc_wired { emit("shell hook", true, "installed", None); - } else if rc_file.is_some() { + } else if shell_supported { emit( "shell hook", false, @@ -1135,6 +1124,70 @@ pub fn handle_doctor(json_mode: bool) -> Result<(), Box> Some("add 'source ~/.aikey/active.env' to your shell config manually"), ); } + + // ── 7.1. Hook wiring vs active bindings ────────────── + // + // Why a separate item: "rc not wired" alone is a setup nit, but + // "bindings are ACTIVE and the rc is not wired" means the user + // believes keys are routing while `claude`/`codex` run bare — + // the exact silent failure of the web-only onboarding path + // (install → web use → never wire rc). Health signals must be + // externally readable, so this states the consequence explicitly + // instead of leaving it implied by two separate green/red items. + // Skipped for unsupported shells: the item above already reports + // that there is no rc to wire. + if shell_supported { + let has_active_bindings = !crate::storage::list_provider_bindings_readonly( + crate::profile_activation::DEFAULT_PROFILE, + ) + .unwrap_or_default() + .is_empty(); + // Re-check: the auto-install above may have just wired the rc. + let rc_wired_now = rc_wired || crate::commands_account::shell_rc_has_aikey_block(); + let (ok, detail, hint) = hook_wiring_check(has_active_bindings, rc_wired_now); + emit("hook wiring", ok, detail, hint); + } + + // ── 7.2. PowerShell ExecutionPolicy (wired-but-dead guard) ──── + // + // 2026-07-12 exploratory finding X2: default client Windows keeps + // every ExecutionPolicy scope Undefined → effective Restricted → + // profile.ps1 refuses to load, so a WIRED hook never runs while + // every other check above stays green. The one detector that can + // see it is this policy probe. No-op on non-PowerShell shells and + // on Unix (stub returns None). + if matches!( + crate::commands_account::shell_kind(), + crate::commands_account::ShellKind::PowerShell + ) { + if crate::commands_account::powershell_profile_load_blocked().is_some() { + emit( + "execution policy", + false, + "ExecutionPolicy blocks profile loading — the wired hook NEVER runs in new sessions", + Some("Set-ExecutionPolicy -Scope CurrentUser RemoteSigned (GPO-managed: ask IT)"), + ); + } else { + emit("execution policy", true, "profile loading allowed", None); + } + + // ── 7.3. pwsh 7+ profile wiring gap ─────────────────── + // + // 3a (2026-07-12): pwsh 7 reads Documents\PowerShell\profile.ps1 + // — a different file from PS 5.1's. Machines wired before the + // dual-flavor write (or where pwsh was installed later) run + // pwsh sessions hookless while the OR-logic wired check above + // stays green. Silent when pwsh isn't present. + if let Some(gap) = crate::commands_account::pwsh_profile_wiring_gap() { + emit( + "pwsh profile", + false, + "pwsh 7+ detected but its profile is not wired — pwsh sessions won't load the hook", + Some("run `aikey hook install` (wires every present PowerShell flavor)"), + ); + let _ = gap; + } + } } // ── 7.5. Optional first-party plugins ──────────────────── @@ -1980,6 +2033,49 @@ fn format_time_short(ts: &str) -> String { // JSON mode short-circuits in main.rs — --detail extras are tty-only. // =========================================================================== +/// Pure classifier for doctor's "hook wiring" item (2026-07-10). Extracted +/// so the four-quadrant matrix (bindings × rc-wired) is unit-testable +/// without touching the vault or the filesystem. +/// +/// Why this item exists: "rc not wired" alone is a setup nit, but "bindings +/// ACTIVE and rc not wired" means the user believes keys are routing while +/// `claude`/`codex` run bare — the silent failure of the web-only +/// onboarding path (install → web use → never wire rc). +fn hook_wiring_check( + has_active_bindings: bool, + rc_wired: bool, +) -> (bool, &'static str, Option<&'static str>) { + match (has_active_bindings, rc_wired) { + (true, false) => ( + false, + "active bindings but shell rc not wired — claude/codex will NOT route through aikey", + Some("run `aikey hook install`, then open a new terminal"), + ), + (true, true) => (true, "active bindings routed via shell hook", None), + (false, _) => (true, "no active bindings to route", None), + } +} + +#[cfg(test)] +mod hook_wiring_check_tests { + use super::hook_wiring_check; + + #[test] + fn red_only_when_bindings_active_and_rc_unwired() { + let (ok, detail, hint) = hook_wiring_check(true, false); + assert!(!ok); + assert!(detail.contains("NOT route through aikey")); + assert_eq!(hint, Some("run `aikey hook install`, then open a new terminal")); + } + + #[test] + fn green_in_the_other_three_quadrants() { + assert!(hook_wiring_check(true, true).0); + assert!(hook_wiring_check(false, true).0); + assert!(hook_wiring_check(false, false).0); + } +} + pub fn handle_doctor_detail() -> Result<(), Box> { use colored::Colorize; let dim_rule = "─".repeat(52).dimmed(); diff --git a/src/main.rs b/src/main.rs index 30f92f0..28585a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2227,7 +2227,9 @@ fn run_command(cli: &Cli) -> Result<(), Box> { }); eprintln!("{}", serde_json::to_string_pretty(&payload).unwrap()); } - std::process::exit(exit_code_from_outcome(&outcome)); + let code = exit_code_from_outcome(&outcome); + print_test_rc_unwired_warning(cli.json, code == EXIT_OK); + std::process::exit(code); } else if let Some(ref alias) = alias { // ── Single-alias mode: resolve across personal/team/OAuth ── let targets = commands_project::targets_from_alias( @@ -2305,7 +2307,9 @@ fn run_command(cli: &Cli) -> Result<(), Box> { }); eprintln!("{}", serde_json::to_string_pretty(&payload).unwrap()); } - std::process::exit(exit_code_from_outcome(&outcome)); + let code = exit_code_from_outcome(&outcome); + print_test_rc_unwired_warning(cli.json, code == EXIT_OK); + std::process::exit(code); } else { // ── No alias: test all active bindings (personal/team/OAuth) ── let (targets, build_errors) = @@ -2372,7 +2376,9 @@ fn run_command(cli: &Cli) -> Result<(), Box> { }); eprintln!("{}", serde_json::to_string_pretty(&payload).unwrap()); } - std::process::exit(exit_code_from_outcome(&outcome)); + let code = exit_code_from_outcome(&outcome); + print_test_rc_unwired_warning(cli.json, code == EXIT_OK); + std::process::exit(code); } } Commands::Export { pattern, output } => { @@ -6624,6 +6630,12 @@ fn handle_hook_command(action: &HookAction) -> Result<(), Box Result<(), Box") ); + println!( + "rc wired: {}", + if rc_wired { + "yes".to_string() + } else { + "no (run: aikey hook install)".to_string() + } + ); println!("state: {}", state); Ok(()) } @@ -6792,6 +6812,9 @@ fn handle_hook_uninstall() -> Result<(), Box> { eprintln!( "\x1b[90m No aikey shell hook block found in any rc file — nothing to remove.\x1b[0m" ); + // Even when nothing was wired, stale third-party CLI configs are + // exactly as broken (no env channel) — reconcile them too. + commands_account::reconcile_cli_configs_after_hook_uninstall(); return Ok(()); } for t in &touched { @@ -6808,6 +6831,9 @@ fn handle_hook_uninstall() -> Result<(), Box> { "\x1b[90m New shells won't load the hook. Already-open shells keep their current env\x1b[0m" ); eprintln!("\x1b[90m until you open a new terminal.\x1b[0m"); + // X8 (2026-07-12): codex/kimi configs that route through aikey only work + // WITH the hook env — offer to strip them (TTY) or warn loudly (non-TTY). + commands_account::reconcile_cli_configs_after_hook_uninstall(); Ok(()) } @@ -6853,6 +6879,88 @@ fn compute_hook_status_state( } } +/// Pure classifier for the `aikey test` tail warning (2026-07-10). +/// +/// Why: `aikey test` probes through the proxy directly, while interactive +/// `claude`/`codex` need the shell hook to inject env — so "test passes" +/// is routinely misread as "claude will route through aikey" even when the +/// rc was never wired (web-only onboarding) . Historical incident: bugfix +/// 2026-04-29-active-env-flat-rename-access-denied-on-windows.md — `aikey +/// test` green while claude.exe launched with no ANTHROPIC_* env. +/// +/// Contract: NEVER affects the exit code — wrapper scripts branch on the +/// EXIT_* codes documented in the Test handler. +fn test_rc_unwired_warning( + connectivity_ok: bool, + has_active_bindings: bool, + rc_wired: bool, + hook_opted_out: bool, +) -> Option<&'static str> { + if connectivity_ok && has_active_bindings && !rc_wired && !hook_opted_out { + Some( + "\u{25b2} Connectivity OK, but the shell rc is not wired \u{2014} interactive \ + `claude`/`codex` will NOT inherit aikey env.\n Fix: run `aikey hook install`, \ + then open a new terminal.", + ) + } else { + None + } +} + +/// Effectful wrapper around `test_rc_unwired_warning` — gathers shell/rc/ +/// binding state and prints the warning to stderr. Called by all three +/// `aikey test` branches right before their `process::exit`. +fn print_test_rc_unwired_warning(json_mode: bool, connectivity_ok: bool) { + if json_mode { + return; + } + let shell_supported = !matches!( + commands_account::shell_kind(), + commands_account::ShellKind::Cmd | commands_account::ShellKind::Unknown + ); + if !shell_supported { + return; + } + let opted_out = std::env::var("AIKEY_NO_HOOK") + .map(|v| v == "1") + .unwrap_or(false); + let has_bindings = + !storage::list_provider_bindings_readonly(profile_activation::DEFAULT_PROFILE) + .unwrap_or_default() + .is_empty(); + let rc_wired = commands_account::shell_rc_has_aikey_block(); + if let Some(w) = test_rc_unwired_warning(connectivity_ok, has_bindings, rc_wired, opted_out) { + eprintln!("\n \x1b[33m{}\x1b[0m", w); + } +} + +#[cfg(test)] +mod test_rc_unwired_warning_tests { + use super::test_rc_unwired_warning; + + #[test] + fn warns_only_when_ok_bindings_active_and_rc_unwired() { + assert!(test_rc_unwired_warning(true, true, false, false).is_some()); + } + + #[test] + fn silent_when_rc_wired_or_no_bindings_or_failed_or_opted_out() { + assert!(test_rc_unwired_warning(true, true, true, false).is_none()); + assert!(test_rc_unwired_warning(true, false, false, false).is_none()); + // Connectivity already failed — the failure output is the signal; + // don't stack a hook warning on top of it. + assert!(test_rc_unwired_warning(false, true, false, false).is_none()); + assert!(test_rc_unwired_warning(true, true, false, true).is_none()); + } + + #[test] + fn warning_points_at_hook_install() { + let w = test_rc_unwired_warning(true, true, false, false).unwrap(); + assert!(w.contains("aikey hook install")); + assert!(w.contains("NOT inherit aikey env")); + } +} + #[cfg(test)] mod hook_command_tests { use super::compute_hook_status_state; diff --git a/src/templates/hook.bash b/src/templates/hook.bash index ae3336e..955af0d 100644 --- a/src/templates/hook.bash +++ b/src/templates/hook.bash @@ -1,5 +1,5 @@ # ~/.aikey/hook.bash — auto-generated by `aikey use`, do not hand-edit -# Version: 5 +# Version: 6 # Self-sufficiency (v5, 2026-06-11): ensure the aikey binary dir is on PATH so # this file works STANDALONE (see hook.zsh v6 note — `command aikey` searches # PATH, fails on a fresh machine where PATH was never wired). Idempotent prepend. @@ -108,6 +108,31 @@ if [ -z "$(trap -p DEBUG 2>/dev/null)" ]; then trap 'aikey_preexec_bash' DEBUG fi +# ── Bootstrap source of active.env (v6, 2026-07-10) ─────────────────────── +# +# Why this exists: PROMPT_COMMAND handles incremental sync (cheap seq-diff on +# every prompt), which is correct for the steady-state interactive REPL — but +# it never fires in shells that don't render a prompt: +# - `bash -lc 'exec claude'` (wrapper / scripted invocation) +# - `bash -ilc 'source X; exec Y'` (CI / test runner) +# - subshells exec'd from non-bash parents +# +# Without this bootstrap, those shells see empty AIKEY_ACTIVE_KEYS / +# OPENAI_API_KEY / ANTHROPIC_API_KEY etc., and aikey_preflight short-circuits +# to "no active binding" while the wrapper command runs against missing env. +# +# We mirror precmd's pin/kill-switch logic but skip the seq-diff (always +# source once at hook-source-time; PROMPT_COMMAND does ongoing updates). +# hook.zsh gained this block in v5 (bugfix 2026-05-05); bash was missed then — +# ported 2026-07-10 (systemic-fix propagation). +# Bugfix: workflow/CI/bugfix/2026-05-05-hook-active-env-not-loaded-without-precmd.md +# workflow/CI/bugfix/20260710-hook-bash-missing-bootstrap.md +if [ "$AIKEY_AUTO_REFRESH" != "off" ] \ + && [ -z "$_AIKEY_EXPLICIT_ALIAS" ] && [ -z "$AIKEY_ACTIVE_LABEL" ] \ + && [ -f ~/.aikey/active.env ]; then + source ~/.aikey/active.env +fi + # ── Wrapper preflight (claude / codex) ──────────────────────────────────── # # See hook.zsh for the full rationale. Same contract here: diff --git a/src/templates/hook.ps1 b/src/templates/hook.ps1 index e0864e0..ad69571 100644 --- a/src/templates/hook.ps1 +++ b/src/templates/hook.ps1 @@ -123,9 +123,9 @@ function global:aikey_precmd_powershell { # The next `aikey use` then can't atomically replace active.env.flat # because Windows MoveFileEx returns ERROR_ACCESS_DENIED (os err 5) # when the target is held without FILE_SHARE_DELETE (PS opens with - # only FILE_SHARE_READ | FILE_SHARE_WRITE). Cascade: stale .flat → - # SEQ matches in-shell → no reload → claude.exe sees no $env:ANTHROPIC_* - # → user gets login prompt despite a valid binding. + # only FILE_SHARE_READ | FILE_SHARE_WRITE). Cascade: stale .flat -> + # SEQ matches in-shell -> no reload -> claude.exe sees no $env:ANTHROPIC_* + # -> user gets login prompt despite a valid binding. # # ReadAllLines reads the whole file into a string[] and closes the # StreamReader synchronously -- safe for the small .flat file. @@ -142,8 +142,8 @@ function global:aikey_precmd_powershell { } catch { aikey_hook_check_once; return } # Decide whether to re-import: - # - on_disk_seq present + differs from in-shell → reload (cheap path) - # - on_disk_seq missing → unconditional reload (mirrors hook.bash's + # - on_disk_seq present + differs from in-shell -> reload (cheap path) + # - on_disk_seq missing -> unconditional reload (mirrors hook.bash's # "Fallback for active.env produced by pre-Stage-1 CLI / by paths # that don't write SEQ"; see commands_account/shell_integration.rs # `write_active_env` which builds a kv_pairs list without SEQ). @@ -184,7 +184,7 @@ function global:aikey_hook_check_once { if (-not $home_dir) { return } $hook_path = Join-Path (Join-Path $home_dir '.aikey') 'hook.ps1' - # ── Layer 1 ─────────────────────────────────────────────────────────── + # -- Layer 1 ----------------------------------------------------------- if ($script:_aikey_hook_loaded_hash) { $disk_hash = $null try { @@ -212,7 +212,7 @@ function global:aikey_hook_check_once { } } - # ── Layer 2 ─────────────────────────────────────────────────────────── + # -- Layer 2 ----------------------------------------------------------- if ($script:_aikey_hook_layer2_checked) { return } $script:_aikey_hook_layer2_checked = $true $bin = aikey_resolve_bin @@ -266,13 +266,13 @@ function global:prompt { } } -# ── Wrapper preflight (claude / codex) ──────────────────────────────────── +# -- Wrapper preflight (claude / codex) ------------------------------------ # # Mirror of hook.bash's `aikey_preflight` + `aikey_clear_before_tui_handoff` # + claude/codex/kimi function wrappers. Same contract: # # - $env:AIKEY_PREFLIGHT = 'off' disables the connectivity probe entirely. -# - If no active binding ($env:AIKEY_ACTIVE_KEYS empty) → emit advisory +# - If no active binding ($env:AIKEY_ACTIVE_KEYS empty) -> emit advisory # line and pass through to the real binary (the user explicitly opted # out by not running `aikey use ` first). # - Otherwise: `aikey proxy ensure-running` (auto-start proxy if down),