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}