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