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