Skip to main content

scv_tools/
lib.rs

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