Skip to main content

everruns_host/session_services/capabilities/
session_storage.rs

1//! Session storage capability.
2//!
3//! This capability provides tools for session-scoped key/value and secret storage.
4//! Data persists for the session duration.
5//!
6//! Tools provided:
7//! - `kv_store`: Key/value storage operations (set, get, delete, list)
8//! - `secret_store`: Encrypted secret storage operations (set, get, delete, list)
9
10use async_trait::async_trait;
11use everruns_core::capabilities::{Capability, CapabilityLocalization, CapabilityStatus};
12use everruns_core::tool_context::ToolContext;
13use everruns_core::tools::{Tool, ToolExecutionResult};
14use everruns_provider::tool_types::ToolHints;
15use serde_json::{Value, json};
16
17// Reserve internal KV prefixes from the user-facing kv_store. Reference the
18// canonical constants so these never drift from how the owning capabilities
19// write them: A2A run records (`a2a_delegation::run_key`) and ARD runtime
20// attachments / discovery cache (`ard_attachment`). Reserving the ARD prefixes
21// stops a session/tool actor forging attachments via kv_store (TM-TOOL/TM-AGENT).
22const INTERNAL_KV_PREFIXES: &[&str] = &[
23    everruns_core::capabilities::AGENT_RUN_KEY_PREFIX,
24    everruns_core::ard_attachment::ARD_ATTACHMENT_KV_PREFIX,
25    everruns_core::ard_attachment::ARD_DISCOVERY_KV_PREFIX,
26];
27const INTERNAL_SECRET_PREFIXES: &[&str] = &["browserless_internal:", "mcp_oauth:"];
28// Exact reserved secret names. Unlike the prefixes above, this one cannot
29// reference its canonical constant: SESSION_SANDBOX_SECRET_NAME is defined in
30// the platform crate, which depends on this one and which host source must not
31// reference (check-agent-record-isolation.sh). The name is repeated here and
32// pinned to the constant by a test beside that definition.
33const INTERNAL_SECRET_NAMES: &[&str] = &["session_sandbox"];
34
35pub fn is_internal_session_kv_key(key: &str) -> bool {
36    INTERNAL_KV_PREFIXES
37        .iter()
38        .any(|prefix| key.starts_with(prefix))
39}
40
41fn reserved_kv_key_error() -> ToolExecutionResult {
42    ToolExecutionResult::tool_error("Key is reserved for internal system use")
43}
44
45pub fn is_internal_session_secret_name(name: &str) -> bool {
46    INTERNAL_SECRET_NAMES.contains(&name)
47        || INTERNAL_SECRET_PREFIXES
48            .iter()
49            .any(|prefix| name.starts_with(prefix))
50}
51
52pub const SESSION_STORAGE_CAPABILITY_ID: &str = "session_storage";
53
54/// Session Storage capability - provides key/value and secret storage for sessions
55pub struct SessionStorageCapability;
56
57impl Capability for SessionStorageCapability {
58    fn id(&self) -> &str {
59        SESSION_STORAGE_CAPABILITY_ID
60    }
61
62    fn name(&self) -> &str {
63        "Storage"
64    }
65
66    fn description(&self) -> &str {
67        r#"Tools to store and retrieve key/value pairs and encrypted secrets within a session.
68
69> [!NOTE]
70> Data persists for the session duration. Secrets are encrypted at rest.
71
72> [!TIP]
73> Use key/value storage for general data. Use secrets for sensitive information like API keys or tokens."#
74    }
75
76    fn localizations(&self) -> Vec<CapabilityLocalization> {
77        vec![CapabilityLocalization::text(
78            "uk",
79            "Сховище",
80            r#"Інструменти для збереження та отримання пар ключ-значення і зашифрованих секретів у межах сесії.
81
82> [!NOTE]
83> Дані зберігаються протягом усієї сесії. Секрети шифруються при зберіганні.
84
85> [!TIP]
86> Використовуйте сховище ключ-значення для загальних даних. Використовуйте секрети для чутливої інформації, як-от API-ключі чи токени."#,
87        )]
88    }
89
90    fn status(&self) -> CapabilityStatus {
91        CapabilityStatus::Available
92    }
93
94    fn icon(&self) -> Option<&str> {
95        Some("database")
96    }
97
98    fn category(&self) -> Option<&str> {
99        Some("Storage")
100    }
101
102    fn system_prompt_addition(&self) -> Option<&str> {
103        Some(
104            "Use `kv_store` for general data. Use `secret_store` for sensitive data (API keys, tokens, credentials) — secrets are encrypted at rest. Keys are unique per session; storing with the same key overwrites.",
105        )
106    }
107
108    fn tools(&self) -> Vec<Box<dyn Tool>> {
109        vec![Box::new(KvStoreTool), Box::new(SecretStoreTool)]
110    }
111
112    fn features(&self) -> Vec<&'static str> {
113        vec!["secrets", "key_value"]
114    }
115}
116
117// ============================================================================
118// KvStoreTool - Unified key/value storage tool
119// ============================================================================
120
121/// Tool for key/value storage operations
122pub struct KvStoreTool;
123
124#[async_trait]
125impl Tool for KvStoreTool {
126    fn narrate(
127        &self,
128        tool_call: &everruns_provider::tool_types::ToolCall,
129        phase: everruns_core::tool_narration::ToolNarrationPhase,
130        locale: Option<&str>,
131        _ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
132    ) -> Option<String> {
133        let fallback = self.display_name().unwrap_or("Key-Value Store");
134        Some(everruns_core::tool_narration::narrate_secret_store(
135            &tool_call.arguments,
136            fallback,
137            phase,
138            locale,
139        ))
140    }
141
142    fn name(&self) -> &str {
143        "kv_store"
144    }
145
146    fn display_name(&self) -> Option<&str> {
147        Some("Key-Value Store")
148    }
149
150    fn description(&self) -> &str {
151        "Key/value storage operations: set, get, delete, or list keys."
152    }
153
154    fn parameters_schema(&self) -> Value {
155        json!({
156            "type": "object",
157            "properties": {
158                "operation": {
159                    "type": "string",
160                    "enum": ["set", "get", "delete", "list"],
161                    "description": "The operation to perform"
162                },
163                "key": {
164                    "type": "string",
165                    "description": "The key (required for set, get, delete; max 255 chars)"
166                },
167                "value": {
168                    "type": "string",
169                    "description": "The value to store (required for set; can be JSON-encoded)"
170                }
171            },
172            "required": ["operation"],
173            "additionalProperties": false
174        })
175    }
176
177    fn hints(&self) -> ToolHints {
178        // Mutates shared session storage on set/delete; serialize storage
179        // mutations within a batch to avoid lost updates.
180        ToolHints::default()
181            .with_idempotent(true)
182            .with_concurrency_class("session_storage")
183    }
184
185    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
186        ToolExecutionResult::tool_error(
187            "kv_store requires context. This tool must be executed with session context.",
188        )
189    }
190
191    async fn execute_with_context(
192        &self,
193        arguments: Value,
194        context: &ToolContext,
195    ) -> ToolExecutionResult {
196        let operation = match arguments.get("operation").and_then(|v| v.as_str()) {
197            Some(op) => op,
198            None => {
199                return ToolExecutionResult::tool_error("Missing required parameter: operation");
200            }
201        };
202
203        let storage_store = match &context.storage_store {
204            Some(store) => store,
205            None => {
206                return ToolExecutionResult::tool_error("Storage not available in this context");
207            }
208        };
209
210        match operation {
211            "set" => {
212                let key = match arguments.get("key").and_then(|v| v.as_str()) {
213                    Some(k) => k,
214                    None => {
215                        return ToolExecutionResult::tool_error(
216                            "Missing required parameter: key (for set operation)",
217                        );
218                    }
219                };
220                let value = match arguments.get("value").and_then(|v| v.as_str()) {
221                    Some(v) => v,
222                    None => {
223                        return ToolExecutionResult::tool_error(
224                            "Missing required parameter: value (for set operation)",
225                        );
226                    }
227                };
228                if key.len() > 255 {
229                    return ToolExecutionResult::tool_error("Key must be 255 characters or less");
230                }
231                if is_internal_session_kv_key(key) {
232                    return reserved_kv_key_error();
233                }
234                match storage_store
235                    .set_value(context.session_id, key, value)
236                    .await
237                {
238                    Ok(()) => ToolExecutionResult::success(json!({
239                        "operation": "set",
240                        "key": key,
241                        "success": true
242                    })),
243                    Err(e) => ToolExecutionResult::internal_error(e),
244                }
245            }
246            "get" => {
247                let key = match arguments.get("key").and_then(|v| v.as_str()) {
248                    Some(k) => k,
249                    None => {
250                        return ToolExecutionResult::tool_error(
251                            "Missing required parameter: key (for get operation)",
252                        );
253                    }
254                };
255                if is_internal_session_kv_key(key) {
256                    return reserved_kv_key_error();
257                }
258                match storage_store.get_value(context.session_id, key).await {
259                    Ok(Some(value)) => ToolExecutionResult::success(json!({
260                        "operation": "get",
261                        "key": key,
262                        "value": value,
263                        "found": true
264                    })),
265                    Ok(None) => ToolExecutionResult::success(json!({
266                        "operation": "get",
267                        "key": key,
268                        "value": null,
269                        "found": false
270                    })),
271                    Err(e) => ToolExecutionResult::internal_error(e),
272                }
273            }
274            "delete" => {
275                let key = match arguments.get("key").and_then(|v| v.as_str()) {
276                    Some(k) => k,
277                    None => {
278                        return ToolExecutionResult::tool_error(
279                            "Missing required parameter: key (for delete operation)",
280                        );
281                    }
282                };
283                if is_internal_session_kv_key(key) {
284                    return reserved_kv_key_error();
285                }
286                match storage_store.delete_value(context.session_id, key).await {
287                    Ok(deleted) => ToolExecutionResult::success(json!({
288                        "operation": "delete",
289                        "key": key,
290                        "deleted": deleted
291                    })),
292                    Err(e) => ToolExecutionResult::internal_error(e),
293                }
294            }
295            "list" => match storage_store.list_keys(context.session_id).await {
296                Ok(keys) => {
297                    let key_list: Vec<Value> = keys
298                        .iter()
299                        .filter(|k| !is_internal_session_kv_key(&k.key))
300                        .map(|k| {
301                            json!({
302                                "key": k.key,
303                                "created_at": k.created_at.to_rfc3339(),
304                                "updated_at": k.updated_at.to_rfc3339()
305                            })
306                        })
307                        .collect();
308                    ToolExecutionResult::success(json!({
309                        "operation": "list",
310                        "keys": key_list,
311                        "count": key_list.len()
312                    }))
313                }
314                Err(e) => ToolExecutionResult::internal_error(e),
315            },
316            _ => ToolExecutionResult::tool_error(format!(
317                "Invalid operation: {}. Must be one of: set, get, delete, list",
318                operation
319            )),
320        }
321    }
322
323    fn requires_context(&self) -> bool {
324        true
325    }
326}
327
328// ============================================================================
329// SecretStoreTool - Unified secret storage tool
330// ============================================================================
331
332/// Tool for encrypted secret storage operations
333pub struct SecretStoreTool;
334
335#[async_trait]
336impl Tool for SecretStoreTool {
337    fn narrate(
338        &self,
339        tool_call: &everruns_provider::tool_types::ToolCall,
340        phase: everruns_core::tool_narration::ToolNarrationPhase,
341        locale: Option<&str>,
342        _ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
343    ) -> Option<String> {
344        let fallback = self.display_name().unwrap_or("Secret Store");
345        Some(everruns_core::tool_narration::narrate_secret_store(
346            &tool_call.arguments,
347            fallback,
348            phase,
349            locale,
350        ))
351    }
352
353    fn name(&self) -> &str {
354        "secret_store"
355    }
356
357    fn display_name(&self) -> Option<&str> {
358        Some("Secret Store")
359    }
360
361    fn description(&self) -> &str {
362        "Encrypted secret storage operations: set, get, delete, or list secrets."
363    }
364
365    fn parameters_schema(&self) -> Value {
366        json!({
367            "type": "object",
368            "properties": {
369                "operation": {
370                    "type": "string",
371                    "enum": ["set", "get", "delete", "list"],
372                    "description": "The operation to perform"
373                },
374                "name": {
375                    "type": "string",
376                    "description": "The secret name (required for set, get, delete; max 255 chars)"
377                },
378                "value": {
379                    "type": "string",
380                    "description": "The secret value to store (required for set; will be encrypted)"
381                }
382            },
383            "required": ["operation"],
384            "additionalProperties": false
385        })
386    }
387
388    fn hints(&self) -> ToolHints {
389        // Shares the session storage backend with kv_store; serialize storage
390        // mutations within a batch to avoid lost updates.
391        ToolHints::default()
392            .with_idempotent(true)
393            .with_requires_secrets(true)
394            .with_concurrency_class("session_storage")
395    }
396
397    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
398        ToolExecutionResult::tool_error(
399            "secret_store requires context. This tool must be executed with session context.",
400        )
401    }
402
403    async fn execute_with_context(
404        &self,
405        arguments: Value,
406        context: &ToolContext,
407    ) -> ToolExecutionResult {
408        let operation = match arguments.get("operation").and_then(|v| v.as_str()) {
409            Some(op) => op,
410            None => {
411                return ToolExecutionResult::tool_error("Missing required parameter: operation");
412            }
413        };
414
415        let storage_store = match &context.storage_store {
416            Some(store) => store,
417            None => {
418                return ToolExecutionResult::tool_error("Storage not available in this context");
419            }
420        };
421
422        match operation {
423            "set" => {
424                let name = match arguments.get("name").and_then(|v| v.as_str()) {
425                    Some(n) => n,
426                    None => {
427                        return ToolExecutionResult::tool_error(
428                            "Missing required parameter: name (for set operation)",
429                        );
430                    }
431                };
432                let value = match arguments.get("value").and_then(|v| v.as_str()) {
433                    Some(v) => v,
434                    None => {
435                        return ToolExecutionResult::tool_error(
436                            "Missing required parameter: value (for set operation)",
437                        );
438                    }
439                };
440                if name.len() > 255 {
441                    return ToolExecutionResult::tool_error(
442                        "Secret name must be 255 characters or less",
443                    );
444                }
445                if is_internal_session_secret_name(name) {
446                    return ToolExecutionResult::tool_error(
447                        "Secret name is reserved for internal system use",
448                    );
449                }
450                match storage_store
451                    .set_secret(context.session_id, name, value)
452                    .await
453                {
454                    Ok(()) => ToolExecutionResult::success(json!({
455                        "operation": "set",
456                        "name": name,
457                        "success": true
458                    })),
459                    Err(e) => {
460                        let msg = e.to_string();
461                        if msg.contains("Encryption not configured") {
462                            ToolExecutionResult::tool_error(
463                                "Secret storage not available. Encryption is not configured.",
464                            )
465                        } else {
466                            ToolExecutionResult::internal_error(e)
467                        }
468                    }
469                }
470            }
471            "get" => {
472                let name = match arguments.get("name").and_then(|v| v.as_str()) {
473                    Some(n) => n,
474                    None => {
475                        return ToolExecutionResult::tool_error(
476                            "Missing required parameter: name (for get operation)",
477                        );
478                    }
479                };
480                if is_internal_session_secret_name(name) {
481                    return ToolExecutionResult::tool_error("Secret not found");
482                }
483                match storage_store.get_secret(context.session_id, name).await {
484                    Ok(Some(value)) => ToolExecutionResult::success(json!({
485                        "operation": "get",
486                        "name": name,
487                        "value": value,
488                        "found": true
489                    })),
490                    Ok(None) => ToolExecutionResult::success(json!({
491                        "operation": "get",
492                        "name": name,
493                        "value": null,
494                        "found": false
495                    })),
496                    Err(e) => {
497                        let msg = e.to_string();
498                        if msg.contains("Encryption not configured") {
499                            ToolExecutionResult::tool_error(
500                                "Secret storage not available. Encryption is not configured.",
501                            )
502                        } else {
503                            ToolExecutionResult::internal_error(e)
504                        }
505                    }
506                }
507            }
508            "delete" => {
509                let name = match arguments.get("name").and_then(|v| v.as_str()) {
510                    Some(n) => n,
511                    None => {
512                        return ToolExecutionResult::tool_error(
513                            "Missing required parameter: name (for delete operation)",
514                        );
515                    }
516                };
517                if is_internal_session_secret_name(name) {
518                    return ToolExecutionResult::tool_error(
519                        "Secret name is reserved for internal system use",
520                    );
521                }
522                match storage_store.delete_secret(context.session_id, name).await {
523                    Ok(deleted) => ToolExecutionResult::success(json!({
524                        "operation": "delete",
525                        "name": name,
526                        "deleted": deleted
527                    })),
528                    Err(e) => ToolExecutionResult::internal_error(e),
529                }
530            }
531            "list" => match storage_store.list_secrets(context.session_id).await {
532                Ok(secrets) => {
533                    let secret_list: Vec<Value> = secrets
534                        .iter()
535                        .filter(|s| !is_internal_session_secret_name(&s.name))
536                        .map(|s| {
537                            json!({
538                                "name": s.name,
539                                "created_at": s.created_at.to_rfc3339(),
540                                "updated_at": s.updated_at.to_rfc3339()
541                            })
542                        })
543                        .collect();
544                    ToolExecutionResult::success(json!({
545                        "operation": "list",
546                        "secrets": secret_list,
547                        "count": secret_list.len()
548                    }))
549                }
550                Err(e) => {
551                    let msg = e.to_string();
552                    if msg.contains("Encryption not configured") {
553                        ToolExecutionResult::tool_error(
554                            "Secret storage not available. Encryption is not configured.",
555                        )
556                    } else {
557                        ToolExecutionResult::internal_error(e)
558                    }
559                }
560            },
561            _ => ToolExecutionResult::tool_error(format!(
562                "Invalid operation: {}. Must be one of: set, get, delete, list",
563                operation
564            )),
565        }
566    }
567
568    fn requires_context(&self) -> bool {
569        true
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use everruns_core::session_services::KeyInfo;
577    use everruns_core::session_services::SessionStorageStore;
578    use everruns_provider::error::Result;
579    use everruns_provider::typed_id::SessionId;
580    use std::collections::HashMap;
581    use std::sync::{Arc, Mutex};
582
583    #[derive(Default)]
584    struct TestStorageStore {
585        values: Mutex<HashMap<String, String>>,
586    }
587
588    #[async_trait]
589    impl everruns_core::session_services::SessionStorageStore for TestStorageStore {
590        async fn set_value(&self, _session_id: SessionId, key: &str, value: &str) -> Result<()> {
591            self.values
592                .lock()
593                .unwrap()
594                .insert(key.to_string(), value.to_string());
595            Ok(())
596        }
597
598        async fn get_value(&self, _session_id: SessionId, key: &str) -> Result<Option<String>> {
599            Ok(self.values.lock().unwrap().get(key).cloned())
600        }
601
602        async fn delete_value(&self, _session_id: SessionId, key: &str) -> Result<bool> {
603            Ok(self.values.lock().unwrap().remove(key).is_some())
604        }
605
606        async fn list_keys(&self, _session_id: SessionId) -> Result<Vec<KeyInfo>> {
607            let now = chrono::Utc::now();
608            Ok(self
609                .values
610                .lock()
611                .unwrap()
612                .keys()
613                .map(|key| KeyInfo {
614                    key: key.clone(),
615                    created_at: now,
616                    updated_at: now,
617                })
618                .collect())
619        }
620
621        async fn set_secret(
622            &self,
623            _session_id: SessionId,
624            _name: &str,
625            _value: &str,
626        ) -> Result<()> {
627            Ok(())
628        }
629
630        async fn get_secret(&self, _session_id: SessionId, _name: &str) -> Result<Option<String>> {
631            Ok(None)
632        }
633
634        async fn delete_secret(&self, _session_id: SessionId, _name: &str) -> Result<bool> {
635            Ok(false)
636        }
637
638        async fn list_secrets(
639            &self,
640            _session_id: SessionId,
641        ) -> Result<Vec<everruns_core::session_services::SecretInfo>> {
642            Ok(Vec::new())
643        }
644    }
645
646    #[test]
647    fn test_internal_kv_key_filtering() {
648        assert!(is_internal_session_kv_key("agent_run:abc"));
649        assert!(!is_internal_session_kv_key("user:agent_run:abc"));
650    }
651
652    #[test]
653    fn test_internal_secret_name_filtering() {
654        assert!(is_internal_session_secret_name(
655            "browserless_internal:cookies"
656        ));
657        assert!(is_internal_session_secret_name(
658            "mcp_oauth:server:access_token"
659        ));
660        assert!(is_internal_session_secret_name("session_sandbox"));
661        assert!(!is_internal_session_secret_name("api_key"));
662    }
663
664    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
665
666    #[test]
667    fn test_capability_has_system_prompt() {
668        let cap = SessionStorageCapability;
669        let prompt = cap.system_prompt_addition().unwrap();
670        assert!(prompt.contains("kv_store"));
671        assert!(prompt.contains("secret_store"));
672        assert!(prompt.contains("encrypted"));
673    }
674
675    #[tokio::test]
676    async fn test_kv_store_without_context() {
677        let tool = KvStoreTool;
678        let result = tool
679            .execute(json!({"operation": "set", "key": "test", "value": "data"}))
680            .await;
681
682        if let ToolExecutionResult::ToolError(msg) = result {
683            assert!(msg.contains("requires context"));
684        } else {
685            panic!("Expected tool error");
686        }
687    }
688
689    #[tokio::test]
690    async fn test_kv_store_missing_operation() {
691        let tool = KvStoreTool;
692        let context = ToolContext::new(SessionId::new());
693
694        let result = tool
695            .execute_with_context(json!({"key": "test"}), &context)
696            .await;
697
698        if let ToolExecutionResult::ToolError(msg) = result {
699            assert!(msg.contains("operation"));
700        } else {
701            panic!("Expected tool error for missing operation");
702        }
703    }
704
705    #[tokio::test]
706    async fn test_kv_store_no_storage_store() {
707        let tool = KvStoreTool;
708        let context = ToolContext::new(SessionId::new());
709
710        let result = tool
711            .execute_with_context(
712                json!({"operation": "set", "key": "test", "value": "data"}),
713                &context,
714            )
715            .await;
716
717        if let ToolExecutionResult::ToolError(msg) = result {
718            assert!(msg.contains("not available"));
719        } else {
720            panic!("Expected tool error for missing storage store");
721        }
722    }
723
724    #[tokio::test]
725    async fn test_kv_store_rejects_reserved_internal_keys() {
726        let tool = KvStoreTool;
727        let session_id = SessionId::new();
728        let storage = Arc::new(TestStorageStore::default());
729        storage
730            .set_value(session_id, "agent_run:trusted", "trusted-record")
731            .await
732            .unwrap();
733        storage
734            .set_value(session_id, "public", "public-record")
735            .await
736            .unwrap();
737        let context = ToolContext::with_storage_store(session_id, storage.clone());
738
739        for arguments in [
740            json!({"operation": "set", "key": "agent_run:trusted", "value": "forged"}),
741            json!({"operation": "get", "key": "agent_run:trusted"}),
742            json!({"operation": "delete", "key": "agent_run:trusted"}),
743        ] {
744            let result = tool.execute_with_context(arguments, &context).await;
745            assert!(
746                matches!(result, ToolExecutionResult::ToolError(ref msg) if msg.contains("reserved")),
747                "expected reserved-key error, got {result:?}"
748            );
749        }
750
751        assert_eq!(
752            storage
753                .get_value(session_id, "agent_run:trusted")
754                .await
755                .unwrap()
756                .as_deref(),
757            Some("trusted-record")
758        );
759
760        let result = tool
761            .execute_with_context(json!({"operation": "list"}), &context)
762            .await;
763        let ToolExecutionResult::Success(value) = result else {
764            panic!("expected successful list");
765        };
766        assert_eq!(value["count"], 1);
767        assert_eq!(value["keys"][0]["key"], "public");
768    }
769
770    #[tokio::test]
771    async fn test_secret_store_without_context() {
772        let tool = SecretStoreTool;
773        let result = tool
774            .execute(json!({"operation": "set", "name": "api_key", "value": "YExample0"}))
775            .await;
776
777        if let ToolExecutionResult::ToolError(msg) = result {
778            assert!(msg.contains("requires context"));
779        } else {
780            panic!("Expected tool error");
781        }
782    }
783}