1use 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 pub model_args: Vec<String>,
59 pub effort_args: Vec<String>,
62 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
522const AGENT_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
524
525impl NativeAgentTool {
526 fn command_args(&self, args: &AgentArgs) -> Result<Vec<String>, ToolError> {
529 validate_process_args(&args.prompt, args.timeout_seconds)?;
530 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
591fn 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
693pub 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
719fn 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 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 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 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}