Skip to main content

scv_tools/
lib.rs

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