Skip to main content

claude_api/managed_agents/
environments.rs

1//! Environments: container configuration for sessions.
2//!
3//! An environment defines the cloud container (pre-installed packages,
4//! networking policy) where sessions run. Multiple sessions can share
5//! one environment; each gets its own container instance.
6//!
7//! Environments are not versioned; mutate via re-create rather than
8//! patch. The lifecycle is create → list/retrieve → archive → delete.
9
10use std::collections::HashMap;
11
12use serde::{Deserialize, Serialize};
13
14use crate::client::Client;
15use crate::error::Result;
16use crate::pagination::Paginated;
17
18use super::MANAGED_AGENTS_BETA;
19
20// =====================================================================
21// Config types
22// =====================================================================
23
24/// Pre-installed packages, indexed by package manager. Caches across
25/// sessions that share the environment.
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[non_exhaustive]
28pub struct EnvironmentPackages {
29    /// System packages (`apt-get`).
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub apt: Vec<String>,
32    /// Rust crates (`cargo install`).
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub cargo: Vec<String>,
35    /// Ruby gems.
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub gem: Vec<String>,
38    /// Go modules (`go install`).
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub go: Vec<String>,
41    /// Node packages (`npm install`).
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub npm: Vec<String>,
44    /// Python packages (`pip install`).
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub pip: Vec<String>,
47}
48
49/// Networking policy for the container.
50///
51/// Forward-compatible: unknown wire `type` tags fall through to
52/// [`Self::Other`] preserving the raw JSON.
53#[derive(Debug, Clone, PartialEq)]
54pub enum Networking {
55    /// Full outbound network access (default), except for a general
56    /// safety blocklist.
57    Unrestricted,
58    /// Restrict to an explicit `allowed_hosts` list, optionally with
59    /// MCP-server / package-manager bypass flags.
60    Limited(LimitedNetworking),
61    /// Unknown networking mode; raw JSON preserved.
62    Other(serde_json::Value),
63}
64
65/// Body of a [`Networking::Limited`] policy.
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[non_exhaustive]
68pub struct LimitedNetworking {
69    /// HTTPS-prefixed domains the container can reach.
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub allowed_hosts: Vec<String>,
72    /// Allow connections to MCP servers configured on the agent
73    /// beyond `allowed_hosts`. Defaults to `false`.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub allow_mcp_servers: Option<bool>,
76    /// Allow connections to public package registries (`PyPI`, `npm`,
77    /// ...) beyond `allowed_hosts`. Defaults to `false`.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub allow_package_managers: Option<bool>,
80}
81
82const KNOWN_NETWORKING_TAGS: &[&str] = &["unrestricted", "limited"];
83
84impl Serialize for Networking {
85    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
86        use serde::ser::SerializeMap;
87        match self {
88            Self::Unrestricted => {
89                let mut map = s.serialize_map(Some(1))?;
90                map.serialize_entry("type", "unrestricted")?;
91                map.end()
92            }
93            Self::Limited(l) => {
94                let mut map = s.serialize_map(None)?;
95                map.serialize_entry("type", "limited")?;
96                if !l.allowed_hosts.is_empty() {
97                    map.serialize_entry("allowed_hosts", &l.allowed_hosts)?;
98                }
99                if let Some(b) = l.allow_mcp_servers {
100                    map.serialize_entry("allow_mcp_servers", &b)?;
101                }
102                if let Some(b) = l.allow_package_managers {
103                    map.serialize_entry("allow_package_managers", &b)?;
104                }
105                map.end()
106            }
107            Self::Other(v) => v.serialize(s),
108        }
109    }
110}
111
112impl<'de> Deserialize<'de> for Networking {
113    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
114        let raw = serde_json::Value::deserialize(d)?;
115        let tag = raw.get("type").and_then(serde_json::Value::as_str);
116        match tag {
117            Some("unrestricted") if KNOWN_NETWORKING_TAGS.contains(&"unrestricted") => {
118                Ok(Self::Unrestricted)
119            }
120            Some("limited") => {
121                let l = serde_json::from_value::<LimitedNetworking>(raw)
122                    .map_err(serde::de::Error::custom)?;
123                Ok(Self::Limited(l))
124            }
125            _ => Ok(Self::Other(raw)),
126        }
127    }
128}
129
130/// Container configuration. Currently only the `cloud` shape is
131/// documented; new shapes fall through to [`Self::Other`].
132#[derive(Debug, Clone, PartialEq)]
133pub enum EnvironmentConfig {
134    /// Cloud container.
135    Cloud(CloudConfig),
136    /// Unknown config shape; raw JSON preserved.
137    Other(serde_json::Value),
138}
139
140/// Body of an [`EnvironmentConfig::Cloud`].
141#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
142#[non_exhaustive]
143pub struct CloudConfig {
144    /// Pre-installed packages.
145    #[serde(default, skip_serializing_if = "is_default_packages")]
146    pub packages: EnvironmentPackages,
147    /// Networking policy. Defaults server-side to `unrestricted`.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub networking: Option<Networking>,
150}
151
152#[allow(clippy::ref_option, clippy::trivially_copy_pass_by_ref)]
153fn is_default_packages(p: &EnvironmentPackages) -> bool {
154    p.apt.is_empty()
155        && p.cargo.is_empty()
156        && p.gem.is_empty()
157        && p.go.is_empty()
158        && p.npm.is_empty()
159        && p.pip.is_empty()
160}
161
162const KNOWN_ENV_CONFIG_TAGS: &[&str] = &["cloud"];
163
164impl Serialize for EnvironmentConfig {
165    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
166        use serde::ser::SerializeMap;
167        match self {
168            Self::Cloud(c) => {
169                let mut map = s.serialize_map(None)?;
170                map.serialize_entry("type", "cloud")?;
171                if !is_default_packages(&c.packages) {
172                    map.serialize_entry("packages", &c.packages)?;
173                }
174                if let Some(n) = &c.networking {
175                    map.serialize_entry("networking", n)?;
176                }
177                map.end()
178            }
179            Self::Other(v) => v.serialize(s),
180        }
181    }
182}
183
184impl<'de> Deserialize<'de> for EnvironmentConfig {
185    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
186        let raw = serde_json::Value::deserialize(d)?;
187        let tag = raw.get("type").and_then(serde_json::Value::as_str);
188        match tag {
189            Some("cloud") if KNOWN_ENV_CONFIG_TAGS.contains(&"cloud") => {
190                let c =
191                    serde_json::from_value::<CloudConfig>(raw).map_err(serde::de::Error::custom)?;
192                Ok(Self::Cloud(c))
193            }
194            _ => Ok(Self::Other(raw)),
195        }
196    }
197}
198
199impl EnvironmentConfig {
200    /// Build a cloud config with no packages and unrestricted networking.
201    #[must_use]
202    pub fn cloud() -> CloudConfigBuilder {
203        CloudConfigBuilder::default()
204    }
205}
206
207/// Builder for [`EnvironmentConfig::Cloud`].
208#[derive(Debug, Default)]
209pub struct CloudConfigBuilder {
210    packages: EnvironmentPackages,
211    networking: Option<Networking>,
212}
213
214impl CloudConfigBuilder {
215    /// Add pip packages.
216    #[must_use]
217    pub fn pip<I, S>(mut self, packages: I) -> Self
218    where
219        I: IntoIterator<Item = S>,
220        S: Into<String>,
221    {
222        self.packages.pip = packages.into_iter().map(Into::into).collect();
223        self
224    }
225
226    /// Add npm packages.
227    #[must_use]
228    pub fn npm<I, S>(mut self, packages: I) -> Self
229    where
230        I: IntoIterator<Item = S>,
231        S: Into<String>,
232    {
233        self.packages.npm = packages.into_iter().map(Into::into).collect();
234        self
235    }
236
237    /// Add apt packages.
238    #[must_use]
239    pub fn apt<I, S>(mut self, packages: I) -> Self
240    where
241        I: IntoIterator<Item = S>,
242        S: Into<String>,
243    {
244        self.packages.apt = packages.into_iter().map(Into::into).collect();
245        self
246    }
247
248    /// Add cargo packages.
249    #[must_use]
250    pub fn cargo<I, S>(mut self, packages: I) -> Self
251    where
252        I: IntoIterator<Item = S>,
253        S: Into<String>,
254    {
255        self.packages.cargo = packages.into_iter().map(Into::into).collect();
256        self
257    }
258
259    /// Add gem packages.
260    #[must_use]
261    pub fn gem<I, S>(mut self, packages: I) -> Self
262    where
263        I: IntoIterator<Item = S>,
264        S: Into<String>,
265    {
266        self.packages.gem = packages.into_iter().map(Into::into).collect();
267        self
268    }
269
270    /// Add go modules.
271    #[must_use]
272    pub fn go<I, S>(mut self, packages: I) -> Self
273    where
274        I: IntoIterator<Item = S>,
275        S: Into<String>,
276    {
277        self.packages.go = packages.into_iter().map(Into::into).collect();
278        self
279    }
280
281    /// Set the networking policy.
282    #[must_use]
283    pub fn networking(mut self, networking: Networking) -> Self {
284        self.networking = Some(networking);
285        self
286    }
287
288    /// Finalize.
289    #[must_use]
290    pub fn build(self) -> EnvironmentConfig {
291        EnvironmentConfig::Cloud(CloudConfig {
292            packages: self.packages,
293            networking: self.networking,
294        })
295    }
296}
297
298// =====================================================================
299// Environment + request types
300// =====================================================================
301
302/// An environment definition.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304#[non_exhaustive]
305pub struct Environment {
306    /// Stable identifier (`env_...`).
307    pub id: String,
308    /// Wire type tag (`"environment"`).
309    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
310    pub ty: Option<String>,
311    /// Unique name within the workspace.
312    pub name: String,
313    /// Optional human-readable description.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub description: Option<String>,
316    /// Free-form key-value metadata attached at create time.
317    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
318    pub metadata: HashMap<String, String>,
319    /// Container configuration.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub config: Option<EnvironmentConfig>,
322    /// Creation timestamp.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub created_at: Option<String>,
325    /// Last-modified timestamp.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub updated_at: Option<String>,
328    /// Set when archived.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub archived_at: Option<String>,
331}
332
333/// Request body for `POST /v1/environments`.
334#[derive(Debug, Clone, Serialize)]
335#[non_exhaustive]
336pub struct CreateEnvironmentRequest {
337    /// Unique name within the workspace.
338    pub name: String,
339    /// Container configuration.
340    pub config: EnvironmentConfig,
341}
342
343impl CreateEnvironmentRequest {
344    /// Build a request.
345    #[must_use]
346    pub fn new(name: impl Into<String>, config: EnvironmentConfig) -> Self {
347        Self {
348            name: name.into(),
349            config,
350        }
351    }
352}
353
354/// Optional knobs for [`Environments::list`].
355#[derive(Debug, Clone, Default)]
356#[non_exhaustive]
357pub struct ListEnvironmentsParams {
358    /// Pagination cursor.
359    pub after: Option<String>,
360    /// Pagination cursor.
361    pub before: Option<String>,
362    /// Page size.
363    pub limit: Option<u32>,
364    /// Whether to include archived environments.
365    pub include_archived: Option<bool>,
366}
367
368impl ListEnvironmentsParams {
369    fn to_query(&self) -> Vec<(&'static str, String)> {
370        let mut q = Vec::new();
371        if let Some(a) = &self.after {
372            q.push(("after", a.clone()));
373        }
374        if let Some(b) = &self.before {
375            q.push(("before", b.clone()));
376        }
377        if let Some(l) = self.limit {
378            q.push(("limit", l.to_string()));
379        }
380        if let Some(ia) = self.include_archived {
381            q.push(("include_archived", ia.to_string()));
382        }
383        q
384    }
385}
386
387// =====================================================================
388// Namespace handle
389// =====================================================================
390
391/// Namespace handle for the Environments API.
392pub struct Environments<'a> {
393    client: &'a Client,
394}
395
396/// Request body for [`Environments::update`]. All fields optional with
397/// merge-patch semantics: omit a field to preserve.
398///
399/// `metadata` follows the same per-key delete protocol as
400/// [`MetadataPatch`](super::agents::MetadataPatch).
401#[derive(Debug, Clone, Default, Serialize)]
402#[non_exhaustive]
403pub struct UpdateEnvironmentRequest {
404    /// Replacement name (1-256 chars).
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub name: Option<String>,
407    /// Replacement description.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub description: Option<String>,
410    /// Per-key metadata patch.
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub metadata: Option<super::agents::MetadataPatch>,
413    /// Replacement environment configuration.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub config: Option<EnvironmentConfig>,
416}
417
418impl UpdateEnvironmentRequest {
419    /// Empty patch.
420    #[must_use]
421    pub fn new() -> Self {
422        Self::default()
423    }
424
425    /// Set the new name.
426    #[must_use]
427    pub fn name(mut self, name: impl Into<String>) -> Self {
428        self.name = Some(name.into());
429        self
430    }
431
432    /// Set the new description.
433    #[must_use]
434    pub fn description(mut self, description: impl Into<String>) -> Self {
435        self.description = Some(description.into());
436        self
437    }
438
439    /// Apply a metadata patch.
440    #[must_use]
441    pub fn metadata(mut self, patch: super::agents::MetadataPatch) -> Self {
442        self.metadata = Some(patch);
443        self
444    }
445
446    /// Set the new config.
447    #[must_use]
448    pub fn config(mut self, config: EnvironmentConfig) -> Self {
449        self.config = Some(config);
450        self
451    }
452}
453
454impl<'a> Environments<'a> {
455    pub(crate) fn new(client: &'a Client) -> Self {
456        Self { client }
457    }
458
459    /// `POST /v1/environments`.
460    pub async fn create(&self, request: CreateEnvironmentRequest) -> Result<Environment> {
461        let body = &request;
462        self.client
463            .execute_with_retry(
464                || {
465                    self.client
466                        .request_builder(reqwest::Method::POST, "/v1/environments")
467                        .json(body)
468                },
469                &[MANAGED_AGENTS_BETA],
470            )
471            .await
472    }
473
474    /// `GET /v1/environments/{id}`.
475    pub async fn retrieve(&self, environment_id: &str) -> Result<Environment> {
476        let path = format!("/v1/environments/{environment_id}");
477        self.client
478            .execute_with_retry(
479                || self.client.request_builder(reqwest::Method::GET, &path),
480                &[MANAGED_AGENTS_BETA],
481            )
482            .await
483    }
484
485    /// `GET /v1/environments`.
486    pub async fn list(&self, params: ListEnvironmentsParams) -> Result<Paginated<Environment>> {
487        let query = params.to_query();
488        self.client
489            .execute_with_retry(
490                || {
491                    let mut req = self
492                        .client
493                        .request_builder(reqwest::Method::GET, "/v1/environments");
494                    for (k, v) in &query {
495                        req = req.query(&[(k, v)]);
496                    }
497                    req
498                },
499                &[MANAGED_AGENTS_BETA],
500            )
501            .await
502    }
503
504    /// `POST /v1/environments/{id}`. Update an environment with
505    /// merge-patch semantics; omitted fields are preserved.
506    pub async fn update(
507        &self,
508        environment_id: &str,
509        request: UpdateEnvironmentRequest,
510    ) -> Result<Environment> {
511        let path = format!("/v1/environments/{environment_id}");
512        let body = &request;
513        self.client
514            .execute_with_retry(
515                || {
516                    self.client
517                        .request_builder(reqwest::Method::POST, &path)
518                        .json(body)
519                },
520                &[MANAGED_AGENTS_BETA],
521            )
522            .await
523    }
524
525    /// `POST /v1/environments/{id}/archive`. Read-only after archive;
526    /// existing sessions continue.
527    pub async fn archive(&self, environment_id: &str) -> Result<Environment> {
528        let path = format!("/v1/environments/{environment_id}/archive");
529        self.client
530            .execute_with_retry(
531                || self.client.request_builder(reqwest::Method::POST, &path),
532                &[MANAGED_AGENTS_BETA],
533            )
534            .await
535    }
536
537    /// `DELETE /v1/environments/{id}`. Only succeeds if no sessions
538    /// reference the environment.
539    pub async fn delete(&self, environment_id: &str) -> Result<()> {
540        let path = format!("/v1/environments/{environment_id}");
541        let _: serde_json::Value = self
542            .client
543            .execute_with_retry(
544                || self.client.request_builder(reqwest::Method::DELETE, &path),
545                &[MANAGED_AGENTS_BETA],
546            )
547            .await?;
548        Ok(())
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use pretty_assertions::assert_eq;
556    use serde_json::json;
557    use wiremock::matchers::{body_partial_json, method, path};
558    use wiremock::{Mock, MockServer, ResponseTemplate};
559
560    fn client_for(mock: &MockServer) -> Client {
561        Client::builder()
562            .api_key("sk-ant-test")
563            .base_url(mock.uri())
564            .build()
565            .unwrap()
566    }
567
568    #[test]
569    fn unrestricted_networking_serializes_minimal_object() {
570        let v = serde_json::to_value(Networking::Unrestricted).unwrap();
571        assert_eq!(v, json!({"type": "unrestricted"}));
572    }
573
574    #[test]
575    fn limited_networking_round_trips_with_flags() {
576        let n = Networking::Limited(LimitedNetworking {
577            allowed_hosts: vec!["api.example.com".into()],
578            allow_mcp_servers: Some(true),
579            allow_package_managers: Some(false),
580        });
581        let v = serde_json::to_value(&n).unwrap();
582        assert_eq!(
583            v,
584            json!({
585                "type": "limited",
586                "allowed_hosts": ["api.example.com"],
587                "allow_mcp_servers": true,
588                "allow_package_managers": false
589            })
590        );
591        let parsed: Networking = serde_json::from_value(v).unwrap();
592        assert_eq!(parsed, n);
593    }
594
595    #[test]
596    fn unknown_networking_falls_through_to_other() {
597        let raw = json!({"type": "future_net", "x": 1});
598        let parsed: Networking = serde_json::from_value(raw.clone()).unwrap();
599        match parsed {
600            Networking::Other(v) => assert_eq!(v, raw),
601            Networking::Unrestricted | Networking::Limited(_) => panic!("expected Other"),
602        }
603    }
604
605    #[test]
606    fn cloud_config_serializes_with_packages_and_networking() {
607        let cfg = EnvironmentConfig::cloud()
608            .pip(["pandas", "numpy"])
609            .npm(["express"])
610            .networking(Networking::Limited(LimitedNetworking {
611                allowed_hosts: vec!["api.example.com".into()],
612                allow_mcp_servers: Some(true),
613                allow_package_managers: Some(true),
614            }))
615            .build();
616        let v = serde_json::to_value(&cfg).unwrap();
617        assert_eq!(v["type"], "cloud");
618        assert_eq!(v["packages"]["pip"], json!(["pandas", "numpy"]));
619        assert_eq!(v["packages"]["npm"], json!(["express"]));
620        assert_eq!(v["networking"]["type"], "limited");
621    }
622
623    #[tokio::test]
624    async fn create_environment_posts_full_payload() {
625        let mock = MockServer::start().await;
626        Mock::given(method("POST"))
627            .and(path("/v1/environments"))
628            .and(body_partial_json(json!({
629                "name": "python-dev",
630                "config": {
631                    "type": "cloud",
632                    "networking": {"type": "unrestricted"}
633                }
634            })))
635            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
636                "id": "env_01",
637                "type": "environment",
638                "name": "python-dev",
639                "config": {"type": "cloud", "networking": {"type": "unrestricted"}}
640            })))
641            .mount(&mock)
642            .await;
643
644        let client = client_for(&mock);
645        let env = client
646            .managed_agents()
647            .environments()
648            .create(CreateEnvironmentRequest::new(
649                "python-dev",
650                EnvironmentConfig::cloud()
651                    .networking(Networking::Unrestricted)
652                    .build(),
653            ))
654            .await
655            .unwrap();
656        assert_eq!(env.id, "env_01");
657        assert_eq!(env.name, "python-dev");
658    }
659
660    #[tokio::test]
661    async fn list_environments_passes_include_archived_query() {
662        let mock = MockServer::start().await;
663        Mock::given(method("GET"))
664            .and(path("/v1/environments"))
665            .and(wiremock::matchers::query_param("include_archived", "true"))
666            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
667                "data": [{"id": "env_01", "name": "python-dev"}],
668                "has_more": false
669            })))
670            .mount(&mock)
671            .await;
672
673        let client = client_for(&mock);
674        let page = client
675            .managed_agents()
676            .environments()
677            .list(ListEnvironmentsParams {
678                include_archived: Some(true),
679                ..Default::default()
680            })
681            .await
682            .unwrap();
683        assert_eq!(page.data.len(), 1);
684    }
685
686    #[tokio::test]
687    async fn archive_then_delete_environment() {
688        let mock = MockServer::start().await;
689        Mock::given(method("POST"))
690            .and(path("/v1/environments/env_01/archive"))
691            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
692                "id": "env_01",
693                "name": "python-dev",
694                "archived_at": "2026-04-30T12:00:00Z"
695            })))
696            .mount(&mock)
697            .await;
698        Mock::given(method("DELETE"))
699            .and(path("/v1/environments/env_01"))
700            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
701            .mount(&mock)
702            .await;
703
704        let client = client_for(&mock);
705        let env = client
706            .managed_agents()
707            .environments()
708            .archive("env_01")
709            .await
710            .unwrap();
711        assert!(env.archived_at.is_some());
712
713        client
714            .managed_agents()
715            .environments()
716            .delete("env_01")
717            .await
718            .unwrap();
719    }
720
721    #[tokio::test]
722    async fn update_environment_posts_merge_patch() {
723        let mock = MockServer::start().await;
724        Mock::given(method("POST"))
725            .and(path("/v1/environments/env_42"))
726            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
727                "id": "env_42",
728                "type": "environment",
729                "name": "renamed",
730                "description": "new desc",
731                "metadata": {"team": "data"},
732                "config": {"type": "cloud"},
733                "created_at": "2026-04-30T12:00:00Z",
734                "updated_at": "2026-04-30T12:01:00Z"
735            })))
736            .mount(&mock)
737            .await;
738
739        let client = client_for(&mock);
740        let env = client
741            .managed_agents()
742            .environments()
743            .update(
744                "env_42",
745                UpdateEnvironmentRequest::new()
746                    .name("renamed")
747                    .description("new desc")
748                    .metadata(super::super::agents::MetadataPatch::new().set("team", "data")),
749            )
750            .await
751            .unwrap();
752        assert_eq!(env.name, "renamed");
753        assert_eq!(env.description.as_deref(), Some("new desc"));
754        assert_eq!(env.metadata.get("team").map(String::as_str), Some("data"));
755    }
756
757    #[tokio::test]
758    async fn retrieve_environment_returns_typed_record() {
759        let mock = MockServer::start().await;
760        Mock::given(method("GET"))
761            .and(path("/v1/environments/env_R1"))
762            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
763                "id": "env_R1",
764                "type": "environment",
765                "name": "python-data",
766                "description": "Python data-analysis env",
767                "metadata": {"team": "research"},
768                "config": {"type": "cloud"},
769                "created_at": "2026-04-30T12:00:00Z",
770                "updated_at": "2026-04-30T12:01:00Z"
771            })))
772            .mount(&mock)
773            .await;
774        let client = client_for(&mock);
775        let env = client
776            .managed_agents()
777            .environments()
778            .retrieve("env_R1")
779            .await
780            .unwrap();
781        assert_eq!(env.id, "env_R1");
782        assert_eq!(env.name, "python-data");
783        assert_eq!(env.description.as_deref(), Some("Python data-analysis env"));
784    }
785}