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
8 changes: 7 additions & 1 deletion app/src/ai/agent_sdk/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use warp_cli::mcp::MCPSpec;
use warp_cli::share::ShareRequest;
use warp_cli::skill::SkillSpec;
use warp_core::features::FeatureFlag;
use warp_core::{safe_debug, safe_error, safe_info};
use warp_core::{safe_debug, safe_error, safe_info, safe_warn};
use warp_errors::{ErrorExt, register_error, report_error, report_if_error};
use warp_graphql::ai::{AgentTaskState, PlatformErrorCode};
use warp_managed_secrets::ManagedSecretValue;
Expand Down Expand Up @@ -1079,6 +1079,12 @@ impl AgentDriver {
selected_harness,
third_party_harness_model_config.as_ref(),
));
if let Err(error) = git_credentials::prepend_azure_cli_wrapper_to_path(&mut env_vars) {
safe_warn!(
safe: ("Failed to add the Azure CLI authentication wrapper to PATH"),
full: ("Failed to add the Azure CLI authentication wrapper to PATH: {error:#}")
);
}

// Signal to third-party harnesses (e.g. Claude Code) that we're in a sandbox
// so they allow root execution with permissive flags.
Expand Down
174 changes: 170 additions & 4 deletions app/src/ai/agent_sdk/driver/git_credentials.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
/// Git credentials management for cloud agent sandboxes.
///
/// This module handles:
/// - Writing provider credentials to `~/.git-credentials`, plus GitHub
/// credentials to `~/.config/gh/hosts.yml`, without requiring environment
/// variables.
/// - Writing provider credentials to `~/.git-credentials`, GitHub credentials
/// to `~/.config/gh/hosts.yml`, and refresh-safe Azure CLI authentication.
/// - One-time git configuration (`credential.helper store`, SSH→HTTPS URL
/// rewrites).
/// - Configuring the git user identity from the server-returned username/email.
Expand All @@ -12,7 +11,8 @@
/// authenticated for their entire duration.
use std::{
collections::HashMap,
path::PathBuf,
ffi::{OsStr, OsString},
path::{Path, PathBuf},
sync::{Arc, RwLock},
time::Duration,
};
Expand All @@ -23,6 +23,7 @@ use anyhow::{Context, Result, bail};
use command::blocking::Command as BlockingCommand;

use crate::server::server_api::ai::{AIClient, GitCredential, TaskGitCredentialsResponse};
use crate::util::path::resolve_executable;

/// How long to wait between credential refresh attempts (~50 minutes, staying
/// well ahead of the shortest-lived one-hour token expiry).
Expand All @@ -34,6 +35,11 @@ const GITHUB_HOST: &str = "github.com";
const GH_HOSTS_FILENAME: &str = "hosts.yml";
const GLAB_HOST: &str = "gitlab.com";
const GLAB_CONFIG_FILENAME: &str = "config.yml";
const AZURE_DEVOPS_HOST: &str = "dev.azure.com";
const AZURE_DEVOPS_AUTH_DIR: &str = "azure-devops";
const AZURE_DEVOPS_TOKEN_FILENAME: &str = "entra-token";
const AZURE_DEVOPS_BIN_DIR: &str = "bin";
const AZURE_CLI_FILENAME: &str = "az";

fn home_dir() -> Result<PathBuf> {
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
Expand Down Expand Up @@ -69,6 +75,165 @@ fn write_secret_file(path: &std::path::Path, content: &str) -> Result<()> {
Ok(())
}

fn azure_devops_auth_dir(home: &Path) -> PathBuf {
home.join(".warp").join(AZURE_DEVOPS_AUTH_DIR)
}

fn azure_cli_wrapper_path(home: &Path) -> PathBuf {
azure_devops_auth_dir(home)
.join(AZURE_DEVOPS_BIN_DIR)
.join(AZURE_CLI_FILENAME)
}

fn prepare_azure_devops_auth_dir(home: &Path) -> Result<PathBuf> {
let auth_dir = azure_devops_auth_dir(home);
std::fs::create_dir_all(&auth_dir)
.with_context(|| format!("Failed to create {}", auth_dir.display()))?;
let metadata = std::fs::symlink_metadata(&auth_dir)
.with_context(|| format!("Failed to inspect {}", auth_dir.display()))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
bail!(
"Azure DevOps auth path is not a real directory: {}",
auth_dir.display()
);
}
Ok(auth_dir)
}

fn write_azure_cli_token(auth_dir: &Path, token: &str) -> Result<()> {
use std::io::Write as _;

let token_path = auth_dir.join(AZURE_DEVOPS_TOKEN_FILENAME);
let mut temp_file = tempfile::Builder::new()
.prefix(&format!(".{AZURE_DEVOPS_TOKEN_FILENAME}.tmp-"))
.tempfile_in(auth_dir)
.with_context(|| {
format!(
"Failed to create a temporary token in {}",
auth_dir.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
temp_file
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))
.with_context(|| {
format!(
"Failed to set permissions on temporary token in {}",
auth_dir.display()
)
})?;
}
temp_file
.write_all(token.as_bytes())
.with_context(|| format!("Failed to write temporary token in {}", auth_dir.display()))?;
temp_file
.as_file()
.sync_all()
.with_context(|| format!("Failed to sync temporary token in {}", auth_dir.display()))?;
temp_file
.persist(&token_path)
.map_err(|error| error.error)
.with_context(|| format!("Failed to replace {}", token_path.display()))?;
Ok(())
}

fn shell_single_quote(value: &Path) -> String {
format!("'{}'", value.to_string_lossy().replace('\'', "'\"'\"'"))
}

fn write_executable_file(path: &Path, content: &str) -> Result<()> {
write_secret_file(path, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("Failed to set permissions on {}", path.display()))?;
}
Ok(())
}

fn write_azure_cli_auth_for_executable(
credential: &GitCredential,
home: &Path,
azure_cli: &Path,
) -> Result<()> {
let auth_dir = prepare_azure_devops_auth_dir(home)?;
let bin_dir = auth_dir.join(AZURE_DEVOPS_BIN_DIR);
std::fs::create_dir_all(&bin_dir)
.with_context(|| format!("Failed to create {}", bin_dir.display()))?;
write_azure_cli_token(&auth_dir, &credential.token)?;

let wrapper_path = azure_cli_wrapper_path(home);
let token_path = auth_dir.join(AZURE_DEVOPS_TOKEN_FILENAME);
let wrapper = format!(
"#!/bin/sh\n\
AZURE_DEVOPS_EXT_PAT=\"$(cat {})\" || exit 1\n\
export AZURE_DEVOPS_EXT_PAT\n\
exec {} \"$@\"\n",
shell_single_quote(&token_path),
shell_single_quote(azure_cli),
);
write_executable_file(&wrapper_path, &wrapper)
}

fn write_azure_cli_auth(credentials: &[GitCredential], home: &Path) -> Result<()> {
let Some(credential) = credentials
.iter()
.find(|credential| credential.host == AZURE_DEVOPS_HOST)
else {
return Ok(());
};

let wrapper_path = azure_cli_wrapper_path(home);
if wrapper_path.exists() {
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(());
};
write_azure_cli_auth_for_executable(credential, home, &azure_cli)
}

pub(crate) fn prepend_azure_cli_wrapper_to_path(
env_vars: &mut HashMap<OsString, OsString>,
) -> Result<()> {
let home = home_dir()?;
prepend_azure_cli_wrapper_to_path_for_home(env_vars, &home)
}

fn prepend_azure_cli_wrapper_to_path_for_home(
env_vars: &mut HashMap<OsString, OsString>,
home: &Path,
) -> Result<()> {
let wrapper_path = azure_cli_wrapper_path(home);
if !wrapper_path.exists() {
return Ok(());
}

let path_key = OsStr::new("PATH");
let current_path = env_vars
.get(path_key)
.cloned()
.or_else(|| std::env::var_os(path_key))
.unwrap_or_default();
let wrapper_dir = wrapper_path
.parent()
.expect("Azure CLI wrapper always has a parent directory")
.to_path_buf();
let path = std::env::join_paths(
std::iter::once(wrapper_dir).chain(std::env::split_paths(&current_path)),
)
.context("Failed to prepend the Azure CLI wrapper to PATH")?;
env_vars.insert(path_key.to_os_string(), path);
Ok(())
}

fn git_credentials_line(cred: &GitCredential) -> String {
let userinfo = match &cred.username {
Some(username) => format!("{username}:{}", cred.token),
Expand Down Expand Up @@ -325,6 +490,7 @@ pub(crate) fn write_git_credentials_with_failures(
write_git_credentials_file(credentials),
write_gh_hosts_yml(credentials, &home),
write_glab_config(credentials, &home),
write_azure_cli_auth(credentials, &home),
];
let mut first_error = None;
for outcome in outcomes {
Expand Down
127 changes: 124 additions & 3 deletions app/src/ai/agent_sdk/driver/git_credentials_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ fn github_credential() -> GitCredential {
host: "github.com".to_string(),
}
}
fn azure_devops_credential(token: &str) -> GitCredential {
GitCredential {
token: token.to_string(),
username: None,
email: None,
host: AZURE_DEVOPS_HOST.to_string(),
}
}

fn gitlab_credential() -> GitCredential {
GitCredential {
Expand All @@ -103,16 +111,129 @@ fn gitlab_credential() -> GitCredential {

#[test]
fn merged_credentials_include_each_provider_host() {
let content =
merge_git_credentials_file_content("", &[github_credential(), gitlab_credential()]);
let content = merge_git_credentials_file_content(
"",
&[
github_credential(),
gitlab_credential(),
azure_devops_credential("azure-token"),
],
);

assert_eq!(
content,
"https://x-access-token:github-token@github.com\n\
https://oauth2:gitlab-token@gitlab.com\n"
https://oauth2:gitlab-token@gitlab.com\n\
https://x-access-token:azure-token@dev.azure.com\n"
);
}

#[cfg(unix)]
#[test]
fn azure_cli_wrapper_uses_refreshed_entra_token() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let azure_cli = temp_dir.path().join("real-az");
std::fs::write(
&azure_cli,
"#!/bin/sh\n\
test \"$AZURE_DEVOPS_EXT_PAT\" = \"$EXPECTED_TOKEN\" && \
test -z \"${AZURE_DEVOPS_TOKEN+x}\"\n",
)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&azure_cli, std::fs::Permissions::from_mode(0o700))?;
}

let initial = azure_devops_credential("initial-token");
write_azure_cli_auth_for_executable(&initial, temp_dir.path(), &azure_cli)?;
let wrapper = azure_cli_wrapper_path(temp_dir.path());
let initial_output = BlockingCommand::new(&wrapper)
.env("EXPECTED_TOKEN", "initial-token")
.env_remove("AZURE_DEVOPS_EXT_PAT")
.env_remove("AZURE_DEVOPS_TOKEN")
.output()?;
assert!(initial_output.status.success());

let refreshed = azure_devops_credential("refreshed-token");
write_azure_cli_auth(&[refreshed], temp_dir.path())?;
let refreshed_output = BlockingCommand::new(&wrapper)
.env("EXPECTED_TOKEN", "refreshed-token")
.env_remove("AZURE_DEVOPS_EXT_PAT")
.env_remove("AZURE_DEVOPS_TOKEN")
.output()?;
assert!(refreshed_output.status.success());

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let token_path = azure_devops_auth_dir(temp_dir.path()).join(AZURE_DEVOPS_TOKEN_FILENAME);
assert_eq!(
std::fs::metadata(token_path)?.permissions().mode() & 0o777,
0o600
);
}

Ok(())
}

#[cfg(unix)]
#[test]
fn azure_cli_token_write_does_not_follow_predictable_temp_symlink() -> Result<()> {
use std::os::unix::fs::{PermissionsExt as _, symlink};

let temp_dir = tempfile::tempdir()?;
let auth_dir = azure_devops_auth_dir(temp_dir.path());
std::fs::create_dir_all(&auth_dir)?;
let victim = temp_dir.path().join("victim");
std::fs::write(&victim, "unchanged")?;
let predictable_temp_path = auth_dir.join(format!("{AZURE_DEVOPS_TOKEN_FILENAME}.tmp"));
symlink(&victim, &predictable_temp_path)?;

let azure_cli = temp_dir.path().join("real-az");
std::fs::write(&azure_cli, "#!/bin/sh\n")?;
std::fs::set_permissions(&azure_cli, std::fs::Permissions::from_mode(0o700))?;
write_azure_cli_auth_for_executable(
&azure_devops_credential("azure-token"),
temp_dir.path(),
&azure_cli,
)?;

assert_eq!(std::fs::read_to_string(&victim)?, "unchanged");
assert!(
std::fs::symlink_metadata(&predictable_temp_path)?
.file_type()
.is_symlink()
);
assert_eq!(
std::fs::read_to_string(auth_dir.join(AZURE_DEVOPS_TOKEN_FILENAME))?,
"azure-token"
);
Ok(())
}
#[test]
fn azure_cli_wrapper_path_is_injected_without_a_token_env_var() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let credential = azure_devops_credential("token");
let azure_cli = temp_dir.path().join("real-az");
std::fs::write(&azure_cli, "")?;
write_azure_cli_auth_for_executable(&credential, temp_dir.path(), &azure_cli)?;

let mut env_vars = HashMap::from([(OsString::from("PATH"), OsString::from("/usr/bin"))]);
prepend_azure_cli_wrapper_to_path_for_home(&mut env_vars, temp_dir.path())?;

let path = env_vars.get(OsStr::new("PATH")).expect("PATH is set");
assert_eq!(
std::env::split_paths(path).next(),
azure_cli_wrapper_path(temp_dir.path())
.parent()
.map(Path::to_path_buf)
);
assert!(!env_vars.contains_key(OsStr::new("AZURE_DEVOPS_EXT_PAT")));
assert!(!env_vars.contains_key(OsStr::new("AZURE_DEVOPS_TOKEN")));
Ok(())
}

#[test]
fn merged_credentials_replace_only_the_refreshed_host() {
let existing = "https://x-access-token:stale-github@github.com\n\
Expand Down
Loading