Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions app/src/ai/agent_sdk/driver/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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<RepositoryForge> {
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
}
Expand All @@ -495,16 +504,25 @@ struct RepositoryCloneRequest {
fn repository_clone_requests(
repos: &[SourceRepo],
overrides: &[RepositoryHeadOverride],
) -> Vec<RepositoryCloneRequest> {
) -> Result<Vec<RepositoryCloneRequest>, 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()
}
Expand Down Expand Up @@ -661,7 +679,12 @@ pub(super) async fn clone_repos(
working_dir: &Path,
spawner: &ModelSpawner<TerminalDriver>,
) -> 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(
Expand Down
84 changes: 81 additions & 3 deletions app/src/ai/agent_sdk/driver/environment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
);

Expand Down Expand Up @@ -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,
);

Expand Down
15 changes: 15 additions & 0 deletions crates/cloud_object_models/src/cloud_environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => "",
}
}
}
Expand All @@ -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"),
}
}
}
Expand Down
41 changes: 41 additions & 0 deletions crates/cloud_object_models/src/cloud_environment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand Down
Loading