1use 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 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 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 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 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 #[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 assert!(schema["properties"].get("provider_config").is_none());
853 assert!(schema["properties"].get("init").is_none());
854
855 assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
857 assert!(cap.validate_config(&json!({})).is_ok());
858
859 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 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}