From 87de07d7b2f1a29f1517caf627416d16f2be6c38 Mon Sep 17 00:00:00 2001 From: Oz Date: Mon, 13 Jul 2026 21:45:28 +0000 Subject: [PATCH] Accept cloud environments without a pinned docker image `AmbientAgentEnvironment.base_image` was a flattened, required `BaseImage` enum, so deserializing a cloud environment whose stored JSON omits the `docker_image` field failed. The server can store environments that do not pin a base image, so the client must load them. Make `base_image` an `Option` with `#[serde(default, flatten, skip_serializing_if = "Option::is_none")]`. Environments without `docker_image` now deserialize to `None`, while environments that pin an image keep the exact existing wire format (`{"docker_image": "..."}`). Serializing an environment with no image omits the key, so the format round-trips. Update the call sites that read `base_image` for display or assignment to handle the `Option`: the CLI `environment` command, the environments settings page, the conversation details panel, and the environment context-chip sidecar. Add unit tests covering deserialization without `docker_image`, preservation of other fields, omission on serialization, wire-format preservation when an image is present, and round-trip. Co-Authored-By: Oz --- app/src/ai/agent_sdk/environment.rs | 17 ++- app/src/ai/conversation_details_panel.rs | 14 ++- app/src/context_chips/display_menu.rs | 8 +- app/src/settings_view/environments_page.rs | 12 +- .../src/cloud_environment.rs | 12 +- .../src/cloud_environment_tests.rs | 103 +++++++++++++++++- 6 files changed, 153 insertions(+), 13 deletions(-) diff --git a/app/src/ai/agent_sdk/environment.rs b/app/src/ai/agent_sdk/environment.rs index 425cb999f89..7cd12ae8a20 100644 --- a/app/src/ai/agent_sdk/environment.rs +++ b/app/src/ai/agent_sdk/environment.rs @@ -301,9 +301,12 @@ impl EnvironmentCommandRunner { println!("Description: {desc}"); } match &env.base_image { - BaseImage::DockerImage(img) => { + Some(BaseImage::DockerImage(img)) => { println!("Docker image: {img}"); } + None => { + println!("Docker image: None"); + } } if env.github_repos.is_empty() { println!("Repositories: None"); @@ -952,7 +955,7 @@ impl EnvironmentCommandRunner { } if let Some(new_docker_image) = docker_image { - updated_env.base_image = BaseImage::DockerImage(new_docker_image); + updated_env.base_image = Some(BaseImage::DockerImage(new_docker_image)); } for repo in add_repos { @@ -1121,7 +1124,8 @@ struct EnvironmentInfo { name: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, - base_image: BaseImage, + #[serde(skip_serializing_if = "Option::is_none")] + base_image: Option, github_repos: Vec, setup_commands: Vec, creator_email: String, @@ -1161,7 +1165,12 @@ impl TableFormat for EnvironmentInfo { Cell::new(&self.id), Cell::new(&self.name), Cell::new(description_display), - Cell::new(self.base_image.to_string()), + Cell::new( + self.base_image + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(), + ), Cell::new(github_repos_display), Cell::new(setup_commands_display), Cell::new(&self.creator_email), diff --git a/app/src/ai/conversation_details_panel.rs b/app/src/ai/conversation_details_panel.rs index 51ba6c9776f..dad8fc4ccc8 100644 --- a/app/src/ai/conversation_details_panel.rs +++ b/app/src/ai/conversation_details_panel.rs @@ -1635,7 +1635,11 @@ impl ConversationDetailsPanel { app: &AppContext, ) -> Box { let environment_name = &env_model.name; - let docker_image = env_model.base_image.to_string(); + let docker_image = env_model + .base_image + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(); let theme = appearance.theme(); let ui_font_size = appearance.ui_font_size(); @@ -2263,7 +2267,13 @@ impl TypedActionView for ConversationDetailsPanel { if let Ok(server_id) = ServerId::try_from(env_id.as_str()) { let sync_id = SyncId::ServerId(server_id); if let Some(env) = CloudAmbientAgentEnvironment::get_by_id(&sync_id, ctx) { - let docker_image = env.model().string_model.base_image.to_string(); + let docker_image = env + .model() + .string_model + .base_image + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(); ctx.clipboard() .write(ClipboardContent::plain_text(docker_image)); self.record_copy(CopyButtonKind::DockerImage, ctx); diff --git a/app/src/context_chips/display_menu.rs b/app/src/context_chips/display_menu.rs index 41513559d52..d88bf8e61f1 100644 --- a/app/src/context_chips/display_menu.rs +++ b/app/src/context_chips/display_menu.rs @@ -664,7 +664,13 @@ impl DisplayChipMenu { Some(EnvironmentSidecarData { name: env.model().string_model.display_name(), id: env.id.to_string(), - image: env.model().string_model.base_image.to_string(), + image: env + .model() + .string_model + .base_image + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(), repos_text, }) } diff --git a/app/src/settings_view/environments_page.rs b/app/src/settings_view/environments_page.rs index 84265303660..07e8bc87708 100644 --- a/app/src/settings_view/environments_page.rs +++ b/app/src/settings_view/environments_page.rs @@ -144,7 +144,11 @@ impl EnvironmentDisplayData { id: env.id, name: model.name.clone(), description: model.description.clone(), - docker_image: model.base_image.to_string(), + docker_image: model + .base_image + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(), github_repos: model .github_repos .iter() @@ -270,7 +274,11 @@ impl EnvironmentsPageView { name: model.name.clone(), description: model.description.clone().unwrap_or_default(), selected_repos: model.github_repos.clone(), - docker_image: model.base_image.to_string(), + docker_image: model + .base_image + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(), setup_commands: model.setup_commands.clone(), } }); diff --git a/crates/cloud_object_models/src/cloud_environment.rs b/crates/cloud_object_models/src/cloud_environment.rs index 0a23d4f6c13..a468c79a39e 100644 --- a/crates/cloud_object_models/src/cloud_environment.rs +++ b/crates/cloud_object_models/src/cloud_environment.rs @@ -182,8 +182,14 @@ pub struct AmbientAgentEnvironment { #[serde(default, skip_serializing_if = "Option::is_none")] pub source_repos: Option>, /// Base image specification - #[serde(flatten)] - pub base_image: BaseImage, + /// + /// `None` represents an environment that does not pin a base image. The + /// server may store environments without a `docker_image` field, which must + /// still load in the client. `None` serializes to an absent `docker_image` + /// key, so the wire format for environments that do pin an image is + /// unchanged (`{"docker_image": "..."}`). + #[serde(default, flatten, skip_serializing_if = "Option::is_none")] + pub base_image: Option, /// List of setup commands to run after cloning #[serde(default)] pub setup_commands: Vec, @@ -212,7 +218,7 @@ impl AmbientAgentEnvironment { code_forge: None, github_repos, source_repos: None, - base_image: BaseImage::DockerImage(docker_image), + base_image: Some(BaseImage::DockerImage(docker_image)), setup_commands, providers: ProvidersConfig::default(), secrets: None, diff --git a/crates/cloud_object_models/src/cloud_environment_tests.rs b/crates/cloud_object_models/src/cloud_environment_tests.rs index 74352cdc331..32327aec931 100644 --- a/crates/cloud_object_models/src/cloud_environment_tests.rs +++ b/crates/cloud_object_models/src/cloud_environment_tests.rs @@ -28,11 +28,112 @@ fn deserialize_legacy_environment_without_providers() { ); assert_eq!( env.base_image, - BaseImage::DockerImage("ubuntu:latest".into()) + Some(BaseImage::DockerImage("ubuntu:latest".into())) ); assert_eq!(env.setup_commands, vec!["echo hello"]); } +#[test] +fn deserialize_environment_without_docker_image() { + // The server may store environments that do not pin a base image. Such an + // environment has no `docker_image` field and must still load in the client. + let json = serde_json::json!({ + "name": "no-image-env", + "github_repos": [{"owner": "warpdotdev", "repo": "warp"}], + "setup_commands": ["echo hello"] + }); + + let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap(); + assert_eq!(env.name, "no-image-env"); + assert_eq!(env.base_image, None); + assert_eq!(env.setup_commands, vec!["echo hello"]); + assert_eq!( + env.effective_repos(), + vec![SourceRepo::new( + CodeForge::GitHub, + "warpdotdev".into(), + "warp".into() + )] + ); +} + +#[test] +fn environment_without_docker_image_preserves_other_fields() { + let json = serde_json::json!({ + "name": "no-image-env", + "description": "desc", + "code_forge": "GITLAB", + "source_repos": [{"owner": "platform/backend", "repo": "api"}], + "secrets": [{"name": "GH_TOKEN"}] + }); + + let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap(); + assert_eq!(env.base_image, None); + assert_eq!(env.description.as_deref(), Some("desc")); + assert_eq!(env.effective_code_forge(), CodeForge::GitLab); + let expected_secrets = vec![EnvironmentSecretRef { + name: "GH_TOKEN".into(), + }]; + assert_eq!(env.secrets.as_deref(), Some(expected_secrets.as_slice())); +} + +#[test] +fn serialize_environment_without_docker_image_omits_field() { + let mut env = AmbientAgentEnvironment::new( + "no-image-env".into(), + None, + vec![GithubRepo::new("warpdotdev".into(), "warp".into())], + "ubuntu:latest".into(), + vec![], + ); + env.base_image = None; + + let json = serde_json::to_value(&env).unwrap(); + assert!( + !json.as_object().unwrap().contains_key("docker_image"), + "serializing an environment without a base image must not emit a docker_image key: {json}" + ); +} + +#[test] +fn serialize_environment_with_docker_image_preserves_wire_format() { + let env = AmbientAgentEnvironment::new( + "with-image-env".into(), + None, + vec![], + "ubuntu:latest".into(), + vec![], + ); + + let json = serde_json::to_value(&env).unwrap(); + // The flattened enum must keep producing the exact existing wire format: + // a top-level `docker_image` string, not a nested object. + assert_eq!( + json.get("docker_image").and_then(|v| v.as_str()), + Some("ubuntu:latest") + ); +} + +#[test] +fn roundtrip_serde_without_docker_image() { + let mut env = AmbientAgentEnvironment::new( + "no-image-rt".into(), + Some("desc".into()), + vec![GithubRepo::new("owner".into(), "repo".into())], + "alpine:latest".into(), + vec!["make build".into()], + ); + env.base_image = None; + + let serialized = serde_json::to_string(&env).unwrap(); + let deserialized: AmbientAgentEnvironment = serde_json::from_str(&serialized).unwrap(); + assert_eq!(env, deserialized); + assert!( + !serialized.contains("docker_image"), + "roundtripped JSON must not contain docker_image: {serialized}" + ); +} + #[test] fn deserialize_gitlab_environment_uses_authoritative_source_repos() { let json = serde_json::json!({