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