Skip to main content

scv_tools/
lib.rs

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