Skip to main content

netget/client/oauth2/
mod.rs

1//! OAuth2 client implementation
2pub mod actions;
3
4pub use actions::OAuth2ClientProtocol;
5
6use anyhow::{Context, Result};
7use oauth2::{
8    basic::{BasicClient, BasicTokenType},
9    AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, DeviceAuthorizationUrl,
10    EmptyExtraDeviceAuthorizationFields, EmptyExtraTokenFields, PkceCodeChallenge, RedirectUrl,
11    RefreshToken, ResourceOwnerPassword, ResourceOwnerUsername, Scope, StandardTokenResponse,
12    TokenResponse, TokenUrl,
13};
14use std::net::SocketAddr;
15use std::sync::Arc;
16use tokio::sync::mpsc;
17use tracing::{error, info};
18
19use crate::client::http_fetch::FetchClient;
20use crate::client::llm_budget::call_llm_for_client;
21use crate::client::oauth2::actions::{
22    OAUTH2_CLIENT_CONNECTED_EVENT, OAUTH2_DEVICE_CODE_EVENT, OAUTH2_ERROR_EVENT,
23    OAUTH2_TOKEN_OBTAINED_EVENT,
24};
25use crate::llm::actions::client_trait::ClientActionResult;
26use crate::llm::ollama_client::OllamaClient;
27use crate::llm::ClientLlmResult;
28use crate::logging::emit::Log;
29use crate::protocol::Event;
30use crate::state::app_state::AppState;
31use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
32use crate::state::{AccessLogOwner, ClientId as NetGetClientId, ClientStatus};
33
34/// The HTTP function the `oauth2` crate is handed for every request it makes. Natively it is
35/// the crate's own reqwest `async_http_client`, unchanged.
36#[cfg(not(target_arch = "wasm32"))]
37use oauth2::reqwest::async_http_client as http_hook;
38
39/// The HTTP function the `oauth2` crate is handed in the browser build. The crate's own
40/// `async_http_client` is reqwest's `fetch` backend there, which cannot reach the page's virtual
41/// loopback, so each request goes through the shared hyper transport
42/// ([`crate::client::http_fetch`]) instead: 30 seconds an exchange, 8 MiB of body, no redirects
43/// (the crate's own client follows none either), and `https://` refused with the reason.
44#[cfg(target_arch = "wasm32")]
45async fn http_hook(
46    request: oauth2::HttpRequest,
47) -> std::result::Result<oauth2::HttpResponse, crate::client::http_fetch::FetchError> {
48    let client = FetchClient::transport(std::time::Duration::from_secs(30));
49    let headers = request
50        .headers
51        .iter()
52        .map(|(name, value)| (name.as_str().to_string(), value.as_bytes().to_vec()))
53        .collect();
54    let (status, headers, body) = crate::client::http_fetch::round_trip_parts(
55        &client,
56        request.method.as_str(),
57        request.url.as_str(),
58        headers,
59        request.body,
60    )
61    .await?;
62    let mut map = oauth2::http::HeaderMap::new();
63    for (name, value) in headers {
64        if let (Ok(name), Ok(value)) = (
65            oauth2::http::header::HeaderName::from_bytes(name.as_bytes()),
66            oauth2::http::HeaderValue::from_bytes(&value),
67        ) {
68            map.append(name, value);
69        }
70    }
71    let status_code = oauth2::http::StatusCode::from_u16(status).map_err(|e| {
72        crate::client::http_fetch::FetchError(anyhow::anyhow!("status {status}: {e}"))
73    })?;
74    Ok(oauth2::HttpResponse {
75        status_code,
76        headers: map,
77        body: body.to_vec(),
78    })
79}
80
81/// The HTTP client for the requests this client writes itself (device-code polling, the
82/// authorization-code callback's exchange): a fresh reqwest client natively, as before, and the
83/// shared hyper transport in the browser build, bounded at 30 seconds.
84fn plain_http_client() -> FetchClient {
85    #[cfg(not(target_arch = "wasm32"))]
86    let client = FetchClient::from_reqwest(reqwest::Client::new());
87    #[cfg(target_arch = "wasm32")]
88    let client = FetchClient::transport(std::time::Duration::from_secs(30));
89    client
90}
91
92type TokenResponseType = StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>;
93
94/// What [`OAuth2Client::apply_action`] did with one executed action.
95///
96/// OAuth2 flows are HTTPS request/response through the `oauth2` crate, so NetGet owns no
97/// socket whose byte count could be reported. The description is deliberately built from
98/// what is *observably* true afterwards - in particular whether a token was actually
99/// stored - so a flow that failed can never read as a success. Nothing on this path may
100/// invent a code or a token; only a provider response can produce one.
101enum Applied {
102    /// The action ran; the string describes the effect.
103    Ran(String),
104    /// The action asked to end the session.
105    Disconnect,
106}
107
108/// Whether an event is reported to the LLM inline or from its own task.
109#[derive(Clone, Copy)]
110enum Dispatch {
111    /// Raise it here and now. Used by the connected-event LLM path.
112    Inline,
113    /// Do the work and raise nothing at all.
114    ///
115    /// Used for a flow the model asks for in answer to `oauth2_token_obtained` or
116    /// `oauth2_error`. Without it a refresh answered on a token event would raise another
117    /// token event, whose answer could refresh again -- an unbounded credential loop, on
118    /// the one client in this tree where that matters most.
119    Silent,
120    /// Hand it to a registered task. Used by the injected-command loop, so a manual
121    /// (human-answered) routing rule on `oauth2_token_obtained` / `oauth2_error` cannot
122    /// hold up the command's outcome, or the next injected command.
123    Deferred,
124}
125
126/// OAuth2 client for authentication flows
127pub struct OAuth2Client;
128
129impl OAuth2Client {
130    /// Connect to an OAuth2 provider with integrated LLM actions
131    pub async fn connect_with_llm_actions(
132        remote_addr: String,
133        llm_client: OllamaClient,
134        app_state: Arc<AppState>,
135        status_tx: mpsc::UnboundedSender<String>,
136        client_id: NetGetClientId,
137    ) -> Result<SocketAddr> {
138        info!(
139            "OAuth2 client {} initializing for {}",
140            client_id, remote_addr
141        );
142
143        // Every OAuth2 flow reads its configuration out of `protocol_data`, but the generic
144        // creation path (dashboard form, MCP, `open_client`) only stores the validated
145        // startup params on the client and leaves `protocol_data` empty - so a client
146        // created that way arrived here unconfigured. Seed the fields once, without
147        // overwriting anything a caller already put there.
148        app_state
149            .with_client_mut(client_id, |client| {
150                if let Some(serde_json::Value::Object(params)) = client.startup_params.clone() {
151                    for (key, value) in params {
152                        if client.get_protocol_field(&key).is_none() {
153                            client.set_protocol_field(key, value);
154                        }
155                    }
156                }
157            })
158            .await;
159
160        // Get startup parameters from protocol data
161        let (oauth_client_id, oauth_client_secret, auth_url_opt, token_url, scopes_opt) = app_state
162            .with_client_mut(client_id, |client| {
163                let client_id_val = client
164                    .get_protocol_field("client_id")
165                    .and_then(|v| v.as_str())
166                    .context("Missing OAuth2 client_id startup parameter")?;
167
168                let client_secret_val = client
169                    .get_protocol_field("client_secret")
170                    .and_then(|v| v.as_str())
171                    .map(|s| s.to_string());
172
173                let auth_url = client
174                    .get_protocol_field("auth_url")
175                    .and_then(|v| v.as_str())
176                    .map(|s| s.to_string());
177
178                let token_url_val = client
179                    .get_protocol_field("token_url")
180                    .and_then(|v| v.as_str())
181                    .context("Missing OAuth2 token_url startup parameter")?;
182
183                let scopes = client
184                    .get_protocol_field("scopes")
185                    .and_then(|v| v.as_str())
186                    .map(|s| s.to_string());
187
188                Ok::<_, anyhow::Error>((
189                    client_id_val.to_string(),
190                    client_secret_val,
191                    auth_url,
192                    token_url_val.to_string(),
193                    scopes,
194                ))
195            })
196            .await
197            .context("Client not found")??;
198
199        // In the browser build the transport has no TLS: an `https://` token endpoint is refused
200        // here, with the reason, rather than on the first token request.
201        crate::client::http_fetch::check_url(&token_url)?;
202
203        // Build OAuth2 client
204        let client_id_obj = ClientId::new(oauth_client_id);
205        let client_secret_obj = oauth_client_secret.map(ClientSecret::new);
206
207        let token_url_obj = TokenUrl::new(token_url.clone()).context("Invalid token URL")?;
208
209        // If auth_url is not provided, use token_url as placeholder (won't be used for flows that don't need it)
210        let auth_url_obj = if let Some(auth_url_str) = auth_url_opt.clone() {
211            AuthUrl::new(auth_url_str).context("Invalid auth URL")?
212        } else {
213            AuthUrl::new(token_url.clone()).context("Invalid token URL for auth placeholder")?
214        };
215
216        let _oauth_client = BasicClient::new(
217            client_id_obj,
218            client_secret_obj.clone(),
219            auth_url_obj,
220            Some(token_url_obj),
221        );
222
223        // Note: Device authorization URL would be set here if needed for device code flow
224        // Format: DeviceAuthorizationUrl::new(format!("{}/device/code", remote_addr))
225        // OAuth client is constructed here to validate configuration but not used yet
226
227        // Store OAuth2 client configuration in protocol data
228        app_state
229            .with_client_mut(client_id, |client| {
230                client
231                    .set_protocol_field("oauth2_initialized".to_string(), serde_json::json!(true));
232                client.set_protocol_field("token_url".to_string(), serde_json::json!(token_url));
233                if let Some(auth_url) = &auth_url_opt {
234                    client.set_protocol_field("auth_url".to_string(), serde_json::json!(auth_url));
235                }
236                if let Some(scopes) = &scopes_opt {
237                    client.set_protocol_field(
238                        "default_scopes".to_string(),
239                        serde_json::json!(scopes),
240                    );
241                }
242            })
243            .await;
244
245        // Update status
246        app_state
247            .update_client_status(client_id, ClientStatus::Connected)
248            .await;
249        Log::new(Some(&status_tx)).info(format!(
250            "OAuth2 client {} ready for {}",
251            client_id, remote_addr
252        ));
253        let _ = status_tx.send("__UPDATE_UI__".to_string());
254
255        // Command channel for injected actions (the dashboard's [ exchange_password ],
256        // [ refresh_token ], ...). Registered BEFORE the connected-event LLM call below,
257        // which is awaited inline and which a manual `*` routing rule can park for minutes
258        // - the operator must be able to drive the flow while it waits.
259        let command_rx =
260            crate::client::command_support::register_command_channel(&app_state, client_id).await;
261        let cmd_task = tokio::spawn(Self::command_loop(
262            command_rx,
263            client_id,
264            app_state.clone(),
265            llm_client.clone(),
266            status_tx.clone(),
267        ));
268        app_state.register_client_task(client_id, cmd_task).await;
269
270        // Call LLM with connected event
271        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
272            let protocol = Arc::new(OAuth2ClientProtocol::new());
273            let event = Event::new(
274                &OAUTH2_CLIENT_CONNECTED_EVENT,
275                serde_json::json!({
276                    "token_url": token_url,
277                    "auth_url": auth_url_opt,
278                }),
279            );
280
281            let memory = app_state
282                .get_memory_for_client(client_id)
283                .await
284                .unwrap_or_default();
285
286            match call_llm_for_client(
287                &llm_client,
288                &app_state,
289                client_id.to_string(),
290                &instruction,
291                &memory,
292                Some(&event),
293                protocol.as_ref(),
294                &status_tx,
295            )
296            .await
297            {
298                Ok(ClientLlmResult {
299                    actions,
300                    memory_updates,
301                }) => {
302                    // Update memory
303                    if let Some(mem) = memory_updates {
304                        app_state.set_memory_for_client(client_id, mem).await;
305                    }
306
307                    // Execute actions through the same path injected commands use.
308                    for action in actions {
309                        use crate::llm::actions::client_trait::Client;
310                        let result = match protocol.execute_action(action) {
311                            Ok(result) => result,
312                            Err(e) => {
313                                error!("OAuth2 client {} rejected action: {}", client_id, e);
314                                continue;
315                            }
316                        };
317                        match Self::apply_action(
318                            result,
319                            Dispatch::Inline,
320                            client_id,
321                            app_state.clone(),
322                            llm_client.clone(),
323                            status_tx.clone(),
324                        )
325                        .await
326                        {
327                            Ok(Applied::Ran(detail)) => {
328                                info!("OAuth2 client {}: {}", client_id, detail)
329                            }
330                            Ok(Applied::Disconnect) => break,
331                            Err(e) => error!("Failed to execute OAuth2 action: {}", e),
332                        }
333                    }
334                }
335                Err(e) => {
336                    error!("LLM error for OAuth2 client {}: {}", client_id, e);
337                }
338            }
339        }
340
341        // No idle-poll task: the command loop is this client's long-lived task and it ends
342        // when the client is removed (`remove_client` drops the command sender). The old
343        // poll was also never registered, so stop_client could not abort it.
344
345        // Return dummy local address (OAuth2 is HTTP-based)
346        Ok("0.0.0.0:0".parse().unwrap())
347    }
348
349    /// Run one executed action. Shared by the connected-event LLM path and by injected
350    /// commands, so a flow is driven by exactly one implementation whoever asked for it.
351    ///
352    /// The description returned by [`Applied::Ran`] is built from what is observably true
353    /// *after* the flow ran - crucially, whether the provider's answer actually produced a
354    /// stored access token. Nothing here can mint a code or a token on its own: a failed
355    /// exchange reports the failure, it never reads as a success.
356    async fn apply_action(
357        action_result: ClientActionResult,
358        dispatch: Dispatch,
359        client_id: NetGetClientId,
360        app_state: Arc<AppState>,
361        llm_client: OllamaClient,
362        status_tx: mpsc::UnboundedSender<String>,
363    ) -> Result<Applied> {
364        match action_result {
365            ClientActionResult::Custom { name, data } => match name.as_str() {
366                "oauth2_exchange_password" => {
367                    Self::exchange_password(
368                        client_id,
369                        data,
370                        app_state.clone(),
371                        llm_client,
372                        status_tx,
373                        dispatch,
374                    )
375                    .await?;
376                    Ok(Applied::Ran(
377                        Self::token_state_detail(&name, client_id, &app_state).await,
378                    ))
379                }
380                "oauth2_exchange_client_credentials" => {
381                    Self::exchange_client_credentials(
382                        client_id,
383                        data,
384                        app_state.clone(),
385                        llm_client,
386                        status_tx,
387                        dispatch,
388                    )
389                    .await?;
390                    Ok(Applied::Ran(
391                        Self::token_state_detail(&name, client_id, &app_state).await,
392                    ))
393                }
394                "oauth2_start_device_code" => {
395                    Self::start_device_code_flow(
396                        client_id,
397                        data,
398                        app_state.clone(),
399                        llm_client,
400                        status_tx,
401                        dispatch,
402                    )
403                    .await?;
404                    let has_code = app_state
405                        .with_client_mut(client_id, |client| {
406                            client.get_protocol_field("device_code").is_some()
407                        })
408                        .await
409                        .unwrap_or(false);
410                    Ok(Applied::Ran(if has_code {
411                        "oauth2_start_device_code: device code issued, polling started".to_string()
412                    } else {
413                        "oauth2_start_device_code: provider issued no device code".to_string()
414                    }))
415                }
416                "oauth2_poll_device_code" => {
417                    Self::poll_device_code(
418                        client_id,
419                        app_state.clone(),
420                        llm_client,
421                        status_tx,
422                        dispatch,
423                    )
424                    .await?;
425                    Ok(Applied::Ran(
426                        Self::token_state_detail(&name, client_id, &app_state).await,
427                    ))
428                }
429                "oauth2_refresh_token" => {
430                    Self::refresh_token(
431                        client_id,
432                        app_state.clone(),
433                        llm_client,
434                        status_tx,
435                        dispatch,
436                    )
437                    .await?;
438                    Ok(Applied::Ran(
439                        Self::token_state_detail(&name, client_id, &app_state).await,
440                    ))
441                }
442                "oauth2_generate_auth_url" => {
443                    Self::generate_auth_url(client_id, data, app_state, status_tx).await?;
444                    Ok(Applied::Ran(
445                        "oauth2_generate_auth_url: authorization URL built (nothing sent; the \
446                         user's browser carries it)"
447                            .to_string(),
448                    ))
449                }
450                "oauth2_exchange_code" => {
451                    Self::exchange_code(
452                        client_id,
453                        data,
454                        app_state.clone(),
455                        llm_client,
456                        status_tx,
457                        dispatch,
458                    )
459                    .await?;
460                    Ok(Applied::Ran(
461                        Self::token_state_detail(&name, client_id, &app_state).await,
462                    ))
463                }
464                other => Err(anyhow::anyhow!(
465                    "OAuth2 client cannot execute custom result '{}'",
466                    other
467                )),
468            },
469            ClientActionResult::Disconnect => {
470                info!("OAuth2 client {} disconnecting", client_id);
471                app_state
472                    .update_client_status(client_id, ClientStatus::Disconnected)
473                    .await;
474                let _ = status_tx.send("__UPDATE_UI__".to_string());
475                Ok(Applied::Disconnect)
476            }
477            // WaitForMore / NoAction / SendData / nested Multiple: OAuth2 has no socket of
478            // its own, so none of these produce a flow.
479            _ => Ok(Applied::Ran(
480                "no OAuth2 flow run (action produced no request)".to_string(),
481            )),
482        }
483    }
484
485    /// Describe a token flow by what it left behind - never by assuming it worked.
486    async fn token_state_detail(
487        name: &str,
488        client_id: NetGetClientId,
489        app_state: &Arc<AppState>,
490    ) -> String {
491        let has_token = app_state
492            .with_client_mut(client_id, |client| {
493                client.get_protocol_field("access_token").is_some()
494            })
495            .await
496            .unwrap_or(false);
497        if has_token {
498            format!("{name}: completed, an access token is stored")
499        } else {
500            format!(
501                "{name}: completed but no access token was stored - the provider did not \
502                 issue one (see the oauth2_error event and netget.log)"
503            )
504        }
505    }
506
507    /// Build OAuth2 client from stored config
508    fn build_oauth_client(
509        oauth_client_id: String,
510        oauth_client_secret: Option<String>,
511        auth_url_opt: Option<String>,
512        token_url: String,
513        device_auth_url_opt: Option<String>,
514    ) -> Result<BasicClient> {
515        let client_id_obj = ClientId::new(oauth_client_id);
516        let client_secret_obj = oauth_client_secret.map(ClientSecret::new);
517        let token_url_obj = TokenUrl::new(token_url.clone())?;
518
519        // If auth_url is not provided, use token_url as placeholder (won't be used for flows that don't need it)
520        let auth_url_obj = if let Some(auth_url_str) = auth_url_opt {
521            AuthUrl::new(auth_url_str)?
522        } else {
523            AuthUrl::new(token_url)?
524        };
525
526        let mut oauth_client = BasicClient::new(
527            client_id_obj,
528            client_secret_obj,
529            auth_url_obj,
530            Some(token_url_obj),
531        );
532
533        if let Some(device_url_str) = device_auth_url_opt {
534            oauth_client = oauth_client
535                .set_device_authorization_url(DeviceAuthorizationUrl::new(device_url_str)?);
536        }
537
538        Ok(oauth_client)
539    }
540
541    /// Exchange username/password for access token
542    async fn exchange_password(
543        client_id: NetGetClientId,
544        data: serde_json::Value,
545        app_state: Arc<AppState>,
546        llm_client: OllamaClient,
547        status_tx: mpsc::UnboundedSender<String>,
548        dispatch: Dispatch,
549    ) -> Result<()> {
550        let username = data["username"]
551            .as_str()
552            .context("Missing username")?
553            .to_string();
554        let password = data["password"]
555            .as_str()
556            .context("Missing password")?
557            .to_string();
558        let scopes_str = data["scopes"].as_str().map(|s| s.to_string());
559
560        info!("OAuth2 client {} exchanging password for token", client_id);
561
562        // Get OAuth2 client config
563        let (oauth_client_id, oauth_client_secret, auth_url, token_url, _) = app_state
564            .with_client_mut(client_id, |client| {
565                let cid = client
566                    .get_protocol_field("client_id")
567                    .and_then(|v| v.as_str())
568                    .map(|s| s.to_string())
569                    .context("Missing client_id")?;
570                let csecret = client
571                    .get_protocol_field("client_secret")
572                    .and_then(|v| v.as_str())
573                    .map(|s| s.to_string());
574                let aurl = client
575                    .get_protocol_field("auth_url")
576                    .and_then(|v| v.as_str())
577                    .map(|s| s.to_string());
578                let turl = client
579                    .get_protocol_field("token_url")
580                    .and_then(|v| v.as_str())
581                    .map(|s| s.to_string())
582                    .context("Missing token_url")?;
583                let dscopes = client
584                    .get_protocol_field("default_scopes")
585                    .and_then(|v| v.as_str())
586                    .map(|s| s.to_string());
587                Ok::<_, anyhow::Error>((cid, csecret, aurl, turl, dscopes))
588            })
589            .await
590            .context("Client not found")??;
591
592        let oauth_client = Self::build_oauth_client(
593            oauth_client_id,
594            oauth_client_secret,
595            auth_url,
596            token_url,
597            None,
598        )?;
599
600        // Build token request
601        let username_obj = ResourceOwnerUsername::new(username);
602        let password_obj = ResourceOwnerPassword::new(password);
603        let mut token_request = oauth_client.exchange_password(&username_obj, &password_obj);
604
605        // Add scopes
606        if let Some(scopes) = scopes_str {
607            for scope in scopes.split_whitespace() {
608                token_request = token_request.add_scope(Scope::new(scope.to_string()));
609            }
610        }
611
612        // Execute token exchange
613        match token_request.request_async(http_hook).await {
614            Ok(token_response) => {
615                Self::handle_token_response(
616                    client_id,
617                    token_response,
618                    app_state,
619                    llm_client,
620                    status_tx,
621                    dispatch,
622                )
623                .await?;
624            }
625            Err(e) => {
626                Self::handle_oauth_error(
627                    client_id,
628                    format!("password_exchange_failed: {}", e),
629                    app_state,
630                    llm_client,
631                    status_tx,
632                    dispatch,
633                )
634                .await?;
635            }
636        }
637
638        Ok(())
639    }
640
641    /// Exchange client credentials for access token
642    async fn exchange_client_credentials(
643        client_id: NetGetClientId,
644        data: serde_json::Value,
645        app_state: Arc<AppState>,
646        llm_client: OllamaClient,
647        status_tx: mpsc::UnboundedSender<String>,
648        dispatch: Dispatch,
649    ) -> Result<()> {
650        let scopes_str = data["scopes"].as_str().map(|s| s.to_string());
651
652        info!(
653            "OAuth2 client {} exchanging client credentials for token",
654            client_id
655        );
656
657        // Get OAuth2 client config
658        let (oauth_client_id, oauth_client_secret, auth_url, token_url, _) = app_state
659            .with_client_mut(client_id, |client| {
660                let cid = client
661                    .get_protocol_field("client_id")
662                    .and_then(|v| v.as_str())
663                    .map(|s| s.to_string())
664                    .context("Missing client_id")?;
665                let csecret = client
666                    .get_protocol_field("client_secret")
667                    .and_then(|v| v.as_str())
668                    .map(|s| s.to_string());
669                let aurl = client
670                    .get_protocol_field("auth_url")
671                    .and_then(|v| v.as_str())
672                    .map(|s| s.to_string());
673                let turl = client
674                    .get_protocol_field("token_url")
675                    .and_then(|v| v.as_str())
676                    .map(|s| s.to_string())
677                    .context("Missing token_url")?;
678                let dscopes = client
679                    .get_protocol_field("default_scopes")
680                    .and_then(|v| v.as_str())
681                    .map(|s| s.to_string());
682                Ok::<_, anyhow::Error>((cid, csecret, aurl, turl, dscopes))
683            })
684            .await
685            .context("Client not found")??;
686
687        let oauth_client = Self::build_oauth_client(
688            oauth_client_id,
689            oauth_client_secret,
690            auth_url,
691            token_url,
692            None,
693        )?;
694
695        // Build token request
696        let mut token_request = oauth_client.exchange_client_credentials();
697
698        // Add scopes
699        if let Some(scopes) = scopes_str {
700            for scope in scopes.split_whitespace() {
701                token_request = token_request.add_scope(Scope::new(scope.to_string()));
702            }
703        }
704
705        // Execute token exchange
706        match token_request.request_async(http_hook).await {
707            Ok(token_response) => {
708                Self::handle_token_response(
709                    client_id,
710                    token_response,
711                    app_state,
712                    llm_client,
713                    status_tx,
714                    dispatch,
715                )
716                .await?;
717            }
718            Err(e) => {
719                Self::handle_oauth_error(
720                    client_id,
721                    format!("client_credentials_exchange_failed: {}", e),
722                    app_state,
723                    llm_client,
724                    status_tx,
725                    dispatch,
726                )
727                .await?;
728            }
729        }
730
731        Ok(())
732    }
733
734    /// Start device code flow
735    async fn start_device_code_flow(
736        client_id: NetGetClientId,
737        data: serde_json::Value,
738        app_state: Arc<AppState>,
739        llm_client: OllamaClient,
740        status_tx: mpsc::UnboundedSender<String>,
741        dispatch: Dispatch,
742    ) -> Result<()> {
743        let scopes_str = data["scopes"].as_str().map(|s| s.to_string());
744
745        info!("OAuth2 client {} starting device code flow", client_id);
746
747        // Get OAuth2 client config
748        let (oauth_client_id, oauth_client_secret, auth_url, token_url, device_auth_url) =
749            app_state
750                .with_client_mut(client_id, |client| {
751                    let cid = client
752                        .get_protocol_field("client_id")
753                        .and_then(|v| v.as_str())
754                        .map(|s| s.to_string())
755                        .context("Missing client_id")?;
756                    let csecret = client
757                        .get_protocol_field("client_secret")
758                        .and_then(|v| v.as_str())
759                        .map(|s| s.to_string());
760                    let aurl = client
761                        .get_protocol_field("auth_url")
762                        .and_then(|v| v.as_str())
763                        .map(|s| s.to_string());
764                    let turl = client
765                        .get_protocol_field("token_url")
766                        .and_then(|v| v.as_str())
767                        .map(|s| s.to_string())
768                        .context("Missing token_url")?;
769                    let durl = client
770                        .get_protocol_field("device_auth_url")
771                        .and_then(|v| v.as_str())
772                        .map(|s| s.to_string());
773                    Ok::<_, anyhow::Error>((cid, csecret, aurl, turl, durl))
774                })
775                .await
776                .context("Client not found")??;
777
778        let oauth_client = Self::build_oauth_client(
779            oauth_client_id,
780            oauth_client_secret,
781            auth_url,
782            token_url,
783            device_auth_url,
784        )?;
785
786        // Build device authorization request
787        let mut device_auth_request = oauth_client
788            .exchange_device_code()
789            .context("Device authorization URL not configured")?;
790
791        // Add scopes
792        if let Some(scopes) = scopes_str {
793            for scope in scopes.split_whitespace() {
794                device_auth_request = device_auth_request.add_scope(Scope::new(scope.to_string()));
795            }
796        }
797
798        // Execute device authorization request
799        match device_auth_request
800            .request_async::<_, _, _, EmptyExtraDeviceAuthorizationFields>(http_hook)
801            .await
802        {
803            Ok(device_response) => {
804                let verification_uri = device_response.verification_uri().to_string();
805                let user_code = device_response.user_code().secret().to_string();
806                let device_code = device_response.device_code().secret().to_string();
807                let interval = device_response.interval().as_secs();
808
809                info!(
810                    "OAuth2 client {} device code flow: visit {} and enter code {}",
811                    client_id, verification_uri, user_code
812                );
813
814                // Store device code for polling
815                app_state
816                    .with_client_mut(client_id, |client| {
817                        client.set_protocol_field(
818                            "device_code".to_string(),
819                            serde_json::json!(device_code),
820                        );
821                        client.set_protocol_field(
822                            "polling_interval".to_string(),
823                            serde_json::json!(interval),
824                        );
825                    })
826                    .await;
827
828                // Call LLM with device code event
829                let event = Event::new(
830                    &OAUTH2_DEVICE_CODE_EVENT,
831                    serde_json::json!({
832                        "verification_uri": verification_uri,
833                        "user_code": user_code,
834                        "device_code": "[REDACTED]",
835                        "interval": interval,
836                    }),
837                );
838                Self::notify_event(
839                    client_id,
840                    event,
841                    app_state.clone(),
842                    llm_client.clone(),
843                    status_tx.clone(),
844                    dispatch,
845                )
846                .await;
847
848                // Spawn polling task
849                let app_state_clone = app_state.clone();
850                let llm_client_clone = llm_client.clone();
851                let status_tx_clone = status_tx.clone();
852                // Tracked, not detached: stop_client must abort this task too.
853                let task_owner = app_state.clone();
854                task_owner
855                    .spawn_client_task(client_id, async move {
856                        for _ in 0..60 {
857                            // Poll for up to 5 minutes
858                            tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
859
860                            if let Err(e) = Self::poll_device_code(
861                                client_id,
862                                app_state_clone.clone(),
863                                llm_client_clone.clone(),
864                                status_tx_clone.clone(),
865                                // The polling task is nobody's injected command: report its
866                                // token event inline, in its own task.
867                                Dispatch::Inline,
868                            )
869                            .await
870                            {
871                                error!("Device code polling error: {}", e);
872                                break;
873                            }
874
875                            // Check if token was obtained
876                            let has_token = app_state_clone
877                                .with_client_mut(client_id, |client| {
878                                    client.get_protocol_field("access_token").is_some()
879                                })
880                                .await
881                                .unwrap_or(false);
882
883                            if has_token {
884                                break;
885                            }
886                        }
887                    })
888                    .await;
889            }
890            Err(e) => {
891                Self::handle_oauth_error(
892                    client_id,
893                    format!("device_code_failed: {}", e),
894                    app_state,
895                    llm_client,
896                    status_tx,
897                    dispatch,
898                )
899                .await?;
900            }
901        }
902
903        Ok(())
904    }
905
906    /// Poll device code for completion
907    async fn poll_device_code(
908        client_id: NetGetClientId,
909        app_state: Arc<AppState>,
910        llm_client: OllamaClient,
911        status_tx: mpsc::UnboundedSender<String>,
912        dispatch: Dispatch,
913    ) -> Result<()> {
914        // Get device code and OAuth client config
915        let (device_code_str, oauth_client_id, oauth_client_secret, _auth_url, token_url) =
916            app_state
917                .with_client_mut(client_id, |client| {
918                    let dc = client
919                        .get_protocol_field("device_code")
920                        .and_then(|v| v.as_str())
921                        .map(|s| s.to_string())
922                        .context("Missing device_code")?;
923                    let cid = client
924                        .get_protocol_field("client_id")
925                        .and_then(|v| v.as_str())
926                        .map(|s| s.to_string())
927                        .context("Missing client_id")?;
928                    let csecret = client
929                        .get_protocol_field("client_secret")
930                        .and_then(|v| v.as_str())
931                        .map(|s| s.to_string());
932                    let aurl = client
933                        .get_protocol_field("auth_url")
934                        .and_then(|v| v.as_str())
935                        .map(|s| s.to_string());
936                    let turl = client
937                        .get_protocol_field("token_url")
938                        .and_then(|v| v.as_str())
939                        .map(|s| s.to_string())
940                        .context("Missing token_url")?;
941                    Ok::<_, anyhow::Error>((dc, cid, csecret, aurl, turl))
942                })
943                .await
944                .context("Client not found")??;
945
946        // Make direct HTTP request to token endpoint for device code polling
947        // This is a workaround since we can't reconstruct DeviceAuthorizationResponse
948        let client = plain_http_client();
949        let mut params = vec![
950            ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
951            ("device_code", device_code_str.as_str()),
952            ("client_id", oauth_client_id.as_str()),
953        ];
954
955        // Add client secret if present
956        let client_secret_param;
957        if let Some(ref secret) = oauth_client_secret {
958            client_secret_param = secret.clone();
959            params.push(("client_secret", client_secret_param.as_str()));
960        }
961
962        match client.post(&token_url).form(&params).send().await {
963            Ok(response) => {
964                let status = response.status();
965                let body: serde_json::Value = response.json().await?;
966
967                if status.is_success() {
968                    // Token obtained successfully
969                    if let Some(access_token) = body.get("access_token").and_then(|v| v.as_str()) {
970                        let token_type = body
971                            .get("token_type")
972                            .and_then(|v| v.as_str())
973                            .unwrap_or("Bearer");
974                        let expires_in = body
975                            .get("expires_in")
976                            .and_then(|v| v.as_u64())
977                            .unwrap_or(3600);
978                        let refresh_token = body
979                            .get("refresh_token")
980                            .and_then(|v| v.as_str())
981                            .map(|s| s.to_string());
982                        let scope = body.get("scope").and_then(|v| v.as_str()).unwrap_or("");
983
984                        // Store tokens
985                        app_state
986                            .with_client_mut(client_id, |client| {
987                                client.set_protocol_field(
988                                    "access_token".to_string(),
989                                    serde_json::json!(access_token),
990                                );
991                                client.set_protocol_field(
992                                    "token_type".to_string(),
993                                    serde_json::json!(token_type),
994                                );
995                                client.set_protocol_field(
996                                    "expires_in".to_string(),
997                                    serde_json::json!(expires_in),
998                                );
999                                if let Some(rt) = &refresh_token {
1000                                    client.set_protocol_field(
1001                                        "refresh_token".to_string(),
1002                                        serde_json::json!(rt),
1003                                    );
1004                                }
1005                                if !scope.is_empty() {
1006                                    client.set_protocol_field(
1007                                        "scopes".to_string(),
1008                                        serde_json::json!(scope),
1009                                    );
1010                                }
1011                            })
1012                            .await;
1013
1014                        info!("OAuth2 client {} device code token obtained", client_id);
1015
1016                        // Call LLM with token obtained event
1017                        let event = Event::new(
1018                            &OAUTH2_TOKEN_OBTAINED_EVENT,
1019                            serde_json::json!({
1020                                "access_token": "[REDACTED]",
1021                                "token_type": token_type,
1022                                "expires_in": expires_in,
1023                                "refresh_token": if refresh_token.is_some() { "[REDACTED]" } else { "" },
1024                                "scope": scope,
1025                            }),
1026                        );
1027                        Self::notify_event(
1028                            client_id,
1029                            event,
1030                            app_state.clone(),
1031                            llm_client.clone(),
1032                            status_tx.clone(),
1033                            dispatch,
1034                        )
1035                        .await;
1036                    }
1037                } else {
1038                    // Check for authorization_pending error
1039                    let error = body
1040                        .get("error")
1041                        .and_then(|v| v.as_str())
1042                        .unwrap_or("unknown");
1043                    if error != "authorization_pending" && error != "slow_down" {
1044                        error!("Device code polling error: {}", error);
1045                    }
1046                }
1047            }
1048            Err(e) => {
1049                error!("Device code polling HTTP error: {}", e);
1050            }
1051        }
1052
1053        Ok(())
1054    }
1055
1056    /// Refresh access token using refresh token
1057    async fn refresh_token(
1058        client_id: NetGetClientId,
1059        app_state: Arc<AppState>,
1060        llm_client: OllamaClient,
1061        status_tx: mpsc::UnboundedSender<String>,
1062        dispatch: Dispatch,
1063    ) -> Result<()> {
1064        info!("OAuth2 client {} refreshing token", client_id);
1065
1066        // Get refresh token and OAuth client config
1067        let (refresh_token_str, oauth_client_id, oauth_client_secret, auth_url, token_url) =
1068            app_state
1069                .with_client_mut(client_id, |client| {
1070                    let rt = client
1071                        .get_protocol_field("refresh_token")
1072                        .and_then(|v| v.as_str())
1073                        .map(|s| s.to_string())
1074                        .context("No refresh token available")?;
1075                    let cid = client
1076                        .get_protocol_field("client_id")
1077                        .and_then(|v| v.as_str())
1078                        .map(|s| s.to_string())
1079                        .context("Missing client_id")?;
1080                    let csecret = client
1081                        .get_protocol_field("client_secret")
1082                        .and_then(|v| v.as_str())
1083                        .map(|s| s.to_string());
1084                    let aurl = client
1085                        .get_protocol_field("auth_url")
1086                        .and_then(|v| v.as_str())
1087                        .map(|s| s.to_string());
1088                    let turl = client
1089                        .get_protocol_field("token_url")
1090                        .and_then(|v| v.as_str())
1091                        .map(|s| s.to_string())
1092                        .context("Missing token_url")?;
1093                    Ok::<_, anyhow::Error>((rt, cid, csecret, aurl, turl))
1094                })
1095                .await
1096                .context("Client not found")??;
1097
1098        let oauth_client = Self::build_oauth_client(
1099            oauth_client_id,
1100            oauth_client_secret,
1101            auth_url,
1102            token_url,
1103            None,
1104        )?;
1105
1106        // Execute token refresh
1107        match oauth_client
1108            .exchange_refresh_token(&RefreshToken::new(refresh_token_str))
1109            .request_async(http_hook)
1110            .await
1111        {
1112            Ok(token_response) => {
1113                Self::handle_token_response(
1114                    client_id,
1115                    token_response,
1116                    app_state,
1117                    llm_client,
1118                    status_tx,
1119                    dispatch,
1120                )
1121                .await?;
1122            }
1123            Err(e) => {
1124                Self::handle_oauth_error(
1125                    client_id,
1126                    format!("token_refresh_failed: {}", e),
1127                    app_state,
1128                    llm_client,
1129                    status_tx,
1130                    dispatch,
1131                )
1132                .await?;
1133            }
1134        }
1135
1136        Ok(())
1137    }
1138
1139    /// Generate authorization URL for authorization code flow
1140    async fn generate_auth_url(
1141        client_id: NetGetClientId,
1142        data: serde_json::Value,
1143        app_state: Arc<AppState>,
1144        status_tx: mpsc::UnboundedSender<String>,
1145    ) -> Result<()> {
1146        let scopes_str = data["scopes"].as_str().map(|s| s.to_string());
1147        let redirect_uri_str = data["redirect_uri"]
1148            .as_str()
1149            .unwrap_or("http://localhost:8080/callback");
1150
1151        info!("OAuth2 client {} generating auth URL", client_id);
1152
1153        // Get OAuth client config
1154        let (oauth_client_id, oauth_client_secret, auth_url, token_url, _) = app_state
1155            .with_client_mut(client_id, |client| {
1156                let cid = client
1157                    .get_protocol_field("client_id")
1158                    .and_then(|v| v.as_str())
1159                    .map(|s| s.to_string())
1160                    .context("Missing client_id")?;
1161                let csecret = client
1162                    .get_protocol_field("client_secret")
1163                    .and_then(|v| v.as_str())
1164                    .map(|s| s.to_string());
1165                let aurl = client
1166                    .get_protocol_field("auth_url")
1167                    .and_then(|v| v.as_str())
1168                    .map(|s| s.to_string())
1169                    .context("Missing auth_url for authorization code flow")?;
1170                let turl = client
1171                    .get_protocol_field("token_url")
1172                    .and_then(|v| v.as_str())
1173                    .map(|s| s.to_string())
1174                    .context("Missing token_url")?;
1175                let dscopes = client
1176                    .get_protocol_field("default_scopes")
1177                    .and_then(|v| v.as_str())
1178                    .map(|s| s.to_string());
1179                Ok::<_, anyhow::Error>((cid, csecret, Some(aurl), turl, dscopes))
1180            })
1181            .await
1182            .context("Client not found")??;
1183
1184        let mut oauth_client = Self::build_oauth_client(
1185            oauth_client_id,
1186            oauth_client_secret,
1187            auth_url,
1188            token_url,
1189            None,
1190        )?;
1191
1192        // Set redirect URI
1193        oauth_client =
1194            oauth_client.set_redirect_uri(RedirectUrl::new(redirect_uri_str.to_string())?);
1195
1196        // Generate PKCE challenge
1197        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
1198
1199        // Build authorization URL
1200        let mut auth_request = oauth_client
1201            .authorize_url(CsrfToken::new_random)
1202            .set_pkce_challenge(pkce_challenge);
1203
1204        // Add scopes
1205        if let Some(scopes) = scopes_str {
1206            for scope in scopes.split_whitespace() {
1207                auth_request = auth_request.add_scope(Scope::new(scope.to_string()));
1208            }
1209        }
1210
1211        let (auth_url_result, csrf_token) = auth_request.url();
1212
1213        // Store PKCE verifier and CSRF token for later
1214        app_state
1215            .with_client_mut(client_id, |client| {
1216                client.set_protocol_field(
1217                    "pkce_verifier".to_string(),
1218                    serde_json::json!(pkce_verifier.secret()),
1219                );
1220                client.set_protocol_field(
1221                    "csrf_token".to_string(),
1222                    serde_json::json!(csrf_token.secret()),
1223                );
1224                client.set_protocol_field(
1225                    "redirect_uri".to_string(),
1226                    serde_json::json!(redirect_uri_str),
1227                );
1228            })
1229            .await;
1230
1231        let log = Log::new(Some(&status_tx));
1232        log.info(format!("OAuth2 authorization URL: {}", auth_url_result));
1233        log.info("Visit the URL above to authorize, then paste the code".to_string());
1234
1235        Ok(())
1236    }
1237
1238    /// Exchange authorization code for access token
1239    async fn exchange_code(
1240        client_id: NetGetClientId,
1241        data: serde_json::Value,
1242        app_state: Arc<AppState>,
1243        llm_client: OllamaClient,
1244        status_tx: mpsc::UnboundedSender<String>,
1245        dispatch: Dispatch,
1246    ) -> Result<()> {
1247        let code = data["code"].as_str().context("Missing code")?.to_string();
1248
1249        info!("OAuth2 client {} exchanging authorization code", client_id);
1250
1251        // Get OAuth client config and PKCE verifier
1252        let (
1253            pkce_verifier_str,
1254            redirect_uri,
1255            oauth_client_id,
1256            oauth_client_secret,
1257            auth_url,
1258            token_url,
1259        ) = app_state
1260            .with_client_mut(client_id, |client| {
1261                let pkce = client
1262                    .get_protocol_field("pkce_verifier")
1263                    .and_then(|v| v.as_str())
1264                    .map(|s| s.to_string())
1265                    .context("Missing PKCE verifier")?;
1266                let redir = client
1267                    .get_protocol_field("redirect_uri")
1268                    .and_then(|v| v.as_str())
1269                    .map(|s| s.to_string())
1270                    .context("Missing redirect_uri")?;
1271                let cid = client
1272                    .get_protocol_field("client_id")
1273                    .and_then(|v| v.as_str())
1274                    .map(|s| s.to_string())
1275                    .context("Missing client_id")?;
1276                let csecret = client
1277                    .get_protocol_field("client_secret")
1278                    .and_then(|v| v.as_str())
1279                    .map(|s| s.to_string());
1280                let aurl = client
1281                    .get_protocol_field("auth_url")
1282                    .and_then(|v| v.as_str())
1283                    .map(|s| s.to_string());
1284                let turl = client
1285                    .get_protocol_field("token_url")
1286                    .and_then(|v| v.as_str())
1287                    .map(|s| s.to_string())
1288                    .context("Missing token_url")?;
1289                Ok::<_, anyhow::Error>((pkce, redir, cid, csecret, aurl, turl))
1290            })
1291            .await
1292            .context("Client not found")??;
1293
1294        let mut oauth_client = Self::build_oauth_client(
1295            oauth_client_id,
1296            oauth_client_secret,
1297            auth_url,
1298            token_url,
1299            None,
1300        )?;
1301
1302        oauth_client = oauth_client.set_redirect_uri(RedirectUrl::new(redirect_uri)?);
1303
1304        // Exchange code for token
1305        let token_request = oauth_client
1306            .exchange_code(AuthorizationCode::new(code))
1307            .set_pkce_verifier(oauth2::PkceCodeVerifier::new(pkce_verifier_str));
1308
1309        match token_request.request_async(http_hook).await {
1310            Ok(token_response) => {
1311                Self::handle_token_response(
1312                    client_id,
1313                    token_response,
1314                    app_state,
1315                    llm_client,
1316                    status_tx,
1317                    dispatch,
1318                )
1319                .await?;
1320            }
1321            Err(e) => {
1322                Self::handle_oauth_error(
1323                    client_id,
1324                    format!("code_exchange_failed: {}", e),
1325                    app_state,
1326                    llm_client,
1327                    status_tx,
1328                    dispatch,
1329                )
1330                .await?;
1331            }
1332        }
1333
1334        Ok(())
1335    }
1336
1337    /// Handle successful token response
1338    async fn handle_token_response(
1339        client_id: NetGetClientId,
1340        token_response: TokenResponseType,
1341        app_state: Arc<AppState>,
1342        llm_client: OllamaClient,
1343        status_tx: mpsc::UnboundedSender<String>,
1344        dispatch: Dispatch,
1345    ) -> Result<()> {
1346        let access_token = token_response.access_token().secret().to_string();
1347        let token_type = token_response.token_type().as_ref().to_string();
1348        let expires_in = token_response
1349            .expires_in()
1350            .map(|d| d.as_secs())
1351            .unwrap_or(0);
1352        let refresh_token = token_response
1353            .refresh_token()
1354            .map(|rt| rt.secret().to_string());
1355        let scopes = token_response
1356            .scopes()
1357            .map(|s| {
1358                s.iter()
1359                    .map(|scope| scope.to_string())
1360                    .collect::<Vec<_>>()
1361                    .join(" ")
1362            })
1363            .unwrap_or_default();
1364
1365        info!(
1366            "OAuth2 client {} obtained access token (expires in {}s)",
1367            client_id, expires_in
1368        );
1369
1370        // Store tokens
1371        app_state
1372            .with_client_mut(client_id, |client| {
1373                client.set_protocol_field(
1374                    "access_token".to_string(),
1375                    serde_json::json!(access_token),
1376                );
1377                client.set_protocol_field("token_type".to_string(), serde_json::json!(token_type));
1378                client.set_protocol_field("expires_in".to_string(), serde_json::json!(expires_in));
1379                if let Some(rt) = &refresh_token {
1380                    client.set_protocol_field("refresh_token".to_string(), serde_json::json!(rt));
1381                }
1382                if !scopes.is_empty() {
1383                    client.set_protocol_field("scopes".to_string(), serde_json::json!(scopes));
1384                }
1385            })
1386            .await;
1387
1388        // Call LLM with token obtained event
1389        let event = Event::new(
1390            &OAUTH2_TOKEN_OBTAINED_EVENT,
1391            serde_json::json!({
1392                "access_token": "[REDACTED]",
1393                "token_type": token_type,
1394                "expires_in": expires_in,
1395                "refresh_token": if refresh_token.is_some() { "[REDACTED]" } else { "" },
1396                "scope": scopes,
1397            }),
1398        );
1399        Self::notify_event(client_id, event, app_state, llm_client, status_tx, dispatch).await;
1400
1401        Ok(())
1402    }
1403
1404    /// Handle OAuth error
1405    async fn handle_oauth_error(
1406        client_id: NetGetClientId,
1407        error: String,
1408        app_state: Arc<AppState>,
1409        llm_client: OllamaClient,
1410        status_tx: mpsc::UnboundedSender<String>,
1411        dispatch: Dispatch,
1412    ) -> Result<()> {
1413        Log::new(Some(&status_tx)).error(format!("OAuth2 client {} error: {}", client_id, error));
1414
1415        // Call LLM with error event
1416        let event = Event::new(
1417            &OAUTH2_ERROR_EVENT,
1418            serde_json::json!({
1419                "error": error,
1420                "error_description": "",
1421            }),
1422        );
1423        Self::notify_event(client_id, event, app_state, llm_client, status_tx, dispatch).await;
1424
1425        Ok(())
1426    }
1427
1428    /// Report one event to the LLM, inline or from a registered task.
1429    async fn notify_event(
1430        client_id: NetGetClientId,
1431        event: Event,
1432        app_state: Arc<AppState>,
1433        llm_client: OllamaClient,
1434        status_tx: mpsc::UnboundedSender<String>,
1435        dispatch: Dispatch,
1436    ) {
1437        match dispatch {
1438            // Deliberately nothing: see `Dispatch::Silent`.
1439            Dispatch::Silent => {}
1440            Dispatch::Inline => {
1441                Self::raise_event(client_id, event, app_state, llm_client, status_tx).await
1442            }
1443            Dispatch::Deferred => {
1444                let registrar = app_state.clone();
1445                let handle = tokio::spawn(Self::raise_event(
1446                    client_id, event, app_state, llm_client, status_tx,
1447                ));
1448                // Registered so stop_client aborts an in-flight LLM call for this event.
1449                registrar.register_client_task(client_id, handle).await;
1450            }
1451        }
1452    }
1453
1454    /// The event -> LLM round-trip itself, plus whatever the model answers.
1455    ///
1456    /// Returns a boxed future rather than being a plain `async fn`. Executing the answer
1457    /// closes a cycle -- raise_event -> apply_action -> a token flow -> notify_event ->
1458    /// raise_event -- and rustc cannot infer the type of a self-referential chain of
1459    /// `async fn`s. Boxing one member of the cycle turns that edge into an ordinary
1460    /// dynamic call.
1461    fn raise_event(
1462        client_id: NetGetClientId,
1463        event: Event,
1464        app_state: Arc<AppState>,
1465        llm_client: OllamaClient,
1466        status_tx: mpsc::UnboundedSender<String>,
1467    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
1468        Box::pin(async move {
1469            let protocol = Arc::new(OAuth2ClientProtocol::new());
1470            let instruction = app_state
1471                .get_instruction_for_client(client_id)
1472                .await
1473                .unwrap_or_default();
1474            let memory = app_state
1475                .get_memory_for_client(client_id)
1476                .await
1477                .unwrap_or_default();
1478
1479            match call_llm_for_client(
1480                &llm_client,
1481                &app_state,
1482                client_id.to_string(),
1483                &instruction,
1484                &memory,
1485                Some(&event),
1486                protocol.as_ref(),
1487                &status_tx,
1488            )
1489            .await
1490            {
1491                Ok(ClientLlmResult {
1492                    actions,
1493                    memory_updates,
1494                }) => {
1495                    if let Some(mem) = memory_updates {
1496                        app_state.set_memory_for_client(client_id, mem).await;
1497                    }
1498
1499                    // Do what the model asked for. This was discarded, so answering
1500                    // oauth2_token_obtained or oauth2_error did nothing -- a model that saw a
1501                    // token expire and wanted to refresh it, or saw an error and wanted to
1502                    // fall back to another grant, was ignored on the only events that report
1503                    // either.
1504                    //
1505                    // Every flow runs with Dispatch::Silent, so it raises no further event.
1506                    // That bound is not optional here: a refresh answered on a token event
1507                    // would raise another token event, whose answer could refresh again.
1508                    use crate::llm::actions::client_trait::Client;
1509                    for action in actions {
1510                        match protocol.execute_action(action.clone()) {
1511                            Ok(result) => {
1512                                if let Err(e) = Self::apply_action(
1513                                    result,
1514                                    Dispatch::Silent,
1515                                    client_id,
1516                                    app_state.clone(),
1517                                    llm_client.clone(),
1518                                    status_tx.clone(),
1519                                )
1520                                .await
1521                                {
1522                                    error!(
1523                                        "OAuth2 client {} follow-up action failed: {}",
1524                                        client_id, e
1525                                    );
1526                                }
1527                            }
1528                            Err(e) => error!(
1529                                "OAuth2 client {} rejected its own follow-up action: {}",
1530                                client_id, e
1531                            ),
1532                        }
1533                    }
1534                }
1535                Err(e) => {
1536                    error!("LLM error for OAuth2 client {}: {}", client_id, e);
1537                }
1538            }
1539        })
1540    }
1541
1542    /// Drain injected commands until the channel closes (the client was removed) or an
1543    /// injected `disconnect` ends the session.
1544    ///
1545    /// The flow itself is awaited, so the reported [`ClientSendOutcome`] describes what the
1546    /// provider actually did; only the resulting `oauth2_token_obtained` / `oauth2_error`
1547    /// event is handed to its own task, so a manual routing rule waiting on a human cannot
1548    /// hold up the reply. An action the protocol rejects is reported as `Rejected` and an
1549    /// action that ran without producing a token says so - there is no path here that can
1550    /// report success for a flow that did not complete.
1551    async fn command_loop(
1552        mut command_rx: mpsc::Receiver<ClientCommand>,
1553        client_id: NetGetClientId,
1554        app_state: Arc<AppState>,
1555        llm_client: OllamaClient,
1556        status_tx: mpsc::UnboundedSender<String>,
1557    ) {
1558        use crate::llm::actions::client_trait::Client;
1559        use crate::llm::actions::protocol_trait::Protocol;
1560
1561        let protocol = OAuth2ClientProtocol::new();
1562
1563        while let Some(command) = command_rx.recv().await {
1564            let action = command.action.clone();
1565            let outcome = match protocol.execute_action(action.clone()) {
1566                Err(e) => Ok(ClientSendOutcome::Rejected {
1567                    error: e.to_string(),
1568                }),
1569                Ok(result) => Self::apply_action(
1570                    result,
1571                    Dispatch::Deferred,
1572                    client_id,
1573                    app_state.clone(),
1574                    llm_client.clone(),
1575                    status_tx.clone(),
1576                )
1577                .await
1578                .map(|applied| match applied {
1579                    Applied::Disconnect => ClientSendOutcome::Disconnected,
1580                    Applied::Ran(detail) => ClientSendOutcome::Executed { detail },
1581                }),
1582            };
1583
1584            let outcome_json = match &outcome {
1585                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
1586                Err(e) => serde_json::json!({"error": e.to_string()}),
1587            };
1588            app_state
1589                .record_access_log(
1590                    AccessLogOwner::Client(client_id.as_u32()),
1591                    protocol.protocol_name(),
1592                    None,
1593                    "injected_action",
1594                    action,
1595                    vec![outcome_json],
1596                )
1597                .await;
1598
1599            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
1600            if let Err(e) = &outcome {
1601                error!("OAuth2 client {} injected action failed: {}", client_id, e);
1602                let _ = status_tx.send(format!(
1603                    "[WARN] Client {} injected action failed: {}",
1604                    client_id, e
1605                ));
1606            }
1607            let _ = status_tx.send("__UPDATE_UI__".to_string());
1608            crate::client::command_support::reply(command, outcome);
1609
1610            if disconnect {
1611                break;
1612            }
1613        }
1614
1615        app_state.remove_client_handle(client_id).await;
1616        let _ = status_tx.send("__UPDATE_UI__".to_string());
1617        info!("OAuth2 client {} command loop ended", client_id);
1618    }
1619}