Scope OS-keyring access to the default install path (fixes real-credential wipe by tests)

Root cause of today's repeated logouts: the keyring entry (service kigi /
oauth/kimi-code) is global per OS user, but the enable gate keyed off ENV
VARS while an AuthManager's identity is its constructor path. Integration
test binaries (compiled without cfg(test), no KIGI_SHARE_DIR in env)
constructed managers on tempdirs whose remove_scope() then deleted the
developer's REAL keychain credential — the unified log shows 13 such
wipes in one day, one per test run.

Structural fix: keyring participation is now a property of the manager's
own path. AuthManager captures keyring_path_scoped at construction
(path == default ~/.kigi/auth.json) and every keyring touch — the
constructor read, update()'s write, remove_scope()'s delete — requires it,
with the dynamic keyring_enabled() gate (env kill-switch, cfg(test) mock
toggle) layered on top. A tempdir-rooted manager can no longer read,
write, or delete the global entry no matter what process type it runs in.

Regression test tempdir_manager_never_touches_global_keyring pins the
incident: a foreign manager's logout must leave the (mock) keyring entry
intact. Keyring behavior tests keep constructor-read coverage via a
thread-local path-scope test seam.
This commit is contained in:
2026-07-17 19:32:56 -04:00
parent 5f3f9509b9
commit dab3fa71c9
2 changed files with 103 additions and 5 deletions
+48 -3
View File
@@ -112,6 +112,37 @@ const PERMANENT_FAILURE_TTL: StdDuration = StdDuration::from_secs(300);
/// `attempted_tombstone_key`, when a tombstone is stored), never co-held. Never hold
/// a `parking_lot` guard across `.await`. Refreshers return [`RefreshOutcome`]
/// for `refresh_chain` to apply.
/// Whether a manager rooted at `path` may use the OS keyring for `scope`.
///
/// The keyring entry (`service kigi / oauth/kimi-code`) is global per OS
/// user, so exactly one auth.json location can own it: the default install
/// path. Everything else (tempdir tests, `KIGI_SHARE_DIR` profiles,
/// `KIGI_AUTH_PATH` overrides) is file-scoped. The dynamic
/// [`keyring_enabled`] gate (env kill-switch, cfg(test) mock toggle) layers
/// on top at each call site.
fn keyring_path_scoped_for(path: &Path, scope: &str) -> bool {
#[cfg(test)]
if TEST_FORCE_KEYRING_PATH_SCOPE.with(|flag| flag.get()) {
return scope == KIMI_CODE_OAUTH_SCOPE;
}
scope == KIMI_CODE_OAUTH_SCOPE && path == kigi_config::default_kigi_home().join("auth.json")
}
// Test seam: pretend managers on this thread are rooted at the default
// install so keyring behavior can be exercised against the mock keyring
// from a tempdir. Thread-local for the same reason as the mock-keyring
// toggle: no leakage into concurrently running persistence tests.
#[cfg(test)]
thread_local! {
static TEST_FORCE_KEYRING_PATH_SCOPE: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
}
#[cfg(test)]
pub(crate) fn set_test_force_keyring_path_scope(on: bool) {
TEST_FORCE_KEYRING_PATH_SCOPE.with(|flag| flag.set(on));
}
pub struct AuthManager {
/// In-memory bearer. Mutate via [`Self::with_inner_write`] or
/// [`Self::refresh_chain`]; the closure helpers' sync return type
@@ -119,6 +150,15 @@ pub struct AuthManager {
inner: Arc<RwLock<Option<KimiAuth>>>,
path: PathBuf,
scope: String,
/// Whether THIS manager may touch the OS keyring. The keyring entry is
/// global per user, so it can only mirror the credential of the default
/// install (`~/.kigi/auth.json`). Managers rooted anywhere else —
/// integration-test tempdirs, alternate profiles, `KIGI_AUTH_PATH` —
/// must never read, write, or DELETE it: before this guard, every
/// `cargo test` run wiped the developer's real login via
/// `remove_scope`'s keyring delete. [`keyring_enabled`] (env
/// kill-switch / cfg(test) mock toggle) still gates dynamically on top.
keyring_path_scoped: bool,
kimi_code_config: KimiCodeConfig,
refresher: RwLock<Option<Arc<dyn TokenRefresher>>>,
/// Idempotency guard for `configure_refresher` so double-calls
@@ -273,7 +313,8 @@ impl AuthManager {
.unwrap_or_else(|_| kigi_home.join("auth.json"));
// Keyring first (PRD F1): the session credential's primary store.
if scope == KIMI_CODE_OAUTH_SCOPE
if keyring_path_scoped_for(&path, &scope)
&& keyring_enabled()
&& let KeyringRead::Found(auth) = keyring_read_session()
{
kigi_log::unified_log::info(
@@ -356,6 +397,7 @@ impl AuthManager {
) -> Self {
Self {
inner: Arc::new(RwLock::new(inner)),
keyring_path_scoped: keyring_path_scoped_for(&path, &scope),
path,
scope,
kimi_code_config,
@@ -402,8 +444,11 @@ impl AuthManager {
fn remove_scope_impl(&self, scope: &str) -> std::io::Result<()> {
// Session credentials also live in the system keyring (primary
// store); drop that copy first so a file-side failure can't leave
// the token behind.
// the token behind. Gated on `keyring_scoped`: only the default
// install owns the (global) keyring entry — a tempdir-rooted
// manager deleting it would log the real user out.
if scope == KIMI_CODE_OAUTH_SCOPE
&& self.keyring_path_scoped
&& let Err(e) = keyring_delete_session()
{
tracing::warn!(error = %e, "auth: failed to remove session credential from keyring");
@@ -637,7 +682,7 @@ impl AuthManager {
let update_started = std::time::Instant::now();
// Keyring first (PRD F1): the session credential's primary store.
if self.scope == KIMI_CODE_OAUTH_SCOPE && keyring_enabled() {
if self.keyring_path_scoped && keyring_enabled() {
match keyring_write_session(&auth) {
Ok(()) => {
let elapsed_ms = update_started.elapsed().as_millis() as u64;