Skip to main content

scv_tools/
lib.rs

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