From 990a4b830baa3bb520ad3f39fe07ee200991b732 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:42:09 +0000 Subject: [PATCH 1/2] Make CodeForge deserialization forward-compatible A repo-less (code_forge: NONE) environment's task run failed with a misleading "Environment not found" error on staging, even though the GSO plainly existed server-side. The real defect: the client's CodeForge enum only recognized GITHUB and GITLAB, so an unrecognized forge value present on the wire (NONE, or any future forge) failed deserialization of the whole environment object instead of just that field. The sync layer never materialized the environment locally, so the later lookup came back empty and got reported as "not found" - pointing at entirely the wrong layer. Adds an explicit None variant for the repo-less case, plus a #[serde(other)] Unknown catch-all (the pattern already used elsewhere in this crate, e.g. ActionPermission) so a forge value newer than this client build degrades to "no usable forge" rather than failing deserialization or silently defaulting to GitHub, which would send an old client off to authenticate against the wrong host. --- app/src/ai/agent_sdk/driver/environment.rs | 6 +++ .../src/cloud_environment.rs | 15 +++++++ .../src/cloud_environment_tests.rs | 41 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/app/src/ai/agent_sdk/driver/environment.rs b/app/src/ai/agent_sdk/driver/environment.rs index 13c3ce5f6e1..36e0265256f 100644 --- a/app/src/ai/agent_sdk/driver/environment.rs +++ b/app/src/ai/agent_sdk/driver/environment.rs @@ -469,6 +469,12 @@ fn repository_forge_for_repo(repo: &SourceRepo) -> RepositoryForge { match repo.code_forge.unwrap_or_default() { CodeForge::GitHub => RepositoryForge::GitHub, CodeForge::GitLab => RepositoryForge::GitLab, + // The server never assigns an individual repository to a repo-less + // or unrecognized forge; only a container (environment) carries one. + CodeForge::None | CodeForge::Unknown => unreachable!( + "repository {:?}/{} has no valid code forge", + repo.code_forge, repo.repo + ), } } fn head_override_matches_repo(head_override: &RepositoryHeadOverride, repo: &SourceRepo) -> bool { diff --git a/crates/cloud_object_models/src/cloud_environment.rs b/crates/cloud_object_models/src/cloud_environment.rs index c739ea62d58..39e11dec034 100644 --- a/crates/cloud_object_models/src/cloud_environment.rs +++ b/crates/cloud_object_models/src/cloud_environment.rs @@ -16,13 +16,26 @@ pub enum CodeForge { GitHub, #[serde(rename = "GITLAB")] GitLab, + /// Explicit "no code forge" container value: a repo-less environment + /// that clones nothing and relies entirely on `setup_commands`. + #[serde(rename = "NONE")] + None, + // Catches a forge value this client build doesn't recognize yet (e.g. the + // server adds one before this client updates), so the rest of the + // environment still deserializes instead of the whole object failing. + #[serde(other)] + Unknown, } impl CodeForge { + /// The clonable host for this forge, empty for `None`/`Unknown` since + /// neither identifies one; callers must not fall back to `github.com` + /// for either, which would authenticate against the wrong host. pub const fn host(self) -> &'static str { match self { CodeForge::GitHub => "github.com", CodeForge::GitLab => "gitlab.com", + CodeForge::None | CodeForge::Unknown => "", } } } @@ -32,6 +45,8 @@ impl fmt::Display for CodeForge { match self { CodeForge::GitHub => write!(f, "GitHub"), CodeForge::GitLab => write!(f, "GitLab"), + CodeForge::None => write!(f, "None"), + CodeForge::Unknown => write!(f, "Unknown"), } } } diff --git a/crates/cloud_object_models/src/cloud_environment_tests.rs b/crates/cloud_object_models/src/cloud_environment_tests.rs index 9c75a933180..161edf85b2b 100644 --- a/crates/cloud_object_models/src/cloud_environment_tests.rs +++ b/crates/cloud_object_models/src/cloud_environment_tests.rs @@ -109,6 +109,47 @@ fn source_repo_checkout_ref_round_trips_and_is_optional() { assert_eq!(parsed.checkout_ref, None); } +#[test] +fn deserialize_repo_less_environment_resolves_to_none_forge() { + let json = serde_json::json!({ + "name": "repo-less-env", + "code_forge": "NONE", + "github_repos": [], + "source_repos": [], + "setup_commands": ["echo hello"] + }); + + let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap(); + + assert_eq!(env.effective_code_forge(), CodeForge::None); + assert!(env.effective_repos().is_empty()); +} + +#[test] +fn deserialize_environment_with_unrecognized_forge_still_succeeds() { + // A forge value this client build doesn't know about yet (e.g. the + // server introduces a new one before this client updates) must not fail + // deserialization of the whole environment. + let json = serde_json::json!({ + "name": "future-forge-env", + "code_forge": "BITBUCKET", + "github_repos": [], + "setup_commands": ["echo hello"] + }); + + let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap(); + + assert_eq!(env.effective_code_forge(), CodeForge::Unknown); +} + +#[test] +fn none_and_unknown_forges_have_no_clonable_host() { + // Neither identifies a real host; a caller falling back to GitHub's host + // for either would authenticate against the wrong one. + assert_eq!(CodeForge::None.host(), ""); + assert_eq!(CodeForge::Unknown.host(), ""); +} + #[test] fn deserialize_gitlab_environment_uses_authoritative_source_repos() { let json = serde_json::json!({ From 284900e5cf03284c2b3b3803519b7a92a34242f8 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:08:00 +0000 Subject: [PATCH 2/2] Make unsupported repository forges a normal error, not a panic Review caught that the panic in repository_forge_for_repo was reachable: effective_repos() copies the container forge onto every repository that omits its own, so a future server forge value on an environment WITH repositories deserializes fine (via CodeForge::Unknown) and then panics while building clone requests, whenever the run has any repository HEAD override at all - moving the failure from deserialization to run preparation instead of actually tolerating it. CodeForgeNone is enforced as a container-only value by the server today, but Unknown specifically represents a value a newer server assigned before this client recognized it, and no invariant of today's server can bound what a future one does. Historical or corrupt payloads land in the same place. A panic can't rely on an invariant a future version is free to violate. repository_forge_for_repo now returns Option, so an override can no longer match a repository with an unsupported forge. repository_clone_requests fails clearly with a new UnsupportedRepositoryForge error for such a repository, rather than silently building a clone request with an empty host. --- app/src/ai/agent_sdk/driver/environment.rs | 45 ++++++---- .../ai/agent_sdk/driver/environment_tests.rs | 84 ++++++++++++++++++- 2 files changed, 112 insertions(+), 17 deletions(-) diff --git a/app/src/ai/agent_sdk/driver/environment.rs b/app/src/ai/agent_sdk/driver/environment.rs index 36e0265256f..92a8e5d0246 100644 --- a/app/src/ai/agent_sdk/driver/environment.rs +++ b/app/src/ai/agent_sdk/driver/environment.rs @@ -56,6 +56,10 @@ pub enum PrepareEnvironmentError { first_owner: String, second_owner: String, }, + #[error( + "Repository {repo_name} has a code forge this client build doesn't support; update Warp to a version that does" + )] + UnsupportedRepositoryForge { repo_name: String }, #[error("Terminal driver error while preparing environment: {source}")] TerminalDriver { source: AgentDriverError }, } @@ -299,7 +303,7 @@ async fn prepare_environment_impl( setup_events .record_result(SetupStep::EnvironmentRepoClone, async { clone_checkout_requests( - &repository_clone_requests(source_repos, repository_head_overrides), + &repository_clone_requests(source_repos, repository_head_overrides)?, working_dir, spawner, ) @@ -465,20 +469,19 @@ fn record_codebase_indexing( }); } -fn repository_forge_for_repo(repo: &SourceRepo) -> RepositoryForge { +// `None` covers both a repo-less container forge and one this client build +// doesn't recognize. Unlike `None`, a future server can assign the latter to +// a real repository before this client updates, so callers must treat it as +// an ordinary "can't clone this" outcome rather than an invariant violation. +fn repository_forge_for_repo(repo: &SourceRepo) -> Option { match repo.code_forge.unwrap_or_default() { - CodeForge::GitHub => RepositoryForge::GitHub, - CodeForge::GitLab => RepositoryForge::GitLab, - // The server never assigns an individual repository to a repo-less - // or unrecognized forge; only a container (environment) carries one. - CodeForge::None | CodeForge::Unknown => unreachable!( - "repository {:?}/{} has no valid code forge", - repo.code_forge, repo.repo - ), + CodeForge::GitHub => Some(RepositoryForge::GitHub), + CodeForge::GitLab => Some(RepositoryForge::GitLab), + CodeForge::None | CodeForge::Unknown => None, } } fn head_override_matches_repo(head_override: &RepositoryHeadOverride, repo: &SourceRepo) -> bool { - head_override.code_forge == repository_forge_for_repo(repo) + Some(head_override.code_forge) == repository_forge_for_repo(repo) && head_override.repo_owner == repo.owner && head_override.repo_name == repo.repo } @@ -501,16 +504,25 @@ struct RepositoryCloneRequest { fn repository_clone_requests( repos: &[SourceRepo], overrides: &[RepositoryHeadOverride], -) -> Vec { +) -> Result, PrepareEnvironmentError> { repos .iter() .cloned() .map(|repo| { + // A repository this client can't identify a host for can never + // clone; fail clearly here rather than attempt one with an empty + // host, which would otherwise be the only signal something is + // wrong. + if repository_forge_for_repo(&repo).is_none() { + return Err(PrepareEnvironmentError::UnsupportedRepositoryForge { + repo_name: format!("{}/{}", repo.owner, repo.repo), + }); + } let checkout = match head_override_for_repo(overrides, &repo) { Some(head_override) => Some(head_override.head.clone()), None => repo.checkout_ref.clone().map(RepositoryHeadRef::Branch), }; - RepositoryCloneRequest { repo, checkout } + Ok(RepositoryCloneRequest { repo, checkout }) }) .collect() } @@ -667,7 +679,12 @@ pub(super) async fn clone_repos( working_dir: &Path, spawner: &ModelSpawner, ) -> Result<(), PrepareEnvironmentError> { - clone_checkout_requests(&repository_clone_requests(repos, &[]), working_dir, spawner).await + clone_checkout_requests( + &repository_clone_requests(repos, &[])?, + working_dir, + spawner, + ) + .await } async fn clone_checkout_requests( diff --git a/app/src/ai/agent_sdk/driver/environment_tests.rs b/app/src/ai/agent_sdk/driver/environment_tests.rs index b5aa4dc47d4..4e399362fea 100644 --- a/app/src/ai/agent_sdk/driver/environment_tests.rs +++ b/app/src/ai/agent_sdk/driver/environment_tests.rs @@ -344,7 +344,7 @@ fn head_overrides_replace_checkout_ref_only_for_matching_repos() { branch_head_override(RepositoryForge::GitHub, "warpdotdev", "unused", "develop"), ]; - let prepared = repository_clone_requests(&repos, &overrides); + let prepared = repository_clone_requests(&repos, &overrides).unwrap(); assert_eq!( prepared[0].checkout, @@ -358,6 +358,84 @@ fn head_overrides_replace_checkout_ref_only_for_matching_repos() { ); } +#[test] +fn clone_requests_reject_a_repository_with_an_unrecognized_forge() { + // An environment forge value newer than this client build (see + // CodeForge::Unknown) can still be assigned to a real repository by a + // newer server. Building clone requests for it must fail clearly rather + // than panic or silently attempt a clone with no host. + let repos = vec![SourceRepo::new( + CodeForge::Unknown, + "warpdotdev".to_string(), + "warp".to_string(), + )]; + + let error = repository_clone_requests(&repos, &[]).unwrap_err(); + + assert!(matches!( + error, + PrepareEnvironmentError::UnsupportedRepositoryForge { repo_name } + if repo_name == "warpdotdev/warp" + )); +} + +#[test] +fn clone_requests_reject_an_unrecognized_forge_repository_even_with_unrelated_overrides() { + // A head override targeting a different, supported-forge repository must + // not mask the unsupported repository elsewhere in the same environment: + // every repository is checked, not just the ones an override names. + let repos = vec![ + SourceRepo::new( + CodeForge::GitHub, + "warpdotdev".to_string(), + "warp".to_string(), + ), + SourceRepo::new( + CodeForge::Unknown, + "warpdotdev".to_string(), + "warp-server".to_string(), + ), + ]; + let overrides = vec![commit_head_override( + RepositoryForge::GitHub, + "warpdotdev", + "warp", + "0123456789abcdef0123456789abcdef01234567", + )]; + + let error = repository_clone_requests(&repos, &overrides).unwrap_err(); + + assert!(matches!( + error, + PrepareEnvironmentError::UnsupportedRepositoryForge { repo_name } + if repo_name == "warpdotdev/warp-server" + )); +} + +#[test] +fn head_override_validation_treats_an_unrecognized_forge_repository_as_never_matching() { + // No head override can target a repository whose forge this client can't + // represent; validation must reject it as "not declared" (an override + // that names a repository the environment doesn't have) rather than + // panicking while checking whether it matches. + let environment = environment_with_repos(vec![SourceRepo::new( + CodeForge::Unknown, + "warpdotdev".to_string(), + "warp".to_string(), + )]); + let override_for_it = commit_head_override( + RepositoryForge::GitHub, + "warpdotdev", + "warp", + "0123456789abcdef0123456789abcdef01234567", + ); + + let error = + validate_repository_head_overrides(&environment.effective_repos(), &[override_for_it]) + .expect_err("an unrecognized-forge repository can never match an override"); + assert!(error.to_string().contains("not declared")); +} + #[test] fn repository_head_override_validation_rejects_duplicates_and_mismatches() { let environment = environment_with_repos(vec![SourceRepo::new( @@ -439,7 +517,7 @@ fn applied_head_overrides_are_threaded_through_the_existing_clone_command() { "develop", )]; let command = build_parallel_clone_command( - &repository_clone_requests(&repos, &overrides), + &repository_clone_requests(&repos, &overrides).unwrap(), ShellType::Bash, ); @@ -468,7 +546,7 @@ fn applied_commit_override_uses_sha_only_fetch() { "0123456789abcdef0123456789abcdef01234567", )]; let command = build_parallel_clone_command( - &repository_clone_requests(&repos, &overrides), + &repository_clone_requests(&repos, &overrides).unwrap(), ShellType::Bash, );