cli: handle send/render http plugin host requests

This commit is contained in:
Gregory Schier
2026-03-03 08:05:54 -08:00
parent 3c12074db6
commit fb5ad8c7f7
3 changed files with 212 additions and 30 deletions

View File

@@ -73,7 +73,16 @@ impl CliContext {
}; };
let plugin_event_bridge = if let Some(plugin_manager) = &plugin_manager { 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 { } else {
None None
}; };

View File

@@ -1,25 +1,52 @@
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use yaak::plugin_events::{ use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event, 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::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{ use yaak_plugins::events::{
EmptyPayload, ErrorResponse, InternalEvent, InternalEventPayload, ListOpenWorkspacesResponse, EmptyPayload, ErrorResponse, InternalEvent, InternalEventPayload, ListOpenWorkspacesResponse,
WorkspaceInfo, RenderHttpRequestResponse, SendHttpRequestResponse, WorkspaceInfo,
}; };
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::RenderOptions;
pub struct CliPluginEventBridge { pub struct CliPluginEventBridge {
rx_id: String, rx_id: String,
task: JoinHandle<()>, task: JoinHandle<()>,
} }
struct CliSendHttpContext {
blob_manager: BlobManager,
plugin_manager: Arc<PluginManager>,
encryption_manager: Arc<EncryptionManager>,
response_dir: PathBuf,
}
impl CliPluginEventBridge { impl CliPluginEventBridge {
pub async fn start(plugin_manager: Arc<PluginManager>, query_manager: QueryManager) -> Self { pub async fn start(
plugin_manager: Arc<PluginManager>,
query_manager: QueryManager,
blob_manager: BlobManager,
encryption_manager: Arc<EncryptionManager>,
data_dir: PathBuf,
) -> Self {
let (rx_id, mut rx) = plugin_manager.subscribe("cli").await; let (rx_id, mut rx) = plugin_manager.subscribe("cli").await;
let rx_id_for_task = rx_id.clone(); let rx_id_for_task = rx_id.clone();
let pm = plugin_manager.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 { let task = tokio::spawn(async move {
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
@@ -37,15 +64,30 @@ impl CliPluginEventBridge {
continue; continue;
}; };
let plugin_name = plugin_handle.info().name; let pm = pm.clone();
let Some(reply_payload) = build_plugin_reply(&query_manager, &event, &plugin_name) let query_manager = query_manager.clone();
else { let send_http_context = send_http_context.clone();
continue;
};
if let Err(err) = pm.reply(&event, &reply_payload).await { // Avoid deadlocks for nested plugin-host requests (for example, template functions
eprintln!("Warning: Failed replying to plugin event: {err}"); // 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; 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, query_manager: &QueryManager,
event: &InternalEvent, event: &InternalEvent,
plugin_name: &str, plugin_name: &str,
send_http_context: Option<&CliSendHttpContext>,
) -> Option<InternalEventPayload> { ) -> Option<InternalEventPayload> {
match handle_shared_plugin_event( match handle_shared_plugin_event(
query_manager, query_manager,
@@ -101,9 +144,128 @@ fn build_plugin_reply(
workspaces, workspaces,
})) }))
} }
req => Some(InternalEventPayload::ErrorResponse(ErrorResponse { HostRequest::SendHttpRequest(send_http_request_request) => {
error: format!("Unsupported plugin request in CLI: {}", req.type_name()), 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 { mod tests {
use super::*; use super::*;
use tempfile::TempDir; 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) { fn query_manager_for_test() -> (QueryManager, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp dir"); let temp_dir = TempDir::new().expect("Failed to create temp dir");
@@ -134,8 +297,8 @@ mod tests {
} }
} }
#[test] #[tokio::test]
fn key_value_requests_round_trip() { async fn key_value_requests_round_trip() {
let (query_manager, _temp_dir) = query_manager_for_test(); let (query_manager, _temp_dir) = query_manager_for_test();
let plugin_name = "@yaak/test-plugin"; let plugin_name = "@yaak/test-plugin";
@@ -145,7 +308,9 @@ mod tests {
key: "missing".to_string(), key: "missing".to_string(),
})), })),
plugin_name, plugin_name,
); None,
)
.await;
match get_missing { match get_missing {
Some(InternalEventPayload::GetKeyValueResponse(r)) => assert_eq!(r.value, None), Some(InternalEventPayload::GetKeyValueResponse(r)) => assert_eq!(r.value, None),
other => panic!("unexpected payload for missing get: {other:?}"), other => panic!("unexpected payload for missing get: {other:?}"),
@@ -160,7 +325,9 @@ mod tests {
}, },
)), )),
plugin_name, plugin_name,
); None,
)
.await;
assert!(matches!(set, Some(InternalEventPayload::SetKeyValueResponse(_)))); assert!(matches!(set, Some(InternalEventPayload::SetKeyValueResponse(_))));
let get_present = build_plugin_reply( let get_present = build_plugin_reply(
@@ -169,7 +336,9 @@ mod tests {
key: "token".to_string(), key: "token".to_string(),
})), })),
plugin_name, plugin_name,
); None,
)
.await;
match get_present { match get_present {
Some(InternalEventPayload::GetKeyValueResponse(r)) => { Some(InternalEventPayload::GetKeyValueResponse(r)) => {
assert_eq!(r.value, Some("{\"access_token\":\"abc\"}".to_string())) assert_eq!(r.value, Some("{\"access_token\":\"abc\"}".to_string()))
@@ -183,28 +352,31 @@ mod tests {
yaak_plugins::events::DeleteKeyValueRequest { key: "token".to_string() }, yaak_plugins::events::DeleteKeyValueRequest { key: "token".to_string() },
)), )),
plugin_name, plugin_name,
); None,
)
.await;
match delete { match delete {
Some(InternalEventPayload::DeleteKeyValueResponse(r)) => assert!(r.deleted), Some(InternalEventPayload::DeleteKeyValueResponse(r)) => assert!(r.deleted),
other => panic!("unexpected payload for delete: {other:?}"), other => panic!("unexpected payload for delete: {other:?}"),
} }
} }
#[test] #[tokio::test]
fn unsupported_request_gets_error_reply() { async fn send_http_request_without_context_gets_error_reply() {
let (query_manager, _temp_dir) = query_manager_for_test(); let (query_manager, _temp_dir) = query_manager_for_test();
let payload = build_plugin_reply( let payload = build_plugin_reply(
&query_manager, &query_manager,
&event(InternalEventPayload::WindowInfoRequest(WindowInfoRequest { &event(InternalEventPayload::SendHttpRequestRequest(SendHttpRequestRequest {
label: "main".to_string(), http_request: HttpRequest::default(),
})), })),
"@yaak/test-plugin", "@yaak/test-plugin",
); None,
)
.await;
match payload { match payload {
Some(InternalEventPayload::ErrorResponse(err)) => { Some(InternalEventPayload::ErrorResponse(err)) => {
assert!(err.error.contains("Unsupported plugin request in CLI")); assert!(err.error.contains("Send HTTP request support is not initialized in CLI"));
assert!(err.error.contains("window_info_request"));
} }
other => panic!("unexpected payload for unsupported request: {other:?}"), other => panic!("unexpected payload for unsupported request: {other:?}"),
} }

View File

@@ -317,7 +317,8 @@ async function getResponse(
finalBehavior === 'always' || finalBehavior === 'always' ||
(finalBehavior === BEHAVIOR_TTL && shouldSendExpired(response, ttl)) (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 }); const renderedHttpRequest = await ctx.httpRequest.render({ httpRequest, purpose });
response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest }); response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest });
} }