1use std::{path::PathBuf, sync::Arc};
4
5use serde::{Deserialize, Serialize};
6use starweaver_envd::LocalEnvd;
7use starweaver_envd_client::EnvdRpcClient;
8use starweaver_envd_core::DEFAULT_ENVIRONMENT_ID;
9use starweaver_environment::{
10 CompositeEnvironmentProvider, DynEnvironmentProvider, DynProcessShellProvider,
11 EnvdEnvironmentProvider, EnvironmentMount, EnvironmentMountMode, EnvironmentPolicy, FilePolicy,
12 LocalEnvironmentProvider, ShellPolicy, VirtualEnvironmentProvider,
13};
14
15use crate::{CliConfig, CliError, CliResult};
16
17pub(crate) const LOCAL_ENVIRONMENT_ATTACHMENT_ID: &str = "local";
18pub(crate) const LOCAL_ENVIRONMENT_ATTACHMENT_KIND: &str = "local";
19
20#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(rename_all = "snake_case")]
23pub enum EnvironmentAttachmentAccessMode {
24 ReadOnly,
26 #[default]
28 ReadWrite,
29}
30
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34pub(crate) struct EnvironmentAttachmentRef {
35 pub id: String,
36 #[serde(default = "default_environment_attachment_kind")]
37 pub kind: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub mode: Option<EnvironmentAttachmentAccessMode>,
40 #[serde(default, rename = "default")]
41 pub is_default: bool,
42 #[serde(default, rename = "defaultForShell")]
43 pub is_default_for_shell: bool,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub endpoint_ref: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub environment_id: Option<String>,
48 #[serde(default, skip_serializing)]
49 pub auth_token: Option<String>,
50 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
51 pub metadata: serde_json::Map<String, serde_json::Value>,
52}
53
54impl EnvironmentAttachmentRef {
55 pub(crate) fn resolved_mode(&self) -> EnvironmentAttachmentAccessMode {
56 self.mode.unwrap_or_default()
57 }
58
59 pub(crate) fn requested_environment_id(&self) -> Option<&str> {
60 self.environment_id.as_deref()
61 }
62
63 pub(crate) fn requested_endpoint_ref(&self) -> Option<&str> {
64 self.endpoint_ref.as_deref()
65 }
66
67 pub(crate) fn requested_auth_token(&self) -> Option<&str> {
68 self.auth_token.as_deref()
69 }
70}
71
72pub(crate) fn is_valid_environment_attachment_id(id: &str) -> bool {
73 !id.is_empty()
74 && !matches!(id, "." | ".." | "environment")
75 && id
76 .bytes()
77 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
78}
79
80fn default_environment_attachment_kind() -> String {
81 LOCAL_ENVIRONMENT_ATTACHMENT_KIND.to_string()
82}
83
84#[derive(Clone)]
86pub struct ResolvedEnvironment {
87 pub provider: DynEnvironmentProvider,
89 pub process_provider: Option<DynProcessShellProvider>,
91 pub attachments: Vec<EnvironmentAttachmentRef>,
93}
94
95pub fn validate_environment_config(config: &CliConfig) -> CliResult<()> {
97 let _policy = environment_policy(config)?;
98 validate_environment_provider(config.environment_provider.as_str())
99}
100
101#[cfg(test)]
103pub fn resolve_environment(config: &CliConfig) -> CliResult<ResolvedEnvironment> {
104 resolve_environment_with_tmp_namespace(config, None)
105}
106
107pub fn resolve_environment_for_session(
109 config: &CliConfig,
110 session_id: &str,
111) -> CliResult<ResolvedEnvironment> {
112 resolve_environment_with_tmp_namespace(config, Some(session_id))
113}
114
115pub fn resolve_environment_for_session_with_attachments(
117 config: &CliConfig,
118 session_id: &str,
119 attachments: &[EnvironmentAttachmentRef],
120) -> CliResult<ResolvedEnvironment> {
121 resolve_environment_target_for_session_with_attachments(config, session_id, attachments)
122}
123
124pub fn resolve_environment_target_for_session_with_attachments(
126 config: &CliConfig,
127 session_id: &str,
128 attachments: &[EnvironmentAttachmentRef],
129) -> CliResult<ResolvedEnvironment> {
130 if attachments.is_empty() {
131 let mut resolved = resolve_environment_for_session(config, session_id)?;
132 resolved.attachments = vec![default_local_attachment()];
133 return Ok(resolved);
134 }
135 let default_id = default_attachment_id(attachments);
136 let default_shell_id = default_shell_attachment_id(attachments, default_id);
137 let mut effective_attachments = attachments.to_vec();
138 for attachment in &mut effective_attachments {
139 attachment.is_default = default_id == Some(attachment.id.as_str());
140 attachment.is_default_for_shell = default_shell_id == Some(attachment.id.as_str());
141 }
142 let mut mounts = Vec::new();
143 for attachment in &effective_attachments {
144 let resolved = resolve_environment_attachment(config, Some(session_id), attachment)?;
145 mounts.push(
146 EnvironmentMount::new(&attachment.id, resolved.provider)
147 .map_err(|error| CliError::Config(error.to_string()))?
148 .with_mode(environment_mount_mode(attachment.resolved_mode()))
149 .with_default(attachment.is_default)
150 .with_default_for_shell(attachment.is_default_for_shell),
151 );
152 }
153 let provider: DynEnvironmentProvider = Arc::new(
154 CompositeEnvironmentProvider::new(mounts)
155 .map_err(|error| CliError::Config(error.to_string()))?,
156 );
157 let process_provider = provider.clone().process_shell_provider();
158 Ok(ResolvedEnvironment {
159 provider,
160 process_provider,
161 attachments: effective_attachments,
162 })
163}
164
165fn default_attachment_id(attachments: &[EnvironmentAttachmentRef]) -> Option<&str> {
166 if attachments.len() == 1 {
167 return Some(attachments[0].id.as_str());
168 }
169 attachments
170 .iter()
171 .find(|attachment| attachment.is_default)
172 .map(|attachment| attachment.id.as_str())
173}
174
175fn default_shell_attachment_id<'a>(
176 attachments: &'a [EnvironmentAttachmentRef],
177 default_id: Option<&'a str>,
178) -> Option<&'a str> {
179 if let Some(explicit) = attachments
180 .iter()
181 .find(|attachment| attachment.is_default_for_shell)
182 .map(|attachment| attachment.id.as_str())
183 {
184 return Some(explicit);
185 }
186 let default_id = default_id?;
187 attachments
188 .iter()
189 .find(|attachment| attachment.id == default_id && attachment_supports_shell(attachment))
190 .map(|attachment| attachment.id.as_str())
191}
192
193fn resolve_environment_with_tmp_namespace(
194 config: &CliConfig,
195 tmp_namespace: Option<&str>,
196) -> CliResult<ResolvedEnvironment> {
197 validate_environment_config(config)?;
198 let policy = environment_policy(config)?;
199 let provider: DynEnvironmentProvider = match config.environment_provider.as_str() {
200 "local" => {
201 let mut provider = LocalEnvironmentProvider::new(config.workspace_root.clone())
202 .with_id("cli-local")
203 .with_allowed_paths(local_allowed_paths(config))
204 .with_context_file_tree_roots([config.workspace_root.clone()])
205 .with_policy(policy);
206 if let Some(namespace) = tmp_namespace {
207 provider = provider.with_tmp_namespace(namespace);
208 }
209 envd_backed_provider(Arc::new(provider), "cli-local")
210 }
211 "virtual" => {
212 let mut provider = VirtualEnvironmentProvider::new("cli-virtual")
213 .with_policy(policy)
214 .with_file("README.md", "Virtual Starweaver CLI workspace");
215 if let Some(namespace) = tmp_namespace {
216 provider = provider.with_tmp_namespace(namespace);
217 }
218 envd_backed_provider(Arc::new(provider), "cli-virtual")
219 }
220 other => {
221 unreachable!("environment provider should be validated before resolution: {other}")
222 }
223 };
224 let process_provider = provider.clone().process_shell_provider();
225 Ok(ResolvedEnvironment {
226 provider,
227 process_provider,
228 attachments: vec![default_local_attachment()],
229 })
230}
231
232fn default_local_attachment() -> EnvironmentAttachmentRef {
233 EnvironmentAttachmentRef {
234 id: LOCAL_ENVIRONMENT_ATTACHMENT_ID.to_string(),
235 kind: LOCAL_ENVIRONMENT_ATTACHMENT_KIND.to_string(),
236 mode: Some(EnvironmentAttachmentAccessMode::ReadWrite),
237 is_default: true,
238 is_default_for_shell: true,
239 endpoint_ref: None,
240 environment_id: None,
241 auth_token: None,
242 metadata: serde_json::Map::new(),
243 }
244}
245
246fn resolve_environment_attachment(
247 config: &CliConfig,
248 tmp_namespace: Option<&str>,
249 attachment: &EnvironmentAttachmentRef,
250) -> CliResult<ResolvedEnvironment> {
251 if attachment.id == LOCAL_ENVIRONMENT_ATTACHMENT_ID
252 && attachment.kind != LOCAL_ENVIRONMENT_ATTACHMENT_KIND
253 {
254 return Err(CliError::Config(
255 "reserved environment attachment id local requires kind local".to_string(),
256 ));
257 }
258 match attachment.kind.as_str() {
259 "local" => resolve_environment_with_tmp_namespace(config, tmp_namespace),
260 "envd" => resolve_envd_attachment(attachment),
261 other => Err(CliError::Config(format!(
262 "unsupported environment attachment kind: {other}"
263 ))),
264 }
265}
266
267fn resolve_envd_attachment(
268 attachment: &EnvironmentAttachmentRef,
269) -> CliResult<ResolvedEnvironment> {
270 let client = envd_client_for_attachment(attachment)
271 .map_err(|error| CliError::Config(format!("invalid envd endpoint: {error}")))?;
272 let environment_id = attachment
273 .requested_environment_id()
274 .unwrap_or(DEFAULT_ENVIRONMENT_ID)
275 .to_string();
276 let provider: DynEnvironmentProvider = Arc::new(
277 EnvdEnvironmentProvider::new(Arc::new(client), environment_id).with_id(&attachment.id),
278 );
279 let process_provider = provider.clone().process_shell_provider();
280 Ok(ResolvedEnvironment {
281 provider,
282 process_provider,
283 attachments: vec![attachment.clone()],
284 })
285}
286
287pub fn envd_client_for_attachment(
288 attachment: &EnvironmentAttachmentRef,
289) -> Result<EnvdRpcClient, String> {
290 let endpoint = attachment
291 .requested_endpoint_ref()
292 .ok_or_else(|| "envd attachment requires endpointRef".to_string())?;
293 EnvdRpcClient::from_local_endpoint_ref(endpoint, attachment.requested_auth_token())
294 .map_err(|error| error.to_string())
295}
296
297const fn environment_mount_mode(mode: EnvironmentAttachmentAccessMode) -> EnvironmentMountMode {
298 match mode {
299 EnvironmentAttachmentAccessMode::ReadOnly => EnvironmentMountMode::ReadOnly,
300 EnvironmentAttachmentAccessMode::ReadWrite => EnvironmentMountMode::ReadWrite,
301 }
302}
303
304fn attachment_supports_shell(attachment: &EnvironmentAttachmentRef) -> bool {
305 matches!(
306 attachment.resolved_mode(),
307 EnvironmentAttachmentAccessMode::ReadWrite
308 )
309}
310
311fn envd_backed_provider(provider: DynEnvironmentProvider, id: &str) -> DynEnvironmentProvider {
312 let shell_review_context = provider.shell_review_context();
313 let envd = Arc::new(LocalEnvd::new(provider));
314 let environment_id = envd.environment_id().to_string();
315 Arc::new(
316 EnvdEnvironmentProvider::new(envd, environment_id)
317 .with_id(id)
318 .with_shell_review_context(shell_review_context),
319 )
320}
321
322fn local_allowed_paths(config: &CliConfig) -> Vec<PathBuf> {
323 let mut paths = Vec::new();
324 push_allowed_path(&mut paths, std::env::temp_dir());
328 push_allowed_path(&mut paths, config.global_dir.clone());
329 if let Some(home) = std::env::var_os("HOME") {
330 push_allowed_path(&mut paths, PathBuf::from(home).join(".agents"));
331 }
332 push_allowed_path(&mut paths, config.workspace_root.clone());
333 push_allowed_path(&mut paths, config.project_dir.clone());
334 for dir in config.skill_dirs.iter().chain(config.subagent_dirs.iter()) {
335 push_allowed_path(&mut paths, dir.clone());
336 }
337 paths
338}
339
340fn push_allowed_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
341 let path = path.canonicalize().unwrap_or(path);
342 if !paths.iter().any(|existing| existing == &path) {
343 paths.push(path);
344 }
345}
346
347#[cfg(test)]
348fn display_local_test_path(path: &std::path::Path) -> String {
349 #[cfg(windows)]
350 {
351 let path = path.to_string_lossy();
352 let normalized = path.replace('/', "\\");
353 let stripped = if let Some(stripped) = normalized.strip_prefix(r"\\?\UNC\") {
354 format!(r"\\{stripped}")
355 } else if let Some(stripped) = normalized.strip_prefix(r"\\?\") {
356 stripped.to_string()
357 } else {
358 normalized
359 };
360 stripped.replace('\\', "/")
361 }
362 #[cfg(not(windows))]
363 {
364 path.to_string_lossy().replace('\\', "/")
365 }
366}
367
368fn validate_environment_provider(provider: &str) -> CliResult<()> {
369 match provider {
370 "local" | "virtual" => Ok(()),
371 other => Err(CliError::Config(format!(
372 "unknown environment provider: {other}"
373 ))),
374 }
375}
376
377fn environment_policy(config: &CliConfig) -> CliResult<EnvironmentPolicy> {
378 let files = match config.files_policy.as_str() {
379 "read_only" | "read-only" => FilePolicy::read_only(),
380 "read_write" | "read-write" => FilePolicy::read_write(),
381 "none" | "disabled" => FilePolicy::default(),
382 other => return Err(CliError::Config(format!("unknown files policy: {other}"))),
383 };
384 let shell = if config.shell_enabled {
385 ShellPolicy::allow_all()
386 } else {
387 ShellPolicy::default()
388 };
389 Ok(EnvironmentPolicy { files, shell })
390}
391
392#[cfg(test)]
393mod tests {
394 #![allow(clippy::unwrap_used)]
395
396 use std::path::Path;
397
398 use super::*;
399 use crate::{ConfigResolver, args, profiles::list_skills};
400
401 #[tokio::test]
402 async fn cli_local_environment_can_read_configured_skill_package_paths() {
403 let temp = tempfile::tempdir().unwrap();
404 let global_dir = temp.path().join("global");
405 std::fs::create_dir_all(&global_dir).unwrap();
406 std::fs::write(
407 global_dir.join("config.toml"),
408 r#"
409[skills]
410additional_dirs = ["../custom-skills"]
411"#,
412 )
413 .unwrap();
414
415 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
416 let config = ConfigResolver::for_tests(temp.path())
417 .resolve(&cli)
418 .unwrap();
419 write_skill(
420 &config.global_dir.join("skills/global-skill/SKILL.md"),
421 "global-skill",
422 "Global config skill",
423 );
424 write_skill(
425 &temp
426 .path()
427 .join("shared-agents/skills/shared-skill/SKILL.md"),
428 "shared-skill",
429 "Shared agent skill",
430 );
431 write_skill(
432 &config.project_dir.join("skills/project-skill/SKILL.md"),
433 "project-skill",
434 "Project config skill",
435 );
436 write_skill(
437 &temp.path().join("custom-skills/custom-skill/SKILL.md"),
438 "custom-skill",
439 "Custom skill dir",
440 );
441
442 let packages = list_skills(&config);
443 assert_eq!(packages.len(), 4);
444 assert!(
445 config
446 .skill_dirs
447 .iter()
448 .any(|path| path.ends_with("shared-agents/skills"))
449 );
450
451 let environment = resolve_environment(&config).unwrap();
452 assert_eq!(environment.provider.id(), "cli-local");
453 let state = environment.provider.export_state().await.unwrap();
454 assert_eq!(
455 state.metadata["envd_environment_id"],
456 serde_json::json!("env_cli_default")
457 );
458 assert_eq!(state.metadata["envd_store"], serde_json::json!("ephemeral"));
459 for package in packages {
460 let content = environment.provider.read_text(&package.path).await.unwrap();
461 assert!(content.contains(&format!("name: {}", package.name)));
462 }
463 }
464
465 #[tokio::test]
466 async fn cli_local_environment_tmp_outputs_and_system_tmp_are_readable() {
467 let temp = tempfile::tempdir().unwrap();
468 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
469 let config = ConfigResolver::for_tests(temp.path())
470 .resolve(&cli)
471 .unwrap();
472 let environment = resolve_environment_for_session(&config, "session_123").unwrap();
473
474 let tmp_path = environment
475 .provider
476 .write_tmp_file("stdout.log", b"captured output")
477 .await
478 .unwrap();
479 assert!(Path::new(&tmp_path).is_absolute());
480 assert!(tmp_path.contains("session_123"));
481 assert_eq!(
482 Path::new(&tmp_path).file_name().unwrap().to_string_lossy(),
483 "stdout.log"
484 );
485 assert_eq!(
486 environment.provider.read_text(&tmp_path).await.unwrap(),
487 "captured output"
488 );
489 let state = environment.provider.export_state().await.unwrap();
490 assert_eq!(
491 state.metadata["envd_operation_ids"]
492 .as_array()
493 .unwrap()
494 .len(),
495 1
496 );
497
498 let system_tmp_file = std::env::temp_dir().join(format!(
499 "starweaver-cli-system-tmp-read-{}",
500 std::process::id()
501 ));
502 let system_tmp_output = std::env::temp_dir().join(format!(
503 "starweaver-cli-system-tmp-write-{}",
504 std::process::id()
505 ));
506 std::fs::write(&system_tmp_file, "user temp").unwrap();
507 assert_eq!(
508 environment
509 .provider
510 .read_text(&system_tmp_file.display().to_string())
511 .await
512 .unwrap(),
513 "user temp"
514 );
515 environment
516 .provider
517 .write_text(&system_tmp_output.display().to_string(), "agent temp")
518 .await
519 .unwrap();
520 assert_eq!(
521 std::fs::read_to_string(&system_tmp_output).unwrap(),
522 "agent temp"
523 );
524 let _ = std::fs::remove_file(system_tmp_file);
525 let _ = std::fs::remove_file(system_tmp_output);
526 }
527
528 #[tokio::test]
529 async fn cli_local_environment_allows_system_tmp_before_config_paths() {
530 let temp = tempfile::tempdir().unwrap();
531 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
532 let config = ConfigResolver::for_tests(temp.path())
533 .resolve(&cli)
534 .unwrap();
535
536 let allowed_paths = local_allowed_paths(&config);
537 let system_tmp_dir = std::env::temp_dir()
538 .canonicalize()
539 .unwrap_or_else(|_| std::env::temp_dir());
540
541 assert_eq!(allowed_paths.first(), Some(&system_tmp_dir));
542 assert!(
543 allowed_paths.contains(
544 &config
545 .global_dir
546 .canonicalize()
547 .unwrap_or_else(|_| config.global_dir.clone())
548 )
549 );
550 assert!(
551 allowed_paths.contains(
552 &config
553 .workspace_root
554 .canonicalize()
555 .unwrap_or_else(|_| config.workspace_root.clone())
556 )
557 );
558 assert!(
559 allowed_paths.contains(
560 &config
561 .project_dir
562 .canonicalize()
563 .unwrap_or_else(|_| config.project_dir.clone())
564 )
565 );
566 }
567
568 #[tokio::test]
569 async fn cli_local_environment_file_tree_context_uses_workspace_only() {
570 let temp = tempfile::tempdir().unwrap();
571 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
572 let config = ConfigResolver::for_tests(temp.path())
573 .resolve(&cli)
574 .unwrap();
575 std::fs::create_dir_all(&config.workspace_root).unwrap();
576 std::fs::create_dir_all(&config.global_dir).unwrap();
577 std::fs::write(config.workspace_root.join("app.rs"), "fn main() {}").unwrap();
578 std::fs::write(config.global_dir.join("config-marker.txt"), "config").unwrap();
579
580 let environment = resolve_environment_for_session(&config, "session_123").unwrap();
581 let context = environment
582 .provider
583 .render_environment_context()
584 .await
585 .unwrap()
586 .unwrap();
587
588 assert_eq!(context.matches("<directory path=").count(), 1);
589 assert!(context.contains(&format!(
590 "<directory path=\"{}\">",
591 display_local_test_path(
592 &config
593 .workspace_root
594 .canonicalize()
595 .unwrap_or_else(|_| config.workspace_root.clone())
596 )
597 )));
598 assert!(context.contains("app.rs"));
599 assert!(!context.contains("config-marker.txt"));
600 assert!(!context.contains(&format!(
601 "<directory path=\"{}\">",
602 display_local_test_path(
603 &config
604 .global_dir
605 .canonicalize()
606 .unwrap_or_else(|_| config.global_dir.clone())
607 )
608 )));
609
610 assert_eq!(
611 environment
612 .provider
613 .read_text(
614 &config
615 .global_dir
616 .join("config-marker.txt")
617 .display()
618 .to_string(),
619 )
620 .await
621 .unwrap(),
622 "config"
623 );
624 }
625
626 #[tokio::test]
627 async fn cli_resolves_single_environment_attachment_as_composite_provider() {
628 let temp = tempfile::tempdir().unwrap();
629 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
630 let config = ConfigResolver::for_tests(temp.path())
631 .resolve(&cli)
632 .unwrap();
633 std::fs::create_dir_all(&config.workspace_root).unwrap();
634 std::fs::write(config.workspace_root.join("README.md"), "local workspace").unwrap();
635 let attachments = vec![EnvironmentAttachmentRef {
636 id: "workspace".to_string(),
637 kind: "local".to_string(),
638 mode: Some(EnvironmentAttachmentAccessMode::ReadOnly),
639 is_default: false,
640 is_default_for_shell: false,
641 endpoint_ref: None,
642 environment_id: None,
643 auth_token: None,
644 metadata: serde_json::Map::new(),
645 }];
646
647 let environment =
648 resolve_environment_for_session_with_attachments(&config, "session_123", &attachments)
649 .unwrap();
650
651 assert_eq!(environment.provider.id(), "composite");
652 assert_eq!(environment.attachments[0].id, "workspace");
653 assert!(environment.attachments[0].is_default);
654 assert!(!environment.attachments[0].is_default_for_shell);
655 assert_eq!(
656 environment.provider.read_text("README.md").await.unwrap(),
657 "local workspace"
658 );
659 assert_eq!(
660 environment
661 .provider
662 .read_text("/environment/workspace/README.md")
663 .await
664 .unwrap(),
665 "local workspace"
666 );
667 assert!(matches!(
668 environment.provider.write_text("new.txt", "blocked").await,
669 Err(starweaver_environment::EnvironmentError::AccessDenied(_))
670 ));
671 assert!(matches!(
672 environment
673 .provider
674 .write_text("/environment/workspace/new.txt", "blocked")
675 .await,
676 Err(starweaver_environment::EnvironmentError::AccessDenied(_))
677 ));
678 }
679
680 #[tokio::test]
681 async fn cli_resolves_multiple_environment_attachments_as_composite_provider() {
682 let temp = tempfile::tempdir().unwrap();
683 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
684 let config = ConfigResolver::for_tests(temp.path())
685 .resolve(&cli)
686 .unwrap();
687 std::fs::create_dir_all(&config.workspace_root).unwrap();
688 std::fs::write(config.workspace_root.join("README.md"), "local workspace").unwrap();
689 let attachments = vec![
690 EnvironmentAttachmentRef {
691 id: "workspace".to_string(),
692 kind: "local".to_string(),
693 mode: Some(EnvironmentAttachmentAccessMode::ReadWrite),
694 is_default: true,
695 is_default_for_shell: true,
696 endpoint_ref: None,
697 environment_id: None,
698 auth_token: None,
699 metadata: serde_json::Map::new(),
700 },
701 EnvironmentAttachmentRef {
702 id: "tools".to_string(),
703 kind: "local".to_string(),
704 mode: Some(EnvironmentAttachmentAccessMode::ReadOnly),
705 is_default: false,
706 is_default_for_shell: false,
707 endpoint_ref: None,
708 environment_id: None,
709 auth_token: None,
710 metadata: serde_json::Map::new(),
711 },
712 ];
713
714 let environment =
715 resolve_environment_for_session_with_attachments(&config, "session_123", &attachments)
716 .unwrap();
717
718 assert_eq!(environment.provider.id(), "composite");
719 assert!(environment.attachments[0].is_default);
720 assert!(environment.attachments[0].is_default_for_shell);
721 assert!(!environment.attachments[1].is_default);
722 assert!(!environment.attachments[1].is_default_for_shell);
723 assert_eq!(
724 environment.provider.read_text("README.md").await.unwrap(),
725 "local workspace"
726 );
727 assert_eq!(
728 environment
729 .provider
730 .read_text("/environment/tools/README.md")
731 .await
732 .unwrap(),
733 "local workspace"
734 );
735 assert!(matches!(
736 environment
737 .provider
738 .write_text("/environment/tools/new.txt", "blocked")
739 .await,
740 Err(starweaver_environment::EnvironmentError::AccessDenied(_))
741 ));
742 assert!(environment.process_provider.is_some());
743 }
744
745 fn write_skill(path: &Path, name: &str, description: &str) {
746 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
747 std::fs::write(
748 path,
749 format!(
750 r"---
751name: {name}
752description: {description}
753---
754Use this skill.
755"
756 ),
757 )
758 .unwrap();
759 }
760}