1mod catalog;
30mod registry;
31mod runner;
32mod tools_agent;
33mod tools_fs;
34mod tools_sys;
35mod types;
36
37pub use catalog::builtin_tool_catalog;
39pub use registry::{BuiltinTool, ToolRegistry};
40pub use runner::{CompiledModuleCache, WasmToolRunner, compute_module_hash};
41pub use tools_agent::*;
42pub use tools_fs::*;
43pub use tools_sys::*;
44pub use types::*;
45
46#[cfg(test)]
51mod tests {
52 use super::*;
53 use std::path::PathBuf;
54 use std::sync::Arc;
55 use std::time::Duration;
56
57 #[test]
60 fn default_config() {
61 let config = WasmSandboxConfig::default();
62 assert_eq!(config.max_fuel, 1_000_000);
63 assert_eq!(config.max_memory_bytes, 16 * 1024 * 1024);
64 assert_eq!(config.max_execution_time_secs, 30);
65 assert!(config.allowed_host_calls.is_empty());
66 assert!(!config.wasi_enabled);
67 assert_eq!(config.max_module_size_bytes, 10 * 1024 * 1024);
68 }
69
70 #[test]
71 fn config_serde_roundtrip() {
72 let config = WasmSandboxConfig {
73 max_fuel: 500_000,
74 max_memory_bytes: 8 * 1024 * 1024,
75 max_execution_time_secs: 10,
76 allowed_host_calls: vec!["clock_time_get".into()],
77 wasi_enabled: true,
78 max_module_size_bytes: 5 * 1024 * 1024,
79 };
80 let json = serde_json::to_string(&config).unwrap();
81 let restored: WasmSandboxConfig = serde_json::from_str(&json).unwrap();
82 assert_eq!(restored.max_fuel, 500_000);
83 assert!(restored.wasi_enabled);
84 assert_eq!(restored.allowed_host_calls.len(), 1);
85 }
86
87 #[test]
88 fn execution_timeout_duration() {
89 let config = WasmSandboxConfig {
90 max_execution_time_secs: 15,
91 ..Default::default()
92 };
93 assert_eq!(config.execution_timeout(), Duration::from_secs(15));
94 }
95
96 #[test]
97 fn tool_state_default() {
98 let state = ToolState::default();
99 assert!(state.tool_name.is_empty());
100 assert!(state.stdin.is_empty());
101 assert!(state.stdout.is_empty());
102 assert!(state.stderr.is_empty());
103 assert!(state.env.is_empty());
104 }
105
106 #[test]
109 fn validate_wasm_rejects_too_large() {
110 let runner = WasmToolRunner::new(WasmSandboxConfig {
111 max_module_size_bytes: 100,
112 ..Default::default()
113 });
114 let big_bytes = vec![0u8; 200];
115 let result = runner.validate_wasm(&big_bytes);
116 assert!(matches!(result, Err(WasmError::ModuleTooLarge { .. })));
117 }
118
119 #[test]
120 fn validate_wasm_rejects_invalid_magic() {
121 let runner = WasmToolRunner::new(WasmSandboxConfig::default());
122 let bad_bytes = b"not a wasm module at all";
123 let result = runner.validate_wasm(bad_bytes);
124 assert!(matches!(result, Err(WasmError::InvalidModule(_))));
125 }
126
127 #[test]
128 fn validate_wasm_rejects_too_short() {
129 let runner = WasmToolRunner::new(WasmSandboxConfig::default());
130 let short = b"\0asm";
131 let result = runner.validate_wasm(short);
132 assert!(matches!(result, Err(WasmError::InvalidModule(_))));
133 }
134
135 #[test]
136 fn validate_wasm_accepts_valid_header() {
137 let runner = WasmToolRunner::new(WasmSandboxConfig::default());
138 let mut wasm = Vec::new();
139 wasm.extend_from_slice(b"\0asm");
140 wasm.extend_from_slice(&1u32.to_le_bytes());
141
142 let result = runner.validate_wasm(&wasm);
143 assert!(result.is_ok());
144 let validation = result.unwrap();
145 assert!(validation.valid);
146 assert!(validation.warnings.is_empty());
147 }
148
149 #[test]
150 fn validate_wasm_warns_on_wrong_version() {
151 let runner = WasmToolRunner::new(WasmSandboxConfig::default());
152 let mut wasm = Vec::new();
153 wasm.extend_from_slice(b"\0asm");
154 wasm.extend_from_slice(&2u32.to_le_bytes());
155
156 #[cfg(not(feature = "wasm-sandbox"))]
157 {
158 let result = runner.validate_wasm(&wasm).unwrap();
159 assert!(result.valid);
160 assert!(!result.warnings.is_empty());
161 assert!(result.warnings[0].contains("version: 2"));
162 }
163 #[cfg(feature = "wasm-sandbox")]
164 {
165 let result = runner.validate_wasm(&wasm);
166 assert!(
167 result.is_err() || {
168 let v = result.unwrap();
169 !v.warnings.is_empty()
170 }
171 );
172 }
173 }
174
175 #[test]
176 fn load_tool_without_feature_rejects() {
177 let runner = WasmToolRunner::new(WasmSandboxConfig::default());
178 let mut wasm = Vec::new();
179 wasm.extend_from_slice(b"\0asm");
180 wasm.extend_from_slice(&1u32.to_le_bytes());
181 wasm.extend_from_slice(&[0u8; 16]);
182
183 #[cfg(not(feature = "wasm-sandbox"))]
184 {
185 let result = runner.load_tool("test-tool", &wasm);
186 assert!(matches!(result, Err(WasmError::RuntimeUnavailable)));
187 }
188 }
189
190 #[test]
191 fn wasm_error_display() {
192 let err = WasmError::RuntimeUnavailable;
193 assert!(err.to_string().contains("wasm-sandbox"));
194
195 let err = WasmError::ModuleTooLarge {
196 size: 20_000_000,
197 limit: 10_000_000,
198 };
199 assert!(err.to_string().contains("20000000"));
200 assert!(err.to_string().contains("10000000"));
201
202 let err = WasmError::FuelExhausted {
203 consumed: 1_000_000,
204 limit: 1_000_000,
205 };
206 assert!(err.to_string().contains("fuel exhausted"));
207
208 let err = WasmError::MemoryLimitExceeded {
209 allocated: 32_000_000,
210 limit: 16_000_000,
211 };
212 assert!(err.to_string().contains("memory limit"));
213 }
214
215 #[test]
216 fn wasm_tool_result_serde_roundtrip() {
217 let result = WasmToolResult {
218 stdout: "output".into(),
219 stderr: String::new(),
220 exit_code: 0,
221 fuel_consumed: 50_000,
222 memory_peak: 1024,
223 execution_time: Duration::from_millis(150),
224 };
225 let json = serde_json::to_string(&result).unwrap();
226 let restored: WasmToolResult = serde_json::from_str(&json).unwrap();
227 assert_eq!(restored.stdout, "output");
228 assert_eq!(restored.exit_code, 0);
229 assert_eq!(restored.fuel_consumed, 50_000);
230 assert_eq!(restored.execution_time, Duration::from_millis(150));
231 }
232
233 #[test]
234 fn wasm_validation_serde_roundtrip() {
235 let validation = WasmValidation {
236 valid: true,
237 exports: vec!["execute".into(), "tool_schema".into()],
238 imports: vec!["wasi_snapshot_preview1::fd_write".into()],
239 estimated_memory: 65536,
240 warnings: vec!["uses wasi".into()],
241 };
242 let json = serde_json::to_string(&validation).unwrap();
243 let restored: WasmValidation = serde_json::from_str(&json).unwrap();
244 assert!(restored.valid);
245 assert_eq!(restored.exports.len(), 2);
246 assert_eq!(restored.imports.len(), 1);
247 }
248
249 #[test]
252 fn builtin_catalog_has_expected_tools() {
253 let catalog = builtin_tool_catalog();
254 #[cfg(feature = "ecc")]
255 assert_eq!(catalog.len(), 36, "29 base + 7 ecc tools");
256 #[cfg(not(feature = "ecc"))]
257 assert_eq!(catalog.len(), 29);
258 }
259
260 #[test]
261 fn all_tools_have_valid_schema() {
262 let catalog = builtin_tool_catalog();
263 for spec in &catalog {
264 assert!(
265 spec.parameters.is_object(),
266 "{} has non-object schema",
267 spec.name
268 );
269 assert!(
270 spec.parameters.get("type").and_then(|v| v.as_str()) == Some("object"),
271 "{} schema type is not 'object'",
272 spec.name,
273 );
274 }
275 }
276
277 #[test]
278 fn all_tools_have_gate_action() {
279 let catalog = builtin_tool_catalog();
280 for spec in &catalog {
281 assert!(
282 !spec.gate_action.is_empty(),
283 "{} missing gate_action",
284 spec.name
285 );
286 assert!(
287 spec.gate_action.starts_with("tool.") || spec.gate_action.starts_with("ecc."),
288 "{} gate_action should start with 'tool.' or 'ecc.'",
289 spec.name,
290 );
291 }
292 }
293
294 #[test]
295 fn tool_names_are_unique() {
296 let catalog = builtin_tool_catalog();
297 let mut names: Vec<&str> = catalog.iter().map(|s| s.name.as_str()).collect();
298 names.sort();
299 let unique_count = {
300 let mut u = names.clone();
301 u.dedup();
302 u.len()
303 };
304 assert_eq!(names.len(), unique_count, "duplicate tool names found");
305 }
306
307 #[test]
308 fn tool_categories_correct() {
309 let catalog = builtin_tool_catalog();
310 let fs_count = catalog
311 .iter()
312 .filter(|s| s.category == ToolCategory::Filesystem)
313 .count();
314 let agent_count = catalog
315 .iter()
316 .filter(|s| s.category == ToolCategory::Agent)
317 .count();
318 let sys_count = catalog
319 .iter()
320 .filter(|s| s.category == ToolCategory::System)
321 .count();
322 assert_eq!(fs_count, 10);
323 assert_eq!(agent_count, 9);
324 assert_eq!(sys_count, 10);
325 #[cfg(feature = "ecc")]
326 {
327 let ecc_count = catalog
328 .iter()
329 .filter(|s| s.category == ToolCategory::Ecc)
330 .count();
331 assert_eq!(ecc_count, 7);
332 }
333 }
334
335 #[test]
338 fn fs_read_file_reads_content() {
339 let dir = std::env::temp_dir().join("clawft-fs-read-test");
340 let _ = std::fs::create_dir_all(&dir);
341 let file = dir.join("test.txt");
342 std::fs::write(&file, "hello world").unwrap();
343
344 let tool = FsReadFileTool::new();
345 let result = tool
346 .execute(serde_json::json!({"path": file.to_str().unwrap()}))
347 .unwrap();
348 assert_eq!(result["content"], "hello world");
349 assert_eq!(result["size"], 11);
350 assert!(result["modified"].as_str().is_some());
351
352 let _ = std::fs::remove_dir_all(&dir);
353 }
354
355 #[test]
356 fn fs_read_file_with_offset_limit() {
357 let dir = std::env::temp_dir().join("clawft-fs-offset-test");
358 let _ = std::fs::create_dir_all(&dir);
359 let file = dir.join("test.txt");
360 std::fs::write(&file, "0123456789").unwrap();
361
362 let tool = FsReadFileTool::new();
363 let result = tool
364 .execute(serde_json::json!({
365 "path": file.to_str().unwrap(),
366 "offset": 3,
367 "limit": 4,
368 }))
369 .unwrap();
370 assert_eq!(result["content"], "3456");
371
372 let _ = std::fs::remove_dir_all(&dir);
373 }
374
375 #[test]
376 fn fs_read_file_not_found() {
377 let tool = FsReadFileTool::new();
378 let result = tool.execute(serde_json::json!({"path": "/no/such/file/ever"}));
379 assert!(matches!(result, Err(ToolError::FileNotFound(_))));
380 }
381
382 #[test]
383 fn fs_read_file_returns_metadata() {
384 let dir = std::env::temp_dir().join("clawft-fs-meta-test");
385 let _ = std::fs::create_dir_all(&dir);
386 let file = dir.join("meta.txt");
387 std::fs::write(&file, "data").unwrap();
388
389 let tool = FsReadFileTool::new();
390 let result = tool
391 .execute(serde_json::json!({"path": file.to_str().unwrap()}))
392 .unwrap();
393 assert!(result.get("size").is_some());
394 assert!(result.get("modified").is_some());
395
396 let _ = std::fs::remove_dir_all(&dir);
397 }
398
399 #[test]
402 fn agent_spawn_creates_process() {
403 let pt = Arc::new(crate::process::ProcessTable::new(64));
404 let tool = AgentSpawnTool::new(pt.clone());
405 let result = tool
406 .execute(serde_json::json!({"agent_id": "test-spawn"}))
407 .unwrap();
408
409 let pid = result["pid"].as_u64().unwrap();
410 assert!(pt.get(pid).is_some());
411 assert_eq!(result["state"], "running");
412 }
413
414 #[test]
415 fn agent_spawn_with_wasm_backend_fails() {
416 let pt = Arc::new(crate::process::ProcessTable::new(64));
417 let tool = AgentSpawnTool::new(pt);
418 let result = tool.execute(serde_json::json!({
419 "agent_id": "wasm-agent",
420 "backend": "wasm",
421 }));
422 assert!(matches!(result, Err(ToolError::ExecutionFailed(_))));
423 }
424
425 #[test]
426 fn agent_spawn_returns_pid() {
427 let pt = Arc::new(crate::process::ProcessTable::new(64));
428 let tool = AgentSpawnTool::new(pt);
429 let result = tool
430 .execute(serde_json::json!({"agent_id": "pid-test"}))
431 .unwrap();
432 assert!(result.get("pid").is_some());
433 assert_eq!(result["agent_id"], "pid-test");
434 }
435
436 #[test]
439 fn registry_register_and_execute() {
440 let mut registry = ToolRegistry::new();
441 let tool = Arc::new(FsReadFileTool::new());
442 registry.register(tool);
443 assert_eq!(registry.len(), 1);
444 assert!(registry.get("fs.read_file").is_some());
445 }
446
447 #[test]
448 fn registry_not_found() {
449 let registry = ToolRegistry::new();
450 let result = registry.execute("no.such.tool", serde_json::json!({}));
451 assert!(matches!(result, Err(ToolError::NotFound(_))));
452 }
453
454 #[test]
457 fn registry_parent_chain_lookup() {
458 let mut parent = ToolRegistry::new();
459 parent.register(Arc::new(FsReadFileTool::new()));
460 let parent = Arc::new(parent);
461
462 let child = ToolRegistry::with_parent(parent);
463 assert!(
464 child.get("fs.read_file").is_some(),
465 "child should find tool in parent"
466 );
467 }
468
469 #[test]
470 fn registry_child_overrides_parent() {
471 let mut parent = ToolRegistry::new();
472 parent.register(Arc::new(FsReadFileTool::new()));
473 let parent = Arc::new(parent);
474
475 let mut child = ToolRegistry::with_parent(parent);
476 child.register(Arc::new(FsReadFileTool::new()));
477 assert_eq!(child.len(), 1, "deduplicated count should be 1");
478 assert!(child.get("fs.read_file").is_some());
479 }
480
481 #[test]
482 fn registry_list_merges_parent() {
483 let mut parent = ToolRegistry::new();
484 parent.register(Arc::new(FsReadFileTool::new()));
485 let parent = Arc::new(parent);
486
487 let pt = Arc::new(crate::process::ProcessTable::new(64));
488 let mut child = ToolRegistry::with_parent(parent);
489 child.register(Arc::new(AgentSpawnTool::new(pt)));
490
491 let list = child.list();
492 assert!(
493 list.contains(&"fs.read_file".to_string()),
494 "should include parent tool"
495 );
496 assert!(
497 list.contains(&"agent.spawn".to_string()),
498 "should include child tool"
499 );
500 assert_eq!(list.len(), 2);
501 }
502
503 #[test]
504 fn registry_empty_child_delegates_all() {
505 let mut parent = ToolRegistry::new();
506 parent.register(Arc::new(FsReadFileTool::new()));
507 let parent = Arc::new(parent);
508
509 let child = ToolRegistry::with_parent(parent);
510 assert_eq!(child.len(), 1);
511 assert!(!child.is_empty());
512
513 let dir = std::env::temp_dir().join("clawft-delegate-test");
514 let _ = std::fs::create_dir_all(&dir);
515 let file = dir.join("test.txt");
516 std::fs::write(&file, "delegate").unwrap();
517 let result = child.execute(
518 "fs.read_file",
519 serde_json::json!({"path": file.to_str().unwrap()}),
520 );
521 assert!(result.is_ok());
522 assert_eq!(result.unwrap()["content"], "delegate");
523 let _ = std::fs::remove_dir_all(&dir);
524 }
525
526 #[test]
529 fn sandbox_denies_path_outside_allowed() {
530 let sandbox = SandboxConfig {
531 allowed_paths: vec![std::env::temp_dir()],
532 ..Default::default()
533 };
534 let tool = FsReadFileTool::with_sandbox(sandbox);
535 let result = tool.execute(serde_json::json!({"path": "/etc/passwd"}));
536 assert!(matches!(result, Err(ToolError::PermissionDenied(_))));
537 }
538
539 #[test]
540 fn sandbox_allows_path_inside_allowed() {
541 let dir = std::env::temp_dir().join("clawft-sandbox-allow-test");
542 let _ = std::fs::create_dir_all(&dir);
543 let file = dir.join("allowed.txt");
544 std::fs::write(&file, "allowed").unwrap();
545
546 let sandbox = SandboxConfig {
547 allowed_paths: vec![std::env::temp_dir()],
548 ..Default::default()
549 };
550 let tool = FsReadFileTool::with_sandbox(sandbox);
551 let result = tool.execute(serde_json::json!({"path": file.to_str().unwrap()}));
552 assert!(result.is_ok());
553 assert_eq!(result.unwrap()["content"], "allowed");
554
555 let _ = std::fs::remove_dir_all(&dir);
556 }
557
558 #[test]
559 fn sandbox_default_allows_all() {
560 let dir = std::env::temp_dir().join("clawft-sandbox-default-test");
561 let _ = std::fs::create_dir_all(&dir);
562 let file = dir.join("default.txt");
563 std::fs::write(&file, "default").unwrap();
564
565 let tool = FsReadFileTool::new();
566 let result = tool.execute(serde_json::json!({"path": file.to_str().unwrap()}));
567 assert!(result.is_ok());
568
569 let _ = std::fs::remove_dir_all(&dir);
570 }
571
572 #[test]
575 fn fs_write_file_creates_and_writes() {
576 let dir = std::env::temp_dir().join("clawft-fs-write-test");
577 let _ = std::fs::create_dir_all(&dir);
578 let file = dir.join("out.txt");
579 let tool = FsWriteFileTool::new();
580 let result =
581 tool.execute(serde_json::json!({"path": file.to_str().unwrap(), "content": "hello"}));
582 assert!(result.is_ok());
583 assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello");
584 let _ = std::fs::remove_dir_all(&dir);
585 }
586
587 #[test]
588 fn fs_write_file_missing_content() {
589 let tool = FsWriteFileTool::new();
590 let result = tool.execute(serde_json::json!({"path": "/tmp/x"}));
591 assert!(matches!(result, Err(ToolError::InvalidArgs(_))));
592 }
593
594 #[test]
595 fn fs_read_dir_lists_entries() {
596 let dir = std::env::temp_dir().join("clawft-fs-readdir-test");
597 let _ = std::fs::create_dir_all(&dir);
598 std::fs::write(dir.join("a.txt"), "a").unwrap();
599 std::fs::write(dir.join("b.txt"), "b").unwrap();
600 let tool = FsReadDirTool::new();
601 let result = tool
602 .execute(serde_json::json!({"path": dir.to_str().unwrap()}))
603 .unwrap();
604 assert!(result["count"].as_u64().unwrap() >= 2);
605 let _ = std::fs::remove_dir_all(&dir);
606 }
607
608 #[test]
609 fn fs_read_dir_not_found() {
610 let tool = FsReadDirTool::new();
611 let result = tool.execute(serde_json::json!({"path": "/no/such/dir/ever"}));
612 assert!(matches!(result, Err(ToolError::FileNotFound(_))));
613 }
614
615 #[test]
616 fn fs_create_dir_recursive() {
617 let dir = std::env::temp_dir().join("clawft-fs-mkdir-test/a/b/c");
618 let tool = FsCreateDirTool::new();
619 let result = tool.execute(serde_json::json!({"path": dir.to_str().unwrap()}));
620 assert!(result.is_ok());
621 assert!(dir.exists());
622 let _ = std::fs::remove_dir_all(std::env::temp_dir().join("clawft-fs-mkdir-test"));
623 }
624
625 #[test]
626 fn fs_create_dir_sandbox_denied() {
627 let tool = FsCreateDirTool::new();
628 let dir = std::env::temp_dir().join("clawft-fs-mkdir-sandbox-test");
629 let result = tool.execute(serde_json::json!({"path": dir.to_str().unwrap()}));
630 assert!(result.is_ok());
631 let _ = std::fs::remove_dir_all(&dir);
632 }
633
634 #[test]
635 fn fs_remove_file() {
636 let dir = std::env::temp_dir().join("clawft-fs-rm-test");
637 let _ = std::fs::create_dir_all(&dir);
638 let file = dir.join("delete_me.txt");
639 std::fs::write(&file, "bye").unwrap();
640 let tool = FsRemoveTool::new();
641 let result = tool.execute(serde_json::json!({"path": file.to_str().unwrap()}));
642 assert!(result.is_ok());
643 assert!(!file.exists());
644 let _ = std::fs::remove_dir_all(&dir);
645 }
646
647 #[test]
648 fn fs_remove_not_found() {
649 let tool = FsRemoveTool::new();
650 let result = tool.execute(serde_json::json!({"path": "/no/such/file/xyz"}));
651 assert!(matches!(result, Err(ToolError::FileNotFound(_))));
652 }
653
654 #[test]
655 fn fs_copy_file() {
656 let dir = std::env::temp_dir().join("clawft-fs-copy-test");
657 let _ = std::fs::create_dir_all(&dir);
658 let src = dir.join("src.txt");
659 let dst = dir.join("dst.txt");
660 std::fs::write(&src, "copy me").unwrap();
661 let tool = FsCopyTool::new();
662 let result = tool.execute(
663 serde_json::json!({"src": src.to_str().unwrap(), "dst": dst.to_str().unwrap()}),
664 );
665 assert!(result.is_ok());
666 assert_eq!(std::fs::read_to_string(&dst).unwrap(), "copy me");
667 let _ = std::fs::remove_dir_all(&dir);
668 }
669
670 #[test]
671 fn fs_copy_not_found() {
672 let tool = FsCopyTool::new();
673 let result = tool.execute(serde_json::json!({"src": "/no/file", "dst": "/tmp/out"}));
674 assert!(matches!(result, Err(ToolError::FileNotFound(_))));
675 }
676
677 #[test]
678 fn fs_move_file() {
679 let dir = std::env::temp_dir().join("clawft-fs-move-test");
680 let _ = std::fs::create_dir_all(&dir);
681 let src = dir.join("old.txt");
682 let dst = dir.join("new.txt");
683 std::fs::write(&src, "move me").unwrap();
684 let tool = FsMoveTool::new();
685 let result = tool.execute(
686 serde_json::json!({"src": src.to_str().unwrap(), "dst": dst.to_str().unwrap()}),
687 );
688 assert!(result.is_ok());
689 assert!(!src.exists());
690 assert_eq!(std::fs::read_to_string(&dst).unwrap(), "move me");
691 let _ = std::fs::remove_dir_all(&dir);
692 }
693
694 #[test]
695 fn fs_move_not_found() {
696 let tool = FsMoveTool::new();
697 let result = tool.execute(serde_json::json!({"src": "/no/file", "dst": "/tmp/out"}));
698 assert!(matches!(result, Err(ToolError::FileNotFound(_))));
699 }
700
701 #[test]
702 fn fs_stat_returns_metadata() {
703 let dir = std::env::temp_dir().join("clawft-fs-stat-test");
704 let _ = std::fs::create_dir_all(&dir);
705 let file = dir.join("stat.txt");
706 std::fs::write(&file, "data").unwrap();
707 let tool = FsStatTool::new();
708 let result = tool
709 .execute(serde_json::json!({"path": file.to_str().unwrap()}))
710 .unwrap();
711 assert_eq!(result["size"], 4);
712 assert_eq!(result["is_file"], true);
713 assert_eq!(result["is_dir"], false);
714 let _ = std::fs::remove_dir_all(&dir);
715 }
716
717 #[test]
718 fn fs_stat_error() {
719 let tool = FsStatTool::new();
720 let result = tool.execute(serde_json::json!({"path": "/no/such/file"}));
721 assert!(matches!(result, Err(ToolError::ExecutionFailed(_))));
722 }
723
724 #[test]
725 fn fs_exists_checks() {
726 let tool = FsExistsTool::new();
727 let result = tool.execute(serde_json::json!({"path": "/tmp"})).unwrap();
728 assert_eq!(result["exists"], true);
729 assert_eq!(result["is_dir"], true);
730
731 let result = tool
732 .execute(serde_json::json!({"path": "/no/such/path/xyz"}))
733 .unwrap();
734 assert_eq!(result["exists"], false);
735 }
736
737 #[test]
738 fn fs_glob_finds_files() {
739 let dir = std::env::temp_dir().join("clawft-fs-glob-test");
740 let _ = std::fs::create_dir_all(&dir);
741 std::fs::write(dir.join("test.rs"), "fn main() {}").unwrap();
742 std::fs::write(dir.join("test.txt"), "text").unwrap();
743 let tool = FsGlobTool::new();
744 let result = tool
745 .execute(serde_json::json!({
746 "pattern": "*.rs",
747 "base_dir": dir.to_str().unwrap(),
748 }))
749 .unwrap();
750 assert!(result["count"].as_u64().unwrap() >= 1);
751 let _ = std::fs::remove_dir_all(&dir);
752 }
753
754 #[test]
755 fn fs_glob_no_match() {
756 let dir = std::env::temp_dir().join("clawft-fs-glob-nomatch-test");
757 let _ = std::fs::create_dir_all(&dir);
758 let tool = FsGlobTool::new();
759 let result = tool
760 .execute(serde_json::json!({
761 "pattern": "*.xyz",
762 "base_dir": dir.to_str().unwrap(),
763 }))
764 .unwrap();
765 assert_eq!(result["count"], 0);
766 let _ = std::fs::remove_dir_all(&dir);
767 }
768
769 #[test]
772 fn agent_stop_cancels_token() {
773 let pt = Arc::new(crate::process::ProcessTable::new(64));
774 let spawn = AgentSpawnTool::new(pt.clone());
775 let result = spawn
776 .execute(serde_json::json!({"agent_id": "stop-test"}))
777 .unwrap();
778 let pid = result["pid"].as_u64().unwrap();
779
780 let tool = AgentStopTool::new(pt.clone());
781 let result = tool.execute(serde_json::json!({"pid": pid}));
782 assert!(result.is_ok());
783 let entry = pt.get(pid).unwrap();
784 assert!(entry.cancel_token.is_cancelled());
785 }
786
787 #[test]
788 fn agent_stop_not_found() {
789 let pt = Arc::new(crate::process::ProcessTable::new(64));
790 let tool = AgentStopTool::new(pt);
791 let result = tool.execute(serde_json::json!({"pid": 9999}));
792 assert!(matches!(result, Err(ToolError::NotFound(_))));
793 }
794
795 #[test]
796 fn agent_list_shows_agents() {
797 let pt = Arc::new(crate::process::ProcessTable::new(64));
798 let spawn = AgentSpawnTool::new(pt.clone());
799 spawn
800 .execute(serde_json::json!({"agent_id": "list-a"}))
801 .unwrap();
802 spawn
803 .execute(serde_json::json!({"agent_id": "list-b"}))
804 .unwrap();
805
806 let tool = AgentListTool::new(pt);
807 let result = tool.execute(serde_json::json!({})).unwrap();
808 assert!(result["count"].as_u64().unwrap() >= 2);
809 }
810
811 #[test]
812 fn agent_inspect_returns_details() {
813 let pt = Arc::new(crate::process::ProcessTable::new(64));
814 let spawn = AgentSpawnTool::new(pt.clone());
815 let r = spawn
816 .execute(serde_json::json!({"agent_id": "inspect-me"}))
817 .unwrap();
818 let pid = r["pid"].as_u64().unwrap();
819
820 let tool = AgentInspectTool::new(pt);
821 let result = tool.execute(serde_json::json!({"pid": pid})).unwrap();
822 assert_eq!(result["agent_id"], "inspect-me");
823 assert!(result["capabilities"].is_object());
824 }
825
826 #[test]
827 fn agent_inspect_not_found() {
828 let pt = Arc::new(crate::process::ProcessTable::new(64));
829 let tool = AgentInspectTool::new(pt);
830 let result = tool.execute(serde_json::json!({"pid": 9999}));
831 assert!(matches!(result, Err(ToolError::NotFound(_))));
832 }
833
834 #[test]
835 fn agent_suspend_resume_cycle() {
836 let pt = Arc::new(crate::process::ProcessTable::new(64));
837 let spawn = AgentSpawnTool::new(pt.clone());
838 let r = spawn
839 .execute(serde_json::json!({"agent_id": "sr-test"}))
840 .unwrap();
841 let pid = r["pid"].as_u64().unwrap();
842
843 let suspend = AgentSuspendTool::new(pt.clone());
844 suspend.execute(serde_json::json!({"pid": pid})).unwrap();
845 assert_eq!(
846 pt.get(pid).unwrap().state,
847 crate::process::ProcessState::Suspended
848 );
849
850 let resume = AgentResumeTool::new(pt.clone());
851 resume.execute(serde_json::json!({"pid": pid})).unwrap();
852 assert_eq!(
853 pt.get(pid).unwrap().state,
854 crate::process::ProcessState::Running
855 );
856 }
857
858 #[test]
861 fn sys_env_get_returns_value() {
862 unsafe {
863 std::env::set_var("CLAWFT_TEST_VAR", "test_value");
864 }
865 let tool = SysEnvGetTool::new();
866 let result = tool
867 .execute(serde_json::json!({"name": "CLAWFT_TEST_VAR"}))
868 .unwrap();
869 assert_eq!(result["value"], "test_value");
870 unsafe {
871 std::env::remove_var("CLAWFT_TEST_VAR");
872 }
873 }
874
875 #[test]
876 fn sys_env_get_missing_returns_null() {
877 let tool = SysEnvGetTool::new();
878 let result = tool
879 .execute(serde_json::json!({"name": "NO_SUCH_VAR_EVER_XYZ"}))
880 .unwrap();
881 assert!(result["value"].is_null());
882 }
883
884 #[test]
885 fn sys_cron_add_list_remove() {
886 let cron = Arc::new(crate::cron::CronService::new());
887 let add = SysCronAddTool::new(cron.clone());
888 let result = add
889 .execute(serde_json::json!({
890 "name": "test-job",
891 "interval_secs": 60,
892 "command": "ping",
893 }))
894 .unwrap();
895 let job_id = result["id"].as_str().unwrap().to_string();
896
897 let list = SysCronListTool::new(cron.clone());
898 let jobs = list.execute(serde_json::json!({})).unwrap();
899 assert!(jobs.as_array().map(|a| !a.is_empty()).unwrap_or(false));
900
901 let rm = SysCronRemoveTool::new(cron);
902 let result = rm.execute(serde_json::json!({"id": job_id}));
903 assert!(result.is_ok());
904 }
905
906 #[test]
907 fn sys_cron_remove_not_found() {
908 let cron = Arc::new(crate::cron::CronService::new());
909 let tool = SysCronRemoveTool::new(cron);
910 let result = tool.execute(serde_json::json!({"id": "no-such-job"}));
911 assert!(matches!(result, Err(ToolError::NotFound(_))));
912 }
913
914 #[test]
915 fn simple_glob_match_works() {
916 assert!(tools_fs::simple_glob_match("*.rs", "main.rs"));
917 assert!(tools_fs::simple_glob_match("*.rs", "lib.rs"));
918 assert!(!tools_fs::simple_glob_match("*.rs", "main.txt"));
919 assert!(tools_fs::simple_glob_match("test?", "test1"));
920 assert!(!tools_fs::simple_glob_match("test?", "test12"));
921 assert!(tools_fs::simple_glob_match("*", "anything"));
922 }
923
924 #[test]
927 fn cache_roundtrip() {
928 let dir = std::env::temp_dir().join("clawft-cache-rt-test");
929 let _ = std::fs::remove_dir_all(&dir);
930 let cache = CompiledModuleCache::new(dir.clone(), 1024 * 1024);
931 let hash = [0xAAu8; 32];
932 let data = b"compiled wasm bytes";
933 cache.put(&hash, data);
934 let got = cache.get(&hash).unwrap();
935 assert_eq!(got, data);
936 let _ = std::fs::remove_dir_all(&dir);
937 }
938
939 #[test]
940 fn cache_miss_returns_none() {
941 let dir = std::env::temp_dir().join("clawft-cache-miss-test");
942 let _ = std::fs::remove_dir_all(&dir);
943 let cache = CompiledModuleCache::new(dir.clone(), 1024 * 1024);
944 assert!(cache.get(&[0xBBu8; 32]).is_none());
945 let _ = std::fs::remove_dir_all(&dir);
946 }
947
948 #[test]
949 fn cache_eviction() {
950 let dir = std::env::temp_dir().join("clawft-cache-evict-test");
951 let _ = std::fs::remove_dir_all(&dir);
952 let cache = CompiledModuleCache::new(dir.clone(), 100);
953 for i in 0..10u8 {
954 let mut hash = [0u8; 32];
955 hash[0] = i;
956 cache.put(&hash, &[i; 20]);
957 }
958 let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
959 assert!(
960 entries.len() < 10,
961 "eviction should have removed some entries"
962 );
963 let _ = std::fs::remove_dir_all(&dir);
964 }
965
966 #[test]
969 fn wasi_scope_default_is_none() {
970 let scope = WasiFsScope::default();
971 assert_eq!(scope, WasiFsScope::None);
972 }
973
974 #[test]
975 fn wasi_scope_serde_roundtrip() {
976 let scope = WasiFsScope::ReadOnly(PathBuf::from("/data"));
977 let json = serde_json::to_string(&scope).unwrap();
978 let restored: WasiFsScope = serde_json::from_str(&json).unwrap();
979 assert_eq!(restored, scope);
980 }
981
982 #[test]
985 #[cfg(feature = "exochain")]
986 fn verify_kernel_signature() {
987 let key = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
988 let hash = [0xAAu8; 32];
989 use ed25519_dalek::Signer;
990 let sig = key.sign(&hash);
991 let pubkey = key.verifying_key().to_bytes();
992 assert!(verify_tool_signature(&hash, &sig.to_bytes(), &pubkey));
993 }
994
995 #[test]
996 #[cfg(feature = "exochain")]
997 fn verify_tampered_cert_fails() {
998 let key = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
999 let hash = [0xAAu8; 32];
1000 use ed25519_dalek::Signer;
1001 let sig = key.sign(&hash);
1002 let mut bad_sig = sig.to_bytes();
1003 bad_sig[0] ^= 0xFF;
1004 let pubkey = key.verifying_key().to_bytes();
1005 assert!(!verify_tool_signature(&hash, &bad_sig, &pubkey));
1006 }
1007
1008 #[test]
1009 fn signing_authority_serde() {
1010 let auth = ToolSigningAuthority::Kernel;
1011 let json = serde_json::to_string(&auth).unwrap();
1012 let _: ToolSigningAuthority = serde_json::from_str(&json).unwrap();
1013 }
1014
1015 #[test]
1016 #[cfg(feature = "exochain")]
1017 fn signing_verify_roundtrip() {
1018 use ed25519_dalek::{Signer, SigningKey};
1019 let mut rng = rand::rngs::OsRng;
1020 let sk = SigningKey::generate(&mut rng);
1021 let pk_bytes: [u8; 32] = sk.verifying_key().to_bytes();
1022 let hash: [u8; 32] = [42u8; 32];
1023 let sig = sk.sign(&hash);
1024 let sig_bytes: [u8; 64] = sig.to_bytes();
1025 assert!(verify_tool_signature(&hash, &sig_bytes, &pk_bytes));
1026 }
1027
1028 #[test]
1029 #[cfg(feature = "exochain")]
1030 fn signing_tampered_fails() {
1031 use ed25519_dalek::{Signer, SigningKey};
1032 let mut rng = rand::rngs::OsRng;
1033 let sk = SigningKey::generate(&mut rng);
1034 let pk_bytes: [u8; 32] = sk.verifying_key().to_bytes();
1035 let hash: [u8; 32] = [42u8; 32];
1036 let sig = sk.sign(&hash);
1037 let mut sig_bytes: [u8; 64] = sig.to_bytes();
1038 sig_bytes[0] ^= 0xff;
1039 assert!(!verify_tool_signature(&hash, &sig_bytes, &pk_bytes));
1040 }
1041
1042 #[test]
1045 fn backend_low_risk_native() {
1046 assert_eq!(BackendSelection::from_risk(0.1), BackendSelection::Native);
1047 assert_eq!(BackendSelection::from_risk(0.3), BackendSelection::Native);
1048 }
1049
1050 #[test]
1051 fn backend_high_risk_wasm() {
1052 assert_eq!(BackendSelection::from_risk(0.5), BackendSelection::Wasm);
1053 assert_eq!(BackendSelection::from_risk(0.7), BackendSelection::Wasm);
1054 }
1055
1056 #[test]
1059 fn module_hash_deterministic() {
1060 let data = b"test module bytes";
1061 let h1 = compute_module_hash(data);
1062 let h2 = compute_module_hash(data);
1063 assert_eq!(h1, h2);
1064 }
1065
1066 #[test]
1067 fn module_hash_differs_for_different_input() {
1068 let h1 = compute_module_hash(b"module A");
1069 let h2 = compute_module_hash(b"module B");
1070 assert_ne!(h1, h2);
1071 }
1072
1073 #[test]
1076 fn tool_version_serde_roundtrip() {
1077 use chrono::Utc;
1078 let tv = ToolVersion {
1079 version: 1,
1080 module_hash: [0xAA; 32],
1081 signature: [0xBB; 64],
1082 deployed_at: Utc::now(),
1083 revoked: false,
1084 chain_seq: 42,
1085 };
1086 let json = serde_json::to_string(&tv).unwrap();
1087 let restored: ToolVersion = serde_json::from_str(&json).unwrap();
1088 assert_eq!(restored.version, 1);
1089 assert_eq!(restored.chain_seq, 42);
1090 assert!(!restored.revoked);
1091 }
1092
1093 #[test]
1094 fn builtin_tool_spec_serde_roundtrip() {
1095 use crate::governance::EffectVector;
1096 let spec = BuiltinToolSpec {
1097 name: "test.tool".into(),
1098 category: ToolCategory::User,
1099 description: "A test tool".into(),
1100 parameters: serde_json::json!({"type": "object", "properties": {}}),
1101 gate_action: "tool.test".into(),
1102 effect: EffectVector::default(),
1103 native: true,
1104 };
1105 let json = serde_json::to_string(&spec).unwrap();
1106 let restored: BuiltinToolSpec = serde_json::from_str(&json).unwrap();
1107 assert_eq!(restored.name, "test.tool");
1108 assert_eq!(restored.category, ToolCategory::User);
1109 }
1110
1111 #[cfg(feature = "wasm-sandbox")]
1114 const NOOP_WAT: &str = r#"(module
1115 (memory (export "memory") 1)
1116 (func (export "_start"))
1117 )"#;
1118
1119 #[cfg(feature = "wasm-sandbox")]
1120 #[tokio::test]
1121 async fn execute_bytes_noop_module() {
1122 let runner = WasmToolRunner::new(WasmSandboxConfig::default());
1123 let result = runner
1124 .execute_bytes("noop", NOOP_WAT.as_bytes(), serde_json::json!({}))
1125 .await
1126 .unwrap();
1127 assert_eq!(result.exit_code, 0);
1128 assert!(result.fuel_consumed > 0);
1129 }
1130
1131 #[cfg(feature = "wasm-sandbox")]
1132 #[tokio::test]
1133 async fn execute_bytes_captures_fuel() {
1134 let config = WasmSandboxConfig {
1135 max_fuel: 10_000_000,
1136 ..Default::default()
1137 };
1138 let runner = WasmToolRunner::new(config);
1139 let result = runner
1140 .execute_bytes("fuel-test", NOOP_WAT.as_bytes(), serde_json::json!({}))
1141 .await
1142 .unwrap();
1143 assert_eq!(result.exit_code, 0);
1144 assert!(result.fuel_consumed > 0);
1145 assert!(result.fuel_consumed < 10_000_000);
1146 }
1147
1148 #[cfg(feature = "wasm-sandbox")]
1149 #[tokio::test]
1150 async fn execute_bytes_invalid_module() {
1151 let runner = WasmToolRunner::new(WasmSandboxConfig::default());
1152 let result = runner
1153 .execute_bytes("bad", b"not wasm at all", serde_json::json!({}))
1154 .await;
1155 assert!(matches!(result, Err(WasmError::CompilationFailed(_))));
1156 }
1157
1158 #[cfg(feature = "wasm-sandbox")]
1159 #[tokio::test]
1160 async fn execute_bytes_fuel_exhaustion() {
1161 let config = WasmSandboxConfig {
1162 max_fuel: 1,
1163 ..Default::default()
1164 };
1165 let runner = WasmToolRunner::new(config);
1166 let loop_wat = r#"(module
1167 (memory (export "memory") 1)
1168 (func (export "_start")
1169 (local $i i32)
1170 (block $break
1171 (loop $loop
1172 (br_if $break (i32.ge_u (local.get $i) (i32.const 1000)))
1173 (local.set $i (i32.add (local.get $i) (i32.const 1)))
1174 (br $loop)
1175 )
1176 )
1177 )
1178 )"#;
1179 let result = runner
1180 .execute_bytes("loop", loop_wat.as_bytes(), serde_json::json!({}))
1181 .await;
1182 assert!(
1183 matches!(result, Err(WasmError::FuelExhausted { .. })),
1184 "expected FuelExhausted, got: {result:?}",
1185 );
1186 }
1187
1188 #[cfg(feature = "wasm-sandbox")]
1189 #[tokio::test]
1190 async fn execute_bytes_no_export_returns_error() {
1191 let no_export_wat = r#"(module
1192 (memory (export "memory") 1)
1193 (func $helper (nop))
1194 )"#;
1195 let runner = WasmToolRunner::new(WasmSandboxConfig::default());
1196 let result = runner
1197 .execute_bytes("noexport", no_export_wat.as_bytes(), serde_json::json!({}))
1198 .await
1199 .unwrap();
1200 assert_eq!(result.exit_code, 1);
1201 assert!(result.stderr.contains("no _start or execute export"));
1202 }
1203
1204 #[cfg(feature = "wasm-sandbox")]
1207 #[test]
1208 fn register_wasm_tool_and_dispatch() {
1209 let runner = Arc::new(WasmToolRunner::new(WasmSandboxConfig::default()));
1210 let mut registry = ToolRegistry::new();
1211 registry
1212 .register_wasm_tool(
1213 "wasm.noop",
1214 "A noop WASM tool",
1215 NOOP_WAT.as_bytes().to_vec(),
1216 runner,
1217 )
1218 .expect("registration should succeed");
1219 assert!(registry.get("wasm.noop").is_some());
1220 let spec = registry.get("wasm.noop").unwrap().spec();
1221 assert!(!spec.native);
1222 assert_eq!(spec.gate_action, "tool.wasm.wasm.noop");
1223 }
1224
1225 #[cfg(feature = "wasm-sandbox")]
1226 #[test]
1227 fn register_wasm_tool_invalid_bytes_rejected() {
1228 let runner = Arc::new(WasmToolRunner::new(WasmSandboxConfig::default()));
1229 let mut registry = ToolRegistry::new();
1230 let result =
1231 registry.register_wasm_tool("wasm.bad", "Invalid", b"not wasm".to_vec(), runner);
1232 assert!(result.is_err());
1233 }
1234
1235 #[cfg(feature = "wasm-sandbox")]
1236 #[test]
1237 fn wasm_adapter_execute_runs_module() {
1238 let runner = Arc::new(WasmToolRunner::new(WasmSandboxConfig::default()));
1239 let mut registry = ToolRegistry::new();
1240 registry
1241 .register_wasm_tool("wasm.noop", "noop", NOOP_WAT.as_bytes().to_vec(), runner)
1242 .unwrap();
1243 let result = registry.execute("wasm.noop", serde_json::json!({}));
1244 assert!(result.is_ok(), "execute should succeed: {:?}", result.err());
1245 let val = result.unwrap();
1246 assert_eq!(val["exit_code"], 0);
1247 assert!(val["fuel_consumed"].as_u64().unwrap() > 0);
1248 }
1249
1250 #[cfg(feature = "wasm-sandbox")]
1251 #[test]
1252 fn wasm_adapter_listed_in_registry() {
1253 let runner = Arc::new(WasmToolRunner::new(WasmSandboxConfig::default()));
1254 let mut registry = ToolRegistry::new();
1255 registry.register(Arc::new(FsExistsTool::new()));
1256 registry
1257 .register_wasm_tool("wasm.noop", "noop", NOOP_WAT.as_bytes().to_vec(), runner)
1258 .unwrap();
1259 let list = registry.list();
1260 assert!(list.contains(&"fs.exists".to_string()));
1261 assert!(list.contains(&"wasm.noop".to_string()));
1262 assert_eq!(list.len(), 2);
1263 }
1264
1265 #[cfg(feature = "wasm-sandbox")]
1268 #[test]
1269 fn k3_wasm_tool_loads_and_executes() {
1270 let runner = WasmToolRunner::new(WasmSandboxConfig::default());
1271 let result = runner
1272 .execute_sync("noop", NOOP_WAT.as_bytes(), serde_json::json!({}))
1273 .expect("execute_sync should succeed for noop WAT module");
1274 assert_eq!(result.exit_code, 0);
1275 assert!(result.fuel_consumed > 0, "fuel_consumed should be non-zero");
1276 }
1277
1278 #[cfg(feature = "wasm-sandbox")]
1279 #[test]
1280 fn k3_fuel_exhaustion_terminates_cleanly() {
1281 let config = WasmSandboxConfig {
1282 max_fuel: 1,
1283 ..Default::default()
1284 };
1285 let runner = WasmToolRunner::new(config);
1286
1287 let loop_wat = r#"(module
1288 (func (export "_start")
1289 (local $i i32)
1290 (block $break
1291 (loop $loop
1292 (br_if $break (i32.ge_u (local.get $i) (i32.const 1000)))
1293 (local.set $i (i32.add (local.get $i) (i32.const 1)))
1294 (br $loop)
1295 )
1296 )
1297 )
1298 )"#;
1299
1300 let result = runner.execute_sync("loop", loop_wat.as_bytes(), serde_json::json!({}));
1301 assert!(
1302 matches!(result, Err(WasmError::FuelExhausted { .. })),
1303 "expected FuelExhausted, got: {result:?}",
1304 );
1305 }
1306
1307 #[cfg(feature = "wasm-sandbox")]
1308 #[test]
1309 fn k3_memory_limit_prevents_allocation_bomb() {
1310 let config = WasmSandboxConfig {
1311 max_memory_bytes: 64 * 1024,
1312 max_fuel: 1_000_000,
1313 ..Default::default()
1314 };
1315 let runner = WasmToolRunner::new(config);
1316
1317 let big_mem_wat = r#"(module
1318 (memory 32)
1319 (func (export "_start") (nop))
1320 )"#;
1321
1322 let result =
1323 runner.execute_sync("alloc-bomb", big_mem_wat.as_bytes(), serde_json::json!({}));
1324 assert!(
1325 result.is_err(),
1326 "module requesting 2 MiB with 64 KiB cap should fail, got: {result:?}",
1327 );
1328 }
1329
1330 #[test]
1331 fn k3_host_filesystem_not_accessible_from_sandbox() {
1332 let config = WasmSandboxConfig::default();
1333 assert!(!config.wasi_enabled, "WASI should be disabled by default");
1334 assert!(
1335 config.allowed_host_calls.is_empty(),
1336 "no host calls should be allowed by default",
1337 );
1338
1339 let scope = WasiFsScope::default();
1340 assert_eq!(scope, WasiFsScope::None, "default fs scope should be None");
1341
1342 #[cfg(feature = "wasm-sandbox")]
1343 {
1344 let runner = WasmToolRunner::new(config);
1345
1346 let wasi_import_wat = r#"(module
1347 (import "wasi_snapshot_preview1" "fd_write"
1348 (func $fd_write (param i32 i32 i32 i32) (result i32)))
1349 (func (export "_start") (nop))
1350 )"#;
1351
1352 let result = runner.execute_sync(
1353 "fs-probe",
1354 wasi_import_wat.as_bytes(),
1355 serde_json::json!({}),
1356 );
1357 assert!(
1358 result.is_err(),
1359 "module importing WASI fd_write should fail in sandboxed execute_sync: {result:?}",
1360 );
1361 }
1362 }
1363
1364 #[test]
1367 fn shell_pipeline_creates_hash() {
1368 let pipeline = ShellPipeline::new(
1369 "deploy",
1370 "cargo build --release && scp target/release/weft server:",
1371 );
1372 assert_ne!(pipeline.content_hash, [0u8; 32]);
1373 }
1374
1375 #[test]
1376 fn shell_pipeline_deterministic_hash() {
1377 let p1 = ShellPipeline::new("test", "echo hello");
1378 let p2 = ShellPipeline::new("test", "echo hello");
1379 assert_eq!(p1.content_hash, p2.content_hash);
1380 }
1381
1382 #[test]
1383 fn shell_pipeline_different_commands_different_hash() {
1384 let p1 = ShellPipeline::new("a", "echo hello");
1385 let p2 = ShellPipeline::new("a", "echo world");
1386 assert_ne!(p1.content_hash, p2.content_hash);
1387 }
1388
1389 #[test]
1390 fn shell_pipeline_to_tool_spec() {
1391 let pipeline = ShellPipeline::new("build", "cargo build");
1392 let spec = pipeline.to_tool_spec();
1393 assert_eq!(spec.name, "shell.build");
1394 assert_eq!(spec.category, ToolCategory::User);
1395 assert!(spec.native);
1396 assert_eq!(spec.gate_action, "tool.shell.execute");
1397 }
1398
1399 #[test]
1400 fn shell_pipeline_initial_chain_seq_none() {
1401 let pipeline = ShellPipeline::new("test", "ls -la");
1402 assert!(pipeline.chain_seq.is_none());
1403 }
1404
1405 #[test]
1406 #[cfg(feature = "exochain")]
1407 fn shell_pipeline_anchor_to_chain() {
1408 let chain = crate::chain::ChainManager::new(0, 1000);
1409 let mut pipeline = ShellPipeline::new("deploy", "make deploy");
1410 assert!(pipeline.chain_seq.is_none());
1411
1412 pipeline.anchor_to_chain(&chain);
1413 assert!(pipeline.chain_seq.is_some());
1414
1415 let events = chain.tail(1);
1416 assert_eq!(events[0].kind, "shell.pipeline.register");
1417 }
1418
1419 #[test]
1420 fn shell_pipeline_serde_roundtrip() {
1421 let pipeline = ShellPipeline::new("test-serde", "echo roundtrip");
1422 let json = serde_json::to_string(&pipeline).unwrap();
1423 let restored: ShellPipeline = serde_json::from_str(&json).unwrap();
1424 assert_eq!(restored.name, "test-serde");
1425 assert_eq!(restored.command, "echo roundtrip");
1426 assert_eq!(restored.content_hash, pipeline.content_hash);
1427 }
1428
1429 #[test]
1432 fn d9_register_unsigned_when_not_required_succeeds() {
1433 let mut registry = ToolRegistry::new();
1434 let tool: Arc<dyn BuiltinTool> = Arc::new(FsReadFileTool::new());
1435 registry.register(tool);
1436 assert!(registry.get("fs.read_file").is_some());
1437 }
1438
1439 #[test]
1440 fn d9_register_unsigned_when_required_fails() {
1441 let mut registry = ToolRegistry::new();
1442 registry.set_require_signatures(true);
1443 assert!(registry.requires_signatures());
1444
1445 let tool: Arc<dyn BuiltinTool> = Arc::new(FsReadFileTool::new());
1446 let result = registry.try_register(tool);
1447 assert!(
1448 matches!(result, Err(ToolError::SignatureRequired(_))),
1449 "expected SignatureRequired, got: {result:?}",
1450 );
1451 assert!(registry.get("fs.read_file").is_none());
1452 }
1453
1454 #[test]
1455 #[cfg(feature = "exochain")]
1456 fn d9_register_with_valid_signature_succeeds() {
1457 use ed25519_dalek::{Signer, SigningKey};
1458
1459 let sk = SigningKey::from_bytes(&[7u8; 32]);
1460 let pk = sk.verifying_key().to_bytes();
1461
1462 let tool: Arc<dyn BuiltinTool> = Arc::new(FsReadFileTool::new());
1463 let tool_hash = compute_module_hash(b"fs.read_file-definition");
1464 let sig = sk.sign(&tool_hash);
1465
1466 let tool_sig = ToolSignature::new(
1467 "fs.read_file",
1468 tool_hash,
1469 "test-signer",
1470 sig.to_bytes().to_vec(),
1471 );
1472
1473 let mut registry = ToolRegistry::new();
1474 registry.set_require_signatures(true);
1475 registry.add_trusted_key(pk);
1476
1477 let result = registry.register_signed(tool, tool_sig);
1478 assert!(result.is_ok(), "register_signed should succeed: {result:?}");
1479 assert!(registry.get("fs.read_file").is_some());
1480 assert!(registry.get_signature("fs.read_file").is_some());
1481 }
1482
1483 #[test]
1484 #[cfg(feature = "exochain")]
1485 fn d9_verify_tool_signature_roundtrip() {
1486 use ed25519_dalek::{Signer, SigningKey};
1487
1488 let sk = SigningKey::from_bytes(&[99u8; 32]);
1489 let pk = sk.verifying_key().to_bytes();
1490
1491 let tool_hash = compute_module_hash(b"my-tool-definition-bytes");
1492 let sig = sk.sign(&tool_hash);
1493
1494 let tool_sig =
1495 ToolSignature::new("my.tool", tool_hash, "dev-alice", sig.to_bytes().to_vec());
1496
1497 assert!(tool_sig.verify(&pk));
1498
1499 let wrong_sk = SigningKey::from_bytes(&[100u8; 32]);
1500 let wrong_pk = wrong_sk.verifying_key().to_bytes();
1501 assert!(!tool_sig.verify(&wrong_pk));
1502 }
1503
1504 #[test]
1505 fn d9_tool_signature_serde_roundtrip() {
1506 let sig = ToolSignature::new("test.tool", [0xAB; 32], "signer-1", vec![0xCD; 64]);
1507 let json = serde_json::to_string(&sig).unwrap();
1508 let restored: ToolSignature = serde_json::from_str(&json).unwrap();
1509 assert_eq!(restored.tool_name, "test.tool");
1510 assert_eq!(restored.tool_hash, [0xAB; 32]);
1511 assert_eq!(restored.signer_id, "signer-1");
1512 assert_eq!(restored.signature.len(), 64);
1513 }
1514
1515 #[test]
1516 fn d9_register_signed_with_invalid_signature_fails() {
1517 let mut registry = ToolRegistry::new();
1518 registry.set_require_signatures(true);
1519 registry.add_trusted_key([42u8; 32]);
1520
1521 let tool: Arc<dyn BuiltinTool> = Arc::new(FsReadFileTool::new());
1522 let bad_sig = ToolSignature::new("fs.read_file", [0u8; 32], "bad-signer", vec![0u8; 64]);
1523
1524 let result = registry.register_signed(tool, bad_sig);
1525 assert!(
1526 matches!(result, Err(ToolError::InvalidSignature(_))),
1527 "expected InvalidSignature, got: {result:?}",
1528 );
1529 }
1530
1531 #[test]
1534 fn d10_shell_command_serde_roundtrip() {
1535 let cmd = ShellCommand {
1536 command: "echo".into(),
1537 args: vec!["hello".into(), "world".into()],
1538 sandbox_config: Some(SandboxConfig::default()),
1539 };
1540 let json = serde_json::to_string(&cmd).unwrap();
1541 let restored: ShellCommand = serde_json::from_str(&json).unwrap();
1542 assert_eq!(restored.command, "echo");
1543 assert_eq!(restored.args, vec!["hello", "world"]);
1544 assert!(restored.sandbox_config.is_some());
1545 }
1546
1547 #[test]
1548 fn d10_execute_shell_echo() {
1549 let cmd = ShellCommand {
1550 command: "echo".into(),
1551 args: vec!["hello".into(), "world".into()],
1552 sandbox_config: None,
1553 };
1554 let result = execute_shell(&cmd).unwrap();
1555 assert_eq!(result.exit_code, 0);
1556 assert_eq!(result.stdout, "hello world");
1557 assert!(result.stderr.is_empty());
1558 }
1559
1560 #[test]
1561 fn d10_execute_shell_includes_execution_time() {
1562 let cmd = ShellCommand {
1563 command: "true".into(),
1564 args: vec![],
1565 sandbox_config: None,
1566 };
1567 let result = execute_shell(&cmd).unwrap();
1568 assert_eq!(result.exit_code, 0);
1569 assert!(result.execution_time_ms < 1000, "should complete in < 1s");
1570 }
1571
1572 #[test]
1573 fn d10_execute_shell_unknown_command() {
1574 let cmd = ShellCommand {
1575 command: "nonexistent".into(),
1576 args: vec![],
1577 sandbox_config: None,
1578 };
1579 let result = execute_shell(&cmd).unwrap();
1580 assert_eq!(result.exit_code, 127);
1581 assert!(result.stderr.contains("command not found"));
1582 }
1583
1584 #[test]
1585 fn d10_shell_exec_tool_dispatch() {
1586 let tool = ShellExecTool::new();
1587 assert_eq!(tool.name(), "shell.exec");
1588
1589 let result = tool
1590 .execute(serde_json::json!({
1591 "command": "echo",
1592 "args": ["test"]
1593 }))
1594 .unwrap();
1595
1596 assert_eq!(result["exit_code"], 0);
1597 assert_eq!(result["stdout"], "test");
1598 }
1599
1600 #[test]
1601 fn d10_shell_result_serde_roundtrip() {
1602 let result = ShellResult {
1603 exit_code: 0,
1604 stdout: "output".into(),
1605 stderr: "".into(),
1606 execution_time_ms: 42,
1607 };
1608 let json = serde_json::to_string(&result).unwrap();
1609 let restored: ShellResult = serde_json::from_str(&json).unwrap();
1610 assert_eq!(restored.exit_code, 0);
1611 assert_eq!(restored.stdout, "output");
1612 assert_eq!(restored.execution_time_ms, 42);
1613 }
1614
1615 #[test]
1616 fn d10_execute_shell_false_returns_nonzero() {
1617 let cmd = ShellCommand {
1618 command: "false".into(),
1619 args: vec![],
1620 sandbox_config: None,
1621 };
1622 let result = execute_shell(&cmd).unwrap();
1623 assert_eq!(result.exit_code, 1);
1624 }
1625}