Skip to content
Open
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
1 change: 1 addition & 0 deletions app/src/ai/agent_sdk/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,7 @@ fn split_run_options(
.map(|(name, value)| (OsString::from(name), OsString::from(value))),
);

git_credentials::protect_managed_auth_environment(&mut env_vars);
// Inject cloud provider env vars.
cloud_provider::collect_env_vars(&cloud_providers, &mut env_vars)?;
env_vars.extend(task_env_vars(
Expand Down
2 changes: 1 addition & 1 deletion app/src/ai/agent_sdk/driver/cache_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pub(crate) fn repository_cache_source(
checkout_name: &str,
working_dir: &Path,
) -> RepositoryCacheSource {
let forge_host = repo.code_forge.unwrap_or_default().host();
let forge_host = repo.host();
RepositoryCacheSource {
name: format!("{}/{}", repo.owner, repo.repo),
identity: RepoIdentity::new(forge_host, &repo.owner, &repo.repo),
Expand Down
73 changes: 50 additions & 23 deletions app/src/ai/agent_sdk/driver/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ fn environment_snapshot(
.filter_map(|request| {
Some(RepositoryRevision {
code_forge: request.remote.code_forge?,
git_host: request.remote.git_host.clone(),
github_instance_uid: request.remote.github_instance_uid.clone(),
repo_owner: request.remote.owner.clone(),
repo_name: request.remote.repo.clone(),
checkout_path: checkout_path(working_dir, &request.checkout_name),
Expand Down Expand Up @@ -284,6 +286,8 @@ impl WorkspaceConfiguration {
request.remove_origin = clones[0].remove_origin;
Ok(PreparationRepository {
source: RepositoryIdentity {
git_host: repo.source.git_host.clone(),
github_instance_uid: repo.source.github_instance_uid.clone(),
code_forge: repository_forge_for_repo(&repo.source)
.ok_or(PrepareEnvironmentError::InvalidRuntimeState)?,
repo_owner: repo.source.owner.clone(),
Expand Down Expand Up @@ -356,6 +360,14 @@ impl WorkspaceConfiguration {
setup_commands: Vec<String>,
) -> Result<Self, PrepareEnvironmentError> {
let clone_requests = resolved_repository_clone_requests(&repositories)?;
merge_repos_deduped(
repositories
.iter()
.chain(&deferred_repos)
.map(|repo| repo.source.clone())
.collect(),
Vec::new(),
)?;
Ok(Self {
source_repos: repositories
.iter()
Expand Down Expand Up @@ -501,24 +513,24 @@ pub(crate) fn merge_repos_deduped(
additional_repos: Vec<SourceRepo>,
) -> Result<Vec<SourceRepo>, PrepareEnvironmentError> {
let mut seen = HashSet::new();
let mut names = HashMap::<String, (String, Option<CodeForge>)>::new();
let mut names = HashMap::new();
let mut merged = Vec::with_capacity(environment_repos.len() + additional_repos.len());

for repo in environment_repos.into_iter().chain(additional_repos) {
let forge = repo.code_forge;
let key = (forge, repo.owner.to_lowercase(), repo.repo.to_lowercase());
if !seen.insert(key) {
let key = source_repo_identity(&repo)?;
if !seen.insert(key.clone()) {
continue;
}

if let Some((owner, existing_forge)) =
names.insert(repo.repo.to_lowercase(), (repo.owner.clone(), forge))
&& (!owner.eq_ignore_ascii_case(&repo.owner) || existing_forge != forge)
if let Some((identity, owner)) = names.insert(
repo.repo.to_lowercase(),
(key.clone(), format!("{}/{}", repo.host(), repo.owner)),
) && identity != key
{
return Err(PrepareEnvironmentError::CloneDirectoryCollision {
repo_name: repo.repo,
repo_name: repo.repo.clone(),
first_owner: owner,
second_owner: repo.owner,
second_owner: format!("{}/{}", repo.host(), repo.owner),
});
}

Expand Down Expand Up @@ -779,7 +791,7 @@ async fn prepare_environment_impl(
for request in &repository_clone_requests {
git_credentials::configure_repository_git_identity_if_unset(
&working_dir.join(&request.checkout_name),
request.remote.code_forge.map(CodeForge::host).unwrap_or(""),
request.remote.host(),
git_identity_baseline.clone(),
);
}
Expand Down Expand Up @@ -872,7 +884,7 @@ fn code_forge_for_repository_forge(forge: RepositoryForge) -> CodeForge {

fn source_repo_identity(
repo: &SourceRepo,
) -> Result<(RepositoryForge, String, String), PrepareEnvironmentError> {
) -> Result<(RepositoryForge, String, Option<String>, String, String), PrepareEnvironmentError> {
let Some(forge) = repository_forge_for_repo(repo) else {
return Err(PrepareEnvironmentError::UnsupportedRepositoryForge {
repo_name: format!("{}/{}", repo.owner, repo.repo),
Expand All @@ -892,7 +904,18 @@ fn source_repo_identity(
},
);
}
Ok((forge, repo.owner.to_lowercase(), repo.repo.to_lowercase()))
repo.validate_host().map_err(|reason| {
PrepareEnvironmentError::InvalidRepositoryPreparationOverrides {
reason: reason.to_owned(),
}
})?;
Ok((
forge,
repo.host().to_owned(),
repo.github_instance_uid.clone(),
repo.owner.to_lowercase(),
repo.repo.to_lowercase(),
))
}

fn preparation_override_matches_repo(
Expand Down Expand Up @@ -939,11 +962,16 @@ fn repository_clone_requests(
let remote = match preparation_override
.and_then(|preparation_override| preparation_override.clone_from.as_ref())
{
Some(identity) => SourceRepo::new(
code_forge_for_repository_forge(identity.code_forge),
identity.repo_owner.clone(),
identity.repo_name.clone(),
),
Some(identity) => {
let mut remote = SourceRepo::new(
code_forge_for_repository_forge(identity.code_forge),
identity.repo_owner.clone(),
identity.repo_name.clone(),
);
remote.git_host = identity.git_host.clone();
remote.github_instance_uid = identity.github_instance_uid.clone();
remote
}
None => repo.clone(),
};
let remove_origin = remove_repository_origins
Expand Down Expand Up @@ -1103,12 +1131,9 @@ fn read_checkout_report(path: &Path, request_count: usize) -> Option<CheckoutRep
.author
.as_deref()
.and_then(sanitize_git_author_name);
identity.credentials.retain(|credential| {
matches!(
credential.host.as_str(),
"github.com" | "gitlab.com" | "dev.azure.com"
)
});
identity
.credentials
.retain(|credential| warp_cli::agent::validate_git_host(&credential.host).is_ok());
for credential in &mut identity.credentials {
credential.username = credential
.username
Expand Down Expand Up @@ -1178,6 +1203,8 @@ fn checkout_requests_batch(
})?;
Ok(CheckoutRequest {
source: RepositoryIdentity {
git_host: request.remote.git_host.clone(),
github_instance_uid: request.remote.github_instance_uid.clone(),
code_forge,
repo_owner: request.remote.owner.clone(),
repo_name: request.remote.repo.clone(),
Expand Down
19 changes: 9 additions & 10 deletions app/src/ai/agent_sdk/driver/environment_checkout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ fn repository_label(request: &CheckoutRequest) -> String {
failure_output::prepare_failure_output(
&format!(
"{}/{}/{} (checkout {})",
repo.code_forge.unwrap_or_default().host(),
repo.host(),
repo.owner,
repo.repo,
request.checkout_name
Expand Down Expand Up @@ -292,8 +292,8 @@ async fn collect_failure_identity(
.iter()
.filter(|outcome| outcome.failure.is_some())
.filter_map(|outcome| batch.repositories.get(outcome.request_index))
.map(|request| source_repo(request).code_forge.unwrap_or_default().host())
.filter(|host| seen.insert(*host))
.map(|request| request.source.host().to_owned())
.filter(|host| seen.insert(host.clone()))
.collect::<Vec<_>>();
let credentials = futures::future::join_all(hosts.into_iter().map(|host| async move {
let input = format!("protocol=https\nhost={host}\n\n");
Expand Down Expand Up @@ -368,21 +368,20 @@ fn source_repo(request: &CheckoutRequest) -> SourceRepo {
RepositoryForge::GitLab => CodeForge::GitLab,
RepositoryForge::AzureDevOps => CodeForge::AzureDevOps,
};
SourceRepo::new(
let mut repo = SourceRepo::new(
forge,
request.source.repo_owner.clone(),
request.source.repo_name.clone(),
)
);
repo.git_host = request.source.git_host.clone();
repo.github_instance_uid = request.source.github_instance_uid.clone();
repo
}

/// Returns the cache key that identifies the mirror for the request's source repository.
fn mirror_key(request: &CheckoutRequest) -> RepoCacheKey {
let repo = source_repo(request);
RepoCacheKey::derive(&RepoIdentity::new(
repo.code_forge.unwrap_or_default().host(),
repo.owner,
repo.repo,
))
RepoCacheKey::derive(&RepoIdentity::new(repo.host(), &repo.owner, &repo.repo))
}

/// Runs every request in the batch, returning one outcome per request in order. Requests that
Expand Down
5 changes: 5 additions & 0 deletions app/src/ai/agent_sdk/driver/environment_checkout_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ impl CheckoutBatch {

impl CheckoutRequest {
pub fn validate(&self) -> Result<(), &'static str> {
warp_cli::agent::validate_repository_host(
self.source.code_forge,
self.source.git_host.as_deref(),
self.source.github_instance_uid.as_deref(),
)?;
let owner = &self.source.repo_owner;
let repo = &self.source.repo_name;
let azure = self.source.code_forge == RepositoryForge::AzureDevOps;
Expand Down
28 changes: 28 additions & 0 deletions app/src/ai/agent_sdk/driver/environment_checkout_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,32 @@ use super::super::environment_checkout_protocol::{
#[cfg(any(target_os = "linux", target_os = "macos"))]
use super::{CachedCheckout, attempt_cached_checkout};
use super::{Git, capture, mirror_key, optional_mirror_root, run};
#[test]
fn github_mirrors_are_qualified_by_host() {
let first = CheckoutRequest {
source: RepositoryIdentity {
code_forge: RepositoryForge::GitHub,
git_host: Some("github.company.com".into()),
github_instance_uid: Some("enterprise".into()),
repo_owner: "owner".into(),
repo_name: "repo".into(),
},
checkout_name: "repo".into(),
head: None,
expected_sha: None,
fetch_branch_only: false,
remove_origin: false,
};
let mut second = first.clone();
second.source.git_host = Some("github.other.com".into());
second.source.github_instance_uid = Some("other".into());
assert_ne!(mirror_key(&first), mirror_key(&second));
assert_eq!(
super::source_repo(&first).https_clone_url(),
"https://github.company.com/owner/repo.git"
);
}

#[test]
fn strict_checkout_preserves_existing_work_and_reports_partial_success() {
fixture_test(
Expand Down Expand Up @@ -1853,6 +1879,8 @@ impl Fixture {
remove_origin: false,

source: RepositoryIdentity {
git_host: None,
github_instance_uid: None,
code_forge: RepositoryForge::GitHub,
repo_owner: "fixtures".to_owned(),
repo_name: "source".to_owned(),
Expand Down
2 changes: 2 additions & 0 deletions app/src/ai/agent_sdk/driver/environment_preparation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use crate::ai::agent_sdk::driver::environment_checkout_protocol::{CheckoutBatch,
fn request(owner: &str, repo: &str) -> CheckoutRequest {
CheckoutRequest {
source: RepositoryIdentity {
git_host: None,
github_instance_uid: None,
code_forge: RepositoryForge::GitHub,
repo_owner: owner.into(),
repo_name: repo.into(),
Expand Down
34 changes: 30 additions & 4 deletions app/src/ai/agent_sdk/driver/environment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,27 @@ fn resolved_repositories_apply_origin_policy_per_checkout() {
}

#[test]
fn deferred_policy_keeps_same_name_identities_for_unused_target_selection() {
fn github_host_collisions_include_deferred_repositories() {
let eager = resolved_repo(CodeForge::GitHub, "owner", "repo");
let mut deferred = eager.clone();
deferred.source.git_host = Some("github.company.com".into());
deferred.source.github_instance_uid = Some("enterprise".into());
let result = WorkspaceConfiguration::from_resolved(vec![eager], vec![deferred], Vec::new());
assert!(
matches!(result, Err(PrepareEnvironmentError::CloneDirectoryCollision {
first_owner, second_owner, ..
}) if first_owner == "github.com/owner" && second_owner == "github.company.com/owner")
);
}

#[test]
fn deferred_policy_keeps_identities_for_unused_target_selection() {
let root = tempfile::TempDir::new().unwrap();
let mut workspace = WorkspaceConfiguration::from_resolved(
Vec::new(),
vec![
resolved_repo(CodeForge::GitHub, "first", "api"),
resolved_repo(CodeForge::GitLab, "second", "api"),
resolved_repo(CodeForge::GitLab, "second", "api-other"),
],
Vec::new(),
)
Expand Down Expand Up @@ -330,6 +344,8 @@ fn checkout_batch<const COUNT: usize>(checkout_names: [&str; COUNT]) -> Checkout
remove_origin: false,

source: RepositoryIdentity {
git_host: None,
github_instance_uid: None,
code_forge: RepositoryForge::GitHub,
repo_owner: "warpdotdev".to_owned(),
repo_name: checkout_name.to_owned(),
Expand Down Expand Up @@ -505,6 +521,8 @@ fn commit_head_override(
sha: &str,
) -> RepositoryPreparationOverride {
RepositoryPreparationOverride {
git_host: None,
github_instance_uid: None,
code_forge,
repo_owner: owner.to_string(),
repo_name: repo.to_string(),
Expand Down Expand Up @@ -641,11 +659,15 @@ fn substitution_override(
target_repo: &str,
) -> RepositoryPreparationOverride {
RepositoryPreparationOverride {
git_host: None,
github_instance_uid: None,
code_forge: RepositoryForge::GitHub,
repo_owner: owner.to_string(),
repo_name: repo.to_string(),
head: RepositoryHeadRef::CommitSha(sha.to_string()),
clone_from: Some(RepositoryIdentity {
git_host: None,
github_instance_uid: None,
code_forge: RepositoryForge::GitHub,
repo_owner: target_owner.to_string(),
repo_name: target_repo.to_string(),
Expand Down Expand Up @@ -733,7 +755,7 @@ fn merge_repos_rejects_clone_directory_collisions() {
repo_name,
first_owner,
second_owner,
} if repo_name == "widget" && first_owner == "a" && second_owner == "b"
} if repo_name == "widget" && first_owner == "github.com/a" && second_owner == "gitlab.com/b"
));
}

Expand Down Expand Up @@ -783,7 +805,7 @@ fn resolved_empty_eager_membership_skips_preparation_with_deferred_inventory() {
#[test]
fn resolved_workspace_keeps_deferred_inventory_out_of_preparation() {
let eager = repo(CodeForge::GitHub, "acme", "billing");
let deferred = vec![resolved_repo(CodeForge::GitLab, "other", "billing")];
let deferred = vec![resolved_repo(CodeForge::GitLab, "other", "inventory")];
let workspace = WorkspaceConfiguration::from_resolved(
vec![ResolvedRepository {
expected_sha: None,
Expand Down Expand Up @@ -1012,6 +1034,8 @@ fn clone_requests_reject_a_mixed_repository_missing_forge() {
"warp".to_string(),
),
SourceRepo {
git_host: None,
github_instance_uid: None,
code_forge: None,
owner: "platform/backend".to_string(),
repo: "api".to_string(),
Expand Down Expand Up @@ -1050,6 +1074,8 @@ fn clone_requests_reject_an_omitted_forge_when_github_is_paired_with_an_unknown_
"warp".to_string(),
),
SourceRepo {
git_host: None,
github_instance_uid: None,
code_forge: None,
owner: "acme".to_string(),
repo: "widgets".to_string(),
Expand Down
Loading
Loading