Skip to main content

everruns_core/capabilities/
session_sandbox.rs

1//! Session Sandbox capability.
2//!
3//! One managed sandbox per session. The concrete provider is chosen by config
4//! (`provider: "daytona"` initially), while the tool surface stays stable.
5
6use super::{Capability, CapabilityLocalization, CapabilityStatus};
7pub use crate::session_sandbox::SESSION_SANDBOX_CAPABILITY_ID;
8use crate::session_sandbox::{
9    DEFAULT_SESSION_SANDBOX_IDLE_TIMEOUT_SECS, SessionSandboxConfig, checkpoint_session_sandbox,
10    create_session_sandbox_provider, delete_session_sandbox, ensure_session_sandbox_running,
11    load_session_sandbox_state, pause_session_sandbox, session_sandbox_tool_hints,
12};
13use crate::tool_output_sanitizer::{
14    READ_FILE_DEFAULT_LIMIT, build_text_read_file_result, parse_read_file_window_args,
15};
16use crate::tools::{Tool, ToolExecutionResult};
17use crate::traits::ToolContext;
18use crate::truncation_info::TruncationInfo;
19use async_trait::async_trait;
20use serde_json::{Value, json};
21
22pub struct SessionSandboxCapability;
23
24impl Capability for SessionSandboxCapability {
25    fn id(&self) -> &str {
26        SESSION_SANDBOX_CAPABILITY_ID
27    }
28
29    fn name(&self) -> &str {
30        "Session Sandbox"
31    }
32
33    fn description(&self) -> &str {
34        "One managed sandbox owned by the current session. Supports exec and file operations with provider-managed lifecycle."
35    }
36
37    fn status(&self) -> CapabilityStatus {
38        CapabilityStatus::Available
39    }
40
41    fn icon(&self) -> Option<&str> {
42        Some("terminal")
43    }
44
45    fn category(&self) -> Option<&str> {
46        Some("Sandboxes")
47    }
48
49    fn system_prompt_addition(&self) -> Option<&str> {
50        Some(
51            "This session owns one managed sandbox. Use sandbox tools for commands and sandbox file I/O; inspect lifecycle state before lifecycle-sensitive work and pause/resume/delete only when requested or cleaning up.",
52        )
53    }
54
55    fn tools(&self) -> Vec<Box<dyn Tool>> {
56        self.tools_with_config(&json!({}))
57    }
58
59    fn tools_with_config(&self, config: &Value) -> Vec<Box<dyn Tool>> {
60        vec![
61            Box::new(SandboxExecTool::new(config.clone())),
62            Box::new(SandboxReadFileTool::new(config.clone())),
63            Box::new(SandboxWriteFileTool::new(config.clone())),
64            Box::new(SandboxStatusTool::new(config.clone())),
65            Box::new(SandboxManageTool::new(config.clone())),
66        ]
67    }
68
69    fn dependencies(&self) -> Vec<&'static str> {
70        vec!["session_storage"]
71    }
72
73    fn features(&self) -> Vec<&'static str> {
74        vec!["managed_sandbox"]
75    }
76
77    /// Exposes only the lifecycle knobs users tune: `provider`, `auto_start`,
78    /// and `idle_pause_after_seconds`. `provider_config` (provider-specific
79    /// payload) and `init` (bootstrap commands) are advanced settings kept out
80    /// of the schema; `validate_config` still accepts them through the typed
81    /// `SessionSandboxConfig` parse.
82    fn config_schema(&self) -> Option<Value> {
83        Some(json!({
84            "type": "object",
85            "properties": {
86                "provider": {
87                    "type": "string",
88                    "title": "Provider",
89                    "description": "Sandbox provider id (e.g. daytona)."
90                },
91                "auto_start": {
92                    "type": "boolean",
93                    "title": "Auto-start",
94                    "description": "Start the sandbox proactively when the session is created.",
95                    "default": true
96                },
97                "idle_pause_after_seconds": {
98                    "type": "integer",
99                    "title": "Idle pause timeout (seconds)",
100                    "description": "Pause the sandbox after this many seconds of session inactivity.",
101                    "minimum": 1,
102                    "default": DEFAULT_SESSION_SANDBOX_IDLE_TIMEOUT_SECS
103                }
104            }
105        }))
106    }
107
108    fn validate_config(&self, config: &Value) -> Result<(), String> {
109        // The capability may be attached before it is configured, so null and
110        // empty-object configs are valid; tools reject unconfigured use at
111        // execution time via `parse_config`.
112        if config.is_null() {
113            return Ok(());
114        }
115        let Some(object) = config.as_object() else {
116            return Err("session_sandbox config must be an object".to_string());
117        };
118        if object.is_empty() {
119            return Ok(());
120        }
121        let typed: SessionSandboxConfig = serde_json::from_value(config.clone())
122            .map_err(|e| format!("invalid session_sandbox config: {e}"))?;
123        // Same checks `parse_config` applies at tool-execution time.
124        if typed.provider.trim().is_empty() {
125            return Err("session_sandbox requires a non-empty provider".to_string());
126        }
127        if typed.idle_pause_after_seconds == 0 {
128            return Err("idle_pause_after_seconds must be >= 1".to_string());
129        }
130        Ok(())
131    }
132
133    fn localizations(&self) -> Vec<CapabilityLocalization> {
134        vec![
135            CapabilityLocalization {
136                locale: "en",
137                name: None,
138                description: None,
139                config_description: Some(
140                    "Controls the sandbox provider, auto-start behavior, and how long the \
141                     sandbox may sit idle before pausing.",
142                ),
143                config_overlay: None,
144            },
145            CapabilityLocalization {
146                locale: "uk",
147                name: Some("Пісочниця сесії"),
148                description: Some(
149                    "Одна керована пісочниця, що належить поточній сесії. Підтримує \
150                     виконання команд і файлові операції з життєвим циклом, яким керує \
151                     провайдер.",
152                ),
153                config_description: Some(
154                    "Визначає провайдера пісочниці, автозапуск і час простою до призупинення.",
155                ),
156                config_overlay: Some(json!({
157                    "properties": {
158                        "provider": {
159                            "title": "Провайдер",
160                            "description": "Ідентифікатор провайдера пісочниці (наприклад, daytona)."
161                        },
162                        "auto_start": {
163                            "title": "Автозапуск",
164                            "description": "Запускати пісочницю одразу після створення сесії."
165                        },
166                        "idle_pause_after_seconds": {
167                            "title": "Призупинення після простою (секунди)",
168                            "description": "Призупиняти пісочницю після зазначеної кількості секунд неактивності сесії."
169                        }
170                    }
171                })),
172            },
173        ]
174    }
175}
176
177fn parse_config(config: &Value) -> Result<SessionSandboxConfig, ToolExecutionResult> {
178    let config: SessionSandboxConfig = serde_json::from_value(config.clone()).map_err(|e| {
179        ToolExecutionResult::tool_error(format!("Invalid session_sandbox capability config: {e}"))
180    })?;
181
182    if config.provider.trim().is_empty() {
183        return Err(ToolExecutionResult::tool_error(
184            "session_sandbox capability requires a non-empty provider",
185        ));
186    }
187    if config.idle_pause_after_seconds == 0 {
188        return Err(ToolExecutionResult::tool_error(
189            "session_sandbox idle_pause_after_seconds must be >= 1",
190        ));
191    }
192
193    Ok(config)
194}
195
196fn provider_for_config(
197    config: &SessionSandboxConfig,
198) -> Result<Box<dyn crate::SessionSandboxProvider>, ToolExecutionResult> {
199    create_session_sandbox_provider(&config.provider).ok_or_else(|| {
200        ToolExecutionResult::tool_error(format!(
201            "Session sandbox provider '{}' is not registered",
202            config.provider
203        ))
204    })
205}
206
207fn build_sandbox_exec_result(
208    response: crate::SessionSandboxExecResponse,
209    cwd: Option<&str>,
210) -> ToolExecutionResult {
211    let mut result = json!({
212        "stdout": response.stdout,
213        "stderr": response.stderr,
214        "exit_code": response.exit_code,
215        "success": response.success,
216        "truncated": response.truncated,
217        "total_lines": response.total_lines,
218        "hint": response.hint,
219    });
220    if let Some(cwd) = cwd {
221        result["cwd"] = json!(cwd);
222    }
223
224    if let Some(raw_output) = response.raw_output {
225        ToolExecutionResult::success_with_raw_output(result, raw_output)
226    } else {
227        ToolExecutionResult::success(result)
228    }
229}
230
231fn build_sandbox_read_file_result(
232    response: crate::SessionSandboxReadFileResponse,
233    offset: usize,
234    limit: usize,
235) -> ToolExecutionResult {
236    if response.encoding != "text" && response.encoding != "utf-8" {
237        let bytes_returned = response.content.len();
238        let mut result = json!({
239            "path": response.path,
240            "content": response.content,
241            "encoding": response.encoding,
242            "size_bytes": bytes_returned,
243        });
244        TruncationInfo::not_truncated(bytes_returned).attach(&mut result);
245        return ToolExecutionResult::success(result);
246    }
247
248    ToolExecutionResult::success(build_text_read_file_result(
249        "sandbox_read_file",
250        &response.path,
251        &response.content,
252        &response.encoding,
253        offset,
254        limit,
255    ))
256}
257
258#[derive(Clone)]
259pub struct SandboxExecTool {
260    config: Value,
261}
262
263impl SandboxExecTool {
264    pub fn new(config: Value) -> Self {
265        Self { config }
266    }
267}
268
269#[async_trait]
270impl Tool for SandboxExecTool {
271    fn narrate(
272        &self,
273        tool_call: &crate::tool_types::ToolCall,
274        phase: crate::tool_narration::ToolNarrationPhase,
275        locale: Option<&str>,
276        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
277    ) -> Option<String> {
278        let fallback = self.display_name().unwrap_or("Sandbox");
279        Some(crate::tool_narration::narrate_shell_exec(
280            &tool_call.arguments,
281            fallback,
282            phase,
283            locale,
284        ))
285    }
286
287    fn name(&self) -> &str {
288        "sandbox_exec"
289    }
290
291    fn description(&self) -> &str {
292        "Execute a shell command inside the session-managed sandbox."
293    }
294
295    fn parameters_schema(&self) -> Value {
296        json!({
297            "type": "object",
298            "properties": {
299                "command": { "type": "string", "description": "Shell command to execute" },
300                "cwd": { "type": "string", "description": "Optional working directory inside the sandbox" },
301                "timeout_ms": { "type": "integer", "minimum": 1, "description": "Optional execution timeout in milliseconds" },
302                "output": crate::tool_output_sanitizer::output_verbosity_schema()
303            },
304            "required": ["command"],
305            "additionalProperties": false
306        })
307    }
308
309    fn hints(&self) -> crate::ToolHints {
310        session_sandbox_tool_hints()
311    }
312
313    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
314        ToolExecutionResult::tool_error(
315            "sandbox_exec requires context. This tool must be executed with session context.",
316        )
317    }
318
319    async fn execute_with_context(
320        &self,
321        arguments: Value,
322        context: &ToolContext,
323    ) -> ToolExecutionResult {
324        let config = match parse_config(&self.config) {
325            Ok(config) => config,
326            Err(err) => return err,
327        };
328        let Some(command) = arguments.get("command").and_then(|v| v.as_str()) else {
329            return ToolExecutionResult::tool_error("Missing required parameter: command");
330        };
331        let timeout_ms = match arguments.get("timeout_ms") {
332            None => None,
333            Some(value) => match value.as_u64() {
334                Some(timeout_ms) if timeout_ms > 0 => Some(timeout_ms),
335                _ => {
336                    return ToolExecutionResult::tool_error(
337                        "timeout_ms must be a positive integer",
338                    );
339                }
340            },
341        };
342        let provider = match provider_for_config(&config) {
343            Ok(provider) => provider,
344            Err(err) => return err,
345        };
346        let mut state = match ensure_session_sandbox_running(context, &config).await {
347            Ok(state) => state,
348            Err(err) => return err,
349        };
350
351        match provider
352            .exec(
353                context,
354                &config,
355                &state.instance,
356                &crate::SessionSandboxExecRequest {
357                    command: command.to_string(),
358                    cwd: arguments
359                        .get("cwd")
360                        .and_then(|v| v.as_str())
361                        .map(ToString::to_string),
362                    timeout_ms,
363                    // EVE-489: persistence-first default — `auto` returns
364                    // compact summaries on success, diagnostics on failure.
365                    output_mode: arguments
366                        .get("output")
367                        .and_then(|v| v.as_str())
368                        .unwrap_or("auto")
369                        .to_string(),
370                },
371            )
372            .await
373        {
374            Ok(response) => {
375                if let Err(err) =
376                    checkpoint_session_sandbox(context, provider.as_ref(), &config, &mut state)
377                        .await
378                {
379                    return err;
380                }
381                build_sandbox_exec_result(response, arguments.get("cwd").and_then(|v| v.as_str()))
382            }
383            Err(err) => err,
384        }
385    }
386
387    fn requires_context(&self) -> bool {
388        true
389    }
390}
391
392#[derive(Clone)]
393pub struct SandboxReadFileTool {
394    config: Value,
395}
396
397impl SandboxReadFileTool {
398    pub fn new(config: Value) -> Self {
399        Self { config }
400    }
401}
402
403#[async_trait]
404impl Tool for SandboxReadFileTool {
405    fn narrate(
406        &self,
407        tool_call: &crate::tool_types::ToolCall,
408        phase: crate::tool_narration::ToolNarrationPhase,
409        locale: Option<&str>,
410        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
411    ) -> Option<String> {
412        Some(crate::tool_narration::narrate_read_file(
413            &tool_call.arguments,
414            phase,
415            locale,
416        ))
417    }
418
419    fn name(&self) -> &str {
420        "sandbox_read_file"
421    }
422
423    fn description(&self) -> &str {
424        "Read a file from the session-managed sandbox filesystem."
425    }
426
427    fn parameters_schema(&self) -> Value {
428        json!({
429            "type": "object",
430            "properties": {
431                "path": { "type": "string", "description": "Path to read inside the sandbox" },
432                "offset": {
433                    "type": "integer",
434                    "minimum": 0,
435                    "default": 0,
436                    "description": "Zero-based line offset to start reading from"
437                },
438                "limit": {
439                    "type": "integer",
440                    "minimum": 1,
441                    "default": READ_FILE_DEFAULT_LIMIT,
442                    "description": "Maximum number of lines to return"
443                }
444            },
445            "required": ["path"],
446            "additionalProperties": false
447        })
448    }
449
450    fn hints(&self) -> crate::ToolHints {
451        session_sandbox_tool_hints().with_readonly(true)
452    }
453
454    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
455        ToolExecutionResult::tool_error(
456            "sandbox_read_file requires context. This tool must be executed with session context.",
457        )
458    }
459
460    async fn execute_with_context(
461        &self,
462        arguments: Value,
463        context: &ToolContext,
464    ) -> ToolExecutionResult {
465        let config = match parse_config(&self.config) {
466            Ok(config) => config,
467            Err(err) => return err,
468        };
469        let provider = match provider_for_config(&config) {
470            Ok(provider) => provider,
471            Err(err) => return err,
472        };
473        let state = match ensure_session_sandbox_running(context, &config).await {
474            Ok(state) => state,
475            Err(err) => return err,
476        };
477        let Some(path) = arguments.get("path").and_then(|v| v.as_str()) else {
478            return ToolExecutionResult::tool_error("Missing required parameter: path");
479        };
480        let (offset, limit) = match parse_read_file_window_args(&arguments) {
481            Ok(window) => window,
482            Err(err) => return ToolExecutionResult::tool_error(err),
483        };
484
485        match provider
486            .read_file(context, &config, &state.instance, path)
487            .await
488        {
489            Ok(response) => build_sandbox_read_file_result(response, offset, limit),
490            Err(err) => err,
491        }
492    }
493
494    fn requires_context(&self) -> bool {
495        true
496    }
497}
498
499#[derive(Clone)]
500pub struct SandboxWriteFileTool {
501    config: Value,
502}
503
504impl SandboxWriteFileTool {
505    pub fn new(config: Value) -> Self {
506        Self { config }
507    }
508}
509
510#[async_trait]
511impl Tool for SandboxWriteFileTool {
512    fn narrate(
513        &self,
514        tool_call: &crate::tool_types::ToolCall,
515        phase: crate::tool_narration::ToolNarrationPhase,
516        locale: Option<&str>,
517        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
518    ) -> Option<String> {
519        Some(crate::tool_narration::narrate_write_file(
520            &tool_call.arguments,
521            phase,
522            locale,
523        ))
524    }
525
526    fn name(&self) -> &str {
527        "sandbox_write_file"
528    }
529
530    fn description(&self) -> &str {
531        "Write a file into the session-managed sandbox filesystem."
532    }
533
534    fn parameters_schema(&self) -> Value {
535        json!({
536            "type": "object",
537            "properties": {
538                "path": { "type": "string", "description": "Destination path inside the sandbox" },
539                "content": { "type": "string", "description": "File content to write" }
540            },
541            "required": ["path", "content"],
542            "additionalProperties": false
543        })
544    }
545
546    fn hints(&self) -> crate::ToolHints {
547        session_sandbox_tool_hints()
548    }
549
550    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
551        ToolExecutionResult::tool_error(
552            "sandbox_write_file requires context. This tool must be executed with session context.",
553        )
554    }
555
556    async fn execute_with_context(
557        &self,
558        arguments: Value,
559        context: &ToolContext,
560    ) -> ToolExecutionResult {
561        let config = match parse_config(&self.config) {
562            Ok(config) => config,
563            Err(err) => return err,
564        };
565        let provider = match provider_for_config(&config) {
566            Ok(provider) => provider,
567            Err(err) => return err,
568        };
569        let mut state = match ensure_session_sandbox_running(context, &config).await {
570            Ok(state) => state,
571            Err(err) => return err,
572        };
573        let Some(path) = arguments.get("path").and_then(|v| v.as_str()) else {
574            return ToolExecutionResult::tool_error("Missing required parameter: path");
575        };
576        let Some(content) = arguments.get("content").and_then(|v| v.as_str()) else {
577            return ToolExecutionResult::tool_error("Missing required parameter: content");
578        };
579
580        match provider
581            .write_file(context, &config, &state.instance, path, content)
582            .await
583        {
584            Ok(response) => {
585                if let Err(err) =
586                    checkpoint_session_sandbox(context, provider.as_ref(), &config, &mut state)
587                        .await
588                {
589                    return err;
590                }
591                ToolExecutionResult::success(json!({
592                    "path": response.path,
593                    "bytes_written": response.bytes_written,
594                }))
595            }
596            Err(err) => err,
597        }
598    }
599
600    fn requires_context(&self) -> bool {
601        true
602    }
603}
604
605#[derive(Clone)]
606pub struct SandboxStatusTool {
607    config: Value,
608}
609
610impl SandboxStatusTool {
611    pub fn new(config: Value) -> Self {
612        Self { config }
613    }
614}
615
616#[async_trait]
617impl Tool for SandboxStatusTool {
618    fn narrate(
619        &self,
620        _tool_call: &crate::tool_types::ToolCall,
621        phase: crate::tool_narration::ToolNarrationPhase,
622        locale: Option<&str>,
623        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
624    ) -> Option<String> {
625        Some(crate::tool_narration::narrate_sandbox_status(phase, locale))
626    }
627
628    fn name(&self) -> &str {
629        "sandbox_status"
630    }
631
632    fn description(&self) -> &str {
633        "Inspect the current state of the session-managed sandbox."
634    }
635
636    fn parameters_schema(&self) -> Value {
637        json!({
638            "type": "object",
639            "properties": {},
640            "additionalProperties": false
641        })
642    }
643
644    fn hints(&self) -> crate::ToolHints {
645        session_sandbox_tool_hints()
646            .with_readonly(true)
647            .with_idempotent(true)
648    }
649
650    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
651        ToolExecutionResult::tool_error(
652            "sandbox_status requires context. This tool must be executed with session context.",
653        )
654    }
655
656    async fn execute_with_context(
657        &self,
658        _arguments: Value,
659        context: &ToolContext,
660    ) -> ToolExecutionResult {
661        let config = match parse_config(&self.config) {
662            Ok(config) => config,
663            Err(err) => return err,
664        };
665        let Some(state) = (match load_session_sandbox_state(context).await {
666            Ok(state) => state,
667            Err(err) => return err,
668        }) else {
669            return ToolExecutionResult::success(json!({
670                "exists": false,
671                "provider": config.provider,
672            }));
673        };
674        let provider = match provider_for_config(&config) {
675            Ok(provider) => provider,
676            Err(err) => return err,
677        };
678
679        match provider.status(context, &config, &state).await {
680            Ok(response) => ToolExecutionResult::success(json!({
681                "exists": true,
682                "provider": response.provider,
683                "session_status": response.session_status,
684                "external_id": response.external_id,
685                "display_name": response.display_name,
686                "workspace_path": response.workspace_path,
687                "metadata": response.metadata,
688            })),
689            Err(err) => err,
690        }
691    }
692
693    fn requires_context(&self) -> bool {
694        true
695    }
696}
697
698#[derive(Clone)]
699pub struct SandboxManageTool {
700    config: Value,
701}
702
703impl SandboxManageTool {
704    pub fn new(config: Value) -> Self {
705        Self { config }
706    }
707}
708
709#[async_trait]
710impl Tool for SandboxManageTool {
711    fn narrate(
712        &self,
713        tool_call: &crate::tool_types::ToolCall,
714        phase: crate::tool_narration::ToolNarrationPhase,
715        locale: Option<&str>,
716        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
717    ) -> Option<String> {
718        Some(crate::tool_narration::narrate_sandbox_manage(
719            &tool_call.arguments,
720            phase,
721            locale,
722        ))
723    }
724
725    fn name(&self) -> &str {
726        "sandbox_manage"
727    }
728
729    fn description(&self) -> &str {
730        "Pause, resume, or delete the session-managed sandbox."
731    }
732
733    fn parameters_schema(&self) -> Value {
734        json!({
735            "type": "object",
736            "properties": {
737                "action": {
738                    "type": "string",
739                    "enum": ["pause", "resume", "delete"],
740                    "description": "Lifecycle action to apply"
741                }
742            },
743            "required": ["action"],
744            "additionalProperties": false
745        })
746    }
747
748    fn hints(&self) -> crate::ToolHints {
749        session_sandbox_tool_hints().with_destructive(true)
750    }
751
752    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
753        ToolExecutionResult::tool_error(
754            "sandbox_manage requires context. This tool must be executed with session context.",
755        )
756    }
757
758    async fn execute_with_context(
759        &self,
760        arguments: Value,
761        context: &ToolContext,
762    ) -> ToolExecutionResult {
763        let config = match parse_config(&self.config) {
764            Ok(config) => config,
765            Err(err) => return err,
766        };
767        let Some(action) = arguments.get("action").and_then(|v| v.as_str()) else {
768            return ToolExecutionResult::tool_error("Missing required parameter: action");
769        };
770
771        match action {
772            "pause" => match pause_session_sandbox(context, &config).await {
773                Ok(Some(state)) => ToolExecutionResult::success(json!({
774                    "action": action,
775                    "provider": state.provider,
776                    "session_status": state.status,
777                    "external_id": state.instance.external_id,
778                })),
779                Ok(None) => ToolExecutionResult::success(json!({
780                    "action": action,
781                    "exists": false,
782                })),
783                Err(err) => err,
784            },
785            "resume" => match ensure_session_sandbox_running(context, &config).await {
786                Ok(state) => ToolExecutionResult::success(json!({
787                    "action": action,
788                    "provider": state.provider,
789                    "session_status": state.status,
790                    "external_id": state.instance.external_id,
791                })),
792                Err(err) => err,
793            },
794            "delete" => match delete_session_sandbox(context, &config).await {
795                Ok(deleted) => ToolExecutionResult::success(json!({
796                    "action": action,
797                    "deleted": deleted,
798                })),
799                Err(err) => err,
800            },
801            _ => ToolExecutionResult::tool_error(
802                "Invalid action: must be one of pause, resume, delete",
803            ),
804        }
805    }
806
807    fn requires_context(&self) -> bool {
808        true
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use crate::capabilities::{Capability, CapabilityRegistry};
816    use crate::deployment::DeploymentGrade;
817    use crate::traits::ToolContext;
818
819    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
820
821    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
822        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
823    }
824
825    // Metadata/dependency constants covered by builtin_capabilities_satisfy_registry_invariants.
826
827    #[test]
828    fn session_sandbox_tools_with_config() {
829        let cap = SessionSandboxCapability;
830        let tools = cap.tools_with_config(&json!({"provider": "daytona"}));
831        let names: Vec<&str> = tools.iter().map(|tool| tool.name()).collect();
832        assert_eq!(names.len(), 5);
833        assert!(names.contains(&"sandbox_exec"));
834        assert!(names.contains(&"sandbox_read_file"));
835        assert!(names.contains(&"sandbox_write_file"));
836        assert!(names.contains(&"sandbox_status"));
837        assert!(names.contains(&"sandbox_manage"));
838    }
839
840    #[test]
841    fn session_sandbox_tools_share_concurrency_class() {
842        let cap = SessionSandboxCapability;
843        let tools = cap.tools_with_config(&json!({"provider": "daytona"}));
844
845        for tool in tools {
846            let definition = tool.to_definition();
847            assert_eq!(
848                definition.concurrency_class(),
849                Some("session_sandbox"),
850                "{} should serialize against other session sandbox tools",
851                tool.name()
852            );
853        }
854    }
855
856    #[test]
857    fn session_sandbox_config_schema_and_validation() {
858        let cap = SessionSandboxCapability;
859
860        let schema = cap.config_schema().expect("config schema");
861        assert_eq!(schema["type"], "object");
862        assert!(schema["properties"]["provider"].is_object());
863        assert!(schema["properties"]["auto_start"].is_object());
864        assert!(schema["properties"]["idle_pause_after_seconds"].is_object());
865        // Advanced settings stay out of the schema.
866        assert!(schema["properties"].get("provider_config").is_none());
867        assert!(schema["properties"].get("init").is_none());
868
869        // Unconfigured attachment is valid.
870        assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
871        assert!(cap.validate_config(&json!({})).is_ok());
872
873        // Valid config, including tolerated advanced fields.
874        assert!(
875            cap.validate_config(&json!({
876                "provider": "daytona",
877                "auto_start": false,
878                "idle_pause_after_seconds": 60,
879                "provider_config": { "snapshot": "base" },
880                "init": { "commands": ["echo ok"] }
881            }))
882            .is_ok()
883        );
884
885        // Invalid configs are rejected with the same checks parse_config applies.
886        assert!(cap.validate_config(&json!({ "provider": "  " })).is_err());
887        let err = cap
888            .validate_config(&json!({
889                "provider": "daytona",
890                "idle_pause_after_seconds": 0
891            }))
892            .unwrap_err();
893        assert!(err.contains("idle_pause_after_seconds"));
894    }
895
896    #[test]
897    fn session_sandbox_localizations_resolve_uk() {
898        let cap = SessionSandboxCapability;
899        assert_eq!(cap.localized_name(Some("uk-UA")), "Пісочниця сесії");
900        assert!(cap.describe_schema(None).is_some());
901    }
902
903    #[test]
904    fn session_sandbox_registry_is_flag_gated() {
905        let _lock = lock_env();
906        unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
907        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
908        assert!(!registry.has("session_sandbox"));
909
910        unsafe { std::env::set_var("FEATURE_SESSION_SANDBOX", "true") };
911        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
912        assert!(registry.has("session_sandbox"));
913        unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
914    }
915
916    #[tokio::test]
917    async fn sandbox_exec_rejects_zero_timeout() {
918        let tool = SandboxExecTool::new(json!({ "provider": "missing-provider" }));
919        let context = ToolContext::new(crate::typed_id::SessionId::new());
920
921        let result = tool
922            .execute_with_context(
923                json!({
924                    "command": "echo hi",
925                    "timeout_ms": 0,
926                }),
927                &context,
928            )
929            .await;
930
931        match result {
932            ToolExecutionResult::ToolError(message) => {
933                assert!(message.contains("timeout_ms must be a positive integer"));
934            }
935            other => panic!("expected ToolError, got {other:?}"),
936        }
937    }
938
939    #[test]
940    fn sandbox_exec_result_preserves_absent_raw_output() {
941        let result = build_sandbox_exec_result(
942            crate::SessionSandboxExecResponse {
943                exit_code: 0,
944                stdout: "ok".to_string(),
945                stderr: String::new(),
946                success: true,
947                truncated: false,
948                total_lines: 1,
949                raw_output: None,
950                hint: None,
951            },
952            Some("/workspace"),
953        )
954        .into_tool_result("call_1", "sandbox_exec");
955
956        assert_eq!(result.raw_output, None);
957        assert_eq!(result.result.unwrap()["cwd"], "/workspace");
958    }
959
960    #[test]
961    fn sandbox_exec_result_keeps_raw_output_sidecar_when_present() {
962        let result = build_sandbox_exec_result(
963            crate::SessionSandboxExecResponse {
964                exit_code: 17,
965                stdout: "trimmed".to_string(),
966                stderr: "warn".to_string(),
967                success: false,
968                truncated: true,
969                total_lines: 42,
970                raw_output: Some("full output".to_string()),
971                hint: Some("non-zero".to_string()),
972            },
973            None,
974        )
975        .into_tool_result("call_1", "sandbox_exec");
976
977        assert_eq!(result.raw_output.as_deref(), Some("full output"));
978        let payload = result.result.unwrap();
979        assert_eq!(payload["exit_code"], 17);
980        assert_eq!(payload["truncated"], true);
981        assert_eq!(payload["hint"], "non-zero");
982    }
983
984    #[test]
985    fn sandbox_read_file_result_applies_line_window() {
986        let result = build_sandbox_read_file_result(
987            crate::SessionSandboxReadFileResponse {
988                path: "/workspace/src/lib.rs".to_string(),
989                content: "alpha\nbeta\ngamma\ndelta".to_string(),
990                encoding: "text".to_string(),
991            },
992            1,
993            2,
994        )
995        .into_tool_result("call_1", "sandbox_read_file");
996
997        let payload = result.result.unwrap();
998        assert_eq!(payload["path"], "/workspace/src/lib.rs");
999        assert_eq!(payload["content"], "2|beta\n3|gamma");
1000        assert_eq!(payload["total_lines"], 4);
1001        assert_eq!(payload["lines_shown"]["start"], 2);
1002        assert_eq!(payload["lines_shown"]["end"], 3);
1003        assert_eq!(payload["truncated"], true);
1004        assert_eq!(payload["truncation"]["next_offset"], 3);
1005        assert!(
1006            payload["truncation"]["resume_hint"]
1007                .as_str()
1008                .unwrap()
1009                .contains("sandbox_read_file")
1010        );
1011    }
1012
1013    #[test]
1014    fn sandbox_read_file_result_marks_untruncated_window() {
1015        let result = build_sandbox_read_file_result(
1016            crate::SessionSandboxReadFileResponse {
1017                path: "/workspace/src/lib.rs".to_string(),
1018                content: "alpha\nbeta".to_string(),
1019                encoding: "text".to_string(),
1020            },
1021            0,
1022            10,
1023        )
1024        .into_tool_result("call_1", "sandbox_read_file");
1025
1026        let payload = result.result.unwrap();
1027        assert_eq!(payload["content"], "1|alpha\n2|beta");
1028        assert_eq!(payload["truncated"], false);
1029        assert_eq!(payload["truncation"]["truncated"], false);
1030    }
1031}