diff --git a/app/src/ai/agent_sdk/driver/environment.rs b/app/src/ai/agent_sdk/driver/environment.rs index 13c3ce5f6e1..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,14 +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, + 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 } @@ -495,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() } @@ -661,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, ); 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!({