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