agentmux_cef\browser_pane/auth.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP Basic / Digest auth callback registry.
5//!
6//! When CEF fires `RequestHandler::get_auth_credentials` for a browser
7//! pane, the host returns `1` (will-async-respond), parks the
8//! `AuthCallback` in this registry keyed by a generated `request_id`,
9//! and broadcasts a `browser-pane-auth-required` event to the renderer.
10//! The renderer prompts the user and replies via the
11//! `browser_pane_auth_submit` / `browser_pane_auth_cancel` IPC
12//! commands, which resolve the callback here and complete the CEF
13//! flow.
14//!
15//! Phase α of SPEC_BROWSER_PANE_HTTP_BASIC_AUTH_2026_05_18.md.
16
17use cef::{AuthCallback, ImplAuthCallback};
18use parking_lot::Mutex;
19use std::collections::HashMap;
20use std::sync::OnceLock;
21use std::time::Duration;
22use tokio::runtime::Handle;
23
24/// One parked auth challenge — the CEF callback plus the block_id
25/// owning it (so pane-close can clean up just its entries) and a
26/// monotonically-increasing arming epoch (so a delayed timeout task
27/// can detect "this request was already resolved + re-registered
28/// under the same id" and bail). The epoch is a defensive guard;
29/// uuid request_ids shouldn't collide.
30struct Entry {
31 block_id: String,
32 callback: AuthCallback,
33 epoch: u64,
34}
35
36/// HashMap::new is not const-fn so the static needs lazy init.
37fn pending() -> &'static Mutex<HashMap<String, Entry>> {
38 static CELL: OnceLock<Mutex<HashMap<String, Entry>>> = OnceLock::new();
39 CELL.get_or_init(|| Mutex::new(HashMap::new()))
40}
41
42static NEXT_EPOCH: std::sync::atomic::AtomicU64 =
43 std::sync::atomic::AtomicU64::new(0);
44
45/// Tokio runtime Handle captured from `main.rs` so `register()` can
46/// schedule the TTL timer from any thread — CEF invokes
47/// `get_auth_credentials` on its IO thread, which has no
48/// `Handle::current()`, so a bare `tokio::spawn(...)` would panic
49/// with "there is no reactor running".
50static TOKIO_HANDLE: OnceLock<Handle> = OnceLock::new();
51
52/// Install the Tokio runtime Handle. Called once from `main.rs` after
53/// `Runtime::new()` and before CEF starts dispatching callbacks.
54pub fn set_runtime_handle(h: Handle) {
55 let _ = TOKIO_HANDLE.set(h);
56}
57
58/// Maximum time a callback can sit parked before we cancel it
59/// automatically. Bounds the leak if the renderer never replies
60/// (background tab + suspended JS, hung modal). 5 minutes is well
61/// above any realistic credential-entry time and well below "user
62/// noticed and wondered what happened."
63const PARKED_TTL: Duration = Duration::from_secs(5 * 60);
64
65/// Park a CEF auth callback under `request_id`. The renderer will
66/// resolve it shortly via `submit` / `cancel`. A 5-minute timeout
67/// task is armed alongside so the entry can't leak indefinitely if
68/// the renderer never replies. Replaces any prior entry for the same
69/// id (shouldn't happen — ids are uuid4).
70pub fn register(request_id: String, block_id: String, cb: AuthCallback) {
71 use std::sync::atomic::Ordering;
72 let epoch = NEXT_EPOCH.fetch_add(1, Ordering::Relaxed);
73 {
74 let mut g = pending().lock();
75 if g.insert(
76 request_id.clone(),
77 Entry { block_id, callback: cb, epoch },
78 ).is_some() {
79 tracing::warn!(
80 "[browser-pane-auth] duplicate request_id {} — overwriting",
81 request_id
82 );
83 }
84 }
85 // Arm the TTL. A delayed task that runs `cancel + remove` if the
86 // entry's epoch still matches when the timeout fires. If the
87 // renderer resolved the request before the timeout, the epoch
88 // check fails and the task is a no-op.
89 //
90 // Spawn via the stored Handle — `tokio::spawn` would panic here
91 // because CEF calls this from its IO thread, which has no
92 // current runtime. If the handle isn't installed (init order
93 // bug), log and skip the TTL rather than crash the host.
94 let rid = request_id;
95 match TOKIO_HANDLE.get() {
96 Some(handle) => {
97 handle.spawn(async move {
98 tokio::time::sleep(PARKED_TTL).await;
99 let to_cancel: Option<AuthCallback> = {
100 let mut g = pending().lock();
101 match g.get(&rid) {
102 Some(entry) if entry.epoch == epoch => g.remove(&rid).map(|e| e.callback),
103 _ => None,
104 }
105 };
106 if let Some(cb) = to_cancel {
107 tracing::warn!(
108 "[browser-pane-auth] request_id {} timed out after {:?} — auto-cancel",
109 rid, PARKED_TTL
110 );
111 cb.cancel();
112 }
113 });
114 }
115 None => {
116 tracing::error!(
117 "[browser-pane-auth] tokio handle not installed; TTL timer skipped for request_id {} \
118 — callback will leak if the renderer never replies. Did main.rs call set_runtime_handle?",
119 rid
120 );
121 }
122 }
123}
124
125/// Pop the callback for `request_id`. Returns None if it was already
126/// resolved (e.g. submit + cancel race) or never existed.
127pub fn take(request_id: &str) -> Option<AuthCallback> {
128 pending().lock().remove(request_id).map(|e| e.callback)
129}
130
131/// Cancel every callback parked for `block_id` — called from
132/// `browser_pane_close` so closing a pane mid-prompt doesn't leak
133/// CEF refcounts. Returns the number cancelled.
134pub fn cancel_for_block(block_id: &str) -> usize {
135 let to_cancel: Vec<AuthCallback> = {
136 let mut g = pending().lock();
137 let ids: Vec<String> = g
138 .iter()
139 .filter(|(_, e)| e.block_id == block_id)
140 .map(|(k, _)| k.clone())
141 .collect();
142 ids.into_iter()
143 .filter_map(|id| g.remove(&id))
144 .map(|e| e.callback)
145 .collect()
146 };
147 let n = to_cancel.len();
148 for cb in to_cancel {
149 cb.cancel();
150 }
151 if n > 0 {
152 tracing::info!(
153 "[browser-pane-auth] cancelled {} pending auth(s) for block {}",
154 n,
155 block_id.chars().take(7).collect::<String>(),
156 );
157 }
158 n
159}
160
161/// Pending request count. Useful for diagnostics and leak checks in
162/// tests.
163pub fn pending_count() -> usize {
164 pending().lock().len()
165}