From 9ba582c4b61b7a488277af4bec8d14909c891909 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:04:01 +0000 Subject: [PATCH 1/2] Support host-qualified GitHub Enterprise runtime credentials and checkout Carry validated host and instance identity through execution, forks, deferred preparation, mirrors, and recovery. Isolate managed Git and forge CLI credentials from a shared runner HOME and refresh or remove each host independently. Co-Authored-By: Warp Agent --- app/src/ai/agent_sdk/driver.rs | 1 + app/src/ai/agent_sdk/driver/cache_setup.rs | 2 +- app/src/ai/agent_sdk/driver/environment.rs | 73 ++++-- .../agent_sdk/driver/environment_checkout.rs | 19 +- .../driver/environment_checkout_protocol.rs | 5 + .../driver/environment_checkout_tests.rs | 28 +++ .../driver/environment_preparation_tests.rs | 2 + .../ai/agent_sdk/driver/environment_tests.rs | 34 ++- .../ai/agent_sdk/driver/git_credentials.rs | 219 ++++++++++++++++-- .../agent_sdk/driver/git_credentials_tests.rs | 194 +++++++++++++++- app/src/ai/agent_sdk/driver/prewarmed.rs | 11 +- .../ai/agent_sdk/driver/prewarmed_tests.rs | 2 + app/src/ai/agent_sdk/environment_snapshot.rs | 4 + .../agent_sdk/environment_snapshot_tests.rs | 2 + app/src/ai/agent_sdk/execution_config.rs | 15 +- .../ai/agent_sdk/execution_config_tests.rs | 72 ++++++ app/src/ai/agent_sdk/mod_tests.rs | 4 + app/src/lib.rs | 12 + app/src/server/server_api/ai.rs | 39 +++- app/src/server/server_api/ai_tests.rs | 15 +- .../src/cloud_environment.rs | 47 +++- .../fixtures/prewarmed_sandbox_config.json | 66 +++++- .../src/api/queries/execution_config.rs | 6 +- .../src/api/queries/task_git_credentials.rs | 16 +- .../api/queries/task_git_credentials_tests.rs | 6 + crates/warp_cli/src/agent.rs | 92 +++++++- crates/warp_graphql_schema/api/schema.graphql | 9 +- 27 files changed, 883 insertions(+), 112 deletions(-) diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index a8f41723a9b..c075950f641 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -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( diff --git a/app/src/ai/agent_sdk/driver/cache_setup.rs b/app/src/ai/agent_sdk/driver/cache_setup.rs index 7cf71e6cd6f..8d7d07400cc 100644 --- a/app/src/ai/agent_sdk/driver/cache_setup.rs +++ b/app/src/ai/agent_sdk/driver/cache_setup.rs @@ -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), diff --git a/app/src/ai/agent_sdk/driver/environment.rs b/app/src/ai/agent_sdk/driver/environment.rs index 45a6cb77a67..c33f70619e2 100644 --- a/app/src/ai/agent_sdk/driver/environment.rs +++ b/app/src/ai/agent_sdk/driver/environment.rs @@ -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), @@ -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(), @@ -356,6 +360,14 @@ impl WorkspaceConfiguration { setup_commands: Vec, ) -> Result { 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() @@ -501,24 +513,24 @@ pub(crate) fn merge_repos_deduped( additional_repos: Vec, ) -> Result, PrepareEnvironmentError> { let mut seen = HashSet::new(); - let mut names = HashMap::)>::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), }); } @@ -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(), ); } @@ -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), PrepareEnvironmentError> { let Some(forge) = repository_forge_for_repo(repo) else { return Err(PrepareEnvironmentError::UnsupportedRepositoryForge { repo_name: format!("{}/{}", repo.owner, repo.repo), @@ -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( @@ -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 @@ -1103,12 +1131,9 @@ fn read_checkout_report(path: &Path, request_count: usize) -> Option String { failure_output::prepare_failure_output( &format!( "{}/{}/{} (checkout {})", - repo.code_forge.unwrap_or_default().host(), + repo.host(), repo.owner, repo.repo, request.checkout_name @@ -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::>(); let credentials = futures::future::join_all(hosts.into_iter().map(|host| async move { let input = format!("protocol=https\nhost={host}\n\n"); @@ -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 diff --git a/app/src/ai/agent_sdk/driver/environment_checkout_protocol.rs b/app/src/ai/agent_sdk/driver/environment_checkout_protocol.rs index e96c0ac4242..a2a75593eb3 100644 --- a/app/src/ai/agent_sdk/driver/environment_checkout_protocol.rs +++ b/app/src/ai/agent_sdk/driver/environment_checkout_protocol.rs @@ -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; diff --git a/app/src/ai/agent_sdk/driver/environment_checkout_tests.rs b/app/src/ai/agent_sdk/driver/environment_checkout_tests.rs index 0afb9cd795f..43bd9074337 100644 --- a/app/src/ai/agent_sdk/driver/environment_checkout_tests.rs +++ b/app/src/ai/agent_sdk/driver/environment_checkout_tests.rs @@ -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( @@ -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(), diff --git a/app/src/ai/agent_sdk/driver/environment_preparation_tests.rs b/app/src/ai/agent_sdk/driver/environment_preparation_tests.rs index ffb19a761e9..3dae7bc7cfc 100644 --- a/app/src/ai/agent_sdk/driver/environment_preparation_tests.rs +++ b/app/src/ai/agent_sdk/driver/environment_preparation_tests.rs @@ -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(), diff --git a/app/src/ai/agent_sdk/driver/environment_tests.rs b/app/src/ai/agent_sdk/driver/environment_tests.rs index f6483a23f9c..7acc8c98d1d 100644 --- a/app/src/ai/agent_sdk/driver/environment_tests.rs +++ b/app/src/ai/agent_sdk/driver/environment_tests.rs @@ -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(), ) @@ -330,6 +344,8 @@ fn checkout_batch(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(), @@ -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(), @@ -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(), @@ -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" )); } @@ -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, @@ -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(), @@ -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(), diff --git a/app/src/ai/agent_sdk/driver/git_credentials.rs b/app/src/ai/agent_sdk/driver/git_credentials.rs index f23a81fdd98..4d4dda328b4 100644 --- a/app/src/ai/agent_sdk/driver/git_credentials.rs +++ b/app/src/ai/agent_sdk/driver/git_credentials.rs @@ -87,7 +87,40 @@ pub(crate) fn ensure_workload_token_available() -> Result<(), TaskGitCredentials Ok(()) } +pub(crate) fn protect_managed_auth_environment(env: &mut HashMap) { + if std::env::var_os("WARP_GIT_AUTH_DIR").is_none() { + return; + } + for name in [ + "WARP_GIT_AUTH_DIR", + "WARP_GIT_AUTH_BINDING", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + "GH_CONFIG_DIR", + "GLAB_CONFIG_DIR", + ] { + if let Some(value) = std::env::var_os(name) { + env.insert(name.into(), value); + } + } + for name in [ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GH_HOST", + "GITHUB_ACCESS_TOKEN", + "GIT_CONFIG_PARAMETERS", + ] { + env.insert(name.into(), OsString::new()); + } + env.insert("GIT_CONFIG_COUNT".into(), "0".into()); +} + fn home_dir() -> Result { + if let Some(root) = std::env::var_os("WARP_GIT_AUTH_DIR") { + return Ok(root.into()); + } let home = if cfg!(windows) { git_home_dir(|key| std::env::var_os(key)) } else { @@ -97,6 +130,52 @@ fn home_dir() -> Result { .ok_or_else(|| anyhow::anyhow!("Could not determine home directory")) } +#[cfg(not(target_family = "wasm"))] +pub(crate) fn isolate_managed_auth(binding: &str) -> Result<()> { + if std::env::var("WARP_GIT_AUTH_BINDING").as_deref() == Ok(binding) { + return Ok(()); + } + let directory = tempfile::Builder::new().prefix("oz-git-auth-").tempdir()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700))?; + } + let root = directory.path(); + write_secret_file(&root.join(".gitconfig"), "")?; + let mut command = BlockingCommand::new(std::env::current_exe()?); + command + .args(std::env::args_os().skip(1)) + .env("WARP_GIT_AUTH_BINDING", binding) + .env("WARP_GIT_AUTH_DIR", root) + .env("GIT_CONFIG_GLOBAL", root.join(".gitconfig")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GH_CONFIG_DIR", root.join("gh")) + .env("GLAB_CONFIG_DIR", root.join(".config/glab-cli")); + for name in [ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GH_HOST", + "GITHUB_ACCESS_TOKEN", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG", + "GIT_DIR", + "GIT_WORK_TREE", + ] { + command.env_remove(name); + } + // A supervisor owns the temporary credentials until the child exits; changing HOME would + // isolate unrelated runner state, while mutating this process's environment is not thread-safe. + let status = command + .status() + .context("Failed to enter managed Git authentication scope")?; + drop(directory); + std::process::exit(status.code().unwrap_or(1)); +} + /// Resolves `~` the way git does on Windows: from the environment, not from the profile of the /// account the process runs as. The credential files must land where git's `store` helper reads /// them, and `dirs::home_dir` disagrees with git when a process running as SYSTEM carries another @@ -264,7 +343,6 @@ fn write_azure_cli_auth(credentials: &[GitCredential], home: &Path) -> Result<() let auth_dir = prepare_azure_devops_auth_dir(home)?; return write_azure_cli_token(&auth_dir, &credential.token); } - let Some(azure_cli) = resolve_executable(AZURE_CLI_FILENAME) else { log::warn!("Azure CLI not found; skipped Azure DevOps CLI authentication"); return Ok(()); @@ -307,11 +385,85 @@ fn prepend_azure_cli_wrapper_to_path_for_home( } fn git_credentials_line(cred: &GitCredential) -> String { - let userinfo = match &cred.username { - Some(username) => format!("{username}:{}", cred.token), - None => format!("x-access-token:{}", cred.token), - }; - format!("https://{}@{}", userinfo, cred.host) + let mut url = url::Url::parse(&format!("https://{}", cred.host)).expect("validated git host"); + url.set_username(cred.username.as_deref().unwrap_or("x-access-token")) + .expect("HTTPS username"); + url.set_password(Some(&cred.token)).expect("HTTPS password"); + url.as_str().trim_end_matches('/').to_owned() +} + +fn read_yaml_mapping(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(content) => serde_yaml::from_str(&content) + .map_err(|_| anyhow::anyhow!("Invalid forge CLI configuration")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(serde_yaml::Mapping::new()) + } + Err(error) => Err(error).context("Failed to read forge CLI configuration"), + } +} + +fn remove_credentials_for_hosts(hosts: &[String]) -> Result<()> { + if hosts.is_empty() { + return Ok(()); + } + for host in hosts { + warp_cli::agent::validate_git_host(host).map_err(anyhow::Error::msg)?; + } + let home = home_dir()?; + let path = home.join(".git-credentials"); + if path.exists() { + let content = std::fs::read_to_string(&path)?; + let retained = content + .lines() + .filter(|line| { + !host_of_credentials_line(line) + .is_some_and(|host| hosts.iter().any(|removed| removed == host)) + }) + .collect::>() + .join("\n"); + let tmp_path = home.join(".git-credentials.tmp"); + write_secret_file(&tmp_path, &format!("{retained}\n"))?; + std::fs::rename(tmp_path, path)?; + } + for path in [ + gh_config_dir(&home).join(GH_HOSTS_FILENAME), + home.join(".config/glab-cli").join(GLAB_CONFIG_FILENAME), + ] { + if !path.exists() { + continue; + } + let mut config = read_yaml_mapping(&path)?; + let entries = if path.file_name() == Some(OsStr::new(GLAB_CONFIG_FILENAME)) { + config + .get_mut(&serde_yaml::Value::from("hosts")) + .and_then(serde_yaml::Value::as_mapping_mut) + } else { + Some(&mut config) + }; + if let Some(entries) = entries { + for host in hosts { + entries.remove(&serde_yaml::Value::from(host.clone())); + } + } + let yaml = serde_yaml::to_string(&config) + .context("Failed to serialize forge CLI configuration")?; + let tmp_path = path.with_extension("tmp"); + write_secret_file(&tmp_path, &yaml)?; + std::fs::rename(tmp_path, path)?; + } + if hosts.iter().any(|host| host == AZURE_DEVOPS_HOST) { + let path = azure_devops_auth_dir(&home).join(AZURE_DEVOPS_TOKEN_FILENAME); + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + if let Ok(mut identities) = HOST_IDENTITIES.write() { + identities.retain(|identity| !hosts.contains(&identity.host)); + } + Ok(()) } fn host_of_credentials_line(line: &str) -> Option<&str> { @@ -325,11 +477,16 @@ fn unique_credentials_by_host(credentials: &[GitCredential]) -> Result Result<()> { /// The directory `gh` reads its config from, in `gh`'s own precedence order. On Windows `gh` /// ignores `~/.config/gh` whenever `APPDATA` is set. fn gh_config_dir(home: &Path) -> PathBuf { + if std::env::var_os("WARP_GIT_AUTH_DIR").is_some() { + return home.join("gh"); + } resolve_gh_config_dir(home, cfg!(windows), |key| std::env::var_os(key)) } @@ -462,7 +622,10 @@ fn resolve_gh_config_dir( fn write_gh_hosts_yml(credentials: &[GitCredential], gh_config_dir: &Path) -> Result<()> { let github_credentials = credentials .iter() - .filter(|credential| credential.host == GITHUB_HOST) + .filter(|credential| { + credential.code_forge == Some(cloud_object_models::CodeForge::GitHub) + || (credential.code_forge.is_none() && credential.host == GITHUB_HOST) + }) .collect::>(); if github_credentials.is_empty() { return Ok(()); @@ -472,15 +635,17 @@ fn write_gh_hosts_yml(credentials: &[GitCredential], gh_config_dir: &Path) -> Re let path = gh_config_dir.join(GH_HOSTS_FILENAME); let tmp_path = gh_config_dir.join(format!("{GH_HOSTS_FILENAME}.tmp")); - let mut yaml = String::new(); + let mut hosts = read_yaml_mapping(&path)?; for cred in github_credentials { - yaml.push_str(&format!("{}:\n", cred.host)); - yaml.push_str(&format!(" oauth_token: {}\n", cred.token)); - yaml.push_str(" git_protocol: https\n"); + let mut entry = serde_yaml::Mapping::new(); + entry.insert("oauth_token".into(), cred.token.clone().into()); + entry.insert("git_protocol".into(), "https".into()); if let Some(username) = &cred.username { - yaml.push_str(&format!(" user: {username}\n")); + entry.insert("user".into(), username.clone().into()); } + hosts.insert(cred.host.clone().into(), entry.into()); } + let yaml = serde_yaml::to_string(&hosts).context("Failed to serialize GitHub CLI hosts")?; write_secret_file(&tmp_path, &yaml)?; std::fs::rename(&tmp_path, &path).with_context(|| { @@ -656,8 +821,7 @@ pub(crate) fn configure_assignment_git_credentials( for (path, host) in managed_repositories { let credential = credentials .iter() - .find(|credential| credential.host == *host) - .or_else(|| credentials.first()); + .find(|credential| credential.host == *host); let (name, email) = identity_of(credential); run_repository_git_config(path, "user.name", &name); run_repository_git_config(path, "user.email", &email); @@ -716,7 +880,12 @@ fn run_git_config_add(key: &str, value: &str) { /// URL forms, so operations on either form use HTTPS credentials instead /// of looking for an SSH key. pub(crate) fn setup_git_config(credentials: &[GitCredential]) { - run_git_config("credential.helper", READ_ONLY_CREDENTIAL_HELPER); + let helper = if std::env::var_os("WARP_GIT_AUTH_DIR").is_some() { + r#"!f() { test "$1" = get && exec git credential-store --file "$WARP_GIT_AUTH_DIR/.git-credentials" get; }; f"# + } else { + READ_ONLY_CREDENTIAL_HELPER + }; + run_git_config("credential.helper", helper); // Use --add for both forms per host so all values coexist as a // multi-value key rather than each entry overwriting the previous one. for cred in credentials { @@ -786,10 +955,7 @@ fn select_host_identity<'a>( identities: &'a [HostIdentity], host: &str, ) -> Option<&'a HostIdentity> { - identities - .iter() - .find(|identity| identity.host == host) - .or_else(|| identities.first()) + identities.iter().find(|identity| identity.host == host) } fn recorded_identity_for_host(host: &str) -> Option<(String, String)> { @@ -935,11 +1101,24 @@ fn run_repository_git_config(repository_dir: &std::path::Path, key: &str, value: /// when some refreshed and others failed. Returns `Err` when the local write /// fails. fn apply_refreshed_credentials(response: TaskGitCredentialsResponse) -> Result { - if response.credentials.is_empty() && response.failed_hosts.is_empty() { + if response.credentials.is_empty() + && response.failed_hosts.is_empty() + && response.removed_hosts.is_empty() + { log::debug!("No git credentials returned during refresh; skipping file write"); return Ok(true); } + if response.removed_hosts.iter().any(|host| { + response.failed_hosts.contains(host) + || response + .credentials + .iter() + .any(|credential| &credential.host == host) + }) { + bail!("Conflicting git credential refresh outcomes"); + } + remove_credentials_for_hosts(&response.removed_hosts)?; write_git_credentials_with_failures(&response.credentials, &response.failed_hosts) .context("Failed to write refreshed git credentials")?; log::info!("Git credentials refreshed successfully"); diff --git a/app/src/ai/agent_sdk/driver/git_credentials_tests.rs b/app/src/ai/agent_sdk/driver/git_credentials_tests.rs index 557bf9ca96c..d63caad9c1f 100644 --- a/app/src/ai/agent_sdk/driver/git_credentials_tests.rs +++ b/app/src/ai/agent_sdk/driver/git_credentials_tests.rs @@ -93,6 +93,7 @@ fn assignment_replaces_standby_author_and_credentials() -> Result<()> { configure_git_credentials(&[github_credential()])?; configure_repository_git_identity_if_unset(home, GITHUB_HOST, global_git_identity()); let creator = GitCredential { + code_forge: None, token: "creator-token".into(), username: Some("creator".into()), email: Some("creator@example.com".into()), @@ -128,6 +129,7 @@ fn assignment_preserves_setup_authors() -> Result<()> { run_repository_git_config(home, "user.name", "Setup Local"); run_repository_git_config(home, "user.email", "local@example.com"); let creator = GitCredential { + code_forge: None, token: "creator-token".into(), username: Some("creator".into()), email: Some("creator@example.com".into()), @@ -260,6 +262,7 @@ fn retained_scope_refresh_subprocess() { }) .returning(|_, _, _, _| { Ok(TaskGitCredentialsResponse { + removed_hosts: Vec::new(), credentials: Vec::new(), failed_hosts: Vec::new(), }) @@ -411,6 +414,7 @@ fn write_gh_hosts_yml_uses_gh_cli_filename() -> Result<()> { write_gh_hosts_yml( &[GitCredential { + code_forge: None, token: "token".to_string(), username: Some("octocat".to_string()), email: Some("octocat@example.com".to_string()), @@ -429,9 +433,10 @@ fn write_gh_hosts_yml_uses_gh_cli_filename() -> Result<()> { let hosts = std::fs::read_to_string(hosts_path)?; assert!(hosts.contains("github.com:")); - assert!(hosts.contains(" oauth_token: token")); - assert!(hosts.contains(" git_protocol: https")); - assert!(hosts.contains(" user: octocat")); + let hosts: serde_yaml::Value = serde_yaml::from_str(&hosts)?; + assert_eq!(hosts["github.com"]["oauth_token"], "token"); + assert_eq!(hosts["github.com"]["git_protocol"], "https"); + assert_eq!(hosts["github.com"]["user"], "octocat"); Ok(()) } @@ -444,12 +449,14 @@ fn write_gh_hosts_yml_excludes_gitlab_credentials() -> Result<()> { write_gh_hosts_yml( &[ GitCredential { + code_forge: None, token: "github-token".to_string(), username: Some("octocat".to_string()), email: None, host: "github.com".to_string(), }, GitCredential { + code_forge: None, token: "gitlab-token".to_string(), username: Some("oauth2".to_string()), email: None, @@ -474,6 +481,7 @@ fn write_gh_hosts_yml_skips_gitlab_only_credentials() -> Result<()> { write_gh_hosts_yml( &[GitCredential { + code_forge: None, token: "gitlab-token".to_string(), username: Some("oauth2".to_string()), email: None, @@ -553,8 +561,173 @@ fn gh_config_dir_follows_gh_precedence() { } } +#[test] +fn github_hosts_refresh_and_remove_independently() -> Result<()> { + with_assignment_git_home("github_hosts_refresh_and_remove_independently", |home| { + let enterprise = GitCredential { + host: "github.company.com".into(), + code_forge: Some(cloud_object_models::CodeForge::GitHub), + token: "enterprise-token".into(), + username: Some("enterprise-user".into()), + email: None, + }; + write_git_credentials(&[github_credential(), enterprise.clone()])?; + assert!(!apply_refreshed_credentials(TaskGitCredentialsResponse { + credentials: vec![GitCredential { + token: "rotated-token".into(), + ..enterprise + }], + failed_hosts: vec!["github.com".into()], + removed_hosts: Vec::new(), + })?); + let hosts = serde_yaml::Value::Mapping(read_yaml_mapping( + &gh_config_dir(home).join(GH_HOSTS_FILENAME), + )?); + assert_eq!( + hosts["github.com"]["oauth_token"].as_str(), + Some("github-token") + ); + assert_eq!( + hosts["github.company.com"]["oauth_token"].as_str(), + Some("rotated-token") + ); + assert!(apply_refreshed_credentials(TaskGitCredentialsResponse { + removed_hosts: vec!["github.company.com".into()], + ..Default::default() + })?); + let hosts = serde_yaml::Value::Mapping(read_yaml_mapping( + &gh_config_dir(home).join(GH_HOSTS_FILENAME), + )?); + assert!(hosts.get("github.company.com").is_none()); + assert_eq!( + hosts["github.com"]["oauth_token"].as_str(), + Some("github-token") + ); + let git = std::fs::read_to_string(home.join(".git-credentials"))?; + assert!(!git.contains("company.com")); + assert!(git.contains("github-token@github.com")); + Ok(()) + }) +} + +#[test] +fn unknown_hosts_do_not_become_github_without_explicit_metadata() -> Result<()> { + let directory = tempfile::tempdir()?; + write_gh_hosts_yml( + &[GitCredential { + host: "unknown.company.com".into(), + ..github_credential() + }], + directory.path(), + )?; + assert!(!directory.path().join(GH_HOSTS_FILENAME).exists()); + Ok(()) +} + +#[test] +fn managed_auth_is_private_with_a_shared_home() -> Result<()> { + let Some(shared_home) = std::env::var_os("WARP_TEST_SHARED_AUTH_HOME") else { + let home = tempfile::tempdir()?; + std::fs::write(home.path().join(".git-credentials"), "runner-credentials")?; + std::fs::write( + home.path().join(".gitconfig"), + "[user]\nname = Runner\n[include]\npath = runner-config\n", + )?; + let output = BlockingCommand::new(std::env::current_exe()?) + .args(["--exact", "ai::agent_sdk::driver::git_credentials::tests::managed_auth_is_private_with_a_shared_home", "--nocapture"]) + .env("WARP_TEST_SHARED_AUTH_HOME", home.path()) + .env("HOME", home.path()) + .env("GH_TOKEN", "inherited-token") + .env("GH_ENTERPRISE_TOKEN", "inherited-enterprise-token") + .env("GIT_CONFIG_GLOBAL", home.path().join(".gitconfig")) + .env_remove("WARP_GIT_AUTH_BINDING") + .env_remove("WARP_GIT_AUTH_DIR") + .output()?; + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + std::fs::read_to_string(home.path().join(".git-credentials"))?, + "runner-credentials" + ); + assert_eq!( + std::fs::read_to_string(home.path().join(".gitconfig"))?, + "[user]\nname = Runner\n[include]\npath = runner-config\n" + ); + let scoped_root = std::fs::read_to_string(home.path().join("scoped-root"))?; + assert!(!Path::new(&scoped_root).exists()); + return Ok(()); + }; + isolate_managed_auth("isolation-test")?; + assert_eq!(std::env::var_os("HOME"), Some(shared_home.clone())); + std::fs::write( + PathBuf::from(&shared_home).join("scoped-root"), + home_dir()?.to_string_lossy().as_bytes(), + )?; + assert_eq!(std::fs::read_to_string(home_dir()?.join(".gitconfig"))?, ""); + assert_ne!(home_dir()?, PathBuf::from(shared_home)); + assert!(std::env::var_os("GH_TOKEN").is_none()); + assert!(std::env::var_os("GH_ENTERPRISE_TOKEN").is_none()); + configure_git_credentials(&[ + github_credential(), + GitCredential { + code_forge: Some(cloud_object_models::CodeForge::GitHub), + host: "github.company.com".into(), + token: "enterprise:@/#?token".into(), + username: Some("enterprise-user".into()), + email: None, + }, + ])?; + let mut overlay = HashMap::from([ + (OsString::from("GH_TOKEN"), OsString::from("overlay-token")), + ( + OsString::from("GIT_CONFIG_GLOBAL"), + OsString::from("/shared-config"), + ), + ]); + protect_managed_auth_environment(&mut overlay); + assert_eq!(overlay[OsStr::new("GH_TOKEN")], OsString::new()); + assert_eq!( + overlay[OsStr::new("GIT_CONFIG_GLOBAL")], + std::env::var_os("GIT_CONFIG_GLOBAL").unwrap() + ); + let rewritten = BlockingCommand::new("git") + .args([ + "ls-remote", + "--get-url", + "git@github.company.com:owner/repo.git", + ]) + .envs(&overlay) + .output()?; + assert!(rewritten.status.success()); + assert_eq!( + String::from_utf8(rewritten.stdout)?.trim(), + "https://github.company.com/owner/repo.git" + ); + let mut child = BlockingCommand::new("git") + .args(["credential", "fill"]) + .envs(&overlay) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + child + .stdin + .take() + .unwrap() + .write_all(b"protocol=https\nhost=github.company.com\n\n")?; + let result = child.wait_with_output()?; + assert!(result.status.success()); + assert!(String::from_utf8(result.stdout)?.contains("password=enterprise:@/#?token")); + Ok(()) +} + fn github_credential() -> GitCredential { GitCredential { + code_forge: None, token: "github-token".to_string(), username: None, email: None, @@ -563,6 +736,7 @@ fn github_credential() -> GitCredential { } fn azure_devops_credential(token: &str) -> GitCredential { GitCredential { + code_forge: None, token: token.to_string(), username: None, email: None, @@ -572,6 +746,7 @@ fn azure_devops_credential(token: &str) -> GitCredential { fn gitlab_credential() -> GitCredential { GitCredential { + code_forge: None, token: "gitlab-token".to_string(), username: Some("oauth2".to_string()), email: None, @@ -733,6 +908,7 @@ fn merged_credentials_preserve_an_unrelated_host() { fn credential_diagnostics_reports_presence_without_values() { let diagnostics = credential_diagnostics( &[GitCredential { + code_forge: None, token: "secret-token".to_string(), username: Some("oauth2".to_string()), email: Some("user@example.com".to_string()), @@ -779,15 +955,14 @@ fn repository_identity_selects_the_matching_host() { } #[test] -fn repository_identity_falls_back_to_the_primary_forge() { +fn repository_identity_never_borrows_another_hosts_author() { let identities = [HostIdentity { host: "github.com".to_string(), name: "warp-agent[bot]".to_string(), email: "bot@users.noreply.github.com".to_string(), }]; - let matched = select_host_identity(&identities, "gitlab.com").expect("an identity"); - assert_eq!(matched.name, "warp-agent[bot]"); + assert!(select_host_identity(&identities, "gitlab.com").is_none()); assert!(select_host_identity(&[], "github.com").is_none()); } @@ -941,6 +1116,7 @@ fn unique_credentials_reject_conflicting_duplicate_hosts() { #[test] fn bootstrap_rejects_a_one_host_failure() { let error = credentials_for_bootstrap(TaskGitCredentialsResponse { + removed_hosts: Vec::new(), credentials: vec![github_credential()], failed_hosts: vec!["gitlab.com".to_string()], }) @@ -953,6 +1129,7 @@ fn bootstrap_rejects_a_one_host_failure() { #[test] fn bootstrap_accepts_complete_multi_host_credentials() { let credentials = credentials_for_bootstrap(TaskGitCredentialsResponse { + removed_hosts: Vec::new(), credentials: vec![github_credential(), gitlab_credential()], failed_hosts: vec![], }) @@ -968,6 +1145,7 @@ fn write_glab_config_uses_glab_cli_filename() -> Result<()> { write_glab_config( &[GitCredential { + code_forge: None, token: "gitlab-token".to_string(), username: Some("oauth2".to_string()), email: Some("user@example.com".to_string()), @@ -1002,12 +1180,14 @@ fn write_glab_config_excludes_github_credentials() -> Result<()> { write_glab_config( &[ GitCredential { + code_forge: None, token: "github-token".to_string(), username: Some("octocat".to_string()), email: None, host: "github.com".to_string(), }, GitCredential { + code_forge: None, token: "gitlab-token".to_string(), username: Some("oauth2".to_string()), email: None, @@ -1031,6 +1211,7 @@ fn write_glab_config_skips_github_only_credentials() -> Result<()> { write_glab_config( &[GitCredential { + code_forge: None, token: "github-token".to_string(), username: Some("octocat".to_string()), email: None, @@ -1050,6 +1231,7 @@ fn refreshed_credentials_return_err_when_the_local_write_fails() { conflicting.token = "other-github-token".to_string(); let error = apply_refreshed_credentials(TaskGitCredentialsResponse { + removed_hosts: Vec::new(), credentials: vec![github_credential(), conflicting], failed_hosts: vec![], }) diff --git a/app/src/ai/agent_sdk/driver/prewarmed.rs b/app/src/ai/agent_sdk/driver/prewarmed.rs index fe8f2fe135b..3e936bfd73a 100644 --- a/app/src/ai/agent_sdk/driver/prewarmed.rs +++ b/app/src/ai/agent_sdk/driver/prewarmed.rs @@ -172,12 +172,7 @@ impl AgentDriver { Ok(prepared .repositories .iter() - .map(|repo| { - ( - me.working_dir.join(&repo.repo), - repo.code_forge.map(|forge| forge.host()).unwrap_or(""), - ) - }) + .map(|repo| (me.working_dir.join(&repo.repo), repo.host().to_owned())) .collect::>()) }) .await??; @@ -188,6 +183,10 @@ impl AgentDriver { ) .await .map_err(AgentDriverError::GitCredentialsFetchFailed)?; + let repositories = repositories + .iter() + .map(|(path, host)| (path.clone(), host.as_str())) + .collect::>(); git_credentials::configure_assignment_git_credentials(&credentials, &repositories) .map_err(AgentDriverError::ConfigBuildFailed)?; AgentDriverRunner::refresh_team_metadata(spawner, true).await?; diff --git a/app/src/ai/agent_sdk/driver/prewarmed_tests.rs b/app/src/ai/agent_sdk/driver/prewarmed_tests.rs index 4adf8051fe5..99773e3bad2 100644 --- a/app/src/ai/agent_sdk/driver/prewarmed_tests.rs +++ b/app/src/ai/agent_sdk/driver/prewarmed_tests.rs @@ -317,6 +317,8 @@ fn prewarmed_accepts_server_assignments_and_rejects_unsupported_runs() { fn prewarmed_rejects_deferred_repositories_without_client_support() { let mut configuration = config(); configuration.deferred_repositories = Some(vec![ExecutionRepository { + git_host: None, + github_instance_uid: None, forge: CodeForge::GitHub, owner: "benchmarks".into(), name: "frozen-api".into(), diff --git a/app/src/ai/agent_sdk/environment_snapshot.rs b/app/src/ai/agent_sdk/environment_snapshot.rs index 43df81199d5..62b6d889d81 100644 --- a/app/src/ai/agent_sdk/environment_snapshot.rs +++ b/app/src/ai/agent_sdk/environment_snapshot.rs @@ -18,6 +18,8 @@ const REPORT_MAX_ATTEMPTS: usize = 3; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct RepositoryRevision { pub code_forge: CodeForge, + pub git_host: Option, + pub github_instance_uid: Option, pub repo_owner: String, pub repo_name: String, pub checkout_path: String, @@ -49,6 +51,8 @@ impl From for AgentRunEnvironmentSnapshotRequest { .into_iter() .map(|revision| AgentRunRepositoryRevision { code_forge: revision.code_forge, + git_host: revision.git_host, + github_instance_uid: revision.github_instance_uid, repo_owner: revision.repo_owner, repo_name: revision.repo_name, checkout_path: revision.checkout_path, diff --git a/app/src/ai/agent_sdk/environment_snapshot_tests.rs b/app/src/ai/agent_sdk/environment_snapshot_tests.rs index 784a5b688d7..77144ead1e9 100644 --- a/app/src/ai/agent_sdk/environment_snapshot_tests.rs +++ b/app/src/ai/agent_sdk/environment_snapshot_tests.rs @@ -18,6 +18,8 @@ fn request() -> AgentRunEnvironmentSnapshotRequest { EnvironmentSnapshot { captured_at: Utc::now(), repositories: vec![RepositoryRevision { + git_host: None, + github_instance_uid: None, code_forge: CodeForge::GitHub, repo_owner: "warpdotdev".to_string(), repo_name: "warp".to_string(), diff --git a/app/src/ai/agent_sdk/execution_config.rs b/app/src/ai/agent_sdk/execution_config.rs index 2eb87a14e65..0e485871e05 100644 --- a/app/src/ai/agent_sdk/execution_config.rs +++ b/app/src/ai/agent_sdk/execution_config.rs @@ -68,11 +68,11 @@ fn forge(forge: CodeForge) -> anyhow::Result { } fn source_repo(source: ConfigSourceRepo) -> anyhow::Result { - Ok(SourceRepo::new( - forge(source.code_forge)?, - source.owner, - source.repo, - )) + let mut repo = SourceRepo::new(forge(source.code_forge)?, source.owner, source.repo); + repo.git_host = source.git_host; + repo.github_instance_uid = source.github_instance_uid.map(cynic::Id::into_inner); + repo.validate_host().map_err(anyhow::Error::msg)?; + Ok(repo) } pub(super) fn repositories( @@ -81,7 +81,10 @@ pub(super) fn repositories( repositories .into_iter() .map(|repo| { - let source = SourceRepo::new(forge(repo.forge)?, repo.owner, repo.name); + let mut source = SourceRepo::new(forge(repo.forge)?, repo.owner, repo.name); + source.git_host = repo.git_host; + source.github_instance_uid = repo.github_instance_uid.map(cynic::Id::into_inner); + source.validate_host().map_err(anyhow::Error::msg)?; let checkout = repo .ref_ .map(|reference| match reference.type_ { diff --git a/app/src/ai/agent_sdk/execution_config_tests.rs b/app/src/ai/agent_sdk/execution_config_tests.rs index f12384a7b65..544e5782aae 100644 --- a/app/src/ai/agent_sdk/execution_config_tests.rs +++ b/app/src/ai/agent_sdk/execution_config_tests.rs @@ -18,6 +18,70 @@ fn execution_scope_uses_configuration_team_uid() { assert!(team_scope(Some(&cynic::Id::new("invalid-uid"))).is_err()); } +#[test] +fn execution_repository_and_fork_keep_their_own_github_hosts() { + let resolved = repositories(vec![ExecutionRepository { + git_host: Some("github.company.com".into()), + github_instance_uid: Some(cynic::Id::new("enterprise")), + forge: CodeForge::GitHub, + owner: "owner".into(), + name: "repo".into(), + ref_: None, + clone_from: Some(ConfigSourceRepo { + code_forge: CodeForge::GitHub, + git_host: Some("github.other.com".into()), + github_instance_uid: Some(cynic::Id::new("other")), + owner: "fork".into(), + repo: "repo".into(), + }), + preserve_origin: true, + expected_sha: None, + }]) + .unwrap(); + assert_eq!( + resolved[0].source.https_clone_url(), + "https://github.company.com/owner/repo.git" + ); + assert_eq!( + resolved[0].clone_from.as_ref().unwrap().https_clone_url(), + "https://github.other.com/fork/repo.git" + ); +} + +#[test] +fn execution_repository_rejects_an_unresolved_or_malformed_instance_host() { + assert!( + source_repo(ConfigSourceRepo { + code_forge: CodeForge::GitHub, + git_host: None, + github_instance_uid: Some(cynic::Id::new("enterprise")), + owner: "owner".into(), + repo: "repo".into(), + }) + .is_err() + ); + for host in [ + "github.company.com@github.com", + "github.company.com/path", + "github.company.com?query", + "github.company.com#fragment", + "github.company.com\n", + "https://github.company.com", + ] { + assert!( + source_repo(ConfigSourceRepo { + code_forge: CodeForge::GitHub, + git_host: Some(host.into()), + github_instance_uid: Some(cynic::Id::new("enterprise")), + owner: "owner".into(), + repo: "repo".into(), + }) + .is_err(), + "accepted {host:?}" + ); + } +} + #[test] fn execution_mcp_config_preserves_literal_environment_and_headers() { let json = r#"{"literal":{"url":"https://example.com/mcp","headers":{"Authorization":"Bearer literal-value"},"env":{"TOKEN":"literal-value"}}}"#; @@ -40,6 +104,8 @@ fn execution_mcp_config_preserves_literal_environment_and_headers() { fn execution_repositories_keep_individual_origin_policy_and_refs() { let resolved = repositories(vec![ ExecutionRepository { + git_host: None, + github_instance_uid: None, expected_sha: None, forge: CodeForge::GitHub, @@ -50,6 +116,8 @@ fn execution_repositories_keep_individual_origin_policy_and_refs() { preserve_origin: true, }, ExecutionRepository { + git_host: None, + github_instance_uid: None, expected_sha: Some("0123456789abcdef0123456789abcdef01234567".into()), forge: CodeForge::GitLab, @@ -60,6 +128,8 @@ fn execution_repositories_keep_individual_origin_policy_and_refs() { value: "abcdef".into(), }), clone_from: Some(ConfigSourceRepo { + git_host: None, + github_instance_uid: None, code_forge: CodeForge::GitLab, owner: "source".into(), repo: "second".into(), @@ -85,6 +155,8 @@ fn execution_repositories_keep_individual_origin_policy_and_refs() { fn execution_repositories_reject_unknown_forges() { assert!( repositories(vec![ExecutionRepository { + git_host: None, + github_instance_uid: None, forge: CodeForge::Unknown, owner: "owner".into(), name: "repo".into(), diff --git a/app/src/ai/agent_sdk/mod_tests.rs b/app/src/ai/agent_sdk/mod_tests.rs index 75f7dbc74b8..4bf3602e416 100644 --- a/app/src/ai/agent_sdk/mod_tests.rs +++ b/app/src/ai/agent_sdk/mod_tests.rs @@ -572,11 +572,15 @@ fn driver_validation_uses_live_repository_membership() { "added-after-dispatch".to_string(), )]; let repository_preparation_overrides = vec![RepositoryPreparationOverride { + git_host: None, + github_instance_uid: None, code_forge: RepositoryForge::GitHub, repo_owner: "WarpDotDev".to_string(), repo_name: "Warp".to_string(), head: RepositoryHeadRef::CommitSha("0123456789abcdef0123456789abcdef01234567".to_string()), clone_from: Some(warp_cli::agent::RepositoryIdentity { + git_host: None, + github_instance_uid: None, code_forge: RepositoryForge::GitHub, repo_owner: "warpdotdev".to_string(), repo_name: "warp-for-benchmarks".to_string(), diff --git a/app/src/lib.rs b/app/src/lib.rs index cd86715425d..f5e8333c1d1 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -828,6 +828,18 @@ pub fn run() -> Result<()> { return warp_cli::completions::generate_to_stdout(*shell); } warp_cli::Command::CommandLine(cmd) => { + #[cfg(not(target_family = "wasm"))] + match cmd.as_ref() { + CliCommand::Agent(AgentCommand::Run(args)) if args.sandboxed => { + if let Some(task_id) = &args.task_id { + ai::agent_sdk::driver::git_credentials::isolate_managed_auth(task_id)?; + } + } + CliCommand::Agent(AgentCommand::RunCloudStandby(_)) => { + ai::agent_sdk::driver::git_credentials::isolate_managed_auth("standby")?; + } + _ => {} + } #[cfg(not(target_family = "wasm"))] if let CliCommand::EnvironmentCheckout(args) = cmd.as_ref() { let (mut tracing_initialization, parent_context) = diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index 06dc3ea2220..44b4235f886 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -506,6 +506,10 @@ pub struct AgentRunEnvironmentSnapshotRequest { #[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] pub struct AgentRunRepositoryRevision { pub code_forge: CodeForge, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub github_instance_uid: Option, pub repo_owner: String, pub repo_name: String, pub checkout_path: String, @@ -801,6 +805,8 @@ pub struct CreateFileArtifactUploadResponse { /// Credentials for one managed Git host. #[derive(Clone, serde::Deserialize)] pub struct GitCredential { + #[serde(default)] + pub code_forge: Option, /// The provider's OAuth or installation access token. pub token: String, /// The provider-specific git username, when available. @@ -827,6 +833,7 @@ impl std::fmt::Debug for GitCredential { pub struct TaskGitCredentialsResponse { pub credentials: Vec, pub failed_hosts: Vec, + pub removed_hosts: Vec, } /// Filter parameters for listing ambient agent tasks. @@ -1892,6 +1899,7 @@ impl ServerApi { workload_token, accepts_partial_refresh: Some(accepts_partial_refresh), use_factory_repositories: use_factory_repositories.then_some(true), + supports_github_instances: Some(true), }, request_context: get_request_context(), }; @@ -1910,6 +1918,7 @@ impl ServerApi { .map(into_git_credential) .collect(), failed_hosts: output.failed_hosts, + removed_hosts: output.removed_hosts, }) } TaskGitCredentialsResult::UserFacingError(error) => { @@ -1945,9 +1954,16 @@ impl ServerApi { credentials: output .credentials .into_iter() - .map(into_git_credential) + .map(|credential| GitCredential { + token: credential.token, + username: credential.username, + email: credential.email, + host: credential.host, + code_forge: None, + }) .collect(), failed_hosts: Vec::new(), + removed_hosts: Vec::new(), }) } TaskGitCredentialsLegacyResult::UserFacingError(error) => { @@ -1964,6 +1980,20 @@ pub(super) fn into_git_credential( credential: warp_graphql::queries::task_git_credentials::TaskGitCredential, ) -> GitCredential { GitCredential { + code_forge: credential.code_forge.map(|forge| match forge { + warp_graphql::queries::execution_config::CodeForge::GitHub => { + cloud_object_models::CodeForge::GitHub + } + warp_graphql::queries::execution_config::CodeForge::GitLab => { + cloud_object_models::CodeForge::GitLab + } + warp_graphql::queries::execution_config::CodeForge::AzureDevOps => { + cloud_object_models::CodeForge::AzureDevOps + } + warp_graphql::queries::execution_config::CodeForge::Unknown => { + cloud_object_models::CodeForge::Unknown + } + }), token: credential.token, username: credential.username, email: credential.email, @@ -1976,8 +2006,11 @@ fn is_unknown_git_credential_schema_error(error: &TaskGitCredentialsError) -> bo return false; }; let message = error.to_string(); - let names_partial_refresh_field = - message.contains("failedHosts") || message.contains("acceptsPartialRefresh"); + let names_partial_refresh_field = message.contains("failedHosts") + || message.contains("acceptsPartialRefresh") + || message.contains("removedHosts") + || message.contains("supportsGithubInstances") + || message.contains("codeForge"); names_partial_refresh_field && (message.contains("Cannot query field") || message.contains("Unknown argument") diff --git a/app/src/server/server_api/ai_tests.rs b/app/src/server/server_api/ai_tests.rs index b198b4ed237..ff7fa73efa5 100644 --- a/app/src/server/server_api/ai_tests.rs +++ b/app/src/server/server_api/ai_tests.rs @@ -54,6 +54,7 @@ fn execution_bootstrap_response() -> Value { "repositories": [], "deferredRepositories": [{ "forge": "GITLAB", "owner": "platform/backend", "name": "api", + "gitHost": null, "githubInstanceUid": null, "ref": null, "cloneFrom": null, "preserveOrigin": true, "expectedSha": "0123456789abcdef0123456789abcdef01234567" }], @@ -161,8 +162,9 @@ fn execution_bootstrap_fetches_config_secrets_and_attachments_once() { let config = &mut response["data"]["task"]["task"]["executionConfig"]; config["repositories"] = json!([{ "forge": "GITLAB", "owner": "platform/backend", "name": "api", + "gitHost": null, "githubInstanceUid": null, "ref": {"type": "COMMIT_SHA", "value": "0123456789abcdef0123456789abcdef01234567"}, - "cloneFrom": {"codeForge": "GITHUB", "owner": "acme", "repo": "api"}, + "cloneFrom": {"codeForge": "GITHUB", "owner": "acme", "repo": "api", "gitHost": null, "githubInstanceUid": null}, "preserveOrigin": true }]); config @@ -265,7 +267,16 @@ fn execution_bootstrap_rejects_configuration_errors() { request.assert(); } -fn mock_git_credentials(input: Value, response: Value, operation: &str) -> Mock { +fn mock_git_credentials(mut input: Value, mut response: Value, operation: &str) -> Mock { + if operation == "TaskGitCredentials" { + input["supportsGithubInstances"] = json!(true); + if let Some(output) = response + .pointer_mut("/data/taskGitCredentials") + .and_then(Value::as_object_mut) + { + output.insert("removedHosts".into(), json!([])); + } + } let mut server = warp_core::channel::ChannelState::mock_server(); server .mock("POST", "/graphql/v2") diff --git a/crates/cloud_object_models/src/cloud_environment.rs b/crates/cloud_object_models/src/cloud_environment.rs index 9e20c840bd8..2dfc14f7a67 100644 --- a/crates/cloud_object_models/src/cloud_environment.rs +++ b/crates/cloud_object_models/src/cloud_environment.rs @@ -90,6 +90,10 @@ pub struct SourceRepo { /// primary. A mixed environment leaves it unset so clone fails closed. #[serde(default, skip_serializing_if = "Option::is_none")] pub code_forge: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github_instance_uid: Option, pub owner: String, pub repo: String, /// Ref to check out after cloning this repository (commit SHA, branch, or @@ -103,6 +107,8 @@ impl SourceRepo { pub fn new(code_forge: CodeForge, owner: String, repo: String) -> Self { Self { code_forge: Some(code_forge), + git_host: None, + github_instance_uid: None, owner, repo, checkout_ref: None, @@ -111,6 +117,8 @@ impl SourceRepo { pub fn with_default_code_forge(&self, code_forge: CodeForge) -> Self { Self { code_forge: Some(self.code_forge.unwrap_or(code_forge)), + git_host: self.git_host.clone(), + github_instance_uid: self.github_instance_uid.clone(), owner: self.owner.clone(), repo: self.repo.clone(), checkout_ref: self.checkout_ref.clone(), @@ -122,6 +130,29 @@ impl SourceRepo { self } + pub fn host(&self) -> &str { + self.git_host + .as_deref() + .unwrap_or_else(|| self.code_forge.map(CodeForge::host).unwrap_or("")) + } + + pub fn validate_host(&self) -> Result<(), &'static str> { + use warp_cli::agent::{RepositoryForge, validate_repository_host}; + let forge = match self.code_forge { + Some(CodeForge::GitHub) => RepositoryForge::GitHub, + Some(CodeForge::GitLab) => RepositoryForge::GitLab, + Some(CodeForge::AzureDevOps) => RepositoryForge::AzureDevOps, + Some(CodeForge::None | CodeForge::Unknown) | None => { + return Err("unsupported repository forge"); + } + }; + validate_repository_host( + forge, + self.git_host.as_deref(), + self.github_instance_uid.as_deref(), + ) + } + pub fn https_clone_url(&self) -> String { // Azure Repos clone URLs carry a `_git` segment between the // organization/project pair and the repository, and no `.git` suffix: @@ -139,12 +170,14 @@ impl SourceRepo { } return clone_url.into(); } - format!( - "https://{}/{}/{}.git", - self.code_forge.map(CodeForge::host).unwrap_or(""), - self.owner, - self.repo - ) + let Ok(mut url) = Url::parse(&format!("https://{}", self.host())) else { + return format!("https://{}/{}/{}.git", self.host(), self.owner, self.repo); + }; + if let Ok(mut segments) = url.path_segments_mut() { + segments.extend(self.owner.split('/')); + segments.push(&format!("{}.git", self.repo)); + } + url.into() } } @@ -367,6 +400,8 @@ impl AmbientAgentEnvironment { .into_iter() .map(|repo| SourceRepo { code_forge: self.fill_forge_for_repo(&repo), + git_host: repo.git_host, + github_instance_uid: repo.github_instance_uid, owner: repo.owner, repo: repo.repo, checkout_ref: repo.checkout_ref, diff --git a/crates/graphql/src/api/mutations/fixtures/prewarmed_sandbox_config.json b/crates/graphql/src/api/mutations/fixtures/prewarmed_sandbox_config.json index 84f5c63e97f..c9a12efbd00 100644 --- a/crates/graphql/src/api/mutations/fixtures/prewarmed_sandbox_config.json +++ b/crates/graphql/src/api/mutations/fixtures/prewarmed_sandbox_config.json @@ -11,18 +11,41 @@ "forge": "GITHUB", "owner": "warpdotdev", "name": "warp", - "ref": { "type": "BRANCH", "value": "main" }, - "cloneFrom": { "codeForge": "GITHUB", "owner": "warpdotdev", "repo": "warp-fork" }, - "preserveOrigin": true + "ref": { + "type": "BRANCH", + "value": "main" + }, + "cloneFrom": { + "codeForge": "GITHUB", + "owner": "warpdotdev", + "repo": "warp-fork", + "gitHost": null, + "githubInstanceUid": null + }, + "preserveOrigin": true, + "gitHost": null, + "githubInstanceUid": null } ], "deferredRepositories": [ - { "codeForge": "GITHUB", "owner": "warpdotdev", "repo": "warp-server" } + { + "codeForge": "GITHUB", + "owner": "warpdotdev", + "repo": "warp-server", + "gitHost": null, + "githubInstanceUid": null + } + ], + "setupCommands": [ + "./script/bootstrap" ], - "setupCommands": ["./script/bootstrap"], "mcpServersJson": "{\"linear\":{\"warp_id\":\"7f1d2c3b-4a59-4e6f-8b70-1c2d3e4f5a6b\"}}", - "skills": ["warpdotdev/warp:review"], - "factorySkillDirs": [".agents/skills"], + "skills": [ + "warpdotdev/warp:review" + ], + "factorySkillDirs": [ + ".agents/skills" + ], "computerUseEnabled": true, "providers": { "gcp": { @@ -31,21 +54,40 @@ "providerId": "oz-provider", "serviceAccountEmail": "agent@example.iam.gserviceaccount.com" }, - "aws": { "roleArn": "arn:aws:iam::123456789012:role/OzAgent" } + "aws": { + "roleArn": "arn:aws:iam::123456789012:role/OzAgent" + } } }, - "environmentVariables": [{ "name": "WARP_FACTORY_REPO_DIR", "value": "factory" }], + "environmentVariables": [ + { + "name": "WARP_FACTORY_REPO_DIR", + "value": "factory" + } + ], "gitCredentials": [ - { "host": "github.com", "username": "x-access-token", "token": "ghs_secret", "email": null } + { + "host": "github.com", + "username": "x-access-token", + "token": "ghs_secret", + "email": null, + "codeForge": null + } ], "managedSecrets": [ { "name": "API_TOKEN", - "value": { "__typename": "ManagedSecretRawValue", "value": "value" } + "value": { + "__typename": "ManagedSecretRawValue", + "value": "value" + } }, { "name": "CLAUDE_AUTH", - "value": { "__typename": "ManagedSecretAnthropicApiKeyValue", "apiKey": "sk-ant-key" } + "value": { + "__typename": "ManagedSecretAnthropicApiKeyValue", + "apiKey": "sk-ant-key" + } } ], "expiresAt": "2026-01-01T00:00:00Z" diff --git a/crates/graphql/src/api/queries/execution_config.rs b/crates/graphql/src/api/queries/execution_config.rs index 6b1ada00a98..29f0004e131 100644 --- a/crates/graphql/src/api/queries/execution_config.rs +++ b/crates/graphql/src/api/queries/execution_config.rs @@ -59,7 +59,7 @@ pub struct BootstrapTaskOutput { #[derive(cynic::QueryFragment, Debug)] #[cynic(graphql_type = "Task", variables = "ExecutionBootstrapVariables")] pub struct BootstrapTask { - #[arguments(executionId: $execution_id, supportsDeferredRepositories: $supports_deferred_repositories)] + #[arguments(executionId: $execution_id, supportsDeferredRepositories: $supports_deferred_repositories, supportsGithubInstances: true)] pub execution_config: ExecutionConfiguration, pub attachments: Vec, } @@ -130,6 +130,8 @@ pub struct ExecutionRepositoryRef { #[derive(cynic::QueryFragment, Clone, Debug)] pub struct SourceRepo { pub code_forge: CodeForge, + pub git_host: Option, + pub github_instance_uid: Option, pub owner: String, pub repo: String, } @@ -137,6 +139,8 @@ pub struct SourceRepo { #[derive(cynic::QueryFragment, Clone, Debug)] pub struct ExecutionRepository { pub forge: CodeForge, + pub git_host: Option, + pub github_instance_uid: Option, pub owner: String, pub name: String, #[cynic(rename = "ref")] diff --git a/crates/graphql/src/api/queries/task_git_credentials.rs b/crates/graphql/src/api/queries/task_git_credentials.rs index d88fcfc5f98..2687e4ca971 100644 --- a/crates/graphql/src/api/queries/task_git_credentials.rs +++ b/crates/graphql/src/api/queries/task_git_credentials.rs @@ -32,6 +32,8 @@ pub struct TaskGitCredentialsInput { pub accepts_partial_refresh: Option, #[cynic(skip_serializing_if = "Option::is_none")] pub use_factory_repositories: Option, + #[cynic(skip_serializing_if = "Option::is_none")] + pub supports_github_instances: Option, } #[derive(cynic::InlineFragments, Debug)] @@ -46,12 +48,12 @@ pub enum TaskGitCredentialsResult { pub struct TaskGitCredentialsOutput { pub credentials: Vec, pub failed_hosts: Vec, + pub removed_hosts: Vec, } /// Pre-#16215 operation: omits `acceptsPartialRefresh` and `failedHosts` so a /// server that has not deployed those fields can still validate the query. pub mod legacy { - use super::TaskGitCredential; use crate::error::UserFacingError; use crate::request_context::RequestContext; use crate::schema; @@ -95,7 +97,16 @@ pub mod legacy { #[derive(cynic::QueryFragment, Debug)] #[cynic(graphql_type = "TaskGitCredentialsOutput")] pub struct TaskGitCredentialsLegacyOutput { - pub credentials: Vec, + pub credentials: Vec, + } + + #[derive(cynic::QueryFragment, Debug)] + #[cynic(graphql_type = "TaskGitCredential")] + pub struct TaskGitCredentialLegacy { + pub token: String, + pub username: Option, + pub email: Option, + pub host: String, } } @@ -106,6 +117,7 @@ pub use legacy::{ #[derive(cynic::QueryFragment, Debug)] pub struct TaskGitCredential { + pub code_forge: Option, pub token: String, pub username: Option, pub email: Option, diff --git a/crates/graphql/src/api/queries/task_git_credentials_tests.rs b/crates/graphql/src/api/queries/task_git_credentials_tests.rs index 4c121e6526b..9bbc58e4a6a 100644 --- a/crates/graphql/src/api/queries/task_git_credentials_tests.rs +++ b/crates/graphql/src/api/queries/task_git_credentials_tests.rs @@ -22,6 +22,7 @@ fn request_context() -> RequestContext { fn current_query_selects_partial_refresh_fields() { let operation = TaskGitCredentials::build(TaskGitCredentialsVariables { input: TaskGitCredentialsInput { + supports_github_instances: Some(true), task_id: cynic::Id::new("task"), workload_token: "token".to_string(), accepts_partial_refresh: Some(true), @@ -32,12 +33,15 @@ fn current_query_selects_partial_refresh_fields() { assert!(operation.query.contains("failedHosts")); assert!(operation.query.contains("credentials")); + assert!(operation.query.contains("removedHosts")); + assert!(operation.query.contains("codeForge")); } #[test] fn bootstrap_query_still_selects_failed_hosts() { let operation = TaskGitCredentials::build(TaskGitCredentialsVariables { input: TaskGitCredentialsInput { + supports_github_instances: Some(true), task_id: cynic::Id::new("task"), workload_token: "token".to_string(), accepts_partial_refresh: Some(false), @@ -61,5 +65,7 @@ fn legacy_query_omits_partial_refresh_fields() { assert!(!operation.query.contains("acceptsPartialRefresh")); assert!(!operation.query.contains("failedHosts")); + assert!(!operation.query.contains("removedHosts")); + assert!(!operation.query.contains("codeForge")); assert!(operation.query.contains("credentials")); } diff --git a/crates/warp_cli/src/agent.rs b/crates/warp_cli/src/agent.rs index c490ece81db..a790ea68f09 100644 --- a/crates/warp_cli/src/agent.rs +++ b/crates/warp_cli/src/agent.rs @@ -34,6 +34,55 @@ pub enum OutputFormat { Text, } +/// Validates a canonical bare HTTPS authority supplied by repository execution configuration. +pub fn validate_git_host(host: &str) -> Result<(), &'static str> { + let url = url::Url::parse(&format!("https://{host}")).map_err(|_| "invalid git host")?; + if host.is_empty() + || !host.is_ascii() + || host.chars().any(|c| c.is_whitespace() || c.is_control()) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + || url.host_str().is_none() + || url[url::Position::BeforeHost..url::Position::AfterPort] != *host + { + return Err("git host must be a canonical bare HTTPS authority"); + } + Ok(()) +} + +pub fn validate_repository_host( + forge: RepositoryForge, + host: Option<&str>, + instance: Option<&str>, +) -> Result<(), &'static str> { + if let Some(host) = host { + validate_git_host(host)?; + let default = match forge { + RepositoryForge::GitHub => "github.com", + RepositoryForge::GitLab => "gitlab.com", + RepositoryForge::AzureDevOps => "dev.azure.com", + }; + if forge != RepositoryForge::GitHub && host != default { + return Err("explicit host is unsupported for this forge"); + } + } + if instance.is_some_and(str::is_empty) + || (instance.is_some() && (forge != RepositoryForge::GitHub || host.is_none())) + { + return Err("GitHub instance requires an explicit GitHub host"); + } + if forge == RepositoryForge::GitHub + && host.is_some_and(|host| host != "github.com") + && instance.is_none() + { + return Err("enterprise GitHub host requires an instance"); + } + Ok(()) +} + impl fmt::Display for OutputFormat { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let value = self.to_possible_value().expect("no values are skipped"); @@ -72,6 +121,10 @@ impl RepositoryHeadRef { #[serde(deny_unknown_fields)] pub struct RepositoryIdentity { pub code_forge: RepositoryForge, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github_instance_uid: Option, #[serde(rename = "owner")] pub repo_owner: String, #[serde(rename = "repo")] @@ -79,15 +132,31 @@ pub struct RepositoryIdentity { } impl RepositoryIdentity { - pub fn identity(&self) -> (RepositoryForge, String, String) { + pub fn host(&self) -> &str { + self.git_host.as_deref().unwrap_or(match self.code_forge { + RepositoryForge::GitHub => "github.com", + RepositoryForge::GitLab => "gitlab.com", + RepositoryForge::AzureDevOps => "dev.azure.com", + }) + } + + pub fn identity(&self) -> (RepositoryForge, String, Option, String, String) { ( self.code_forge, + self.host().to_owned(), + self.github_instance_uid.clone(), self.repo_owner.to_lowercase(), self.repo_name.to_lowercase(), ) } fn validate(&self) -> Result<(), String> { + validate_repository_host( + self.code_forge, + self.git_host.as_deref(), + self.github_instance_uid.as_deref(), + ) + .map_err(str::to_owned)?; if self.repo_owner.is_empty() || self.repo_owner.trim() != self.repo_owner { return Err( "repo_owner must not be empty or contain surrounding whitespace".to_string(), @@ -107,6 +176,10 @@ impl RepositoryIdentity { #[serde(deny_unknown_fields)] pub struct RepositoryPreparationOverride { pub code_forge: RepositoryForge, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_host: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github_instance_uid: Option, pub repo_owner: String, pub repo_name: String, pub head: RepositoryHeadRef, @@ -116,21 +189,22 @@ pub struct RepositoryPreparationOverride { } impl RepositoryPreparationOverride { - pub fn identity(&self) -> (RepositoryForge, String, String) { - ( - self.code_forge, - self.repo_owner.to_lowercase(), - self.repo_name.to_lowercase(), - ) + pub fn identity(&self) -> (RepositoryForge, String, Option, String, String) { + self.repository_identity().identity() } - fn validate(&self) -> Result<(), String> { + fn repository_identity(&self) -> RepositoryIdentity { RepositoryIdentity { code_forge: self.code_forge, + git_host: self.git_host.clone(), + github_instance_uid: self.github_instance_uid.clone(), repo_owner: self.repo_owner.clone(), repo_name: self.repo_name.clone(), } - .validate()?; + } + + fn validate(&self) -> Result<(), String> { + self.repository_identity().validate()?; if let Some(clone_from) = &self.clone_from { clone_from.validate()?; } diff --git a/crates/warp_graphql_schema/api/schema.graphql b/crates/warp_graphql_schema/api/schema.graphql index a9b7597d911..fa2b0f46357 100644 --- a/crates/warp_graphql_schema/api/schema.graphql +++ b/crates/warp_graphql_schema/api/schema.graphql @@ -3911,7 +3911,7 @@ type Task { """ Configuration for the active cloud execution. Requires the execution's workload token. """ - executionConfig(executionId: ID!, supportsDeferredRepositories: Boolean = false): ExecutionConfiguration! + executionConfig(executionId: ID!, supportsDeferredRepositories: Boolean = false, supportsGithubInstances: Boolean = false): ExecutionConfiguration! """The ID of the task.""" taskId: ID! @@ -3937,6 +3937,7 @@ type TaskAttachment { """A set of git credentials for a single hosting provider.""" type TaskGitCredential { + codeForge: CodeForge """ The email address associated with the token, if available. Null for service-account (installation) tokens. @@ -3958,6 +3959,7 @@ type TaskGitCredential { """Input for the taskGitCredentials query.""" input TaskGitCredentialsInput { + supportsGithubInstances: Boolean """ Whether the caller can handle a response in which one forge's credential is fresh and another's failed. A caller that opts in must merge the fresh @@ -3978,6 +3980,7 @@ input TaskGitCredentialsInput { } type TaskGitCredentialsOutput implements Response { + removedHosts: [String!]! """The credentials that were freshly issued this cycle, one per host.""" credentials: [TaskGitCredential!]! @@ -5204,6 +5207,8 @@ type ExecutionRepositoryRef { type ExecutionRepository { + gitHost: String + githubInstanceUid: ID forge: CodeForge! owner: String! name: String! @@ -5291,6 +5296,8 @@ enum CodeForge { } type SourceRepo { + gitHost: String + githubInstanceUid: ID codeForge: CodeForge! owner: String! repo: String! From 5c09793cec783d861704ee32987ec37cd96b0009 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:11:45 +0000 Subject: [PATCH 2/2] Preserve Direct agent PID while scoping managed authentication Re-exec on Unix so worker cancellation still targets the runtime itself. Hold normal-return credential cleanup in the runtime scope and test PID preservation. Co-Authored-By: Warp Agent --- .../ai/agent_sdk/driver/git_credentials.rs | 38 ++++++++++++++----- .../agent_sdk/driver/git_credentials_tests.rs | 14 ++++++- app/src/lib.rs | 22 +++++------ 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/app/src/ai/agent_sdk/driver/git_credentials.rs b/app/src/ai/agent_sdk/driver/git_credentials.rs index 4d4dda328b4..455a747a34c 100644 --- a/app/src/ai/agent_sdk/driver/git_credentials.rs +++ b/app/src/ai/agent_sdk/driver/git_credentials.rs @@ -131,9 +131,21 @@ fn home_dir() -> Result { } #[cfg(not(target_family = "wasm"))] -pub(crate) fn isolate_managed_auth(binding: &str) -> Result<()> { +pub(crate) struct ManagedAuthScope(PathBuf); + +#[cfg(not(target_family = "wasm"))] +impl Drop for ManagedAuthScope { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[cfg(not(target_family = "wasm"))] +pub(crate) fn isolate_managed_auth(binding: &str) -> Result { if std::env::var("WARP_GIT_AUTH_BINDING").as_deref() == Ok(binding) { - return Ok(()); + let root = std::env::var_os("WARP_GIT_AUTH_DIR") + .context("Missing managed Git authentication directory")?; + return Ok(ManagedAuthScope(root.into())); } let directory = tempfile::Builder::new().prefix("oz-git-auth-").tempdir()?; #[cfg(unix)] @@ -167,13 +179,21 @@ pub(crate) fn isolate_managed_auth(binding: &str) -> Result<()> { ] { command.env_remove(name); } - // A supervisor owns the temporary credentials until the child exits; changing HOME would - // isolate unrelated runner state, while mutating this process's environment is not thread-safe. - let status = command - .status() - .context("Failed to enter managed Git authentication scope")?; - drop(directory); - std::process::exit(status.code().unwrap_or(1)); + // Direct runner cancellation targets this PID. Re-exec also avoids changing a multithreaded + // process's environment while retaining the runner's shared HOME. + #[cfg(unix)] + { + use command::unix::CommandExt as _; + Err(command.exec()).context("Failed to enter managed Git authentication scope") + } + #[cfg(windows)] + { + let status = command + .status() + .context("Failed to enter managed Git authentication scope")?; + drop(directory); + std::process::exit(status.code().unwrap_or(1)); + } } /// Resolves `~` the way git does on Windows: from the environment, not from the profile of the diff --git a/app/src/ai/agent_sdk/driver/git_credentials_tests.rs b/app/src/ai/agent_sdk/driver/git_credentials_tests.rs index d63caad9c1f..2b739f2b919 100644 --- a/app/src/ai/agent_sdk/driver/git_credentials_tests.rs +++ b/app/src/ai/agent_sdk/driver/git_credentials_tests.rs @@ -661,7 +661,19 @@ fn managed_auth_is_private_with_a_shared_home() -> Result<()> { assert!(!Path::new(&scoped_root).exists()); return Ok(()); }; - isolate_managed_auth("isolation-test")?; + #[cfg(unix)] + if std::env::var_os("WARP_GIT_AUTH_BINDING").is_none() { + std::fs::write( + PathBuf::from(&shared_home).join("agent-pid"), + std::process::id().to_string(), + )?; + } + let _managed_auth_scope = isolate_managed_auth("isolation-test")?; + #[cfg(unix)] + assert_eq!( + std::fs::read_to_string(PathBuf::from(&shared_home).join("agent-pid"))?, + std::process::id().to_string(), + ); assert_eq!(std::env::var_os("HOME"), Some(shared_home.clone())); std::fs::write( PathBuf::from(&shared_home).join("scoped-root"), diff --git a/app/src/lib.rs b/app/src/lib.rs index f5e8333c1d1..638d350d03d 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -829,17 +829,17 @@ pub fn run() -> Result<()> { } warp_cli::Command::CommandLine(cmd) => { #[cfg(not(target_family = "wasm"))] - match cmd.as_ref() { - CliCommand::Agent(AgentCommand::Run(args)) if args.sandboxed => { - if let Some(task_id) = &args.task_id { - ai::agent_sdk::driver::git_credentials::isolate_managed_auth(task_id)?; - } - } - CliCommand::Agent(AgentCommand::RunCloudStandby(_)) => { - ai::agent_sdk::driver::git_credentials::isolate_managed_auth("standby")?; - } - _ => {} - } + let _managed_auth_scope = match cmd.as_ref() { + CliCommand::Agent(AgentCommand::Run(args)) if args.sandboxed => args + .task_id + .as_deref() + .map(ai::agent_sdk::driver::git_credentials::isolate_managed_auth) + .transpose()?, + CliCommand::Agent(AgentCommand::RunCloudStandby(_)) => Some( + ai::agent_sdk::driver::git_credentials::isolate_managed_auth("standby")?, + ), + _ => None, + }; #[cfg(not(target_family = "wasm"))] if let CliCommand::EnvironmentCheckout(args) = cmd.as_ref() { let (mut tracing_initialization, parent_context) =