1use serde::Serialize;
7use std::sync::{Arc, Mutex, OnceLock};
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9use tokio::io::{AsyncReadExt, AsyncWriteExt};
10use tokio::net::TcpListener;
11use tokio::sync::watch;
12use tokio_util::sync::CancellationToken;
13
14const DEFAULT_AUTH_URL: &str = "https://openrouter.ai/auth";
15const DEFAULT_EXCHANGE_URL: &str = "https://openrouter.ai/api/v1/auth/keys";
16pub const TEST_AUTHORIZATION_BASE_URL_ENV: &str = "OPENROUTER_AUTHORIZATION_BASE_URL";
17pub const TEST_EXCHANGE_URL_ENV: &str = "OPENROUTER_EXCHANGE_URL";
18const DEFAULT_TIMEOUT_SECS: u64 = 300;
19const MAX_TIMEOUT_SECS: u64 = 900;
20
21#[derive(Debug, Clone, Serialize)]
22pub struct PendingStatus {
23 pub flow_id: String,
24 pub deadline_unix_ms: u64,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct TerminalResult {
29 pub flow_id: String,
30 pub kind: String,
31 pub message: String,
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub struct Status {
36 pub authority_generation: u64,
37 pub state: String,
38 pub effective_source: String,
39 pub pasted_key_exists: bool,
40 pub oauth_key_exists: bool,
41 pub pending: Option<PendingStatus>,
42 pub last_result: Option<TerminalResult>,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct StartResult {
47 pub authority_generation: u64,
48 pub authorize_url: String,
49 pub flow_id: String,
50 pub deadline_unix_ms: u64,
51}
52
53struct PendingFlow {
54 flow_id: String,
55 action_generation: u64,
56 deadline_unix_ms: u64,
57 cancel: CancellationToken,
58}
59
60#[derive(Clone, Copy, Default)]
61struct CredentialPresence {
62 environment: bool,
63 pasted: bool,
64 oauth: bool,
65}
66
67struct Manager {
68 action_generation: u64,
69 pending: Option<PendingFlow>,
70 last_result: Option<TerminalResult>,
71 credentials: CredentialPresence,
72 credential_revision: u64,
73}
74
75impl Default for Manager {
76 fn default() -> Self {
77 let action_generation = SystemTime::now()
81 .duration_since(UNIX_EPOCH)
82 .unwrap_or_default()
83 .as_nanos()
84 .try_into()
85 .unwrap_or(u64::MAX / 2);
86 Self {
87 action_generation,
88 pending: None,
89 last_result: None,
90 credentials: credential_presence_from_vault(),
95 credential_revision: 0,
96 }
97 }
98}
99
100impl Manager {
101 fn advance_action_generation(&mut self) -> u64 {
102 self.action_generation = self
103 .action_generation
104 .checked_add(1)
105 .expect("OpenRouter authority generation exhausted");
106 self.action_generation
107 }
108
109 fn set_pasted_exists(&mut self, exists: bool) {
110 if self.credentials.pasted != exists {
111 self.credentials.pasted = exists;
112 self.credential_revision = self.credential_revision.wrapping_add(1);
113 }
114 }
115
116 fn set_oauth_exists(&mut self, exists: bool) {
117 if self.credentials.oauth != exists {
118 self.credentials.oauth = exists;
119 self.credential_revision = self.credential_revision.wrapping_add(1);
120 }
121 }
122
123 fn replace_credential_presence(&mut self, presence: CredentialPresence) {
124 if self.credentials.environment != presence.environment
125 || self.credentials.pasted != presence.pasted
126 || self.credentials.oauth != presence.oauth
127 {
128 self.credentials = presence;
129 self.credential_revision = self.credential_revision.wrapping_add(1);
130 }
131 }
132}
133
134#[derive(Clone, Default)]
138struct ManagerTransactionHook {
139 #[cfg(test)]
140 before_lock: Option<Arc<std::sync::Barrier>>,
141 #[cfg(test)]
142 inside_lock_entered: Option<Arc<std::sync::Barrier>>,
143 #[cfg(test)]
144 inside_lock_release: Option<Arc<std::sync::Barrier>>,
145 #[cfg(test)]
146 after_reserve_entered: Option<Arc<std::sync::Barrier>>,
147 #[cfg(test)]
148 after_reserve_release: Option<Arc<std::sync::Barrier>>,
149 #[cfg(test)]
150 before_return_entered: Option<Arc<std::sync::Barrier>>,
151 #[cfg(test)]
152 before_return_release: Option<Arc<std::sync::Barrier>>,
153}
154
155impl ManagerTransactionHook {
156 fn before_lock(&self) {
157 #[cfg(test)]
158 if let Some(barrier) = &self.before_lock {
159 barrier.wait();
160 }
161 }
162
163 fn inside_lock(&self) {
164 #[cfg(test)]
165 {
166 if let Some(barrier) = &self.inside_lock_entered {
167 barrier.wait();
168 }
169 if let Some(barrier) = &self.inside_lock_release {
170 barrier.wait();
171 }
172 }
173 }
174
175 fn after_reserve(&self) {
176 #[cfg(test)]
177 {
178 if let Some(barrier) = &self.after_reserve_entered {
179 barrier.wait();
180 }
181 if let Some(barrier) = &self.after_reserve_release {
182 barrier.wait();
183 }
184 }
185 }
186
187 fn before_return(&self) {
188 #[cfg(test)]
189 {
190 if let Some(barrier) = &self.before_return_entered {
191 barrier.wait();
192 }
193 if let Some(barrier) = &self.before_return_release {
194 barrier.wait();
195 }
196 }
197 }
198}
199
200#[derive(Clone, Default)]
205struct DisconnectHook {
206 #[cfg(test)]
207 before_delete_entered: Option<Arc<std::sync::Barrier>>,
208 #[cfg(test)]
209 before_delete_release: Option<Arc<std::sync::Barrier>>,
210 #[cfg(test)]
211 forced_delete_error: Option<String>,
212}
213
214impl DisconnectHook {
215 fn before_delete(&self) {
216 #[cfg(test)]
217 {
218 if let Some(barrier) = &self.before_delete_entered {
219 barrier.wait();
220 }
221 if let Some(barrier) = &self.before_delete_release {
222 barrier.wait();
223 }
224 }
225 }
226
227 fn delete_oauth_key(&self) -> Result<(), String> {
228 self.before_delete();
229 #[cfg(test)]
230 if let Some(error) = &self.forced_delete_error {
231 return Err(error.clone());
232 }
233 delete_oauth_key()
234 }
235}
236
237#[derive(Clone, Default)]
238struct CredentialFinishHook {
239 #[cfg(test)]
240 before_gate_entered: Option<Arc<std::sync::Barrier>>,
241 #[cfg(test)]
242 after_gate_entered: Option<Arc<std::sync::Barrier>>,
243 #[cfg(test)]
244 after_gate_release: Option<Arc<std::sync::Barrier>>,
245}
246
247impl CredentialFinishHook {
248 fn before_gate(&self) {
249 #[cfg(test)]
250 if let Some(barrier) = &self.before_gate_entered {
251 barrier.wait();
252 }
253 }
254
255 fn after_gate(&self) {
256 #[cfg(test)]
257 {
258 if let Some(barrier) = &self.after_gate_entered {
259 barrier.wait();
260 }
261 if let Some(barrier) = &self.after_gate_release {
262 barrier.wait();
263 }
264 }
265 }
266}
267
268#[derive(Clone, Default)]
269struct PastedMutationHook {
270 #[cfg(test)]
271 before_gate_entered: Option<Arc<std::sync::Barrier>>,
272}
273
274impl PastedMutationHook {
275 fn before_gate(&self) {
276 #[cfg(test)]
277 if let Some(barrier) = &self.before_gate_entered {
278 barrier.wait();
279 }
280 }
281}
282
283fn manager() -> &'static Arc<Mutex<Manager>> {
284 static MANAGER: OnceLock<Arc<Mutex<Manager>>> = OnceLock::new();
285 MANAGER.get_or_init(|| Arc::new(Mutex::new(Manager::default())))
286}
287
288fn credential_io_gate() -> &'static tokio::sync::Mutex<()> {
289 static GATE: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
290 GATE.get_or_init(|| tokio::sync::Mutex::new(()))
291}
292
293fn authority_updates() -> &'static watch::Sender<Option<Status>> {
294 static UPDATES: OnceLock<watch::Sender<Option<Status>>> = OnceLock::new();
295 UPDATES.get_or_init(|| watch::channel(None).0)
296}
297
298pub fn subscribe_authority_updates() -> watch::Receiver<Option<Status>> {
303 authority_updates().subscribe()
304}
305
306fn publish_status(status: &Status) {
307 authority_updates().send_replace(Some(status.clone()));
308}
309
310fn now_unix_ms() -> u64 {
311 SystemTime::now()
312 .duration_since(UNIX_EPOCH)
313 .unwrap_or_default()
314 .as_millis() as u64
315}
316
317fn terminal(flow_id: &str, kind: &str, message: &str) -> TerminalResult {
318 TerminalResult {
319 flow_id: flow_id.to_string(),
320 kind: kind.to_string(),
321 message: message.to_string(),
322 }
323}
324
325#[cfg(not(test))]
326fn oauth_key_exists() -> bool {
327 car_inference::openrouter::oauth_key_exists()
328}
329
330#[cfg(test)]
331fn test_oauth_slot() -> &'static Mutex<Option<String>> {
332 static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
333 SLOT.get_or_init(|| Mutex::new(None))
334}
335
336#[cfg(test)]
337fn oauth_key_exists() -> bool {
338 test_oauth_slot()
339 .lock()
340 .unwrap_or_else(|p| p.into_inner())
341 .is_some()
342}
343
344#[cfg(not(test))]
345fn store_oauth_key(key: &str) -> Result<(), String> {
346 car_inference::openrouter::store_oauth_credential(key).map_err(|error| error.to_string())
347}
348
349#[cfg(test)]
350fn store_oauth_key(key: &str) -> Result<(), String> {
351 *test_oauth_slot().lock().unwrap_or_else(|p| p.into_inner()) = Some(key.to_string());
352 Ok(())
353}
354
355#[cfg(not(test))]
356fn delete_oauth_key() -> Result<(), String> {
357 car_inference::openrouter::delete_oauth_credential().map_err(|error| error.to_string())
358}
359
360#[cfg(test)]
361fn delete_oauth_key() -> Result<(), String> {
362 *test_oauth_slot().lock().unwrap_or_else(|p| p.into_inner()) = None;
363 Ok(())
364}
365
366fn credential_presence_from_vault() -> CredentialPresence {
367 CredentialPresence {
368 environment: car_inference::openrouter::environment_key_exists(),
369 pasted: car_inference::openrouter::pasted_key_exists(),
370 oauth: oauth_key_exists(),
371 }
372}
373
374fn status_from_manager_with_presence(guard: &Manager, presence: CredentialPresence) -> Status {
375 let effective = if presence.environment {
376 Some(car_inference::openrouter::CredentialSource::Env)
377 } else if presence.pasted {
378 Some(car_inference::openrouter::CredentialSource::Pasted)
379 } else if presence.oauth {
380 Some(car_inference::openrouter::CredentialSource::Oauth)
381 } else {
382 None
383 };
384 let pending = guard.pending.as_ref().map(|flow| PendingStatus {
385 flow_id: flow.flow_id.clone(),
386 deadline_unix_ms: flow.deadline_unix_ms,
387 });
388 Status {
389 authority_generation: guard.action_generation,
390 state: if pending.is_some() {
391 "pending"
392 } else if effective.is_some() || presence.oauth {
393 "connected"
394 } else {
395 "idle"
396 }
397 .to_string(),
398 effective_source: effective
399 .map(|source| source.as_str().to_string())
400 .or_else(|| presence.oauth.then(|| "oauth".to_string()))
401 .unwrap_or_else(|| "none".to_string()),
402 pasted_key_exists: presence.pasted,
403 oauth_key_exists: presence.oauth,
404 pending,
405 last_result: guard.last_result.clone(),
406 }
407}
408
409fn status_from_manager(guard: &Manager) -> Status {
410 status_from_manager_with_presence(guard, guard.credentials)
411}
412
413pub fn status() -> Status {
414 let expected = {
419 let guard = manager().lock().unwrap_or_else(|p| p.into_inner());
420 (guard.action_generation, guard.credential_revision)
421 };
422 let observed = credential_presence_from_vault();
423 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
424 if (guard.action_generation, guard.credential_revision) == expected {
425 guard.replace_credential_presence(observed);
426 }
427 status_from_manager(&guard)
428}
429
430fn complete_pasted_change(reserved_generation: u64, exists_after_success: Option<bool>) -> Status {
431 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
432 let is_current = guard.action_generation == reserved_generation;
433 if is_current {
434 if let Some(exists) = exists_after_success {
435 guard.set_pasted_exists(exists);
436 }
437 }
438 let status = status_from_manager(&guard);
439 drop(guard);
440 if is_current {
441 publish_status(&status);
442 }
443 status
444}
445
446pub fn is_pending(flow_id: &str) -> bool {
447 manager()
448 .lock()
449 .unwrap_or_else(|p| p.into_inner())
450 .pending
451 .as_ref()
452 .is_some_and(|pending| pending.flow_id == flow_id)
453}
454
455pub fn terminal_status_for_flow(flow_id: &str) -> Option<Status> {
459 terminal_status_for_flow_with_hook(flow_id, ManagerTransactionHook::default())
460}
461
462fn terminal_status_for_flow_with_hook(
463 flow_id: &str,
464 hook: ManagerTransactionHook,
465) -> Option<Status> {
466 hook.before_lock();
467 let guard = manager().lock().unwrap_or_else(|p| p.into_inner());
468 let matches_terminal = guard.pending.is_none()
469 && guard
470 .last_result
471 .as_ref()
472 .is_some_and(|result| result.flow_id == flow_id);
473 if !matches_terminal {
474 return None;
475 }
476 hook.inside_lock();
477 Some(status_from_manager(&guard))
478}
479
480fn replace_pending_result(kind: &str, message: &str) -> Status {
481 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
482 guard.advance_action_generation();
483 if let Some(flow) = guard.pending.take() {
484 flow.cancel.cancel();
485 guard.last_result = Some(terminal(&flow.flow_id, kind, message));
486 }
487 let status = status_from_manager(&guard);
488 drop(guard);
489 publish_status(&status);
490 status
491}
492
493fn supersede_for_pasted_change() -> Status {
498 replace_pending_result(
499 "superseded",
500 "OpenRouter connect was superseded by a pasted-key change",
501 )
502}
503
504pub async fn mutate_pasted_credential<T, F>(
509 exists_on_success: bool,
510 operation: F,
511) -> (Result<T, String>, Status)
512where
513 F: FnOnce() -> Result<T, String>,
514{
515 mutate_pasted_credential_with_hook(exists_on_success, operation, PastedMutationHook::default())
516 .await
517}
518
519async fn mutate_pasted_credential_with_hook<T, F>(
520 exists_on_success: bool,
521 operation: F,
522 hook: PastedMutationHook,
523) -> (Result<T, String>, Status)
524where
525 F: FnOnce() -> Result<T, String>,
526{
527 hook.before_gate();
528 let _credential_io = credential_io_gate().lock().await;
529 let observed = credential_presence_from_vault();
530 manager()
531 .lock()
532 .unwrap_or_else(|p| p.into_inner())
533 .replace_credential_presence(observed);
534 let reserved = supersede_for_pasted_change();
535 let result = operation();
536 let final_status = complete_pasted_change(
537 reserved.authority_generation,
538 result.is_ok().then_some(exists_on_success),
539 );
540 (result, final_status)
541}
542
543pub fn cancel(flow_id: Option<&str>) -> Status {
544 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
545 let matches = guard
546 .pending
547 .as_ref()
548 .is_some_and(|flow| flow_id.is_none_or(|id| id == flow.flow_id));
549 if matches {
550 guard.advance_action_generation();
551 let flow = guard.pending.take().expect("checked above");
552 flow.cancel.cancel();
553 guard.last_result = Some(terminal(
554 &flow.flow_id,
555 "cancelled",
556 "OpenRouter connect was cancelled",
557 ));
558 let status = status_from_manager(&guard);
559 drop(guard);
560 publish_status(&status);
561 return status;
562 }
563 let status = status_from_manager(&guard);
564 drop(guard);
565 status
566}
567
568pub async fn disconnect() -> Result<Status, String> {
569 disconnect_with_hook(DisconnectHook::default()).await
570}
571
572async fn disconnect_with_hook(hook: DisconnectHook) -> Result<Status, String> {
573 let _credential_io = credential_io_gate().lock().await;
577 let observed = credential_presence_from_vault();
578 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
579 guard.replace_credential_presence(observed);
580 let reserved_generation = guard.advance_action_generation();
581 if let Some(flow) = guard.pending.take() {
582 flow.cancel.cancel();
583 guard.last_result = Some(terminal(
584 &flow.flow_id,
585 "cancelled",
586 "OpenRouter connect was cancelled",
587 ));
588 }
589 let reserved_status = status_from_manager(&guard);
590 drop(guard);
591 publish_status(&reserved_status);
592
593 let delete_result = hook
594 .delete_oauth_key()
595 .map_err(|error| format!("remove OpenRouter OAuth credential: {error}"));
596 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
597 let is_current = guard.action_generation == reserved_generation;
598 if is_current && delete_result.is_ok() {
599 guard.set_oauth_exists(false);
600 }
601 let final_status = status_from_manager(&guard);
602 drop(guard);
603 if is_current {
604 publish_status(&final_status);
605 }
606 delete_result.map(|()| final_status)
607}
608
609pub async fn start(
610 authorization_base_url: Option<&str>,
611 exchange_url: Option<&str>,
612 timeout_seconds: Option<u64>,
613) -> Result<StartResult, String> {
614 start_with_hook(
615 authorization_base_url,
616 exchange_url,
617 timeout_seconds,
618 ManagerTransactionHook::default(),
619 )
620 .await
621}
622
623const SUPERSEDED_START_ERROR: &str =
624 "superseded: OpenRouter connect was replaced by a newer credential action";
625
626fn test_mode_enabled() -> bool {
627 std::env::var("CAR_OPENROUTER_TEST_MODE").as_deref() == Ok("1")
628}
629
630fn non_empty_environment_value(key: &str) -> Option<String> {
631 std::env::var(key)
632 .ok()
633 .map(|value| value.trim().to_string())
634 .filter(|value| !value.is_empty())
635}
636
637fn resolve_start_endpoints(
643 authorization_base_url: Option<&str>,
644 exchange_url: Option<&str>,
645) -> Result<(String, String), String> {
646 let explicit_override = authorization_base_url.is_some() || exchange_url.is_some();
647 let test_mode = test_mode_enabled();
648 if explicit_override && !cfg!(test) && !test_mode {
649 return Err("OpenRouter endpoint overrides require CAR_OPENROUTER_TEST_MODE=1".into());
650 }
651 let authorization_base_url = authorization_base_url
652 .map(str::to_string)
653 .or_else(|| {
654 test_mode.then(|| non_empty_environment_value(TEST_AUTHORIZATION_BASE_URL_ENV))?
655 })
656 .unwrap_or_else(|| DEFAULT_AUTH_URL.to_string());
657 let exchange_url = exchange_url
658 .map(str::to_string)
659 .or_else(|| test_mode.then(|| non_empty_environment_value(TEST_EXCHANGE_URL_ENV))?)
660 .unwrap_or_else(|| DEFAULT_EXCHANGE_URL.to_string());
661 Ok((authorization_base_url, exchange_url))
662}
663
664fn start_reservation_is_current(action_generation: u64, flow_id: &str) -> bool {
665 let guard = manager().lock().unwrap_or_else(|p| p.into_inner());
666 guard.action_generation == action_generation
667 && guard.pending.as_ref().is_some_and(|pending| {
668 pending.action_generation == action_generation && pending.flow_id == flow_id
669 })
670}
671
672fn fail_current_start_preparation(action_generation: u64, flow_id: &str, message: &str) {
673 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
674 let is_current = guard.action_generation == action_generation
675 && guard.pending.as_ref().is_some_and(|pending| {
676 pending.action_generation == action_generation && pending.flow_id == flow_id
677 });
678 if !is_current {
679 return;
680 }
681 if let Some(flow) = guard.pending.take() {
682 flow.cancel.cancel();
683 }
684 guard.last_result = Some(terminal(flow_id, "exchange_rejected", message));
685 let status = status_from_manager(&guard);
686 drop(guard);
687 publish_status(&status);
688}
689
690async fn start_with_hook(
691 authorization_base_url: Option<&str>,
692 exchange_url: Option<&str>,
693 timeout_seconds: Option<u64>,
694 transaction_hook: ManagerTransactionHook,
695) -> Result<StartResult, String> {
696 let (authorization_base_url, exchange_url) =
697 resolve_start_endpoints(authorization_base_url, exchange_url)?;
698
699 let flow_id = uuid::Uuid::new_v4().to_string();
700 let verifier = car_auth::pkce_verifier();
701 let challenge = car_auth::pkce_challenge(&verifier);
702 let timeout = timeout_seconds
703 .unwrap_or(DEFAULT_TIMEOUT_SECS)
704 .clamp(1, MAX_TIMEOUT_SECS);
705 let deadline_unix_ms = now_unix_ms().saturating_add(timeout * 1000);
706 let cancel = CancellationToken::new();
707 let (action_generation, reserved_status) = {
708 transaction_hook.before_lock();
709 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
710 transaction_hook.inside_lock();
711 let action_generation = guard.advance_action_generation();
712 if let Some(flow) = guard.pending.take() {
713 flow.cancel.cancel();
714 }
715 guard.pending = Some(PendingFlow {
716 flow_id: flow_id.clone(),
717 action_generation,
718 deadline_unix_ms,
719 cancel: cancel.clone(),
720 });
721 guard.last_result = None;
722 (action_generation, status_from_manager(&guard))
723 };
724 publish_status(&reserved_status);
725
726 transaction_hook.after_reserve();
730 if !start_reservation_is_current(action_generation, &flow_id) {
731 return Err(SUPERSEDED_START_ERROR.into());
732 }
733
734 let listener = match TcpListener::bind("127.0.0.1:0").await {
735 Ok(listener) => listener,
736 Err(error) => {
737 let message = format!("bind OpenRouter OAuth callback: {error}");
738 fail_current_start_preparation(action_generation, &flow_id, &message);
739 return Err(message);
740 }
741 };
742 let port = match listener.local_addr() {
743 Ok(address) => address.port(),
744 Err(error) => {
745 let message = format!("read OpenRouter OAuth callback address: {error}");
746 fail_current_start_preparation(action_generation, &flow_id, &message);
747 return Err(message);
748 }
749 };
750 let callback_url = format!("http://127.0.0.1:{port}/openrouter/callback/{flow_id}");
751 let mut auth_url = match reqwest::Url::parse(&authorization_base_url) {
752 Ok(url) => url,
753 Err(error) => {
754 let message = format!("invalid OpenRouter authorization URL: {error}");
755 fail_current_start_preparation(action_generation, &flow_id, &message);
756 return Err(message);
757 }
758 };
759 auth_url
760 .query_pairs_mut()
761 .append_pair("callback_url", &callback_url)
762 .append_pair("code_challenge", &challenge)
763 .append_pair("code_challenge_method", "S256");
764
765 if !start_reservation_is_current(action_generation, &flow_id) {
766 cancel.cancel();
767 return Err(SUPERSEDED_START_ERROR.into());
768 }
769
770 transaction_hook.before_return();
775
776 let task_flow_id = flow_id.clone();
777 tokio::spawn(async move {
778 run_flow(
779 listener,
780 task_flow_id,
781 verifier,
782 exchange_url,
783 Duration::from_secs(timeout),
784 cancel,
785 )
786 .await;
787 });
788
789 Ok(StartResult {
790 authority_generation: action_generation,
791 authorize_url: auth_url.to_string(),
792 flow_id,
793 deadline_unix_ms,
794 })
795}
796
797async fn run_flow(
798 listener: TcpListener,
799 flow_id: String,
800 verifier: String,
801 exchange_url: String,
802 timeout: Duration,
803 cancel: CancellationToken,
804) {
805 let deadline = tokio::time::Instant::now() + timeout;
806 let accepted = tokio::select! {
807 _ = cancel.cancelled() => return,
808 _ = tokio::time::sleep_until(deadline) => {
809 finish(&flow_id, terminal(&flow_id, "timed_out", "OpenRouter connect timed out"), None);
810 return;
811 }
812 accepted = listener.accept() => accepted,
813 };
814 let Ok((mut stream, _)) = accepted else {
815 finish(
816 &flow_id,
817 terminal(&flow_id, "exchange_rejected", "OpenRouter callback failed"),
818 None,
819 );
820 return;
821 };
822 let callback_read_deadline = std::cmp::min(
823 deadline,
824 tokio::time::Instant::now() + Duration::from_secs(5),
825 );
826 let request = match read_callback_headers(&mut stream, callback_read_deadline, &cancel).await {
827 Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
828 Err(CallbackReadError::Cancelled) => return,
829 Err(CallbackReadError::TimedOut) => {
830 finish(
831 &flow_id,
832 terminal(&flow_id, "timed_out", "OpenRouter connect timed out"),
833 None,
834 );
835 return;
836 }
837 Err(CallbackReadError::TooLarge) => {
838 finish(
839 &flow_id,
840 terminal(
841 &flow_id,
842 "exchange_rejected",
843 "OpenRouter callback was too large",
844 ),
845 None,
846 );
847 return;
848 }
849 Err(CallbackReadError::Incomplete) => {
850 finish(
851 &flow_id,
852 terminal(
853 &flow_id,
854 "exchange_rejected",
855 "OpenRouter callback was incomplete",
856 ),
857 None,
858 );
859 return;
860 }
861 Err(CallbackReadError::Io) => {
862 finish(
863 &flow_id,
864 terminal(
865 &flow_id,
866 "exchange_rejected",
867 "OpenRouter callback was invalid",
868 ),
869 None,
870 );
871 return;
872 }
873 };
874 let target = request
875 .lines()
876 .next()
877 .and_then(|line| line.split_whitespace().nth(1))
878 .unwrap_or("/");
879 let parsed = reqwest::Url::parse(&format!("http://127.0.0.1{target}"));
880 let (code, callback_error) = match parsed {
881 Ok(url) if url.path() == format!("/openrouter/callback/{flow_id}") => {
882 let pairs: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
883 (pairs.get("code").cloned(), pairs.get("error").cloned())
884 }
885 _ => (None, Some("invalid_callback".to_string())),
886 };
887 let response = if code.is_some() {
888 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n<!doctype html><title>CAR connected</title><p>OpenRouter connected. You can close this window.</p>"
889 } else {
890 "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n<!doctype html><title>CAR not connected</title><p>OpenRouter was not connected. Return to CarHost and retry.</p>"
891 };
892 let _ = stream.write_all(response.as_bytes()).await;
893 let _ = stream.shutdown().await;
894
895 if let Some(error) = callback_error {
896 let kind = if error == "access_denied" {
897 "cancelled"
898 } else {
899 "exchange_rejected"
900 };
901 finish(
902 &flow_id,
903 terminal(&flow_id, kind, "OpenRouter did not approve the connection"),
904 None,
905 );
906 return;
907 }
908 let Some(code) = code else {
909 finish(
910 &flow_id,
911 terminal(
912 &flow_id,
913 "exchange_rejected",
914 "OpenRouter callback contained no code",
915 ),
916 None,
917 );
918 return;
919 };
920
921 let exchange = reqwest::Client::new()
922 .post(&exchange_url)
923 .json(&serde_json::json!({
924 "code": code,
925 "code_verifier": verifier,
926 "code_challenge_method": "S256",
927 }))
928 .send();
929 let exchanged = tokio::select! {
930 _ = cancel.cancelled() => return,
931 _ = tokio::time::sleep_until(deadline) => {
932 finish(&flow_id, terminal(&flow_id, "timed_out", "OpenRouter connect timed out"), None);
933 return;
934 }
935 response = exchange => response,
936 };
937 let response = match exchanged {
938 Ok(response) if response.status().is_success() => response,
939 _ => {
940 finish(
941 &flow_id,
942 terminal(
943 &flow_id,
944 "exchange_rejected",
945 "OpenRouter rejected the code exchange",
946 ),
947 None,
948 );
949 return;
950 }
951 };
952 let payload: serde_json::Value = match tokio::select! {
953 _ = cancel.cancelled() => return,
954 _ = tokio::time::sleep_until(deadline) => {
955 finish(&flow_id, terminal(&flow_id, "timed_out", "OpenRouter connect timed out"), None);
956 return;
957 }
958 payload = response.json() => payload,
959 } {
960 Ok(payload) => payload,
961 Err(_) => {
962 finish(
963 &flow_id,
964 terminal(
965 &flow_id,
966 "exchange_rejected",
967 "OpenRouter returned an invalid exchange response",
968 ),
969 None,
970 );
971 return;
972 }
973 };
974 let Some(key) = payload
975 .get("key")
976 .and_then(|value| value.as_str())
977 .filter(|key| !key.trim().is_empty())
978 else {
979 finish(
980 &flow_id,
981 terminal(
982 &flow_id,
983 "exchange_rejected",
984 "OpenRouter exchange returned no key",
985 ),
986 None,
987 );
988 return;
989 };
990 finish_with_key(
991 &flow_id,
992 terminal(&flow_id, "connected", "OpenRouter account connected"),
993 key,
994 )
995 .await;
996}
997
998const MAX_CALLBACK_HEADER_BYTES: usize = 8_192;
999
1000#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1001enum CallbackReadError {
1002 Cancelled,
1003 TimedOut,
1004 TooLarge,
1005 Incomplete,
1006 Io,
1007}
1008
1009async fn read_callback_headers(
1010 stream: &mut tokio::net::TcpStream,
1011 deadline: tokio::time::Instant,
1012 cancel: &CancellationToken,
1013) -> Result<Vec<u8>, CallbackReadError> {
1014 let mut request = Vec::with_capacity(1024);
1015 let mut chunk = [0u8; 1024];
1016 loop {
1017 let count = tokio::select! {
1018 _ = cancel.cancelled() => return Err(CallbackReadError::Cancelled),
1019 _ = tokio::time::sleep_until(deadline) => return Err(CallbackReadError::TimedOut),
1020 read = stream.read(&mut chunk) => read.map_err(|_| CallbackReadError::Io)?,
1021 };
1022 if count == 0 {
1023 return Err(CallbackReadError::Incomplete);
1024 }
1025 request.extend_from_slice(&chunk[..count]);
1026 if request.len() > MAX_CALLBACK_HEADER_BYTES {
1027 return Err(CallbackReadError::TooLarge);
1028 }
1029 if request.windows(4).any(|window| window == b"\r\n\r\n") {
1030 return Ok(request);
1031 }
1032 }
1033}
1034
1035fn finish(flow_id: &str, result: TerminalResult, key: Option<&str>) {
1036 debug_assert!(key.is_none(), "credential finishes use finish_with_key");
1037 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
1038 if !guard.pending.as_ref().is_some_and(|flow| {
1039 flow.flow_id == flow_id && flow.action_generation == guard.action_generation
1040 }) {
1041 return;
1042 }
1043 guard.pending = None;
1044 guard.last_result = Some(result);
1045 let status = status_from_manager(&guard);
1046 drop(guard);
1047 publish_status(&status);
1048}
1049
1050async fn finish_with_key(flow_id: &str, result: TerminalResult, key: &str) {
1051 finish_with_key_with_hook(flow_id, result, key, CredentialFinishHook::default()).await;
1052}
1053
1054async fn finish_with_key_with_hook(
1055 flow_id: &str,
1056 result: TerminalResult,
1057 key: &str,
1058 hook: CredentialFinishHook,
1059) {
1060 hook.before_gate();
1064 let _credential_io = credential_io_gate().lock().await;
1065 let observed = credential_presence_from_vault();
1066 hook.after_gate();
1067 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
1068 if !guard.pending.as_ref().is_some_and(|flow| {
1069 flow.flow_id == flow_id && flow.action_generation == guard.action_generation
1070 }) {
1071 return;
1072 }
1073 guard.replace_credential_presence(observed);
1074 if store_oauth_key(key).is_err() {
1075 guard.pending = None;
1076 guard.last_result = Some(terminal(
1077 flow_id,
1078 "exchange_rejected",
1079 "OpenRouter connected but the OS keychain rejected the credential",
1080 ));
1081 let status = status_from_manager(&guard);
1082 drop(guard);
1083 publish_status(&status);
1084 return;
1085 }
1086 guard.set_oauth_exists(true);
1087 guard.pending = None;
1088 guard.last_result = Some(result);
1089 let status = status_from_manager(&guard);
1090 drop(guard);
1091 publish_status(&status);
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096 use super::*;
1097 use tokio::net::TcpListener;
1098
1099 fn oauth_test_lock() -> &'static tokio::sync::Mutex<()> {
1100 static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
1101 LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
1102 }
1103
1104 fn reset_test_manager() {
1105 delete_oauth_key().unwrap();
1106 let mut guard = manager().lock().unwrap_or_else(|p| p.into_inner());
1107 if let Some(flow) = guard.pending.take() {
1108 flow.cancel.cancel();
1109 }
1110 guard.action_generation = 0;
1111 guard.last_result = None;
1112 guard.credentials.oauth = false;
1113 guard.credential_revision = 0;
1114 }
1115
1116 async fn exchange_server(
1117 status: u16,
1118 body: &'static str,
1119 ) -> (String, tokio::task::JoinHandle<String>) {
1120 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1121 let url = format!("http://{}/exchange", listener.local_addr().unwrap());
1122 let task = tokio::spawn(async move {
1123 let (mut stream, _) = listener.accept().await.unwrap();
1124 let mut request = Vec::new();
1125 let mut chunk = [0u8; 4096];
1126 loop {
1127 let count = stream.read(&mut chunk).await.unwrap();
1128 if count == 0 {
1129 break;
1130 }
1131 request.extend_from_slice(&chunk[..count]);
1132 let text = String::from_utf8_lossy(&request);
1133 let Some(header_end) = text.find("\r\n\r\n") else {
1134 continue;
1135 };
1136 let content_length = text[..header_end]
1137 .lines()
1138 .find_map(|line| {
1139 let (name, value) = line.split_once(':')?;
1140 name.eq_ignore_ascii_case("content-length")
1141 .then(|| value.trim().parse::<usize>().ok())
1142 .flatten()
1143 })
1144 .unwrap_or(0);
1145 if request.len() >= header_end + 4 + content_length {
1146 break;
1147 }
1148 }
1149 let reason = if status == 200 { "OK" } else { "Unauthorized" };
1150 let response = format!(
1151 "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1152 body.len()
1153 );
1154 stream.write_all(response.as_bytes()).await.unwrap();
1155 String::from_utf8(request).unwrap()
1156 });
1157 (url, task)
1158 }
1159
1160 async fn wait_for_terminal(flow_id: &str) -> Status {
1161 for _ in 0..250 {
1162 let current = status();
1163 if current
1164 .pending
1165 .as_ref()
1166 .is_none_or(|pending| pending.flow_id != flow_id)
1167 {
1168 return current;
1169 }
1170 tokio::time::sleep(Duration::from_millis(25)).await;
1171 }
1172 panic!("OpenRouter flow {flow_id} did not finish");
1173 }
1174
1175 fn spawn_paused_preparing_start() -> (
1176 tokio::task::JoinHandle<Result<StartResult, String>>,
1177 String,
1178 Arc<std::sync::Barrier>,
1179 ) {
1180 let reserved = Arc::new(std::sync::Barrier::new(2));
1181 let release = Arc::new(std::sync::Barrier::new(2));
1182 let hook = ManagerTransactionHook {
1183 after_reserve_entered: Some(reserved.clone()),
1184 after_reserve_release: Some(release.clone()),
1185 ..ManagerTransactionHook::default()
1186 };
1187 let task = tokio::spawn(async move {
1188 start_with_hook(
1189 Some("http://127.0.0.1:9/auth"),
1190 Some("http://127.0.0.1:9/exchange"),
1191 Some(30),
1192 hook,
1193 )
1194 .await
1195 });
1196 tokio::task::block_in_place(|| reserved.wait());
1197 let flow_id = status()
1198 .pending
1199 .expect("preparing start owns pending authority")
1200 .flow_id;
1201 (task, flow_id, release)
1202 }
1203
1204 #[tokio::test]
1205 async fn empty_param_start_uses_local_endpoints_only_in_explicit_test_mode() {
1206 let _test_guard = oauth_test_lock().lock().await;
1207 reset_test_manager();
1208 let (exchange_url, exchange_task) =
1209 exchange_server(200, r#"{"key":"local-oauth-test-key"}"#).await;
1210 unsafe {
1211 std::env::set_var("CAR_OPENROUTER_TEST_MODE", "1");
1212 std::env::set_var(
1213 "OPENROUTER_AUTHORIZATION_BASE_URL",
1214 "http://127.0.0.1:9/local-openrouter-auth",
1215 );
1216 std::env::set_var("OPENROUTER_EXCHANGE_URL", &exchange_url);
1217 }
1218
1219 let started = start(None, None, Some(5)).await.unwrap();
1220 assert!(
1221 started
1222 .authorize_url
1223 .starts_with("http://127.0.0.1:9/local-openrouter-auth?"),
1224 "the exact native empty-params action must use the daemon test-mode override: {}",
1225 started.authorize_url
1226 );
1227 let authorize_url = reqwest::Url::parse(&started.authorize_url).unwrap();
1228 let callback_url = authorize_url
1229 .query_pairs()
1230 .find_map(|(name, value)| (name == "callback_url").then(|| value.into_owned()))
1231 .unwrap();
1232 reqwest::get(format!("{callback_url}?code=local-approved-code"))
1233 .await
1234 .unwrap();
1235 let terminal = wait_for_terminal(&started.flow_id).await;
1236 assert_eq!(terminal.state, "connected");
1237 let exchange_request = exchange_task.await.unwrap();
1238 assert!(exchange_request.starts_with("POST /exchange "));
1239 assert!(exchange_request.contains("local-approved-code"));
1240 assert!(exchange_request.contains("code_verifier"));
1241 disconnect().await.unwrap();
1242
1243 unsafe {
1244 std::env::remove_var("CAR_OPENROUTER_TEST_MODE");
1245 }
1246 let production = start(None, None, Some(5)).await.unwrap();
1247 assert!(
1248 production
1249 .authorize_url
1250 .starts_with("https://openrouter.ai/auth?"),
1251 "unsafe endpoint env vars must be ignored outside explicit test mode: {}",
1252 production.authorize_url
1253 );
1254 assert_eq!(
1255 resolve_start_endpoints(None, None).unwrap().1,
1256 DEFAULT_EXCHANGE_URL
1257 );
1258 cancel(Some(&production.flow_id));
1259
1260 unsafe {
1261 std::env::remove_var("OPENROUTER_EXCHANGE_URL");
1262 std::env::remove_var("OPENROUTER_AUTHORIZATION_BASE_URL");
1263 }
1264 }
1265
1266 #[tokio::test]
1267 async fn pkce_flow_stores_real_key_and_terminal_states_are_bounded() {
1268 let _test_guard = oauth_test_lock().lock().await;
1269 reset_test_manager();
1270
1271 let marker = "sk-or-v1-test-marker-never-serialize";
1272 let exchange_body: &'static str = Box::leak(
1273 serde_json::json!({ "key": marker })
1274 .to_string()
1275 .into_boxed_str(),
1276 );
1277 let (exchange_url, exchange_task) = exchange_server(200, exchange_body).await;
1278 let started = start(
1279 Some("http://127.0.0.1:9/auth"),
1280 Some(&exchange_url),
1281 Some(5),
1282 )
1283 .await
1284 .unwrap();
1285 let url = reqwest::Url::parse(&started.authorize_url).unwrap();
1286 let query: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
1287 assert_eq!(
1288 query.get("code_challenge_method").map(String::as_str),
1289 Some("S256")
1290 );
1291 assert!(query.get("code_challenge").is_some_and(|v| v.len() >= 43));
1292 assert!(query
1293 .get("callback_url")
1294 .is_some_and(|v| v.contains("127.0.0.1") && v.contains(&started.flow_id)));
1295 assert_eq!(status().state, "pending");
1296
1297 let callback = reqwest::Url::parse(query.get("callback_url").unwrap()).unwrap();
1298 let mut callback_stream = tokio::net::TcpStream::connect((
1299 callback.host_str().unwrap(),
1300 callback.port().unwrap(),
1301 ))
1302 .await
1303 .unwrap();
1304 for fragment in [
1305 "GET ".to_string(),
1306 format!("{}?code=approved-code HTTP/1.1\r\n", callback.path()),
1307 "Host: 127.0.0.1\r\n".to_string(),
1308 "Connection: close\r\n\r\n".to_string(),
1309 ] {
1310 callback_stream
1311 .write_all(fragment.as_bytes())
1312 .await
1313 .unwrap();
1314 tokio::task::yield_now().await;
1315 }
1316 let mut callback_response = Vec::new();
1317 callback_stream
1318 .read_to_end(&mut callback_response)
1319 .await
1320 .unwrap();
1321 assert!(
1322 String::from_utf8_lossy(&callback_response).starts_with("HTTP/1.1 200"),
1323 "{}",
1324 String::from_utf8_lossy(&callback_response)
1325 );
1326 let terminal = wait_for_terminal(&started.flow_id).await;
1327 assert_eq!(
1328 terminal.last_result.as_ref().unwrap().kind,
1329 "connected",
1330 "{}",
1331 terminal.last_result.as_ref().unwrap().message
1332 );
1333 assert!(terminal.oauth_key_exists);
1334 assert_eq!(
1335 test_oauth_slot()
1336 .lock()
1337 .unwrap_or_else(|p| p.into_inner())
1338 .as_deref(),
1339 Some(marker)
1340 );
1341 assert!(!serde_json::to_string(&terminal).unwrap().contains(marker));
1342 let exchange_request = exchange_task.await.unwrap();
1343 assert!(exchange_request.contains("approved-code"));
1344 assert!(exchange_request.contains("code_verifier"));
1345 assert!(exchange_request.contains("S256"));
1346
1347 let disconnected = disconnect().await.unwrap();
1348 assert!(!disconnected.oauth_key_exists);
1349 assert!(test_oauth_slot()
1350 .lock()
1351 .unwrap_or_else(|p| p.into_inner())
1352 .is_none());
1353
1354 let (reject_url, _reject_task) = exchange_server(401, "{}").await;
1355 let rejected = start(Some("http://127.0.0.1:9/auth"), Some(&reject_url), Some(5))
1356 .await
1357 .unwrap();
1358 let rejected_url = reqwest::Url::parse(&rejected.authorize_url).unwrap();
1359 let rejected_callback = rejected_url
1360 .query_pairs()
1361 .find_map(|(name, value)| (name == "callback_url").then(|| value.into_owned()))
1362 .unwrap();
1363 reqwest::get(format!("{rejected_callback}?code=rejected-code"))
1364 .await
1365 .unwrap();
1366 assert_eq!(
1367 wait_for_terminal(&rejected.flow_id)
1368 .await
1369 .last_result
1370 .unwrap()
1371 .kind,
1372 "exchange_rejected"
1373 );
1374
1375 let timed = start(
1376 Some("http://127.0.0.1:9/auth"),
1377 Some("http://127.0.0.1:9/exchange"),
1378 Some(1),
1379 )
1380 .await
1381 .unwrap();
1382 assert_eq!(
1383 wait_for_terminal(&timed.flow_id)
1384 .await
1385 .last_result
1386 .unwrap()
1387 .kind,
1388 "timed_out"
1389 );
1390
1391 let cancelled = start(
1392 Some("http://127.0.0.1:9/auth"),
1393 Some("http://127.0.0.1:9/exchange"),
1394 Some(5),
1395 )
1396 .await
1397 .unwrap();
1398 assert_eq!(
1399 cancel(Some(&cancelled.flow_id)).last_result.unwrap().kind,
1400 "cancelled"
1401 );
1402
1403 let first = start(
1404 Some("http://127.0.0.1:9/auth"),
1405 Some("http://127.0.0.1:9/exchange"),
1406 Some(5),
1407 )
1408 .await
1409 .unwrap();
1410 let replacement = start(
1411 Some("http://127.0.0.1:9/auth"),
1412 Some("http://127.0.0.1:9/exchange"),
1413 Some(5),
1414 )
1415 .await
1416 .unwrap();
1417 let replacement_status = status();
1418 assert_eq!(replacement_status.state, "pending");
1419 assert_eq!(
1420 replacement_status
1421 .pending
1422 .as_ref()
1423 .map(|p| p.flow_id.as_str()),
1424 Some(replacement.flow_id.as_str())
1425 );
1426 assert!(
1427 terminal_status_for_flow(&first.flow_id).is_none(),
1428 "superseded watcher A must not emit replacement flow B's pending state"
1429 );
1430 let replacement_terminal = cancel(Some(&replacement.flow_id));
1431 assert_eq!(
1432 replacement_terminal
1433 .last_result
1434 .as_ref()
1435 .map(|r| r.flow_id.as_str()),
1436 Some(replacement.flow_id.as_str())
1437 );
1438 assert!(terminal_status_for_flow(&replacement.flow_id).is_some());
1439
1440 let superseded = start(
1441 Some("http://127.0.0.1:9/auth"),
1442 Some("http://127.0.0.1:9/exchange"),
1443 Some(5),
1444 )
1445 .await
1446 .unwrap();
1447 supersede_for_pasted_change();
1448 let superseded_status = wait_for_terminal(&superseded.flow_id).await;
1449 assert_eq!(superseded_status.last_result.unwrap().kind, "superseded");
1450
1451 delete_oauth_key().unwrap();
1452 }
1453
1454 #[tokio::test]
1455 async fn callback_rejects_oversize_and_times_out_incomplete_headers() {
1456 let _test_guard = oauth_test_lock().lock().await;
1457 reset_test_manager();
1458
1459 let oversized = start(
1460 Some("http://127.0.0.1:9/auth"),
1461 Some("http://127.0.0.1:9/exchange"),
1462 Some(5),
1463 )
1464 .await
1465 .unwrap();
1466 let oversized_url = reqwest::Url::parse(&oversized.authorize_url)
1467 .unwrap()
1468 .query_pairs()
1469 .find_map(|(name, value)| (name == "callback_url").then(|| value.into_owned()))
1470 .unwrap();
1471 let oversized_url = reqwest::Url::parse(&oversized_url).unwrap();
1472 let mut stream = tokio::net::TcpStream::connect((
1473 oversized_url.host_str().unwrap(),
1474 oversized_url.port().unwrap(),
1475 ))
1476 .await
1477 .unwrap();
1478 stream.write_all(&vec![b'x'; 8_193]).await.unwrap();
1479 let oversized_terminal = wait_for_terminal(&oversized.flow_id).await;
1480 let oversized_result = oversized_terminal.last_result.unwrap();
1481 assert_eq!(oversized_result.kind, "exchange_rejected");
1482 assert_eq!(
1483 oversized_result.message,
1484 "OpenRouter callback was too large"
1485 );
1486
1487 let incomplete = start(
1488 Some("http://127.0.0.1:9/auth"),
1489 Some("http://127.0.0.1:9/exchange"),
1490 Some(10),
1491 )
1492 .await
1493 .unwrap();
1494 let incomplete_url = reqwest::Url::parse(&incomplete.authorize_url)
1495 .unwrap()
1496 .query_pairs()
1497 .find_map(|(name, value)| (name == "callback_url").then(|| value.into_owned()))
1498 .unwrap();
1499 let incomplete_url = reqwest::Url::parse(&incomplete_url).unwrap();
1500 let mut stream = tokio::net::TcpStream::connect((
1501 incomplete_url.host_str().unwrap(),
1502 incomplete_url.port().unwrap(),
1503 ))
1504 .await
1505 .unwrap();
1506 stream
1507 .write_all(b"GET /incomplete HTTP/1.1\r\n")
1508 .await
1509 .unwrap();
1510 let incomplete_terminal = wait_for_terminal(&incomplete.flow_id).await;
1511 assert_eq!(incomplete_terminal.last_result.unwrap().kind, "timed_out");
1512
1513 let half_closed = start(
1514 Some("http://127.0.0.1:9/auth"),
1515 Some("http://127.0.0.1:9/exchange"),
1516 Some(5),
1517 )
1518 .await
1519 .unwrap();
1520 let half_closed_url = reqwest::Url::parse(&half_closed.authorize_url)
1521 .unwrap()
1522 .query_pairs()
1523 .find_map(|(name, value)| (name == "callback_url").then(|| value.into_owned()))
1524 .unwrap();
1525 let half_closed_url = reqwest::Url::parse(&half_closed_url).unwrap();
1526 let mut stream = tokio::net::TcpStream::connect((
1527 half_closed_url.host_str().unwrap(),
1528 half_closed_url.port().unwrap(),
1529 ))
1530 .await
1531 .unwrap();
1532 stream
1533 .write_all(b"GET /half-close HTTP/1.1\r\nHost: localhost\r\n")
1534 .await
1535 .unwrap();
1536 stream.shutdown().await.unwrap();
1537 let half_closed_terminal = wait_for_terminal(&half_closed.flow_id).await;
1538 let half_closed_result = half_closed_terminal.last_result.unwrap();
1539 assert_eq!(half_closed_result.kind, "exchange_rejected");
1540 assert_eq!(
1541 half_closed_result.message,
1542 "OpenRouter callback was incomplete"
1543 );
1544 }
1545
1546 #[tokio::test]
1547 async fn callback_header_reader_rejects_half_closed_incomplete_request() {
1548 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1549 let address = listener.local_addr().unwrap();
1550 let client = tokio::spawn(async move {
1551 let mut stream = tokio::net::TcpStream::connect(address).await.unwrap();
1552 stream
1553 .write_all(b"GET /callback?code=partial HTTP/1.1\r\nHost: localhost\r\n")
1554 .await
1555 .unwrap();
1556 stream.shutdown().await.unwrap();
1557 });
1558 let (mut server, _) = listener.accept().await.unwrap();
1559 let result = read_callback_headers(
1560 &mut server,
1561 tokio::time::Instant::now() + Duration::from_secs(1),
1562 &CancellationToken::new(),
1563 )
1564 .await;
1565 client.await.unwrap();
1566 assert!(
1567 result.is_err(),
1568 "EOF before CRLFCRLF must be an invalid callback, not a parseable request"
1569 );
1570 }
1571
1572 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1573 async fn newer_start_supersedes_an_older_preparing_start() {
1574 let _test_guard = oauth_test_lock().lock().await;
1575 reset_test_manager();
1576
1577 let (first_task, first_flow_id, release_first) = spawn_paused_preparing_start();
1578 let second = start(
1579 Some("http://127.0.0.1:9/auth"),
1580 Some("http://127.0.0.1:9/exchange"),
1581 Some(30),
1582 )
1583 .await
1584 .unwrap();
1585 tokio::task::block_in_place(|| release_first.wait());
1586 let first_error = first_task.await.unwrap().unwrap_err();
1587
1588 assert!(first_error.contains("superseded"));
1589 let current = status();
1590 assert_eq!(
1591 current.pending.as_ref().map(|p| p.flow_id.as_str()),
1592 Some(second.flow_id.as_str())
1593 );
1594 assert!(!is_pending(&first_flow_id));
1595 assert!(is_pending(&second.flow_id));
1596 assert!(terminal_status_for_flow(&first_flow_id).is_none());
1597 cancel(Some(&second.flow_id));
1598 }
1599
1600 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1601 async fn delayed_start_result_is_lower_authority_than_newer_broadcast() {
1602 let _test_guard = oauth_test_lock().lock().await;
1603 reset_test_manager();
1604
1605 let before_return = Arc::new(std::sync::Barrier::new(2));
1606 let release_return = Arc::new(std::sync::Barrier::new(2));
1607 let hook = ManagerTransactionHook {
1608 before_return_entered: Some(before_return.clone()),
1609 before_return_release: Some(release_return.clone()),
1610 ..ManagerTransactionHook::default()
1611 };
1612 let mut updates = subscribe_authority_updates();
1613 let first_task = tokio::spawn(async move {
1614 start_with_hook(
1615 Some("http://127.0.0.1:9/auth"),
1616 Some("http://127.0.0.1:9/exchange"),
1617 Some(30),
1618 hook,
1619 )
1620 .await
1621 .unwrap()
1622 });
1623 tokio::task::block_in_place(|| before_return.wait());
1624
1625 let second = start(
1626 Some("http://127.0.0.1:9/auth"),
1627 Some("http://127.0.0.1:9/exchange"),
1628 Some(30),
1629 )
1630 .await
1631 .unwrap();
1632 updates.changed().await.unwrap();
1633 let broadcast = updates
1634 .borrow_and_update()
1635 .clone()
1636 .expect("newer authority snapshot");
1637 assert_eq!(broadcast.authority_generation, second.authority_generation);
1638 assert_eq!(
1639 broadcast
1640 .pending
1641 .as_ref()
1642 .map(|pending| pending.flow_id.as_str()),
1643 Some(second.flow_id.as_str())
1644 );
1645
1646 tokio::task::block_in_place(|| release_return.wait());
1647 let delayed_first = first_task.await.unwrap();
1648 assert!(
1649 delayed_first.authority_generation < broadcast.authority_generation,
1650 "host must reject delayed flow A after receiving flow B broadcast"
1651 );
1652 assert_ne!(delayed_first.flow_id, second.flow_id);
1653 cancel(Some(&second.flow_id));
1654 }
1655
1656 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1657 async fn disconnect_publishes_before_delete_and_preserves_truth_on_failure() {
1658 let _test_guard = oauth_test_lock().lock().await;
1659 reset_test_manager();
1660 *test_oauth_slot()
1661 .lock()
1662 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some("existing-oauth".into());
1663 manager()
1664 .lock()
1665 .unwrap_or_else(|poisoned| poisoned.into_inner())
1666 .set_oauth_exists(true);
1667
1668 let before_return = Arc::new(std::sync::Barrier::new(2));
1669 let release_return = Arc::new(std::sync::Barrier::new(2));
1670 let start_hook = ManagerTransactionHook {
1671 before_return_entered: Some(before_return.clone()),
1672 before_return_release: Some(release_return.clone()),
1673 ..ManagerTransactionHook::default()
1674 };
1675 let mut updates = subscribe_authority_updates();
1676 let start_task = tokio::spawn(async move {
1677 start_with_hook(
1678 Some("http://127.0.0.1:9/auth"),
1679 Some("http://127.0.0.1:9/exchange"),
1680 Some(30),
1681 start_hook,
1682 )
1683 .await
1684 .unwrap()
1685 });
1686 tokio::task::block_in_place(|| before_return.wait());
1687 updates.changed().await.unwrap();
1688 let start_snapshot = updates.borrow_and_update().clone().unwrap();
1689
1690 let before_delete = Arc::new(std::sync::Barrier::new(2));
1691 let release_delete = Arc::new(std::sync::Barrier::new(2));
1692 let disconnect_hook = DisconnectHook {
1693 before_delete_entered: Some(before_delete.clone()),
1694 before_delete_release: Some(release_delete.clone()),
1695 ..DisconnectHook::default()
1696 };
1697 let disconnect_task =
1698 tokio::spawn(async move { disconnect_with_hook(disconnect_hook).await });
1699 tokio::task::block_in_place(|| before_delete.wait());
1700
1701 updates.changed().await.unwrap();
1704 let reserved_disconnect = updates.borrow_and_update().clone().unwrap();
1705 assert!(reserved_disconnect.authority_generation > start_snapshot.authority_generation);
1706 assert!(reserved_disconnect.pending.is_none());
1707 assert!(reserved_disconnect.oauth_key_exists);
1708
1709 tokio::task::block_in_place(|| release_return.wait());
1710 let delayed_start = start_task.await.unwrap();
1711 assert!(
1712 delayed_start.authority_generation < reserved_disconnect.authority_generation,
1713 "a host must reject delayed flow A before disconnect enters the vault"
1714 );
1715
1716 tokio::task::block_in_place(|| release_delete.wait());
1717 let disconnected = disconnect_task.await.unwrap().unwrap();
1718 assert_eq!(
1719 disconnected.authority_generation,
1720 reserved_disconnect.authority_generation
1721 );
1722 assert!(!disconnected.oauth_key_exists);
1723
1724 *test_oauth_slot()
1727 .lock()
1728 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some("existing-oauth".into());
1729 manager()
1730 .lock()
1731 .unwrap_or_else(|poisoned| poisoned.into_inner())
1732 .set_oauth_exists(true);
1733 let generation_before_failure = status().authority_generation;
1734 let error = disconnect_with_hook(DisconnectHook {
1735 forced_delete_error: Some("permitted vault failure".into()),
1736 ..DisconnectHook::default()
1737 })
1738 .await
1739 .unwrap_err();
1740 assert!(error.contains("permitted vault failure"));
1741 let failed_status = status();
1742 assert_eq!(
1743 failed_status.authority_generation,
1744 generation_before_failure + 1
1745 );
1746 assert!(failed_status.oauth_key_exists);
1747 assert!(failed_status.pending.is_none());
1748 }
1749
1750 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1751 async fn disconnect_delete_finishes_before_newer_oauth_store() {
1752 let _test_guard = oauth_test_lock().lock().await;
1753 reset_test_manager();
1754 *test_oauth_slot()
1755 .lock()
1756 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some("old-oauth".into());
1757 manager()
1758 .lock()
1759 .unwrap_or_else(|poisoned| poisoned.into_inner())
1760 .set_oauth_exists(true);
1761
1762 let before_delete = Arc::new(std::sync::Barrier::new(2));
1763 let release_delete = Arc::new(std::sync::Barrier::new(2));
1764 let disconnect_hook = DisconnectHook {
1765 before_delete_entered: Some(before_delete.clone()),
1766 before_delete_release: Some(release_delete.clone()),
1767 ..DisconnectHook::default()
1768 };
1769 let disconnect_task =
1770 tokio::spawn(async move { disconnect_with_hook(disconnect_hook).await });
1771 tokio::task::block_in_place(|| before_delete.wait());
1772
1773 let newer = start(
1774 Some("http://127.0.0.1:9/auth"),
1775 Some("http://127.0.0.1:9/exchange"),
1776 Some(30),
1777 )
1778 .await
1779 .unwrap();
1780 let finish_waiting = Arc::new(std::sync::Barrier::new(2));
1781 let finish_entered = Arc::new(std::sync::Barrier::new(2));
1782 let release_finish = Arc::new(std::sync::Barrier::new(2));
1783 let finish_hook = CredentialFinishHook {
1784 before_gate_entered: Some(finish_waiting.clone()),
1785 after_gate_entered: Some(finish_entered.clone()),
1786 after_gate_release: Some(release_finish.clone()),
1787 };
1788 let newer_flow_id = newer.flow_id.clone();
1789 let finish_task = tokio::spawn(async move {
1790 finish_with_key_with_hook(
1791 &newer_flow_id,
1792 terminal(&newer_flow_id, "connected", "newer OAuth connected"),
1793 "new-oauth",
1794 finish_hook,
1795 )
1796 .await;
1797 });
1798
1799 tokio::task::block_in_place(|| finish_waiting.wait());
1802 tokio::task::block_in_place(|| release_delete.wait());
1803 tokio::task::block_in_place(|| finish_entered.wait());
1804 let stale_disconnect = disconnect_task.await.unwrap().unwrap();
1805 assert_eq!(
1806 stale_disconnect.authority_generation,
1807 newer.authority_generation
1808 );
1809 assert_eq!(
1810 test_oauth_slot()
1811 .lock()
1812 .unwrap_or_else(|poisoned| poisoned.into_inner())
1813 .as_deref(),
1814 None,
1815 "disconnect deletion must complete before newer OAuth store enters"
1816 );
1817
1818 tokio::task::block_in_place(|| release_finish.wait());
1819 finish_task.await.unwrap();
1820 assert_eq!(
1821 test_oauth_slot()
1822 .lock()
1823 .unwrap_or_else(|poisoned| poisoned.into_inner())
1824 .as_deref(),
1825 Some("new-oauth")
1826 );
1827 let final_status = status();
1828 assert_eq!(
1829 final_status.authority_generation,
1830 newer.authority_generation
1831 );
1832 assert!(final_status.oauth_key_exists);
1833 assert_eq!(
1834 final_status
1835 .last_result
1836 .as_ref()
1837 .map(|result| (result.flow_id.as_str(), result.kind.as_str())),
1838 Some((newer.flow_id.as_str(), "connected"))
1839 );
1840 }
1841
1842 #[tokio::test]
1843 async fn status_read_reconciles_external_oauth_slot_changes() {
1844 let _test_guard = oauth_test_lock().lock().await;
1845 reset_test_manager();
1846
1847 *test_oauth_slot()
1848 .lock()
1849 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some("external-oauth".into());
1850 let connected = status();
1851 assert!(connected.oauth_key_exists);
1852
1853 *test_oauth_slot()
1854 .lock()
1855 .unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
1856 let removed = status();
1857 assert!(!removed.oauth_key_exists);
1858 }
1859
1860 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1861 async fn cancel_supersedes_an_older_preparing_start() {
1862 let _test_guard = oauth_test_lock().lock().await;
1863 reset_test_manager();
1864
1865 let (start_task, flow_id, release_start) = spawn_paused_preparing_start();
1866 let cancelled = cancel(Some(&flow_id));
1867 tokio::task::block_in_place(|| release_start.wait());
1868 let start_error = start_task.await.unwrap().unwrap_err();
1869
1870 assert!(start_error.contains("superseded"));
1871 assert!(cancelled.pending.is_none());
1872 assert_eq!(
1873 cancelled
1874 .last_result
1875 .as_ref()
1876 .map(|result| result.kind.as_str()),
1877 Some("cancelled")
1878 );
1879 assert!(!is_pending(&flow_id));
1880 }
1881
1882 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1883 async fn disconnect_supersedes_an_older_preparing_start() {
1884 let _test_guard = oauth_test_lock().lock().await;
1885 reset_test_manager();
1886
1887 let (start_task, flow_id, release_start) = spawn_paused_preparing_start();
1888 let disconnected = disconnect().await.unwrap();
1889 tokio::task::block_in_place(|| release_start.wait());
1890 let start_error = start_task.await.unwrap().unwrap_err();
1891
1892 assert!(start_error.contains("superseded"));
1893 assert!(disconnected.pending.is_none());
1894 assert!(!disconnected.oauth_key_exists);
1895 assert!(!is_pending(&flow_id));
1896 }
1897
1898 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1899 async fn pasted_put_and_delete_reserve_before_vault_and_supersede_preparing_start() {
1900 let _test_guard = oauth_test_lock().lock().await;
1901
1902 for operation in ["put", "delete"] {
1903 reset_test_manager();
1904 let (start_task, flow_id, release_start) = spawn_paused_preparing_start();
1905 let generation_before_mutation = status().authority_generation;
1906 let mut updates = subscribe_authority_updates();
1907 let vault_entered = Arc::new(std::sync::Barrier::new(2));
1908 let release_vault = Arc::new(std::sync::Barrier::new(2));
1909 let vault_entered_task = vault_entered.clone();
1910 let release_vault_task = release_vault.clone();
1911 let mutation_task = tokio::spawn(async move {
1912 mutate_pasted_credential(operation == "put", move || {
1913 vault_entered_task.wait();
1916 release_vault_task.wait();
1917 Ok::<_, String>(())
1918 })
1919 .await
1920 });
1921 tokio::task::block_in_place(|| vault_entered.wait());
1922 updates.changed().await.unwrap();
1923 let reserved = updates.borrow_and_update().clone().unwrap();
1924 assert!(
1925 reserved.authority_generation > generation_before_mutation,
1926 "{operation} must broadcast before entering the vault"
1927 );
1928
1929 let finish_flow_id = flow_id.clone();
1932 let finish_task = tokio::spawn(async move {
1933 finish_with_key(
1934 &finish_flow_id,
1935 terminal(&finish_flow_id, "connected", "stale OAuth finish"),
1936 "test-oauth-key",
1937 )
1938 .await;
1939 });
1940 tokio::task::block_in_place(|| release_start.wait());
1941 let start_error = start_task.await.unwrap().unwrap_err();
1942 assert!(start_error.contains("superseded"), "{operation}");
1943 assert!(!is_pending(&flow_id), "{operation}");
1944 assert!(
1945 test_oauth_slot()
1946 .lock()
1947 .unwrap_or_else(|poisoned| poisoned.into_inner())
1948 .is_none(),
1949 "{operation} must reject A's OAuth key"
1950 );
1951 assert_eq!(
1952 status()
1953 .last_result
1954 .as_ref()
1955 .map(|result| result.kind.as_str()),
1956 Some("superseded"),
1957 "{operation}"
1958 );
1959
1960 tokio::task::block_in_place(|| release_vault.wait());
1961 let (mutation_result, _) = mutation_task.await.unwrap();
1962 mutation_result.unwrap();
1963 finish_task.await.unwrap();
1964 assert!(
1965 test_oauth_slot()
1966 .lock()
1967 .unwrap_or_else(|poisoned| poisoned.into_inner())
1968 .is_none(),
1969 "{operation} must reject A's OAuth key after waiting for B's vault IO"
1970 );
1971 }
1972 }
1973
1974 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1975 async fn pasted_same_slot_generation_order_matches_physical_io_order() {
1976 let _test_guard = oauth_test_lock().lock().await;
1977
1978 for (first_exists, second_exists) in [(false, true), (true, false)] {
1979 reset_test_manager();
1980 let physical_slot = Arc::new(Mutex::new(if first_exists {
1981 None
1982 } else {
1983 Some("initial".to_string())
1984 }));
1985 let first_entered = Arc::new(std::sync::Barrier::new(2));
1986 let release_first = Arc::new(std::sync::Barrier::new(2));
1987 let first_slot = physical_slot.clone();
1988 let first_entered_task = first_entered.clone();
1989 let release_first_task = release_first.clone();
1990 let first_task = tokio::spawn(async move {
1991 mutate_pasted_credential(first_exists, move || {
1992 first_entered_task.wait();
1993 release_first_task.wait();
1994 *first_slot
1995 .lock()
1996 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
1997 first_exists.then(|| "first".to_string());
1998 Ok::<_, String>(())
1999 })
2000 .await
2001 });
2002 tokio::task::block_in_place(|| first_entered.wait());
2003 let first_generation = status().authority_generation;
2004
2005 let second_waiting = Arc::new(std::sync::Barrier::new(2));
2006 let second_slot = physical_slot.clone();
2007 let second_waiting_task = second_waiting.clone();
2008 let second_task = tokio::spawn(async move {
2009 mutate_pasted_credential_with_hook(
2010 second_exists,
2011 move || {
2012 *second_slot
2013 .lock()
2014 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
2015 second_exists.then(|| "second".to_string());
2016 Ok::<_, String>(())
2017 },
2018 PastedMutationHook {
2019 before_gate_entered: Some(second_waiting_task),
2020 },
2021 )
2022 .await
2023 });
2024
2025 tokio::task::block_in_place(|| second_waiting.wait());
2028 assert_eq!(status().authority_generation, first_generation);
2029 tokio::task::block_in_place(|| release_first.wait());
2030 let (_, first_status) = first_task.await.unwrap();
2031 let (_, second_status) = second_task.await.unwrap();
2032
2033 assert_eq!(first_status.authority_generation, first_generation);
2034 assert!(second_status.authority_generation > first_generation);
2035 assert_eq!(second_status.pasted_key_exists, second_exists);
2036 assert_eq!(
2037 physical_slot
2038 .lock()
2039 .unwrap_or_else(|poisoned| poisoned.into_inner())
2040 .as_deref(),
2041 second_exists.then_some("second")
2042 );
2043 }
2044 }
2045
2046 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2047 async fn watcher_snapshot_never_turns_flow_a_terminal_into_flow_b_status() {
2048 let _test_guard = oauth_test_lock().lock().await;
2049 reset_test_manager();
2050
2051 let first = start(
2052 Some("http://127.0.0.1:9/auth"),
2053 Some("http://127.0.0.1:9/exchange"),
2054 Some(30),
2055 )
2056 .await
2057 .unwrap();
2058 let cancelled = cancel(Some(&first.flow_id));
2059 assert_eq!(
2060 cancelled.last_result.as_ref().map(|r| r.flow_id.as_str()),
2061 Some(first.flow_id.as_str())
2062 );
2063
2064 let watcher_inside = Arc::new(std::sync::Barrier::new(2));
2065 let release_watcher = Arc::new(std::sync::Barrier::new(2));
2066 let watcher_hook = ManagerTransactionHook {
2067 inside_lock_entered: Some(watcher_inside.clone()),
2068 inside_lock_release: Some(release_watcher.clone()),
2069 ..ManagerTransactionHook::default()
2070 };
2071 let first_flow_id = first.flow_id.clone();
2072 let watcher = tokio::task::spawn_blocking(move || {
2073 terminal_status_for_flow_with_hook(&first_flow_id, watcher_hook)
2074 });
2075 tokio::task::block_in_place(|| watcher_inside.wait());
2076
2077 let second_ready = Arc::new(std::sync::Barrier::new(2));
2078 let second_hook = ManagerTransactionHook {
2079 before_lock: Some(second_ready.clone()),
2080 ..ManagerTransactionHook::default()
2081 };
2082 let second_task = tokio::spawn(async move {
2083 start_with_hook(
2084 Some("http://127.0.0.1:9/auth"),
2085 Some("http://127.0.0.1:9/exchange"),
2086 Some(30),
2087 second_hook,
2088 )
2089 .await
2090 .unwrap()
2091 });
2092 tokio::task::block_in_place(|| second_ready.wait());
2093 tokio::task::block_in_place(|| release_watcher.wait());
2094
2095 let captured = watcher.await.unwrap().expect("flow A terminal snapshot");
2096 let second = second_task.await.unwrap();
2097 assert!(captured.pending.is_none());
2098 assert_eq!(
2099 captured.last_result.as_ref().map(|r| r.flow_id.as_str()),
2100 Some(first.flow_id.as_str()),
2101 "watcher A must carry captured A terminal state, never global B state"
2102 );
2103 assert_eq!(
2104 status().pending.as_ref().map(|p| p.flow_id.as_str()),
2105 Some(second.flow_id.as_str())
2106 );
2107 assert!(terminal_status_for_flow(&first.flow_id).is_none());
2108 cancel(Some(&second.flow_id));
2109 }
2110}