Skip to main content

scv_tools/
lib.rs

1//! SCV's bounded, workspace-aware built-in tools.
2
3use std::{
4    collections::HashMap,
5    ffi::OsString,
6    io::{Read as _, Write as _},
7    os::unix::process::CommandExt as _,
8    path::{Component, Path, PathBuf},
9    sync::{
10        Arc,
11        atomic::{AtomicU64, Ordering},
12    },
13    time::Duration,
14};
15
16use async_trait::async_trait;
17use cap_std::{
18    ambient_authority,
19    fs::{Dir, OpenOptions},
20};
21use scv_core::{Tool, ToolContext, ToolError, ToolOutput, ToolRegistry, ToolRisk, ToolSpec};
22use serde::Deserialize;
23use serde_json::{Value, json};
24use sha2::{Digest, Sha256};
25use tokio::{
26    io::AsyncReadExt,
27    process::Command,
28    sync::Mutex,
29    task::JoinHandle,
30    time::{Instant, sleep, sleep_until, timeout, timeout_at},
31};
32
33#[derive(Debug, Clone)]
34pub struct ToolsConfig {
35    pub command_timeout: Duration,
36    pub output_limit_bytes: usize,
37    pub max_read_bytes: usize,
38    pub max_write_bytes: usize,
39}
40
41impl Default for ToolsConfig {
42    fn default() -> Self {
43        Self {
44            command_timeout: Duration::from_secs(120),
45            output_limit_bytes: 64 * 1024,
46            max_read_bytes: 256 * 1024,
47            max_write_bytes: 1024 * 1024,
48        }
49    }
50}
51
52#[derive(Debug, Clone)]
53pub struct AgentAdapterConfig {
54    pub command: String,
55    pub args: Vec<String>,
56    /// Arguments appended for a per-call model; `{model}` is substituted.
57    /// Empty means the adapter does not offer model selection.
58    pub model_args: Vec<String>,
59    /// Arguments appended for a per-call effort; `{effort}` is substituted.
60    /// Empty means the adapter does not offer effort selection.
61    pub effort_args: Vec<String>,
62    /// Environment for the nested process. SCV supplies an instance-private home.
63    pub environment: Vec<(OsString, OsString)>,
64}
65
66pub type SkillMap = HashMap<String, PathBuf>;
67
68pub fn builtin_registry(
69    config: ToolsConfig,
70    skills: SkillMap,
71    skill_roots: Vec<PathBuf>,
72    max_skill_bytes: usize,
73    adapters: HashMap<String, AgentAdapterConfig>,
74) -> Result<ToolRegistry, ToolError> {
75    let mut registry = ToolRegistry::default();
76    registry.register(Arc::new(ReadTool {
77        max_bytes: config.max_read_bytes,
78    }))?;
79    registry.register(Arc::new(ReadSkillTool {
80        skills,
81        roots: skill_roots,
82        max_bytes: max_skill_bytes,
83    }))?;
84    registry.register(Arc::new(WriteTool {
85        max_bytes: config.max_write_bytes,
86    }))?;
87    registry.register(Arc::new(BashTool {
88        timeout: config.command_timeout,
89        output_limit: config.output_limit_bytes,
90    }))?;
91    for (name, adapter) in adapters {
92        registry.register(Arc::new(NativeAgentTool::new(
93            name,
94            adapter,
95            config.command_timeout,
96            config.output_limit_bytes,
97        )))?;
98    }
99    Ok(registry)
100}
101
102struct ReadTool {
103    max_bytes: usize,
104}
105
106#[derive(Deserialize)]
107#[serde(deny_unknown_fields)]
108struct ReadArgs {
109    path: String,
110    #[serde(default)]
111    offset: usize,
112    limit: Option<usize>,
113}
114
115#[async_trait]
116impl Tool for ReadTool {
117    fn spec(&self) -> ToolSpec {
118        ToolSpec {
119            name: "read".into(),
120            description: "Read a bounded UTF-8 file inside the workspace".into(),
121            parameters: json!({
122                "type":"object",
123                "properties":{
124                    "path":{"type":"string"},
125                    "offset":{"type":"integer","minimum":0},
126                    "limit":{"type":"integer","minimum":1}
127                },
128                "required":["path"],
129                "additionalProperties":false
130            }),
131        }
132    }
133
134    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
135        let args: ReadArgs = parse_args(arguments)?;
136        validate_read_args(&args)?;
137        Ok(if is_secret_like(Path::new(&args.path)) {
138            ToolRisk::Filesystem
139        } else {
140            ToolRisk::ReadOnly
141        })
142    }
143
144    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
145        let args: ReadArgs = parse_args(arguments)?;
146        validate_read_args(&args)?;
147        Ok(format!("Read {}", args.path))
148    }
149
150    async fn execute(
151        &self,
152        arguments: Value,
153        context: ToolContext,
154    ) -> Result<ToolOutput, ToolError> {
155        let args: ReadArgs = parse_args(&arguments)?;
156        validate_read_args(&args)?;
157        let requested = args.limit.unwrap_or(self.max_bytes).min(self.max_bytes);
158        let offset = u64::try_from(args.offset).unwrap_or(u64::MAX);
159        let workspace = context.workspace.clone();
160        let display_path = args.path.clone();
161        let relative = PathBuf::from(&args.path);
162        validate_relative(&relative)?;
163        let read = tokio::task::spawn_blocking(move || {
164            let root = open_workspace(&workspace)?;
165            let mut file = root
166                .open(&relative)
167                .map_err(|error| map_cap_error("read", &display_path, error))?;
168            let total_bytes = file
169                .metadata()
170                .map_err(|error| ToolError(format!("stat {display_path}: {error}")))?
171                .len();
172            let start = offset.min(total_bytes);
173            std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(start))
174                .map_err(|error| ToolError(format!("seek {display_path}: {error}")))?;
175            let mut bytes = Vec::with_capacity(requested.min(8192));
176            std::io::Read::take(&mut file, u64::try_from(requested).unwrap_or(u64::MAX))
177                .read_to_end(&mut bytes)
178                .map_err(|error| ToolError(format!("read {display_path}: {error}")))?;
179            Ok::<_, ToolError>((bytes, total_bytes, start))
180        });
181        let (bytes, total_bytes, start) = tokio::select! {
182            result = read => result.map_err(|error| ToolError(format!("read task failed: {error}")))??,
183            _ = context.cancellation.cancelled() => return Err(ToolError("read cancelled".into())),
184        };
185        let content = std::str::from_utf8(&bytes)
186            .map_err(|_| ToolError(format!("selected range of {} is not UTF-8", args.path)))?;
187        let end = start.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
188        let truncated = start > 0 || end < total_bytes;
189        Ok(ToolOutput {
190            content: json!({
191                "path": args.path,
192                "content": content,
193                "total_bytes": total_bytes,
194                "offset": start,
195                "truncated": truncated
196            })
197            .to_string(),
198            is_error: false,
199            truncated,
200        })
201    }
202}
203
204struct ReadSkillTool {
205    skills: SkillMap,
206    roots: Vec<PathBuf>,
207    max_bytes: usize,
208}
209
210#[derive(Deserialize)]
211#[serde(deny_unknown_fields)]
212struct ReadSkillArgs {
213    name: String,
214}
215
216#[async_trait]
217impl Tool for ReadSkillTool {
218    fn spec(&self) -> ToolSpec {
219        ToolSpec {
220            name: "read_skill".into(),
221            description: "Load a discovered SCV skill by name".into(),
222            parameters: json!({
223                "type":"object",
224                "properties":{"name":{"type":"string"}},
225                "required":["name"],
226                "additionalProperties":false
227            }),
228        }
229    }
230
231    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
232        let _: ReadSkillArgs = parse_args(arguments)?;
233        Ok(ToolRisk::ReadOnly)
234    }
235
236    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
237        let args: ReadSkillArgs = parse_args(arguments)?;
238        Ok(format!("Load skill {}", args.name))
239    }
240
241    async fn execute(
242        &self,
243        arguments: Value,
244        context: ToolContext,
245    ) -> Result<ToolOutput, ToolError> {
246        let args: ReadSkillArgs = parse_args(&arguments)?;
247        let configured = self
248            .skills
249            .get(&args.name)
250            .ok_or_else(|| ToolError(format!("unknown skill: {}", args.name)))?;
251        let path = std::fs::canonicalize(configured)
252            .map_err(|error| ToolError(format!("load skill {}: {error}", args.name)))?;
253        if !self.roots.iter().any(|root| path.starts_with(root)) {
254            return Err(ToolError("skill path escaped its configured root".into()));
255        }
256        let max_bytes = self.max_bytes;
257        let skill_name = args.name.clone();
258        let bytes = tokio::select! {
259            result = tokio::task::spawn_blocking(move || {
260                let mut file = std::fs::File::open(&path)
261                    .map_err(|error| ToolError(format!("load skill {skill_name}: {error}")))?;
262                let mut bytes = Vec::with_capacity(max_bytes.min(8192));
263                std::io::Read::take(
264                    &mut file,
265                    u64::try_from(max_bytes).unwrap_or(u64::MAX).saturating_add(1),
266                )
267                .read_to_end(&mut bytes)
268                .map_err(|error| ToolError(format!("load skill {skill_name}: {error}")))?;
269                Ok::<_, ToolError>(bytes)
270            }) => result.map_err(|error| ToolError(format!("skill read task failed: {error}")))??,
271            _ = context.cancellation.cancelled() => return Err(ToolError("skill read cancelled".into())),
272        };
273        let end = bytes.len().min(self.max_bytes);
274        let content = std::str::from_utf8(&bytes[..end])
275            .map_err(|_| ToolError("skill is not UTF-8".into()))?;
276        Ok(ToolOutput {
277            content: content.to_owned(),
278            is_error: false,
279            truncated: end < bytes.len(),
280        })
281    }
282}
283
284struct WriteTool {
285    max_bytes: usize,
286}
287
288#[derive(Deserialize)]
289#[serde(deny_unknown_fields)]
290struct WriteArgs {
291    path: String,
292    content: String,
293    mode: WriteMode,
294    expected_sha256: Option<String>,
295}
296
297#[derive(Deserialize)]
298#[serde(rename_all = "snake_case")]
299enum WriteMode {
300    Create,
301    Replace,
302}
303
304#[async_trait]
305impl Tool for WriteTool {
306    fn spec(&self) -> ToolSpec {
307        ToolSpec {
308            name: "write".into(),
309            description: "Atomically create or replace a UTF-8 file inside the workspace".into(),
310            parameters: json!({
311                "type":"object",
312                "properties":{
313                    "path":{"type":"string"},
314                    "content":{"type":"string"},
315                    "mode":{"type":"string","enum":["create","replace"]},
316                    "expected_sha256":{"type":"string"}
317                },
318                "required":["path","content","mode"],
319                "additionalProperties":false
320            }),
321        }
322    }
323
324    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
325        let _: WriteArgs = parse_args(arguments)?;
326        Ok(ToolRisk::Filesystem)
327    }
328
329    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
330        let args: WriteArgs = parse_args(arguments)?;
331        let mode = match args.mode {
332            WriteMode::Create => "Create",
333            WriteMode::Replace => "Replace",
334        };
335        Ok(format!(
336            "{mode} {} ({} bytes)",
337            args.path,
338            args.content.len()
339        ))
340    }
341
342    async fn execute(
343        &self,
344        arguments: Value,
345        context: ToolContext,
346    ) -> Result<ToolOutput, ToolError> {
347        let args: WriteArgs = parse_args(&arguments)?;
348        if args.content.len() > self.max_bytes {
349            return Err(ToolError(format!(
350                "write exceeds {} byte limit",
351                self.max_bytes
352            )));
353        }
354        let workspace = context.workspace.clone();
355        let cancellation = context.cancellation.clone();
356        tokio::task::spawn_blocking(move || {
357            if cancellation.is_cancelled() {
358                return Err(ToolError("write cancelled".into()));
359            }
360            let path = PathBuf::from(&args.path);
361            validate_relative(&path)?;
362            let root = open_workspace(&workspace)?;
363            let exists = match root.symlink_metadata(&path) {
364                Ok(_) => true,
365                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
366                Err(error) => return Err(map_cap_error("inspect", &args.path, error)),
367            };
368            match args.mode {
369                WriteMode::Create if exists => {
370                    return Err(ToolError(format!("{} already exists", args.path)));
371                }
372                WriteMode::Replace if !exists => {
373                    return Err(ToolError(format!("{} does not exist", args.path)));
374                }
375                _ => {}
376            }
377            if let Some(expected) = args.expected_sha256 {
378                let mut current_file = root
379                    .open(&path)
380                    .map_err(|error| map_cap_error("hash", &args.path, error))?;
381                let mut current = Vec::new();
382                current_file
383                    .read_to_end(&mut current)
384                    .map_err(|error| ToolError(format!("hash {}: {error}", args.path)))?;
385                let actual = format!("{:x}", Sha256::digest(current));
386                if actual != expected.to_ascii_lowercase() {
387                    return Err(ToolError(format!(
388                        "{} changed: expected sha256 {}, found {}",
389                        args.path, expected, actual
390                    )));
391                }
392            }
393            let parent = path.parent().unwrap_or_else(|| Path::new("."));
394            root.create_dir_all(parent)
395                .map_err(|error| map_cap_error("create directory for", &args.path, error))?;
396            let temporary_path = unique_temporary_path(parent);
397            let mut options = OpenOptions::new();
398            options.write(true).create_new(true);
399            let mut temporary = root
400                .open_with(&temporary_path, &options)
401                .map_err(|error| map_cap_error("create temporary file for", &args.path, error))?;
402            let write_result = (|| {
403                temporary
404                    .write_all(args.content.as_bytes())
405                    .and_then(|_| temporary.sync_all())
406                    .map_err(|error| ToolError(format!("write {}: {error}", args.path)))?;
407                if cancellation.is_cancelled() {
408                    return Err(ToolError("write cancelled".into()));
409                }
410                match args.mode {
411                    WriteMode::Create => root
412                        .hard_link(&temporary_path, &root, &path)
413                        .map_err(|error| map_cap_error("create", &args.path, error)),
414                    WriteMode::Replace => root
415                        .rename(&temporary_path, &root, &path)
416                        .map_err(|error| map_cap_error("replace", &args.path, error)),
417                }
418            })();
419            if matches!(args.mode, WriteMode::Create) || write_result.is_err() {
420                let _ = root.remove_file(&temporary_path);
421            }
422            write_result?;
423            Ok(ToolOutput::success(
424                json!({
425                    "path":args.path,
426                    "bytes":args.content.len(),
427                    "sha256":format!("{:x}", Sha256::digest(args.content.as_bytes()))
428                })
429                .to_string(),
430            ))
431        })
432        .await
433        .map_err(|error| ToolError(format!("write task failed: {error}")))?
434    }
435}
436
437struct BashTool {
438    timeout: Duration,
439    output_limit: usize,
440}
441
442#[derive(Deserialize)]
443#[serde(deny_unknown_fields)]
444struct BashArgs {
445    command: String,
446    timeout_seconds: Option<u64>,
447}
448
449#[async_trait]
450impl Tool for BashTool {
451    fn spec(&self) -> ToolSpec {
452        ToolSpec {
453            name: "bash".into(),
454            description: "Run a Bash command in the workspace (not sandboxed)".into(),
455            parameters: json!({
456                "type":"object",
457                "properties":{
458                    "command":{"type":"string"},
459                    "timeout_seconds":{"type":"integer","minimum":1}
460                },
461                "required":["command"],
462                "additionalProperties":false
463            }),
464        }
465    }
466
467    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
468        let args: BashArgs = parse_args(arguments)?;
469        validate_process_args(&args.command, args.timeout_seconds)?;
470        Ok(ToolRisk::Process)
471    }
472
473    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
474        let args: BashArgs = parse_args(arguments)?;
475        validate_process_args(&args.command, args.timeout_seconds)?;
476        Ok(format!(
477            "Run with /bin/bash -lc: {}",
478            bounded(&args.command, 2000)
479        ))
480    }
481
482    async fn execute(
483        &self,
484        arguments: Value,
485        context: ToolContext,
486    ) -> Result<ToolOutput, ToolError> {
487        let args: BashArgs = parse_args(&arguments)?;
488        validate_process_args(&args.command, args.timeout_seconds)?;
489        let requested = args
490            .timeout_seconds
491            .map(Duration::from_secs)
492            .unwrap_or(self.timeout)
493            .min(self.timeout);
494        execute_process(
495            ProcessSpec {
496                executable: OsString::from("/bin/bash"),
497                args: vec![OsString::from("-lc"), OsString::from(args.command)],
498                cwd: context.workspace,
499                environment: Vec::new(),
500                sanitize_scv_environment: false,
501                timeout: requested,
502                output_limit: self.output_limit,
503            },
504            context.cancellation,
505        )
506        .await
507    }
508}
509
510struct NativeAgentTool {
511    name: String,
512    command: String,
513    resolved: Option<PathBuf>,
514    args: Vec<String>,
515    model_args: Vec<String>,
516    effort_args: Vec<String>,
517    environment: Vec<(OsString, OsString)>,
518    timeout: Duration,
519    output_limit: usize,
520}
521
522/// Effort levels accepted by the built-in adapters' CLIs.
523const AGENT_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
524
525impl NativeAgentTool {
526    /// The fixed arguments plus validated model and effort selections; the
527    /// prompt is appended separately as the final argument.
528    fn command_args(&self, args: &AgentArgs) -> Result<Vec<String>, ToolError> {
529        validate_process_args(&args.prompt, args.timeout_seconds)?;
530        // The prompt follows the flags as a positional argument, so it must
531        // not be readable as one.
532        if args.prompt.starts_with('-') {
533            return Err(ToolError("agent prompt must not start with '-'".into()));
534        }
535        let mut command = self.args.clone();
536        for (field, value, template, placeholder) in [
537            ("model", &args.model, &self.model_args, "{model}"),
538            ("effort", &args.effort, &self.effort_args, "{effort}"),
539        ] {
540            let Some(value) = value else {
541                continue;
542            };
543            if template.is_empty() {
544                return Err(ToolError(format!(
545                    "{} does not support selecting a {field}",
546                    self.name
547                )));
548            }
549            let valid = if field == "model" {
550                valid_model_name(value)
551            } else {
552                AGENT_EFFORTS.contains(&value.as_str())
553            };
554            if !valid {
555                return Err(ToolError(format!("invalid {field} {value:?}")));
556            }
557            command.extend(template.iter().map(|part| part.replace(placeholder, value)));
558        }
559        Ok(command)
560    }
561    fn new(
562        name: String,
563        config: AgentAdapterConfig,
564        timeout: Duration,
565        output_limit: usize,
566    ) -> Self {
567        let resolved = which::which(&config.command).ok();
568        Self {
569            name,
570            command: config.command,
571            resolved,
572            args: config.args,
573            model_args: config.model_args,
574            effort_args: config.effort_args,
575            environment: config.environment,
576            timeout,
577            output_limit,
578        }
579    }
580}
581
582#[derive(Deserialize)]
583#[serde(deny_unknown_fields)]
584struct AgentArgs {
585    prompt: String,
586    timeout_seconds: Option<u64>,
587    model: Option<String>,
588    effort: Option<String>,
589}
590
591/// Model names are passed as one argument, so only reject values that could
592/// read as a flag, name an `@file` argument, or carry unexpected characters.
593fn valid_model_name(value: &str) -> bool {
594    !value.is_empty()
595        && value.len() <= 128
596        && !value.starts_with(['-', '@'])
597        && value
598            .chars()
599            .all(|c| c.is_ascii_alphanumeric() || "._:/@[]-".contains(c))
600}
601
602#[async_trait]
603impl Tool for NativeAgentTool {
604    fn spec(&self) -> ToolSpec {
605        let mut properties = json!({
606            "prompt":{"type":"string"},
607            "timeout_seconds":{"type":"integer","minimum":1}
608        });
609        if !self.model_args.is_empty() {
610            properties["model"] = json!({
611                "type":"string",
612                "description":"Model alias or ID for this call, e.g. sonnet or opus"
613            });
614        }
615        if !self.effort_args.is_empty() {
616            properties["effort"] = json!({"type":"string","enum":AGENT_EFFORTS});
617        }
618        ToolSpec {
619            name: self.name.clone(),
620            description: format!(
621                "Launch the configured {} CLI as a nested agent (not sandboxed)",
622                self.name
623            ),
624            parameters: json!({
625                "type":"object",
626                "properties":properties,
627                "required":["prompt"],
628                "additionalProperties":false
629            }),
630        }
631    }
632
633    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
634        let args: AgentArgs = parse_args(arguments)?;
635        self.command_args(&args)?;
636        Ok(ToolRisk::Delegate)
637    }
638
639    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
640        let args: AgentArgs = parse_args(arguments)?;
641        let command_args = self.command_args(&args)?;
642        let executable = self.resolved.as_ref().map_or_else(
643            || self.command.as_str().into(),
644            |path| path.display().to_string(),
645        );
646        Ok(format!(
647            "Launch {executable} with args {command_args:?} and prompt {:?}. The nested agent has your user permissions.",
648            bounded(&args.prompt, 2000)
649        ))
650    }
651
652    async fn execute(
653        &self,
654        arguments: Value,
655        context: ToolContext,
656    ) -> Result<ToolOutput, ToolError> {
657        let args: AgentArgs = parse_args(&arguments)?;
658        let command_args = self.command_args(&args)?;
659        let executable = self.resolved.as_ref().ok_or_else(|| {
660            ToolError(format!(
661                "{} executable {:?} was not found in PATH",
662                self.name, self.command
663            ))
664        })?;
665        let mut command_args: Vec<OsString> =
666            command_args.into_iter().map(OsString::from).collect();
667        command_args.push(OsString::from(args.prompt));
668        let requested = args
669            .timeout_seconds
670            .map(Duration::from_secs)
671            .unwrap_or(self.timeout)
672            .min(self.timeout);
673        let mut output = execute_process(
674            ProcessSpec {
675                executable: executable.as_os_str().to_owned(),
676                args: command_args,
677                cwd: context.workspace,
678                environment: self.environment.clone(),
679                sanitize_scv_environment: true,
680                timeout: requested,
681                output_limit: self.output_limit,
682            },
683            context.cancellation,
684        )
685        .await?;
686        if output.is_error {
687            add_sign_in_hint(&mut output, self.name.trim_start_matches("agent_"));
688        }
689        Ok(output)
690    }
691}
692
693/// Variables removed from every native agent's environment, so an agent
694/// signs in only with credentials stored in its SCV-private home and never
695/// inherits SCV's provider settings or a config location outside that home.
696pub const AGENT_REMOVED_ENVIRONMENT: &[&str] = &[
697    "SCV_CONFIG",
698    "SCV_MODEL",
699    "SCV_PROVIDER",
700    "SCV_BASE_URL",
701    "SCV_API_KEY_ENV",
702    "OPENAI_API_KEY",
703    "OPENAI_BASE_URL",
704    "OPENAI_ORG_ID",
705    "OPENAI_PROJECT_ID",
706    "CODEX_API_KEY",
707    "CODEX_BASE_URL",
708    "ANTHROPIC_API_KEY",
709    "ANTHROPIC_BASE_URL",
710    "ANTHROPIC_AUTH_TOKEN",
711    "CLAUDE_CODE_OAUTH_TOKEN",
712    "CLAUDE_CONFIG_DIR",
713    "GEMINI_API_KEY",
714    "GOOGLE_API_KEY",
715    "AZURE_OPENAI_API_KEY",
716    "AZURE_OPENAI_ENDPOINT",
717];
718
719/// Point a failed agent run that reads like a missing sign-in at the host
720/// command that fixes it, since the agent's own advice (`/login`) cannot be
721/// followed from a remote chat.
722fn add_sign_in_hint(output: &mut ToolOutput, agent: &str) {
723    let lower = output.content.to_ascii_lowercase();
724    let unauthenticated = [
725        "not logged in",
726        "/login",
727        "codex login",
728        "log in",
729        "unauthorized",
730        "authentication",
731    ]
732    .iter()
733    .any(|needle| lower.contains(needle));
734    if !unauthenticated {
735        return;
736    }
737    if let Ok(Value::Object(mut content)) = serde_json::from_str::<Value>(&output.content) {
738        content.insert(
739            "hint".into(),
740            format!(
741                "The {agent} CLI appears to be signed out of SCV's private agent home. \
742                 The host owner can sign it in with: scv agents login {agent}"
743            )
744            .into(),
745        );
746        output.content = Value::Object(content).to_string();
747    }
748}
749
750struct ProcessSpec {
751    executable: OsString,
752    args: Vec<OsString>,
753    cwd: PathBuf,
754    environment: Vec<(OsString, OsString)>,
755    sanitize_scv_environment: bool,
756    timeout: Duration,
757    output_limit: usize,
758}
759
760async fn execute_process(
761    spec: ProcessSpec,
762    cancellation: tokio_util::sync::CancellationToken,
763) -> Result<ToolOutput, ToolError> {
764    let deadline = Instant::now() + spec.timeout;
765    let mut command = Command::new(&spec.executable);
766    command
767        .args(&spec.args)
768        .current_dir(&spec.cwd)
769        .envs(spec.environment)
770        .stdin(std::process::Stdio::null())
771        .stdout(std::process::Stdio::piped())
772        .stderr(std::process::Stdio::piped())
773        .kill_on_drop(true);
774    if spec.sanitize_scv_environment {
775        for variable in AGENT_REMOVED_ENVIRONMENT {
776            command.env_remove(variable);
777        }
778    }
779    command.as_std_mut().process_group(0);
780    let mut child = command
781        .spawn()
782        .map_err(|error| ToolError(format!("launch {:?}: {error}", spec.executable)))?;
783    let pid = child
784        .id()
785        .ok_or_else(|| ToolError("child process has no pid".into()))? as i32;
786    let output = Arc::new(Mutex::new(BoundedOutput::new(spec.output_limit)));
787    let stdout_task = child.stdout.take().map(|stdout| {
788        let output = Arc::clone(&output);
789        tokio::spawn(drain_output(stdout, output))
790    });
791    let stderr_task = child.stderr.take().map(|stderr| {
792        let output = Arc::clone(&output);
793        tokio::spawn(drain_output(stderr, output))
794    });
795
796    enum Completion {
797        Exited(std::process::ExitStatus),
798        TimedOut,
799        Cancelled,
800    }
801    let completion = tokio::select! {
802        status = child.wait() => Completion::Exited(status.map_err(|error| ToolError(format!("wait for child: {error}")))?),
803        _ = cancellation.cancelled() => {
804            Completion::Cancelled
805        },
806        _ = sleep_until(deadline) => Completion::TimedOut,
807    };
808
809    let (status, timed_out, drain_deadline) = match completion {
810        Completion::Exited(status) => {
811            let cleanup_deadline = deadline.min(Instant::now() + Duration::from_secs(2));
812            let status =
813                terminate_group(pid, &mut child, Some(status), cleanup_deadline, true).await?;
814            (
815                status,
816                false,
817                deadline.min(Instant::now() + Duration::from_millis(250)),
818            )
819        }
820        Completion::TimedOut => {
821            let status = terminate_group(pid, &mut child, None, Instant::now(), false).await?;
822            (status, true, Instant::now() + Duration::from_millis(250))
823        }
824        Completion::Cancelled => {
825            let cleanup_deadline = Instant::now() + Duration::from_secs(2);
826            let _ = terminate_group(pid, &mut child, None, cleanup_deadline, true).await;
827            finish_drain(stdout_task, Instant::now() + Duration::from_millis(250)).await;
828            finish_drain(stderr_task, Instant::now() + Duration::from_millis(250)).await;
829            return Err(ToolError("process cancelled".into()));
830        }
831    };
832    finish_drain(stdout_task, drain_deadline).await;
833    finish_drain(stderr_task, drain_deadline).await;
834    let collected = output.lock().await;
835    let text = String::from_utf8_lossy(&collected.bytes).into_owned();
836    let content = json!({
837        "exit_code": status.code(),
838        "timed_out": timed_out,
839        "output": text,
840        "truncated": collected.truncated
841    })
842    .to_string();
843    Ok(ToolOutput {
844        content,
845        is_error: timed_out || !status.success(),
846        truncated: collected.truncated,
847    })
848}
849
850async fn terminate_group(
851    pid: i32,
852    child: &mut tokio::process::Child,
853    mut status: Option<std::process::ExitStatus>,
854    deadline: Instant,
855    graceful: bool,
856) -> Result<std::process::ExitStatus, ToolError> {
857    signal_group(
858        pid,
859        if graceful {
860            libc::SIGTERM
861        } else {
862            libc::SIGKILL
863        },
864    );
865    while Instant::now() < deadline {
866        if status.is_none() {
867            status = child
868                .try_wait()
869                .map_err(|error| ToolError(format!("wait for child: {error}")))?;
870        }
871        if !process_group_exists(pid)
872            && let Some(status) = status
873        {
874            return Ok(status);
875        }
876        sleep(Duration::from_millis(20)).await;
877    }
878    // Always finish the process group, even if its original leader already exited.
879    signal_group(pid, libc::SIGKILL);
880    if let Some(status) = status {
881        return Ok(status);
882    }
883    timeout(Duration::from_secs(1), child.wait())
884        .await
885        .map_err(|_| ToolError("child did not exit after process-group kill".into()))?
886        .map_err(|error| ToolError(format!("wait after KILL: {error}")))
887}
888
889fn signal_group(pid: i32, signal: i32) {
890    // Negative PID addresses the process group created at spawn.
891    unsafe {
892        libc::kill(-pid, signal);
893    }
894}
895
896fn process_group_exists(pid: i32) -> bool {
897    let result = unsafe { libc::kill(-pid, 0) };
898    result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
899}
900
901async fn finish_drain(task: Option<JoinHandle<()>>, deadline: Instant) {
902    let Some(mut task) = task else { return };
903    if timeout_at(deadline, &mut task).await.is_err() {
904        task.abort();
905        let _ = task.await;
906    }
907}
908
909async fn drain_output<R>(mut reader: R, output: Arc<Mutex<BoundedOutput>>)
910where
911    R: tokio::io::AsyncRead + Unpin,
912{
913    let mut chunk = [0u8; 8192];
914    loop {
915        match reader.read(&mut chunk).await {
916            Ok(0) | Err(_) => break,
917            Ok(read) => output.lock().await.push(&chunk[..read]),
918        }
919    }
920}
921
922struct BoundedOutput {
923    bytes: Vec<u8>,
924    limit: usize,
925    truncated: bool,
926}
927
928impl BoundedOutput {
929    fn new(limit: usize) -> Self {
930        Self {
931            bytes: Vec::with_capacity(limit.min(8192)),
932            limit,
933            truncated: false,
934        }
935    }
936
937    fn push(&mut self, bytes: &[u8]) {
938        let remaining = self.limit.saturating_sub(self.bytes.len());
939        self.bytes
940            .extend_from_slice(&bytes[..bytes.len().min(remaining)]);
941        self.truncated |= bytes.len() > remaining;
942    }
943}
944
945fn parse_args<T: for<'de> Deserialize<'de>>(value: &Value) -> Result<T, ToolError> {
946    serde_json::from_value(value.clone())
947        .map_err(|error| ToolError(format!("invalid arguments: {error}")))
948}
949
950fn validate_read_args(args: &ReadArgs) -> Result<(), ToolError> {
951    if args.limit == Some(0) {
952        return Err(ToolError("read limit must be positive".into()));
953    }
954    Ok(())
955}
956
957fn validate_process_args(value: &str, timeout_seconds: Option<u64>) -> Result<(), ToolError> {
958    if value.trim().is_empty() {
959        return Err(ToolError("command or prompt must be non-empty".into()));
960    }
961    if timeout_seconds == Some(0) {
962        return Err(ToolError("timeout_seconds must be positive".into()));
963    }
964    Ok(())
965}
966
967fn validate_relative(path: &Path) -> Result<(), ToolError> {
968    if path.as_os_str().is_empty() || path.is_absolute() {
969        return Err(ToolError("path must be non-empty and relative".into()));
970    }
971    for component in path.components() {
972        if matches!(
973            component,
974            Component::ParentDir | Component::RootDir | Component::Prefix(_)
975        ) {
976            return Err(ToolError(
977                "parent traversal and absolute paths are not allowed".into(),
978            ));
979        }
980    }
981    Ok(())
982}
983
984static TEMPORARY_COUNTER: AtomicU64 = AtomicU64::new(0);
985
986fn open_workspace(workspace: &Path) -> Result<Dir, ToolError> {
987    Dir::open_ambient_dir(workspace, ambient_authority())
988        .map_err(|error| ToolError(format!("open workspace capability: {error}")))
989}
990
991fn unique_temporary_path(parent: &Path) -> PathBuf {
992    let id = TEMPORARY_COUNTER.fetch_add(1, Ordering::Relaxed);
993    parent.join(format!(".scv-write-{}-{id}.tmp", std::process::id()))
994}
995
996fn map_cap_error(action: &str, path: &str, error: std::io::Error) -> ToolError {
997    ToolError(format!(
998        "{action} {path}: {error}; path must remain within workspace"
999    ))
1000}
1001
1002fn is_secret_like(path: &Path) -> bool {
1003    path.components().any(|component| {
1004        let value = component.as_os_str().to_string_lossy().to_ascii_lowercase();
1005        value == ".env"
1006            || value.starts_with(".env.")
1007            || value.contains("credential")
1008            || value.contains("private_key")
1009            || value.ends_with(".pem")
1010            || value.ends_with(".key")
1011    })
1012}
1013
1014fn bounded(value: &str, max_chars: usize) -> String {
1015    let mut output: String = value.chars().take(max_chars).collect();
1016    if value.chars().count() > max_chars {
1017        output.push('…');
1018    }
1019    output
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024    use std::os::unix::fs::symlink;
1025
1026    use super::*;
1027
1028    #[test]
1029    fn rejects_parent_traversal() {
1030        assert!(validate_relative(Path::new("../secret")).is_err());
1031        assert!(validate_relative(Path::new("/etc/passwd")).is_err());
1032    }
1033
1034    #[test]
1035    fn detects_secret_like_paths() {
1036        assert!(is_secret_like(Path::new(".env")));
1037        assert!(is_secret_like(Path::new("keys/id.pem")));
1038        assert!(!is_secret_like(Path::new("src/main.rs")));
1039    }
1040
1041    #[tokio::test]
1042    async fn read_is_contained_and_bounded() {
1043        let directory = tempfile::tempdir().unwrap();
1044        std::fs::write(directory.path().join("hello.txt"), "abcdef").unwrap();
1045        let tool = ReadTool { max_bytes: 3 };
1046        let output = tool
1047            .execute(
1048                json!({"path":"hello.txt"}),
1049                ToolContext {
1050                    workspace: directory.path().canonicalize().unwrap(),
1051                    cancellation: tokio_util::sync::CancellationToken::new(),
1052                },
1053            )
1054            .await
1055            .unwrap();
1056        assert!(output.truncated);
1057        assert!(output.content.contains("abc"));
1058    }
1059
1060    #[tokio::test]
1061    async fn read_rejects_symlink_escape() {
1062        let workspace = tempfile::tempdir().unwrap();
1063        let outside = tempfile::tempdir().unwrap();
1064        std::fs::write(outside.path().join("secret"), "nope").unwrap();
1065        symlink(outside.path(), workspace.path().join("escape")).unwrap();
1066        let tool = ReadTool { max_bytes: 100 };
1067        let result = tool
1068            .execute(
1069                json!({"path":"escape/secret"}),
1070                ToolContext {
1071                    workspace: workspace.path().canonicalize().unwrap(),
1072                    cancellation: tokio_util::sync::CancellationToken::new(),
1073                },
1074            )
1075            .await;
1076        assert!(result.unwrap_err().to_string().contains("workspace"));
1077    }
1078
1079    #[tokio::test]
1080    async fn write_is_atomic_and_checks_hash() {
1081        let workspace = tempfile::tempdir().unwrap();
1082        let root = workspace.path().canonicalize().unwrap();
1083        let tool = WriteTool { max_bytes: 100 };
1084        tool.execute(
1085            json!({"path":"file.txt","content":"first","mode":"create"}),
1086            ToolContext {
1087                workspace: root.clone(),
1088                cancellation: tokio_util::sync::CancellationToken::new(),
1089            },
1090        )
1091        .await
1092        .unwrap();
1093        let hash = format!("{:x}", Sha256::digest(b"first"));
1094        tool.execute(
1095            json!({"path":"file.txt","content":"second","mode":"replace","expected_sha256":hash}),
1096            ToolContext {
1097                workspace: root.clone(),
1098                cancellation: tokio_util::sync::CancellationToken::new(),
1099            },
1100        )
1101        .await
1102        .unwrap();
1103        assert_eq!(
1104            std::fs::read_to_string(root.join("file.txt")).unwrap(),
1105            "second"
1106        );
1107        let result = tool
1108            .execute(
1109                json!({"path":"file.txt","content":"third","mode":"replace","expected_sha256":"deadbeef"}),
1110                ToolContext {
1111                    workspace: root,
1112                    cancellation: tokio_util::sync::CancellationToken::new(),
1113                },
1114            )
1115            .await;
1116        assert!(result.unwrap_err().to_string().contains("changed"));
1117    }
1118
1119    #[tokio::test]
1120    async fn write_rejects_symlink_escape() {
1121        let workspace = tempfile::tempdir().unwrap();
1122        let outside = tempfile::tempdir().unwrap();
1123        symlink(outside.path(), workspace.path().join("escape")).unwrap();
1124        let tool = WriteTool { max_bytes: 100 };
1125        let result = tool
1126            .execute(
1127                json!({"path":"escape/file.txt","content":"nope","mode":"create"}),
1128                ToolContext {
1129                    workspace: workspace.path().canonicalize().unwrap(),
1130                    cancellation: tokio_util::sync::CancellationToken::new(),
1131                },
1132            )
1133            .await;
1134        assert!(result.unwrap_err().to_string().contains("workspace"));
1135        assert!(!outside.path().join("file.txt").exists());
1136    }
1137
1138    #[tokio::test]
1139    async fn bash_timeout_terminates_the_process() {
1140        let workspace = tempfile::tempdir().unwrap();
1141        let tool = BashTool {
1142            timeout: Duration::from_millis(50),
1143            output_limit: 100,
1144        };
1145        let started = std::time::Instant::now();
1146        let output = tool
1147            .execute(
1148                json!({"command":"sleep 5"}),
1149                ToolContext {
1150                    workspace: workspace.path().canonicalize().unwrap(),
1151                    cancellation: tokio_util::sync::CancellationToken::new(),
1152                },
1153            )
1154            .await
1155            .unwrap();
1156        assert!(output.is_error);
1157        assert!(started.elapsed() < Duration::from_secs(3));
1158    }
1159
1160    #[tokio::test]
1161    async fn bash_output_is_bounded_and_reports_truncation() {
1162        let workspace = tempfile::tempdir().unwrap();
1163        let tool = BashTool {
1164            timeout: Duration::from_secs(2),
1165            output_limit: 8,
1166        };
1167        let output = tool
1168            .execute(
1169                json!({"command":"printf 12345678901234567890"}),
1170                ToolContext {
1171                    workspace: workspace.path().canonicalize().unwrap(),
1172                    cancellation: tokio_util::sync::CancellationToken::new(),
1173                },
1174            )
1175            .await
1176            .unwrap();
1177        assert!(output.truncated);
1178        assert!(output.content.contains("12345678"));
1179        assert!(!output.content.contains("123456789"));
1180    }
1181
1182    #[tokio::test]
1183    async fn bash_cancellation_terminates_the_process_group() {
1184        let workspace = tempfile::tempdir().unwrap();
1185        let tool = BashTool {
1186            timeout: Duration::from_secs(30),
1187            output_limit: 100,
1188        };
1189        let cancellation = tokio_util::sync::CancellationToken::new();
1190        let cancel = cancellation.clone();
1191        let started = std::time::Instant::now();
1192        let execution = tokio::spawn(async move {
1193            tool.execute(
1194                json!({"command":"sleep 30"}),
1195                ToolContext {
1196                    workspace: workspace.path().canonicalize().unwrap(),
1197                    cancellation,
1198                },
1199            )
1200            .await
1201        });
1202        tokio::time::sleep(Duration::from_millis(50)).await;
1203        cancel.cancel();
1204        let error = execution.await.unwrap().unwrap_err();
1205        assert!(error.to_string().contains("cancelled"));
1206        assert!(started.elapsed() < Duration::from_secs(3));
1207    }
1208
1209    #[tokio::test]
1210    async fn background_descendant_cannot_hold_output_pipes_open() {
1211        let workspace = tempfile::tempdir().unwrap();
1212        let root = workspace.path().canonicalize().unwrap();
1213        let tool = BashTool {
1214            timeout: Duration::from_secs(5),
1215            output_limit: 100,
1216        };
1217        let started = std::time::Instant::now();
1218        let output = tool
1219            .execute(
1220                json!({"command":"sleep 30 & echo $! > background.pid; exit 0"}),
1221                ToolContext {
1222                    workspace: root.clone(),
1223                    cancellation: tokio_util::sync::CancellationToken::new(),
1224                },
1225            )
1226            .await
1227            .unwrap();
1228        assert!(!output.is_error);
1229        assert!(started.elapsed() < Duration::from_secs(3));
1230        let pid: i32 = std::fs::read_to_string(root.join("background.pid"))
1231            .unwrap()
1232            .trim()
1233            .parse()
1234            .unwrap();
1235        for _ in 0..20 {
1236            if unsafe { libc::kill(pid, 0) } != 0 {
1237                return;
1238            }
1239            tokio::time::sleep(Duration::from_millis(10)).await;
1240        }
1241        panic!("background descendant {pid} survived tool completion");
1242    }
1243
1244    #[tokio::test]
1245    async fn cancellation_kills_a_term_ignoring_descendant() {
1246        let workspace = tempfile::tempdir().unwrap();
1247        let root = workspace.path().canonicalize().unwrap();
1248        let tool = BashTool {
1249            timeout: Duration::from_secs(30),
1250            output_limit: 100,
1251        };
1252        let cancellation = tokio_util::sync::CancellationToken::new();
1253        let cancel = cancellation.clone();
1254        let command_root = root.clone();
1255        let execution = tokio::spawn(async move {
1256            tool.execute(
1257                json!({"command":"trap '' TERM; (trap '' TERM; sleep 30) & echo $! > stubborn.pid; wait"}),
1258                ToolContext {
1259                    workspace: command_root,
1260                    cancellation,
1261                },
1262            )
1263            .await
1264        });
1265        let pid_path = root.join("stubborn.pid");
1266        let mut descendant_pid = None;
1267        for _ in 0..100 {
1268            descendant_pid = std::fs::read_to_string(&pid_path)
1269                .ok()
1270                .and_then(|value| value.trim().parse::<i32>().ok());
1271            if descendant_pid.is_some() {
1272                break;
1273            }
1274            tokio::time::sleep(Duration::from_millis(10)).await;
1275        }
1276        let pid = descendant_pid.expect("command did not report its descendant pid");
1277        let started = std::time::Instant::now();
1278        cancel.cancel();
1279        let error = execution.await.unwrap().unwrap_err();
1280        assert!(error.to_string().contains("cancelled"));
1281        assert!(started.elapsed() < Duration::from_secs(3));
1282        for _ in 0..20 {
1283            if unsafe { libc::kill(pid, 0) } != 0 {
1284                return;
1285            }
1286            tokio::time::sleep(Duration::from_millis(10)).await;
1287        }
1288        panic!("TERM-ignoring descendant {pid} survived cancellation");
1289    }
1290
1291    /// Fake agents run through `bash` so no test ever executes a file that a
1292    /// concurrently forked test process may still hold open for writing
1293    /// (which fails spawning with ETXTBSY).
1294    fn fake_agent(
1295        workspace: &Path,
1296        name: &str,
1297        script: &str,
1298        args: &[&str],
1299        environment: Vec<(OsString, OsString)>,
1300    ) -> NativeAgentTool {
1301        let script_path = workspace.join("fake-agent.sh");
1302        std::fs::write(&script_path, script).unwrap();
1303        let mut fixed = vec![script_path.display().to_string()];
1304        fixed.extend(args.iter().map(|arg| arg.to_string()));
1305        NativeAgentTool::new(
1306            name.into(),
1307            AgentAdapterConfig {
1308                command: "bash".into(),
1309                args: fixed,
1310                model_args: vec!["--model".into(), "{model}".into()],
1311                effort_args: vec!["--effort".into(), "{effort}".into()],
1312                environment,
1313            },
1314            Duration::from_secs(2),
1315            1024,
1316        )
1317    }
1318
1319    fn context(workspace: &Path) -> ToolContext {
1320        ToolContext {
1321            workspace: workspace.canonicalize().unwrap(),
1322            cancellation: tokio_util::sync::CancellationToken::new(),
1323        }
1324    }
1325
1326    #[tokio::test]
1327    async fn native_agent_preserves_argument_boundaries() {
1328        let workspace = tempfile::tempdir().unwrap();
1329        let tool = fake_agent(
1330            workspace.path(),
1331            "agent_fake",
1332            "pwd\nprintf '%s\\n' \"$@\"\n",
1333            &["--fixed"],
1334            Vec::new(),
1335        );
1336        let output = tool
1337            .execute(
1338                json!({"prompt":"hello; echo unsafe"}),
1339                context(workspace.path()),
1340            )
1341            .await
1342            .unwrap();
1343        assert!(output.content.contains("--fixed"));
1344        assert!(output.content.contains("hello; echo unsafe"));
1345        assert!(
1346            output
1347                .content
1348                .contains(&workspace.path().display().to_string())
1349        );
1350    }
1351
1352    #[tokio::test]
1353    async fn native_agent_maps_model_and_effort_to_adapter_flags() {
1354        let workspace = tempfile::tempdir().unwrap();
1355        let tool = fake_agent(
1356            workspace.path(),
1357            "agent_claude",
1358            "printf '%s\\n' \"$@\"\n",
1359            &["-p"],
1360            Vec::new(),
1361        );
1362        let properties = &tool.spec().parameters["properties"];
1363        assert_eq!(properties["effort"]["enum"], json!(AGENT_EFFORTS));
1364        assert_eq!(properties["model"]["type"], "string");
1365        let arguments = json!({"prompt":"hi","model":"sonnet","effort":"medium"});
1366        assert!(
1367            tool.approval_summary(&arguments)
1368                .unwrap()
1369                .contains(r#""--model", "sonnet", "--effort", "medium""#)
1370        );
1371        let output = tool
1372            .execute(arguments, context(workspace.path()))
1373            .await
1374            .unwrap();
1375        let output: Value = serde_json::from_str(&output.content).unwrap();
1376        assert_eq!(
1377            output["output"],
1378            "-p\n--model\nsonnet\n--effort\nmedium\nhi\n"
1379        );
1380        for invalid in [
1381            json!({"prompt":"hi","model":"--dangerously-skip-permissions"}),
1382            json!({"prompt":"hi","model":"sonnet medium"}),
1383            json!({"prompt":"hi","effort":"extreme"}),
1384            json!({"prompt":"hi","model":"@/etc/passwd"}),
1385            json!({"prompt":"--resume"}),
1386        ] {
1387            assert!(tool.risk(&invalid).is_err());
1388        }
1389        let fixed_only = NativeAgentTool::new(
1390            "agent_pi".into(),
1391            AgentAdapterConfig {
1392                command: "pi".into(),
1393                args: vec!["-p".into()],
1394                model_args: Vec::new(),
1395                effort_args: Vec::new(),
1396                environment: Vec::new(),
1397            },
1398            Duration::from_secs(2),
1399            1024,
1400        );
1401        assert!(
1402            fixed_only.spec().parameters["properties"]
1403                .get("model")
1404                .is_none()
1405        );
1406        let error = fixed_only
1407            .risk(&json!({"prompt":"hi","model":"sonnet"}))
1408            .unwrap_err();
1409        assert!(
1410            error
1411                .to_string()
1412                .contains("does not support selecting a model")
1413        );
1414    }
1415
1416    #[tokio::test]
1417    async fn signed_out_agent_failure_names_the_host_login_command() {
1418        let workspace = tempfile::tempdir().unwrap();
1419        let tool = fake_agent(
1420            workspace.path(),
1421            "agent_claude",
1422            "echo 'Not logged in · Please run /login'\nexit 1\n",
1423            &[],
1424            Vec::new(),
1425        );
1426        let output = tool
1427            .execute(json!({"prompt":"hi"}), context(workspace.path()))
1428            .await
1429            .unwrap();
1430        assert!(output.is_error);
1431        let content: Value = serde_json::from_str(&output.content).unwrap();
1432        assert!(
1433            content["hint"]
1434                .as_str()
1435                .unwrap()
1436                .ends_with("scv agents login claude")
1437        );
1438        let other = fake_agent(
1439            workspace.path(),
1440            "agent_claude",
1441            "echo 'disk full'\nexit 1\n",
1442            &[],
1443            Vec::new(),
1444        );
1445        let output = other
1446            .execute(json!({"prompt":"hi"}), context(workspace.path()))
1447            .await
1448            .unwrap();
1449        assert!(output.is_error);
1450        assert!(!output.content.contains("hint"));
1451    }
1452
1453    #[tokio::test]
1454    async fn native_agent_uses_instance_private_environment() {
1455        let workspace = tempfile::tempdir().unwrap();
1456        let home = workspace.path().join("private-home");
1457        let tool = fake_agent(
1458            workspace.path(),
1459            "agent_codex",
1460            "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",
1461            &[],
1462            vec![
1463                ("HOME".into(), home.clone().into()),
1464                ("SCV_HOME".into(), home.clone().into()),
1465                ("CODEX_HOME".into(), home.join("codex").into()),
1466            ],
1467        );
1468        let output = tool
1469            .execute(
1470                json!({"prompt":"print environment"}),
1471                context(workspace.path()),
1472            )
1473            .await
1474            .unwrap();
1475        assert!(output.content.contains(&format!("HOME={}", home.display())));
1476        assert!(
1477            output
1478                .content
1479                .contains(&format!("CODEX_HOME={}/codex", home.display()))
1480        );
1481        assert!(output.content.contains("SCV_CONFIG=unset"));
1482        assert!(output.content.contains("OPENAI_API_KEY=unset"));
1483        assert!(output.content.contains("CODEX_API_KEY=unset"));
1484    }
1485}