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