Skip to main content

edgecrab_plugins/
host_api.rs

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            watch_notification_tx: None,
369        }
370    }
371
372    #[tokio::test]
373    async fn platform_info_requires_no_capability() {
374        let home = TempDir::new().expect("tempdir");
375        let response = handle_host_request(
376            "demo",
377            &PluginCapabilities::default(),
378            &json!({"jsonrpc":"2.0","id":"1","method":"host:platform_info","params":{}}),
379            &test_ctx(home.path()),
380        )
381        .await;
382
383        assert_eq!(response["result"]["platform"], "cli");
384    }
385
386    #[tokio::test]
387    async fn memory_write_and_read_round_trip() {
388        let home = TempDir::new().expect("tempdir");
389        let caps = PluginCapabilities {
390            host: vec!["host:memory_read".into(), "host:memory_write".into()],
391            ..PluginCapabilities::default()
392        };
393        let ctx = test_ctx(home.path());
394
395        let _ = handle_host_request(
396            "demo",
397            &caps,
398            &json!({"jsonrpc":"2.0","id":"1","method":"host:memory_write","params":{"key":"user_lang","value":"English"}}),
399            &ctx,
400        )
401        .await;
402        let response = handle_host_request(
403            "demo",
404            &caps,
405            &json!({"jsonrpc":"2.0","id":"2","method":"host:memory_read","params":{"keys":["user_lang"]}}),
406            &ctx,
407        )
408        .await;
409
410        assert_eq!(response["result"]["facts"]["user_lang"], "English");
411    }
412
413    #[tokio::test]
414    async fn session_search_returns_hits() {
415        let home = TempDir::new().expect("tempdir");
416        let db = Arc::new(SessionDb::open(&home.path().join("sessions.db")).expect("db"));
417        db.save_session(&SessionRecord {
418            id: "session-1".into(),
419            source: "cli".into(),
420            user_id: None,
421            model: Some("test".into()),
422            system_prompt: None,
423            parent_session_id: None,
424            started_at: 0.0,
425            ended_at: None,
426            end_reason: None,
427            message_count: 0,
428            tool_call_count: 0,
429            input_tokens: 0,
430            output_tokens: 0,
431            cache_read_tokens: 0,
432            cache_write_tokens: 0,
433            reasoning_tokens: 0,
434            estimated_cost_usd: None,
435            title: Some("demo".into()),
436        })
437        .expect("save");
438        db.save_message("session-1", &Message::user("find this session"), 1.0)
439            .expect("save message");
440
441        let mut ctx = test_ctx(home.path());
442        ctx.state_db = Some(db);
443        let caps = PluginCapabilities {
444            host: vec!["host:session_search".into()],
445            ..PluginCapabilities::default()
446        };
447        let response = handle_host_request(
448            "demo",
449            &caps,
450            &json!({"jsonrpc":"2.0","id":"1","method":"host:session_search","params":{"query":"find","limit":5}}),
451            &ctx,
452        )
453        .await;
454
455        assert_eq!(response["result"]["hits"].as_array().map(Vec::len), Some(1));
456    }
457
458    #[tokio::test]
459    async fn inject_message_enqueues_runtime_message() {
460        let home = TempDir::new().expect("tempdir");
461        let caps = PluginCapabilities {
462            host: vec!["host:inject_message".into()],
463            ..PluginCapabilities::default()
464        };
465        let queue = Arc::new(tokio::sync::Mutex::new(Vec::new()));
466        let mut ctx = test_ctx(home.path());
467        ctx.injected_messages = Some(queue.clone());
468
469        let response = handle_host_request(
470            "demo",
471            &caps,
472            &json!({"jsonrpc":"2.0","id":"1","method":"host:inject_message","params":{"role":"assistant","content":"Created issue #42"}}),
473            &ctx,
474        )
475        .await;
476
477        assert_eq!(response["result"]["ok"], true);
478        let queued = queue.lock().await.clone();
479        assert_eq!(queued, vec![Message::assistant("Created issue #42")]);
480    }
481}