diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index efc38f029..9a3b73c7e 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -96,6 +96,7 @@ pub(crate) mod attachments; pub(crate) mod cloud_provider; pub(crate) mod environment; mod error_classification; +pub(crate) mod git_credentials; pub(crate) mod harness; pub(super) mod output; mod snapshot; @@ -1453,17 +1454,42 @@ impl AgentDriver { } } - // Run the harness with a prompt + // Fetch task_id and AI client once for the refresh loop. Both are needed to call + // `taskGitCredentials` periodically to keep credential files fresh. + let (task_id_for_refresh, ai_client_for_refresh) = foreground + .spawn(|me, ctx| { + let task_id = me.task_id.map(|id| id.to_string()); + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client().clone(); + (task_id, ai_client) + }) + .await?; + + // Run the harness with a prompt, racing it against an infinite git-credentials + // refresh loop. The refresh future never resolves on its own — it is dropped + // automatically when `select!` resolves on the harness result. match task.harness { HarnessKind::Oz => { - let conversation_status = foreground + let status_rx = foreground .spawn(move |me, ctx| me.execute_run(task.prompt, ctx)) - .await? - .await - .map_err(|_| { + .await?; + + let conversation_status = if let Some(task_id) = task_id_for_refresh { + let refresh = + git_credentials::refresh_loop(task_id, ai_client_for_refresh).fuse(); + futures::pin_mut!(refresh); + futures::select! { + result = status_rx.fuse() => result.map_err(|_| { + log::error!("Subscription dropped before agent finished"); + AgentDriverError::InvalidRuntimeState + })?, + _ = refresh => unreachable!("git credentials refresh loop resolved unexpectedly"), + } + } else { + status_rx.await.map_err(|_| { log::error!("Subscription dropped before agent finished"); AgentDriverError::InvalidRuntimeState - })?; + })? + }; // Pause before returning to make sure that all conversation events are transmitted before the session is closed. // TODO: This is a bit of a bandaid fix, and it would be better if we explicitly waited for the session to end before terminating. @@ -1479,7 +1505,20 @@ impl AgentDriver { let harness_exit_rx = Self::setup_harness(harness.as_ref(), &foreground).await?; let runner = Self::prepare_harness(&task.prompt, harness.as_ref(), &foreground).await?; - Self::run_harness(runner, &foreground, harness_exit_rx).await + + if let Some(task_id) = task_id_for_refresh { + let harness_fut = + Self::run_harness(runner, &foreground, harness_exit_rx).fuse(); + let refresh = + git_credentials::refresh_loop(task_id, ai_client_for_refresh).fuse(); + futures::pin_mut!(harness_fut, refresh); + futures::select! { + result = harness_fut => result, + _ = refresh => unreachable!("git credentials refresh loop resolved unexpectedly"), + } + } else { + Self::run_harness(runner, &foreground, harness_exit_rx).await + } } HarnessKind::Unsupported(harness) => Err(AgentDriverError::HarnessSetupFailed { harness: harness.to_string(), diff --git a/app/src/ai/agent_sdk/driver/git_credentials.rs b/app/src/ai/agent_sdk/driver/git_credentials.rs new file mode 100644 index 000000000..e37836b81 --- /dev/null +++ b/app/src/ai/agent_sdk/driver/git_credentials.rs @@ -0,0 +1,235 @@ +/// Git credentials management for cloud agent sandboxes. +/// +/// This module handles: +/// - Writing `~/.git-credentials` and `~/.config/gh/hosts.yaml` so that `git` +/// and the `gh` CLI can authenticate to GitHub without requiring environment +/// variables. +/// - One-time git configuration (`credential.helper store`, SSH→HTTPS URL +/// rewrites). +/// - Configuring the git user identity from the server-returned username/email. +/// - An async refresh loop that periodically fetches a fresh token from the +/// server and overwrites the credential files, keeping long-running agents +/// authenticated for their entire duration. +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use anyhow::{Context, Result}; + +use crate::server::server_api::ai::{AIClient, GitCredential}; + +// Use the project's allowed Command wrapper (not std::process::Command, which is +// disallowed by clippy rules because it flashes a terminal window on Windows). +use command::blocking::Command as BlockingCommand; + +/// How long to wait between credential refresh attempts (~50 minutes, staying +/// well ahead of the one-hour GitHub token expiry). +pub(crate) const GIT_CREDENTIALS_REFRESH_INTERVAL: Duration = Duration::from_secs(50 * 60); + +/// Fallback git user name when the server returns no username. +const DEFAULT_GIT_NAME: &str = "Oz"; + +/// Fallback git user email when the server returns no email. +const DEFAULT_GIT_EMAIL: &str = "oz-agent@warp.dev"; + +/// Returns the home directory path, or an error if it cannot be determined. +fn home_dir() -> Result { + dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory")) +} + +/// Write `~/.git-credentials` with the given credentials. +/// +/// Each credential entry is formatted as: +/// - `https://{username}:{token}@{host}` when a username is present +/// - `https://x-access-token:{token}@{host}` for service-account tokens +/// +/// The write is done atomically: a temporary file is written then renamed. +fn write_git_credentials_file(credentials: &[GitCredential]) -> Result<()> { + if credentials.is_empty() { + return Ok(()); + } + + let home = home_dir()?; + let path = home.join(".git-credentials"); + let tmp_path = home.join(".git-credentials.tmp"); + + let mut content = String::new(); + for cred in credentials { + let userinfo = match &cred.username { + Some(username) => format!("{username}:{}", cred.token), + None => format!("x-access-token:{}", cred.token), + }; + content.push_str(&format!("https://{}@{}\n", userinfo, cred.host)); + } + + std::fs::write(&tmp_path, &content) + .with_context(|| format!("Failed to write {}", tmp_path.display()))?; + std::fs::rename(&tmp_path, &path).with_context(|| { + format!( + "Failed to rename {} to {}", + tmp_path.display(), + path.display() + ) + })?; + + Ok(()) +} + +/// Write `~/.config/gh/hosts.yaml` so the `gh` CLI is authenticated. +/// +/// The YAML format is stable for `gh` v2+: +/// ```yaml +/// github.com: +/// oauth_token: TOKEN +/// git_protocol: https +/// user: USERNAME +/// ``` +/// +/// The write is atomic: a temporary file is written then renamed. +fn write_gh_hosts_yaml(credentials: &[GitCredential]) -> Result<()> { + if credentials.is_empty() { + return Ok(()); + } + + let home = home_dir()?; + let gh_config_dir = home.join(".config").join("gh"); + std::fs::create_dir_all(&gh_config_dir) + .with_context(|| format!("Failed to create {}", gh_config_dir.display()))?; + + let path = gh_config_dir.join("hosts.yaml"); + let tmp_path = gh_config_dir.join("hosts.yaml.tmp"); + + let mut yaml = String::new(); + for cred in credentials { + yaml.push_str(&format!("{}:\n", cred.host)); + yaml.push_str(&format!(" oauth_token: {}\n", cred.token)); + yaml.push_str(" git_protocol: https\n"); + if let Some(username) = &cred.username { + yaml.push_str(&format!(" user: {username}\n")); + } + } + + std::fs::write(&tmp_path, &yaml) + .with_context(|| format!("Failed to write {}", tmp_path.display()))?; + std::fs::rename(&tmp_path, &path).with_context(|| { + format!( + "Failed to rename {} to {}", + tmp_path.display(), + path.display() + ) + })?; + + Ok(()) +} + +/// Write credential files for both `git` (`~/.git-credentials`) and the `gh` +/// CLI (`~/.config/gh/hosts.yaml`). +pub(crate) fn write_git_credentials(credentials: &[GitCredential]) -> Result<()> { + write_git_credentials_file(credentials)?; + write_gh_hosts_yaml(credentials)?; + Ok(()) +} + +/// Run a git config command, logging a warning on failure rather than +/// propagating the error (git may not be installed in all sandboxes). +fn run_git_config(key: &str, value: &str) { + match BlockingCommand::new("git") + .args(["config", "--global", key, value]) + .output() + { + Ok(output) if output.status.success() => {} + Ok(output) => { + log::warn!( + "git config --global {key} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Err(e) => { + log::warn!("Failed to run git config --global {key}: {e}"); + } + } +} + +/// Run one-time git configuration that is set at startup and never needs to +/// be refreshed: +/// - `credential.helper store` so git reads `~/.git-credentials` +/// - SSH→HTTPS URL rewrites so `git clone git@github.com:...` works +pub(crate) fn setup_git_config() { + run_git_config("credential.helper", "store"); + // Rewrite both ssh:// and scp-style git@ URLs to HTTPS. + run_git_config("url.https://github.com/.insteadOf", "ssh://git@github.com/"); + run_git_config("url.https://github.com/.insteadOf", "git@github.com:"); +} + +/// Configure the git user identity from the server-returned credential. +/// +/// Uses the first credential's `username`/`email` fields, falling back to the +/// Oz defaults when either is absent (e.g. service-account principals). +pub(crate) fn configure_git_identity(credentials: &[GitCredential]) { + let (name, email) = credentials + .first() + .map(|c| { + ( + c.username.as_deref().unwrap_or(DEFAULT_GIT_NAME), + c.email.as_deref().unwrap_or(DEFAULT_GIT_EMAIL), + ) + }) + .unwrap_or((DEFAULT_GIT_NAME, DEFAULT_GIT_EMAIL)); + + run_git_config("user.name", name); + run_git_config("user.email", email); +} + +/// Infinite async loop that refreshes git credentials every +/// [`GIT_CREDENTIALS_REFRESH_INTERVAL`]. +/// +/// On each iteration: +/// 1. Issue a short-lived workload token. +/// 2. Call `taskGitCredentials` to get a fresh token from the server. +/// 3. Overwrite `~/.git-credentials` and `~/.config/gh/hosts.yaml`. +/// +/// If any step fails, a warning is logged and the loop continues with the +/// next interval. The existing credential files remain valid until the token +/// actually expires (~10 minutes of buffer remain when we retry). +/// +/// This future never resolves — it is designed to be raced with the harness +/// execution future via `futures::select!` and dropped when the harness +/// completes. +pub(crate) async fn refresh_loop(task_id: String, ai_client: Arc) { + loop { + warpui::r#async::Timer::after(GIT_CREDENTIALS_REFRESH_INTERVAL).await; + + log::info!("Refreshing git credentials for task {task_id}"); + + // Issue a fresh workload token for this refresh call. + let workload_token = + match warp_isolation_platform::issue_workload_token(Some(Duration::from_mins(5))).await + { + Ok(token) => token.token, + Err(e) => { + log::warn!("Failed to issue workload token for git credentials refresh: {e}"); + continue; + } + }; + + let credentials = match ai_client + .get_task_git_credentials(task_id.clone(), workload_token) + .await + { + Ok(creds) => creds, + Err(e) => { + log::warn!("Failed to refresh git credentials: {e:#}"); + continue; + } + }; + + if credentials.is_empty() { + log::debug!("No git credentials returned during refresh; skipping file write"); + continue; + } + + if let Err(e) = write_git_credentials(&credentials) { + log::warn!("Failed to write refreshed git credentials: {e:#}"); + } else { + log::info!("Git credentials refreshed successfully"); + } + } +} diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index 397cd304c..33a496e65 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -995,19 +995,47 @@ impl AgentDriverRunner { ) .await }; - let (secrets_result, attachments_result, task_metadata_result, handoff_snapshot_result) = - futures::future::join4( - task_secrets, - driver::attachments::fetch_and_download_attachments( - ai_client.clone(), - server_api.clone(), - task_id_str.clone(), - attachments_download_dir.clone(), - ), - task_metadata, - handoff_snapshot, - ) - .await; + + // Fetch a fresh GitHub token from the server so the driver can configure git + // and gh credentials without relying on environment variable injection. + let git_creds_ai_client = ai_client.clone(); + let git_creds_task_id = task_id_str.clone(); + let git_credentials = async move { + let workload_token = match warp_isolation_platform::issue_workload_token(Some( + std::time::Duration::from_mins(5), + )) + .await + { + Ok(token) => token.token, + Err(e) => { + // Not in an isolated environment — no workload token available. + log::debug!("Skipping git credentials fetch: {e}"); + return Ok(vec![]); + } + }; + git_creds_ai_client + .get_task_git_credentials(git_creds_task_id, workload_token) + .await + }; + + let ( + secrets_result, + attachments_result, + task_metadata_result, + handoff_snapshot_result, + git_credentials_result, + ) = futures::join!( + task_secrets, + driver::attachments::fetch_and_download_attachments( + ai_client.clone(), + server_api.clone(), + task_id_str.clone(), + attachments_download_dir.clone(), + ), + task_metadata, + handoff_snapshot, + git_credentials, + ); // Extract attachments_dir from successful result, log errors let mut attachments_dir = match attachments_result { @@ -1029,6 +1057,28 @@ impl AgentDriverRunner { log::warn!("Failed to fetch handoff snapshot attachments: {e:#}"); } } + + // Write git credentials to ~/.git-credentials and ~/.config/gh/hosts.yaml, + // and run one-time git config setup. Non-fatal: errors are logged and the + // run continues without file-based git authentication. + match git_credentials_result { + Ok(credentials) if !credentials.is_empty() => { + driver::git_credentials::setup_git_config(); + driver::git_credentials::configure_git_identity(&credentials); + if let Err(e) = driver::git_credentials::write_git_credentials(&credentials) { + log::warn!("Failed to write git credentials: {e:#}"); + } else { + log::info!("Git credentials configured from taskGitCredentials"); + } + } + Ok(_) => { + log::debug!("No git credentials returned; skipping credential file setup"); + } + Err(e) => { + log::warn!("Failed to fetch git credentials: {e:#}"); + } + } + let secrets = match secrets_result { Ok(secrets) => secrets, Err(err) => { diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index 0b3fe2c28..7f1f3e546 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -120,6 +120,10 @@ use warp_graphql::{ UpdateMerkleTreeVariables, }, }, + queries::task_git_credentials::{ + TaskGitCredentials, TaskGitCredentialsInput, TaskGitCredentialsResult, + TaskGitCredentialsVariables, + }, queries::{ codebase_context_config::{ CodebaseContextConfigQuery, CodebaseContextConfigResult, CodebaseContextConfigVariables, @@ -515,6 +519,19 @@ pub struct CreateFileArtifactUploadResponse { pub upload_target: FileArtifactUploadTargetInfo, } +/// A single git credential entry returned by `taskGitCredentials`. +#[derive(Debug, Clone)] +pub struct GitCredential { + /// The GitHub token (OAuth user token or App installation token). + pub token: String, + /// The GitHub username. `None` for service-account (installation token) principals. + pub username: Option, + /// The GitHub email. `None` for service-account principals. + pub email: Option, + /// The host (always `"github.com"` in V1). + pub host: String, +} + /// Filter parameters for listing ambient agent tasks. #[derive(Clone, Debug, Default)] pub struct TaskListFilter { @@ -901,6 +918,12 @@ pub trait AIClient: 'static + Send + Sync { task_id: &AmbientAgentTaskId, ) -> anyhow::Result<(), anyhow::Error>; + async fn get_task_git_credentials( + &self, + task_id: String, + workload_token: String, + ) -> anyhow::Result, anyhow::Error>; + async fn get_task_attachments( &self, task_id: String, @@ -1780,6 +1803,44 @@ impl AIClient for ServerApi { Ok(()) } + async fn get_task_git_credentials( + &self, + task_id: String, + workload_token: String, + ) -> anyhow::Result, anyhow::Error> { + let variables = TaskGitCredentialsVariables { + input: TaskGitCredentialsInput { + task_id: cynic::Id::new(task_id), + workload_token, + }, + request_context: get_request_context(), + }; + let operation = TaskGitCredentials::build(variables); + let response = self.send_graphql_request(operation, None).await?; + + match response.task_git_credentials { + TaskGitCredentialsResult::TaskGitCredentialsOutput(output) => { + let credentials = output + .credentials + .into_iter() + .map(|c| GitCredential { + token: c.token, + username: c.username, + email: c.email, + host: c.host, + }) + .collect(); + Ok(credentials) + } + TaskGitCredentialsResult::UserFacingError(error) => { + Err(anyhow!(get_user_facing_error_message(error))) + } + TaskGitCredentialsResult::Unknown => { + Err(anyhow!("Failed to fetch task git credentials")) + } + } + } + async fn get_task_attachments( &self, task_id: String, diff --git a/crates/graphql/src/api/queries/mod.rs b/crates/graphql/src/api/queries/mod.rs index 9ab0851f5..a6da98d86 100644 --- a/crates/graphql/src/api/queries/mod.rs +++ b/crates/graphql/src/api/queries/mod.rs @@ -28,6 +28,7 @@ pub mod rerank_fragments; pub mod suggest_cloud_environment_image; pub mod sync_merkle_tree; pub mod task_attachments; +pub mod task_git_credentials; pub mod task_secrets; pub mod user_github_info; pub mod user_repo_auth_status; diff --git a/crates/graphql/src/api/queries/task_git_credentials.rs b/crates/graphql/src/api/queries/task_git_credentials.rs new file mode 100644 index 000000000..c80ff5f54 --- /dev/null +++ b/crates/graphql/src/api/queries/task_git_credentials.rs @@ -0,0 +1,50 @@ +use crate::{error::UserFacingError, request_context::RequestContext, schema}; + +/// A GraphQL query to fetch git credentials for a specific task. +/// +/// This query is used by Agent Mode tasks to retrieve a fresh GitHub token that the +/// driver uses to configure git and the gh CLI, and to refresh those credentials +/// periodically so long-running agents retain GitHub access for their full duration. +#[derive(cynic::QueryFragment, Debug)] +#[cynic(graphql_type = "RootQuery", variables = "TaskGitCredentialsVariables")] +pub struct TaskGitCredentials { + #[arguments(input: $input, requestContext: $request_context)] + pub task_git_credentials: TaskGitCredentialsResult, +} + +crate::client::define_operation! { + task_git_credentials(TaskGitCredentialsVariables) -> TaskGitCredentials; +} + +#[derive(cynic::QueryVariables, Debug)] +pub struct TaskGitCredentialsVariables { + pub input: TaskGitCredentialsInput, + pub request_context: RequestContext, +} + +#[derive(cynic::InputObject, Debug)] +pub struct TaskGitCredentialsInput { + pub task_id: cynic::Id, + pub workload_token: String, +} + +#[derive(cynic::InlineFragments, Debug)] +pub enum TaskGitCredentialsResult { + TaskGitCredentialsOutput(TaskGitCredentialsOutput), + UserFacingError(UserFacingError), + #[cynic(fallback)] + Unknown, +} + +#[derive(cynic::QueryFragment, Debug)] +pub struct TaskGitCredentialsOutput { + pub credentials: Vec, +} + +#[derive(cynic::QueryFragment, Debug)] +pub struct TaskGitCredential { + pub token: String, + pub username: Option, + pub email: Option, + pub host: String, +} diff --git a/crates/warp_graphql_schema/api/client-schema.ts b/crates/warp_graphql_schema/api/client-schema.ts index 510983082..2b19aa73b 100644 --- a/crates/warp_graphql_schema/api/client-schema.ts +++ b/crates/warp_graphql_schema/api/client-schema.ts @@ -97,6 +97,7 @@ const clientQueries = [ 'getIntegrationsUsingEnvironment', 'scheduledAgentHistory', 'task', + 'taskGitCredentials', 'taskSecrets', 'listAIConversations', 'suggestCloudEnvironmentImage' diff --git a/crates/warp_graphql_schema/api/schema.graphql b/crates/warp_graphql_schema/api/schema.graphql index 2d371a657..a992c46af 100644 --- a/crates/warp_graphql_schema/api/schema.graphql +++ b/crates/warp_graphql_schema/api/schema.graphql @@ -2769,6 +2769,7 @@ type RootQuery { In the future, this may be merged with taskSecrets into a single task query. """ task(input: TaskInput!, requestContext: RequestContext!): TaskResult! + taskGitCredentials(input: TaskGitCredentialsInput!, requestContext: RequestContext!): TaskGitCredentialsResult! taskSecrets(input: TaskSecretsInput!, requestContext: RequestContext!): TaskSecretsResult! updatedCloudObjects(input: UpdatedCloudObjectsInput!, requestContext: RequestContext!): UpdatedCloudObjectsResult! user(requestContext: RequestContext!): UserResult! @@ -3183,6 +3184,43 @@ type TaskSecretEntry { value: ManagedSecretValue! } +"""A set of git credentials for a single hosting provider.""" +type TaskGitCredential { + """ + The email address associated with the token, if available. + Null for service-account (installation) tokens. + """ + email: String + + """The git hosting provider (e.g. \"github.com\").""" + host: String! + + """The OAuth or installation access token.""" + token: String! + + """ + The GitHub username associated with the token, if available. + Null for service-account (installation) tokens. + """ + username: String +} + +"""Input for the taskGitCredentials query.""" +input TaskGitCredentialsInput { + """The ID of the task.""" + taskId: ID! + + """A short-lived token authorizing credential retrieval for the workload.""" + workloadToken: String! +} + +type TaskGitCredentialsOutput implements Response { + credentials: [TaskGitCredential!]! + responseContext: ResponseContext! +} + +union TaskGitCredentialsResult = TaskGitCredentialsOutput | UserFacingError + """Input for the taskSecrets query.""" input TaskSecretsInput { """The ID of the task."""