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