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_scratch_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_scratch_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_scratch_namespace(
194 config: &CliConfig,
195 scratch_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 .map_err(|error| CliError::Config(error.to_string()))?
203 .with_id("cli-local")
204 .with_allowed_paths(local_allowed_paths(config))
205 .with_context_file_tree_roots([config.workspace_root.clone()])
206 .with_policy(policy);
207 if let Some(namespace) = scratch_namespace {
208 provider = provider
209 .with_scratch_namespace(namespace)
210 .map_err(|error| CliError::Config(error.to_string()))?;
211 }
212 envd_backed_provider(Arc::new(provider), "cli-local")
213 }
214 "virtual" => {
215 let mut provider = VirtualEnvironmentProvider::new("cli-virtual")
216 .with_policy(policy)
217 .with_file("README.md", "Virtual Starweaver CLI workspace");
218 if let Some(namespace) = scratch_namespace {
219 provider = provider
220 .with_scratch_namespace(namespace)
221 .map_err(|error| CliError::Config(error.to_string()))?;
222 }
223 envd_backed_provider(Arc::new(provider), "cli-virtual")
224 }
225 other => {
226 unreachable!("environment provider should be validated before resolution: {other}")
227 }
228 };
229 let process_provider = provider.clone().process_shell_provider();
230 Ok(ResolvedEnvironment {
231 provider,
232 process_provider,
233 attachments: vec![default_local_attachment()],
234 })
235}
236
237fn default_local_attachment() -> EnvironmentAttachmentRef {
238 EnvironmentAttachmentRef {
239 id: LOCAL_ENVIRONMENT_ATTACHMENT_ID.to_string(),
240 kind: LOCAL_ENVIRONMENT_ATTACHMENT_KIND.to_string(),
241 mode: Some(EnvironmentAttachmentAccessMode::ReadWrite),
242 is_default: true,
243 is_default_for_shell: true,
244 endpoint_ref: None,
245 environment_id: None,
246 auth_token: None,
247 metadata: serde_json::Map::new(),
248 }
249}
250
251fn resolve_environment_attachment(
252 config: &CliConfig,
253 scratch_namespace: Option<&str>,
254 attachment: &EnvironmentAttachmentRef,
255) -> CliResult<ResolvedEnvironment> {
256 if attachment.id == LOCAL_ENVIRONMENT_ATTACHMENT_ID
257 && attachment.kind != LOCAL_ENVIRONMENT_ATTACHMENT_KIND
258 {
259 return Err(CliError::Config(
260 "reserved environment attachment id local requires kind local".to_string(),
261 ));
262 }
263 match attachment.kind.as_str() {
264 "local" => resolve_environment_with_scratch_namespace(config, scratch_namespace),
265 "envd" => resolve_envd_attachment(attachment),
266 other => Err(CliError::Config(format!(
267 "unsupported environment attachment kind: {other}"
268 ))),
269 }
270}
271
272fn resolve_envd_attachment(
273 attachment: &EnvironmentAttachmentRef,
274) -> CliResult<ResolvedEnvironment> {
275 let client = envd_client_for_attachment(attachment)
276 .map_err(|error| CliError::Config(format!("invalid envd endpoint: {error}")))?;
277 let environment_id = attachment
278 .requested_environment_id()
279 .unwrap_or(DEFAULT_ENVIRONMENT_ID)
280 .to_string();
281 let provider: DynEnvironmentProvider = Arc::new(
282 EnvdEnvironmentProvider::new(Arc::new(client), environment_id).with_id(&attachment.id),
283 );
284 let process_provider = provider.clone().process_shell_provider();
285 Ok(ResolvedEnvironment {
286 provider,
287 process_provider,
288 attachments: vec![attachment.clone()],
289 })
290}
291
292pub fn envd_client_for_attachment(
293 attachment: &EnvironmentAttachmentRef,
294) -> Result<EnvdRpcClient, String> {
295 let endpoint = attachment
296 .requested_endpoint_ref()
297 .ok_or_else(|| "envd attachment requires endpointRef".to_string())?;
298 EnvdRpcClient::from_local_endpoint_ref(endpoint, attachment.requested_auth_token())
299 .map_err(|error| error.to_string())
300}
301
302const fn environment_mount_mode(mode: EnvironmentAttachmentAccessMode) -> EnvironmentMountMode {
303 match mode {
304 EnvironmentAttachmentAccessMode::ReadOnly => EnvironmentMountMode::ReadOnly,
305 EnvironmentAttachmentAccessMode::ReadWrite => EnvironmentMountMode::ReadWrite,
306 }
307}
308
309fn attachment_supports_shell(attachment: &EnvironmentAttachmentRef) -> bool {
310 matches!(
311 attachment.resolved_mode(),
312 EnvironmentAttachmentAccessMode::ReadWrite
313 )
314}
315
316fn envd_backed_provider(provider: DynEnvironmentProvider, id: &str) -> DynEnvironmentProvider {
317 let shell_review_context = provider.shell_review_context();
318 let envd = Arc::new(LocalEnvd::new(provider));
319 let environment_id = envd.environment_id().to_string();
320 Arc::new(
321 EnvdEnvironmentProvider::new(envd, environment_id)
322 .with_id(id)
323 .with_shell_review_context(shell_review_context),
324 )
325}
326
327fn local_allowed_paths(config: &CliConfig) -> Vec<PathBuf> {
328 let mut paths = Vec::new();
329 push_allowed_path(&mut paths, std::env::temp_dir());
332 push_allowed_path(&mut paths, config.global_dir.clone());
333 if let Some(home) = std::env::var_os("HOME") {
334 push_allowed_path(&mut paths, PathBuf::from(home).join(".agents"));
335 }
336 push_allowed_path(&mut paths, config.workspace_root.clone());
337 push_allowed_path(&mut paths, config.project_dir.clone());
338 for dir in config.skill_dirs.iter().chain(config.subagent_dirs.iter()) {
339 push_allowed_path(&mut paths, dir.clone());
340 }
341 paths
342}
343
344fn push_allowed_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
345 let path = path.canonicalize().unwrap_or(path);
346 if !paths.iter().any(|existing| existing == &path) {
347 paths.push(path);
348 }
349}
350
351#[cfg(test)]
352fn display_local_test_path(path: &std::path::Path) -> String {
353 #[cfg(windows)]
354 {
355 let path = path.to_string_lossy();
356 let normalized = path.replace('/', "\\");
357 let stripped = if let Some(stripped) = normalized.strip_prefix(r"\\?\UNC\") {
358 format!(r"\\{stripped}")
359 } else if let Some(stripped) = normalized.strip_prefix(r"\\?\") {
360 stripped.to_string()
361 } else {
362 normalized
363 };
364 stripped.replace('\\', "/")
365 }
366 #[cfg(not(windows))]
367 {
368 path.to_string_lossy().replace('\\', "/")
369 }
370}
371
372fn validate_environment_provider(provider: &str) -> CliResult<()> {
373 match provider {
374 "local" | "virtual" => Ok(()),
375 other => Err(CliError::Config(format!(
376 "unknown environment provider: {other}"
377 ))),
378 }
379}
380
381fn environment_policy(config: &CliConfig) -> CliResult<EnvironmentPolicy> {
382 let files = match config.files_policy.as_str() {
383 "read_only" | "read-only" => FilePolicy::read_only(),
384 "read_write" | "read-write" => FilePolicy::read_write(),
385 "none" | "disabled" => FilePolicy::default(),
386 other => return Err(CliError::Config(format!("unknown files policy: {other}"))),
387 };
388 let shell = if config.shell_enabled {
389 ShellPolicy::allow_all()
390 } else {
391 ShellPolicy::default()
392 };
393 Ok(EnvironmentPolicy { files, shell })
394}
395
396#[cfg(test)]
397mod tests {
398 #![allow(clippy::unwrap_used)]
399
400 use std::path::Path;
401
402 use super::*;
403 use crate::{ConfigResolver, args, profiles::list_skills};
404
405 #[tokio::test]
406 async fn cli_local_environment_can_read_configured_skill_package_paths() {
407 let temp = tempfile::tempdir().unwrap();
408 let global_dir = temp.path().join("global");
409 std::fs::create_dir_all(&global_dir).unwrap();
410 std::fs::write(
411 global_dir.join("config.toml"),
412 r#"
413[skills]
414additional_dirs = ["../custom-skills"]
415"#,
416 )
417 .unwrap();
418
419 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
420 let config = ConfigResolver::for_tests(temp.path())
421 .resolve(&cli)
422 .unwrap();
423 write_skill(
424 &config.global_dir.join("skills/global-skill/SKILL.md"),
425 "global-skill",
426 "Global config skill",
427 );
428 write_skill(
429 &temp
430 .path()
431 .join("shared-agents/skills/shared-skill/SKILL.md"),
432 "shared-skill",
433 "Shared agent skill",
434 );
435 write_skill(
436 &config.project_dir.join("skills/project-skill/SKILL.md"),
437 "project-skill",
438 "Project config skill",
439 );
440 write_skill(
441 &temp.path().join("custom-skills/custom-skill/SKILL.md"),
442 "custom-skill",
443 "Custom skill dir",
444 );
445
446 let packages = list_skills(&config);
447 assert_eq!(packages.len(), 4);
448 assert!(
449 config
450 .skill_dirs
451 .iter()
452 .any(|path| path.ends_with("shared-agents/skills"))
453 );
454
455 let environment = resolve_environment(&config).unwrap();
456 assert_eq!(environment.provider.id(), "cli-local");
457 let state = environment.provider.export_state().await.unwrap();
458 assert_eq!(
459 state.metadata["envd_environment_id"],
460 serde_json::json!("env_cli_default")
461 );
462 assert_eq!(state.metadata["envd_store"], serde_json::json!("ephemeral"));
463 for package in packages {
464 let content = environment.provider.read_text(&package.path).await.unwrap();
465 assert!(content.contains(&format!("name: {}", package.name)));
466 }
467 }
468
469 #[tokio::test]
470 async fn cli_local_environment_uses_owned_system_temp_scratch_and_user_temp_grant() {
471 let temp = tempfile::tempdir().unwrap();
472 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
473 let config = ConfigResolver::for_tests(temp.path())
474 .resolve(&cli)
475 .unwrap();
476 let environment = resolve_environment_for_session(&config, "session_123").unwrap();
477
478 let scratch_path = environment
479 .provider
480 .write_scratch_file("stdout.log", b"captured output")
481 .await
482 .unwrap();
483 let scratch_path = PathBuf::from(scratch_path);
484 let canonical_scratch_path = scratch_path.canonicalize().unwrap();
485 let scratch_root = canonical_scratch_path
486 .parent()
487 .and_then(Path::parent)
488 .unwrap()
489 .to_path_buf();
490 let system_temp = std::env::temp_dir()
491 .canonicalize()
492 .unwrap_or_else(|_| std::env::temp_dir());
493 assert!(scratch_path.is_absolute());
494 assert!(canonical_scratch_path.starts_with(&system_temp));
495 assert_ne!(scratch_root, system_temp);
496 assert!(scratch_path.to_string_lossy().contains("session_123"));
497 assert_eq!(
498 scratch_path.file_name().unwrap().to_string_lossy(),
499 "stdout.log"
500 );
501 assert_eq!(
502 environment
503 .provider
504 .read_text(&scratch_path.to_string_lossy())
505 .await
506 .unwrap(),
507 "captured output"
508 );
509 let state = environment.provider.export_state().await.unwrap();
510 assert_eq!(
511 state.metadata["envd_operation_ids"]
512 .as_array()
513 .unwrap()
514 .len(),
515 1
516 );
517
518 let system_tmp_file = std::env::temp_dir().join(format!(
519 "starweaver-cli-system-tmp-read-{}",
520 std::process::id()
521 ));
522 let system_tmp_output = std::env::temp_dir().join(format!(
523 "starweaver-cli-system-tmp-write-{}",
524 std::process::id()
525 ));
526 std::fs::write(&system_tmp_file, "user temp").unwrap();
527 assert_eq!(
528 environment
529 .provider
530 .read_text(&system_tmp_file.display().to_string())
531 .await
532 .unwrap(),
533 "user temp"
534 );
535 environment
536 .provider
537 .write_text(&system_tmp_output.display().to_string(), "agent temp")
538 .await
539 .unwrap();
540 assert_eq!(
541 std::fs::read_to_string(&system_tmp_output).unwrap(),
542 "agent temp"
543 );
544 let _ = std::fs::remove_file(system_tmp_file);
545 let _ = std::fs::remove_file(system_tmp_output);
546 drop(environment);
547 assert!(!scratch_root.exists());
548 }
549
550 #[tokio::test]
551 async fn cli_local_environment_allows_system_tmp_before_config_paths() {
552 let temp = tempfile::tempdir().unwrap();
553 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
554 let config = ConfigResolver::for_tests(temp.path())
555 .resolve(&cli)
556 .unwrap();
557
558 let allowed_paths = local_allowed_paths(&config);
559 let system_tmp_dir = std::env::temp_dir()
560 .canonicalize()
561 .unwrap_or_else(|_| std::env::temp_dir());
562
563 assert_eq!(allowed_paths.first(), Some(&system_tmp_dir));
564 assert!(
565 allowed_paths.contains(
566 &config
567 .global_dir
568 .canonicalize()
569 .unwrap_or_else(|_| config.global_dir.clone())
570 )
571 );
572 assert!(
573 allowed_paths.contains(
574 &config
575 .workspace_root
576 .canonicalize()
577 .unwrap_or_else(|_| config.workspace_root.clone())
578 )
579 );
580 assert!(
581 allowed_paths.contains(
582 &config
583 .project_dir
584 .canonicalize()
585 .unwrap_or_else(|_| config.project_dir.clone())
586 )
587 );
588 }
589
590 #[tokio::test]
591 async fn cli_local_environment_file_tree_context_uses_workspace_only() {
592 let temp = tempfile::tempdir().unwrap();
593 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
594 let config = ConfigResolver::for_tests(temp.path())
595 .resolve(&cli)
596 .unwrap();
597 std::fs::create_dir_all(&config.workspace_root).unwrap();
598 std::fs::create_dir_all(&config.global_dir).unwrap();
599 std::fs::write(config.workspace_root.join("app.rs"), "fn main() {}").unwrap();
600 std::fs::write(config.global_dir.join("config-marker.txt"), "config").unwrap();
601
602 let environment = resolve_environment_for_session(&config, "session_123").unwrap();
603 let context = environment
604 .provider
605 .render_environment_context()
606 .await
607 .unwrap()
608 .unwrap();
609
610 assert_eq!(context.matches("<directory path=").count(), 1);
611 assert!(context.contains(&format!(
612 "<directory path=\"{}\">",
613 display_local_test_path(
614 &config
615 .workspace_root
616 .canonicalize()
617 .unwrap_or_else(|_| config.workspace_root.clone())
618 )
619 )));
620 assert!(context.contains("app.rs"));
621 assert!(!context.contains("config-marker.txt"));
622 assert!(!context.contains(&format!(
623 "<directory path=\"{}\">",
624 display_local_test_path(
625 &config
626 .global_dir
627 .canonicalize()
628 .unwrap_or_else(|_| config.global_dir.clone())
629 )
630 )));
631
632 assert_eq!(
633 environment
634 .provider
635 .read_text(
636 &config
637 .global_dir
638 .join("config-marker.txt")
639 .display()
640 .to_string(),
641 )
642 .await
643 .unwrap(),
644 "config"
645 );
646 }
647
648 #[tokio::test]
649 async fn cli_resolves_single_environment_attachment_as_composite_provider() {
650 let temp = tempfile::tempdir().unwrap();
651 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
652 let config = ConfigResolver::for_tests(temp.path())
653 .resolve(&cli)
654 .unwrap();
655 std::fs::create_dir_all(&config.workspace_root).unwrap();
656 std::fs::write(config.workspace_root.join("README.md"), "local workspace").unwrap();
657 let attachments = vec![EnvironmentAttachmentRef {
658 id: "workspace".to_string(),
659 kind: "local".to_string(),
660 mode: Some(EnvironmentAttachmentAccessMode::ReadOnly),
661 is_default: false,
662 is_default_for_shell: false,
663 endpoint_ref: None,
664 environment_id: None,
665 auth_token: None,
666 metadata: serde_json::Map::new(),
667 }];
668
669 let environment =
670 resolve_environment_for_session_with_attachments(&config, "session_123", &attachments)
671 .unwrap();
672
673 assert_eq!(environment.provider.id(), "composite");
674 assert_eq!(environment.attachments[0].id, "workspace");
675 assert!(environment.attachments[0].is_default);
676 assert!(!environment.attachments[0].is_default_for_shell);
677 assert_eq!(
678 environment.provider.read_text("README.md").await.unwrap(),
679 "local workspace"
680 );
681 assert_eq!(
682 environment
683 .provider
684 .read_text("/environment/workspace/README.md")
685 .await
686 .unwrap(),
687 "local workspace"
688 );
689 assert!(matches!(
690 environment.provider.write_text("new.txt", "blocked").await,
691 Err(starweaver_environment::EnvironmentError::AccessDenied(_))
692 ));
693 assert!(matches!(
694 environment
695 .provider
696 .write_text("/environment/workspace/new.txt", "blocked")
697 .await,
698 Err(starweaver_environment::EnvironmentError::AccessDenied(_))
699 ));
700 }
701
702 #[tokio::test]
703 async fn cli_resolves_multiple_environment_attachments_as_composite_provider() {
704 let temp = tempfile::tempdir().unwrap();
705 let cli = args::parse(["starweaver-cli".to_string()]).unwrap();
706 let config = ConfigResolver::for_tests(temp.path())
707 .resolve(&cli)
708 .unwrap();
709 std::fs::create_dir_all(&config.workspace_root).unwrap();
710 std::fs::write(config.workspace_root.join("README.md"), "local workspace").unwrap();
711 let attachments = vec![
712 EnvironmentAttachmentRef {
713 id: "workspace".to_string(),
714 kind: "local".to_string(),
715 mode: Some(EnvironmentAttachmentAccessMode::ReadWrite),
716 is_default: true,
717 is_default_for_shell: true,
718 endpoint_ref: None,
719 environment_id: None,
720 auth_token: None,
721 metadata: serde_json::Map::new(),
722 },
723 EnvironmentAttachmentRef {
724 id: "tools".to_string(),
725 kind: "local".to_string(),
726 mode: Some(EnvironmentAttachmentAccessMode::ReadOnly),
727 is_default: false,
728 is_default_for_shell: false,
729 endpoint_ref: None,
730 environment_id: None,
731 auth_token: None,
732 metadata: serde_json::Map::new(),
733 },
734 ];
735
736 let environment =
737 resolve_environment_for_session_with_attachments(&config, "session_123", &attachments)
738 .unwrap();
739
740 assert_eq!(environment.provider.id(), "composite");
741 assert!(environment.attachments[0].is_default);
742 assert!(environment.attachments[0].is_default_for_shell);
743 assert!(!environment.attachments[1].is_default);
744 assert!(!environment.attachments[1].is_default_for_shell);
745 assert_eq!(
746 environment.provider.read_text("README.md").await.unwrap(),
747 "local workspace"
748 );
749 assert_eq!(
750 environment
751 .provider
752 .read_text("/environment/tools/README.md")
753 .await
754 .unwrap(),
755 "local workspace"
756 );
757 assert!(matches!(
758 environment
759 .provider
760 .write_text("/environment/tools/new.txt", "blocked")
761 .await,
762 Err(starweaver_environment::EnvironmentError::AccessDenied(_))
763 ));
764 assert!(environment.process_provider.is_some());
765 }
766
767 fn write_skill(path: &Path, name: &str, description: &str) {
768 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
769 std::fs::write(
770 path,
771 format!(
772 r"---
773name: {name}
774description: {description}
775---
776Use this skill.
777"
778 ),
779 )
780 .unwrap();
781 }
782}