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