Skip to main content

scv_tools/
lib.rs

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