1use std::collections::BTreeMap;
2use std::path::Path;
3
4use chrono::Utc;
5use edgecrab_security::{check_injection, check_memory_content};
6use edgecrab_tools::registry::ToolContext;
7use edgecrab_types::Message;
8use serde_json::{Value, json};
9
10use crate::manifest::PluginCapabilities;
11
12const MAX_MEMORY_READ_KEYS: usize = 200;
13const MAX_SESSION_SEARCH_LIMIT: usize = 50;
14const MAX_INJECT_CHARS: usize = 32_000;
15const MAX_TOOL_CALL_DEPTH: u32 = 3;
16
17pub fn is_host_method(method: &str) -> bool {
18 method.starts_with("host:") || method.starts_with("host/")
19}
20
21pub async fn handle_host_request(
22 plugin_name: &str,
23 capabilities: &PluginCapabilities,
24 request: &Value,
25 ctx: &ToolContext,
26) -> Value {
27 let id = request.get("id").cloned().unwrap_or(Value::Null);
28 match handle_host_request_inner(plugin_name, capabilities, request, ctx).await {
29 Ok(result) => json!({
30 "jsonrpc": "2.0",
31 "id": id,
32 "result": result,
33 }),
34 Err((code, message)) => json!({
35 "jsonrpc": "2.0",
36 "id": id,
37 "error": {
38 "code": code,
39 "message": message,
40 }
41 }),
42 }
43}
44
45async fn handle_host_request_inner(
46 plugin_name: &str,
47 capabilities: &PluginCapabilities,
48 request: &Value,
49 ctx: &ToolContext,
50) -> Result<Value, (i64, String)> {
51 let method = request
52 .get("method")
53 .and_then(Value::as_str)
54 .ok_or((-32600, "missing JSON-RPC method".into()))?;
55 let method = normalize_method(method);
56 let params = request.get("params").cloned().unwrap_or(Value::Null);
57
58 match method.as_str() {
59 "host:platform_info" => Ok(json!({
60 "platform": ctx.platform.to_string(),
61 "session_id": ctx.session_id,
62 "model": std::env::var("EDGECRAB_MODEL").ok(),
63 "timestamp_utc": Utc::now().to_rfc3339(),
64 })),
65 "host:log" => {
66 let level = params
67 .get("level")
68 .and_then(Value::as_str)
69 .unwrap_or("info");
70 let message = params
71 .get("message")
72 .and_then(Value::as_str)
73 .unwrap_or_default();
74 match level {
75 "trace" => {
76 tracing::trace!(target: "edgecrab_plugins::host_api", plugin = plugin_name, "{message}")
77 }
78 "debug" => {
79 tracing::debug!(target: "edgecrab_plugins::host_api", plugin = plugin_name, "{message}")
80 }
81 "warn" => {
82 tracing::warn!(target: "edgecrab_plugins::host_api", plugin = plugin_name, "{message}")
83 }
84 "error" => {
85 tracing::error!(target: "edgecrab_plugins::host_api", plugin = plugin_name, "{message}")
86 }
87 _ => {
88 tracing::info!(target: "edgecrab_plugins::host_api", plugin = plugin_name, "{message}")
89 }
90 }
91 Ok(json!({ "ok": true }))
92 }
93 "host:memory_read" => {
94 require_capability(capabilities, &method)?;
95 let keys = params
96 .get("keys")
97 .and_then(Value::as_array)
98 .cloned()
99 .unwrap_or_default()
100 .into_iter()
101 .filter_map(|value| value.as_str().map(ToString::to_string))
102 .take(MAX_MEMORY_READ_KEYS)
103 .collect::<Vec<_>>();
104 let facts = read_memory_facts(&ctx.config.edgecrab_home, &keys)
105 .map_err(|error| (-32002, error.to_string()))?;
106 Ok(json!({ "facts": facts }))
107 }
108 "host:memory_write" => {
109 require_capability(capabilities, &method)?;
110 let key = params
111 .get("key")
112 .and_then(Value::as_str)
113 .map(str::trim)
114 .filter(|value| !value.is_empty())
115 .ok_or((-32602, "missing key".into()))?;
116 let value = params
117 .get("value")
118 .and_then(Value::as_str)
119 .ok_or((-32602, "missing value".into()))?;
120 if key.len() > 128 || value.len() > 4096 {
121 return Err((-32602, "memory key/value exceeds limits".into()));
122 }
123 check_memory_content(value).map_err(|msg| (-32002, msg))?;
124 write_memory_fact(&ctx.config.edgecrab_home, key, value)
125 .map_err(|error| (-32002, error.to_string()))?;
126 Ok(json!({ "ok": true }))
127 }
128 "host:session_search" => {
129 require_capability(capabilities, &method)?;
130 let query = params
131 .get("query")
132 .and_then(Value::as_str)
133 .map(str::trim)
134 .filter(|value| !value.is_empty())
135 .ok_or((-32602, "missing query".into()))?;
136 let limit = params
137 .get("limit")
138 .and_then(Value::as_u64)
139 .unwrap_or(10)
140 .min(MAX_SESSION_SEARCH_LIMIT as u64) as usize;
141 let Some(db) = &ctx.state_db else {
142 return Err((-32006, "session database unavailable".into()));
143 };
144 let hits = db
145 .search_sessions_rich(query, limit)
146 .map_err(|error| (-32006, error.to_string()))?;
147 Ok(json!({
148 "hits": hits.into_iter().map(|hit| json!({
149 "session_id": hit.session.id,
150 "excerpt": hit.snippet,
151 "score": hit.score,
152 })).collect::<Vec<_>>()
153 }))
154 }
155 "host:secret_get" => {
156 require_capability(capabilities, &method)?;
157 let name = params
158 .get("name")
159 .and_then(Value::as_str)
160 .map(str::trim)
161 .filter(|value| !value.is_empty())
162 .ok_or((-32602, "missing secret name".into()))?;
163 if !capabilities
164 .secrets
165 .iter()
166 .any(|candidate| candidate == name)
167 {
168 return Err((-32003, format!("Secret not whitelisted: {name}")));
169 }
170 let value =
171 std::env::var(name).map_err(|_| (-32003, format!("Secret unavailable: {name}")))?;
172 tracing::info!(target: "edgecrab_plugins::host_api", plugin = plugin_name, secret = name, "plugin secret read");
173 Ok(json!({ "value": value }))
174 }
175 "host:inject_message" => {
176 require_capability(capabilities, &method)?;
177 let role = params.get("role").and_then(Value::as_str).unwrap_or("user");
178 let content = params
179 .get("content")
180 .and_then(Value::as_str)
181 .unwrap_or_default();
182 if content.len() > MAX_INJECT_CHARS {
183 return Err((-32602, "message content exceeds 32000 characters".into()));
184 }
185 if let Some(message) = check_injection(content) {
186 return Err((-32004, message.into()));
187 }
188 let Some(queue) = &ctx.injected_messages else {
189 return Err((
190 -32004,
191 "conversation injection is not available in the current runtime".into(),
192 ));
193 };
194 let message = match role {
195 "user" => Message::user(content),
196 "assistant" => Message::assistant(content),
197 _ => {
198 return Err((
199 -32602,
200 "inject_message role must be 'user' or 'assistant'".into(),
201 ));
202 }
203 };
204 queue.lock().await.push(message);
205 Ok(json!({ "ok": true, "role": role }))
206 }
207 "host:tool_call" => {
208 require_capability(capabilities, &method)?;
209 if ctx.delegate_depth >= MAX_TOOL_CALL_DEPTH {
210 return Err((
211 -32005,
212 format!("Reentrancy limit exceeded (max depth: {MAX_TOOL_CALL_DEPTH})"),
213 ));
214 }
215 let tool = params
216 .get("tool")
217 .and_then(Value::as_str)
218 .map(str::trim)
219 .filter(|value| !value.is_empty())
220 .ok_or((-32602, "missing tool name".into()))?;
221 if ctx.current_tool_name.as_deref() == Some(tool) {
222 return Err((
223 -32005,
224 format!("Plugin cannot call itself via host:tool_call: {tool}"),
225 ));
226 }
227 let Some(registry) = &ctx.tool_registry else {
228 return Err((-32006, "tool registry unavailable".into()));
229 };
230 let args = params.get("args").cloned().unwrap_or_else(|| json!({}));
231 let output = registry
232 .dispatch(tool, args, ctx)
233 .await
234 .map_err(|error| (-32006, error.to_string()))?;
235 Ok(json!({ "result": output }))
236 }
237 _ => Err((-32601, format!("unknown host method: {method}"))),
238 }
239}
240
241fn normalize_method(method: &str) -> String {
242 if let Some(rest) = method.strip_prefix("host/") {
243 format!("host:{}", rest.replace('/', "_"))
244 } else {
245 method.to_string()
246 }
247}
248
249fn require_capability(
250 capabilities: &PluginCapabilities,
251 method: &str,
252) -> Result<(), (i64, String)> {
253 if matches!(method, "host:platform_info" | "host:log") {
254 return Ok(());
255 }
256 if capabilities
257 .host
258 .iter()
259 .any(|candidate| candidate == method)
260 {
261 return Ok(());
262 }
263 Err((-32001, format!("Capability not granted: {method}")))
264}
265
266fn read_memory_facts(
267 edgecrab_home: &Path,
268 keys: &[String],
269) -> Result<BTreeMap<String, Option<String>>, std::io::Error> {
270 let mut facts = parse_memory_facts(edgecrab_home)?;
271 if keys.is_empty() {
272 return Ok(facts.into_iter().map(|(k, v)| (k, Some(v))).collect());
273 }
274
275 let mut filtered = BTreeMap::new();
276 for key in keys {
277 filtered.insert(key.clone(), facts.remove(key));
278 }
279 Ok(filtered)
280}
281
282fn write_memory_fact(edgecrab_home: &Path, key: &str, value: &str) -> Result<(), std::io::Error> {
283 let memories_dir = edgecrab_home.join("memories");
284 std::fs::create_dir_all(&memories_dir)?;
285 let memory_path = memories_dir.join("USER.md");
286 let mut facts = parse_memory_facts(edgecrab_home)?;
287 facts.insert(key.to_string(), value.to_string());
288
289 let mut body = String::new();
290 for (fact_key, fact_value) in facts {
291 if !body.is_empty() {
292 body.push_str("\n§\n");
293 }
294 body.push_str(&format!("{fact_key}: {fact_value}"));
295 }
296 std::fs::write(memory_path, body)
297}
298
299fn parse_memory_facts(edgecrab_home: &Path) -> Result<BTreeMap<String, String>, std::io::Error> {
300 let mut facts = BTreeMap::new();
301 for path in [
302 edgecrab_home.join("memories").join("MEMORY.md"),
303 edgecrab_home.join("memories").join("USER.md"),
304 ] {
305 let content = match std::fs::read_to_string(path) {
306 Ok(content) => content,
307 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
308 Err(error) => return Err(error),
309 };
310 for chunk in content.split("\n§\n") {
311 let line = chunk.trim();
312 let Some((key, value)) = line.split_once(':') else {
313 continue;
314 };
315 let key = key.trim();
316 let value = value.trim();
317 if !key.is_empty() && !value.is_empty() {
318 facts.insert(key.to_string(), value.to_string());
319 }
320 }
321 }
322 Ok(facts)
323}
324
325#[cfg(test)]
326mod tests {
327 use std::sync::Arc;
328
329 use edgecrab_state::{SessionDb, SessionRecord};
330 use edgecrab_tools::config_ref::AppConfigRef;
331 use edgecrab_tools::registry::ToolContext;
332 use edgecrab_types::{Message, Platform};
333 use tempfile::TempDir;
334 use tokio_util::sync::CancellationToken;
335
336 use super::*;
337
338 fn test_ctx(home: &Path) -> ToolContext {
339 ToolContext {
340 task_id: "task".into(),
341 cwd: home.to_path_buf(),
342 session_id: "session-1".into(),
343 user_task: None,
344 cancel: CancellationToken::new(),
345 config: AppConfigRef {
346 edgecrab_home: home.to_path_buf(),
347 ..AppConfigRef::default()
348 },
349 state_db: None,
350 platform: Platform::Cli,
351 process_table: None,
352 provider: None,
353 tool_registry: None,
354 delegate_depth: 0,
355 sub_agent_runner: None,
356 delegation_event_tx: None,
357 clarify_tx: None,
358 approval_tx: None,
359 on_skills_changed: None,
360 gateway_sender: None,
361 origin_chat: None,
362 session_key: None,
363 todo_store: None,
364 current_tool_call_id: None,
365 current_tool_name: Some("plugin_tool".into()),
366 injected_messages: None,
367 tool_progress_tx: None,
368 }
369 }
370
371 #[tokio::test]
372 async fn platform_info_requires_no_capability() {
373 let home = TempDir::new().expect("tempdir");
374 let response = handle_host_request(
375 "demo",
376 &PluginCapabilities::default(),
377 &json!({"jsonrpc":"2.0","id":"1","method":"host:platform_info","params":{}}),
378 &test_ctx(home.path()),
379 )
380 .await;
381
382 assert_eq!(response["result"]["platform"], "cli");
383 }
384
385 #[tokio::test]
386 async fn memory_write_and_read_round_trip() {
387 let home = TempDir::new().expect("tempdir");
388 let caps = PluginCapabilities {
389 host: vec!["host:memory_read".into(), "host:memory_write".into()],
390 ..PluginCapabilities::default()
391 };
392 let ctx = test_ctx(home.path());
393
394 let _ = handle_host_request(
395 "demo",
396 &caps,
397 &json!({"jsonrpc":"2.0","id":"1","method":"host:memory_write","params":{"key":"user_lang","value":"English"}}),
398 &ctx,
399 )
400 .await;
401 let response = handle_host_request(
402 "demo",
403 &caps,
404 &json!({"jsonrpc":"2.0","id":"2","method":"host:memory_read","params":{"keys":["user_lang"]}}),
405 &ctx,
406 )
407 .await;
408
409 assert_eq!(response["result"]["facts"]["user_lang"], "English");
410 }
411
412 #[tokio::test]
413 async fn session_search_returns_hits() {
414 let home = TempDir::new().expect("tempdir");
415 let db = Arc::new(SessionDb::open(&home.path().join("sessions.db")).expect("db"));
416 db.save_session(&SessionRecord {
417 id: "session-1".into(),
418 source: "cli".into(),
419 user_id: None,
420 model: Some("test".into()),
421 system_prompt: None,
422 parent_session_id: None,
423 started_at: 0.0,
424 ended_at: None,
425 end_reason: None,
426 message_count: 0,
427 tool_call_count: 0,
428 input_tokens: 0,
429 output_tokens: 0,
430 cache_read_tokens: 0,
431 cache_write_tokens: 0,
432 reasoning_tokens: 0,
433 estimated_cost_usd: None,
434 title: Some("demo".into()),
435 })
436 .expect("save");
437 db.save_message("session-1", &Message::user("find this session"), 1.0)
438 .expect("save message");
439
440 let mut ctx = test_ctx(home.path());
441 ctx.state_db = Some(db);
442 let caps = PluginCapabilities {
443 host: vec!["host:session_search".into()],
444 ..PluginCapabilities::default()
445 };
446 let response = handle_host_request(
447 "demo",
448 &caps,
449 &json!({"jsonrpc":"2.0","id":"1","method":"host:session_search","params":{"query":"find","limit":5}}),
450 &ctx,
451 )
452 .await;
453
454 assert_eq!(response["result"]["hits"].as_array().map(Vec::len), Some(1));
455 }
456
457 #[tokio::test]
458 async fn inject_message_enqueues_runtime_message() {
459 let home = TempDir::new().expect("tempdir");
460 let caps = PluginCapabilities {
461 host: vec!["host:inject_message".into()],
462 ..PluginCapabilities::default()
463 };
464 let queue = Arc::new(tokio::sync::Mutex::new(Vec::new()));
465 let mut ctx = test_ctx(home.path());
466 ctx.injected_messages = Some(queue.clone());
467
468 let response = handle_host_request(
469 "demo",
470 &caps,
471 &json!({"jsonrpc":"2.0","id":"1","method":"host:inject_message","params":{"role":"assistant","content":"Created issue #42"}}),
472 &ctx,
473 )
474 .await;
475
476 assert_eq!(response["result"]["ok"], true);
477 let queued = queue.lock().await.clone();
478 assert_eq!(queued, vec![Message::assistant("Created issue #42")]);
479 }
480}