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