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