1pub mod adapters;
4mod agent_output;
5pub mod delegation;
6pub mod web;
7
8use std::{
9 collections::HashMap,
10 ffi::OsString,
11 io::{Read as _, Write as _},
12 os::unix::process::CommandExt as _,
13 path::{Component, Path, PathBuf},
14 sync::{
15 Arc,
16 atomic::{AtomicU64, Ordering},
17 },
18 time::Duration,
19};
20
21use async_trait::async_trait;
22use cap_std::{
23 ambient_authority,
24 fs::{Dir, OpenOptions},
25};
26use scv_core::{Tool, ToolContext, ToolError, ToolOutput, ToolRegistry, ToolRisk, ToolSpec};
27
28use crate::{
29 adapters::OutputFormat,
30 agent_output::{AgentStream, RunExit, STDERR_TAIL_BYTES, TailBuffer},
31 delegation::{DelegationGuard, DelegationRegistry},
32};
33use serde::Deserialize;
34use serde_json::{Value, json};
35use sha2::{Digest, Sha256};
36use tokio::{
37 io::AsyncReadExt,
38 process::Command,
39 sync::Mutex,
40 task::JoinHandle,
41 time::{Instant, sleep, sleep_until, timeout, timeout_at},
42};
43
44#[derive(Debug, Clone)]
45pub struct ToolsConfig {
46 pub command_timeout: Duration,
48 pub agent_timeout: Duration,
50 pub max_timeout: Duration,
52 pub output_limit_bytes: usize,
53 pub max_read_bytes: usize,
54 pub max_write_bytes: usize,
55 pub max_delegation_depth: u32,
57 pub delegation: Option<DelegationContext>,
59}
60
61impl Default for ToolsConfig {
62 fn default() -> Self {
63 Self {
64 command_timeout: Duration::from_secs(600),
65 agent_timeout: Duration::from_secs(3600),
66 max_timeout: Duration::from_secs(14400),
67 output_limit_bytes: 64 * 1024,
68 max_read_bytes: 256 * 1024,
69 max_write_bytes: 1024 * 1024,
70 max_delegation_depth: 2,
71 delegation: None,
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
78pub struct DelegationContext {
79 pub registry: Arc<DelegationRegistry>,
80 pub session: String,
81}
82
83#[derive(Debug, Clone)]
84pub struct AgentAdapterConfig {
85 pub command: String,
86 pub args: Vec<String>,
87 pub prompt_args: Vec<String>,
90 pub full_permission_args: Option<Vec<String>>,
93 pub model_args: Vec<String>,
96 pub effort_args: Vec<String>,
99 pub model_hint: String,
101 pub environment: Vec<(OsString, OsString)>,
103 pub search_dirs: Vec<PathBuf>,
105 pub output: OutputFormat,
107 pub home: Option<PathBuf>,
109}
110
111pub type SkillMap = HashMap<String, PathBuf>;
112
113pub fn builtin_registry(
114 config: ToolsConfig,
115 skills: SkillMap,
116 skill_roots: Vec<PathBuf>,
117 max_skill_bytes: usize,
118 adapters: HashMap<String, AgentAdapterConfig>,
119) -> Result<ToolRegistry, ToolError> {
120 let mut registry = ToolRegistry::default();
121 registry.register(Arc::new(ReadTool {
122 max_bytes: config.max_read_bytes,
123 }))?;
124 registry.register(Arc::new(ReadSkillTool {
125 skills,
126 roots: skill_roots,
127 max_bytes: max_skill_bytes,
128 }))?;
129 registry.register(Arc::new(WriteTool {
130 max_bytes: config.max_write_bytes,
131 }))?;
132 registry.register(Arc::new(BashTool {
133 timeout: config.command_timeout,
134 max_timeout: config.max_timeout,
135 output_limit: config.output_limit_bytes,
136 }))?;
137 let depth = config
139 .delegation
140 .as_ref()
141 .map_or_else(delegation::current_depth, |context| {
142 context.registry.depth()
143 });
144 let adapters = if depth < config.max_delegation_depth {
145 adapters
146 } else {
147 HashMap::new()
148 };
149 for (name, adapter) in adapters {
150 let tool = NativeAgentTool::new(
151 name,
152 adapter,
153 Timeouts {
154 default: config.agent_timeout,
155 max: config.max_timeout,
156 },
157 config.output_limit_bytes,
158 config.delegation.clone(),
159 );
160 if tool.resolved.is_some() {
162 registry.register(Arc::new(tool))?;
163 }
164 }
165 Ok(registry)
166}
167
168struct ReadTool {
169 max_bytes: usize,
170}
171
172#[derive(Deserialize)]
173#[serde(deny_unknown_fields)]
174struct ReadArgs {
175 path: String,
176 #[serde(default)]
177 offset: usize,
178 limit: Option<usize>,
179}
180
181#[async_trait]
182impl Tool for ReadTool {
183 fn spec(&self) -> ToolSpec {
184 ToolSpec {
185 name: "read".into(),
186 description: "Read a bounded UTF-8 file inside the workspace".into(),
187 parameters: json!({
188 "type":"object",
189 "properties":{
190 "path":{"type":"string"},
191 "offset":{"type":"integer","minimum":0},
192 "limit":{"type":"integer","minimum":1}
193 },
194 "required":["path"],
195 "additionalProperties":false
196 }),
197 }
198 }
199
200 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
201 let args: ReadArgs = parse_args(arguments)?;
202 validate_read_args(&args)?;
203 Ok(if is_secret_like(Path::new(&args.path)) {
204 ToolRisk::Filesystem
205 } else {
206 ToolRisk::ReadOnly
207 })
208 }
209
210 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
211 let args: ReadArgs = parse_args(arguments)?;
212 validate_read_args(&args)?;
213 Ok(format!("Read {}", args.path))
214 }
215
216 async fn execute(
217 &self,
218 arguments: Value,
219 context: ToolContext,
220 ) -> Result<ToolOutput, ToolError> {
221 let args: ReadArgs = parse_args(&arguments)?;
222 validate_read_args(&args)?;
223 let requested = args.limit.unwrap_or(self.max_bytes).min(self.max_bytes);
224 let offset = u64::try_from(args.offset).unwrap_or(u64::MAX);
225 let workspace = context.workspace.clone();
226 let display_path = args.path.clone();
227 let relative = PathBuf::from(&args.path);
228 validate_relative(&relative)?;
229 let read = tokio::task::spawn_blocking(move || {
230 let root = open_workspace(&workspace)?;
231 let mut file = root
232 .open(&relative)
233 .map_err(|error| map_cap_error("read", &display_path, error))?;
234 let total_bytes = file
235 .metadata()
236 .map_err(|error| ToolError(format!("stat {display_path}: {error}")))?
237 .len();
238 let start = offset.min(total_bytes);
239 std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(start))
240 .map_err(|error| ToolError(format!("seek {display_path}: {error}")))?;
241 let mut bytes = Vec::with_capacity(requested.min(8192));
242 std::io::Read::take(&mut file, u64::try_from(requested).unwrap_or(u64::MAX))
243 .read_to_end(&mut bytes)
244 .map_err(|error| ToolError(format!("read {display_path}: {error}")))?;
245 Ok::<_, ToolError>((bytes, total_bytes, start))
246 });
247 let (bytes, total_bytes, start) = tokio::select! {
248 result = read => result.map_err(|error| ToolError(format!("read task failed: {error}")))??,
249 _ = context.cancellation.cancelled() => return Err(ToolError("read cancelled".into())),
250 };
251 let content = std::str::from_utf8(&bytes)
252 .map_err(|_| ToolError(format!("selected range of {} is not UTF-8", args.path)))?;
253 let end = start.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
254 let truncated = start > 0 || end < total_bytes;
255 Ok(ToolOutput {
256 content: json!({
257 "path": args.path,
258 "content": content,
259 "total_bytes": total_bytes,
260 "offset": start,
261 "truncated": truncated
262 })
263 .to_string(),
264 is_error: false,
265 truncated,
266 })
267 }
268}
269
270struct ReadSkillTool {
271 skills: SkillMap,
272 roots: Vec<PathBuf>,
273 max_bytes: usize,
274}
275
276#[derive(Deserialize)]
277#[serde(deny_unknown_fields)]
278struct ReadSkillArgs {
279 name: String,
280}
281
282#[async_trait]
283impl Tool for ReadSkillTool {
284 fn spec(&self) -> ToolSpec {
285 ToolSpec {
286 name: "read_skill".into(),
287 description: "Load a discovered SCV skill by name".into(),
288 parameters: json!({
289 "type":"object",
290 "properties":{"name":{"type":"string"}},
291 "required":["name"],
292 "additionalProperties":false
293 }),
294 }
295 }
296
297 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
298 let _: ReadSkillArgs = parse_args(arguments)?;
299 Ok(ToolRisk::ReadOnly)
300 }
301
302 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
303 let args: ReadSkillArgs = parse_args(arguments)?;
304 Ok(format!("Load skill {}", args.name))
305 }
306
307 async fn execute(
308 &self,
309 arguments: Value,
310 context: ToolContext,
311 ) -> Result<ToolOutput, ToolError> {
312 let args: ReadSkillArgs = parse_args(&arguments)?;
313 let configured = self
314 .skills
315 .get(&args.name)
316 .ok_or_else(|| ToolError(format!("unknown skill: {}", args.name)))?;
317 let path = std::fs::canonicalize(configured)
318 .map_err(|error| ToolError(format!("load skill {}: {error}", args.name)))?;
319 if !self.roots.iter().any(|root| path.starts_with(root)) {
320 return Err(ToolError("skill path escaped its configured root".into()));
321 }
322 let max_bytes = self.max_bytes;
323 let skill_name = args.name.clone();
324 let bytes = tokio::select! {
325 result = tokio::task::spawn_blocking(move || {
326 let mut file = std::fs::File::open(&path)
327 .map_err(|error| ToolError(format!("load skill {skill_name}: {error}")))?;
328 let mut bytes = Vec::with_capacity(max_bytes.min(8192));
329 std::io::Read::take(
330 &mut file,
331 u64::try_from(max_bytes).unwrap_or(u64::MAX).saturating_add(1),
332 )
333 .read_to_end(&mut bytes)
334 .map_err(|error| ToolError(format!("load skill {skill_name}: {error}")))?;
335 Ok::<_, ToolError>(bytes)
336 }) => result.map_err(|error| ToolError(format!("skill read task failed: {error}")))??,
337 _ = context.cancellation.cancelled() => return Err(ToolError("skill read cancelled".into())),
338 };
339 let end = bytes.len().min(self.max_bytes);
340 let content = std::str::from_utf8(&bytes[..end])
341 .map_err(|_| ToolError("skill is not UTF-8".into()))?;
342 Ok(ToolOutput {
343 content: content.to_owned(),
344 is_error: false,
345 truncated: end < bytes.len(),
346 })
347 }
348}
349
350struct WriteTool {
351 max_bytes: usize,
352}
353
354#[derive(Deserialize)]
355#[serde(deny_unknown_fields)]
356struct WriteArgs {
357 path: String,
358 content: String,
359 mode: WriteMode,
360 expected_sha256: Option<String>,
361}
362
363#[derive(Deserialize)]
364#[serde(rename_all = "snake_case")]
365enum WriteMode {
366 Create,
367 Replace,
368}
369
370#[async_trait]
371impl Tool for WriteTool {
372 fn spec(&self) -> ToolSpec {
373 ToolSpec {
374 name: "write".into(),
375 description: "Atomically create or replace a UTF-8 file inside the workspace".into(),
376 parameters: json!({
377 "type":"object",
378 "properties":{
379 "path":{"type":"string"},
380 "content":{"type":"string"},
381 "mode":{"type":"string","enum":["create","replace"]},
382 "expected_sha256":{"type":"string"}
383 },
384 "required":["path","content","mode"],
385 "additionalProperties":false
386 }),
387 }
388 }
389
390 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
391 let _: WriteArgs = parse_args(arguments)?;
392 Ok(ToolRisk::Filesystem)
393 }
394
395 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
396 let args: WriteArgs = parse_args(arguments)?;
397 let mode = match args.mode {
398 WriteMode::Create => "Create",
399 WriteMode::Replace => "Replace",
400 };
401 Ok(format!(
402 "{mode} {} ({} bytes)",
403 args.path,
404 args.content.len()
405 ))
406 }
407
408 async fn execute(
409 &self,
410 arguments: Value,
411 context: ToolContext,
412 ) -> Result<ToolOutput, ToolError> {
413 let args: WriteArgs = parse_args(&arguments)?;
414 if args.content.len() > self.max_bytes {
415 return Err(ToolError(format!(
416 "write exceeds {} byte limit",
417 self.max_bytes
418 )));
419 }
420 let workspace = context.workspace.clone();
421 let cancellation = context.cancellation.clone();
422 tokio::task::spawn_blocking(move || {
423 if cancellation.is_cancelled() {
424 return Err(ToolError("write cancelled".into()));
425 }
426 let path = PathBuf::from(&args.path);
427 validate_relative(&path)?;
428 let root = open_workspace(&workspace)?;
429 let exists = match root.symlink_metadata(&path) {
430 Ok(_) => true,
431 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
432 Err(error) => return Err(map_cap_error("inspect", &args.path, error)),
433 };
434 match args.mode {
435 WriteMode::Create if exists => {
436 return Err(ToolError(format!("{} already exists", args.path)));
437 }
438 WriteMode::Replace if !exists => {
439 return Err(ToolError(format!("{} does not exist", args.path)));
440 }
441 _ => {}
442 }
443 if let Some(expected) = args.expected_sha256 {
444 let mut current_file = root
445 .open(&path)
446 .map_err(|error| map_cap_error("hash", &args.path, error))?;
447 let mut current = Vec::new();
448 current_file
449 .read_to_end(&mut current)
450 .map_err(|error| ToolError(format!("hash {}: {error}", args.path)))?;
451 let actual = format!("{:x}", Sha256::digest(current));
452 if actual != expected.to_ascii_lowercase() {
453 return Err(ToolError(format!(
454 "{} changed: expected sha256 {}, found {}",
455 args.path, expected, actual
456 )));
457 }
458 }
459 let parent = path.parent().unwrap_or_else(|| Path::new("."));
460 root.create_dir_all(parent)
461 .map_err(|error| map_cap_error("create directory for", &args.path, error))?;
462 let temporary_path = unique_temporary_path(parent);
463 let mut options = OpenOptions::new();
464 options.write(true).create_new(true);
465 let mut temporary = root
466 .open_with(&temporary_path, &options)
467 .map_err(|error| map_cap_error("create temporary file for", &args.path, error))?;
468 let write_result = (|| {
469 temporary
470 .write_all(args.content.as_bytes())
471 .and_then(|_| temporary.sync_all())
472 .map_err(|error| ToolError(format!("write {}: {error}", args.path)))?;
473 if cancellation.is_cancelled() {
474 return Err(ToolError("write cancelled".into()));
475 }
476 match args.mode {
477 WriteMode::Create => root
478 .hard_link(&temporary_path, &root, &path)
479 .map_err(|error| map_cap_error("create", &args.path, error)),
480 WriteMode::Replace => root
481 .rename(&temporary_path, &root, &path)
482 .map_err(|error| map_cap_error("replace", &args.path, error)),
483 }
484 })();
485 if matches!(args.mode, WriteMode::Create) || write_result.is_err() {
486 let _ = root.remove_file(&temporary_path);
487 }
488 write_result?;
489 Ok(ToolOutput::success(
490 json!({
491 "path":args.path,
492 "bytes":args.content.len(),
493 "sha256":format!("{:x}", Sha256::digest(args.content.as_bytes()))
494 })
495 .to_string(),
496 ))
497 })
498 .await
499 .map_err(|error| ToolError(format!("write task failed: {error}")))?
500 }
501}
502
503struct BashTool {
504 timeout: Duration,
505 max_timeout: Duration,
506 output_limit: usize,
507}
508
509impl BashTool {
510 fn timeouts(&self) -> Timeouts {
511 Timeouts {
512 default: self.timeout,
513 max: self.max_timeout,
514 }
515 }
516}
517
518#[derive(Debug, Clone, Copy)]
520struct Timeouts {
521 default: Duration,
522 max: Duration,
523}
524
525impl Timeouts {
526 fn resolve(self, requested: Option<u64>) -> Result<Duration, ToolError> {
530 match requested {
531 None => Ok(self.default.min(self.max)),
532 Some(0) => Err(ToolError("timeout_seconds must be positive".into())),
533 Some(seconds) if seconds > self.max.as_secs() => Err(ToolError(format!(
534 "timeout_seconds {seconds} exceeds the configured maximum of {} seconds \
535 (tools.max_timeout_seconds)",
536 self.max.as_secs()
537 ))),
538 Some(seconds) => Ok(Duration::from_secs(seconds)),
539 }
540 }
541}
542
543fn timeout_schema(timeouts: Timeouts) -> Value {
544 json!({
545 "type":"integer",
546 "minimum":1,
547 "maximum":timeouts.max.as_secs(),
548 "description":format!(
549 "Seconds before the process is killed. Defaults to {}; at most {}. \
550 Raise it for long work such as builds, releases, or landing a change.",
551 timeouts.default.min(timeouts.max).as_secs(),
552 timeouts.max.as_secs()
553 )
554 })
555}
556
557#[derive(Deserialize)]
558#[serde(deny_unknown_fields)]
559struct BashArgs {
560 command: String,
561 timeout_seconds: Option<u64>,
562}
563
564#[async_trait]
565impl Tool for BashTool {
566 fn spec(&self) -> ToolSpec {
567 ToolSpec {
568 name: "bash".into(),
569 description: "Run a Bash command in the workspace (not sandboxed)".into(),
570 parameters: json!({
571 "type":"object",
572 "properties":{
573 "command":{"type":"string"},
574 "timeout_seconds":timeout_schema(self.timeouts())
575 },
576 "required":["command"],
577 "additionalProperties":false
578 }),
579 }
580 }
581
582 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
583 let args: BashArgs = parse_args(arguments)?;
584 validate_process_args(&args.command)?;
585 self.timeouts().resolve(args.timeout_seconds)?;
586 Ok(ToolRisk::Process)
587 }
588
589 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
590 let args: BashArgs = parse_args(arguments)?;
591 validate_process_args(&args.command)?;
592 self.timeouts().resolve(args.timeout_seconds)?;
593 Ok(format!(
594 "Run with /bin/bash -lc: {}",
595 bounded(&args.command, 2000)
596 ))
597 }
598
599 async fn execute(
600 &self,
601 arguments: Value,
602 context: ToolContext,
603 ) -> Result<ToolOutput, ToolError> {
604 let args: BashArgs = parse_args(&arguments)?;
605 validate_process_args(&args.command)?;
606 let requested = self.timeouts().resolve(args.timeout_seconds)?;
607 execute_process(
608 ProcessSpec {
609 executable: OsString::from("/bin/bash"),
610 args: vec![OsString::from("-lc"), OsString::from(args.command)],
611 cwd: context.workspace,
612 environment: Vec::new(),
613 sanitize_scv_environment: false,
614 timeout: requested,
615 output_limit: self.output_limit,
616 },
617 context.cancellation,
618 )
619 .await
620 }
621}
622
623struct NativeAgentTool {
624 name: String,
625 command: String,
626 resolved: Option<PathBuf>,
627 args: Vec<String>,
628 prompt_args: Vec<String>,
629 full_permission_args: Option<Vec<String>>,
630 model_args: Vec<String>,
631 effort_args: Vec<String>,
632 model_hint: String,
633 environment: Vec<(OsString, OsString)>,
634 timeouts: Timeouts,
635 output_limit: usize,
636 output: OutputFormat,
637 home: Option<PathBuf>,
638 delegation: Option<DelegationContext>,
639}
640
641const AGENT_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
643
644impl NativeAgentTool {
645 fn command_args(&self, args: &AgentArgs) -> Result<Vec<String>, ToolError> {
648 validate_process_args(&args.prompt)?;
649 self.timeouts.resolve(args.timeout_seconds)?;
650 if let Some(cwd) = &args.cwd {
651 validate_agent_cwd(cwd)?;
652 }
653 if args.prompt.starts_with('-') {
656 return Err(ToolError("agent prompt must not start with '-'".into()));
657 }
658 let mut command = self.args.clone();
659 command.extend(self.full_permission_args.iter().flatten().cloned());
660 command.extend(self.output.args().iter().map(|arg| (*arg).to_owned()));
661 for (field, value, template, placeholder) in [
662 ("model", &args.model, &self.model_args, "{model}"),
663 ("effort", &args.effort, &self.effort_args, "{effort}"),
664 ] {
665 let Some(value) = value else {
666 continue;
667 };
668 if template.is_empty() {
669 return Err(ToolError(format!(
670 "{} does not support selecting a {field}",
671 self.name
672 )));
673 }
674 let valid = if field == "model" {
675 valid_model_name(value)
676 } else {
677 AGENT_EFFORTS.contains(&value.as_str())
678 };
679 if !valid {
680 return Err(ToolError(format!("invalid {field} {value:?}")));
681 }
682 command.extend(template.iter().map(|part| part.replace(placeholder, value)));
683 }
684 command.extend(self.prompt_args.iter().cloned());
685 Ok(command)
686 }
687 fn new(
688 name: String,
689 config: AgentAdapterConfig,
690 timeouts: Timeouts,
691 output_limit: usize,
692 delegation: Option<DelegationContext>,
693 ) -> Self {
694 let resolved = adapters::resolve_agent_executable(&config.command, &config.search_dirs);
695 Self {
696 name,
697 command: config.command,
698 resolved,
699 args: config.args,
700 prompt_args: config.prompt_args,
701 full_permission_args: config.full_permission_args,
702 model_args: config.model_args,
703 effort_args: config.effort_args,
704 model_hint: config.model_hint,
705 environment: config.environment,
706 timeouts,
707 output_limit,
708 output: config.output,
709 home: config.home,
710 delegation,
711 }
712 }
713
714 fn run_args(&self, id: &str) -> (Vec<OsString>, Option<PathBuf>) {
718 match self.output {
719 OutputFormat::ClaudeStreamJson => (
720 vec![
721 "--session-id".into(),
722 uuid::Uuid::new_v4().to_string().into(),
723 ],
724 None,
725 ),
726 OutputFormat::CodexJsonl => {
727 let Some(dir) = self.home.as_ref().map(|home| home.join("tmp")) else {
728 return (Vec::new(), None);
729 };
730 if private_dir(&dir).is_err() {
731 return (Vec::new(), None);
732 }
733 let file = dir.join(format!("scv-{id}.last-message"));
734 (vec!["-o".into(), file.clone().into()], Some(file))
735 }
736 OutputFormat::Text | OutputFormat::PiJson => (Vec::new(), None),
737 }
738 }
739}
740
741fn private_dir(dir: &Path) -> std::io::Result<()> {
742 use std::os::unix::fs::PermissionsExt as _;
743 std::fs::create_dir_all(dir)?;
744 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
745}
746
747fn take_file(path: &Path, limit: usize) -> Option<String> {
749 let file = std::fs::File::open(path).ok();
750 let _ = std::fs::remove_file(path);
751 let mut bytes = Vec::new();
752 std::io::Read::take(file?, u64::try_from(limit).unwrap_or(u64::MAX))
753 .read_to_end(&mut bytes)
754 .ok()?;
755 let text = String::from_utf8_lossy(&bytes).trim().to_owned();
756 (!text.is_empty()).then_some(text)
757}
758
759#[derive(Deserialize)]
760#[serde(deny_unknown_fields)]
761struct AgentArgs {
762 prompt: String,
763 timeout_seconds: Option<u64>,
764 #[serde(default, deserialize_with = "blank_as_none")]
765 cwd: Option<String>,
766 #[serde(default, deserialize_with = "blank_as_none")]
767 model: Option<String>,
768 #[serde(default, deserialize_with = "blank_as_none")]
769 effort: Option<String>,
770}
771
772fn blank_as_none<'de, D: serde::Deserializer<'de>>(
775 deserializer: D,
776) -> Result<Option<String>, D::Error> {
777 let value = Option::<String>::deserialize(deserializer)?;
778 Ok(value.filter(|value| !value.trim().is_empty()))
779}
780
781const MAX_AGENT_CWD_BYTES: usize = 4096;
783
784fn validate_agent_cwd(cwd: &str) -> Result<(), ToolError> {
785 if cwd.trim().is_empty() || cwd.len() > MAX_AGENT_CWD_BYTES || cwd.contains('\0') {
786 return Err(ToolError(format!(
787 "cwd must be a non-empty directory path of at most {MAX_AGENT_CWD_BYTES} bytes"
788 )));
789 }
790 Ok(())
791}
792
793fn resolve_agent_cwd(workspace: &Path, cwd: Option<&str>) -> Result<PathBuf, ToolError> {
797 let root = std::fs::canonicalize(workspace)
798 .map_err(|error| ToolError(format!("resolve workspace: {error}")))?;
799 let Some(cwd) = cwd else {
800 return Ok(root);
801 };
802 validate_agent_cwd(cwd)?;
803 let resolved = std::fs::canonicalize(root.join(cwd))
804 .map_err(|error| ToolError(format!("cwd {cwd:?}: {error}")))?;
805 if !resolved.starts_with(&root) {
806 return Err(ToolError(format!("cwd {cwd:?} is outside the workspace")));
807 }
808 if !resolved.is_dir() {
809 return Err(ToolError(format!("cwd {cwd:?} is not a directory")));
810 }
811 Ok(resolved)
812}
813
814fn valid_model_name(value: &str) -> bool {
817 !value.is_empty()
818 && value.len() <= 128
819 && !value.starts_with(['-', '@'])
820 && value
821 .chars()
822 .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
823}
824
825#[async_trait]
826impl Tool for NativeAgentTool {
827 fn spec(&self) -> ToolSpec {
828 let mut properties = json!({
829 "prompt":{"type":"string"},
830 "cwd":{
831 "type":"string",
832 "description":"Directory inside the workspace to run in, such as a project directory (\"scv\"). \
833 The agent loads that directory's AGENTS.md or CLAUDE.md and its project skills. \
834 Defaults to the workspace root."
835 },
836 "timeout_seconds":timeout_schema(self.timeouts)
837 });
838 if !self.model_args.is_empty() {
839 properties["model"] = json!({
840 "type":"string",
841 "description":format!(
842 "{} Set only when the user asks for a specific model; \
843 omit to use the agent's configured default.",
844 self.model_hint
845 )
846 });
847 }
848 if !self.effort_args.is_empty() {
849 properties["effort"] = json!({
850 "type":"string",
851 "enum":AGENT_EFFORTS,
852 "description":"Reasoning effort. Set only when the user asks for one; \
853 omit to use the agent's configured default."
854 });
855 }
856 ToolSpec {
857 name: self.name.clone(),
858 description: format!(
859 "Launch the configured {} CLI as a nested coding agent (not sandboxed). \
860 Delegate substantial work here rather than doing it step by step with \
861 bash: research and web lookups, multi-file coding, and running tools, \
862 builds, and tests. Set cwd to the project the work is in so the agent \
863 follows that project's instructions and skills.",
864 self.name
865 ),
866 parameters: json!({
867 "type":"object",
868 "properties":properties,
869 "required":["prompt"],
870 "additionalProperties":false
871 }),
872 }
873 }
874
875 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
876 let args: AgentArgs = parse_args(arguments)?;
877 self.command_args(&args)?;
878 Ok(ToolRisk::Delegate)
879 }
880
881 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
882 let args: AgentArgs = parse_args(arguments)?;
883 let command_args = self.command_args(&args)?;
884 let executable = self.resolved.as_ref().map_or_else(
885 || self.command.as_str().into(),
886 |path| path.display().to_string(),
887 );
888 let directory = args.cwd.as_deref().map_or_else(
889 || "the workspace root".to_owned(),
890 |cwd| format!("{:?} (inside the workspace)", bounded(cwd, 200)),
891 );
892 let timeout = self.timeouts.resolve(args.timeout_seconds)?;
893 let permissions = if self.full_permission_args.is_some() {
894 " FULL PERMISSIONS (permissions = \"full\"): the agent's own approval prompts \
895 and sandbox are off, so it edits files, runs commands, and uses the network \
896 without asking."
897 } else {
898 ""
899 };
900 Ok(format!(
901 "Launch {executable} with args {command_args:?} and prompt {:?} in {directory} for up to {} seconds. The nested agent has your user permissions.{permissions}",
902 bounded(&args.prompt, 2000),
903 timeout.as_secs()
904 ))
905 }
906
907 async fn execute(
908 &self,
909 arguments: Value,
910 context: ToolContext,
911 ) -> Result<ToolOutput, ToolError> {
912 let args: AgentArgs = parse_args(&arguments)?;
913 let command_args = self.command_args(&args)?;
914 let cwd = resolve_agent_cwd(&context.workspace, args.cwd.as_deref())?;
915 let executable = self.resolved.as_ref().ok_or_else(|| {
916 ToolError(format!(
917 "{} executable {:?} was not found on PATH or in the user's install directories",
918 self.name, self.command
919 ))
920 })?;
921 let agent = self.name.trim_start_matches("agent_");
922 let pending = self
923 .delegation
924 .as_ref()
925 .map(|delegation| delegation.registry.begin(agent, &delegation.session, &cwd));
926 let run_id = pending.as_ref().map_or_else(
927 || uuid::Uuid::new_v4().simple().to_string(),
928 |pending| pending.handle.clone(),
929 );
930 let (fixed, selections) = command_args.split_at(
931 self.args.len()
932 + self.full_permission_args.as_ref().map_or(0, Vec::len)
933 + self.output.args().len(),
934 );
935 let (run_args, last_message) = self.run_args(&run_id);
936 let mut process_args: Vec<OsString> = fixed.iter().map(OsString::from).collect();
937 process_args.extend(run_args);
938 process_args.extend(selections.iter().map(OsString::from));
939 process_args.push(OsString::from(args.prompt));
940 let mut environment = self.environment.clone();
941 match &pending {
942 Some(pending) => environment.extend(pending.environment.iter().cloned()),
943 None => environment.push((
944 delegation::DEPTH_VARIABLE.into(),
945 (delegation::current_depth() + 1).to_string().into(),
946 )),
947 }
948 let requested = self.timeouts.resolve(args.timeout_seconds)?;
949 let registration = self
950 .delegation
951 .as_ref()
952 .map(|delegation| Arc::clone(&delegation.registry))
953 .zip(pending);
954 let run = execute_agent_process(
955 ProcessSpec {
956 executable: executable.as_os_str().to_owned(),
957 args: process_args,
958 cwd,
959 environment,
960 sanitize_scv_environment: true,
961 timeout: requested,
962 output_limit: self.output_limit,
963 },
964 AgentStream::new(self.output, self.output_limit),
965 registration,
966 context.cancellation,
967 )
968 .await;
969 let fallback = last_message
970 .as_deref()
971 .and_then(|path| take_file(path, self.output_limit));
972 let run = run?;
973 let result = run.stream.finish(run.exit, fallback);
974 let (content, truncated) =
975 result.to_json(agent, run.exit_code, &run.stderr_tail, self.output_limit);
976 let mut output = ToolOutput {
977 content,
978 is_error: result.status != agent_output::RunStatus::Completed,
979 truncated,
980 };
981 if output.is_error {
982 add_sign_in_hint(&mut output, agent);
983 }
984 Ok(output)
985 }
986}
987
988fn add_sign_in_hint(output: &mut ToolOutput, agent: &str) {
992 let lower = output.content.to_ascii_lowercase();
993 let unauthenticated = [
994 "not logged in",
995 "not signed in",
996 "not authenticated",
997 "login",
998 "log in",
999 "unauthorized",
1000 "authentication",
1001 "missing_credential",
1002 ]
1003 .iter()
1004 .any(|needle| lower.contains(needle));
1005 if !unauthenticated {
1006 return;
1007 }
1008 if let Ok(Value::Object(mut content)) = serde_json::from_str::<Value>(&output.content) {
1009 content.insert(
1010 "hint".into(),
1011 format!(
1012 "The {agent} CLI appears to be signed out of SCV's private agent home. \
1013 The host owner can sign it in with: scv agents login {agent}"
1014 )
1015 .into(),
1016 );
1017 output.content = Value::Object(content).to_string();
1018 }
1019}
1020
1021pub fn apply_agent_environment(
1025 command: &mut std::process::Command,
1026 environment: &[(OsString, OsString)],
1027) {
1028 apply_agent_environment_from(
1029 command,
1030 std::env::vars_os().map(|(variable, _)| variable),
1031 environment,
1032 );
1033}
1034
1035fn apply_agent_environment_from(
1036 command: &mut std::process::Command,
1037 inherited: impl IntoIterator<Item = OsString>,
1038 environment: &[(OsString, OsString)],
1039) {
1040 for variable in inherited {
1041 if adapters::is_removed_agent_variable(&variable) {
1042 command.env_remove(variable);
1043 }
1044 }
1045 command.envs(environment.iter().map(|(key, value)| (key, value)));
1046}
1047
1048struct ProcessSpec {
1049 executable: OsString,
1050 args: Vec<OsString>,
1051 cwd: PathBuf,
1052 environment: Vec<(OsString, OsString)>,
1053 sanitize_scv_environment: bool,
1054 timeout: Duration,
1055 output_limit: usize,
1056}
1057
1058async fn execute_process(
1059 spec: ProcessSpec,
1060 cancellation: tokio_util::sync::CancellationToken,
1061) -> Result<ToolOutput, ToolError> {
1062 let deadline = Instant::now() + spec.timeout;
1063 let mut child = spawn_process(&spec)?;
1064 let pid = child_pid(&child)?;
1065 let output = Arc::new(Mutex::new(BoundedOutput::new(spec.output_limit)));
1066 let stdout_task = child
1067 .stdout
1068 .take()
1069 .map(|stdout| tokio::spawn(drain_output(stdout, Arc::clone(&output))));
1070 let stderr_task = child
1071 .stderr
1072 .take()
1073 .map(|stderr| tokio::spawn(drain_output(stderr, Arc::clone(&output))));
1074 let finished = supervise(
1075 &mut child,
1076 pid,
1077 deadline,
1078 cancellation,
1079 stdout_task,
1080 stderr_task,
1081 )
1082 .await;
1083 delegation::untrack_spawned(pid as u32);
1084 let finished = finished?;
1085 let collected = output.lock().await;
1086 let text = String::from_utf8_lossy(&collected.bytes).into_owned();
1087 let content = json!({
1088 "exit_code": finished.status.code(),
1089 "timed_out": finished.timed_out,
1090 "output": text,
1091 "truncated": collected.truncated
1092 })
1093 .to_string();
1094 Ok(ToolOutput {
1095 content,
1096 is_error: finished.timed_out || !finished.status.success(),
1097 truncated: collected.truncated,
1098 })
1099}
1100
1101struct AgentRun {
1103 stream: AgentStream,
1104 exit: RunExit,
1105 exit_code: Option<i32>,
1106 stderr_tail: String,
1107}
1108
1109async fn execute_agent_process(
1113 spec: ProcessSpec,
1114 stream: AgentStream,
1115 registration: Option<(Arc<DelegationRegistry>, delegation::PendingDelegation)>,
1116 cancellation: tokio_util::sync::CancellationToken,
1117) -> Result<AgentRun, ToolError> {
1118 let deadline = Instant::now() + spec.timeout;
1119 let mut child = spawn_process(&spec)?;
1120 let pid = child_pid(&child)?;
1121 let guard: Option<DelegationGuard> =
1124 registration.and_then(|(registry, pending)| registry.register(pending, pid as u32).ok());
1125 let stdout = Arc::new(Mutex::new(stream));
1126 let stderr = Arc::new(Mutex::new(TailBuffer::new(STDERR_TAIL_BYTES)));
1127 let stdout_task = child
1128 .stdout
1129 .take()
1130 .map(|reader| tokio::spawn(drain_output(reader, Arc::clone(&stdout))));
1131 let stderr_task = child
1132 .stderr
1133 .take()
1134 .map(|reader| tokio::spawn(drain_output(reader, Arc::clone(&stderr))));
1135 let finished = supervise(
1136 &mut child,
1137 pid,
1138 deadline,
1139 cancellation,
1140 stdout_task,
1141 stderr_task,
1142 )
1143 .await;
1144 delegation::untrack_spawned(pid as u32);
1145 let killed = guard.as_ref().is_some_and(DelegationGuard::was_killed);
1146 if let Some(guard) = guard {
1147 guard.finish().await;
1148 }
1149 let finished = finished?;
1150 let exit = if finished.timed_out {
1151 RunExit::TimedOut
1152 } else if killed {
1153 RunExit::Killed
1154 } else {
1155 RunExit::Exited {
1156 success: finished.status.success(),
1157 }
1158 };
1159 let stderr_tail = stderr.lock().await.text();
1160 let stream = Arc::try_unwrap(stdout)
1161 .map_err(|_| ToolError("agent output reader is still running".into()))?
1162 .into_inner();
1163 Ok(AgentRun {
1164 stream,
1165 exit,
1166 exit_code: finished.status.code(),
1167 stderr_tail,
1168 })
1169}
1170
1171fn spawn_process(spec: &ProcessSpec) -> Result<tokio::process::Child, ToolError> {
1172 let mut command = Command::new(&spec.executable);
1173 if spec.sanitize_scv_environment {
1174 apply_agent_environment(command.as_std_mut(), &spec.environment);
1175 } else {
1176 command.envs(spec.environment.iter().map(|(key, value)| (key, value)));
1177 }
1178 command
1179 .args(&spec.args)
1180 .current_dir(&spec.cwd)
1181 .stdin(std::process::Stdio::null())
1182 .stdout(std::process::Stdio::piped())
1183 .stderr(std::process::Stdio::piped())
1184 .kill_on_drop(true);
1185 command.as_std_mut().process_group(0);
1186 let child = command
1187 .spawn()
1188 .map_err(|error| ToolError(format!("launch {:?}: {error}", spec.executable)))?;
1189 if let Some(pid) = child.id() {
1190 delegation::track_spawned(pid);
1191 }
1192 Ok(child)
1193}
1194
1195fn child_pid(child: &tokio::process::Child) -> Result<i32, ToolError> {
1196 child
1197 .id()
1198 .and_then(|pid| i32::try_from(pid).ok())
1199 .ok_or_else(|| ToolError("child process has no pid".into()))
1200}
1201
1202struct Finished {
1203 status: std::process::ExitStatus,
1204 timed_out: bool,
1205}
1206
1207async fn supervise(
1210 child: &mut tokio::process::Child,
1211 pid: i32,
1212 deadline: Instant,
1213 cancellation: tokio_util::sync::CancellationToken,
1214 stdout_task: Option<JoinHandle<()>>,
1215 stderr_task: Option<JoinHandle<()>>,
1216) -> Result<Finished, ToolError> {
1217 enum Completion {
1218 Exited(std::process::ExitStatus),
1219 TimedOut,
1220 Cancelled,
1221 }
1222 let completion = tokio::select! {
1223 status = child.wait() => Completion::Exited(status.map_err(|error| ToolError(format!("wait for child: {error}")))?),
1224 _ = cancellation.cancelled() => {
1225 Completion::Cancelled
1226 },
1227 _ = sleep_until(deadline) => Completion::TimedOut,
1228 };
1229
1230 let (status, timed_out, drain_deadline) = match completion {
1231 Completion::Exited(status) => {
1232 let cleanup_deadline = deadline.min(Instant::now() + Duration::from_secs(2));
1233 let status = terminate_group(pid, child, Some(status), cleanup_deadline, true).await?;
1234 (
1235 status,
1236 false,
1237 deadline.min(Instant::now() + Duration::from_millis(250)),
1238 )
1239 }
1240 Completion::TimedOut => {
1241 let status = terminate_group(pid, child, None, Instant::now(), false).await?;
1242 (status, true, Instant::now() + Duration::from_millis(250))
1243 }
1244 Completion::Cancelled => {
1245 let cleanup_deadline = Instant::now() + Duration::from_secs(2);
1246 let _ = terminate_group(pid, child, None, cleanup_deadline, true).await;
1247 finish_drain(stdout_task, Instant::now() + Duration::from_millis(250)).await;
1248 finish_drain(stderr_task, Instant::now() + Duration::from_millis(250)).await;
1249 return Err(ToolError("process cancelled".into()));
1250 }
1251 };
1252 finish_drain(stdout_task, drain_deadline).await;
1253 finish_drain(stderr_task, drain_deadline).await;
1254 Ok(Finished { status, timed_out })
1255}
1256
1257async fn terminate_group(
1258 pid: i32,
1259 child: &mut tokio::process::Child,
1260 mut status: Option<std::process::ExitStatus>,
1261 deadline: Instant,
1262 graceful: bool,
1263) -> Result<std::process::ExitStatus, ToolError> {
1264 signal_group(
1265 pid,
1266 if graceful {
1267 libc::SIGTERM
1268 } else {
1269 libc::SIGKILL
1270 },
1271 );
1272 while Instant::now() < deadline {
1273 if status.is_none() {
1274 status = child
1275 .try_wait()
1276 .map_err(|error| ToolError(format!("wait for child: {error}")))?;
1277 }
1278 if !process_group_exists(pid)
1279 && let Some(status) = status
1280 {
1281 return Ok(status);
1282 }
1283 sleep(Duration::from_millis(20)).await;
1284 }
1285 signal_group(pid, libc::SIGKILL);
1287 if let Some(status) = status {
1288 return Ok(status);
1289 }
1290 timeout(Duration::from_secs(1), child.wait())
1291 .await
1292 .map_err(|_| ToolError("child did not exit after process-group kill".into()))?
1293 .map_err(|error| ToolError(format!("wait after KILL: {error}")))
1294}
1295
1296fn signal_group(pid: i32, signal: i32) {
1297 unsafe {
1299 libc::kill(-pid, signal);
1300 }
1301}
1302
1303fn process_group_exists(pid: i32) -> bool {
1304 let result = unsafe { libc::kill(-pid, 0) };
1305 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1306}
1307
1308async fn finish_drain(task: Option<JoinHandle<()>>, deadline: Instant) {
1309 let Some(mut task) = task else { return };
1310 if timeout_at(deadline, &mut task).await.is_err() {
1311 task.abort();
1312 let _ = task.await;
1313 }
1314}
1315
1316trait OutputSink: Send + 'static {
1318 fn push(&mut self, bytes: &[u8]);
1319}
1320
1321impl OutputSink for BoundedOutput {
1322 fn push(&mut self, bytes: &[u8]) {
1323 BoundedOutput::push(self, bytes);
1324 }
1325}
1326
1327impl OutputSink for AgentStream {
1328 fn push(&mut self, bytes: &[u8]) {
1329 AgentStream::push(self, bytes);
1330 }
1331}
1332
1333impl OutputSink for TailBuffer {
1334 fn push(&mut self, bytes: &[u8]) {
1335 TailBuffer::push(self, bytes);
1336 }
1337}
1338
1339async fn drain_output<R, S>(mut reader: R, output: Arc<Mutex<S>>)
1340where
1341 R: tokio::io::AsyncRead + Unpin,
1342 S: OutputSink,
1343{
1344 let mut chunk = [0u8; 8192];
1345 loop {
1346 match reader.read(&mut chunk).await {
1347 Ok(0) | Err(_) => break,
1348 Ok(read) => output.lock().await.push(&chunk[..read]),
1349 }
1350 }
1351}
1352
1353struct BoundedOutput {
1354 bytes: Vec<u8>,
1355 limit: usize,
1356 truncated: bool,
1357}
1358
1359impl BoundedOutput {
1360 fn new(limit: usize) -> Self {
1361 Self {
1362 bytes: Vec::with_capacity(limit.min(8192)),
1363 limit,
1364 truncated: false,
1365 }
1366 }
1367
1368 fn push(&mut self, bytes: &[u8]) {
1369 let remaining = self.limit.saturating_sub(self.bytes.len());
1370 self.bytes
1371 .extend_from_slice(&bytes[..bytes.len().min(remaining)]);
1372 self.truncated |= bytes.len() > remaining;
1373 }
1374}
1375
1376fn parse_args<T: for<'de> Deserialize<'de>>(value: &Value) -> Result<T, ToolError> {
1377 serde_json::from_value(value.clone())
1378 .map_err(|error| ToolError(format!("invalid arguments: {error}")))
1379}
1380
1381fn validate_read_args(args: &ReadArgs) -> Result<(), ToolError> {
1382 if args.limit == Some(0) {
1383 return Err(ToolError("read limit must be positive".into()));
1384 }
1385 Ok(())
1386}
1387
1388fn validate_process_args(value: &str) -> Result<(), ToolError> {
1389 if value.trim().is_empty() {
1390 return Err(ToolError("command or prompt must be non-empty".into()));
1391 }
1392 Ok(())
1393}
1394
1395fn validate_relative(path: &Path) -> Result<(), ToolError> {
1396 if path.as_os_str().is_empty() || path.is_absolute() {
1397 return Err(ToolError("path must be non-empty and relative".into()));
1398 }
1399 for component in path.components() {
1400 if matches!(
1401 component,
1402 Component::ParentDir | Component::RootDir | Component::Prefix(_)
1403 ) {
1404 return Err(ToolError(
1405 "parent traversal and absolute paths are not allowed".into(),
1406 ));
1407 }
1408 }
1409 Ok(())
1410}
1411
1412static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0);
1413
1414fn open_workspace(workspace: &Path) -> Result<Dir, ToolError> {
1415 Dir::open_ambient_dir(workspace, ambient_authority())
1416 .map_err(|error| ToolError(format!("open workspace capability: {error}")))
1417}
1418
1419fn unique_temporary_path(parent: &Path) -> PathBuf {
1420 let id = TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed);
1421 parent.join(format!(".scv-write-{}-{id}.tmp", std::process::id()))
1422}
1423
1424fn map_cap_error(action: &str, path: &str, error: std::io::Error) -> ToolError {
1425 ToolError(format!(
1426 "{action} {path}: {error}; path must remain within workspace"
1427 ))
1428}
1429
1430fn is_secret_like(path: &Path) -> bool {
1431 path.components().any(|component| {
1432 let value = component.as_os_str().to_string_lossy().to_ascii_lowercase();
1433 value == ".env"
1434 || value.starts_with(".env.")
1435 || value.contains("credential")
1436 || value.contains("private_key")
1437 || value.ends_with(".pem")
1438 || value.ends_with(".key")
1439 })
1440}
1441
1442fn bounded(value: &str, max_chars: usize) -> String {
1443 let mut output: String = value.chars().take(max_chars).collect();
1444 if value.chars().count() > max_chars {
1445 output.push('โฆ');
1446 }
1447 output
1448}
1449
1450#[cfg(test)]
1451mod tests {
1452 use std::os::unix::fs::symlink;
1453
1454 use super::*;
1455
1456 const SHELL_STARTUP: Duration = Duration::from_secs(30);
1461
1462 async fn wait_for<T>(limit: Duration, mut probe: impl FnMut() -> Option<T>) -> Option<T> {
1464 let deadline = std::time::Instant::now() + limit;
1465 loop {
1466 if let Some(value) = probe() {
1467 return Some(value);
1468 }
1469 if std::time::Instant::now() >= deadline {
1470 return None;
1471 }
1472 tokio::time::sleep(Duration::from_millis(10)).await;
1473 }
1474 }
1475
1476 fn is_gone(pid: i32) -> Option<()> {
1477 (unsafe { libc::kill(pid, 0) } != 0).then_some(())
1478 }
1479
1480 #[test]
1481 fn rejects_parent_traversal() {
1482 assert!(validate_relative(Path::new("../secret")).is_err());
1483 assert!(validate_relative(Path::new("/etc/passwd")).is_err());
1484 }
1485
1486 #[test]
1487 fn detects_secret_like_paths() {
1488 assert!(is_secret_like(Path::new(".env")));
1489 assert!(is_secret_like(Path::new("keys/id.pem")));
1490 assert!(!is_secret_like(Path::new("src/main.rs")));
1491 }
1492
1493 #[tokio::test]
1494 async fn read_is_contained_and_bounded() {
1495 let directory = tempfile::tempdir().unwrap();
1496 std::fs::write(directory.path().join("hello.txt"), "abcdef").unwrap();
1497 let tool = ReadTool { max_bytes: 3 };
1498 let output = tool
1499 .execute(
1500 json!({"path":"hello.txt"}),
1501 ToolContext {
1502 workspace: directory.path().canonicalize().unwrap(),
1503 cancellation: tokio_util::sync::CancellationToken::new(),
1504 },
1505 )
1506 .await
1507 .unwrap();
1508 assert!(output.truncated);
1509 assert!(output.content.contains("abc"));
1510 }
1511
1512 #[tokio::test]
1513 async fn read_rejects_symlink_escape() {
1514 let workspace = tempfile::tempdir().unwrap();
1515 let outside = tempfile::tempdir().unwrap();
1516 std::fs::write(outside.path().join("secret"), "nope").unwrap();
1517 symlink(outside.path(), workspace.path().join("escape")).unwrap();
1518 let tool = ReadTool { max_bytes: 100 };
1519 let result = tool
1520 .execute(
1521 json!({"path":"escape/secret"}),
1522 ToolContext {
1523 workspace: workspace.path().canonicalize().unwrap(),
1524 cancellation: tokio_util::sync::CancellationToken::new(),
1525 },
1526 )
1527 .await;
1528 assert!(result.unwrap_err().to_string().contains("workspace"));
1529 }
1530
1531 #[tokio::test]
1532 async fn write_is_atomic_and_checks_hash() {
1533 let workspace = tempfile::tempdir().unwrap();
1534 let root = workspace.path().canonicalize().unwrap();
1535 let tool = WriteTool { max_bytes: 100 };
1536 tool.execute(
1537 json!({"path":"file.txt","content":"first","mode":"create"}),
1538 ToolContext {
1539 workspace: root.clone(),
1540 cancellation: tokio_util::sync::CancellationToken::new(),
1541 },
1542 )
1543 .await
1544 .unwrap();
1545 let hash = format!("{:x}", Sha256::digest(b"first"));
1546 tool.execute(
1547 json!({"path":"file.txt","content":"second","mode":"replace","expected_sha256":hash}),
1548 ToolContext {
1549 workspace: root.clone(),
1550 cancellation: tokio_util::sync::CancellationToken::new(),
1551 },
1552 )
1553 .await
1554 .unwrap();
1555 assert_eq!(
1556 std::fs::read_to_string(root.join("file.txt")).unwrap(),
1557 "second"
1558 );
1559 let result = tool
1560 .execute(
1561 json!({"path":"file.txt","content":"third","mode":"replace","expected_sha256":"deadbeef"}),
1562 ToolContext {
1563 workspace: root,
1564 cancellation: tokio_util::sync::CancellationToken::new(),
1565 },
1566 )
1567 .await;
1568 assert!(result.unwrap_err().to_string().contains("changed"));
1569 }
1570
1571 #[tokio::test]
1572 async fn write_rejects_symlink_escape() {
1573 let workspace = tempfile::tempdir().unwrap();
1574 let outside = tempfile::tempdir().unwrap();
1575 symlink(outside.path(), workspace.path().join("escape")).unwrap();
1576 let tool = WriteTool { max_bytes: 100 };
1577 let result = tool
1578 .execute(
1579 json!({"path":"escape/file.txt","content":"nope","mode":"create"}),
1580 ToolContext {
1581 workspace: workspace.path().canonicalize().unwrap(),
1582 cancellation: tokio_util::sync::CancellationToken::new(),
1583 },
1584 )
1585 .await;
1586 assert!(result.unwrap_err().to_string().contains("workspace"));
1587 assert!(!outside.path().join("file.txt").exists());
1588 }
1589
1590 #[tokio::test]
1591 async fn bash_timeout_terminates_the_process() {
1592 let workspace = tempfile::tempdir().unwrap();
1593 let tool = BashTool {
1594 timeout: Duration::from_millis(50),
1595 max_timeout: Duration::from_millis(50),
1596 output_limit: 100,
1597 };
1598 let started = std::time::Instant::now();
1599 let output = tool
1600 .execute(
1601 json!({"command":"sleep 5"}),
1602 ToolContext {
1603 workspace: workspace.path().canonicalize().unwrap(),
1604 cancellation: tokio_util::sync::CancellationToken::new(),
1605 },
1606 )
1607 .await
1608 .unwrap();
1609 assert!(output.is_error);
1610 assert!(started.elapsed() < Duration::from_secs(3));
1611 }
1612
1613 #[tokio::test]
1614 async fn bash_output_is_bounded_and_reports_truncation() {
1615 let workspace = tempfile::tempdir().unwrap();
1616 let tool = BashTool {
1617 timeout: SHELL_STARTUP,
1618 max_timeout: SHELL_STARTUP,
1619 output_limit: 8,
1620 };
1621 let output = tool
1622 .execute(
1623 json!({"command":"printf 12345678901234567890"}),
1624 ToolContext {
1625 workspace: workspace.path().canonicalize().unwrap(),
1626 cancellation: tokio_util::sync::CancellationToken::new(),
1627 },
1628 )
1629 .await
1630 .unwrap();
1631 assert!(output.truncated);
1632 assert!(output.content.contains("12345678"));
1633 assert!(!output.content.contains("123456789"));
1634 }
1635
1636 #[tokio::test]
1637 async fn bash_cancellation_terminates_the_process_group() {
1638 let workspace = tempfile::tempdir().unwrap();
1639 let tool = BashTool {
1640 timeout: Duration::from_secs(30),
1641 max_timeout: Duration::from_secs(30),
1642 output_limit: 100,
1643 };
1644 let cancellation = tokio_util::sync::CancellationToken::new();
1645 let cancel = cancellation.clone();
1646 let started = std::time::Instant::now();
1647 let execution = tokio::spawn(async move {
1648 tool.execute(
1649 json!({"command":"sleep 30"}),
1650 ToolContext {
1651 workspace: workspace.path().canonicalize().unwrap(),
1652 cancellation,
1653 },
1654 )
1655 .await
1656 });
1657 tokio::time::sleep(Duration::from_millis(50)).await;
1658 cancel.cancel();
1659 let error = execution.await.unwrap().unwrap_err();
1660 assert!(error.to_string().contains("cancelled"));
1661 assert!(started.elapsed() < Duration::from_secs(3));
1662 }
1663
1664 #[tokio::test]
1665 async fn background_descendant_cannot_hold_output_pipes_open() {
1666 let workspace = tempfile::tempdir().unwrap();
1667 let root = workspace.path().canonicalize().unwrap();
1668 let tool = BashTool {
1669 timeout: SHELL_STARTUP,
1670 max_timeout: SHELL_STARTUP,
1671 output_limit: 100,
1672 };
1673 let output = tool
1674 .execute(
1675 json!({"command":"sleep 60 & echo $! > background.pid; exit 0"}),
1676 ToolContext {
1677 workspace: root.clone(),
1678 cancellation: tokio_util::sync::CancellationToken::new(),
1679 },
1680 )
1681 .await
1682 .unwrap();
1683 let returned = std::time::SystemTime::now();
1684 assert!(!output.is_error);
1685 let exited = std::fs::metadata(root.join("background.pid"))
1687 .unwrap()
1688 .modified()
1689 .unwrap();
1690 assert!(returned.duration_since(exited).unwrap_or_default() < Duration::from_secs(3));
1691 let pid: i32 = std::fs::read_to_string(root.join("background.pid"))
1692 .unwrap()
1693 .trim()
1694 .parse()
1695 .unwrap();
1696 assert!(
1697 wait_for(Duration::from_secs(5), || is_gone(pid))
1698 .await
1699 .is_some(),
1700 "background descendant {pid} survived tool completion"
1701 );
1702 }
1703
1704 #[tokio::test]
1705 async fn cancellation_kills_a_term_ignoring_descendant() {
1706 let workspace = tempfile::tempdir().unwrap();
1707 let root = workspace.path().canonicalize().unwrap();
1708 let tool = BashTool {
1709 timeout: Duration::from_secs(30),
1710 max_timeout: Duration::from_secs(30),
1711 output_limit: 100,
1712 };
1713 let cancellation = tokio_util::sync::CancellationToken::new();
1714 let cancel = cancellation.clone();
1715 let command_root = root.clone();
1716 let execution = tokio::spawn(async move {
1717 tool.execute(
1718 json!({"command":"trap '' TERM; (trap '' TERM; sleep 30) & echo $! > stubborn.pid; wait"}),
1719 ToolContext {
1720 workspace: command_root,
1721 cancellation,
1722 },
1723 )
1724 .await
1725 });
1726 let pid_path = root.join("stubborn.pid");
1727 let pid = wait_for(SHELL_STARTUP, || {
1728 std::fs::read_to_string(&pid_path)
1729 .ok()
1730 .and_then(|value| value.trim().parse::<i32>().ok())
1731 })
1732 .await
1733 .expect("command did not report its descendant pid");
1734 let started = std::time::Instant::now();
1735 cancel.cancel();
1736 let error = execution.await.unwrap().unwrap_err();
1737 assert!(error.to_string().contains("cancelled"));
1738 assert!(started.elapsed() < Duration::from_secs(3));
1739 assert!(
1740 wait_for(Duration::from_secs(5), || is_gone(pid))
1741 .await
1742 .is_some(),
1743 "TERM-ignoring descendant {pid} survived cancellation"
1744 );
1745 }
1746
1747 fn fake_agent(
1751 workspace: &Path,
1752 name: &str,
1753 script: &str,
1754 args: &[&str],
1755 environment: Vec<(OsString, OsString)>,
1756 ) -> NativeAgentTool {
1757 fake_agent_with_prompt_args(workspace, name, script, args, &[], environment)
1758 }
1759
1760 fn fake_agent_with_prompt_args(
1761 workspace: &Path,
1762 name: &str,
1763 script: &str,
1764 args: &[&str],
1765 prompt_args: &[&str],
1766 environment: Vec<(OsString, OsString)>,
1767 ) -> NativeAgentTool {
1768 let script_path = workspace.join("fake-agent.sh");
1769 std::fs::write(&script_path, script).unwrap();
1770 let mut fixed = vec![script_path.display().to_string()];
1771 fixed.extend(args.iter().map(|arg| arg.to_string()));
1772 NativeAgentTool::new(
1773 name.into(),
1774 AgentAdapterConfig {
1775 command: "bash".into(),
1776 args: fixed,
1777 prompt_args: prompt_args.iter().map(|arg| arg.to_string()).collect(),
1778 full_permission_args: None,
1779 model_args: vec!["--model".into(), "{model}".into()],
1780 effort_args: vec!["--effort".into(), "{effort}".into()],
1781 model_hint: adapters::adapter(name.trim_start_matches("agent_"))
1782 .map_or(
1783 "Model ID in the form this agent's CLI accepts.",
1784 |adapter| adapter.model_hint,
1785 )
1786 .into(),
1787 environment,
1788 search_dirs: Vec::new(),
1789 output: OutputFormat::Text,
1790 home: None,
1791 },
1792 Timeouts {
1793 default: Duration::from_secs(2),
1794 max: Duration::from_secs(5),
1795 },
1796 1024,
1797 None,
1798 )
1799 }
1800
1801 fn context(workspace: &Path) -> ToolContext {
1802 ToolContext {
1803 workspace: workspace.canonicalize().unwrap(),
1804 cancellation: tokio_util::sync::CancellationToken::new(),
1805 }
1806 }
1807
1808 #[tokio::test]
1809 async fn native_agent_preserves_argument_boundaries() {
1810 let workspace = tempfile::tempdir().unwrap();
1811 let tool = fake_agent(
1812 workspace.path(),
1813 "agent_fake",
1814 "pwd\nprintf '%s\\n' \"$@\"\n",
1815 &["--fixed"],
1816 Vec::new(),
1817 );
1818 let output = tool
1819 .execute(
1820 json!({"prompt":"hello; echo unsafe"}),
1821 context(workspace.path()),
1822 )
1823 .await
1824 .unwrap();
1825 assert!(output.content.contains("--fixed"));
1826 assert!(output.content.contains("hello; echo unsafe"));
1827 assert!(
1828 output
1829 .content
1830 .contains(&workspace.path().display().to_string())
1831 );
1832 }
1833
1834 #[tokio::test]
1835 async fn native_agent_maps_model_and_effort_to_adapter_flags() {
1836 let workspace = tempfile::tempdir().unwrap();
1837 let tool = fake_agent(
1838 workspace.path(),
1839 "agent_claude",
1840 "printf '%s\\n' \"$@\"\n",
1841 &["-p"],
1842 Vec::new(),
1843 );
1844 let properties = &tool.spec().parameters["properties"];
1845 assert_eq!(properties["effort"]["enum"], json!(AGENT_EFFORTS));
1846 assert_eq!(properties["model"]["type"], "string");
1847 let arguments = json!({"prompt":"hi","model":"sonnet","effort":"medium"});
1848 assert!(
1849 tool.approval_summary(&arguments)
1850 .unwrap()
1851 .contains(r#""--model", "sonnet", "--effort", "medium""#)
1852 );
1853 let output = tool
1854 .execute(arguments, context(workspace.path()))
1855 .await
1856 .unwrap();
1857 let output: Value = serde_json::from_str(&output.content).unwrap();
1858 assert_eq!(output["reply"], "-p\n--model\nsonnet\n--effort\nmedium\nhi");
1859 for invalid in [
1860 json!({"prompt":"hi","model":"--dangerously-skip-permissions"}),
1861 json!({"prompt":"hi","model":"sonnet medium"}),
1862 json!({"prompt":"hi","effort":"extreme"}),
1863 json!({"prompt":"hi","model":"@/etc/passwd"}),
1864 json!({"prompt":"--resume"}),
1865 ] {
1866 assert!(tool.risk(&invalid).is_err());
1867 }
1868 let fixed_only = NativeAgentTool::new(
1869 "agent_pi".into(),
1870 AgentAdapterConfig {
1871 command: "pi".into(),
1872 args: vec!["-p".into()],
1873 prompt_args: Vec::new(),
1874 full_permission_args: None,
1875 model_args: Vec::new(),
1876 effort_args: Vec::new(),
1877 model_hint: String::new(),
1878 environment: Vec::new(),
1879 search_dirs: Vec::new(),
1880 output: OutputFormat::Text,
1881 home: None,
1882 },
1883 Timeouts {
1884 default: Duration::from_secs(2),
1885 max: Duration::from_secs(2),
1886 },
1887 1024,
1888 None,
1889 );
1890 assert!(
1891 fixed_only.spec().parameters["properties"]
1892 .get("model")
1893 .is_none()
1894 );
1895 let error = fixed_only
1896 .risk(&json!({"prompt":"hi","model":"sonnet"}))
1897 .unwrap_err();
1898 assert!(
1899 error
1900 .to_string()
1901 .contains("does not support selecting a model")
1902 );
1903 }
1904
1905 #[test]
1906 fn native_agent_model_hints_name_the_adapter_family_and_default() {
1907 let workspace = tempfile::tempdir().unwrap();
1908 let description = |name: &str, field: &str| {
1909 fake_agent(workspace.path(), name, "", &[], Vec::new())
1910 .spec()
1911 .parameters["properties"][field]["description"]
1912 .as_str()
1913 .unwrap()
1914 .to_owned()
1915 };
1916 let claude = description("agent_claude", "model");
1917 let codex = description("agent_codex", "model");
1918 let other = description("agent_other", "model");
1919 assert!(claude.contains("sonnet or opus"));
1920 for text in [&codex, &other] {
1921 assert!(!text.contains("sonnet"), "{text}");
1922 }
1923 assert!(codex.contains("not a Claude alias"));
1924 for text in [claude, codex, other, description("agent_codex", "effort")] {
1925 assert!(
1926 text.contains("omit to use the agent's configured default"),
1927 "{text}"
1928 );
1929 }
1930 }
1931
1932 #[tokio::test]
1933 async fn signed_out_agent_failure_names_the_host_login_command() {
1934 let workspace = tempfile::tempdir().unwrap();
1935 let tool = fake_agent(
1936 workspace.path(),
1937 "agent_claude",
1938 "echo 'Not logged in ยท Please run /login'\nexit 1\n",
1939 &[],
1940 Vec::new(),
1941 );
1942 let output = tool
1943 .execute(json!({"prompt":"hi"}), context(workspace.path()))
1944 .await
1945 .unwrap();
1946 assert!(output.is_error);
1947 let content: Value = serde_json::from_str(&output.content).unwrap();
1948 assert!(
1949 content["hint"]
1950 .as_str()
1951 .unwrap()
1952 .ends_with("scv agents login claude")
1953 );
1954 let other = fake_agent(
1955 workspace.path(),
1956 "agent_claude",
1957 "echo 'disk full'\nexit 1\n",
1958 &[],
1959 Vec::new(),
1960 );
1961 let output = other
1962 .execute(json!({"prompt":"hi"}), context(workspace.path()))
1963 .await
1964 .unwrap();
1965 assert!(output.is_error);
1966 assert!(!output.content.contains("hint"));
1967 }
1968
1969 #[tokio::test]
1970 async fn native_agent_uses_instance_private_environment() {
1971 let workspace = tempfile::tempdir().unwrap();
1972 let home = workspace.path().join("private-home");
1973 let tool = fake_agent(
1974 workspace.path(),
1975 "agent_codex",
1976 "printf 'HOME=%s\\nSCV_HOME=%s\\nCODEX_HOME=%s\\nSCV_CONFIG=%s\\nOPENAI_API_KEY=%s\\nCODEX_API_KEY=%s\\n' \"$HOME\" \"$SCV_HOME\" \"$CODEX_HOME\" \"${SCV_CONFIG-unset}\" \"${OPENAI_API_KEY-unset}\" \"${CODEX_API_KEY-unset}\"\n",
1977 &[],
1978 vec![
1979 ("HOME".into(), home.clone().into()),
1980 ("SCV_HOME".into(), home.clone().into()),
1981 ("CODEX_HOME".into(), home.join("codex").into()),
1982 ],
1983 );
1984 let output = tool
1985 .execute(
1986 json!({"prompt":"print environment"}),
1987 context(workspace.path()),
1988 )
1989 .await
1990 .unwrap();
1991 assert!(output.content.contains(&format!("HOME={}", home.display())));
1992 assert!(
1993 output
1994 .content
1995 .contains(&format!("CODEX_HOME={}/codex", home.display()))
1996 );
1997 assert!(output.content.contains("SCV_CONFIG=unset"));
1998 assert!(output.content.contains("OPENAI_API_KEY=unset"));
1999 assert!(output.content.contains("CODEX_API_KEY=unset"));
2000 }
2001
2002 #[tokio::test]
2003 async fn native_agent_places_prompt_flags_just_before_the_prompt() {
2004 let workspace = tempfile::tempdir().unwrap();
2005 let tool = fake_agent_with_prompt_args(
2006 workspace.path(),
2007 "agent_grok",
2008 "printf '%s\\n' \"$@\"\n",
2009 &[],
2010 &["-p"],
2011 Vec::new(),
2012 );
2013 let arguments = json!({"prompt":"hi","model":"grok-4","effort":"high"});
2014 assert!(
2015 tool.approval_summary(&arguments)
2016 .unwrap()
2017 .contains(r#""--model", "grok-4", "--effort", "high", "-p""#)
2018 );
2019 let output = tool
2020 .execute(arguments, context(workspace.path()))
2021 .await
2022 .unwrap();
2023 let output: Value = serde_json::from_str(&output.content).unwrap();
2024 assert_eq!(output["reply"], "--model\ngrok-4\n--effort\nhigh\n-p\nhi");
2025 }
2026
2027 #[tokio::test]
2028 async fn full_permissions_follow_the_fixed_arguments_and_are_announced() {
2029 let workspace = tempfile::tempdir().unwrap();
2030 let mut tool = fake_agent(
2031 workspace.path(),
2032 "agent_claude",
2033 "printf '%s\\n' \"$@\"\n",
2034 &["-p"],
2035 Vec::new(),
2036 );
2037 let arguments = json!({"prompt":"hi","model":"opus"});
2038 assert!(!tool.approval_summary(&arguments).unwrap().contains("FULL"));
2039 tool.full_permission_args =
2040 Some(vec!["--permission-mode".into(), "bypassPermissions".into()]);
2041 let summary = tool.approval_summary(&arguments).unwrap();
2042 assert!(summary.contains("FULL PERMISSIONS"), "{summary}");
2043 assert!(
2044 summary
2045 .contains(r#""-p", "--permission-mode", "bypassPermissions", "--model", "opus""#)
2046 );
2047 let output = tool
2048 .execute(arguments, context(workspace.path()))
2049 .await
2050 .unwrap();
2051 let output: Value = serde_json::from_str(&output.content).unwrap();
2052 assert_eq!(
2053 output["reply"],
2054 "-p\n--permission-mode\nbypassPermissions\n--model\nopus\nhi"
2055 );
2056 }
2057
2058 #[test]
2059 fn agent_environment_drops_inherited_credentials_but_keeps_its_own_home() {
2060 let mut command = std::process::Command::new("true");
2061 apply_agent_environment_from(
2062 &mut command,
2063 [
2064 "GROK_HOME",
2065 "XAI_API_KEY",
2066 "PI_CODING_AGENT_DIR",
2067 "DEEPSEEK_API_KEY",
2068 "ANTHROPIC_API_KEY",
2069 "OPENROUTER_API_KEY",
2070 "PATH",
2071 ]
2072 .map(OsString::from),
2073 &[("GROK_HOME".into(), "/private/.grok".into())],
2074 );
2075 let envs: HashMap<_, _> = command
2076 .get_envs()
2077 .map(|(key, value)| (key.to_owned(), value.map(ToOwned::to_owned)))
2078 .collect();
2079 assert_eq!(
2080 envs[&OsString::from("GROK_HOME")],
2081 Some(OsString::from("/private/.grok"))
2082 );
2083 for removed in [
2084 "XAI_API_KEY",
2085 "PI_CODING_AGENT_DIR",
2086 "DEEPSEEK_API_KEY",
2087 "ANTHROPIC_API_KEY",
2088 "OPENROUTER_API_KEY",
2089 ] {
2090 assert_eq!(envs[&OsString::from(removed)], None, "{removed}");
2091 }
2092 assert!(!envs.contains_key(&OsString::from("PATH")));
2093 }
2094
2095 #[test]
2096 fn uninstalled_agents_are_not_offered() {
2097 let adapter = |command: &str| AgentAdapterConfig {
2098 command: command.into(),
2099 args: Vec::new(),
2100 prompt_args: Vec::new(),
2101 full_permission_args: None,
2102 model_args: Vec::new(),
2103 effort_args: Vec::new(),
2104 model_hint: String::new(),
2105 environment: Vec::new(),
2106 search_dirs: Vec::new(),
2107 output: OutputFormat::Text,
2108 home: None,
2109 };
2110 let registry = builtin_registry(
2111 ToolsConfig::default(),
2112 SkillMap::new(),
2113 Vec::new(),
2114 1024,
2115 HashMap::from([
2116 ("agent_present".to_owned(), adapter("bash")),
2117 (
2118 "agent_missing".to_owned(),
2119 adapter("scv-test-agent-that-is-not-installed"),
2120 ),
2121 ]),
2122 )
2123 .unwrap();
2124 assert!(registry.get("agent_present").is_some());
2125 assert!(registry.get("agent_missing").is_none());
2126 }
2127
2128 #[tokio::test]
2129 async fn native_agent_runs_in_a_contained_directory() {
2130 let workspace = tempfile::tempdir().unwrap();
2131 let outside = tempfile::tempdir().unwrap();
2132 let root = workspace.path().canonicalize().unwrap();
2133 std::fs::create_dir(root.join("project")).unwrap();
2134 std::fs::write(root.join("notes.txt"), "not a directory").unwrap();
2135 symlink(outside.path(), root.join("escape")).unwrap();
2136 symlink(root.join("project"), root.join("inner-link")).unwrap();
2137 let tool = fake_agent(&root, "agent_codex", "pwd\n", &[], Vec::new());
2138 let run = |arguments: Value| tool.execute(arguments, context(&root));
2139
2140 for arguments in [
2141 json!({"prompt":"hi"}),
2142 json!({"prompt":"hi","cwd":""}),
2143 json!({"prompt":"hi","cwd":" ","model":"","effort":" "}),
2144 ] {
2145 let output = run(arguments.clone()).await.unwrap();
2146 let output: Value = serde_json::from_str(&output.content).unwrap();
2147 assert_eq!(output["reply"], root.display().to_string(), "{arguments}");
2148 }
2149 for cwd in [
2150 "project".to_owned(),
2151 "project/".to_owned(),
2152 "inner-link".to_owned(),
2153 root.join("project").display().to_string(),
2154 ] {
2155 let output = run(json!({"prompt":"hi","cwd":cwd})).await.unwrap();
2156 let output: Value = serde_json::from_str(&output.content).unwrap();
2157 assert_eq!(
2158 output["reply"],
2159 root.join("project").display().to_string(),
2160 "{cwd}"
2161 );
2162 }
2163 for (cwd, error) in [
2164 ("..", "outside the workspace"),
2165 ("escape", "outside the workspace"),
2166 ("/", "outside the workspace"),
2167 ("notes.txt", "not a directory"),
2168 ("missing", "No such file"),
2169 ] {
2170 let result = run(json!({"prompt":"hi","cwd":cwd})).await;
2171 assert!(
2172 result.as_ref().unwrap_err().to_string().contains(error),
2173 "{cwd}: {result:?}"
2174 );
2175 }
2176 assert!(tool.risk(&json!({"prompt":"hi","cwd":"a\0b"})).is_err());
2177 assert!(
2178 tool.risk(&json!({"prompt":"hi","cwd":"x".repeat(MAX_AGENT_CWD_BYTES + 1)}))
2179 .is_err()
2180 );
2181 let summary = tool
2182 .approval_summary(&json!({"prompt":"hi","cwd":"project","timeout_seconds":4}))
2183 .unwrap();
2184 assert!(summary.contains(r#"in "project" (inside the workspace) for up to 4 seconds"#));
2185 assert!(
2186 tool.approval_summary(&json!({"prompt":"hi"}))
2187 .unwrap()
2188 .contains("in the workspace root for up to 2 seconds")
2189 );
2190 let description = tool.spec().parameters["properties"]["cwd"]["description"]
2191 .as_str()
2192 .unwrap()
2193 .to_owned();
2194 assert!(description.contains("AGENTS.md"));
2195 }
2196
2197 #[tokio::test]
2198 async fn per_call_timeouts_may_rise_to_the_ceiling_but_not_past_it() {
2199 let timeouts = Timeouts {
2200 default: Duration::from_secs(120),
2201 max: Duration::from_secs(1800),
2202 };
2203 assert_eq!(timeouts.resolve(None).unwrap(), Duration::from_secs(120));
2204 assert_eq!(timeouts.resolve(Some(30)).unwrap(), Duration::from_secs(30));
2205 assert_eq!(
2206 timeouts.resolve(Some(1800)).unwrap(),
2207 Duration::from_secs(1800)
2208 );
2209 assert!(timeouts.resolve(Some(0)).is_err());
2210 assert!(
2211 timeouts
2212 .resolve(Some(1801))
2213 .unwrap_err()
2214 .to_string()
2215 .contains("maximum of 1800 seconds (tools.max_timeout_seconds)")
2216 );
2217
2218 let workspace = tempfile::tempdir().unwrap();
2219 let agent = fake_agent(
2220 workspace.path(),
2221 "agent_codex",
2222 "echo ran\n",
2223 &[],
2224 Vec::new(),
2225 );
2226 let schema = &agent.spec().parameters["properties"]["timeout_seconds"];
2227 assert_eq!(schema["maximum"], 5);
2228 assert!(
2229 schema["description"]
2230 .as_str()
2231 .unwrap()
2232 .contains("Defaults to 2; at most 5")
2233 );
2234 assert!(
2235 agent
2236 .risk(&json!({"prompt":"hi","timeout_seconds":5}))
2237 .is_ok()
2238 );
2239 assert!(
2240 agent
2241 .risk(&json!({"prompt":"hi","timeout_seconds":6}))
2242 .is_err()
2243 );
2244 assert!(
2245 agent
2246 .execute(
2247 json!({"prompt":"hi","timeout_seconds":6}),
2248 context(workspace.path())
2249 )
2250 .await
2251 .is_err()
2252 );
2253
2254 let bash = BashTool {
2255 timeout: Duration::from_secs(1),
2256 max_timeout: Duration::from_secs(3),
2257 output_limit: 100,
2258 };
2259 assert_eq!(
2260 bash.spec().parameters["properties"]["timeout_seconds"]["maximum"],
2261 3
2262 );
2263 assert!(
2264 bash.risk(&json!({"command":"true","timeout_seconds":3}))
2265 .is_ok()
2266 );
2267 assert!(
2268 bash.risk(&json!({"command":"true","timeout_seconds":4}))
2269 .unwrap_err()
2270 .to_string()
2271 .contains("tools.max_timeout_seconds")
2272 );
2273 }
2274
2275 fn structured_agent(
2278 workspace: &Path,
2279 name: &str,
2280 format: OutputFormat,
2281 script: &str,
2282 home: Option<PathBuf>,
2283 delegation: Option<DelegationContext>,
2284 timeout: Duration,
2285 ) -> NativeAgentTool {
2286 let script_path = workspace.join(format!("fake-{name}.sh"));
2287 std::fs::write(&script_path, script).unwrap();
2288 NativeAgentTool::new(
2289 name.into(),
2290 AgentAdapterConfig {
2291 command: "bash".into(),
2292 args: vec![script_path.display().to_string()],
2293 prompt_args: Vec::new(),
2294 full_permission_args: None,
2295 model_args: Vec::new(),
2296 effort_args: Vec::new(),
2297 model_hint: String::new(),
2298 environment: Vec::new(),
2299 search_dirs: Vec::new(),
2300 output: format,
2301 home,
2302 },
2303 Timeouts {
2304 default: timeout,
2305 max: Duration::from_secs(30),
2306 },
2307 64 * 1024,
2308 delegation,
2309 )
2310 }
2311
2312 fn delegation_context(home: &Path) -> DelegationContext {
2313 DelegationContext {
2314 registry: Arc::new(DelegationRegistry::new(home)),
2315 session: "session-1".into(),
2316 }
2317 }
2318
2319 #[tokio::test]
2320 async fn claude_stream_json_becomes_a_structured_result() {
2321 let workspace = tempfile::tempdir().unwrap();
2322 let args_file = workspace.path().join("args.txt");
2323 let script = format!(
2324 r#"printf '%s\n' "$@" > {args}
2325printf '%s\n' "$SCV_PARENT" "$SCV_DELEGATION_DEPTH" >> {args}
2326echo '{{"type":"system","subtype":"init","session_id":"x","unknown":[1,2]}}'
2327echo '{{"type":"assistant","message":{{"content":[{{"type":"text","text":"thinking"}}]}}}}'
2328echo 'stray diagnostic' >&2
2329echo '{{"type":"result","subtype":"success","is_error":false,"result":"all done","usage":{{"input_tokens":12,"output_tokens":3}}}}'
2330"#,
2331 args = args_file.display()
2332 );
2333 let home = tempfile::tempdir().unwrap();
2334 let context_home = delegation_context(home.path());
2335 let tool = structured_agent(
2336 workspace.path(),
2337 "agent_claude",
2338 OutputFormat::ClaudeStreamJson,
2339 &script,
2340 None,
2341 Some(context_home.clone()),
2342 Duration::from_secs(10),
2343 );
2344 let output = tool
2345 .execute(json!({"prompt":"hi"}), context(workspace.path()))
2346 .await
2347 .unwrap();
2348 assert!(!output.is_error, "{}", output.content);
2349 let value: Value = serde_json::from_str(&output.content).unwrap();
2350 assert_eq!(value["agent"], "claude");
2351 assert_eq!(value["status"], "completed");
2352 assert_eq!(value["reply"], "all done");
2353 assert_eq!(value["usage"]["input_tokens"], 12);
2354 assert_eq!(value["exit_code"], 0);
2355 assert_eq!(value["stderr_tail"], "stray diagnostic");
2356 assert_eq!(value["truncated"], false);
2357 assert!(!output.content.contains("thinking"));
2359 let recorded = std::fs::read_to_string(&args_file).unwrap();
2360 let lines: Vec<&str> = recorded.lines().collect();
2361 assert_eq!(
2362 &lines[..4],
2363 [
2364 "--output-format",
2365 "stream-json",
2366 "--verbose",
2367 "--session-id"
2368 ]
2369 );
2370 assert!(uuid::Uuid::parse_str(lines[4]).is_ok());
2371 assert_eq!(lines[5], "hi");
2372 let chain = lines[6];
2373 assert!(chain.contains("/session-1/claude-"), "{chain}");
2374 assert_eq!(lines[7], "1");
2375 assert!(context_home.registry.list(true).is_empty());
2377 }
2378
2379 #[tokio::test]
2380 async fn codex_json_reads_the_last_message_file_and_removes_it() {
2381 let workspace = tempfile::tempdir().unwrap();
2382 let home = tempfile::tempdir().unwrap();
2383 let script = r#"while [ "$#" -gt 0 ]; do
2384 if [ "$1" = "-o" ]; then printf 'final from file\n' > "$2"; echo "$2" > last-path.txt; fi
2385 shift
2386done
2387echo '{"type":"thread.started","thread_id":"t"}'
2388echo '{"type":"turn.completed","usage":{"input_tokens":5,"output_tokens":1}}'
2389"#;
2390 let tool = structured_agent(
2391 workspace.path(),
2392 "agent_codex",
2393 OutputFormat::CodexJsonl,
2394 script,
2395 Some(home.path().to_owned()),
2396 None,
2397 Duration::from_secs(10),
2398 );
2399 let output = tool
2400 .execute(json!({"prompt":"hi"}), context(workspace.path()))
2401 .await
2402 .unwrap();
2403 let value: Value = serde_json::from_str(&output.content).unwrap();
2404 assert_eq!(value["status"], "completed", "{value}");
2405 assert_eq!(value["reply"], "final from file");
2406 let path = std::fs::read_to_string(workspace.path().join("last-path.txt")).unwrap();
2407 let path = PathBuf::from(path.trim());
2408 assert!(path.starts_with(home.path().join("tmp")));
2409 assert!(!path.exists(), "the last-message file is removed");
2410 use std::os::unix::fs::PermissionsExt as _;
2411 let mode = std::fs::metadata(home.path().join("tmp"))
2412 .unwrap()
2413 .permissions()
2414 .mode();
2415 assert_eq!(mode & 0o777, 0o700);
2416 }
2417
2418 #[tokio::test]
2419 async fn pi_json_and_signed_out_claude_results() {
2420 let workspace = tempfile::tempdir().unwrap();
2421 let pi = structured_agent(
2422 workspace.path(),
2423 "agent_pi",
2424 OutputFormat::PiJson,
2425 r#"echo '{"type":"session","id":"p"}'
2426echo '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"pi ok"}],"usage":{"input":7,"output":2}}}'
2427"#,
2428 None,
2429 None,
2430 Duration::from_secs(10),
2431 );
2432 let output = pi
2433 .execute(json!({"prompt":"hi"}), context(workspace.path()))
2434 .await
2435 .unwrap();
2436 let value: Value = serde_json::from_str(&output.content).unwrap();
2437 assert_eq!(value["reply"], "pi ok");
2438 assert_eq!(value["usage"]["output_tokens"], 2);
2439
2440 let claude = structured_agent(
2441 workspace.path(),
2442 "agent_claude",
2443 OutputFormat::ClaudeStreamJson,
2444 r#"echo '{"type":"result","subtype":"success","is_error":true,"result":"Not logged in ยท Please run /login"}'
2445exit 1
2446"#,
2447 None,
2448 None,
2449 Duration::from_secs(10),
2450 );
2451 let output = claude
2452 .execute(json!({"prompt":"hi"}), context(workspace.path()))
2453 .await
2454 .unwrap();
2455 assert!(output.is_error);
2456 let value: Value = serde_json::from_str(&output.content).unwrap();
2457 assert_eq!(value["status"], "failed");
2458 assert_eq!(value["exit_code"], 1);
2459 assert!(
2460 value["hint"]
2461 .as_str()
2462 .unwrap()
2463 .contains("scv agents login claude")
2464 );
2465 }
2466
2467 #[cfg(target_os = "linux")]
2468 #[tokio::test]
2469 async fn a_timed_out_run_and_its_detached_descendants_are_stopped() {
2470 let workspace = tempfile::tempdir().unwrap();
2471 let home = tempfile::tempdir().unwrap();
2472 let delegation = delegation_context(home.path());
2473 let tool = structured_agent(
2474 workspace.path(),
2475 "agent_codex",
2476 OutputFormat::CodexJsonl,
2477 "setsid sleep 60 &\necho \"$SCV_PARENT\" > chain.txt\nexec sleep 60\n",
2479 None,
2480 Some(delegation.clone()),
2481 Duration::from_secs(1),
2482 );
2483 let output = tool
2484 .execute(json!({"prompt":"hi"}), context(workspace.path()))
2485 .await
2486 .unwrap();
2487 let value: Value = serde_json::from_str(&output.content).unwrap();
2488 assert_eq!(value["status"], "timeout");
2489 let chain = std::fs::read_to_string(workspace.path().join("chain.txt")).unwrap();
2490 let handle = chain.trim().rsplit('/').next().unwrap().to_owned();
2491 let tagged = || {
2492 std::fs::read_dir("/proc")
2493 .unwrap()
2494 .filter_map(Result::ok)
2495 .filter(|entry| {
2496 std::fs::read(entry.path().join("environ")).is_ok_and(|environ| {
2497 environ
2498 .split(|byte| *byte == 0)
2499 .any(|entry| entry == format!("SCV_PARENT={}", chain.trim()).as_bytes())
2500 })
2501 })
2502 .count()
2503 };
2504 let mut remaining = tagged();
2505 for _ in 0..100 {
2506 if remaining == 0 {
2507 break;
2508 }
2509 tokio::time::sleep(Duration::from_millis(50)).await;
2510 remaining = tagged();
2511 }
2512 assert_eq!(remaining, 0, "tagged processes of {handle} survived");
2513 assert!(delegation.registry.list(true).is_empty());
2514 }
2515
2516 #[test]
2517 fn agents_are_not_offered_at_the_delegation_depth_limit() {
2518 let adapter = AgentAdapterConfig {
2519 command: "bash".into(),
2520 args: Vec::new(),
2521 prompt_args: Vec::new(),
2522 full_permission_args: None,
2523 model_args: Vec::new(),
2524 effort_args: Vec::new(),
2525 model_hint: String::new(),
2526 environment: Vec::new(),
2527 search_dirs: Vec::new(),
2528 output: OutputFormat::Text,
2529 home: None,
2530 };
2531 let home = tempfile::tempdir().unwrap();
2532 for (max_depth, offered) in [(0, false), (1, true)] {
2533 let registry = builtin_registry(
2534 ToolsConfig {
2535 max_delegation_depth: max_depth,
2536 delegation: Some(delegation_context(home.path())),
2537 ..ToolsConfig::default()
2538 },
2539 SkillMap::new(),
2540 Vec::new(),
2541 1024,
2542 HashMap::from([("agent_claude".to_owned(), adapter.clone())]),
2543 )
2544 .unwrap();
2545 assert_eq!(registry.get("agent_claude").is_some(), offered);
2546 assert!(registry.get("bash").is_some());
2547 }
2548 }
2549}