1use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::path::Path;
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, SystemTime};
7
8use anyhow::{Context, Result, bail};
9use chrono::{DateTime, Datelike, Days, FixedOffset, Local, NaiveDate, NaiveTime, TimeZone};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::claude_usage;
14use crate::codex_usage::{self, CodexUsageClient, CodexUsageStatus};
15use crate::grok_usage;
16use hel::hel_config::HarnessKind;
17use hel::hel_config::harness_authentication_marker;
18use hel::hel_credentials::{
19 MAX_CREDENTIAL_BYTES, credential_expiry, credential_fingerprint, credential_freshness,
20};
21
22pub use mj_client::quota::{API_LABEL, ProfileQuota, QuotaWindow, projects_exhaustion};
23
24#[derive(Debug, Clone)]
25pub struct QuotaRefreshRequest {
26 pub profile_id: String,
27 pub harness: HarnessKind,
28 pub source_home: std::path::PathBuf,
29 pub environment: BTreeMap<String, String>,
30 pub cwd: std::path::PathBuf,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct QuotaRefreshOutcome {
35 pub report: ProfileQuota,
36 pub credentials_changed: bool,
37}
38
39#[derive(Default)]
40pub struct QuotaManager {
41 codex_clients: HashMap<String, CodexUsageClient>,
42 reports: BTreeMap<String, ProfileQuota>,
43}
44
45impl QuotaManager {
46 pub fn reports(&self) -> &BTreeMap<String, ProfileQuota> {
47 &self.reports
48 }
49
50 pub async fn refresh_profiles<F, Fut>(
54 &mut self,
55 requests: Vec<QuotaRefreshRequest>,
56 mut on_report: F,
57 ) where
58 F: FnMut(QuotaRefreshOutcome) -> Fut,
59 Fut: Future<Output = ()> + Send,
60 {
61 let batch = requests
62 .iter()
63 .map(|request| request.profile_id.clone())
64 .collect::<BTreeSet<_>>();
65 let mut tasks = tokio::task::JoinSet::new();
66 for request in requests {
67 let client = self.codex_clients.remove(&request.profile_id);
68 tasks.spawn(refresh_profile(request, client));
69 }
70
71 while let Some(result) = tasks.join_next().await {
72 let (outcome, client) = match result {
73 Ok(output) => output,
74 Err(error) => {
75 tracing::warn!(%error, "quota refresh task failed");
76 continue;
77 }
78 };
79 if let Some(client) = client {
80 self.codex_clients
81 .insert(outcome.report.profile_id.clone(), client);
82 }
83 self.reports
84 .insert(outcome.report.profile_id.clone(), outcome.report.clone());
85 on_report(outcome).await;
86 }
87 self.stop_clients_outside_batch(&batch).await;
88 }
89
90 async fn stop_clients_outside_batch(&mut self, keep: &BTreeSet<String>) {
96 let stranded = self
97 .codex_clients
98 .keys()
99 .filter(|profile_id| !keep.contains(*profile_id))
100 .cloned()
101 .collect::<Vec<_>>();
102 for profile_id in stranded {
103 if let Some(client) = self.codex_clients.remove(&profile_id) {
104 tracing::info!(profile_id, "stopping the quota client of a removed profile");
105 client.shutdown().await;
106 }
107 }
108 }
109
110 pub async fn shutdown(mut self) {
111 for (_, client) in self.codex_clients.drain() {
112 client.shutdown().await;
113 }
114 }
115}
116
117async fn refresh_profile(
118 request: QuotaRefreshRequest,
119 mut codex_client: Option<CodexUsageClient>,
120) -> (QuotaRefreshOutcome, Option<CodexUsageClient>) {
121 let credential_path = harness_authentication_marker(request.harness, &request.source_home);
122 let credential_before = credential_marker_fingerprint(&credential_path).await;
123 let QuotaRefreshRequest {
124 profile_id,
125 harness,
126 source_home,
127 environment,
128 cwd,
129 } = request;
130 let environment = environment.into_iter().collect::<HashMap<_, _>>();
131 let refreshed_at_epoch_seconds = SystemTime::now()
132 .duration_since(SystemTime::UNIX_EPOCH)
133 .unwrap_or_default()
134 .as_secs();
135 let result = match harness {
136 HarnessKind::Codex => {
137 if codex_login_is_near_expiry(&credential_path).await {
138 match codex_usage::refresh_login(
139 &mut codex_client,
140 cwd.clone(),
141 environment.clone(),
142 )
143 .await
144 {
145 Ok(()) => tracing::info!(
146 profile_id = %profile_id,
147 "refreshed Codex login ahead of expiry"
148 ),
149 Err(error) => tracing::warn!(
150 profile_id = %profile_id,
151 %error,
152 "could not refresh the Codex login ahead of expiry"
153 ),
154 }
155 }
156 let status = codex_usage::refresh(&mut codex_client, cwd, environment).await;
157 match status {
158 CodexUsageStatus::Available(report) => Ok(ProfileQuota {
159 profile_id: profile_id.clone(),
160 harness,
161 windows: [report.primary, report.secondary]
162 .into_iter()
163 .flatten()
164 .map(|window| QuotaWindow {
165 label: window.label,
166 remaining_percent: Some(window.remaining_percent),
167 used: None,
168 limit: None,
169 resets: window.resets_at.and_then(format_reset_local_seconds),
170 resets_at_epoch_seconds: window.resets_at,
171 })
172 .collect(),
173 extra: None,
174 error: None,
175 refreshed_at_epoch_seconds,
176 }),
177 CodexUsageStatus::Unavailable(error) => Err(anyhow::anyhow!(error)),
178 }
179 }
180 HarnessKind::Claude => claude_usage::query(source_home, environment)
181 .await
182 .map(|report| ProfileQuota {
183 profile_id: profile_id.clone(),
184 harness,
185 windows: [
186 report.five_hour.map(|window| ("5H", window)),
187 report.week.map(|window| ("Week", window)),
188 ]
189 .into_iter()
190 .flatten()
191 .map(|(label, window)| QuotaWindow {
192 label: label.to_string(),
193 remaining_percent: Some(window.remaining_percent),
194 used: None,
195 limit: None,
196 resets: window
197 .reset_context
198 .as_deref()
199 .and_then(normalize_reset_text),
200 resets_at_epoch_seconds: window
201 .reset_context
202 .as_deref()
203 .and_then(normalize_reset_epoch_seconds),
204 })
205 .collect(),
206 extra: None,
207 error: None,
208 refreshed_at_epoch_seconds,
209 })
210 .map_err(|error| anyhow::anyhow!(error.to_string())),
211 HarnessKind::Kimi => {
212 query_kimi(&source_home, &environment)
213 .await
214 .map(|(windows, extra)| ProfileQuota {
215 profile_id: profile_id.clone(),
216 harness,
217 windows,
218 extra,
219 error: None,
220 refreshed_at_epoch_seconds,
221 })
222 }
223 HarnessKind::Grok => {
226 grok_usage::query(source_home.clone(), cwd, environment)
227 .await
228 .map(|report| ProfileQuota {
229 profile_id: profile_id.clone(),
230 harness,
231 windows: vec![QuotaWindow {
232 label: report.period_label.clone(),
233 remaining_percent: Some(report.remaining_percent()),
234 used: None,
237 limit: None,
238 resets: report.resets_at.and_then(format_reset_local_seconds),
239 resets_at_epoch_seconds: report.resets_at,
240 }],
241 extra: None,
242 error: None,
243 refreshed_at_epoch_seconds,
244 })
245 .map_err(|error| anyhow::anyhow!(error.to_string()))
246 }
247 HarnessKind::Deepseek => Ok(ProfileQuota {
248 profile_id: profile_id.clone(),
249 harness,
250 windows: Vec::new(),
251 extra: Some(API_LABEL.to_owned()),
252 error: None,
253 refreshed_at_epoch_seconds,
254 }),
255 HarnessKind::Muse => crate::muse_usage::query(&source_home, &environment)
256 .await
257 .map(|report| ProfileQuota {
258 profile_id: profile_id.clone(),
259 harness,
260 windows: report
261 .windows
262 .into_iter()
263 .map(|window| QuotaWindow {
264 label: window.label,
265 remaining_percent: Some(window.remaining_percent),
266 used: None,
267 limit: None,
268 resets: window.resets_at.and_then(format_reset_local_seconds),
269 resets_at_epoch_seconds: window.resets_at,
270 })
271 .collect(),
272 extra: report.note,
273 error: None,
274 refreshed_at_epoch_seconds,
275 }),
276 };
277 let report = result.unwrap_or_else(|error| ProfileQuota {
278 profile_id,
279 harness,
280 windows: Vec::new(),
281 extra: None,
282 error: Some(error.to_string()),
283 refreshed_at_epoch_seconds,
284 });
285 let credential_after = credential_marker_fingerprint(&credential_path).await;
286 let credentials_changed = match (credential_before, credential_after) {
287 (Ok(before), Ok(after)) => before != after,
288 (Err(error), _) | (_, Err(error)) => {
289 tracing::warn!(path = %credential_path.display(), %error, "could not fingerprint quota credentials");
290 false
291 }
292 };
293 (
294 QuotaRefreshOutcome {
295 report,
296 credentials_changed,
297 },
298 codex_client,
299 )
300}
301
302const CODEX_MINIMUM_REFRESH_MARGIN_MS: i64 = 60 * 60 * 1000;
306
307async fn codex_login_is_near_expiry(marker: &Path) -> bool {
310 let Ok(bytes) = tokio::fs::read(marker).await else {
311 return false;
312 };
313 if bytes.len() > MAX_CREDENTIAL_BYTES {
314 return false;
315 }
316 let now = SystemTime::now()
317 .duration_since(SystemTime::UNIX_EPOCH)
318 .unwrap_or_default()
319 .as_millis() as i64;
320 codex_login_needs_refresh(
321 credential_expiry(HarnessKind::Codex, &bytes),
322 credential_freshness(HarnessKind::Codex, &bytes),
323 now,
324 )
325}
326
327fn codex_login_needs_refresh(
331 expiry_millis: Option<i64>,
332 last_refresh_millis: Option<i64>,
333 now_millis: i64,
334) -> bool {
335 let Some(expiry) = expiry_millis else {
336 return false;
337 };
338 let lifetime = last_refresh_millis
339 .map(|refreshed| expiry.saturating_sub(refreshed))
340 .unwrap_or_default();
341 let margin = CODEX_MINIMUM_REFRESH_MARGIN_MS.max(lifetime / 10);
342 expiry.saturating_sub(now_millis) < margin
343}
344
345async fn credential_marker_fingerprint(path: &Path) -> Result<Option<String>> {
346 let metadata = match tokio::fs::metadata(path).await {
347 Ok(metadata) => metadata,
348 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
349 Err(error) => return Err(error).context("inspect credential marker"),
350 };
351 if metadata.len() > MAX_CREDENTIAL_BYTES as u64 {
352 bail!("credential marker exceeds {MAX_CREDENTIAL_BYTES} bytes");
353 }
354 let bytes = tokio::fs::read(path)
355 .await
356 .context("read credential marker")?;
357 if bytes.len() > MAX_CREDENTIAL_BYTES {
358 bail!("credential marker exceeds {MAX_CREDENTIAL_BYTES} bytes");
359 }
360 Ok(Some(credential_fingerprint(&bytes)))
361}
362
363async fn query_kimi(
364 home: &Path,
365 environment: &HashMap<String, String>,
366) -> Result<(Vec<QuotaWindow>, Option<String>)> {
367 let base = environment
368 .get("KIMI_CODE_BASE_URL")
369 .map(String::as_str)
370 .unwrap_or("https://api.kimi.com/coding/v1")
371 .trim_end_matches('/');
372 let client = reqwest::Client::builder()
373 .timeout(Duration::from_secs(10))
374 .build()
375 .context("build Kimi quota client")?;
376 let credentials_path = home.join("credentials/kimi-code.json");
377 let usage_url = format!("{base}/usages");
378 let response = fetch_bearer_with_auth_retry(&client, &usage_url, |force, rejected_token| {
379 ensure_fresh_kimi_token(
380 &client,
381 home,
382 &credentials_path,
383 environment,
384 force,
385 rejected_token,
386 )
387 })
388 .await?;
389 if !response.status().is_success() {
390 bail!("Kimi Code quota returned HTTP {}", response.status());
391 }
392 let payload: Value = response.json().await.context("decode Kimi Code quota")?;
393 Ok(parse_kimi_usage(&payload))
394}
395
396const KIMI_OAUTH_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
397
398#[derive(Clone, Debug, Deserialize, Serialize)]
399struct KimiCredentials {
400 #[serde(alias = "accessToken")]
401 access_token: String,
402 #[serde(default, alias = "refreshToken")]
403 refresh_token: String,
404 #[serde(default, alias = "expiresAt")]
405 expires_at: i64,
406 #[serde(default)]
407 scope: String,
408 #[serde(default, alias = "tokenType")]
409 token_type: String,
410 #[serde(default, alias = "expiresIn")]
411 expires_in: i64,
412}
413
414impl KimiCredentials {
415 fn differs_from(&self, other: &Self) -> bool {
419 self.access_token != other.access_token
420 || self.refresh_token != other.refresh_token
421 || self.expires_at != other.expires_at
422 }
423
424 fn needs_refresh(&self) -> bool {
425 if self.expires_at == 0 {
426 return false;
427 }
428 let now = SystemTime::now()
429 .duration_since(SystemTime::UNIX_EPOCH)
430 .unwrap_or_default()
431 .as_secs() as i64;
432 let threshold = 300.max(self.expires_in / 2);
433 self.expires_at - now < threshold
434 }
435}
436
437async fn read_kimi_credentials(path: &Path) -> Result<KimiCredentials> {
438 let bytes = tokio::fs::read(path)
439 .await
440 .context("Kimi Code credentials are unavailable")?;
441 let credentials: KimiCredentials =
442 serde_json::from_slice(&bytes).context("Kimi Code credentials are invalid")?;
443 if credentials.access_token.is_empty() {
444 bail!("Kimi Code access token is missing");
445 }
446 Ok(credentials)
447}
448
449async fn fetch_bearer_with_auth_retry<F, Fut>(
450 client: &reqwest::Client,
451 url: &str,
452 mut authenticate: F,
453) -> Result<reqwest::Response>
454where
455 F: FnMut(bool, Option<String>) -> Fut,
456 Fut: std::future::Future<Output = Result<String>>,
457{
458 let token = authenticate(false, None).await?;
459 let response = client
460 .get(url)
461 .bearer_auth(&token)
462 .header(reqwest::header::ACCEPT, "application/json")
463 .send()
464 .await
465 .context("query quota")?;
466 if response.status() != reqwest::StatusCode::UNAUTHORIZED {
467 return Ok(response);
468 }
469
470 let refreshed = authenticate(true, Some(token)).await?;
471 client
472 .get(url)
473 .bearer_auth(refreshed)
474 .header(reqwest::header::ACCEPT, "application/json")
475 .send()
476 .await
477 .context("retry quota after authentication refresh")
478}
479
480async fn ensure_fresh_kimi_token(
489 client: &reqwest::Client,
490 home: &Path,
491 credentials_path: &Path,
492 environment: &HashMap<String, String>,
493 force: bool,
494 rejected_token: Option<String>,
495) -> Result<String> {
496 let initial = read_kimi_credentials(credentials_path).await?;
497 if !force && !initial.needs_refresh() {
498 return Ok(initial.access_token);
499 }
500
501 let refresh_lock = KimiRefreshLock::acquire(home).await?;
502 let active = read_kimi_credentials(credentials_path).await?;
503 let changed_while_waiting = active.differs_from(&initial);
504 if (!force && !active.needs_refresh())
505 || (force
506 && (changed_while_waiting
507 || rejected_token.is_some_and(|token| token != active.access_token)))
508 {
509 refresh_lock.release().await?;
510 return Ok(active.access_token);
511 }
512 if active.refresh_token.is_empty() {
513 refresh_lock.release().await?;
514 bail!("Kimi Code refresh token is missing; run `kimi login`");
515 }
516
517 let oauth_host = environment
518 .get("KIMI_CODE_OAUTH_HOST")
519 .or_else(|| environment.get("KIMI_OAUTH_HOST"))
520 .map(String::as_str)
521 .unwrap_or("https://auth.kimi.com")
522 .trim_end_matches('/');
523 let response = client
524 .post(format!("{oauth_host}/api/oauth/token"))
525 .header(reqwest::header::ACCEPT, "application/json")
526 .form(&[
527 ("client_id", KIMI_OAUTH_CLIENT_ID),
528 ("grant_type", "refresh_token"),
529 ("refresh_token", active.refresh_token.as_str()),
530 ])
531 .send()
532 .await
533 .context("refresh Kimi Code access token")?;
534 if !response.status().is_success() {
535 let status = response.status();
536 if matches!(
537 status,
538 reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
539 ) {
540 tokio::time::sleep(Duration::from_millis(100)).await;
541 let recovery = read_kimi_credentials(credentials_path).await?;
542 if recovery.refresh_token != active.refresh_token && !recovery.access_token.is_empty() {
543 refresh_lock.release().await?;
544 return Ok(recovery.access_token);
545 }
546 }
547 refresh_lock.release().await?;
548 bail!("Kimi Code token refresh returned HTTP {status}");
549 }
550
551 let payload: Value = response
552 .json()
553 .await
554 .context("decode Kimi Code token refresh")?;
555 let access_token = required_string(&payload, "access_token", "Kimi Code token refresh")?;
556 let refresh_token = required_string(&payload, "refresh_token", "Kimi Code token refresh")?;
557 let expires_in = payload
558 .get("expires_in")
559 .and_then(value_i64)
560 .filter(|value| *value > 0)
561 .context("Kimi Code token refresh is missing expires_in")?;
562 let now = SystemTime::now()
563 .duration_since(SystemTime::UNIX_EPOCH)
564 .unwrap_or_default()
565 .as_secs() as i64;
566 let refreshed = KimiCredentials {
567 access_token: access_token.to_string(),
568 refresh_token: refresh_token.to_string(),
569 expires_at: now + expires_in,
570 scope: payload
571 .get("scope")
572 .and_then(Value::as_str)
573 .unwrap_or_default()
574 .to_string(),
575 token_type: payload
576 .get("token_type")
577 .and_then(Value::as_str)
578 .unwrap_or("Bearer")
579 .to_string(),
580 expires_in,
581 };
582 let ownership = confirm_kimi_lock_ownership(&refresh_lock.path, &refresh_lock.ownership);
586 let on_disk = match &ownership {
587 Err(KimiLockLoss::Stolen { .. } | KimiLockLoss::Gone) => {
589 read_kimi_credentials(credentials_path).await.ok()
590 }
591 Ok(_) | Err(KimiLockLoss::Unproven(_)) => None,
592 };
593 match decide_kimi_refresh_persist(&ownership, on_disk.as_ref(), &active) {
594 KimiRefreshPersist::Save => {
595 save_kimi_credentials(credentials_path, &refreshed)?;
596 if let Err(error) = refresh_lock.release().await {
597 tracing::warn!(
601 %error,
602 "saved refreshed Kimi Code credentials, then lost the OAuth refresh lock before releasing it"
603 );
604 }
605 Ok(refreshed.access_token)
606 }
607 KimiRefreshPersist::SaveContested(loss) => {
608 save_kimi_credentials(credentials_path, &refreshed)?;
609 tracing::warn!(
610 path = %refresh_lock.path.display(),
611 %loss,
612 "another Kimi Code token refresh took the OAuth refresh lock, but left behind the pair this refresh already spent; saved Mjolnir's refreshed pair, the only live one"
613 );
614 Ok(refreshed.access_token)
615 }
616 KimiRefreshPersist::Adopt { access_token, loss } => {
617 tracing::warn!(
618 path = %refresh_lock.path.display(),
619 %loss,
620 "another Kimi Code token refresh took the OAuth refresh lock and stored its own credentials; using those instead of the pair Mjolnir just fetched"
621 );
622 Ok(access_token)
623 }
624 }
625}
626
627fn save_kimi_credentials(path: &Path, credentials: &KimiCredentials) -> Result<()> {
628 let mut body = serde_json::to_vec_pretty(credentials)?;
629 body.push(b'\n');
630 hel::hel_config::atomic_write(path, &body).context("save refreshed Kimi Code credentials")
631}
632
633#[derive(Debug, Clone, PartialEq, Eq)]
635enum KimiRefreshPersist {
636 Save,
638 SaveContested(KimiLockLoss),
641 Adopt {
644 access_token: String,
645 loss: KimiLockLoss,
646 },
647}
648
649fn decide_kimi_refresh_persist(
666 ownership: &Result<SystemTime, KimiLockLoss>,
667 on_disk: Option<&KimiCredentials>,
668 active: &KimiCredentials,
669) -> KimiRefreshPersist {
670 let loss = match ownership {
671 Ok(_) => return KimiRefreshPersist::Save,
672 Err(KimiLockLoss::Unproven(_)) => return KimiRefreshPersist::Save,
675 Err(loss) => loss.clone(),
676 };
677 match on_disk {
678 Some(pair) if pair.differs_from(active) => KimiRefreshPersist::Adopt {
679 access_token: pair.access_token.clone(),
680 loss,
681 },
682 _ => KimiRefreshPersist::SaveContested(loss),
683 }
684}
685
686fn required_string<'a>(payload: &'a Value, key: &str, context: &str) -> Result<&'a str> {
687 payload
688 .get(key)
689 .and_then(Value::as_str)
690 .filter(|value| !value.is_empty())
691 .with_context(|| format!("{context} is missing {key}"))
692}
693
694struct KimiRefreshLock {
695 path: std::path::PathBuf,
696 ownership: Arc<Mutex<KimiLockOwnership>>,
697 heartbeat: Option<tokio::task::JoinHandle<()>>,
698}
699
700#[derive(Debug)]
708enum KimiLockOwnership {
709 Held(SystemTime),
712 Lost(KimiLockLoss),
714}
715
716#[derive(Debug, Clone, PartialEq, Eq)]
718enum KimiLockLoss {
719 Stolen {
722 published: SystemTime,
723 observed: SystemTime,
724 },
725 Gone,
727 Unproven(String),
731}
732
733impl std::fmt::Display for KimiLockLoss {
734 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
735 match self {
736 Self::Stolen {
737 published,
738 observed,
739 } => write!(
740 formatter,
741 "another process took it: Mjolnir published modification time {}, but the directory carries {}",
742 epoch_label(*published),
743 epoch_label(*observed)
744 ),
745 Self::Gone => formatter.write_str("another process removed it"),
746 Self::Unproven(error) => {
747 write!(
748 formatter,
749 "Mjolnir could not confirm it still owns it: {error}"
750 )
751 }
752 }
753 }
754}
755
756fn epoch_label(time: SystemTime) -> String {
757 match time.duration_since(SystemTime::UNIX_EPOCH) {
758 Ok(since) => format!("{:.3}", since.as_secs_f64()),
759 Err(_) => "before the epoch".to_string(),
760 }
761}
762
763const KIMI_CLI_LOCK_STALE_AFTER: Duration = Duration::from_secs(5);
778const KIMI_LOCK_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1);
784const KIMI_LOCK_HEARTBEAT_STALL_TOLERANCE: Duration = Duration::from_secs(
789 KIMI_CLI_LOCK_STALE_AFTER.as_secs() - KIMI_LOCK_HEARTBEAT_INTERVAL.as_secs(),
790);
791const KIMI_LOCK_STALE_HEARTBEATS: u64 = 10;
798const KIMI_LOCK_STALE_AFTER: Duration =
800 Duration::from_secs(KIMI_LOCK_STALE_HEARTBEATS * KIMI_LOCK_HEARTBEAT_INTERVAL.as_secs());
801const _: () = assert!(
802 KIMI_LOCK_HEARTBEAT_STALL_TOLERANCE.as_secs() >= 4 * KIMI_LOCK_HEARTBEAT_INTERVAL.as_secs(),
803 "Mjolnir must be able to miss several beats in a row and still hold a lock the Kimi Code CLI could otherwise break"
804);
805const _: () = assert!(
806 KIMI_LOCK_STALE_AFTER.as_secs() >= KIMI_CLI_LOCK_STALE_AFTER.as_secs(),
807 "Mjolnir must not call a lock stale sooner than the Kimi Code CLI does, or it can break a lock the CLI still holds"
808);
809const KIMI_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(500);
810const KIMI_LOCK_WAIT: Duration = Duration::from_secs(60);
811const KIMI_LOCK_MTIME_TOLERANCE: Duration = Duration::from_secs(1);
821
822impl KimiRefreshLock {
823 async fn acquire(home: &Path) -> Result<Self> {
824 Self::acquire_within(home, KIMI_LOCK_WAIT).await
825 }
826
827 async fn acquire_within(home: &Path, wait: Duration) -> Result<Self> {
828 let oauth_dir = home.join("oauth");
829 tokio::fs::create_dir_all(&oauth_dir)
830 .await
831 .context("prepare Kimi Code OAuth lock")?;
832 let sentinel = oauth_dir.join("kimi-code");
833 tokio::fs::OpenOptions::new()
834 .create(true)
835 .append(true)
836 .open(&sentinel)
837 .await
838 .context("prepare Kimi Code OAuth lock sentinel")?;
839 let path = oauth_dir.join("kimi-code.lock");
840 let deadline = tokio::time::Instant::now() + wait;
841 loop {
842 match tokio::fs::create_dir(&path).await {
843 Ok(()) => return Self::claim(path).await,
844 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
845 if tokio::time::Instant::now() >= deadline {
846 break;
847 }
848 if !break_stale_kimi_lock(&path).await {
852 tokio::time::sleep(KIMI_LOCK_RETRY_INTERVAL).await;
853 }
854 }
855 Err(error) => return Err(error).context("acquire Kimi Code OAuth refresh lock"),
856 }
857 }
858 bail!(
859 "timed out waiting for Kimi Code OAuth refresh lock {}; another Kimi Code refresh is holding it, or a crashed one left it behind and the directory has to be removed",
860 path.display()
861 )
862 }
863
864 async fn claim(path: std::path::PathBuf) -> Result<Self> {
868 let published = kimi_lock_mtime(&path)
869 .map_err(anyhow::Error::new)
870 .and_then(|mtime| mtime.context("it vanished as Mjolnir created it"));
871 match published {
872 Ok(published) => Ok(Self::held(path, published)),
873 Err(error) => {
874 let _ = tokio::fs::remove_dir(&path).await;
878 Err(error).with_context(|| {
879 format!(
880 "claim the new Kimi Code OAuth refresh lock {}",
881 path.display()
882 )
883 })
884 }
885 }
886 }
887
888 fn held(path: std::path::PathBuf, published: SystemTime) -> Self {
889 let ownership = Arc::new(Mutex::new(KimiLockOwnership::Held(published)));
890 let heartbeat_path = path.clone();
891 let heartbeat_ownership = Arc::clone(&ownership);
892 let heartbeat = tokio::spawn(async move {
893 loop {
894 tokio::time::sleep(KIMI_LOCK_HEARTBEAT_INTERVAL).await;
895 match beat_kimi_lock(&heartbeat_path, &heartbeat_ownership) {
896 Ok(()) => {}
897 Err(KimiLockLoss::Unproven(error)) => {
898 tracing::debug!(path = %heartbeat_path.display(), %error, "heartbeat Kimi Code OAuth refresh lock");
899 }
900 Err(loss) => {
901 tracing::warn!(path = %heartbeat_path.display(), %loss, "stopped heartbeating a Kimi Code OAuth refresh lock Mjolnir no longer holds");
902 return;
903 }
904 }
905 }
906 });
907 Self {
908 path,
909 ownership,
910 heartbeat: Some(heartbeat),
911 }
912 }
913
914 async fn release(mut self) -> Result<()> {
920 if let Some(heartbeat) = self.heartbeat.take() {
921 heartbeat.abort();
922 }
923 if let Err(loss) = confirm_kimi_lock_ownership(&self.path, &self.ownership) {
924 bail!(
925 "the Kimi Code OAuth refresh lock {} stopped being Mjolnir's mid-refresh: {loss}; another Kimi Code token refresh may have rotated the credentials beside this one",
926 self.path.display()
927 );
928 }
929 match tokio::fs::remove_dir(&self.path).await {
930 Ok(()) => {
931 *lock_ownership(&self.ownership) = KimiLockOwnership::Lost(KimiLockLoss::Gone)
932 }
933 Err(error) => {
934 tracing::warn!(path = %self.path.display(), %error, "release Kimi Code OAuth refresh lock");
935 }
936 }
937 Ok(())
938 }
939}
940
941impl Drop for KimiRefreshLock {
942 fn drop(&mut self) {
943 if let Some(heartbeat) = self.heartbeat.take() {
944 heartbeat.abort();
945 }
946 if matches!(*lock_ownership(&self.ownership), KimiLockOwnership::Lost(_)) {
947 return;
949 }
950 match confirm_kimi_lock_ownership(&self.path, &self.ownership) {
951 Ok(_) => {
952 if let Err(error) = std::fs::remove_dir(&self.path) {
953 tracing::warn!(path = %self.path.display(), %error, "release Kimi Code OAuth refresh lock");
954 }
955 }
956 Err(loss) => {
957 tracing::warn!(path = %self.path.display(), %loss, "left a Kimi Code OAuth refresh lock Mjolnir no longer holds in place");
958 }
959 }
960 }
961}
962
963fn lock_ownership(
964 ownership: &Mutex<KimiLockOwnership>,
965) -> std::sync::MutexGuard<'_, KimiLockOwnership> {
966 ownership
967 .lock()
968 .unwrap_or_else(|poisoned| poisoned.into_inner())
969}
970
971fn confirm_kimi_lock_ownership(
976 path: &Path,
977 ownership: &Mutex<KimiLockOwnership>,
978) -> Result<SystemTime, KimiLockLoss> {
979 let published = match &*lock_ownership(ownership) {
980 KimiLockOwnership::Held(published) => *published,
981 KimiLockOwnership::Lost(loss) => return Err(loss.clone()),
982 };
983 let loss = match kimi_lock_mtime(path) {
984 Ok(Some(observed)) if kimi_lock_mtime_matches(published, observed) => {
985 return Ok(published);
986 }
987 Ok(Some(observed)) => KimiLockLoss::Stolen {
988 published,
989 observed,
990 },
991 Ok(None) => KimiLockLoss::Gone,
992 Err(error) => return Err(KimiLockLoss::Unproven(error.to_string())),
995 };
996 *lock_ownership(ownership) = KimiLockOwnership::Lost(loss.clone());
997 Err(loss)
998}
999
1000fn beat_kimi_lock(path: &Path, ownership: &Mutex<KimiLockOwnership>) -> Result<(), KimiLockLoss> {
1003 confirm_kimi_lock_ownership(path, ownership)?;
1004 let published = SystemTime::now();
1005 if let Err(error) = touch_kimi_lock(path, published) {
1006 return Err(KimiLockLoss::Unproven(error.to_string()));
1009 }
1010 let mut ownership = lock_ownership(ownership);
1011 if matches!(*ownership, KimiLockOwnership::Held(_)) {
1012 *ownership = KimiLockOwnership::Held(published);
1013 }
1014 Ok(())
1015}
1016
1017fn kimi_lock_mtime_matches(published: SystemTime, observed: SystemTime) -> bool {
1021 observed
1022 .duration_since(published)
1023 .or_else(|_| published.duration_since(observed))
1024 .is_ok_and(|drift| drift < KIMI_LOCK_MTIME_TOLERANCE)
1025}
1026
1027fn kimi_lock_mtime(path: &Path) -> std::io::Result<Option<SystemTime>> {
1032 match std::fs::metadata(path) {
1033 Ok(metadata) => metadata.modified().map(Some),
1034 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1035 Err(error) => Err(error),
1036 }
1037}
1038
1039fn touch_kimi_lock(path: &Path, modified: SystemTime) -> std::io::Result<()> {
1044 let mut options = std::fs::OpenOptions::new();
1045 options.read(true);
1046 #[cfg(windows)]
1047 {
1048 use std::os::windows::fs::OpenOptionsExt;
1049 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1050 options.custom_flags(FILE_FLAG_BACKUP_SEMANTICS);
1051 }
1052 options
1053 .open(path)?
1054 .set_times(std::fs::FileTimes::new().set_modified(modified))
1055}
1056
1057async fn break_stale_kimi_lock(path: &Path) -> bool {
1063 let modified = match kimi_lock_mtime(path) {
1064 Ok(Some(modified)) => modified,
1065 Ok(None) => return true,
1066 Err(error) => {
1067 tracing::warn!(path = %path.display(), %error, "inspect Kimi Code OAuth refresh lock");
1068 return false;
1069 }
1070 };
1071 let age = SystemTime::now().duration_since(modified).ok();
1072 let Some(age) = age.filter(|age| *age >= KIMI_LOCK_STALE_AFTER) else {
1073 return false;
1074 };
1075 match tokio::fs::remove_dir(path).await {
1076 Ok(()) => {
1077 tracing::warn!(
1078 path = %path.display(),
1079 age_seconds = age.as_secs(),
1080 "removed a Kimi Code OAuth refresh lock whose holder stopped heartbeating"
1081 );
1082 true
1083 }
1084 Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
1085 Err(error) => {
1086 tracing::warn!(path = %path.display(), %error, "remove stale Kimi Code OAuth refresh lock");
1087 false
1088 }
1089 }
1090}
1091
1092fn parse_kimi_usage(payload: &Value) -> (Vec<QuotaWindow>, Option<String>) {
1093 let mut windows = Vec::new();
1094 if let Some(summary) = payload.get("usage")
1095 && let Some(window) = parse_kimi_window(summary, "Weekly limit")
1096 {
1097 windows.push(window);
1098 }
1099 if let Some(limits) = payload.get("limits").and_then(Value::as_array) {
1100 for (index, item) in limits.iter().enumerate() {
1101 let detail = item.get("detail").unwrap_or(item);
1102 if let Some(window) = parse_kimi_window(detail, &format!("Limit #{}", index + 1)) {
1103 windows.push(window);
1104 }
1105 }
1106 }
1107 let extra = payload
1108 .pointer("/boosterWallet/balance/amountLeft")
1109 .and_then(value_i64)
1110 .map(|value| format!("booster {} remaining", value / 1_000_000));
1111 (windows, extra)
1112}
1113
1114fn parse_kimi_window(value: &Value, fallback: &str) -> Option<QuotaWindow> {
1115 let limit = value.get("limit").and_then(value_i64);
1116 let used = value.get("used").and_then(value_i64).or_else(|| {
1117 let remaining = value.get("remaining").and_then(value_i64)?;
1118 Some(limit? - remaining)
1119 });
1120 if used.is_none() && limit.is_none() {
1121 return None;
1122 }
1123 let provider_label = value
1124 .get("name")
1125 .or_else(|| value.get("title"))
1126 .and_then(Value::as_str)
1127 .unwrap_or(fallback);
1128 let label = if provider_label.to_ascii_lowercase().contains("week") {
1129 "Week".to_string()
1130 } else if provider_label.to_ascii_lowercase().contains("5h") || fallback.starts_with("Limit #")
1131 {
1132 "5H".to_string()
1133 } else {
1134 provider_label.to_string()
1135 };
1136 let reset_value = ["resetAt", "reset_at", "resetTime", "reset_time"]
1137 .iter()
1138 .find_map(|key| value.get(*key));
1139 let resets = reset_value.and_then(normalize_kimi_reset);
1140 let resets_at_epoch_seconds = reset_value.and_then(kimi_reset_epoch_seconds);
1141 let remaining_percent = match (used, limit) {
1142 (Some(used), Some(limit)) if limit > 0 => {
1143 Some((100 - used.clamp(0, limit) * 100 / limit) as u8)
1144 }
1145 _ => None,
1146 };
1147 Some(QuotaWindow {
1148 label,
1149 remaining_percent,
1150 used,
1151 limit,
1152 resets,
1153 resets_at_epoch_seconds,
1154 })
1155}
1156
1157fn value_i64(value: &Value) -> Option<i64> {
1158 value
1159 .as_i64()
1160 .or_else(|| value.as_str()?.parse::<i64>().ok())
1161}
1162
1163fn normalize_kimi_reset(value: &Value) -> Option<String> {
1164 value
1165 .as_f64()
1166 .and_then(format_reset_local)
1167 .or_else(|| value.as_str().and_then(normalize_reset_text))
1168}
1169
1170fn kimi_reset_epoch_seconds(value: &Value) -> Option<i64> {
1171 value
1172 .as_f64()
1173 .map(|epoch| {
1174 if epoch.abs() >= 1_000_000_000_000.0 {
1175 (epoch / 1000.0).trunc() as i64
1176 } else {
1177 epoch.trunc() as i64
1178 }
1179 })
1180 .or_else(|| value.as_str().and_then(normalize_reset_epoch_seconds))
1181}
1182
1183pub(crate) fn format_reset_local(epoch: f64) -> Option<String> {
1187 if !epoch.is_finite() {
1188 return None;
1189 }
1190 let seconds = if epoch.abs() >= 1_000_000_000_000.0 {
1191 (epoch / 1000.0).trunc() as i64
1192 } else {
1193 epoch.trunc() as i64
1194 };
1195 let local = Local.timestamp_opt(seconds, 0).single()?;
1196 Some(format_reset_label(local.fixed_offset()))
1197}
1198
1199pub(crate) fn format_reset_local_seconds(epoch: i64) -> Option<String> {
1200 format_reset_local(epoch as f64)
1201}
1202
1203pub(crate) fn normalize_reset_text(value: &str) -> Option<String> {
1207 normalize_reset_at(value, Local::now().fixed_offset()).map(format_reset_label)
1208}
1209
1210pub(crate) fn normalize_reset_epoch_seconds(value: &str) -> Option<i64> {
1211 normalize_reset_at(value, Local::now().fixed_offset()).map(|reset| reset.timestamp())
1212}
1213
1214fn normalize_reset_at(value: &str, now: DateTime<FixedOffset>) -> Option<DateTime<FixedOffset>> {
1215 let value = value.trim();
1216 if value.is_empty() {
1217 return None;
1218 }
1219 if let Ok(epoch) = value.parse::<f64>() {
1220 let seconds = if epoch.abs() >= 1_000_000_000_000.0 {
1221 (epoch / 1000.0).trunc() as i64
1222 } else {
1223 epoch.trunc() as i64
1224 };
1225 return Local
1226 .timestamp_opt(seconds, 0)
1227 .single()
1228 .map(|reset| reset.fixed_offset());
1229 }
1230 if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) {
1231 return Some(timestamp.with_timezone(&Local).fixed_offset());
1232 }
1233
1234 let value = value
1235 .strip_prefix("at ")
1236 .unwrap_or(value)
1237 .split('(')
1238 .next()
1239 .unwrap_or(value)
1240 .trim()
1241 .trim_end_matches(',');
1242 let parse_time = |value: &str| {
1243 let value = value
1244 .to_ascii_lowercase()
1245 .chars()
1246 .filter(|ch| !ch.is_whitespace())
1247 .collect::<String>();
1248 let value = ["am", "pm"]
1249 .into_iter()
1250 .find_map(|suffix| {
1251 let hour = value.strip_suffix(suffix)?;
1252 (!hour.contains(':')).then(|| format!("{hour}:00{suffix}"))
1253 })
1254 .unwrap_or(value);
1255 ["%I:%M%P", "%I%P", "%H:%M"]
1256 .iter()
1257 .find_map(|format| NaiveTime::parse_from_str(&value, format).ok())
1258 };
1259
1260 let dated_time = value.split_once(" at ").or_else(|| {
1263 value
1264 .split_once(',')
1265 .map(|(date, time)| (date, time.trim()))
1266 });
1267 if let Some((date, time)) = dated_time {
1268 let time = parse_time(time.trim())?;
1269 let date = date.trim().trim_end_matches(',');
1270 let date = match date.to_ascii_lowercase().as_str() {
1271 "today" => now.date_naive(),
1272 "tomorrow" => now.date_naive().checked_add_days(Days::new(1))?,
1273 _ => NaiveDate::parse_from_str(
1274 &format!("{} {}", date.replace(',', ""), now.year()),
1275 "%b %e %Y",
1276 )
1277 .ok()?,
1278 };
1279 return now
1280 .timezone()
1281 .from_local_datetime(&date.and_time(time))
1282 .single();
1283 }
1284
1285 let time = parse_time(value)?;
1286 let mut date = now.date_naive();
1287 let mut reset = now
1288 .timezone()
1289 .from_local_datetime(&date.and_time(time))
1290 .single()?;
1291 if reset <= now {
1292 date = date.checked_add_days(Days::new(1))?;
1293 reset = now
1294 .timezone()
1295 .from_local_datetime(&date.and_time(time))
1296 .single()?;
1297 }
1298 Some(reset)
1299}
1300
1301fn format_reset_label(reset: DateTime<FixedOffset>) -> String {
1303 reset.format("%H:%M %b %-d").to_string()
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308 use super::*;
1309 use axum::body::Bytes;
1310 use axum::extract::State;
1311 use axum::http::{HeaderMap, StatusCode};
1312 use axum::routing::{get, post};
1313 use axum::{Json, Router};
1314 use std::sync::{Arc, Mutex};
1315
1316 #[test]
1317 fn parses_kimi_summary_limits_and_booster_without_credentials() {
1318 let payload = serde_json::json!({
1319 "usage": {"name":"Weekly", "used":40, "limit":1000, "resetAt":"tomorrow"},
1320 "limits": [{"detail":{"remaining":"90", "limit":"100", "name":"5h"}}],
1321 "boosterWallet": {"balance":{"amountLeft":42000000}}
1322 });
1323 let (windows, extra) = parse_kimi_usage(&payload);
1324 assert_eq!(windows.len(), 2);
1325 assert_eq!(windows[0].used, Some(40));
1326 assert_eq!(windows[1].used, Some(10));
1327 assert_eq!(windows[0].label, "Week");
1328 assert_eq!(windows[0].remaining_percent, Some(96));
1329 assert_eq!(windows[1].label, "5H");
1330 assert_eq!(windows[1].remaining_percent, Some(90));
1331 assert_eq!(extra.as_deref(), Some("booster 42 remaining"));
1332 }
1333
1334 #[test]
1335 fn compact_includes_reset_and_error_states() {
1336 let report = ProfileQuota {
1337 profile_id: "codex-1".into(),
1338 harness: HarnessKind::Codex,
1339 windows: vec![QuotaWindow {
1340 label: "5H".into(),
1341 remaining_percent: Some(70),
1342 used: None,
1343 limit: None,
1344 resets: Some("10:00 Jun 17".into()),
1345 resets_at_epoch_seconds: Some(14_400),
1346 }],
1347 extra: None,
1348 error: None,
1349 refreshed_at_epoch_seconds: 0,
1350 };
1351 assert!(report.compact().contains("70% left"));
1352 assert!(report.compact().contains("resets 10:00 Jun 17"));
1353 }
1354
1355 #[test]
1356 fn compact_shows_login_expired_without_unavailable_prefix() {
1357 let report = ProfileQuota {
1358 profile_id: "claude2".into(),
1359 harness: HarnessKind::Claude,
1360 windows: vec![],
1361 extra: None,
1362 error: Some(claude_usage::LOGIN_EXPIRED.into()),
1363 refreshed_at_epoch_seconds: 0,
1364 };
1365 assert_eq!(report.compact(), claude_usage::LOGIN_EXPIRED);
1366 assert_eq!(
1367 report.error_label().as_deref(),
1368 Some(claude_usage::LOGIN_EXPIRED)
1369 );
1370 }
1371
1372 #[test]
1373 fn compact_shows_other_errors_as_unavailable() {
1374 let report = ProfileQuota {
1375 profile_id: "claude2".into(),
1376 harness: HarnessKind::Claude,
1377 windows: vec![],
1378 extra: None,
1379 error: Some("query Claude usage: HTTP 429".into()),
1380 refreshed_at_epoch_seconds: 0,
1381 };
1382 assert_eq!(report.compact(), "unavailable");
1383 assert_eq!(report.error_label().as_deref(), Some("unavailable"));
1384 }
1385
1386 #[test]
1387 fn compact_displays_a_shared_reset_once() {
1388 let report = ProfileQuota {
1389 profile_id: "codex-1".into(),
1390 harness: HarnessKind::Codex,
1391 windows: vec![
1392 QuotaWindow {
1393 label: "5H".into(),
1394 remaining_percent: Some(70),
1395 used: None,
1396 limit: None,
1397 resets: Some("10:00 Jun 17".into()),
1398 resets_at_epoch_seconds: Some(14_400),
1399 },
1400 QuotaWindow {
1401 label: "Week".into(),
1402 remaining_percent: Some(55),
1403 used: None,
1404 limit: None,
1405 resets: Some("10:00 Jun 17".into()),
1406 resets_at_epoch_seconds: Some(14_400),
1407 },
1408 ],
1409 extra: None,
1410 error: None,
1411 refreshed_at_epoch_seconds: 0,
1412 };
1413 assert_eq!(
1414 report.compact(),
1415 "5H 70% left, resets 10:00 Jun 17 · Week 55% left"
1416 );
1417 }
1418
1419 #[test]
1420 fn compact_hides_claude_short_window_when_week_is_exhausted() {
1421 let report = ProfileQuota {
1422 profile_id: "claude".into(),
1423 harness: HarnessKind::Claude,
1424 windows: vec![
1425 QuotaWindow {
1426 label: "5H".into(),
1427 remaining_percent: Some(100),
1428 used: None,
1429 limit: None,
1430 resets: None,
1431 resets_at_epoch_seconds: None,
1432 },
1433 QuotaWindow {
1434 label: "Week".into(),
1435 remaining_percent: Some(0),
1436 used: None,
1437 limit: None,
1438 resets: Some("03:59 Aug 14".into()),
1439 resets_at_epoch_seconds: None,
1440 },
1441 ],
1442 extra: None,
1443 error: None,
1444 refreshed_at_epoch_seconds: 0,
1445 };
1446
1447 assert_eq!(report.compact(), "Week 0% left, resets 03:59 Aug 14");
1448 }
1449
1450 #[cfg(unix)]
1451 #[tokio::test]
1452 async fn a_grok_profile_reports_its_billing_period_as_one_quota_window() {
1453 use std::os::unix::fs::PermissionsExt;
1454
1455 let directory = tempfile::tempdir().unwrap();
1456 let executable = directory.path().join("grok");
1457 std::fs::write(directory.path().join("auth.json"), b"old credentials").unwrap();
1458 std::fs::write(
1459 &executable,
1460 "#!/bin/sh\nprintf 'refreshed credentials' > \"$GROK_HOME/auth.json\"\nwhile IFS= read -r line; do\n case \"$line\" in\n *initialize*) printf '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\\n' ;;\n *billing*) printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"config\":{\"creditUsagePercent\":25.0,\"currentPeriod\":{\"type\":\"USAGE_PERIOD_TYPE_WEEKLY\",\"end\":\"2026-08-18T05:22:07+00:00\"}},\"subscription_tier\":\"X Premium+\"}}\\n' ;;\n esac\ndone\n",
1461 )
1462 .unwrap();
1463 std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
1464 let environment = BTreeMap::from([
1465 (
1466 "GROK_HOME".to_owned(),
1467 directory.path().to_string_lossy().into_owned(),
1468 ),
1469 (
1470 "PATH".to_owned(),
1471 directory.path().to_string_lossy().into_owned(),
1472 ),
1473 ]);
1474
1475 let (outcome, _) = refresh_profile(
1476 QuotaRefreshRequest {
1477 profile_id: "grok".into(),
1478 harness: HarnessKind::Grok,
1479 source_home: directory.path().to_path_buf(),
1480 environment,
1481 cwd: directory.path().to_path_buf(),
1482 },
1483 None,
1484 )
1485 .await;
1486 assert!(outcome.credentials_changed);
1487 let report = outcome.report;
1488
1489 assert_eq!(report.error, None, "{:?}", report.error);
1490 assert_eq!(report.windows.len(), 1);
1492 assert_eq!(report.weekly_window().unwrap().remaining_percent, Some(75));
1493 assert_eq!(report.five_hour_window(), None);
1494 assert_eq!(report.extra, None);
1497 assert!(report.compact().starts_with("Week 75% left, resets "));
1498 }
1499
1500 #[cfg(unix)]
1503 fn fake_codex_app_server(
1504 directory: &Path,
1505 script: &str,
1506 ) -> (BTreeMap<String, String>, std::path::PathBuf) {
1507 use std::os::unix::fs::PermissionsExt;
1508
1509 let executable = directory.join("codex");
1510 std::fs::write(&executable, script).unwrap();
1511 std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
1512 let log = directory.join("requests.jsonl");
1513 let environment = BTreeMap::from([
1514 ("PATH".to_owned(), directory.to_string_lossy().into_owned()),
1515 (
1516 "CODEX_USAGE_TEST_LOG".to_owned(),
1517 log.to_string_lossy().into_owned(),
1518 ),
1519 (
1520 "CODEX_AUTH_FILE".to_owned(),
1521 directory.join("auth.json").to_string_lossy().into_owned(),
1522 ),
1523 ]);
1524 (environment, log)
1525 }
1526
1527 #[cfg(unix)]
1530 fn write_codex_auth(home: &Path, expires_in: Duration, refreshed_ago: Duration) {
1531 use base64::Engine as _;
1532
1533 let now = chrono::Utc::now();
1534 let expiry = (now + chrono::TimeDelta::from_std(expires_in).unwrap()).timestamp();
1535 let segment = |value: Value| {
1536 base64::engine::general_purpose::URL_SAFE_NO_PAD
1537 .encode(serde_json::to_vec(&value).unwrap())
1538 };
1539 let access_token = format!(
1540 "{}.{}.signature-is-never-checked",
1541 segment(serde_json::json!({ "alg": "RS256", "typ": "JWT" })),
1542 segment(serde_json::json!({ "exp": expiry })),
1543 );
1544 let body = serde_json::json!({
1545 "auth_mode": "chatgpt",
1546 "tokens": {
1547 "access_token": access_token,
1548 "refresh_token": "refresh",
1549 "id_token": "id",
1550 "account_id": "account",
1551 },
1552 "last_refresh": (now - chrono::TimeDelta::from_std(refreshed_ago).unwrap())
1553 .to_rfc3339(),
1554 });
1555 std::fs::write(home.join("auth.json"), serde_json::to_vec(&body).unwrap()).unwrap();
1556 }
1557
1558 #[cfg(unix)]
1559 fn codex_request_log(log: &Path) -> Vec<Value> {
1560 std::fs::read_to_string(log)
1561 .unwrap()
1562 .lines()
1563 .map(|line| serde_json::from_str::<Value>(line).unwrap())
1564 .collect()
1565 }
1566
1567 #[cfg(unix)]
1568 async fn poll_codex_profile(
1569 directory: &Path,
1570 environment: BTreeMap<String, String>,
1571 ) -> QuotaRefreshOutcome {
1572 let (outcome, client) = refresh_profile(
1573 QuotaRefreshRequest {
1574 profile_id: "codex".into(),
1575 harness: HarnessKind::Codex,
1576 source_home: directory.to_path_buf(),
1577 environment,
1578 cwd: directory.to_path_buf(),
1579 },
1580 None,
1581 )
1582 .await;
1583 if let Some(client) = client {
1584 client.shutdown().await;
1585 }
1586 outcome
1587 }
1588
1589 #[cfg(unix)]
1590 #[tokio::test]
1591 async fn a_codex_login_near_expiry_is_rotated_before_the_usage_query() {
1592 let directory = tempfile::tempdir().unwrap();
1593 write_codex_auth(
1595 directory.path(),
1596 Duration::from_secs(600),
1597 Duration::from_secs(3_000),
1598 );
1599 let (environment, log) = fake_codex_app_server(
1600 directory.path(),
1601 r#"#!/bin/sh
1602read_and_log() {
1603 IFS= read -r line || exit 1
1604 printf '%s\n' "$line" >> "$CODEX_USAGE_TEST_LOG"
1605}
1606read_and_log
1607printf '%s\n' '{"id":1,"result":{}}'
1608read_and_log
1609read_and_log
1610printf '%s\n' '{"auth_mode":"chatgpt","tokens":{"access_token":"rotated"}}' > "$CODEX_AUTH_FILE"
1611printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt"}}}'
1612read_and_log
1613printf '%s\n' '{"id":3,"result":{"account":{"type":"chatgpt"}}}'
1614read_and_log
1615printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":300}}}}'
1616"#,
1617 );
1618
1619 let outcome = poll_codex_profile(directory.path(), environment).await;
1620
1621 assert_eq!(outcome.report.error, None);
1622 assert_eq!(
1623 outcome.report.five_hour_window().unwrap().remaining_percent,
1624 Some(75)
1625 );
1626 assert!(outcome.credentials_changed);
1629
1630 let messages = codex_request_log(&log);
1631 assert_eq!(messages.len(), 5);
1632 assert_eq!(messages[0]["method"], "initialize");
1633 assert_eq!(messages[1]["method"], "initialized");
1634 assert_eq!(messages[2]["method"], "account/read");
1635 assert_eq!(messages[2]["params"]["refreshToken"], true);
1636 assert_eq!(messages[3]["method"], "account/read");
1637 assert_eq!(messages[3]["params"]["refreshToken"], false);
1638 assert_eq!(messages[4]["method"], "account/rateLimits/read");
1639 }
1640
1641 #[cfg(unix)]
1642 #[tokio::test]
1643 async fn a_codex_login_far_from_expiry_is_polled_without_a_rotation() {
1644 let directory = tempfile::tempdir().unwrap();
1645 write_codex_auth(
1647 directory.path(),
1648 Duration::from_secs(10 * 3_600),
1649 Duration::from_secs(3_600),
1650 );
1651 let (environment, log) = fake_codex_app_server(
1652 directory.path(),
1653 r#"#!/bin/sh
1654read_and_log() {
1655 IFS= read -r line || exit 1
1656 printf '%s\n' "$line" >> "$CODEX_USAGE_TEST_LOG"
1657}
1658read_and_log
1659printf '%s\n' '{"id":1,"result":{}}'
1660read_and_log
1661read_and_log
1662printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt"}}}'
1663read_and_log
1664printf '%s\n' '{"id":3,"result":{"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":300}}}}'
1665"#,
1666 );
1667
1668 let outcome = poll_codex_profile(directory.path(), environment).await;
1669
1670 assert_eq!(outcome.report.error, None);
1671 assert!(!outcome.credentials_changed);
1672
1673 let messages = codex_request_log(&log);
1674 assert_eq!(messages.len(), 4);
1675 assert_eq!(messages[0]["method"], "initialize");
1676 assert_eq!(messages[1]["method"], "initialized");
1677 assert_eq!(messages[2]["params"]["refreshToken"], false);
1678 assert_eq!(messages[3]["method"], "account/rateLimits/read");
1679 }
1680
1681 #[cfg(unix)]
1682 #[tokio::test]
1683 async fn a_codex_app_server_without_the_refresh_flag_still_reports_quota() {
1684 let directory = tempfile::tempdir().unwrap();
1685 write_codex_auth(
1686 directory.path(),
1687 Duration::from_secs(600),
1688 Duration::from_secs(3_000),
1689 );
1690 let (environment, _log) = fake_codex_app_server(
1691 directory.path(),
1692 r#"#!/bin/sh
1693IFS= read -r line || exit 1
1694printf '%s\n' '{"id":1,"result":{}}'
1695IFS= read -r line || exit 1
1696IFS= read -r line || exit 1
1697printf '%s\n' '{"id":2,"error":{"code":-32601,"message":"unknown parameter"}}'
1698IFS= read -r line || exit 1
1699printf '%s\n' '{"id":3,"result":{"account":{"type":"chatgpt"}}}'
1700IFS= read -r line || exit 1
1701printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":{"usedPercent":40,"windowDurationMins":300}}}}'
1702"#,
1703 );
1704
1705 let outcome = poll_codex_profile(directory.path(), environment).await;
1706
1707 assert_eq!(outcome.report.error, None);
1708 assert_eq!(
1709 outcome.report.five_hour_window().unwrap().remaining_percent,
1710 Some(60)
1711 );
1712 }
1713
1714 #[test]
1715 fn a_codex_refresh_margin_is_an_hour_or_a_tenth_of_the_token_life() {
1716 let hour = 3_600_000;
1717 let now = 1_800_000_000_000;
1718 assert!(codex_login_needs_refresh(
1720 Some(now + hour / 2),
1721 Some(now - hour / 2),
1722 now
1723 ));
1724 assert!(!codex_login_needs_refresh(
1725 Some(now + 2 * hour),
1726 Some(now - hour),
1727 now
1728 ));
1729 assert!(codex_login_needs_refresh(
1731 Some(now + 3 * hour),
1732 Some(now - 40 * hour),
1733 now
1734 ));
1735 assert!(codex_login_needs_refresh(Some(now + hour / 2), None, now));
1737 assert!(!codex_login_needs_refresh(Some(now + 3 * hour), None, now));
1738 assert!(!codex_login_needs_refresh(None, Some(now - hour), now));
1740 }
1741
1742 #[tokio::test]
1743 async fn a_missing_codex_credential_file_asks_for_no_rotation() {
1744 let directory = tempfile::tempdir().unwrap();
1745 assert!(!codex_login_is_near_expiry(&directory.path().join("auth.json")).await);
1746 }
1747
1748 #[tokio::test]
1749 async fn an_unreachable_grok_reports_the_failure_instead_of_a_zero_reading() {
1750 let directory = tempfile::tempdir().unwrap();
1751
1752 let (outcome, _) = refresh_profile(
1753 QuotaRefreshRequest {
1754 profile_id: "grok".into(),
1755 harness: HarnessKind::Grok,
1756 source_home: directory.path().to_path_buf(),
1757 environment: BTreeMap::from([(
1758 "PATH".to_owned(),
1759 directory.path().to_string_lossy().into_owned(),
1760 )]),
1761 cwd: directory.path().to_path_buf(),
1762 },
1763 None,
1764 )
1765 .await;
1766 let report = outcome.report;
1767
1768 assert!(report.windows.is_empty());
1769 assert_eq!(
1770 report.error.as_deref(),
1771 Some("Grok Build executable not found")
1772 );
1773 }
1774
1775 #[tokio::test]
1776 async fn muse_quota_refresh_recovers_and_populates_dashboard_windows() {
1777 let directory = tempfile::tempdir().unwrap();
1778 let credentials = br#"{"providers":{"meta":{"access_token":"profile-token"}}}"#;
1779 std::fs::write(directory.path().join("auth.json"), credentials).unwrap();
1780 let rejected = Arc::new(std::sync::atomic::AtomicBool::new(true));
1781 let app = Router::new()
1782 .route(
1783 "/muse-code/key",
1784 post(
1785 |State(rejected): State<Arc<std::sync::atomic::AtomicBool>>,
1786 headers: HeaderMap,
1787 Json(body): Json<Value>| async move {
1788 assert_eq!(headers["authorization"], "Bearer profile-token");
1789 assert_eq!(body, serde_json::json!({"onboard": false}));
1790 if rejected.load(std::sync::atomic::Ordering::SeqCst) {
1791 return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({})));
1792 }
1793 (
1794 StatusCode::OK,
1795 Json(serde_json::json!({
1796 "api_key": "must-not-be-persisted",
1797 "subs_usage": {
1798 "weekly": {"used_percent": 1, "resets_at": 1789344000},
1799 "window": {
1800 "used_percent": 3,
1801 "window_duration_mins": 300,
1802 "resets_at": 1788890595
1803 }
1804 }
1805 })),
1806 )
1807 },
1808 ),
1809 )
1810 .with_state(rejected.clone());
1811 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1812 let address = listener.local_addr().unwrap();
1813 let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1814 let request = QuotaRefreshRequest {
1815 profile_id: "muse".into(),
1816 harness: HarnessKind::Muse,
1817 source_home: directory.path().to_path_buf(),
1818 environment: BTreeMap::from([(
1819 "TBH_MINT_BASE_URL".into(),
1820 format!("http://{address}"),
1821 )]),
1822 cwd: directory.path().to_path_buf(),
1823 };
1824 let mut manager = QuotaManager::default();
1825 manager
1826 .refresh_profiles(vec![request.clone()], |_| async {})
1827 .await;
1828 assert!(manager.reports()["muse"].error.is_some());
1829 rejected.store(false, std::sync::atomic::Ordering::SeqCst);
1830 manager
1831 .refresh_profiles(vec![request], |outcome| async move {
1832 assert!(!outcome.credentials_changed);
1833 })
1834 .await;
1835 let report = &manager.reports()["muse"];
1836 assert_eq!(report.error, None);
1837 assert_eq!(report.extra, None);
1838 assert_eq!(report.weekly_window().unwrap().remaining_percent, Some(99));
1839 assert_eq!(
1840 report.five_hour_window().unwrap().remaining_percent,
1841 Some(97)
1842 );
1843 assert_eq!(
1844 report.weekly_window().unwrap().resets_at_epoch_seconds,
1845 Some(1789344000)
1846 );
1847 assert!(report.weekly_window().unwrap().resets.is_some());
1848 assert!(report.compact().contains("Week 99% left"));
1849 assert_eq!(
1850 std::fs::read(directory.path().join("auth.json")).unwrap(),
1851 credentials
1852 );
1853 manager.shutdown().await;
1854 server.abort();
1855 assert!(server.await.unwrap_err().is_cancelled());
1856 }
1857
1858 #[tokio::test]
1859 async fn deepseek_reports_api_instead_of_inventing_quota() {
1860 let directory = tempfile::tempdir().unwrap();
1861 let (outcome, _) = refresh_profile(
1862 QuotaRefreshRequest {
1863 profile_id: "deepseek".into(),
1864 harness: HarnessKind::Deepseek,
1865 source_home: directory.path().to_path_buf(),
1866 environment: BTreeMap::new(),
1867 cwd: directory.path().to_path_buf(),
1868 },
1869 None,
1870 )
1871 .await;
1872
1873 assert!(outcome.report.windows.is_empty());
1874 assert_eq!(outcome.report.error, None);
1875 assert_eq!(outcome.report.extra.as_deref(), Some(API_LABEL));
1876 assert_eq!(outcome.report.compact(), API_LABEL);
1877 }
1878
1879 #[tokio::test]
1880 async fn expired_claude_credentials_report_login_expired() {
1881 let directory = tempfile::tempdir().unwrap();
1882 std::fs::write(
1883 directory.path().join(".credentials.json"),
1884 serde_json::to_vec(&serde_json::json!({
1885 "claudeAiOauth": {
1886 "accessToken": "sk-ant-oat01-expired",
1887 "expiresAt": 1,
1888 }
1889 }))
1890 .unwrap(),
1891 )
1892 .unwrap();
1893
1894 let (outcome, _) = refresh_profile(
1895 QuotaRefreshRequest {
1896 profile_id: "claude2".into(),
1897 harness: HarnessKind::Claude,
1898 source_home: directory.path().to_path_buf(),
1899 environment: BTreeMap::new(),
1900 cwd: directory.path().to_path_buf(),
1901 },
1902 None,
1903 )
1904 .await;
1905 let report = outcome.report;
1906
1907 assert!(report.windows.is_empty());
1908 assert_eq!(report.error.as_deref(), Some(claude_usage::LOGIN_EXPIRED));
1909 assert_eq!(report.compact(), claude_usage::LOGIN_EXPIRED);
1910 }
1911
1912 #[test]
1913 fn a_monthly_window_shares_the_long_window_column_with_a_weekly_one() {
1914 for label in ["Week", "Month"] {
1915 let report = ProfileQuota {
1916 profile_id: "grok".into(),
1917 harness: HarnessKind::Grok,
1918 windows: vec![QuotaWindow {
1919 label: label.into(),
1920 remaining_percent: Some(60),
1921 used: None,
1922 limit: None,
1923 resets: None,
1924 resets_at_epoch_seconds: None,
1925 }],
1926 extra: None,
1927 error: None,
1928 refreshed_at_epoch_seconds: 0,
1929 };
1930
1931 assert!(report.weekly_window().is_some(), "{label}");
1932 assert_eq!(report.compact(), format!("{label} 60% left"));
1933 }
1934 }
1935
1936 #[test]
1937 fn kimi_uses_percent_left_and_hides_a_short_window_on_sustainable_pace() {
1938 let report = ProfileQuota {
1939 profile_id: "kimi".into(),
1940 harness: HarnessKind::Kimi,
1941 windows: vec![
1942 QuotaWindow {
1943 label: "Week".into(),
1944 remaining_percent: Some(94),
1945 used: Some(6),
1946 limit: Some(100),
1947 resets: Some("12:22 Aug 18".into()),
1948 resets_at_epoch_seconds: Some(604_800),
1949 },
1950 QuotaWindow {
1951 label: "5H".into(),
1952 remaining_percent: Some(97),
1953 used: Some(3),
1954 limit: Some(100),
1955 resets: Some("10:22 Aug 13".into()),
1956 resets_at_epoch_seconds: Some(18_000),
1957 },
1958 ],
1959 extra: None,
1960 error: None,
1961 refreshed_at_epoch_seconds: 3_600,
1962 };
1963
1964 assert_eq!(report.compact(), "Week 94% left, resets 12:22 Aug 18");
1965 }
1966
1967 #[test]
1968 fn short_window_is_shown_only_when_burn_rate_projects_early_exhaustion() {
1969 let window = QuotaWindow {
1970 label: "5H".into(),
1971 remaining_percent: Some(70),
1972 used: None,
1973 limit: None,
1974 resets: Some("later".into()),
1975 resets_at_epoch_seconds: Some(14_400),
1976 };
1977 assert!(projects_exhaustion(&window, 0));
1978
1979 let sustainable = QuotaWindow {
1980 remaining_percent: Some(80),
1981 ..window
1982 };
1983 assert!(!projects_exhaustion(&sustainable, 0));
1984 }
1985
1986 #[derive(Clone, Default)]
1987 struct KimiServerState {
1988 refresh_forms: Arc<Mutex<Vec<String>>>,
1989 }
1990
1991 async fn test_kimi_usage(headers: HeaderMap) -> (StatusCode, Json<Value>) {
1992 let accepted = headers
1993 .get(reqwest::header::AUTHORIZATION)
1994 .and_then(|value| value.to_str().ok())
1995 == Some("Bearer fresh-access");
1996 if accepted {
1997 (
1998 StatusCode::OK,
1999 Json(serde_json::json!({
2000 "usage": {"name": "Weekly", "used": 1, "limit": 100}
2001 })),
2002 )
2003 } else {
2004 (StatusCode::UNAUTHORIZED, Json(serde_json::json!({})))
2005 }
2006 }
2007
2008 async fn test_kimi_refresh(State(state): State<KimiServerState>, body: Bytes) -> Json<Value> {
2009 state
2010 .refresh_forms
2011 .lock()
2012 .unwrap()
2013 .push(String::from_utf8(body.to_vec()).unwrap());
2014 Json(serde_json::json!({
2015 "access_token": "fresh-access",
2016 "refresh_token": "fresh-refresh",
2017 "expires_in": 900,
2018 "scope": "kimi-code",
2019 "token_type": "Bearer"
2020 }))
2021 }
2022
2023 #[tokio::test]
2024 async fn kimi_quota_refreshes_after_unauthorized_and_retries() {
2025 let state = KimiServerState::default();
2026 let app = Router::new()
2027 .route("/coding/v1/usages", get(test_kimi_usage))
2028 .route("/api/oauth/token", post(test_kimi_refresh))
2029 .with_state(state.clone());
2030 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2031 let address = listener.local_addr().unwrap();
2032 let server = tokio::spawn(async move {
2033 axum::serve(listener, app).await.unwrap();
2034 });
2035
2036 let home = tempfile::tempdir().unwrap();
2037 let credentials_path = home.path().join("credentials/kimi-code.json");
2038 tokio::fs::create_dir_all(credentials_path.parent().unwrap())
2039 .await
2040 .unwrap();
2041 let future_expiry = SystemTime::now()
2042 .duration_since(SystemTime::UNIX_EPOCH)
2043 .unwrap()
2044 .as_secs()
2045 + 3_600;
2046 tokio::fs::write(
2047 &credentials_path,
2048 serde_json::to_vec(&serde_json::json!({
2049 "access_token": "rejected-access",
2050 "refresh_token": "old-refresh",
2051 "expires_at": future_expiry,
2052 "scope": "kimi-code",
2053 "token_type": "Bearer",
2054 "expires_in": 900
2055 }))
2056 .unwrap(),
2057 )
2058 .await
2059 .unwrap();
2060 let endpoint = format!("http://{address}");
2061 let environment = HashMap::from([
2062 ("KIMI_CODE_BASE_URL".into(), format!("{endpoint}/coding/v1")),
2063 ("KIMI_CODE_OAUTH_HOST".into(), endpoint),
2064 ]);
2065
2066 let (windows, _) = query_kimi(home.path(), &environment).await.unwrap();
2067
2068 assert_eq!(windows[0].used, Some(1));
2069 let form = {
2070 let forms = state.refresh_forms.lock().unwrap();
2071 assert_eq!(forms.len(), 1);
2072 url::form_urlencoded::parse(forms[0].as_bytes())
2073 .into_owned()
2074 .collect::<HashMap<_, _>>()
2075 };
2076 assert_eq!(
2077 form.get("grant_type").map(String::as_str),
2078 Some("refresh_token")
2079 );
2080 assert_eq!(
2081 form.get("refresh_token").map(String::as_str),
2082 Some("old-refresh")
2083 );
2084 let saved = read_kimi_credentials(&credentials_path).await.unwrap();
2085 assert_eq!(saved.access_token, "fresh-access");
2086 assert_eq!(saved.refresh_token, "fresh-refresh");
2087 assert!(!home.path().join("oauth/kimi-code.lock").exists());
2088 server.abort();
2089 }
2090
2091 fn age_kimi_lock(path: &Path, age: Duration) {
2094 touch_kimi_lock(path, SystemTime::now() - age).expect("backdate lock directory");
2095 }
2096
2097 #[tokio::test]
2098 async fn a_kimi_refresh_lock_left_by_a_crashed_holder_is_broken_and_reacquired() {
2099 let home = tempfile::tempdir().unwrap();
2100 let lock = home.path().join("oauth/kimi-code.lock");
2101 std::fs::create_dir_all(&lock).unwrap();
2102 age_kimi_lock(&lock, KIMI_LOCK_STALE_AFTER + Duration::from_secs(60));
2103
2104 let started = std::time::Instant::now();
2105 let held = KimiRefreshLock::acquire_within(home.path(), Duration::from_secs(10))
2106 .await
2107 .expect("an orphaned lock must not block a refresh");
2108 let waited = started.elapsed();
2109
2110 assert!(
2111 waited < Duration::from_secs(5),
2112 "acquisition waited {waited:?}"
2113 );
2114 held.release().await.expect("release an uncontested lock");
2115 assert!(!lock.exists(), "the released lock must be gone");
2116 }
2117
2118 #[tokio::test]
2119 async fn a_heartbeating_kimi_refresh_lock_is_not_broken_by_a_waiter() {
2120 let home = tempfile::tempdir().unwrap();
2121 let lock = home.path().join("oauth/kimi-code.lock");
2122 std::fs::create_dir_all(&lock).unwrap();
2123
2124 let error = KimiRefreshLock::acquire_within(home.path(), Duration::from_millis(600))
2125 .await
2126 .err()
2127 .expect("a lock with a live holder must be waited out, not stolen");
2128
2129 assert!(
2130 error.to_string().contains("kimi-code.lock"),
2131 "the timeout must name the lock: {error}"
2132 );
2133 assert!(lock.exists(), "a live holder's lock must survive a waiter");
2134 }
2135
2136 #[tokio::test]
2140 async fn a_held_kimi_lock_republishes_its_mtime_several_times_per_cli_break_window() {
2141 let home = tempfile::tempdir().unwrap();
2142 let held = KimiRefreshLock::acquire(home.path()).await.unwrap();
2143 let lock = home.path().join("oauth/kimi-code.lock");
2144
2145 let watched = KIMI_CLI_LOCK_STALE_AFTER / 2;
2149 let deadline = tokio::time::Instant::now() + watched;
2150 let mut published = vec![kimi_lock_mtime(&lock).unwrap().expect("the created lock")];
2151 while tokio::time::Instant::now() < deadline {
2152 tokio::time::sleep(Duration::from_millis(50)).await;
2153 let observed = kimi_lock_mtime(&lock).unwrap().expect("a held lock");
2154 if published.last() != Some(&observed) {
2155 published.push(observed);
2156 }
2157 assert_eq!(
2158 std::fs::read_dir(&lock).unwrap().count(),
2159 0,
2160 "the CLI releases this lock with a plain rmdir, so it must stay empty"
2161 );
2162 }
2163
2164 assert!(
2165 published.len() >= 3,
2166 "the lock's modification time moved {} times in {watched:?}; the Kimi Code CLI breaks a lock after {KIMI_CLI_LOCK_STALE_AFTER:?} without a beat",
2167 published.len() - 1
2168 );
2169 held.release().await.expect("release an uncontested lock");
2170 }
2171
2172 #[tokio::test]
2173 async fn a_stolen_kimi_refresh_lock_is_left_to_its_new_holder_and_fails_the_refresh() {
2174 let home = tempfile::tempdir().unwrap();
2175 let held = KimiRefreshLock::acquire(home.path()).await.unwrap();
2176 let lock = home.path().join("oauth/kimi-code.lock");
2177
2178 std::fs::remove_dir(&lock).unwrap();
2183 std::fs::create_dir(&lock).unwrap();
2184 let thief = SystemTime::now() + Duration::from_secs(30);
2185 touch_kimi_lock(&lock, thief).unwrap();
2186
2187 tokio::time::sleep(2 * KIMI_LOCK_HEARTBEAT_INTERVAL + Duration::from_millis(400)).await;
2190 let observed = kimi_lock_mtime(&lock)
2191 .unwrap()
2192 .expect("the new holder's lock");
2193 assert!(
2194 observed.duration_since(SystemTime::now()).is_ok(),
2195 "Mjolnir kept heartbeating a lock it no longer holds: the directory carries {} instead of the new holder's {}",
2196 epoch_label(observed),
2197 epoch_label(thief)
2198 );
2199
2200 let error = held
2201 .release()
2202 .await
2203 .expect_err("a refresh that lost its lock must fail loudly");
2204 let message = error.to_string();
2205 assert!(message.contains("kimi-code.lock"), "{message}");
2206 assert!(message.contains("another process took it"), "{message}");
2207 assert!(
2208 lock.exists(),
2209 "Mjolnir must not remove a lock another holder owns"
2210 );
2211 }
2212
2213 #[tokio::test]
2214 async fn a_kimi_refresh_lock_removed_underneath_hel_fails_the_refresh() {
2215 let home = tempfile::tempdir().unwrap();
2216 let held = KimiRefreshLock::acquire(home.path()).await.unwrap();
2217 let lock = home.path().join("oauth/kimi-code.lock");
2218 std::fs::remove_dir(&lock).unwrap();
2219
2220 let error = held
2221 .release()
2222 .await
2223 .expect_err("a refresh that lost its lock must fail loudly");
2224
2225 assert!(
2226 error.to_string().contains("another process removed it"),
2227 "{error}"
2228 );
2229 assert!(!lock.exists(), "Mjolnir must not recreate a lock it lost");
2230 }
2231
2232 fn kimi_pair(access: &str, refresh: &str, expires_at: i64) -> KimiCredentials {
2233 KimiCredentials {
2234 access_token: access.into(),
2235 refresh_token: refresh.into(),
2236 expires_at,
2237 scope: "kimi-code".into(),
2238 token_type: "Bearer".into(),
2239 expires_in: 900,
2240 }
2241 }
2242
2243 fn a_stolen_lock() -> KimiLockLoss {
2244 KimiLockLoss::Stolen {
2245 published: SystemTime::UNIX_EPOCH,
2246 observed: SystemTime::UNIX_EPOCH + Duration::from_secs(30),
2247 }
2248 }
2249
2250 #[test]
2251 fn a_lock_still_held_saves_the_refreshed_pair_and_releases() {
2252 let active = kimi_pair("spent-access", "spent-refresh", 100);
2253
2254 assert_eq!(
2255 decide_kimi_refresh_persist(&Ok(SystemTime::now()), None, &active),
2256 KimiRefreshPersist::Save
2257 );
2258 }
2259
2260 #[test]
2261 fn an_unprovable_ownership_check_still_saves_the_refreshed_pair() {
2262 let active = kimi_pair("spent-access", "spent-refresh", 100);
2263 let unproven = Err(KimiLockLoss::Unproven("stat failed".into()));
2264
2265 assert_eq!(
2266 decide_kimi_refresh_persist(&unproven, None, &active),
2267 KimiRefreshPersist::Save,
2268 "a failed stat proves nothing about the lock and must not discard valid tokens"
2269 );
2270 }
2271
2272 #[test]
2273 fn a_stolen_lock_adopts_the_thiefs_newer_credentials() {
2274 let active = kimi_pair("spent-access", "spent-refresh", 100);
2275 let thiefs = kimi_pair("thief-access", "thief-refresh", 900);
2276
2277 assert_eq!(
2278 decide_kimi_refresh_persist(&Err(a_stolen_lock()), Some(&thiefs), &active),
2279 KimiRefreshPersist::Adopt {
2280 access_token: "thief-access".into(),
2281 loss: a_stolen_lock(),
2282 }
2283 );
2284 }
2285
2286 #[test]
2287 fn a_removed_lock_adopts_the_newer_credentials_left_on_disk() {
2288 let active = kimi_pair("spent-access", "spent-refresh", 100);
2289 let thiefs = kimi_pair("thief-access", "thief-refresh", 900);
2290
2291 assert_eq!(
2292 decide_kimi_refresh_persist(&Err(KimiLockLoss::Gone), Some(&thiefs), &active),
2293 KimiRefreshPersist::Adopt {
2294 access_token: "thief-access".into(),
2295 loss: KimiLockLoss::Gone,
2296 }
2297 );
2298 }
2299
2300 #[test]
2303 fn a_stolen_lock_saves_hels_pair_over_the_spent_one_on_disk() {
2304 let active = kimi_pair("spent-access", "spent-refresh", 100);
2305 let on_disk = active.clone();
2306
2307 assert_eq!(
2308 decide_kimi_refresh_persist(&Err(a_stolen_lock()), Some(&on_disk), &active),
2309 KimiRefreshPersist::SaveContested(a_stolen_lock())
2310 );
2311 }
2312
2313 #[test]
2314 fn an_unreadable_credentials_file_after_a_lost_lock_saves_hels_pair() {
2315 let active = kimi_pair("spent-access", "spent-refresh", 100);
2316
2317 assert_eq!(
2318 decide_kimi_refresh_persist(&Err(KimiLockLoss::Gone), None, &active),
2319 KimiRefreshPersist::SaveContested(KimiLockLoss::Gone),
2320 "nothing readable is newer, so the refreshed pair is the only live one"
2321 );
2322 }
2323
2324 #[test]
2325 fn any_rotated_field_marks_the_disk_pair_as_the_other_refreshers() {
2326 let active = kimi_pair("spent-access", "spent-refresh", 100);
2327 let rotations = [
2328 kimi_pair("other-access", "spent-refresh", 100),
2329 kimi_pair("spent-access", "other-refresh", 100),
2330 kimi_pair("spent-access", "spent-refresh", 900),
2331 ];
2332
2333 for on_disk in &rotations {
2334 assert!(
2335 matches!(
2336 decide_kimi_refresh_persist(&Err(a_stolen_lock()), Some(on_disk), &active),
2337 KimiRefreshPersist::Adopt { .. }
2338 ),
2339 "{on_disk:?} is a rotated pair, not the spent one"
2340 );
2341 }
2342 }
2343
2344 #[test]
2345 fn a_disk_pair_that_differs_only_in_description_is_still_the_spent_one() {
2346 let active = kimi_pair("spent-access", "spent-refresh", 100);
2347 let on_disk = KimiCredentials {
2348 scope: "kimi-code extra".into(),
2349 token_type: "bearer".into(),
2350 expires_in: 1_800,
2351 ..active.clone()
2352 };
2353
2354 assert_eq!(
2355 decide_kimi_refresh_persist(&Err(a_stolen_lock()), Some(&on_disk), &active),
2356 KimiRefreshPersist::SaveContested(a_stolen_lock())
2357 );
2358 }
2359
2360 #[derive(Clone)]
2361 struct KimiThiefState {
2362 home: std::path::PathBuf,
2363 winner: Option<Value>,
2366 }
2367
2368 async fn test_kimi_refresh_stealing_the_lock(
2372 State(state): State<KimiThiefState>,
2373 _body: Bytes,
2374 ) -> Json<Value> {
2375 let lock = state.home.join("oauth/kimi-code.lock");
2376 std::fs::remove_dir(&lock).unwrap();
2377 std::fs::create_dir(&lock).unwrap();
2378 touch_kimi_lock(&lock, SystemTime::now() + Duration::from_secs(30)).unwrap();
2379 if let Some(winner) = &state.winner {
2380 std::fs::write(
2381 state.home.join("credentials/kimi-code.json"),
2382 serde_json::to_vec(winner).unwrap(),
2383 )
2384 .unwrap();
2385 }
2386 Json(serde_json::json!({
2387 "access_token": "hel-access",
2388 "refresh_token": "hel-refresh",
2389 "expires_in": 900,
2390 "scope": "kimi-code",
2391 "token_type": "Bearer"
2392 }))
2393 }
2394
2395 async fn refresh_against_a_lock_thief(
2398 home: &Path,
2399 winner: Option<Value>,
2400 ) -> (Result<String>, std::path::PathBuf) {
2401 let credentials_path = home.join("credentials/kimi-code.json");
2402 tokio::fs::create_dir_all(credentials_path.parent().unwrap())
2403 .await
2404 .unwrap();
2405 let soon = SystemTime::now()
2406 .duration_since(SystemTime::UNIX_EPOCH)
2407 .unwrap()
2408 .as_secs() as i64
2409 + 10;
2410 tokio::fs::write(
2411 &credentials_path,
2412 serde_json::to_vec(&serde_json::json!({
2413 "access_token": "spent-access",
2414 "refresh_token": "spent-refresh",
2415 "expires_at": soon,
2416 "scope": "kimi-code",
2417 "token_type": "Bearer",
2418 "expires_in": 900
2419 }))
2420 .unwrap(),
2421 )
2422 .await
2423 .unwrap();
2424
2425 let app = Router::new()
2426 .route(
2427 "/api/oauth/token",
2428 post(test_kimi_refresh_stealing_the_lock),
2429 )
2430 .with_state(KimiThiefState {
2431 home: home.to_path_buf(),
2432 winner,
2433 });
2434 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2435 let address = listener.local_addr().unwrap();
2436 let server = tokio::spawn(async move {
2437 axum::serve(listener, app).await.unwrap();
2438 });
2439 let environment =
2440 HashMap::from([("KIMI_CODE_OAUTH_HOST".into(), format!("http://{address}"))]);
2441
2442 let token = ensure_fresh_kimi_token(
2443 &reqwest::Client::new(),
2444 home,
2445 &credentials_path,
2446 &environment,
2447 false,
2448 None,
2449 )
2450 .await;
2451 server.abort();
2452 (token, credentials_path)
2453 }
2454
2455 #[tokio::test]
2456 async fn a_refresh_that_loses_its_lock_returns_the_winners_stored_token() {
2457 let home = tempfile::tempdir().unwrap();
2458 let winner = serde_json::json!({
2459 "access_token": "winner-access",
2460 "refresh_token": "winner-refresh",
2461 "expires_at": 4_102_444_800i64,
2462 "scope": "kimi-code",
2463 "token_type": "Bearer",
2464 "expires_in": 900
2465 });
2466
2467 let (token, credentials_path) =
2468 refresh_against_a_lock_thief(home.path(), Some(winner)).await;
2469
2470 assert_eq!(
2471 token.unwrap(),
2472 "winner-access",
2473 "a contested lock must not fail a refresh when a live token exists"
2474 );
2475 let saved = read_kimi_credentials(&credentials_path).await.unwrap();
2476 assert_eq!(
2477 saved.access_token, "winner-access",
2478 "Mjolnir must not clobber the credentials the lock's new holder stored"
2479 );
2480 assert!(
2481 home.path().join("oauth/kimi-code.lock").exists(),
2482 "Mjolnir must not remove a lock another holder owns"
2483 );
2484 }
2485
2486 #[tokio::test]
2490 async fn a_refresh_that_loses_its_lock_saves_its_pair_over_the_spent_one() {
2491 let home = tempfile::tempdir().unwrap();
2492
2493 let (token, credentials_path) = refresh_against_a_lock_thief(home.path(), None).await;
2494
2495 assert_eq!(token.unwrap(), "hel-access");
2496 let saved = read_kimi_credentials(&credentials_path).await.unwrap();
2497 assert_eq!(
2498 saved.access_token, "hel-access",
2499 "leaving the spent pair on disk would force a `kimi login`"
2500 );
2501 assert_eq!(saved.refresh_token, "hel-refresh");
2502 assert!(
2503 home.path().join("oauth/kimi-code.lock").exists(),
2504 "Mjolnir must not remove a lock another holder owns"
2505 );
2506 }
2507
2508 #[cfg(unix)]
2511 fn process_is_gone(pid: i32) -> bool {
2512 unsafe { libc::kill(pid, 0) != 0 }
2514 }
2515
2516 #[cfg(unix)]
2517 #[tokio::test]
2518 async fn dropping_a_profile_from_the_configuration_stops_its_codex_quota_client() {
2519 use std::os::unix::fs::PermissionsExt;
2520
2521 let directory = tempfile::tempdir().unwrap();
2522 let executable = directory.path().join("codex");
2523 let pid_file = directory.path().join("codex.pid");
2524 std::fs::write(
2527 &executable,
2528 r#"#!/bin/sh
2529printf '%s\n' "$$" > "$CODEX_QUOTA_TEST_PID"
2530IFS= read -r line || exit 0
2531printf '%s\n' '{"id":1,"result":{}}'
2532IFS= read -r line || exit 0
2533IFS= read -r line || exit 0
2534printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt"}}}'
2535IFS= read -r line || exit 0
2536printf '%s\n' '{"id":3,"result":{"rateLimits":{"primary":{"usedPercent":25,"windowDurationMins":300}}}}'
2537while IFS= read -r line; do :; done
2538"#,
2539 )
2540 .unwrap();
2541 std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
2542 let request = QuotaRefreshRequest {
2543 profile_id: "codex-1".into(),
2544 harness: HarnessKind::Codex,
2545 source_home: directory.path().to_path_buf(),
2546 environment: BTreeMap::from([
2547 (
2548 "PATH".to_owned(),
2549 directory.path().to_string_lossy().into_owned(),
2550 ),
2551 (
2552 "CODEX_QUOTA_TEST_PID".to_owned(),
2553 pid_file.to_string_lossy().into_owned(),
2554 ),
2555 ]),
2556 cwd: directory.path().to_path_buf(),
2557 };
2558
2559 let mut quotas = QuotaManager::default();
2560 quotas.refresh_profiles(vec![request], |_| async {}).await;
2561
2562 assert_eq!(
2563 quotas.reports()["codex-1"].error,
2564 None,
2565 "the stand-in app-server must answer the quota query"
2566 );
2567 let pid = std::fs::read_to_string(&pid_file)
2568 .unwrap()
2569 .trim()
2570 .parse::<i32>()
2571 .unwrap();
2572 assert!(
2573 !process_is_gone(pid),
2574 "the app-server child is cached between refreshes"
2575 );
2576
2577 quotas.refresh_profiles(Vec::new(), |_| async {}).await;
2580
2581 assert!(
2582 process_is_gone(pid),
2583 "a profile removed from the configuration must not leave its `codex app-server` child running"
2584 );
2585 quotas.shutdown().await;
2586 }
2587
2588 #[test]
2589 fn reset_time_normalization_uses_24_hour_month_day_format() {
2590 let paris = FixedOffset::east_opt(2 * 3_600).expect("offset");
2591 let reset = paris
2592 .with_ymd_and_hms(2026, 6, 17, 16, 49, 0)
2593 .single()
2594 .expect("instant");
2595 assert_eq!(format_reset_label(reset), "16:49 Jun 17");
2596 assert_eq!(
2597 normalize_reset_text("Jun 17 at 4:49pm").as_deref(),
2598 Some("16:49 Jun 17")
2599 );
2600 }
2601
2602 #[test]
2603 fn reset_timestamp_accepts_seconds_and_milliseconds() {
2604 let seconds = 1_781_712_540_f64;
2605 assert_eq!(
2606 format_reset_local(seconds),
2607 format_reset_local(seconds * 1_000.0)
2608 );
2609 assert_eq!(
2610 format_reset_local(seconds),
2611 format_reset_local_seconds(seconds as i64)
2612 );
2613 }
2614
2615 #[test]
2616 fn time_only_reset_is_rendered_as_the_next_datetime() {
2617 let zone = FixedOffset::west_opt(5 * 3_600).expect("offset");
2618 let now = zone
2619 .with_ymd_and_hms(2026, 8, 10, 14, 0, 0)
2620 .single()
2621 .expect("now");
2622 assert_eq!(
2623 normalize_reset_at("3:30 PM (America/Chicago)", now)
2624 .map(format_reset_label)
2625 .as_deref(),
2626 Some("15:30 Aug 10")
2627 );
2628 assert_eq!(
2629 normalize_reset_at("at 1pm (America/Chicago)", now)
2630 .map(format_reset_label)
2631 .as_deref(),
2632 Some("13:00 Aug 11")
2633 );
2634 }
2635
2636 #[test]
2637 fn claude_comma_separated_reset_is_normalized() {
2638 let zone = FixedOffset::west_opt(5 * 3_600).expect("offset");
2639 let now = zone
2640 .with_ymd_and_hms(2026, 8, 11, 7, 0, 0)
2641 .single()
2642 .expect("now");
2643 assert_eq!(
2644 normalize_reset_at("Aug 14, 4am (America/Chicago)", now)
2645 .map(format_reset_label)
2646 .as_deref(),
2647 Some("04:00 Aug 14")
2648 );
2649 }
2650}