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,
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 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                build_sandbox_exec_result(response, arguments.get("cwd").and_then(|v| v.as_str()))
376            }
377            Err(err) => err,
378        }
379    }
380
381    fn requires_context(&self) -> bool {
382        true
383    }
384}
385
386#[derive(Clone)]
387pub struct SandboxReadFileTool {
388    config: Value,
389}
390
391impl SandboxReadFileTool {
392    pub fn new(config: Value) -> Self {
393        Self { config }
394    }
395}
396
397#[async_trait]
398impl Tool for SandboxReadFileTool {
399    fn narrate(
400        &self,
401        tool_call: &crate::tool_types::ToolCall,
402        phase: crate::tool_narration::ToolNarrationPhase,
403        locale: Option<&str>,
404        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
405    ) -> Option<String> {
406        Some(crate::tool_narration::narrate_read_file(
407            &tool_call.arguments,
408            phase,
409            locale,
410        ))
411    }
412
413    fn name(&self) -> &str {
414        "sandbox_read_file"
415    }
416
417    fn description(&self) -> &str {
418        "Read a file from the session-managed sandbox filesystem."
419    }
420
421    fn parameters_schema(&self) -> Value {
422        json!({
423            "type": "object",
424            "properties": {
425                "path": { "type": "string", "description": "Path to read inside the sandbox" },
426                "offset": {
427                    "type": "integer",
428                    "minimum": 0,
429                    "default": 0,
430                    "description": "Zero-based line offset to start reading from"
431                },
432                "limit": {
433                    "type": "integer",
434                    "minimum": 1,
435                    "default": READ_FILE_DEFAULT_LIMIT,
436                    "description": "Maximum number of lines to return"
437                }
438            },
439            "required": ["path"],
440            "additionalProperties": false
441        })
442    }
443
444    fn hints(&self) -> crate::ToolHints {
445        session_sandbox_tool_hints().with_readonly(true)
446    }
447
448    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
449        ToolExecutionResult::tool_error(
450            "sandbox_read_file requires context. This tool must be executed with session context.",
451        )
452    }
453
454    async fn execute_with_context(
455        &self,
456        arguments: Value,
457        context: &ToolContext,
458    ) -> ToolExecutionResult {
459        let config = match parse_config(&self.config) {
460            Ok(config) => config,
461            Err(err) => return err,
462        };
463        let provider = match provider_for_config(&config) {
464            Ok(provider) => provider,
465            Err(err) => return err,
466        };
467        let state = match ensure_session_sandbox_running(context, &config).await {
468            Ok(state) => state,
469            Err(err) => return err,
470        };
471        let Some(path) = arguments.get("path").and_then(|v| v.as_str()) else {
472            return ToolExecutionResult::tool_error("Missing required parameter: path");
473        };
474        let (offset, limit) = match parse_read_file_window_args(&arguments) {
475            Ok(window) => window,
476            Err(err) => return ToolExecutionResult::tool_error(err),
477        };
478
479        match provider
480            .read_file(context, &config, &state.instance, path)
481            .await
482        {
483            Ok(response) => build_sandbox_read_file_result(response, offset, limit),
484            Err(err) => err,
485        }
486    }
487
488    fn requires_context(&self) -> bool {
489        true
490    }
491}
492
493#[derive(Clone)]
494pub struct SandboxWriteFileTool {
495    config: Value,
496}
497
498impl SandboxWriteFileTool {
499    pub fn new(config: Value) -> Self {
500        Self { config }
501    }
502}
503
504#[async_trait]
505impl Tool for SandboxWriteFileTool {
506    fn narrate(
507        &self,
508        tool_call: &crate::tool_types::ToolCall,
509        phase: crate::tool_narration::ToolNarrationPhase,
510        locale: Option<&str>,
511        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
512    ) -> Option<String> {
513        Some(crate::tool_narration::narrate_write_file(
514            &tool_call.arguments,
515            phase,
516            locale,
517        ))
518    }
519
520    fn name(&self) -> &str {
521        "sandbox_write_file"
522    }
523
524    fn description(&self) -> &str {
525        "Write a file into the session-managed sandbox filesystem."
526    }
527
528    fn parameters_schema(&self) -> Value {
529        json!({
530            "type": "object",
531            "properties": {
532                "path": { "type": "string", "description": "Destination path inside the sandbox" },
533                "content": { "type": "string", "description": "File content to write" }
534            },
535            "required": ["path", "content"],
536            "additionalProperties": false
537        })
538    }
539
540    fn hints(&self) -> crate::ToolHints {
541        session_sandbox_tool_hints()
542    }
543
544    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
545        ToolExecutionResult::tool_error(
546            "sandbox_write_file requires context. This tool must be executed with session context.",
547        )
548    }
549
550    async fn execute_with_context(
551        &self,
552        arguments: Value,
553        context: &ToolContext,
554    ) -> ToolExecutionResult {
555        let config = match parse_config(&self.config) {
556            Ok(config) => config,
557            Err(err) => return err,
558        };
559        let provider = match provider_for_config(&config) {
560            Ok(provider) => provider,
561            Err(err) => return err,
562        };
563        let state = match ensure_session_sandbox_running(context, &config).await {
564            Ok(state) => state,
565            Err(err) => return err,
566        };
567        let Some(path) = arguments.get("path").and_then(|v| v.as_str()) else {
568            return ToolExecutionResult::tool_error("Missing required parameter: path");
569        };
570        let Some(content) = arguments.get("content").and_then(|v| v.as_str()) else {
571            return ToolExecutionResult::tool_error("Missing required parameter: content");
572        };
573
574        match provider
575            .write_file(context, &config, &state.instance, path, content)
576            .await
577        {
578            Ok(response) => ToolExecutionResult::success(json!({
579                "path": response.path,
580                "bytes_written": response.bytes_written,
581            })),
582            Err(err) => err,
583        }
584    }
585
586    fn requires_context(&self) -> bool {
587        true
588    }
589}
590
591#[derive(Clone)]
592pub struct SandboxStatusTool {
593    config: Value,
594}
595
596impl SandboxStatusTool {
597    pub fn new(config: Value) -> Self {
598        Self { config }
599    }
600}
601
602#[async_trait]
603impl Tool for SandboxStatusTool {
604    fn narrate(
605        &self,
606        _tool_call: &crate::tool_types::ToolCall,
607        phase: crate::tool_narration::ToolNarrationPhase,
608        locale: Option<&str>,
609        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
610    ) -> Option<String> {
611        Some(crate::tool_narration::narrate_sandbox_status(phase, locale))
612    }
613
614    fn name(&self) -> &str {
615        "sandbox_status"
616    }
617
618    fn description(&self) -> &str {
619        "Inspect the current state of the session-managed sandbox."
620    }
621
622    fn parameters_schema(&self) -> Value {
623        json!({
624            "type": "object",
625            "properties": {},
626            "additionalProperties": false
627        })
628    }
629
630    fn hints(&self) -> crate::ToolHints {
631        session_sandbox_tool_hints()
632            .with_readonly(true)
633            .with_idempotent(true)
634    }
635
636    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
637        ToolExecutionResult::tool_error(
638            "sandbox_status requires context. This tool must be executed with session context.",
639        )
640    }
641
642    async fn execute_with_context(
643        &self,
644        _arguments: Value,
645        context: &ToolContext,
646    ) -> ToolExecutionResult {
647        let config = match parse_config(&self.config) {
648            Ok(config) => config,
649            Err(err) => return err,
650        };
651        let Some(state) = (match load_session_sandbox_state(context).await {
652            Ok(state) => state,
653            Err(err) => return err,
654        }) else {
655            return ToolExecutionResult::success(json!({
656                "exists": false,
657                "provider": config.provider,
658            }));
659        };
660        let provider = match provider_for_config(&config) {
661            Ok(provider) => provider,
662            Err(err) => return err,
663        };
664
665        match provider.status(context, &config, &state).await {
666            Ok(response) => ToolExecutionResult::success(json!({
667                "exists": true,
668                "provider": response.provider,
669                "session_status": response.session_status,
670                "external_id": response.external_id,
671                "display_name": response.display_name,
672                "workspace_path": response.workspace_path,
673                "metadata": response.metadata,
674            })),
675            Err(err) => err,
676        }
677    }
678
679    fn requires_context(&self) -> bool {
680        true
681    }
682}
683
684#[derive(Clone)]
685pub struct SandboxManageTool {
686    config: Value,
687}
688
689impl SandboxManageTool {
690    pub fn new(config: Value) -> Self {
691        Self { config }
692    }
693}
694
695#[async_trait]
696impl Tool for SandboxManageTool {
697    fn narrate(
698        &self,
699        tool_call: &crate::tool_types::ToolCall,
700        phase: crate::tool_narration::ToolNarrationPhase,
701        locale: Option<&str>,
702        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
703    ) -> Option<String> {
704        Some(crate::tool_narration::narrate_sandbox_manage(
705            &tool_call.arguments,
706            phase,
707            locale,
708        ))
709    }
710
711    fn name(&self) -> &str {
712        "sandbox_manage"
713    }
714
715    fn description(&self) -> &str {
716        "Pause, resume, or delete the session-managed sandbox."
717    }
718
719    fn parameters_schema(&self) -> Value {
720        json!({
721            "type": "object",
722            "properties": {
723                "action": {
724                    "type": "string",
725                    "enum": ["pause", "resume", "delete"],
726                    "description": "Lifecycle action to apply"
727                }
728            },
729            "required": ["action"],
730            "additionalProperties": false
731        })
732    }
733
734    fn hints(&self) -> crate::ToolHints {
735        session_sandbox_tool_hints().with_destructive(true)
736    }
737
738    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
739        ToolExecutionResult::tool_error(
740            "sandbox_manage requires context. This tool must be executed with session context.",
741        )
742    }
743
744    async fn execute_with_context(
745        &self,
746        arguments: Value,
747        context: &ToolContext,
748    ) -> ToolExecutionResult {
749        let config = match parse_config(&self.config) {
750            Ok(config) => config,
751            Err(err) => return err,
752        };
753        let Some(action) = arguments.get("action").and_then(|v| v.as_str()) else {
754            return ToolExecutionResult::tool_error("Missing required parameter: action");
755        };
756
757        match action {
758            "pause" => match pause_session_sandbox(context, &config).await {
759                Ok(Some(state)) => ToolExecutionResult::success(json!({
760                    "action": action,
761                    "provider": state.provider,
762                    "session_status": state.status,
763                    "external_id": state.instance.external_id,
764                })),
765                Ok(None) => ToolExecutionResult::success(json!({
766                    "action": action,
767                    "exists": false,
768                })),
769                Err(err) => err,
770            },
771            "resume" => match ensure_session_sandbox_running(context, &config).await {
772                Ok(state) => ToolExecutionResult::success(json!({
773                    "action": action,
774                    "provider": state.provider,
775                    "session_status": state.status,
776                    "external_id": state.instance.external_id,
777                })),
778                Err(err) => err,
779            },
780            "delete" => match delete_session_sandbox(context, &config).await {
781                Ok(deleted) => ToolExecutionResult::success(json!({
782                    "action": action,
783                    "deleted": deleted,
784                })),
785                Err(err) => err,
786            },
787            _ => ToolExecutionResult::tool_error(
788                "Invalid action: must be one of pause, resume, delete",
789            ),
790        }
791    }
792
793    fn requires_context(&self) -> bool {
794        true
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use crate::capabilities::{Capability, CapabilityRegistry};
802    use crate::deployment::DeploymentGrade;
803    use crate::traits::ToolContext;
804
805    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
806
807    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
808        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
809    }
810
811    // Metadata/dependency constants covered by builtin_capabilities_satisfy_registry_invariants.
812
813    #[test]
814    fn session_sandbox_tools_with_config() {
815        let cap = SessionSandboxCapability;
816        let tools = cap.tools_with_config(&json!({"provider": "daytona"}));
817        let names: Vec<&str> = tools.iter().map(|tool| tool.name()).collect();
818        assert_eq!(names.len(), 5);
819        assert!(names.contains(&"sandbox_exec"));
820        assert!(names.contains(&"sandbox_read_file"));
821        assert!(names.contains(&"sandbox_write_file"));
822        assert!(names.contains(&"sandbox_status"));
823        assert!(names.contains(&"sandbox_manage"));
824    }
825
826    #[test]
827    fn session_sandbox_tools_share_concurrency_class() {
828        let cap = SessionSandboxCapability;
829        let tools = cap.tools_with_config(&json!({"provider": "daytona"}));
830
831        for tool in tools {
832            let definition = tool.to_definition();
833            assert_eq!(
834                definition.concurrency_class(),
835                Some("session_sandbox"),
836                "{} should serialize against other session sandbox tools",
837                tool.name()
838            );
839        }
840    }
841
842    #[test]
843    fn session_sandbox_config_schema_and_validation() {
844        let cap = SessionSandboxCapability;
845
846        let schema = cap.config_schema().expect("config schema");
847        assert_eq!(schema["type"], "object");
848        assert!(schema["properties"]["provider"].is_object());
849        assert!(schema["properties"]["auto_start"].is_object());
850        assert!(schema["properties"]["idle_pause_after_seconds"].is_object());
851        // Advanced settings stay out of the schema.
852        assert!(schema["properties"].get("provider_config").is_none());
853        assert!(schema["properties"].get("init").is_none());
854
855        // Unconfigured attachment is valid.
856        assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
857        assert!(cap.validate_config(&json!({})).is_ok());
858
859        // Valid config, including tolerated advanced fields.
860        assert!(
861            cap.validate_config(&json!({
862                "provider": "daytona",
863                "auto_start": false,
864                "idle_pause_after_seconds": 60,
865                "provider_config": { "snapshot": "base" },
866                "init": { "commands": ["echo ok"] }
867            }))
868            .is_ok()
869        );
870
871        // Invalid configs are rejected with the same checks parse_config applies.
872        assert!(cap.validate_config(&json!({ "provider": "  " })).is_err());
873        let err = cap
874            .validate_config(&json!({
875                "provider": "daytona",
876                "idle_pause_after_seconds": 0
877            }))
878            .unwrap_err();
879        assert!(err.contains("idle_pause_after_seconds"));
880    }
881
882    #[test]
883    fn session_sandbox_localizations_resolve_uk() {
884        let cap = SessionSandboxCapability;
885        assert_eq!(cap.localized_name(Some("uk-UA")), "Пісочниця сесії");
886        assert!(cap.describe_schema(None).is_some());
887    }
888
889    #[test]
890    fn session_sandbox_registry_is_flag_gated() {
891        let _lock = lock_env();
892        unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
893        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
894        assert!(!registry.has("session_sandbox"));
895
896        unsafe { std::env::set_var("FEATURE_SESSION_SANDBOX", "true") };
897        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
898        assert!(registry.has("session_sandbox"));
899        unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
900    }
901
902    #[tokio::test]
903    async fn sandbox_exec_rejects_zero_timeout() {
904        let tool = SandboxExecTool::new(json!({ "provider": "missing-provider" }));
905        let context = ToolContext::new(crate::typed_id::SessionId::new());
906
907        let result = tool
908            .execute_with_context(
909                json!({
910                    "command": "echo hi",
911                    "timeout_ms": 0,
912                }),
913                &context,
914            )
915            .await;
916
917        match result {
918            ToolExecutionResult::ToolError(message) => {
919                assert!(message.contains("timeout_ms must be a positive integer"));
920            }
921            other => panic!("expected ToolError, got {other:?}"),
922        }
923    }
924
925    #[test]
926    fn sandbox_exec_result_preserves_absent_raw_output() {
927        let result = build_sandbox_exec_result(
928            crate::SessionSandboxExecResponse {
929                exit_code: 0,
930                stdout: "ok".to_string(),
931                stderr: String::new(),
932                success: true,
933                truncated: false,
934                total_lines: 1,
935                raw_output: None,
936                hint: None,
937            },
938            Some("/workspace"),
939        )
940        .into_tool_result("call_1", "sandbox_exec");
941
942        assert_eq!(result.raw_output, None);
943        assert_eq!(result.result.unwrap()["cwd"], "/workspace");
944    }
945
946    #[test]
947    fn sandbox_exec_result_keeps_raw_output_sidecar_when_present() {
948        let result = build_sandbox_exec_result(
949            crate::SessionSandboxExecResponse {
950                exit_code: 17,
951                stdout: "trimmed".to_string(),
952                stderr: "warn".to_string(),
953                success: false,
954                truncated: true,
955                total_lines: 42,
956                raw_output: Some("full output".to_string()),
957                hint: Some("non-zero".to_string()),
958            },
959            None,
960        )
961        .into_tool_result("call_1", "sandbox_exec");
962
963        assert_eq!(result.raw_output.as_deref(), Some("full output"));
964        let payload = result.result.unwrap();
965        assert_eq!(payload["exit_code"], 17);
966        assert_eq!(payload["truncated"], true);
967        assert_eq!(payload["hint"], "non-zero");
968    }
969
970    #[test]
971    fn sandbox_read_file_result_applies_line_window() {
972        let result = build_sandbox_read_file_result(
973            crate::SessionSandboxReadFileResponse {
974                path: "/workspace/src/lib.rs".to_string(),
975                content: "alpha\nbeta\ngamma\ndelta".to_string(),
976                encoding: "text".to_string(),
977            },
978            1,
979            2,
980        )
981        .into_tool_result("call_1", "sandbox_read_file");
982
983        let payload = result.result.unwrap();
984        assert_eq!(payload["path"], "/workspace/src/lib.rs");
985        assert_eq!(payload["content"], "2|beta\n3|gamma");
986        assert_eq!(payload["total_lines"], 4);
987        assert_eq!(payload["lines_shown"]["start"], 2);
988        assert_eq!(payload["lines_shown"]["end"], 3);
989        assert_eq!(payload["truncated"], true);
990        assert_eq!(payload["truncation"]["next_offset"], 3);
991        assert!(
992            payload["truncation"]["resume_hint"]
993                .as_str()
994                .unwrap()
995                .contains("sandbox_read_file")
996        );
997    }
998
999    #[test]
1000    fn sandbox_read_file_result_marks_untruncated_window() {
1001        let result = build_sandbox_read_file_result(
1002            crate::SessionSandboxReadFileResponse {
1003                path: "/workspace/src/lib.rs".to_string(),
1004                content: "alpha\nbeta".to_string(),
1005                encoding: "text".to_string(),
1006            },
1007            0,
1008            10,
1009        )
1010        .into_tool_result("call_1", "sandbox_read_file");
1011
1012        let payload = result.result.unwrap();
1013        assert_eq!(payload["content"], "1|alpha\n2|beta");
1014        assert_eq!(payload["truncated"], false);
1015        assert_eq!(payload["truncation"]["truncated"], false);
1016    }
1017}