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