Skip to main content

starweaver_cli/
environment.rs

1//! CLI environment provider resolution.
2
3use 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/// CLI-private access mode for one configured environment mount.
21#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(rename_all = "snake_case")]
23pub enum EnvironmentAttachmentAccessMode {
24    /// Permit reads but reject writes and process execution.
25    ReadOnly,
26    /// Permit reads, writes, and process execution allowed by the provider policy.
27    #[default]
28    ReadWrite,
29}
30
31/// CLI-private provider material used while building one run environment.
32#[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/// Resolved environment provider for one CLI run.
85#[derive(Clone)]
86pub struct ResolvedEnvironment {
87    /// Provider handle attached to `AgentSession`.
88    pub provider: DynEnvironmentProvider,
89    /// Optional process-capable provider override for background shell tools.
90    pub process_provider: Option<DynProcessShellProvider>,
91    /// Effective run-local attachment refs backing this environment.
92    pub attachments: Vec<EnvironmentAttachmentRef>,
93}
94
95/// Validate environment configuration before creating run/session records.
96pub 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/// Build an environment provider from resolved CLI config.
102#[cfg(test)]
103pub fn resolve_environment(config: &CliConfig) -> CliResult<ResolvedEnvironment> {
104    resolve_environment_with_scratch_namespace(config, None)
105}
106
107/// Build an environment provider with a session-scoped scratch file namespace.
108pub 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
115/// Build an environment provider for a session, optionally using host RPC attachments.
116pub 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
124/// Build a non-switchable target for a session and attachment list.
125pub 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    // This broad user-file grant is independent from the CLI-owned scratch
330    // subdirectory created beneath the system temp directory.
331    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}