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_tmp_namespace(config, None)
105}
106
107/// Build an environment provider with a session-scoped temporary file namespace.
108pub 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
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_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    // Include the system temp dir so CLI/TUI agents can access user-specified
325    // temporary files, while LocalEnvironmentProvider still appends its
326    // provider-managed session temp dir separately.
327    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}