From fb5ad8c7f7b64ecbf453f4e1400dac1127557ce0 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 3 Mar 2026 08:05:54 -0800 Subject: [PATCH] cli: handle send/render http plugin host requests --- crates-cli/yaak-cli/src/context.rs | 11 +- crates-cli/yaak-cli/src/plugin_events.rs | 228 +++++++++++++++--- .../template-function-response/src/index.ts | 3 +- 3 files changed, 212 insertions(+), 30 deletions(-) diff --git a/crates-cli/yaak-cli/src/context.rs b/crates-cli/yaak-cli/src/context.rs index af3753f4..c0750008 100644 --- a/crates-cli/yaak-cli/src/context.rs +++ b/crates-cli/yaak-cli/src/context.rs @@ -73,7 +73,16 @@ impl CliContext { }; let plugin_event_bridge = if let Some(plugin_manager) = &plugin_manager { - Some(CliPluginEventBridge::start(plugin_manager.clone(), query_manager.clone()).await) + Some( + CliPluginEventBridge::start( + plugin_manager.clone(), + query_manager.clone(), + blob_manager.clone(), + encryption_manager.clone(), + data_dir.clone(), + ) + .await, + ) } else { None }; diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index d9bb2037..b443448f 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -1,25 +1,52 @@ +use std::path::PathBuf; use std::sync::Arc; use tokio::task::JoinHandle; use yaak::plugin_events::{ GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event, }; +use yaak::render::render_http_request; +use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins}; +use yaak_crypto::manager::EncryptionManager; +use yaak_models::blob_manager::BlobManager; use yaak_models::query_manager::QueryManager; +use yaak_models::util::UpdateSource; use yaak_plugins::events::{ EmptyPayload, ErrorResponse, InternalEvent, InternalEventPayload, ListOpenWorkspacesResponse, - WorkspaceInfo, + RenderHttpRequestResponse, SendHttpRequestResponse, WorkspaceInfo, }; use yaak_plugins::manager::PluginManager; +use yaak_plugins::template_callback::PluginTemplateCallback; +use yaak_templates::RenderOptions; pub struct CliPluginEventBridge { rx_id: String, task: JoinHandle<()>, } +struct CliSendHttpContext { + blob_manager: BlobManager, + plugin_manager: Arc, + encryption_manager: Arc, + response_dir: PathBuf, +} + impl CliPluginEventBridge { - pub async fn start(plugin_manager: Arc, query_manager: QueryManager) -> Self { + pub async fn start( + plugin_manager: Arc, + query_manager: QueryManager, + blob_manager: BlobManager, + encryption_manager: Arc, + data_dir: PathBuf, + ) -> Self { let (rx_id, mut rx) = plugin_manager.subscribe("cli").await; let rx_id_for_task = rx_id.clone(); let pm = plugin_manager.clone(); + let send_http_context = Arc::new(CliSendHttpContext { + blob_manager, + plugin_manager, + encryption_manager, + response_dir: data_dir.join("responses"), + }); let task = tokio::spawn(async move { while let Some(event) = rx.recv().await { @@ -37,15 +64,30 @@ impl CliPluginEventBridge { continue; }; - let plugin_name = plugin_handle.info().name; - let Some(reply_payload) = build_plugin_reply(&query_manager, &event, &plugin_name) - else { - continue; - }; + let pm = pm.clone(); + let query_manager = query_manager.clone(); + let send_http_context = send_http_context.clone(); - if let Err(err) = pm.reply(&event, &reply_payload).await { - eprintln!("Warning: Failed replying to plugin event: {err}"); - } + // Avoid deadlocks for nested plugin-host requests (for example, template functions + // that trigger additional host requests during render) by handling each event in + // its own task. + tokio::spawn(async move { + let plugin_name = plugin_handle.info().name; + let Some(reply_payload) = build_plugin_reply( + &query_manager, + &event, + &plugin_name, + Some(send_http_context.as_ref()), + ) + .await + else { + return; + }; + + if let Err(err) = pm.reply(&event, &reply_payload).await { + eprintln!("Warning: Failed replying to plugin event: {err}"); + } + }); } pm.unsubscribe(&rx_id_for_task).await; @@ -61,10 +103,11 @@ impl CliPluginEventBridge { } } -fn build_plugin_reply( +async fn build_plugin_reply( query_manager: &QueryManager, event: &InternalEvent, plugin_name: &str, + send_http_context: Option<&CliSendHttpContext>, ) -> Option { match handle_shared_plugin_event( query_manager, @@ -101,9 +144,128 @@ fn build_plugin_reply( workspaces, })) } - req => Some(InternalEventPayload::ErrorResponse(ErrorResponse { - error: format!("Unsupported plugin request in CLI: {}", req.type_name()), - })), + HostRequest::SendHttpRequest(send_http_request_request) => { + let Some(send_ctx) = send_http_context else { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Send HTTP request support is not initialized in CLI".to_string(), + })); + }; + + let mut http_request = send_http_request_request.http_request.clone(); + if http_request.workspace_id.is_empty() { + let Some(workspace_id) = event.context.workspace_id.clone() else { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "workspace_id is required to send HTTP requests in CLI" + .to_string(), + })); + }; + http_request.workspace_id = workspace_id; + } + + let mut plugin_context = event.context.clone(); + if plugin_context.workspace_id.is_none() { + plugin_context.workspace_id = Some(http_request.workspace_id.clone()); + } + + match send_http_request_with_plugins(SendHttpRequestWithPluginsParams { + query_manager, + blob_manager: &send_ctx.blob_manager, + request: http_request, + environment_id: None, + update_source: UpdateSource::Plugin, + cookie_jar_id: None, + response_dir: &send_ctx.response_dir, + emit_events_to: None, + emit_response_body_chunks_to: None, + existing_response: None, + plugin_manager: send_ctx.plugin_manager.clone(), + encryption_manager: send_ctx.encryption_manager.clone(), + plugin_context: &plugin_context, + cancelled_rx: None, + connection_manager: None, + }) + .await + { + Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse( + SendHttpRequestResponse { http_response: result.response }, + )), + Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to send HTTP request in CLI: {err}"), + })), + } + } + HostRequest::CopyText(copy_text_request) => todo!("copy_text_request"), + HostRequest::PromptText(prompt_text_request) => todo!("prompt_text_request"), + HostRequest::PromptForm(prompt_form_request) => todo!("prompt_form_request"), + HostRequest::RenderGrpcRequest(render_grpc_request_request) => todo!("render_grpc"), + HostRequest::RenderHttpRequest(render_http_request_request) => { + let Some(send_ctx) = send_http_context else { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "Render HTTP request support is not initialized in CLI".to_string(), + })); + }; + + let mut http_request = render_http_request_request.http_request.clone(); + if http_request.workspace_id.is_empty() { + let Some(workspace_id) = event.context.workspace_id.clone() else { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: "workspace_id is required to render HTTP requests in CLI" + .to_string(), + })); + }; + http_request.workspace_id = workspace_id; + } + + let mut plugin_context = event.context.clone(); + if plugin_context.workspace_id.is_none() { + plugin_context.workspace_id = Some(http_request.workspace_id.clone()); + } + + let environment_chain = match query_manager.connect().resolve_environments( + &http_request.workspace_id, + http_request.folder_id.as_deref(), + None, + ) { + Ok(chain) => chain, + Err(err) => { + return Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to resolve environments in CLI: {err}"), + })); + } + }; + + let template_callback = PluginTemplateCallback::new( + send_ctx.plugin_manager.clone(), + send_ctx.encryption_manager.clone(), + &plugin_context, + render_http_request_request.purpose.clone(), + ); + let render_options = RenderOptions::throw(); + + match render_http_request( + &http_request, + environment_chain, + &template_callback, + &render_options, + ) + .await + { + Ok(http_request) => Some(InternalEventPayload::RenderHttpRequestResponse( + RenderHttpRequestResponse { http_request }, + )), + Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse { + error: format!("Failed to render HTTP request in CLI: {err}"), + })), + } + } + HostRequest::TemplateRender(template_render_request) => todo!("template_render"), + HostRequest::OpenWindow(open_window_request) => todo!("open_window"), + HostRequest::CloseWindow(close_window_request) => todo!("close_window"), + HostRequest::OpenExternalUrl(open_external_url_request) => todo!("open_url"), + HostRequest::ListCookieNames(list_cookie_names_request) => todo!("list_cookie"), + HostRequest::GetCookieValue(get_cookie_value_request) => todo!("get_cookie"), + HostRequest::WindowInfo(window_info_request) => todo!("window_info"), + HostRequest::OtherRequest(internal_event_payload) => todo!("other"), }, } } @@ -112,7 +274,8 @@ fn build_plugin_reply( mod tests { use super::*; use tempfile::TempDir; - use yaak_plugins::events::{GetKeyValueRequest, PluginContext, WindowInfoRequest}; + use yaak_models::models::HttpRequest; + use yaak_plugins::events::{GetKeyValueRequest, PluginContext, SendHttpRequestRequest}; fn query_manager_for_test() -> (QueryManager, TempDir) { let temp_dir = TempDir::new().expect("Failed to create temp dir"); @@ -134,8 +297,8 @@ mod tests { } } - #[test] - fn key_value_requests_round_trip() { + #[tokio::test] + async fn key_value_requests_round_trip() { let (query_manager, _temp_dir) = query_manager_for_test(); let plugin_name = "@yaak/test-plugin"; @@ -145,7 +308,9 @@ mod tests { key: "missing".to_string(), })), plugin_name, - ); + None, + ) + .await; match get_missing { Some(InternalEventPayload::GetKeyValueResponse(r)) => assert_eq!(r.value, None), other => panic!("unexpected payload for missing get: {other:?}"), @@ -160,7 +325,9 @@ mod tests { }, )), plugin_name, - ); + None, + ) + .await; assert!(matches!(set, Some(InternalEventPayload::SetKeyValueResponse(_)))); let get_present = build_plugin_reply( @@ -169,7 +336,9 @@ mod tests { key: "token".to_string(), })), plugin_name, - ); + None, + ) + .await; match get_present { Some(InternalEventPayload::GetKeyValueResponse(r)) => { assert_eq!(r.value, Some("{\"access_token\":\"abc\"}".to_string())) @@ -183,28 +352,31 @@ mod tests { yaak_plugins::events::DeleteKeyValueRequest { key: "token".to_string() }, )), plugin_name, - ); + None, + ) + .await; match delete { Some(InternalEventPayload::DeleteKeyValueResponse(r)) => assert!(r.deleted), other => panic!("unexpected payload for delete: {other:?}"), } } - #[test] - fn unsupported_request_gets_error_reply() { + #[tokio::test] + async fn send_http_request_without_context_gets_error_reply() { let (query_manager, _temp_dir) = query_manager_for_test(); let payload = build_plugin_reply( &query_manager, - &event(InternalEventPayload::WindowInfoRequest(WindowInfoRequest { - label: "main".to_string(), + &event(InternalEventPayload::SendHttpRequestRequest(SendHttpRequestRequest { + http_request: HttpRequest::default(), })), "@yaak/test-plugin", - ); + None, + ) + .await; match payload { Some(InternalEventPayload::ErrorResponse(err)) => { - assert!(err.error.contains("Unsupported plugin request in CLI")); - assert!(err.error.contains("window_info_request")); + assert!(err.error.contains("Send HTTP request support is not initialized in CLI")); } other => panic!("unexpected payload for unsupported request: {other:?}"), } diff --git a/plugins/template-function-response/src/index.ts b/plugins/template-function-response/src/index.ts index dcf85295..a086f9c9 100644 --- a/plugins/template-function-response/src/index.ts +++ b/plugins/template-function-response/src/index.ts @@ -317,7 +317,8 @@ async function getResponse( finalBehavior === 'always' || (finalBehavior === BEHAVIOR_TTL && shouldSendExpired(response, ttl)) ) { - // NOTE: Render inside this conditional, or we'll get infinite recursion (render->render->...) + // Explicitly render the request before send (instead of relying on send() to render) so that we can + // preserve the render purpose. const renderedHttpRequest = await ctx.httpRequest.render({ httpRequest, purpose }); response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest }); }