1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[non_exhaustive]
28pub struct EnvironmentPackages {
29 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub apt: Vec<String>,
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
34 pub cargo: Vec<String>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub gem: Vec<String>,
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub go: Vec<String>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 pub npm: Vec<String>,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub pip: Vec<String>,
47}
48
49#[derive(Debug, Clone, PartialEq)]
54pub enum Networking {
55 Unrestricted,
58 Limited(LimitedNetworking),
61 Other(serde_json::Value),
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[non_exhaustive]
68pub struct LimitedNetworking {
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub allowed_hosts: Vec<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub allow_mcp_servers: Option<bool>,
76 #[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#[derive(Debug, Clone, PartialEq)]
133pub enum EnvironmentConfig {
134 Cloud(CloudConfig),
136 Other(serde_json::Value),
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
142#[non_exhaustive]
143pub struct CloudConfig {
144 #[serde(default, skip_serializing_if = "is_default_packages")]
146 pub packages: EnvironmentPackages,
147 #[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 #[must_use]
202 pub fn cloud() -> CloudConfigBuilder {
203 CloudConfigBuilder::default()
204 }
205}
206
207#[derive(Debug, Default)]
209pub struct CloudConfigBuilder {
210 packages: EnvironmentPackages,
211 networking: Option<Networking>,
212}
213
214impl CloudConfigBuilder {
215 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
283 pub fn networking(mut self, networking: Networking) -> Self {
284 self.networking = Some(networking);
285 self
286 }
287
288 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
304#[non_exhaustive]
305pub struct Environment {
306 pub id: String,
308 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
310 pub ty: Option<String>,
311 pub name: String,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub description: Option<String>,
316 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
318 pub metadata: HashMap<String, String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub config: Option<EnvironmentConfig>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub created_at: Option<String>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub updated_at: Option<String>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub archived_at: Option<String>,
331}
332
333#[derive(Debug, Clone, Serialize)]
335#[non_exhaustive]
336pub struct CreateEnvironmentRequest {
337 pub name: String,
339 pub config: EnvironmentConfig,
341}
342
343impl CreateEnvironmentRequest {
344 #[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#[derive(Debug, Clone, Default)]
356#[non_exhaustive]
357pub struct ListEnvironmentsParams {
358 pub after: Option<String>,
360 pub before: Option<String>,
362 pub limit: Option<u32>,
364 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
387pub struct Environments<'a> {
393 client: &'a Client,
394}
395
396#[derive(Debug, Clone, Default, Serialize)]
402#[non_exhaustive]
403pub struct UpdateEnvironmentRequest {
404 #[serde(skip_serializing_if = "Option::is_none")]
406 pub name: Option<String>,
407 #[serde(skip_serializing_if = "Option::is_none")]
409 pub description: Option<String>,
410 #[serde(skip_serializing_if = "Option::is_none")]
412 pub metadata: Option<super::agents::MetadataPatch>,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub config: Option<EnvironmentConfig>,
416}
417
418impl UpdateEnvironmentRequest {
419 #[must_use]
421 pub fn new() -> Self {
422 Self::default()
423 }
424
425 #[must_use]
427 pub fn name(mut self, name: impl Into<String>) -> Self {
428 self.name = Some(name.into());
429 self
430 }
431
432 #[must_use]
434 pub fn description(mut self, description: impl Into<String>) -> Self {
435 self.description = Some(description.into());
436 self
437 }
438
439 #[must_use]
441 pub fn metadata(mut self, patch: super::agents::MetadataPatch) -> Self {
442 self.metadata = Some(patch);
443 self
444 }
445
446 #[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 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 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 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 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 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 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}