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 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 #[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 assert!(schema["properties"].get("provider_config").is_none());
801 assert!(schema["properties"].get("init").is_none());
802
803 assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
805 assert!(cap.validate_config(&json!({})).is_ok());
806
807 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 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}