Skip to main content

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