1use 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
17const 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:"];
28const 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
54pub 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
117pub 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 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
328pub 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 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 #[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}