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_dsh_failure_names_the_host_login_command() {
2074        let workspace = tempfile::tempdir().unwrap();
2075        // DeepSeek Harness 0.1.7-rc.1's startup error without a key.
2076        let tool = fake_agent(
2077            workspace.path(),
2078            "agent_dsh",
2079            "echo 'dsh: MISSING_CREDENTIAL: llm-deepseek: no API key for provider route \"deepseek-official\"' >&2\nexit 1\n",
2080            &[],
2081            Vec::new(),
2082        );
2083        let output = tool
2084            .execute(json!({"prompt":"hi"}), context(workspace.path()))
2085            .await
2086            .unwrap();
2087        assert!(output.is_error);
2088        let content: Value = serde_json::from_str(&output.content).unwrap();
2089        assert!(
2090            content["hint"]
2091                .as_str()
2092                .unwrap()
2093                .ends_with("scv agents login dsh"),
2094            "{content}"
2095        );
2096    }
2097
2098    #[tokio::test]
2099    async fn signed_out_agent_failure_names_the_host_login_command() {
2100        let workspace = tempfile::tempdir().unwrap();
2101        let tool = fake_agent(
2102            workspace.path(),
2103            "agent_claude",
2104            "echo 'Not logged in ยท Please run /login'\nexit 1\n",
2105            &[],
2106            Vec::new(),
2107        );
2108        let output = tool
2109            .execute(json!({"prompt":"hi"}), context(workspace.path()))
2110            .await
2111            .unwrap();
2112        assert!(output.is_error);
2113        let content: Value = serde_json::from_str(&output.content).unwrap();
2114        assert!(
2115            content["hint"]
2116                .as_str()
2117                .unwrap()
2118                .ends_with("scv agents login claude")
2119        );
2120        let other = fake_agent(
2121            workspace.path(),
2122            "agent_claude",
2123            "echo 'disk full'\nexit 1\n",
2124            &[],
2125            Vec::new(),
2126        );
2127        let output = other
2128            .execute(json!({"prompt":"hi"}), context(workspace.path()))
2129            .await
2130            .unwrap();
2131        assert!(output.is_error);
2132        assert!(!output.content.contains("hint"));
2133    }
2134
2135    #[tokio::test]
2136    async fn native_agent_uses_instance_private_environment() {
2137        let workspace = tempfile::tempdir().unwrap();
2138        let home = workspace.path().join("private-home");
2139        let tool = fake_agent(
2140            workspace.path(),
2141            "agent_codex",
2142            "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",
2143            &[],
2144            vec![
2145                ("HOME".into(), home.clone().into()),
2146                ("SCV_HOME".into(), home.clone().into()),
2147                ("CODEX_HOME".into(), home.join("codex").into()),
2148            ],
2149        );
2150        let output = tool
2151            .execute(
2152                json!({"prompt":"print environment"}),
2153                context(workspace.path()),
2154            )
2155            .await
2156            .unwrap();
2157        assert!(output.content.contains(&format!("HOME={}", home.display())));
2158        assert!(
2159            output
2160                .content
2161                .contains(&format!("CODEX_HOME={}/codex", home.display()))
2162        );
2163        assert!(output.content.contains("SCV_CONFIG=unset"));
2164        assert!(output.content.contains("OPENAI_API_KEY=unset"));
2165        assert!(output.content.contains("CODEX_API_KEY=unset"));
2166    }
2167
2168    #[tokio::test]
2169    async fn native_agent_places_prompt_flags_just_before_the_prompt() {
2170        let workspace = tempfile::tempdir().unwrap();
2171        let tool = fake_agent_with_prompt_args(
2172            workspace.path(),
2173            "agent_grok",
2174            "printf '%s\\n' \"$@\"\n",
2175            &[],
2176            &["-p"],
2177            Vec::new(),
2178        );
2179        let arguments = json!({"prompt":"hi","model":"grok-4","effort":"high"});
2180        assert!(
2181            tool.approval_summary(&arguments)
2182                .unwrap()
2183                .contains(r#""--model", "grok-4", "--effort", "high", "-p""#)
2184        );
2185        let output = tool
2186            .execute(arguments, context(workspace.path()))
2187            .await
2188            .unwrap();
2189        let output: Value = serde_json::from_str(&output.content).unwrap();
2190        assert_eq!(output["reply"], "--model\ngrok-4\n--effort\nhigh\n-p\nhi");
2191    }
2192
2193    #[tokio::test]
2194    async fn full_permissions_follow_the_fixed_arguments_and_are_announced() {
2195        let workspace = tempfile::tempdir().unwrap();
2196        let mut tool = fake_agent(
2197            workspace.path(),
2198            "agent_claude",
2199            "printf '%s\\n' \"$@\"\n",
2200            &["-p"],
2201            Vec::new(),
2202        );
2203        let arguments = json!({"prompt":"hi","model":"opus"});
2204        assert!(!tool.approval_summary(&arguments).unwrap().contains("FULL"));
2205        tool.full_permission_args =
2206            Some(vec!["--permission-mode".into(), "bypassPermissions".into()]);
2207        let summary = tool.approval_summary(&arguments).unwrap();
2208        assert!(summary.contains("FULL PERMISSIONS"), "{summary}");
2209        assert!(
2210            summary
2211                .contains(r#""-p", "--permission-mode", "bypassPermissions", "--model", "opus""#)
2212        );
2213        let output = tool
2214            .execute(arguments, context(workspace.path()))
2215            .await
2216            .unwrap();
2217        let output: Value = serde_json::from_str(&output.content).unwrap();
2218        assert_eq!(
2219            output["reply"],
2220            "-p\n--permission-mode\nbypassPermissions\n--model\nopus\nhi"
2221        );
2222    }
2223
2224    #[test]
2225    fn agent_environment_drops_inherited_credentials_but_keeps_its_own_home() {
2226        let mut command = std::process::Command::new("true");
2227        apply_agent_environment_from(
2228            &mut command,
2229            [
2230                "GROK_HOME",
2231                "XAI_API_KEY",
2232                "PI_CODING_AGENT_DIR",
2233                "DEEPSEEK_API_KEY",
2234                "ANTHROPIC_API_KEY",
2235                "OPENROUTER_API_KEY",
2236                "PATH",
2237            ]
2238            .map(OsString::from),
2239            &[("GROK_HOME".into(), "/private/.grok".into())],
2240        );
2241        let envs: HashMap<_, _> = command
2242            .get_envs()
2243            .map(|(key, value)| (key.to_owned(), value.map(ToOwned::to_owned)))
2244            .collect();
2245        assert_eq!(
2246            envs[&OsString::from("GROK_HOME")],
2247            Some(OsString::from("/private/.grok"))
2248        );
2249        for removed in [
2250            "XAI_API_KEY",
2251            "PI_CODING_AGENT_DIR",
2252            "DEEPSEEK_API_KEY",
2253            "ANTHROPIC_API_KEY",
2254            "OPENROUTER_API_KEY",
2255        ] {
2256            assert_eq!(envs[&OsString::from(removed)], None, "{removed}");
2257        }
2258        assert!(!envs.contains_key(&OsString::from("PATH")));
2259    }
2260
2261    #[test]
2262    fn uninstalled_agents_are_not_offered() {
2263        let adapter = |command: &str| AgentAdapterConfig {
2264            command: command.into(),
2265            args: Vec::new(),
2266            prompt_args: Vec::new(),
2267            full_permission_args: None,
2268            model_args: Vec::new(),
2269            effort_args: Vec::new(),
2270            model_hint: String::new(),
2271            environment: Vec::new(),
2272            search_dirs: Vec::new(),
2273            output: OutputFormat::Text,
2274            resume: Resume::Unsupported,
2275            home: None,
2276        };
2277        let registry = builtin_registry(
2278            ToolsConfig::default(),
2279            SkillMap::new(),
2280            Vec::new(),
2281            1024,
2282            HashMap::from([
2283                ("agent_present".to_owned(), adapter("bash")),
2284                (
2285                    "agent_missing".to_owned(),
2286                    adapter("scv-test-agent-that-is-not-installed"),
2287                ),
2288            ]),
2289        )
2290        .unwrap();
2291        assert!(registry.get("agent_present").is_some());
2292        assert!(registry.get("agent_missing").is_none());
2293    }
2294
2295    #[tokio::test]
2296    async fn native_agent_runs_in_a_contained_directory() {
2297        let workspace = tempfile::tempdir().unwrap();
2298        let outside = tempfile::tempdir().unwrap();
2299        let root = workspace.path().canonicalize().unwrap();
2300        std::fs::create_dir(root.join("project")).unwrap();
2301        std::fs::write(root.join("notes.txt"), "not a directory").unwrap();
2302        symlink(outside.path(), root.join("escape")).unwrap();
2303        symlink(root.join("project"), root.join("inner-link")).unwrap();
2304        let tool = fake_agent(&root, "agent_codex", "pwd\n", &[], Vec::new());
2305        let run = |arguments: Value| tool.execute(arguments, context(&root));
2306
2307        for arguments in [
2308            json!({"prompt":"hi"}),
2309            json!({"prompt":"hi","cwd":""}),
2310            json!({"prompt":"hi","cwd":"  ","model":"","effort":" "}),
2311        ] {
2312            let output = run(arguments.clone()).await.unwrap();
2313            let output: Value = serde_json::from_str(&output.content).unwrap();
2314            assert_eq!(output["reply"], root.display().to_string(), "{arguments}");
2315        }
2316        for cwd in [
2317            "project".to_owned(),
2318            "project/".to_owned(),
2319            "inner-link".to_owned(),
2320            root.join("project").display().to_string(),
2321        ] {
2322            let output = run(json!({"prompt":"hi","cwd":cwd})).await.unwrap();
2323            let output: Value = serde_json::from_str(&output.content).unwrap();
2324            assert_eq!(
2325                output["reply"],
2326                root.join("project").display().to_string(),
2327                "{cwd}"
2328            );
2329        }
2330        for (cwd, error) in [
2331            ("..", "outside the workspace"),
2332            ("escape", "outside the workspace"),
2333            ("/", "outside the workspace"),
2334            ("notes.txt", "not a directory"),
2335            ("missing", "No such file"),
2336        ] {
2337            let result = run(json!({"prompt":"hi","cwd":cwd})).await;
2338            assert!(
2339                result.as_ref().unwrap_err().to_string().contains(error),
2340                "{cwd}: {result:?}"
2341            );
2342        }
2343        assert!(tool.risk(&json!({"prompt":"hi","cwd":"a\0b"})).is_err());
2344        assert!(
2345            tool.risk(&json!({"prompt":"hi","cwd":"x".repeat(MAX_AGENT_CWD_BYTES + 1)}))
2346                .is_err()
2347        );
2348        let summary = tool
2349            .approval_summary(&json!({"prompt":"hi","cwd":"project","timeout_seconds":4}))
2350            .unwrap();
2351        assert!(summary.contains(r#"in "project" (inside the workspace) for up to 4 seconds"#));
2352        assert!(
2353            tool.approval_summary(&json!({"prompt":"hi"}))
2354                .unwrap()
2355                .contains("in the workspace root for up to 2 seconds")
2356        );
2357        let description = tool.spec().parameters["properties"]["cwd"]["description"]
2358            .as_str()
2359            .unwrap()
2360            .to_owned();
2361        assert!(description.contains("AGENTS.md"));
2362    }
2363
2364    #[tokio::test]
2365    async fn per_call_timeouts_may_rise_to_the_ceiling_but_not_past_it() {
2366        let timeouts = Timeouts {
2367            default: Duration::from_secs(120),
2368            max: Duration::from_secs(1800),
2369        };
2370        assert_eq!(timeouts.resolve(None).unwrap(), Duration::from_secs(120));
2371        assert_eq!(timeouts.resolve(Some(30)).unwrap(), Duration::from_secs(30));
2372        assert_eq!(
2373            timeouts.resolve(Some(1800)).unwrap(),
2374            Duration::from_secs(1800)
2375        );
2376        assert!(timeouts.resolve(Some(0)).is_err());
2377        assert!(
2378            timeouts
2379                .resolve(Some(1801))
2380                .unwrap_err()
2381                .to_string()
2382                .contains("maximum of 1800 seconds (tools.max_timeout_seconds)")
2383        );
2384
2385        let workspace = tempfile::tempdir().unwrap();
2386        let agent = fake_agent(
2387            workspace.path(),
2388            "agent_codex",
2389            "echo ran\n",
2390            &[],
2391            Vec::new(),
2392        );
2393        let schema = &agent.spec().parameters["properties"]["timeout_seconds"];
2394        assert_eq!(schema["maximum"], 5);
2395        assert!(
2396            schema["description"]
2397                .as_str()
2398                .unwrap()
2399                .contains("Defaults to 2; at most 5")
2400        );
2401        assert!(
2402            agent
2403                .risk(&json!({"prompt":"hi","timeout_seconds":5}))
2404                .is_ok()
2405        );
2406        assert!(
2407            agent
2408                .risk(&json!({"prompt":"hi","timeout_seconds":6}))
2409                .is_err()
2410        );
2411        assert!(
2412            agent
2413                .execute(
2414                    json!({"prompt":"hi","timeout_seconds":6}),
2415                    context(workspace.path())
2416                )
2417                .await
2418                .is_err()
2419        );
2420
2421        let bash = BashTool {
2422            timeout: Duration::from_secs(1),
2423            max_timeout: Duration::from_secs(3),
2424            output_limit: 100,
2425        };
2426        assert_eq!(
2427            bash.spec().parameters["properties"]["timeout_seconds"]["maximum"],
2428            3
2429        );
2430        assert!(
2431            bash.risk(&json!({"command":"true","timeout_seconds":3}))
2432                .is_ok()
2433        );
2434        assert!(
2435            bash.risk(&json!({"command":"true","timeout_seconds":4}))
2436                .unwrap_err()
2437                .to_string()
2438                .contains("tools.max_timeout_seconds")
2439        );
2440    }
2441
2442    /// A fake agent CLI in `format`, run through `bash script`, optionally
2443    /// recorded in `delegation`.
2444    fn structured_agent(
2445        workspace: &Path,
2446        name: &str,
2447        format: OutputFormat,
2448        script: &str,
2449        home: Option<PathBuf>,
2450        delegation: Option<DelegationContext>,
2451        timeout: Duration,
2452    ) -> NativeAgentTool {
2453        conversing_agent(
2454            workspace,
2455            name,
2456            format,
2457            Resume::Unsupported,
2458            script,
2459            home,
2460            delegation,
2461            timeout,
2462            test_conversations(),
2463        )
2464    }
2465
2466    /// Like [`structured_agent`], continuing conversations as `resume` says,
2467    /// in `conversations` (shared by one session's tools).
2468    #[allow(clippy::too_many_arguments)]
2469    fn conversing_agent(
2470        workspace: &Path,
2471        name: &str,
2472        format: OutputFormat,
2473        resume: Resume,
2474        script: &str,
2475        home: Option<PathBuf>,
2476        delegation: Option<DelegationContext>,
2477        timeout: Duration,
2478        conversations: Arc<ConversationStore>,
2479    ) -> NativeAgentTool {
2480        let script_path = workspace.join(format!("fake-{name}.sh"));
2481        std::fs::write(&script_path, script).unwrap();
2482        NativeAgentTool::new(
2483            name.into(),
2484            AgentAdapterConfig {
2485                command: "bash".into(),
2486                args: vec![script_path.display().to_string()],
2487                prompt_args: Vec::new(),
2488                full_permission_args: None,
2489                model_args: Vec::new(),
2490                effort_args: Vec::new(),
2491                model_hint: String::new(),
2492                environment: Vec::new(),
2493                search_dirs: Vec::new(),
2494                output: format,
2495                resume,
2496                home,
2497            },
2498            Timeouts {
2499                default: timeout,
2500                max: Duration::from_secs(30),
2501            },
2502            64 * 1024,
2503            delegation,
2504            conversations,
2505        )
2506    }
2507
2508    fn delegation_context(home: &Path) -> DelegationContext {
2509        DelegationContext {
2510            registry: Arc::new(DelegationRegistry::new(home)),
2511            session: "session-1".into(),
2512        }
2513    }
2514
2515    #[tokio::test]
2516    async fn claude_stream_json_becomes_a_structured_result() {
2517        let workspace = tempfile::tempdir().unwrap();
2518        let args_file = workspace.path().join("args.txt");
2519        let script = format!(
2520            r#"printf '%s\n' "$@" > {args}
2521printf '%s\n' "$SCV_PARENT" "$SCV_DELEGATION_DEPTH" >> {args}
2522echo '{{"type":"system","subtype":"init","session_id":"x","unknown":[1,2]}}'
2523echo '{{"type":"assistant","message":{{"content":[{{"type":"text","text":"thinking"}}]}}}}'
2524echo 'stray diagnostic' >&2
2525echo '{{"type":"result","subtype":"success","is_error":false,"result":"all done","usage":{{"input_tokens":12,"output_tokens":3}}}}'
2526"#,
2527            args = args_file.display()
2528        );
2529        let home = tempfile::tempdir().unwrap();
2530        let context_home = delegation_context(home.path());
2531        let tool = conversing_agent(
2532            workspace.path(),
2533            "agent_claude",
2534            OutputFormat::ClaudeStreamJson,
2535            adapters::adapter("claude").unwrap().resume,
2536            &script,
2537            None,
2538            Some(context_home.clone()),
2539            Duration::from_secs(10),
2540            test_conversations(),
2541        );
2542        let output = tool
2543            .execute(json!({"prompt":"hi"}), context(workspace.path()))
2544            .await
2545            .unwrap();
2546        assert!(!output.is_error, "{}", output.content);
2547        let value: Value = serde_json::from_str(&output.content).unwrap();
2548        assert_eq!(value["agent"], "claude");
2549        assert_eq!(
2550            (value["session"].as_str(), value["turn"].as_u64()),
2551            (Some("claude-1"), Some(1))
2552        );
2553        assert_eq!(value["status"], "completed");
2554        assert_eq!(value["reply"], "all done");
2555        assert_eq!(value["usage"]["input_tokens"], 12);
2556        assert_eq!(value["exit_code"], 0);
2557        assert_eq!(value["stderr_tail"], "stray diagnostic");
2558        assert_eq!(value["truncated"], false);
2559        // No event log reaches the parent.
2560        assert!(!output.content.contains("thinking"));
2561        let recorded = std::fs::read_to_string(&args_file).unwrap();
2562        let lines: Vec<&str> = recorded.lines().collect();
2563        assert_eq!(
2564            &lines[..4],
2565            [
2566                "--output-format",
2567                "stream-json",
2568                "--verbose",
2569                "--session-id"
2570            ]
2571        );
2572        assert!(uuid::Uuid::parse_str(lines[4]).is_ok());
2573        assert_eq!(lines[5], "hi");
2574        let chain = lines[6];
2575        assert!(chain.contains("/session-1/claude-"), "{chain}");
2576        assert_eq!(lines[7], "1");
2577        // The run's record is gone once it ends.
2578        assert!(context_home.registry.list(true).is_empty());
2579    }
2580
2581    /// A fake Codex that records each call's arguments, reports thread
2582    /// `th-1`, and answers with the prompt it was given. With `slow_start`,
2583    /// a first (non-resume) turn hangs after reporting its thread.
2584    fn fake_codex(workspace: &Path, slow_start: bool) -> String {
2585        let log = workspace.join("calls.txt");
2586        format!(
2587            r#"printf '%s\n' "$@" '--' >> {log}
2588case " $* " in *" resume "*) ;; *) echo '{{"type":"thread.started","thread_id":"th-1"}}'; {hang} ;; esac
2589for last; do :; done
2590echo "{{\"type\":\"item.completed\",\"item\":{{\"type\":\"agent_message\",\"text\":\"echo: $last\"}}}}"
2591echo '{{"type":"turn.completed","usage":{{"input_tokens":1,"output_tokens":1}}}}'
2592"#,
2593            log = log.display(),
2594            hang = if slow_start { "sleep 30" } else { ":" }
2595        )
2596    }
2597
2598    fn calls(workspace: &Path) -> Vec<Vec<String>> {
2599        std::fs::read_to_string(workspace.join("calls.txt"))
2600            .unwrap()
2601            .split("--\n")
2602            .filter(|call| !call.is_empty())
2603            .map(|call| call.lines().map(str::to_owned).collect())
2604            .collect()
2605    }
2606
2607    #[tokio::test]
2608    async fn conversations_continue_the_cli_session_in_the_same_cwd() {
2609        let workspace = tempfile::tempdir().unwrap();
2610        std::fs::create_dir(workspace.path().join("sub")).unwrap();
2611        let codex_resume = adapters::adapter("codex").unwrap().resume;
2612        let store = test_conversations();
2613        let tool = conversing_agent(
2614            workspace.path(),
2615            "agent_codex",
2616            OutputFormat::CodexJsonl,
2617            codex_resume,
2618            &fake_codex(workspace.path(), false),
2619            None,
2620            None,
2621            Duration::from_secs(10),
2622            Arc::clone(&store),
2623        );
2624        let first = tool
2625            .execute(
2626                json!({"prompt":"remember heron"}),
2627                context(workspace.path()),
2628            )
2629            .await
2630            .unwrap();
2631        let value: Value = serde_json::from_str(&first.content).unwrap();
2632        assert_eq!(value["status"], "completed", "{value}");
2633        assert_eq!(
2634            (value["session"].as_str(), value["turn"].as_u64()),
2635            (Some("codex-1"), Some(1))
2636        );
2637        let second = tool
2638            .execute(
2639                json!({"prompt":"what word?","session":"codex-1"}),
2640                context(workspace.path()),
2641            )
2642            .await
2643            .unwrap();
2644        let value: Value = serde_json::from_str(&second.content).unwrap();
2645        assert_eq!(value["reply"], "echo: what word?");
2646        assert_eq!(
2647            (value["session"].as_str(), value["turn"].as_u64()),
2648            (Some("codex-1"), Some(2))
2649        );
2650        // The script path is the only fixed argument, so `$@` starts after it:
2651        // `resume` comes right after the fixed arguments, and the CLI's thread
2652        // ID sits just before the prompt.
2653        let recorded = calls(workspace.path());
2654        assert_eq!(recorded[0], ["--json", "remember heron"]);
2655        assert_eq!(recorded[1], ["resume", "--json", "th-1", "what word?"]);
2656
2657        // A conversation stays in its cwd.
2658        let moved = tool
2659            .execute(
2660                json!({"prompt":"x","session":"codex-1","cwd":"sub"}),
2661                context(workspace.path()),
2662            )
2663            .await
2664            .unwrap_err();
2665        assert!(moved.0.contains("runs in"), "{}", moved.0);
2666        // Another session's tools do not know this session's handles.
2667        let other_session = conversing_agent(
2668            workspace.path(),
2669            "agent_codex",
2670            OutputFormat::CodexJsonl,
2671            codex_resume,
2672            &fake_codex(workspace.path(), false),
2673            None,
2674            None,
2675            Duration::from_secs(10),
2676            test_conversations(),
2677        );
2678        let unknown = other_session
2679            .execute(
2680                json!({"prompt":"x","session":"codex-1"}),
2681                context(workspace.path()),
2682            )
2683            .await
2684            .unwrap_err();
2685        assert!(
2686            unknown.0.contains("unknown in this session"),
2687            "{}",
2688            unknown.0
2689        );
2690        assert_eq!(
2691            calls(workspace.path()).len(),
2692            2,
2693            "rejected turns never launch the CLI"
2694        );
2695        // The CLI's own ID is never accepted in place of a handle.
2696        let vendor = json!({"prompt":"x","session":"01a0cd5a-7195-7b31-a503-e235d5da7b45"});
2697        assert!(
2698            tool.risk(&vendor)
2699                .unwrap_err()
2700                .0
2701                .contains("not a conversation handle")
2702        );
2703        assert!(
2704            tool.spec().parameters["properties"]
2705                .get("session")
2706                .is_some()
2707        );
2708    }
2709
2710    #[tokio::test]
2711    async fn a_timed_out_turn_stays_resumable_and_unsupported_agents_refuse_sessions() {
2712        let workspace = tempfile::tempdir().unwrap();
2713        let tool = conversing_agent(
2714            workspace.path(),
2715            "agent_codex",
2716            OutputFormat::CodexJsonl,
2717            adapters::adapter("codex").unwrap().resume,
2718            &fake_codex(workspace.path(), true),
2719            None,
2720            None,
2721            Duration::from_secs(1),
2722            test_conversations(),
2723        );
2724        let first = tool
2725            .execute(json!({"prompt":"start"}), context(workspace.path()))
2726            .await
2727            .unwrap();
2728        let value: Value = serde_json::from_str(&first.content).unwrap();
2729        assert_eq!(value["status"], "timeout", "{value}");
2730        assert_eq!(value["session"], "codex-1");
2731        let resumed = tool
2732            .execute(
2733                json!({"prompt":"continue where you left off","session":"codex-1"}),
2734                context(workspace.path()),
2735            )
2736            .await
2737            .unwrap();
2738        let value: Value = serde_json::from_str(&resumed.content).unwrap();
2739        assert_eq!(value["status"], "completed", "{value}");
2740        assert_eq!(value["turn"], 2);
2741
2742        let plain = structured_agent(
2743            workspace.path(),
2744            "agent_grok",
2745            OutputFormat::Text,
2746            "echo hi\n",
2747            None,
2748            None,
2749            Duration::from_secs(5),
2750        );
2751        let refused = plain
2752            .risk(&json!({"prompt":"x","session":"grok-1"}))
2753            .unwrap_err();
2754        assert!(
2755            refused.0.contains("cannot continue a conversation"),
2756            "{}",
2757            refused.0
2758        );
2759        assert!(
2760            plain.spec().parameters["properties"]
2761                .get("session")
2762                .is_none()
2763        );
2764        let output = plain
2765            .execute(json!({"prompt":"x"}), context(workspace.path()))
2766            .await
2767            .unwrap();
2768        assert!(
2769            !output.content.contains("\"session\""),
2770            "{}",
2771            output.content
2772        );
2773    }
2774
2775    #[tokio::test]
2776    async fn codex_json_reads_the_last_message_file_and_removes_it() {
2777        let workspace = tempfile::tempdir().unwrap();
2778        let home = tempfile::tempdir().unwrap();
2779        let script = r#"while [ "$#" -gt 0 ]; do
2780  if [ "$1" = "-o" ]; then printf 'final from file\n' > "$2"; echo "$2" > last-path.txt; fi
2781  shift
2782done
2783echo '{"type":"thread.started","thread_id":"t"}'
2784echo '{"type":"turn.completed","usage":{"input_tokens":5,"output_tokens":1}}'
2785"#;
2786        let tool = structured_agent(
2787            workspace.path(),
2788            "agent_codex",
2789            OutputFormat::CodexJsonl,
2790            script,
2791            Some(home.path().to_owned()),
2792            None,
2793            Duration::from_secs(10),
2794        );
2795        let output = tool
2796            .execute(json!({"prompt":"hi"}), context(workspace.path()))
2797            .await
2798            .unwrap();
2799        let value: Value = serde_json::from_str(&output.content).unwrap();
2800        assert_eq!(value["status"], "completed", "{value}");
2801        assert_eq!(value["reply"], "final from file");
2802        let path = std::fs::read_to_string(workspace.path().join("last-path.txt")).unwrap();
2803        let path = PathBuf::from(path.trim());
2804        assert!(path.starts_with(home.path().join("tmp")));
2805        assert!(!path.exists(), "the last-message file is removed");
2806        use std::os::unix::fs::PermissionsExt as _;
2807        let mode = std::fs::metadata(home.path().join("tmp"))
2808            .unwrap()
2809            .permissions()
2810            .mode();
2811        assert_eq!(mode & 0o777, 0o700);
2812    }
2813
2814    #[tokio::test]
2815    async fn pi_json_and_signed_out_claude_results() {
2816        let workspace = tempfile::tempdir().unwrap();
2817        let pi = structured_agent(
2818            workspace.path(),
2819            "agent_pi",
2820            OutputFormat::PiJson,
2821            r#"echo '{"type":"session","id":"p"}'
2822echo '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"pi ok"}],"usage":{"input":7,"output":2}}}'
2823"#,
2824            None,
2825            None,
2826            Duration::from_secs(10),
2827        );
2828        let output = pi
2829            .execute(json!({"prompt":"hi"}), context(workspace.path()))
2830            .await
2831            .unwrap();
2832        let value: Value = serde_json::from_str(&output.content).unwrap();
2833        assert_eq!(value["reply"], "pi ok");
2834        assert_eq!(value["usage"]["output_tokens"], 2);
2835
2836        let claude = structured_agent(
2837            workspace.path(),
2838            "agent_claude",
2839            OutputFormat::ClaudeStreamJson,
2840            r#"echo '{"type":"result","subtype":"success","is_error":true,"result":"Not logged in ยท Please run /login"}'
2841exit 1
2842"#,
2843            None,
2844            None,
2845            Duration::from_secs(10),
2846        );
2847        let output = claude
2848            .execute(json!({"prompt":"hi"}), context(workspace.path()))
2849            .await
2850            .unwrap();
2851        assert!(output.is_error);
2852        let value: Value = serde_json::from_str(&output.content).unwrap();
2853        assert_eq!(value["status"], "failed");
2854        assert_eq!(value["exit_code"], 1);
2855        assert!(
2856            value["hint"]
2857                .as_str()
2858                .unwrap()
2859                .contains("scv agents login claude")
2860        );
2861    }
2862
2863    #[cfg(target_os = "linux")]
2864    #[tokio::test]
2865    async fn a_timed_out_run_and_its_detached_descendants_are_stopped() {
2866        let workspace = tempfile::tempdir().unwrap();
2867        let home = tempfile::tempdir().unwrap();
2868        let delegation = delegation_context(home.path());
2869        let tool = structured_agent(
2870            workspace.path(),
2871            "agent_codex",
2872            OutputFormat::CodexJsonl,
2873            // The detached sleep leaves the agent's process group and session.
2874            "setsid sleep 60 &\necho \"$SCV_PARENT\" > chain.txt\nexec sleep 60\n",
2875            None,
2876            Some(delegation.clone()),
2877            Duration::from_secs(1),
2878        );
2879        let output = tool
2880            .execute(json!({"prompt":"hi"}), context(workspace.path()))
2881            .await
2882            .unwrap();
2883        let value: Value = serde_json::from_str(&output.content).unwrap();
2884        assert_eq!(value["status"], "timeout");
2885        let chain = std::fs::read_to_string(workspace.path().join("chain.txt")).unwrap();
2886        let handle = chain.trim().rsplit('/').next().unwrap().to_owned();
2887        let tagged = || {
2888            std::fs::read_dir("/proc")
2889                .unwrap()
2890                .filter_map(Result::ok)
2891                .filter(|entry| {
2892                    std::fs::read(entry.path().join("environ")).is_ok_and(|environ| {
2893                        environ
2894                            .split(|byte| *byte == 0)
2895                            .any(|entry| entry == format!("SCV_PARENT={}", chain.trim()).as_bytes())
2896                    })
2897                })
2898                .count()
2899        };
2900        let mut remaining = tagged();
2901        for _ in 0..100 {
2902            if remaining == 0 {
2903                break;
2904            }
2905            tokio::time::sleep(Duration::from_millis(50)).await;
2906            remaining = tagged();
2907        }
2908        assert_eq!(remaining, 0, "tagged processes of {handle} survived");
2909        assert!(delegation.registry.list(true).is_empty());
2910    }
2911
2912    #[test]
2913    fn agents_are_not_offered_at_the_delegation_depth_limit() {
2914        let adapter = AgentAdapterConfig {
2915            command: "bash".into(),
2916            args: Vec::new(),
2917            prompt_args: Vec::new(),
2918            full_permission_args: None,
2919            model_args: Vec::new(),
2920            effort_args: Vec::new(),
2921            model_hint: String::new(),
2922            environment: Vec::new(),
2923            search_dirs: Vec::new(),
2924            output: OutputFormat::Text,
2925            resume: Resume::Unsupported,
2926            home: None,
2927        };
2928        let home = tempfile::tempdir().unwrap();
2929        for (max_depth, offered) in [(0, false), (1, true)] {
2930            let registry = builtin_registry(
2931                ToolsConfig {
2932                    max_delegation_depth: max_depth,
2933                    delegation: Some(delegation_context(home.path())),
2934                    ..ToolsConfig::default()
2935                },
2936                SkillMap::new(),
2937                Vec::new(),
2938                1024,
2939                HashMap::from([("agent_claude".to_owned(), adapter.clone())]),
2940            )
2941            .unwrap();
2942            assert_eq!(registry.get("agent_claude").is_some(), offered);
2943            assert!(registry.get("bash").is_some());
2944        }
2945    }
2946}