Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
//! General-purpose administrative commands in the Warp CLI.
use anyhow::{Context, Result};
use serde::Serialize;
use warp_cli::agent::OutputFormat;
use warpui::{platform::TerminationMode, AppContext, SingletonEntity};
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::auth::user::PrincipalType;
use crate::auth::AuthStateProvider;
use crate::workspaces::user_workspaces::UserWorkspaces;
/// Kick off a device authorization login flow and handle auth events.
pub fn login(ctx: &mut AppContext) -> Result<()> {
let auth_state = AuthStateProvider::as_ref(ctx).get();
let has_cached_credentials = auth_state.is_logged_in();
// If the user is already logged in, we require that the user log out before logging
// back in to ensure their existing state isn't replaced (especially if using both the CLI
// and the desktop app). In this case, try refreshing their credentials first. If the user
// is trying to log in because the cached credentials are invalid, we should let them do so.
// Track whether we've started the device auth flow. Failure events
// that arrive before device auth has started are leftover refresh
// errors and should be ignored rather than treated as terminal.
let mut started_device_auth = !has_cached_credentials;
ctx.subscribe_to_model(
&AuthManager::handle(ctx),
move |_, event, ctx| match event {
AuthManagerEvent::AuthComplete => {
if !started_device_auth {
// Refresh succeeded - credentials are still valid.
let auth_state = AuthStateProvider::as_ref(ctx).get();
match (auth_state.username_for_display(), auth_state.user_email()) {
(Some(username), Some(email)) if username != email => {
println!("You are already logged in as {username} ({email}).")
}
(Some(name), _) | (None, Some(name)) => {
println!("You are already logged in as {name}.")
}
(None, None) => {
println!("You are already logged in.")
}
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
} else {
// Device auth succeeded.
println!("Logged in successfully");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
}
AuthManagerEvent::AuthFailed(_) => {
if !started_device_auth {
// Refresh failed - start a fresh device auth flow.
started_device_auth = true;
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.authorize_device(ctx);
});
} else {
// Device auth failed.
let err_msg = match event {
AuthManagerEvent::AuthFailed(err) => {
format!("Authentication failed: {err:#}")
}
_ => "Authentication failed".to_string(),
};
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!(err_msg))),
);
}
}
AuthManagerEvent::ReceivedDeviceAuthorizationCode {
verification_url,
verification_url_complete,
user_code,
} => {
if let Some(url) = verification_url_complete {
println!("To log in, open this URL in your browser:\n{url}");
} else {
println!(
"To log in, visit {verification_url} and enter this code: {user_code}"
);
}
}
_ => {}
},
);
// Either refresh existing credentials or start device auth from scratch.
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
if has_cached_credentials {
auth_manager.refresh_user(ctx);
} else {
auth_manager.authorize_device(ctx);
}
});
Ok(())
}
#[derive(Serialize)]
struct WhoamiOutput {
uid: String,
#[serde(rename = "type")]
principal_type: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
team_uid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
team_name: Option<String>,
}
/// Singleton model that provides a `ModelContext` for the `whoami` command's async work.
struct WhoamiRunner;
impl warpui::Entity for WhoamiRunner {
type Event = ();
}
impl SingletonEntity for WhoamiRunner {}
/// Print information about the currently authenticated principal.
pub fn whoami(ctx: &mut AppContext, output_format: OutputFormat) -> Result<()> {
let auth_state = AuthStateProvider::as_ref(ctx).get();
let principal_type = auth_state.principal_type().unwrap_or_default();
let uid = auth_state
.user_id()
.map(|id| {
let s = id.as_string();
s.strip_prefix("serviceAccount:")
.map(String::from)
.unwrap_or(s)
})
.ok_or_else(|| anyhow::anyhow!("Could not determine user ID. Are you logged in?"))?;
let mut info = WhoamiOutput {
uid,
principal_type: match principal_type {
PrincipalType::User => "user",
PrincipalType::ServiceAccount => "service_account",
},
display_name: auth_state.display_name(),
email: match principal_type {
PrincipalType::User => auth_state.user_email().filter(|e| !e.is_empty()),
PrincipalType::ServiceAccount => None,
},
team_uid: None,
team_name: None,
};
// Refresh workspace metadata before reading team info, so we don't print
// stale or missing team data if the metadata hasn't been fetched yet.
let runner = ctx.add_singleton_model(|_| WhoamiRunner);
runner.update(ctx, move |_, ctx| {
let refresh_future = super::common::refresh_workspace_metadata(ctx);
ctx.spawn(refresh_future, move |_, result, ctx| {
if let Err(err) = result {
// Do not prevent showing user info if fetching team metadata fails.
log::warn!("Failed to refresh team metadata for whoami: {err:#}");
}
let current_team = UserWorkspaces::as_ref(ctx).current_team();
info.team_uid = current_team.map(|t| t.uid.to_string());
info.team_name = current_team
.map(|t| t.name.clone())
.filter(|n| !n.is_empty());
match output_format {
OutputFormat::Json => {
match serde_json::to_string(&info).context("whoami output should serialize") {
Ok(json) => println!("{json}"),
Err(err) => {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
return;
}
}
}
OutputFormat::Pretty => {
match principal_type {
PrincipalType::User => println!("User ID: {}", info.uid),
PrincipalType::ServiceAccount => {
println!("Service account ID: {}", info.uid)
}
}
if let Some(name) = &info.display_name {
println!("Display Name: {name}");
}
if let Some(email) = &info.email {
println!("Email: {email}");
}
if let Some(team_uid) = &info.team_uid {
println!("Team ID: {team_uid}");
}
if let Some(team_name) = &info.team_name {
println!("Team Name: {team_name}");
}
}
OutputFormat::Text => {
println!("{}:{}", info.principal_type, info.uid);
}
OutputFormat::Ndjson => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!(
"`whoami` does not support `--output-format ndjson`"
))),
);
return;
}
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
});
});
Ok(())
}
/// Log out of Warp using the same logic as the app.
pub fn logout(ctx: &mut AppContext) -> Result<()> {
let auth_state = AuthStateProvider::as_ref(ctx).get();
if !auth_state.is_logged_in() {
println!("You are not logged in.");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return Ok(());
}
crate::auth::log_out(ctx);
println!("Logged out successfully.");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
Ok(())
}
+311
View File
@@ -0,0 +1,311 @@
//! Commands to interact with available agents via the public API.
use crate::ai::agent_sdk::oauth_flow::poll_oauth_until_terminal;
use crate::ai::cloud_environments::GithubRepo;
use crate::server::server_api::ai::AgentListItem;
use crate::server::server_api::ServerApiProvider;
use warp_cli::agent::ListAgentConfigsArgs;
use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
use warp_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
use warpui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
const MAX_LINE_WIDTH: usize = 90;
const MAX_AUTH_ATTEMPTS: u32 = 8;
/// Singleton model that runs async work for agent CLI commands.
struct AgentConfigRunner;
/// List all available agents.
pub fn list_agents(ctx: &mut AppContext, args: ListAgentConfigsArgs) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentConfigRunner);
runner.update(ctx, |runner, ctx| runner.list(args.repo.clone(), ctx))
}
/// Parse a repo spec string (owner/repo or GitHub URL) into a GithubRepo.
fn parse_repo_spec(spec: &str) -> anyhow::Result<GithubRepo> {
let spec = spec.trim();
// Try URL format: https://github.com/owner/repo or https://github.com/owner/repo.git
if spec.starts_with("https://github.com/") || spec.starts_with("http://github.com/") {
let path = spec
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_end_matches(".git")
.trim_end_matches('/');
let parts: Vec<&str> = path.split('/').collect();
if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
return Ok(GithubRepo::new(parts[0].to_string(), parts[1].to_string()));
}
}
// Try slug format: owner/repo
let parts: Vec<&str> = spec.split('/').collect();
if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
return Ok(GithubRepo::new(parts[0].to_string(), parts[1].to_string()));
}
Err(anyhow::anyhow!(
"Invalid repo format: '{}'. Expected 'owner/repo' or 'https://github.com/owner/repo'",
spec
))
}
impl AgentConfigRunner {
fn list(&self, repo: Option<String>, ctx: &mut ModelContext<Self>) -> anyhow::Result<()> {
// If a repo is specified, check auth first
if let Some(ref repo_spec) = repo {
let github_repo = parse_repo_spec(repo_spec)?;
self.auth_then_list(vec![github_repo], 1, repo, ctx);
} else {
// No repo specified - just list from environments
self.fetch_and_display_agents(repo, ctx);
}
Ok(())
}
/// Check GitHub auth for repos, then list agents.
fn auth_then_list(
&self,
repos: Vec<GithubRepo>,
attempt: u32,
repo_spec: Option<String>,
ctx: &mut ModelContext<Self>,
) {
if attempt > MAX_AUTH_ATTEMPTS {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!(
"Exceeded maximum number of authorization attempts ({}). Please try again later.",
MAX_AUTH_ATTEMPTS
))),
);
return;
}
let integrations_client = ServerApiProvider::handle(ctx)
.as_ref(ctx)
.get_integrations_client();
let repo_tuples: Vec<(String, String)> = repos
.iter()
.map(|repo| (repo.owner.clone(), repo.repo.clone()))
.collect();
let auth_check_future = async move {
integrations_client
.check_user_repo_auth_status(repo_tuples)
.await
};
ctx.spawn(auth_check_future, move |runner, auth_result, ctx| {
match auth_result {
Ok(response) => {
let mut has_blocking_private_issues = false;
for status in &response.statuses {
match status.status {
UserRepoAuthStatusEnum::Success => {}
UserRepoAuthStatusEnum::NoInstallationOrAccessForRepo => {
if !status.is_public {
eprintln!(
"Cannot access private repo {}/{}",
status.owner, status.repo,
);
has_blocking_private_issues = true;
}
// Public repos without auth are fine - no warning needed
}
UserRepoAuthStatusEnum::UserNotConnectedToGithub => {
eprintln!("User not connected to GitHub");
has_blocking_private_issues = true;
break;
}
}
}
if !has_blocking_private_issues {
// No blocking issues - proceed with listing
runner.fetch_and_display_agents(repo_spec, ctx);
return;
}
// Handle OAuth flow if server provides auth_url + tx_id
match (response.auth_url, response.tx_id) {
(Some(auth_url), Some(tx_id)) => {
println!("\nAuthorization required for private repository access.");
println!("Opening browser for GitHub authorization: {auth_url}\n");
ctx.open_url(&auth_url);
let integrations_client = ServerApiProvider::handle(ctx)
.as_ref(ctx)
.get_integrations_client();
let tx_id = tx_id.into_inner();
let poll_future = poll_oauth_until_terminal(integrations_client, tx_id);
let next_attempt = attempt + 1;
ctx.spawn(poll_future, move |runner, poll_result, ctx| {
match poll_result {
Ok(OauthConnectTxStatus::Completed) => {
// OAuth completed, retry
runner.auth_then_list(repos, next_attempt, repo_spec, ctx);
}
Ok(OauthConnectTxStatus::Failed) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!(
"GitHub authorization failed. Please try again."
))),
);
}
Ok(OauthConnectTxStatus::Expired) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!(
"GitHub authorization expired. Please try again."
))),
);
}
Ok(_) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!(
"Unexpected OAuth status"
))),
);
}
Err(err) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!(
"Error polling OAuth status: {err}"
))),
);
}
}
});
}
(Some(auth_url), None) => {
println!("\nAuthorize access here: {auth_url}\n");
println!("After authorizing, please re-run this command.");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
_ => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!(
"Cannot list agents: authorization required but no auth flow provided"
))),
);
}
}
}
Err(e) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(e.context("Failed to check GitHub auth status"))),
);
}
}
});
}
fn fetch_and_display_agents(&self, repo: Option<String>, ctx: &mut ModelContext<Self>) {
let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client();
if repo.is_some() {
println!("Fetching agent skills from the specified repository...");
} else {
println!("Fetching agent skills from your Warp environments...");
}
let list_future = async move { ai_client.list_agents(repo).await };
ctx.spawn(list_future, |_, result, ctx| match result {
Ok(agents) => {
Self::print_agents_table(&agents);
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
}
/// Print a list of agents in a card-style format.
fn print_agents_table(agents: &[AgentListItem]) {
if agents.is_empty() {
println!("No agents found.");
return;
}
if agents.len() == 1 {
println!("\nAgent:");
} else {
println!("\nAgents ({}):", agents.len());
}
for agent in agents {
println!("\n{}", agent.name);
for variant in &agent.variants {
let mut table = super::output::standard_table();
// ID
table.add_row(vec![format!("ID: {}", variant.id)]);
// Description
if !variant.description.is_empty() {
let description_cell = super::text_layout::render_labeled_wrapped_field(
"Description",
&variant.description,
MAX_LINE_WIDTH,
);
table.add_row(vec![description_cell]);
}
// Base prompt (truncated)
if !variant.base_prompt.is_empty() {
let mut chars = variant.base_prompt.chars();
let truncated: String = chars.by_ref().take(100).collect();
let truncated_prompt = if chars.next().is_some() {
format!("{truncated}...")
} else {
truncated
};
let prompt_cell = super::text_layout::render_labeled_wrapped_field(
"Base Prompt",
&truncated_prompt,
MAX_LINE_WIDTH,
);
table.add_row(vec![prompt_cell]);
}
// Source
table.add_row(vec![format!(
"Source: {}/{}",
variant.source.owner, variant.source.name
)]);
// Environments
if !variant.environments.is_empty() {
let env_entries: Vec<_> = variant
.environments
.iter()
.map(|e| format!("{} ({})", e.name, e.uid))
.collect();
table.add_row(vec![format!("Environments: {}", env_entries.join(", "))]);
}
println!("{table}");
}
}
}
}
impl warpui::Entity for AgentConfigRunner {
type Event = ();
}
impl SingletonEntity for AgentConfigRunner {}
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
//! Unit tests for `filter_from_args`. Verifies the clap enums are faithfully translated into
//! `TaskListFilter` without dropping any fields.
use chrono::{TimeZone, Utc};
use warp_cli::json_filter::JsonOutput;
use warp_cli::task::{
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, RunSortByArg, RunSortOrderArg,
RunSourceArg, RunStateArg,
};
use super::*;
use crate::server::server_api::ai::{ArtifactType, ExecutionLocation, RunSortBy, RunSortOrder};
/// A `ListTasksArgs` whose fields are all at their defaults.
fn empty_args() -> ListTasksArgs {
ListTasksArgs {
limit: 10,
state: vec![],
source: None,
execution_location: None,
creator: None,
environment: None,
skill: None,
schedule: None,
ancestor_run: None,
name: None,
model: None,
artifact_type: None,
created_after: None,
created_before: None,
updated_after: None,
query: None,
sort_by: None,
sort_order: None,
cursor: None,
json_output: JsonOutput::default(),
}
}
#[test]
fn empty_args_yields_default_filter() {
let filter = filter_from_args(&empty_args());
assert!(filter.creator_uid.is_none());
assert!(filter.updated_after.is_none());
assert!(filter.created_after.is_none());
assert!(filter.created_before.is_none());
assert!(filter.states.is_none());
assert!(filter.source.is_none());
assert!(filter.execution_location.is_none());
assert!(filter.environment_id.is_none());
assert!(filter.skill_spec.is_none());
assert!(filter.schedule_id.is_none());
assert!(filter.ancestor_run_id.is_none());
assert!(filter.config_name.is_none());
assert!(filter.model_id.is_none());
assert!(filter.artifact_type.is_none());
assert!(filter.search_query.is_none());
assert!(filter.sort_by.is_none());
assert!(filter.sort_order.is_none());
assert!(filter.cursor.is_none());
}
#[test]
fn state_flags_map_to_filter() {
let args = ListTasksArgs {
state: vec![
RunStateArg::Failed,
RunStateArg::Error,
RunStateArg::Cancelled,
],
..empty_args()
};
let filter = filter_from_args(&args);
assert_eq!(
filter.states.as_deref(),
Some(
[
AmbientAgentTaskState::Failed,
AmbientAgentTaskState::Error,
AmbientAgentTaskState::Cancelled,
]
.as_slice()
)
);
}
#[test]
fn source_cli_maps_to_cli() {
let args = ListTasksArgs {
source: Some(RunSourceArg::Cli),
..empty_args()
};
let filter = filter_from_args(&args);
assert_eq!(filter.source, Some(AgentSource::Cli));
// Sanity-check the wire value: `--source CLI` must send `source=CLI`.
assert_eq!(filter.source.as_ref().map(AgentSource::as_str), Some("CLI"));
}
#[test]
fn source_interactive_maps_to_local() {
// The public API uses `LOCAL` as the source value for local interactive
// tasks. The CLI exposes this as `--source INTERACTIVE` for readability,
// but the request sent to the server must use `LOCAL`.
let args = ListTasksArgs {
source: Some(RunSourceArg::Interactive),
..empty_args()
};
let filter = filter_from_args(&args);
assert_eq!(filter.source, Some(AgentSource::Interactive));
assert_eq!(
filter.source.as_ref().map(AgentSource::as_str),
Some("LOCAL")
);
}
#[test]
fn every_field_maps_through() {
let created_after = Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap();
let created_before = Utc.with_ymd_and_hms(2026, 4, 2, 0, 0, 0).unwrap();
let updated_after = Utc.with_ymd_and_hms(2026, 4, 3, 12, 30, 0).unwrap();
let args = ListTasksArgs {
limit: 20,
state: vec![RunStateArg::InProgress],
source: Some(RunSourceArg::Api),
execution_location: Some(ExecutionLocationArg::Remote),
creator: Some("user-uid".to_string()),
environment: Some("env-123".to_string()),
skill: Some("owner/repo:SKILL.md".to_string()),
schedule: Some("sched-1".to_string()),
ancestor_run: Some("run-parent".to_string()),
name: Some("nightly".to_string()),
model: Some("claude-4-5".to_string()),
artifact_type: Some(ArtifactTypeArg::PullRequest),
created_after: Some(created_after),
created_before: Some(created_before),
updated_after: Some(updated_after),
query: Some("oz run".to_string()),
sort_by: Some(RunSortByArg::CreatedAt),
sort_order: Some(RunSortOrderArg::Asc),
cursor: Some("abcd==".to_string()),
json_output: JsonOutput::default(),
};
let filter = filter_from_args(&args);
assert_eq!(filter.creator_uid.as_deref(), Some("user-uid"));
assert_eq!(filter.updated_after, Some(updated_after));
assert_eq!(filter.created_after, Some(created_after));
assert_eq!(filter.created_before, Some(created_before));
assert_eq!(
filter.states.as_deref(),
Some([AmbientAgentTaskState::InProgress].as_slice())
);
assert_eq!(filter.source, Some(AgentSource::AgentWebhook));
assert_eq!(filter.execution_location, Some(ExecutionLocation::Remote));
assert_eq!(filter.environment_id.as_deref(), Some("env-123"));
assert_eq!(filter.skill_spec.as_deref(), Some("owner/repo:SKILL.md"));
assert_eq!(filter.schedule_id.as_deref(), Some("sched-1"));
assert_eq!(filter.ancestor_run_id.as_deref(), Some("run-parent"));
assert_eq!(filter.config_name.as_deref(), Some("nightly"));
assert_eq!(filter.model_id.as_deref(), Some("claude-4-5"));
assert_eq!(filter.artifact_type, Some(ArtifactType::PullRequest));
assert_eq!(filter.search_query.as_deref(), Some("oz run"));
assert_eq!(filter.sort_by, Some(RunSortBy::CreatedAt));
assert_eq!(filter.sort_order, Some(RunSortOrder::Asc));
assert_eq!(filter.cursor.as_deref(), Some("abcd=="));
}
+405
View File
@@ -0,0 +1,405 @@
use std::{path::PathBuf, sync::Arc};
use anyhow::{Context, Result};
use serde::Serialize;
use warp_cli::agent::OutputFormat;
use warp_cli::artifact::{
ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs,
};
use warp_cli::GlobalOptions;
use warpui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
use crate::ai::artifact_download::{download_artifact_bytes, download_destination};
#[cfg(test)]
use crate::server::server_api::ai::FileArtifactRecord;
use crate::server::server_api::ai::{AIClient, ArtifactDownloadResponse};
use crate::server::server_api::{ServerApi, ServerApiProvider};
use super::artifact_upload::{
CompletedFileArtifactUpload, FileArtifactUploadRequest, FileArtifactUploader,
};
/// Run artifact-related commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: ArtifactCommand,
) -> Result<()> {
let runner = ctx.add_singleton_model(|_| ArtifactCommandRunner);
match command {
ArtifactCommand::Upload(args) => {
runner.update(ctx, |runner, ctx| {
runner.upload(args, global_options.output_format, ctx);
});
Ok(())
}
ArtifactCommand::Get(args) => {
runner.update(ctx, |runner, ctx| {
runner.get(args, global_options.output_format, ctx);
});
Ok(())
}
ArtifactCommand::Download(args) => {
runner.update(ctx, |runner, ctx| {
runner.download(args, global_options.output_format, ctx);
});
Ok(())
}
}
}
struct ArtifactCommandRunner;
impl ArtifactCommandRunner {
fn get(
&self,
args: GetArtifactArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
ctx.spawn(
async move { get_artifact(ai_client, &args.artifact_uid).await },
move |_, result, ctx| match result {
Ok(artifact) => {
if let Err(err) = write_get_output(&artifact, output_format) {
super::report_fatal_error(err, ctx);
return;
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => super::report_fatal_error(err, ctx),
},
);
}
fn download(
&self,
args: DownloadArtifactArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let server_api = ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move { download_artifact(ai_client, server_api, args).await },
move |_, result, ctx| match result {
Ok(output) => {
if let Err(err) = write_download_output(&output, output_format) {
super::report_fatal_error(err, ctx);
return;
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => super::report_fatal_error(err, ctx),
},
);
}
fn upload(
&self,
args: UploadArtifactArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) {
let server_api = ServerApiProvider::as_ref(ctx).get();
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let uploader = FileArtifactUploader::new(ai_client, server_api.clone());
ctx.spawn(
async move {
let request = FileArtifactUploadRequest::try_from(args)?;
let association = uploader.resolve_upload_association(&request).await?;
server_api.set_ambient_agent_task_id(Some(association.ambient_task_id));
uploader.upload_with_association(request, association).await
},
move |_, result, ctx| match result {
Ok(artifact) => {
if let Err(err) = write_upload_output(&artifact, output_format) {
super::report_fatal_error(err, ctx);
return;
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => super::report_fatal_error(err, ctx),
},
);
}
}
impl warpui::Entity for ArtifactCommandRunner {
type Event = ();
}
impl SingletonEntity for ArtifactCommandRunner {}
async fn get_artifact(
ai_client: Arc<dyn AIClient>,
artifact_uid: &str,
) -> Result<ArtifactDownloadResponse> {
ai_client
.get_artifact_download(artifact_uid)
.await
.with_context(|| format!("Failed to get artifact '{artifact_uid}'"))
}
async fn download_artifact(
ai_client: Arc<dyn AIClient>,
server_api: Arc<ServerApi>,
args: DownloadArtifactArgs,
) -> Result<DownloadArtifactOutput> {
let artifact = get_artifact(ai_client, &args.artifact_uid).await?;
let path = download_destination(&artifact, args.out);
download_artifact_bytes(server_api.http_client(), &artifact, &path).await?;
let path = std::path::absolute(&path).unwrap_or(path);
Ok(DownloadArtifactOutput::new(&artifact, path))
}
#[derive(Debug, Serialize)]
struct ArtifactMetadataOutput {
artifact_uid: String,
artifact_type: String,
created_at: String,
download_url: String,
expires_at: String,
content_type: String,
filepath: Option<String>,
filename: Option<String>,
description: Option<String>,
size_bytes: Option<i64>,
}
impl ArtifactMetadataOutput {
fn new(artifact: &ArtifactDownloadResponse) -> Self {
Self {
artifact_uid: artifact.artifact_uid().to_string(),
artifact_type: artifact.artifact_type().to_string(),
created_at: artifact.created_at().to_rfc3339(),
download_url: artifact.download_url().to_string(),
expires_at: artifact.expires_at().to_rfc3339(),
content_type: artifact.content_type().to_string(),
filepath: artifact.filepath().map(ToString::to_string),
filename: artifact.filename().map(ToString::to_string),
description: artifact.description().map(ToString::to_string),
size_bytes: artifact.size_bytes(),
}
}
}
#[derive(Debug, Serialize)]
struct DownloadArtifactOutput {
artifact_uid: String,
artifact_type: String,
path: PathBuf,
}
impl DownloadArtifactOutput {
fn new(artifact: &ArtifactDownloadResponse, path: PathBuf) -> Self {
Self {
artifact_uid: artifact.artifact_uid().to_string(),
artifact_type: artifact.artifact_type().to_string(),
path,
}
}
}
#[derive(Debug, Serialize)]
struct UploadArtifactOutput {
artifact_uid: String,
filepath: String,
description: Option<String>,
mime_type: String,
size_bytes: Option<i64>,
}
fn write_get_output(
artifact: &ArtifactDownloadResponse,
output_format: OutputFormat,
) -> Result<()> {
let mut stdout = std::io::stdout();
write_get_output_to(&mut stdout, artifact, output_format)
}
fn write_get_output_to<W: std::io::Write>(
output: &mut W,
artifact: &ArtifactDownloadResponse,
output_format: OutputFormat,
) -> Result<()> {
let output_record = ArtifactMetadataOutput::new(artifact);
match output_format {
OutputFormat::Json | OutputFormat::Ndjson => {
serde_json::to_writer(&mut *output, &output_record)
.context("unable to write JSON output")?;
writeln!(&mut *output)?;
}
OutputFormat::Pretty => {
writeln!(&mut *output, "Artifact UID: {}", output_record.artifact_uid)?;
writeln!(
&mut *output,
"Artifact type: {}",
output_record.artifact_type
)?;
writeln!(&mut *output, "Created at: {}", output_record.created_at)?;
writeln!(&mut *output, "Download URL: {}", output_record.download_url)?;
writeln!(&mut *output, "Expires at: {}", output_record.expires_at)?;
writeln!(&mut *output, "Content type: {}", output_record.content_type)?;
if let Some(filepath) = output_record.filepath {
writeln!(&mut *output, "Filepath: {filepath}")?;
}
if let Some(filename) = output_record.filename {
writeln!(&mut *output, "Filename: {filename}")?;
}
if let Some(description) = output_record.description {
writeln!(&mut *output, "Description: {description}")?;
}
if let Some(size_bytes) = output_record.size_bytes {
writeln!(&mut *output, "Size bytes: {size_bytes}")?;
}
}
OutputFormat::Text => {
writeln!(
&mut *output,
"Artifact UID\tArtifact type\tCreated at\tDownload URL\tExpires at\tContent type\tFilepath\tFilename\tDescription\tSize bytes"
)?;
writeln!(
&mut *output,
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
output_record.artifact_uid,
output_record.artifact_type,
output_record.created_at,
output_record.download_url,
output_record.expires_at,
output_record.content_type,
output_record.filepath.unwrap_or_default(),
output_record.filename.unwrap_or_default(),
output_record.description.unwrap_or_default(),
output_record
.size_bytes
.map(|size| size.to_string())
.unwrap_or_default()
)?;
}
}
Ok(())
}
fn write_download_output(
output_record: &DownloadArtifactOutput,
output_format: OutputFormat,
) -> Result<()> {
let mut stdout = std::io::stdout();
write_download_output_to(&mut stdout, output_record, output_format)
}
fn write_download_output_to<W: std::io::Write>(
output: &mut W,
output_record: &DownloadArtifactOutput,
output_format: OutputFormat,
) -> Result<()> {
match output_format {
OutputFormat::Json | OutputFormat::Ndjson => {
serde_json::to_writer(&mut *output, output_record)
.context("unable to write JSON output")?;
writeln!(&mut *output)?;
}
OutputFormat::Pretty => {
writeln!(&mut *output, "Artifact downloaded")?;
writeln!(&mut *output, "Artifact UID: {}", output_record.artifact_uid)?;
writeln!(
&mut *output,
"Artifact type: {}",
output_record.artifact_type
)?;
writeln!(&mut *output, "Path: {}", output_record.path.display())?;
}
OutputFormat::Text => {
writeln!(&mut *output, "Artifact UID\tArtifact type\tPath")?;
writeln!(
&mut *output,
"{}\t{}\t{}",
output_record.artifact_uid,
output_record.artifact_type,
output_record.path.display()
)?;
}
}
Ok(())
}
fn write_upload_output(
artifact: &CompletedFileArtifactUpload,
output_format: OutputFormat,
) -> Result<()> {
let mut stdout = std::io::stdout();
write_upload_output_to(&mut stdout, artifact, output_format)
}
fn write_upload_output_to<W: std::io::Write>(
output: &mut W,
artifact: &CompletedFileArtifactUpload,
output_format: OutputFormat,
) -> Result<()> {
let output_record = UploadArtifactOutput {
artifact_uid: artifact.artifact.artifact_uid.clone(),
filepath: artifact.artifact.filepath.clone(),
description: artifact.artifact.description.clone(),
mime_type: artifact.artifact.mime_type.clone(),
size_bytes: Some(artifact.size_bytes),
};
match output_format {
OutputFormat::Json | OutputFormat::Ndjson => {
serde_json::to_writer(&mut *output, &output_record)
.context("unable to write JSON output")?;
writeln!(&mut *output)?;
}
OutputFormat::Pretty => {
writeln!(&mut *output, "Artifact uploaded")?;
writeln!(&mut *output, "Artifact UID: {}", output_record.artifact_uid)?;
writeln!(&mut *output, "Filepath: {}", output_record.filepath)?;
writeln!(
&mut *output,
"Description: {}",
output_record.description.as_deref().unwrap_or("")
)?;
writeln!(&mut *output, "MIME type: {}", output_record.mime_type)?;
writeln!(
&mut *output,
"Size bytes: {}",
output_record
.size_bytes
.map(|size| size.to_string())
.unwrap_or_default()
)?;
}
OutputFormat::Text => {
writeln!(
&mut *output,
"Artifact UID\tFilepath\tDescription\tMIME type\tSize bytes"
)?;
writeln!(
&mut *output,
"{}\t{}\t{}\t{}\t{}",
output_record.artifact_uid,
output_record.filepath,
output_record.description.unwrap_or_default(),
output_record.mime_type,
output_record
.size_bytes
.map(|size| size.to_string())
.unwrap_or_default()
)?;
}
}
Ok(())
}
#[cfg(test)]
#[path = "artifact_tests.rs"]
mod tests;
+255
View File
@@ -0,0 +1,255 @@
use std::path::PathBuf;
use warp_cli::agent::OutputFormat;
use super::*;
fn sample_completed_upload() -> CompletedFileArtifactUpload {
CompletedFileArtifactUpload {
artifact: sample_artifact_record(),
size_bytes: 42,
}
}
fn sample_artifact_record() -> FileArtifactRecord {
FileArtifactRecord {
artifact_uid: "artifact-123".to_string(),
filepath: "outputs/report.txt".to_string(),
description: Some("daily summary".to_string()),
mime_type: "text/plain".to_string(),
size_bytes: Some(42),
}
}
fn sample_file_download_response() -> ArtifactDownloadResponse {
serde_json::from_str(
r#"{
"artifact_uid": "artifact-123",
"artifact_type": "FILE",
"created_at": "2024-01-15T10:30:00Z",
"data": {
"download_url": "https://storage.example.com/report.txt",
"expires_at": "2024-01-15T11:30:00Z",
"content_type": "text/plain",
"filepath": "outputs/report.txt",
"filename": "report.txt",
"description": "daily summary",
"size_bytes": 42
}
}"#,
)
.unwrap()
}
fn sample_screenshot_download_response() -> ArtifactDownloadResponse {
serde_json::from_str(
r#"{
"artifact_uid": "screenshot-123",
"artifact_type": "SCREENSHOT",
"created_at": "2024-01-15T10:30:00Z",
"data": {
"download_url": "https://storage.example.com/screenshot.png",
"expires_at": "2024-01-15T11:30:00Z",
"content_type": "image/png",
"description": "dashboard screenshot"
}
}"#,
)
.unwrap()
}
#[test]
fn write_get_output_to_writes_json_output() {
let mut output = Vec::new();
write_get_output_to(
&mut output,
&sample_file_download_response(),
OutputFormat::Json,
)
.unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"{\"artifact_uid\":\"artifact-123\",\"artifact_type\":\"FILE\",\"created_at\":\"2024-01-15T10:30:00+00:00\",\"download_url\":\"https://storage.example.com/report.txt\",\"expires_at\":\"2024-01-15T11:30:00+00:00\",\"content_type\":\"text/plain\",\"filepath\":\"outputs/report.txt\",\"filename\":\"report.txt\",\"description\":\"daily summary\",\"size_bytes\":42}\n"
);
}
#[test]
fn write_get_output_to_writes_ndjson_output() {
let mut output = Vec::new();
write_get_output_to(
&mut output,
&sample_file_download_response(),
OutputFormat::Ndjson,
)
.unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"{\"artifact_uid\":\"artifact-123\",\"artifact_type\":\"FILE\",\"created_at\":\"2024-01-15T10:30:00+00:00\",\"download_url\":\"https://storage.example.com/report.txt\",\"expires_at\":\"2024-01-15T11:30:00+00:00\",\"content_type\":\"text/plain\",\"filepath\":\"outputs/report.txt\",\"filename\":\"report.txt\",\"description\":\"daily summary\",\"size_bytes\":42}\n"
);
}
#[test]
fn write_get_output_to_writes_pretty_output() {
let mut output = Vec::new();
write_get_output_to(
&mut output,
&sample_file_download_response(),
OutputFormat::Pretty,
)
.unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"Artifact UID: artifact-123\nArtifact type: FILE\nCreated at: 2024-01-15T10:30:00+00:00\nDownload URL: https://storage.example.com/report.txt\nExpires at: 2024-01-15T11:30:00+00:00\nContent type: text/plain\nFilepath: outputs/report.txt\nFilename: report.txt\nDescription: daily summary\nSize bytes: 42\n"
);
}
#[test]
fn write_get_output_to_writes_text_output() {
let mut output = Vec::new();
write_get_output_to(
&mut output,
&sample_file_download_response(),
OutputFormat::Text,
)
.unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"Artifact UID\tArtifact type\tCreated at\tDownload URL\tExpires at\tContent type\tFilepath\tFilename\tDescription\tSize bytes\nartifact-123\tFILE\t2024-01-15T10:30:00+00:00\thttps://storage.example.com/report.txt\t2024-01-15T11:30:00+00:00\ttext/plain\toutputs/report.txt\treport.txt\tdaily summary\t42\n"
);
}
#[test]
fn write_download_output_to_writes_pretty_output() {
let artifact = sample_file_download_response();
let path = std::path::absolute("report.txt").unwrap();
let output_record = DownloadArtifactOutput::new(&artifact, path.clone());
let mut output = Vec::new();
write_download_output_to(&mut output, &output_record, OutputFormat::Pretty).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
format!(
"Artifact downloaded\nArtifact UID: artifact-123\nArtifact type: FILE\nPath: {}\n",
path.display()
)
);
}
#[test]
fn download_destination_uses_explicit_path() {
assert_eq!(
download_destination(
&sample_file_download_response(),
Some(PathBuf::from("downloads/report.txt"))
),
PathBuf::from("downloads/report.txt")
);
}
#[test]
fn download_destination_defaults_to_file_artifact_filename() {
assert_eq!(
download_destination(&sample_file_download_response(), None),
PathBuf::from("report.txt")
);
}
#[test]
fn download_destination_defaults_screenshot_to_artifact_uid_with_extension() {
assert_eq!(
download_destination(&sample_screenshot_download_response(), None),
PathBuf::from("artifact-screenshot-123.png")
);
}
#[test]
fn download_destination_defaults_pdf_to_artifact_uid_with_extension() {
let artifact: ArtifactDownloadResponse = serde_json::from_str(
r#"{
"artifact_uid": "artifact-pdf-123",
"artifact_type": "FILE",
"created_at": "2024-01-15T10:30:00Z",
"data": {
"download_url": "https://storage.example.com/report.pdf",
"expires_at": "2024-01-15T11:30:00Z",
"content_type": "application/pdf",
"filepath": "outputs/report.pdf",
"filename": "",
"description": "pdf report",
"size_bytes": 42
}
}"#,
)
.unwrap();
assert_eq!(
download_destination(&artifact, None),
PathBuf::from("artifact-artifact-pdf-123.pdf")
);
}
#[test]
fn write_upload_output_to_writes_json_output() {
let mut output = Vec::new();
write_upload_output_to(&mut output, &sample_completed_upload(), OutputFormat::Json).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"{\"artifact_uid\":\"artifact-123\",\"filepath\":\"outputs/report.txt\",\"description\":\"daily summary\",\"mime_type\":\"text/plain\",\"size_bytes\":42}\n"
);
}
#[test]
fn write_upload_output_to_writes_ndjson_output() {
let mut output = Vec::new();
write_upload_output_to(
&mut output,
&sample_completed_upload(),
OutputFormat::Ndjson,
)
.unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"{\"artifact_uid\":\"artifact-123\",\"filepath\":\"outputs/report.txt\",\"description\":\"daily summary\",\"mime_type\":\"text/plain\",\"size_bytes\":42}\n"
);
}
#[test]
fn write_upload_output_to_writes_pretty_output() {
let mut output = Vec::new();
write_upload_output_to(
&mut output,
&sample_completed_upload(),
OutputFormat::Pretty,
)
.unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"Artifact uploaded\nArtifact UID: artifact-123\nFilepath: outputs/report.txt\nDescription: daily summary\nMIME type: text/plain\nSize bytes: 42\n"
);
}
#[test]
fn write_upload_output_to_writes_text_output() {
let mut output = Vec::new();
write_upload_output_to(&mut output, &sample_completed_upload(), OutputFormat::Text).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"Artifact UID\tFilepath\tDescription\tMIME type\tSize bytes\nartifact-123\toutputs/report.txt\tdaily summary\ttext/plain\t42\n"
);
}
+386
View File
@@ -0,0 +1,386 @@
use std::env;
use std::fs::File;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use blocking::unblock;
use mime_guess::from_path;
use warp_cli::artifact::UploadArtifactArgs;
use super::common::parse_ambient_task_id;
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::ServerAIConversationMetadata;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::ai::{
AIClient, CreateFileArtifactUploadRequest, CreateFileArtifactUploadResponse,
FileArtifactRecord, FileArtifactUploadTargetInfo,
};
use crate::server::server_api::presigned_upload::upload_file_to_target;
use crate::server::server_api::ServerApi;
const MIME_SNIFF_BYTES: usize = 8 * 1024;
const OZ_RUN_ID_ENV_VAR: &str = "OZ_RUN_ID";
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) struct FileArtifactUploadRequest {
pub(crate) path: PathBuf,
pub(crate) run_id: Option<AmbientAgentTaskId>,
pub(crate) conversation_id: Option<ServerConversationToken>,
pub(crate) description: Option<String>,
}
impl TryFrom<UploadArtifactArgs> for FileArtifactUploadRequest {
type Error = anyhow::Error;
fn try_from(value: UploadArtifactArgs) -> Result<Self> {
let run_id = match value.run_id {
Some(run_id) => Some(parse_run_id(&run_id, "Invalid run ID")?),
None => None,
};
Ok(Self {
path: value.path,
run_id,
conversation_id: value.conversation_id.map(ServerConversationToken::new),
description: value.description,
})
}
}
#[derive(Debug, Clone)]
pub(crate) struct CompletedFileArtifactUpload {
pub(crate) artifact: FileArtifactRecord,
pub(crate) size_bytes: i64,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) struct ResolvedUploadAssociation {
conversation_id: Option<ServerConversationToken>,
run_id: Option<AmbientAgentTaskId>,
pub(crate) ambient_task_id: AmbientAgentTaskId,
}
#[derive(Debug, Clone)]
struct PreparedUploadArtifact {
path: PathBuf,
filepath: String,
mime_type: String,
file_size: u64,
}
impl PreparedUploadArtifact {
fn from_path(path: PathBuf) -> Result<Self> {
// `infer` only needs leading signature bytes, so avoid buffering the whole artifact
// before we stream the file body to the upload target.
let (file_size, mime_sniff_bytes) = file_size_and_prefix_for_path(&path, MIME_SNIFF_BYTES)?;
Ok(Self {
filepath: normalize_artifact_filepath(&path),
mime_type: infer_mime_type(&path, &mime_sniff_bytes),
file_size,
path,
})
}
fn graphql_size_bytes(&self) -> Option<i32> {
checked_graphql_size_bytes_for_upload(&self.path, self.file_size)
}
}
pub(crate) struct FileArtifactUploader {
ai_client: Arc<dyn AIClient>,
server_api: Arc<ServerApi>,
}
impl FileArtifactUploader {
pub(crate) fn new(ai_client: Arc<dyn AIClient>, server_api: Arc<ServerApi>) -> Self {
Self {
ai_client,
server_api,
}
}
pub(crate) async fn upload_with_association(
&self,
request: FileArtifactUploadRequest,
association: ResolvedUploadAssociation,
) -> Result<CompletedFileArtifactUpload> {
let FileArtifactUploadRequest {
path, description, ..
} = request;
let artifact = self.prepare_upload_artifact(path).await?;
let create_response = self
.create_upload_target(association, description, &artifact)
.await?;
let checksum = self
.upload_artifact_bytes(&create_response.upload_target, &artifact)
.await?;
let uploaded_artifact = self
.confirm_upload(create_response.artifact.artifact_uid, checksum)
.await?;
let size_bytes = i64::try_from(artifact.file_size)
.context("Artifact file size exceeds supported range")?;
Ok(CompletedFileArtifactUpload {
artifact: uploaded_artifact,
size_bytes,
})
}
async fn prepare_upload_artifact(&self, path: PathBuf) -> Result<PreparedUploadArtifact> {
unblock(move || PreparedUploadArtifact::from_path(path)).await
}
async fn create_upload_target(
&self,
association: ResolvedUploadAssociation,
description: Option<String>,
artifact: &PreparedUploadArtifact,
) -> Result<CreateFileArtifactUploadResponse> {
self.ai_client
.create_file_artifact_upload_target(CreateFileArtifactUploadRequest {
conversation_id: association
.conversation_id
.as_ref()
.map(|token| token.as_str().to_string()),
run_id: association.run_id.as_ref().map(ToString::to_string),
filepath: artifact.filepath.clone(),
description,
mime_type: Some(artifact.mime_type.clone()),
size_bytes: artifact.graphql_size_bytes(),
})
.await
.context("Failed to create file artifact upload target")
}
async fn upload_artifact_bytes(
&self,
target: &FileArtifactUploadTargetInfo,
artifact: &PreparedUploadArtifact,
) -> Result<String> {
upload_file_to_target(
self.server_api.http_client(),
target,
&artifact.path,
artifact.file_size,
)
.await
}
async fn confirm_upload(
&self,
artifact_uid: String,
checksum: String,
) -> Result<FileArtifactRecord> {
self.ai_client
.confirm_file_artifact_upload(artifact_uid, checksum)
.await
.context("Failed to confirm file artifact upload")
}
pub(crate) async fn resolve_upload_association(
&self,
request: &FileArtifactUploadRequest,
) -> Result<ResolvedUploadAssociation> {
let conversation_task_id = match (request.run_id.as_ref(), request.conversation_id.as_ref())
{
// we were given a conversation id, so we need to resolve the task id from the conversation via the api
(None, Some(conversation_id)) => {
Some(self.resolve_conversation_task_id(conversation_id).await)
}
_ => None,
};
resolve_upload_association_from_sources(
request.run_id,
request.conversation_id.clone(),
conversation_task_id,
load_env_run_id()?,
)
}
async fn resolve_conversation_task_id(
&self,
conversation_id: &ServerConversationToken,
) -> Result<AmbientAgentTaskId> {
let metadata = self
.ai_client
.list_ai_conversation_metadata(Some(vec![conversation_id.as_str().to_string()]))
.await
.with_context(|| {
format!(
"Failed to load conversation '{}' to resolve artifact upload headers",
conversation_id.as_str()
)
})?;
let metadata = single_conversation_metadata(conversation_id.as_str(), metadata)
.with_context(|| {
format!(
"Failed to load conversation '{}' to resolve artifact upload headers",
conversation_id.as_str()
)
})?;
ambient_task_id_from_conversation_metadata(conversation_id.as_str(), metadata)
}
}
fn normalize_artifact_filepath(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn infer_mime_type(path: &Path, file_bytes: &[u8]) -> String {
infer::get(file_bytes)
.map(|kind| kind.mime_type().to_string())
.unwrap_or_else(|| from_path(path).first_or_octet_stream().to_string())
}
fn file_size_and_prefix_for_path(path: &Path, max_bytes: usize) -> Result<(u64, Vec<u8>)> {
let mut file = File::open(path)
.with_context(|| format!("Failed to open artifact file '{}'", path.display()))?;
let file_size = file
.metadata()
.with_context(|| format!("Failed to stat artifact file '{}'", path.display()))?
.len();
let mut bytes = vec![0; max_bytes];
let bytes_read = file
.read(&mut bytes)
.with_context(|| format!("Failed to read artifact file '{}'", path.display()))?;
bytes.truncate(bytes_read);
Ok((file_size, bytes))
}
fn checked_graphql_size_bytes_for_upload(path: &Path, size_bytes: u64) -> Option<i32> {
let graphql_size_bytes = i32::try_from(size_bytes).ok();
if graphql_size_bytes.is_none() {
// The backing upload can handle large files, but the GraphQL field is still `Int`.
// Dropping `size_bytes` preserves the upload request instead of failing on conversion.
log::warn!(
"Artifact file '{}' is {} bytes, which exceeds the GraphQL size_bytes limit of {} bytes; omitting size_bytes from the upload target request",
path.display(),
size_bytes,
i32::MAX,
);
}
graphql_size_bytes
}
fn single_conversation_metadata(
conversation_id: &str,
mut metadata: Vec<ServerAIConversationMetadata>,
) -> Result<ServerAIConversationMetadata> {
match metadata.len() {
0 => bail!("Conversation not found"),
1 => Ok(metadata.pop().expect("metadata length checked")),
_ => bail!("Multiple conversations found for '{conversation_id}'"),
}
}
fn ambient_task_id_from_conversation_metadata(
conversation_id: &str,
metadata: ServerAIConversationMetadata,
) -> Result<AmbientAgentTaskId> {
metadata.ambient_agent_task_id.ok_or_else(|| {
anyhow!("Conversation '{conversation_id}' is not backed by a cloud agent task")
})
}
fn parse_run_id(run_id: &str, error_prefix: &str) -> Result<AmbientAgentTaskId> {
parse_ambient_task_id(run_id, error_prefix)
}
fn load_env_run_id() -> Result<Option<String>> {
match env::var(OZ_RUN_ID_ENV_VAR) {
Ok(run_id) => Ok(Some(run_id)),
Err(env::VarError::NotPresent) => Ok(None),
Err(env::VarError::NotUnicode(_)) => Err(anyhow!(
"{OZ_RUN_ID_ENV_VAR} is set but is not valid Unicode"
)),
}
}
fn resolve_env_run_id(env_run_id: Option<String>) -> Result<AmbientAgentTaskId> {
let Some(run_id) = env_run_id else {
bail!("{OZ_RUN_ID_ENV_VAR} is not set");
};
parse_run_id(&run_id, "Invalid OZ_RUN_ID")
}
fn resolve_upload_association_from_sources(
explicit_run_id: Option<AmbientAgentTaskId>,
explicit_conversation_id: Option<ServerConversationToken>,
conversation_task_id: Option<Result<AmbientAgentTaskId>>,
env_run_id: Option<String>,
) -> Result<ResolvedUploadAssociation> {
// Precedence is deliberate:
// 1. An explicit run ID is authoritative and must not silently fall back.
// 2. A conversation ID stays attached to the artifact even if we have to borrow the ambient
// task ID from `OZ_RUN_ID` because the conversation lacks cloud-task metadata.
// 3. `OZ_RUN_ID` becomes the sole source of truth only when the caller supplied nothing else.
if let Some(run_id) = explicit_run_id {
let ambient_task_id = run_id;
return Ok(ResolvedUploadAssociation {
conversation_id: None,
run_id: Some(run_id),
ambient_task_id,
});
}
if let Some(conversation_id) = explicit_conversation_id {
match conversation_task_id
.ok_or_else(|| anyhow!("conversation resolution should be provided"))?
{
Ok(ambient_task_id) => {
return Ok(ResolvedUploadAssociation {
conversation_id: Some(conversation_id),
run_id: None,
ambient_task_id,
});
}
Err(conversation_err) => {
let env_err = match resolve_env_run_id(env_run_id) {
Ok(ambient_task_id) => {
log::warn!(
"Conversation '{}' task resolution failed ({conversation_err}); falling back to {OZ_RUN_ID_ENV_VAR} for ambient task context",
conversation_id.as_str()
);
return Ok(ResolvedUploadAssociation {
conversation_id: Some(conversation_id),
run_id: None,
ambient_task_id,
});
}
Err(env_err) => env_err,
};
return Err(anyhow!(
"Failed to resolve artifact upload association for conversation '{}': {conversation_err}; also failed to use {OZ_RUN_ID_ENV_VAR}: {env_err}",
conversation_id.as_str()
));
}
}
}
let ambient_task_id = resolve_env_run_id(env_run_id).map_err(|env_err| {
anyhow!(
"Failed to resolve artifact upload association: no usable --run-id or --conversation-id was provided, and {OZ_RUN_ID_ENV_VAR}: {env_err}"
)
})?;
Ok(ResolvedUploadAssociation {
conversation_id: None,
run_id: Some(ambient_task_id),
ambient_task_id,
})
}
#[cfg(test)]
#[path = "artifact_upload_tests.rs"]
mod tests;
@@ -0,0 +1,269 @@
use std::env;
use std::fs;
use std::path::PathBuf;
use chrono::Utc;
use tempfile::tempdir;
use warp_cli::artifact::UploadArtifactArgs;
use super::*;
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::{AIAgentHarness, ServerAIConversationMetadata};
use crate::cloud_object::{Revision, ServerMetadata, ServerPermissions};
use crate::persistence::model::ConversationUsageMetadata;
use crate::server::ids::ServerId;
fn create_mock_server_metadata() -> ServerMetadata {
ServerMetadata {
uid: ServerId::default(),
revision: Revision::now(),
metadata_last_updated_ts: Utc::now().into(),
trashed_ts: None,
folder_id: None,
is_welcome_object: false,
creator_uid: None,
last_editor_uid: None,
current_editor_uid: None,
}
}
fn create_conversation_metadata(
conversation_id: &str,
ambient_task_id: Option<&str>,
) -> ServerAIConversationMetadata {
ServerAIConversationMetadata {
title: "Artifact upload".to_string(),
working_directory: None,
harness: AIAgentHarness::Oz,
usage: ConversationUsageMetadata {
was_summarized: false,
context_window_usage: 0.0,
credits_spent: 0.0,
credits_spent_for_last_block: None,
token_usage: vec![],
tool_usage_metadata: Default::default(),
},
metadata: create_mock_server_metadata(),
permissions: ServerPermissions::mock_personal(),
ambient_agent_task_id: ambient_task_id.map(|task_id| task_id.parse().unwrap()),
server_conversation_token: ServerConversationToken::new(conversation_id.to_string()),
artifacts: Vec::new(),
}
}
#[test]
fn normalize_artifact_filepath_preserves_shape_and_normalizes_separators() {
let path = PathBuf::from(r"outputs\reports/final.txt");
assert_eq!(
normalize_artifact_filepath(&path),
"outputs/reports/final.txt"
);
}
#[test]
fn checked_graphql_size_bytes_for_upload_returns_none_for_overflow() {
let path = PathBuf::from("outputs/large-artifact.bin");
assert_eq!(
checked_graphql_size_bytes_for_upload(&path, i32::MAX as u64),
Some(i32::MAX)
);
assert_eq!(
checked_graphql_size_bytes_for_upload(&path, i32::MAX as u64 + 1),
None
);
}
#[test]
fn file_size_and_prefix_for_path_returns_truncated_prefix() {
let tempdir = tempdir().unwrap();
let path = tempdir.path().join("artifact.bin");
fs::write(&path, b"0123456789").unwrap();
assert_eq!(
file_size_and_prefix_for_path(&path, 4).unwrap(),
(10, b"0123".to_vec())
);
}
#[test]
fn file_size_and_prefix_for_path_returns_full_contents_when_prefix_exceeds_file() {
let tempdir = tempdir().unwrap();
let path = tempdir.path().join("artifact.bin");
fs::write(&path, b"0123456789").unwrap();
assert_eq!(
file_size_and_prefix_for_path(&path, 32).unwrap(),
(10, b"0123456789".to_vec())
);
}
#[test]
fn single_conversation_metadata_returns_the_only_metadata_record() {
let metadata = single_conversation_metadata(
"conversation-123",
vec![create_conversation_metadata(
"conversation-123",
Some("550e8400-e29b-41d4-a716-446655440000"),
)],
)
.unwrap();
let task_id = ambient_task_id_from_conversation_metadata("conversation-123", metadata).unwrap();
assert_eq!(
task_id,
"550e8400-e29b-41d4-a716-446655440000".parse().unwrap()
);
}
#[test]
fn single_conversation_metadata_errors_when_no_metadata_is_returned() {
let err = single_conversation_metadata("conversation-123", Vec::new()).unwrap_err();
assert!(err.to_string().contains("Conversation not found"));
}
#[test]
fn ambient_task_id_from_conversation_metadata_requires_cloud_task_metadata() {
let err = ambient_task_id_from_conversation_metadata(
"conversation-123",
create_conversation_metadata("conversation-123", None),
)
.unwrap_err();
assert!(err
.to_string()
.contains("Conversation 'conversation-123' is not backed by a cloud agent task"));
}
#[test]
fn explicit_run_id_wins_over_env_fallback() {
let resolved = resolve_upload_association_from_sources(
Some("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
None,
None,
Some("11111111-1111-1111-1111-111111111111".to_string()),
)
.unwrap();
assert_eq!(
resolved,
ResolvedUploadAssociation {
conversation_id: None,
run_id: Some("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
ambient_task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
}
);
}
#[test]
fn invalid_explicit_run_id_errors_even_if_env_fallback_exists() {
let err = FileArtifactUploadRequest::try_from(UploadArtifactArgs {
path: PathBuf::from("outputs/report.txt"),
run_id: Some("not-a-run-id".to_string()),
conversation_id: None,
description: None,
})
.unwrap_err();
assert!(err.to_string().contains("Invalid run ID 'not-a-run-id'"));
}
#[test]
fn valid_conversation_resolution_ignores_env_fallback() {
let resolved = resolve_upload_association_from_sources(
None,
Some(ServerConversationToken::new("conversation-123".to_string())),
Some(Ok("550e8400-e29b-41d4-a716-446655440000".parse().unwrap())),
Some("11111111-1111-1111-1111-111111111111".to_string()),
)
.unwrap();
assert_eq!(
resolved,
ResolvedUploadAssociation {
conversation_id: Some(ServerConversationToken::new("conversation-123".to_string())),
run_id: None,
ambient_task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
}
);
}
#[test]
fn failed_conversation_resolution_falls_back_to_env_run_id() {
let resolved = resolve_upload_association_from_sources(
None,
Some(ServerConversationToken::new("conversation-123".to_string())),
Some(Err(anyhow!(
"Conversation 'conversation-123' is not backed by a cloud agent task"
))),
Some("550e8400-e29b-41d4-a716-446655440000".to_string()),
)
.unwrap();
assert_eq!(
resolved,
ResolvedUploadAssociation {
conversation_id: Some(ServerConversationToken::new("conversation-123".to_string())),
run_id: None,
ambient_task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
}
);
}
#[test]
fn missing_args_fall_back_to_env_run_id_for_request_association() {
let resolved = resolve_upload_association_from_sources(
None,
None,
None,
Some("550e8400-e29b-41d4-a716-446655440000".to_string()),
)
.unwrap();
assert_eq!(
resolved,
ResolvedUploadAssociation {
conversation_id: None,
run_id: Some("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
ambient_task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
}
);
}
#[test]
fn missing_args_and_missing_env_return_clear_error() {
let err = resolve_upload_association_from_sources(None, None, None, None).unwrap_err();
assert!(err
.to_string()
.contains("no usable --run-id or --conversation-id was provided"));
assert!(err.to_string().contains("OZ_RUN_ID"));
}
#[test]
fn invalid_env_run_id_returns_clear_error() {
let err =
resolve_upload_association_from_sources(None, None, None, Some("not-a-run-id".to_string()))
.unwrap_err();
assert!(err.to_string().contains("Invalid OZ_RUN_ID 'not-a-run-id'"));
}
#[test]
fn load_env_run_id_reads_variable() {
let previous = env::var_os(OZ_RUN_ID_ENV_VAR);
env::set_var(OZ_RUN_ID_ENV_VAR, "550e8400-e29b-41d4-a716-446655440000");
let loaded = load_env_run_id().unwrap();
match previous {
Some(value) => env::set_var(OZ_RUN_ID_ENV_VAR, value),
None => env::remove_var(OZ_RUN_ID_ENV_VAR),
}
assert_eq!(
loaded.as_deref(),
Some("550e8400-e29b-41d4-a716-446655440000")
);
}
+350
View File
@@ -0,0 +1,350 @@
//! Common utilities for agent SDK commands.
use std::fmt;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use futures::TryFutureExt;
use inquire::{InquireError, Select};
use warp_cli::agent::Harness;
use warp_cli::environment::{EnvironmentCreateArgs, EnvironmentUpdateArgs};
use warpui::r#async::FutureExt;
use warpui::{AppContext, GetSingletonModelHandle, SingletonEntity as _, UpdateModel};
use crate::ai::agent::conversation::ServerAIConversationMetadata;
use crate::ai::agent_sdk::driver::{AgentDriverError, WARP_DRIVE_SYNC_TIMEOUT};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::auth::auth_state::AuthStateProvider;
use crate::cloud_object::{CloudObject, Owner};
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::{ServerId, SyncId};
use crate::server::server_api::ai::AIClient;
use crate::server::server_api::ServerApiProvider;
use crate::workspaces::update_manager::TeamUpdateManager;
use crate::workspaces::user_workspaces::UserWorkspaces;
/// How long to wait for workspace metadata to refresh.
pub const WORKSPACE_METADATA_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
pub fn validate_agent_mode_base_model_id(
model_id: &str,
ctx: &AppContext,
) -> anyhow::Result<LLMId> {
let llm_prefs = LLMPreferences::as_ref(ctx);
let llm_id: LLMId = model_id.into();
let valid_ids = llm_prefs
.get_base_llm_choices_for_agent_mode()
.map(|info| info.id.clone())
.collect::<Vec<_>>();
if valid_ids.contains(&llm_id) {
Ok(llm_id)
} else {
let suggestions = valid_ids
.into_iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ");
Err(anyhow::anyhow!(
"Unknown model id '{model_id}'. Try one of: {suggestions}"
))
}
}
pub(super) fn parse_ambient_task_id(
run_id: &str,
error_prefix: &str,
) -> anyhow::Result<AmbientAgentTaskId> {
run_id
.parse()
.map_err(|err| anyhow::anyhow!("{error_prefix} '{run_id}': {err}"))
}
pub(super) fn set_ambient_task_context_from_run_id(
ctx: &AppContext,
run_id: &str,
) -> anyhow::Result<AmbientAgentTaskId> {
let task_id = parse_ambient_task_id(run_id, "Invalid run ID")?;
ServerApiProvider::handle(ctx)
.as_ref(ctx)
.get()
.set_ambient_agent_task_id(Some(task_id));
Ok(task_id)
}
/// Resolve the owner of a new cloud object. This resolution is based on the CLI `--team` and `--personal` flags.
///
/// If `team_flag` is true, attempts to get the current team UID (errors if not on a team).
/// If `user_flag` is true, gets the current user's UID.
/// Otherwise, defaults to team if available, falling back to user.
pub fn resolve_owner(team_flag: bool, user_flag: bool, ctx: &AppContext) -> anyhow::Result<Owner> {
if team_flag {
let team_id = UserWorkspaces::as_ref(ctx)
.current_team_uid()
.ok_or_else(|| anyhow::anyhow!("User is not on a team"))?;
return Ok(Owner::Team { team_uid: team_id });
}
if user_flag {
let user_id = AuthStateProvider::as_ref(ctx)
.get()
.user_id()
.ok_or_else(|| anyhow::anyhow!("User should be logged in"))?;
return Ok(Owner::User { user_uid: user_id });
}
// Default: try team first, fall back to user
if let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() {
return Ok(Owner::Team { team_uid });
}
log::warn!("Tried to default to creating team object, team could not be found.");
let user_id = AuthStateProvider::as_ref(ctx)
.get()
.user_id()
.ok_or_else(|| anyhow::anyhow!("User should be logged in"))?;
Ok(Owner::User { user_uid: user_id })
}
/// Refresh workspace metadata before executing an operation.
///
/// This ensures that team state is up-to-date before creating cloud objects or performing
/// other operations that depend on team membership.
pub fn refresh_workspace_metadata<C>(
ctx: &mut C,
) -> impl Future<Output = anyhow::Result<()>> + Send + 'static
where
C: GetSingletonModelHandle + UpdateModel,
{
let refresh_future = TeamUpdateManager::handle(ctx).update(ctx, |manager, ctx| {
manager
.refresh_workspace_metadata(ctx)
.with_timeout(WORKSPACE_METADATA_REFRESH_TIMEOUT)
});
async move {
let _ = refresh_future
.await
.map_err(|_| anyhow::anyhow!("Timed out refreshing team metadata"))?;
Ok(())
}
}
/// Refresh Warp Drive before executing an operation.
pub fn refresh_warp_drive(
ctx: &AppContext,
) -> impl Future<Output = anyhow::Result<()>> + Send + 'static {
UpdateManager::as_ref(ctx)
.initial_load_complete()
.with_timeout(WARP_DRIVE_SYNC_TIMEOUT)
.map_err(|_| anyhow::anyhow!("Timed out waiting for Warp Drive to sync"))
}
/// Fetch the conversation's server metadata and validate that its harness matches the caller's
/// `--harness` choice. Returns the metadata on success so the caller can reuse it (e.g. for the
/// server conversation token).
///
/// Called up-front before any task/config-build logic consumes `args.harness`, so a mismatch
/// error surfaces before side effects like task creation. We deliberately do NOT auto-upgrade
/// the harness: `Harness::Oz` default with a Claude conversation id is treated as a mismatch
/// and errors out.
pub(super) async fn fetch_and_validate_conversation_harness(
ai_client: Arc<dyn AIClient>,
conversation_id: &str,
args_harness: Harness,
) -> Result<ServerAIConversationMetadata, AgentDriverError> {
let metadata = ai_client
.list_ai_conversation_metadata(Some(vec![conversation_id.to_string()]))
.await
.map_err(|e| AgentDriverError::ConversationLoadFailed(format!("{e:#}")))?
.into_iter()
.next()
.ok_or_else(|| {
AgentDriverError::ConversationLoadFailed(format!(
"conversation {conversation_id} not found or not accessible"
))
})?;
if metadata.harness != args_harness {
return Err(AgentDriverError::ConversationHarnessMismatch {
conversation_id: conversation_id.to_string(),
expected: Harness::from(metadata.harness).to_string(),
got: args_harness.to_string(),
});
}
Ok(metadata)
}
/// Format an object owner for display in the CLI.
pub fn format_owner(owner: &Owner) -> &'static str {
// TODO: For potentially-shared objects, consider looking up the particular user/team name.
match owner {
Owner::User { .. } => "Personal",
Owner::Team { .. } => "Team",
}
}
/// An error resolving an agent option, which we may have prompted the user for.
#[derive(Debug, thiserror::Error)]
pub enum ResolveConfigurationError {
/// The user canceled the operation, and we should exit.
#[error("Operation canceled")]
Canceled,
#[error("{id} is not a valid {kind} identifier")]
InvalidId { id: String, kind: &'static str },
#[error("{kind} {id} not found")]
ObjectNotFound { id: String, kind: &'static str },
#[error(transparent)]
Other(anyhow::Error),
}
#[derive(Clone, Debug, PartialEq)]
pub enum EnvironmentChoice {
/// The user explicitly chose not to use an environment.
None,
/// The user chose a specific environment.
Environment { id: String, name: String },
}
impl EnvironmentChoice {
/// Resolve the environment to use when creating an agent integration.
/// Warp Drive *must* have been synced first.
pub fn resolve_for_create(
args: EnvironmentCreateArgs,
ctx: &AppContext,
) -> Result<Self, ResolveConfigurationError> {
if args.no_environment {
Ok(EnvironmentChoice::None)
} else if let Some(id) = args.environment {
Self::get_by_id(id, ctx)
} else {
let all_environments = CloudAmbientAgentEnvironment::get_all(ctx);
let mut synced_environments: Vec<(ServerId, &CloudAmbientAgentEnvironment)> =
all_environments
.iter()
.filter_map(|env| {
if let SyncId::ServerId(server_id) = env.sync_id() {
Some((server_id, env))
} else {
None
}
})
.collect();
synced_environments
.sort_by_key(|(_, env)| env.model().string_model.name.to_lowercase());
let environments: Vec<EnvironmentChoice> = synced_environments
.into_iter()
.map(|(server_id, env)| EnvironmentChoice::Environment {
id: server_id.to_string(),
name: env.model().string_model.name.clone(),
})
.collect();
let mut options = vec![EnvironmentChoice::None];
options.extend(environments);
// If there are no synced environments, require the user to create one or use --no-environment.
if options.len() == 1 {
let cli_name = warp_cli::binary_name().unwrap_or_else(|| "warp".to_string());
return Err(ResolveConfigurationError::Other(anyhow::anyhow!(
"No environments are configured for this account.\n\
You can create an environment with `{cli_name} environment create`.\n\
Or, re-run this command with `--no-environment` to not use an environment.\n\
Without an environment, the agent will not be able to access private repositories or create pull requests.",
)));
}
let prompt = "Select an environment to run the agent in (or 'No environment'):";
let choice = Select::new(prompt, options).prompt();
match choice {
Ok(choice) => Ok(choice),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
Err(ResolveConfigurationError::Canceled)
}
Err(err) => Err(ResolveConfigurationError::Other(anyhow::anyhow!(
"Error selecting environment: {err}"
))),
}
}
}
/// Resolve the environment to use when updating an agent integration. If the user did not
/// request any changes to the environment, this returns `Ok(None)`.
/// Warp Drive *must* have been synced first.
pub fn resolve_for_update(
args: EnvironmentUpdateArgs,
ctx: &AppContext,
) -> Result<Option<Self>, ResolveConfigurationError> {
if args.remove_environment {
Ok(Some(EnvironmentChoice::None))
} else if let Some(id) = args.environment {
Self::get_by_id(id, ctx).map(Some)
} else {
Ok(None)
}
}
fn get_by_id(id: String, ctx: &AppContext) -> Result<Self, ResolveConfigurationError> {
let sync_id = SyncId::ServerId(ServerId::try_from(id.as_str()).map_err(|_| {
ResolveConfigurationError::InvalidId {
id: id.clone(),
kind: "environment",
}
})?);
let environment =
CloudAmbientAgentEnvironment::get_by_id(&sync_id, ctx).ok_or_else(|| {
ResolveConfigurationError::ObjectNotFound {
id: id.clone(),
kind: "environment",
}
})?;
Ok(EnvironmentChoice::Environment {
id,
name: environment.model().string_model.name.clone(),
})
}
}
impl fmt::Display for EnvironmentChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EnvironmentChoice::None => write!(
f,
"No environment (agent will not be able to access private repositories or create pull requests)",
),
EnvironmentChoice::Environment { id, name } => write!(f, "{name} ({id})"),
}
}
}
#[cfg(test)]
mod tests {
use super::parse_ambient_task_id;
#[test]
fn parse_ambient_task_id_accepts_valid_ids() {
let task_id =
parse_ambient_task_id("550e8400-e29b-41d4-a716-446655440000", "Invalid run ID")
.unwrap();
assert_eq!(task_id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
}
#[test]
fn parse_ambient_task_id_preserves_error_prefix() {
let err = parse_ambient_task_id("not-a-run-id", "Invalid run ID").unwrap_err();
assert!(err.to_string().contains("Invalid run ID 'not-a-run-id'"));
}
}
+211
View File
@@ -0,0 +1,211 @@
use std::path::Path;
use anyhow::Context as _;
use serde_json::{Map, Value};
use warp_cli::mcp::MCPSpec;
use crate::ai::ambient_agents::AgentConfigSnapshot;
/// A strict, file-based representation of `AgentConfigSnapshot`.
///
/// Notes:
/// - Keys are snake_case and unknown keys are rejected.
/// - MCP configuration must be provided only under the `mcp_servers` key and must be the
/// unwrapped server map `{ <server_name>: <server_config>, ... }`.
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentConfigSnapshotFile {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub environment_id: Option<String>,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default)]
pub base_prompt: Option<String>,
#[serde(default)]
pub mcp_servers: Option<Map<String, Value>>,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub computer_use_enabled: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct LoadedAgentConfigSnapshotFile {
pub file: AgentConfigSnapshotFile,
}
/// Load an `AgentConfigSnapshotFile` from disk.
///
/// Parsing rules:
/// - `.json` => JSON
/// - `.yml` / `.yaml` => YAML
/// - otherwise: try JSON, then YAML
#[cfg(not(target_family = "wasm"))]
pub fn load_config_file(path: &Path) -> anyhow::Result<LoadedAgentConfigSnapshotFile> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file '{}'", path.display()))?;
let ext = path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase());
let file = match ext.as_deref() {
Some("json") => parse_json(&contents)
.with_context(|| format!("Invalid JSON in config file '{}'", path.display()))?,
Some("yml") | Some("yaml") => parse_yaml(&contents)
.with_context(|| format!("Invalid YAML in config file '{}'", path.display()))?,
_ => parse_json(&contents)
.or_else(|_| parse_yaml(&contents))
.with_context(|| {
format!(
"Failed to parse config file '{}' as JSON or YAML",
path.display()
)
})?,
};
if let Some(mcp_servers) = &file.mcp_servers {
super::mcp_config::validate_mcp_servers(mcp_servers)
.with_context(|| format!("Invalid mcp_servers in '{}'", path.display()))?;
}
Ok(LoadedAgentConfigSnapshotFile { file })
}
/// WASM builds don't use CLI command execution / local file access.
#[cfg(target_family = "wasm")]
pub fn load_config_file(_path: &Path) -> anyhow::Result<LoadedAgentConfigSnapshotFile> {
Err(anyhow::anyhow!(
"Config files are not supported in WASM builds"
))
}
fn parse_json(input: &str) -> anyhow::Result<AgentConfigSnapshotFile> {
serde_json::from_str::<AgentConfigSnapshotFile>(input).with_context(supported_keys_context)
}
fn parse_yaml(input: &str) -> anyhow::Result<AgentConfigSnapshotFile> {
// `serde_yaml` can deserialize into `serde_json::Value` directly.
serde_yaml::from_str::<AgentConfigSnapshotFile>(input).with_context(supported_keys_context)
}
fn supported_keys_context() -> String {
"Supported keys: name, environment_id, model_id, base_prompt, mcp_servers, host, computer_use_enabled".to_string()
}
/// Convert an unwrapped `mcp_servers` map into runtime MCP specs for AgentDriver.
///
/// Behavior:
/// - Entries with `warp_id` become `MCPSpec::Uuid`.
/// - Entries with `command`/`url` remain as inline JSON (`MCPSpec::Json`) containing the unwrapped server map.
pub fn mcp_specs_from_mcp_servers(
mcp_servers: &Map<String, Value>,
) -> anyhow::Result<Vec<MCPSpec>> {
let mut uuids: Vec<uuid::Uuid> = Vec::new();
let mut json_map: Map<String, Value> = Map::new();
for (name, config) in mcp_servers {
let obj = config
.as_object()
.ok_or_else(|| anyhow::anyhow!("MCP server '{name}' config must be a JSON object"))?;
if let Some(warp_id) = obj.get("warp_id").and_then(Value::as_str) {
let uuid = uuid::Uuid::parse_str(warp_id).map_err(|_| {
anyhow::anyhow!("MCP server '{name}' field 'warp_id' must be a UUID")
})?;
uuids.push(uuid);
} else {
json_map.insert(name.clone(), config.clone());
}
}
uuids.sort();
uuids.dedup();
let mut specs: Vec<MCPSpec> = uuids.into_iter().map(MCPSpec::Uuid).collect();
if !json_map.is_empty() {
let json =
serde_json::to_string(&json_map).context("Failed to serialize MCP server map")?;
specs.push(MCPSpec::Json(json));
}
Ok(specs)
}
/// Merge config file settings with CLI-provided overrides.
///
/// Precedence: CLI > file > default.
pub fn merge_with_precedence(
file: Option<&LoadedAgentConfigSnapshotFile>,
cli: AgentConfigSnapshot,
) -> AgentConfigSnapshot {
let default_file = AgentConfigSnapshotFile::default();
let file = file.map(|loaded| &loaded.file).unwrap_or(&default_file);
let name = cli.name.or_else(|| file.name.clone());
let environment_id = cli.environment_id.or_else(|| file.environment_id.clone());
let model_id = cli.model_id.or_else(|| file.model_id.clone());
let base_prompt = cli.base_prompt.or_else(|| file.base_prompt.clone());
let mcp_servers = merge_mcp_servers(file.mcp_servers.clone(), cli.mcp_servers);
let worker_host = cli.worker_host.or_else(|| file.host.clone());
let computer_use_enabled = cli.computer_use_enabled.or(file.computer_use_enabled);
AgentConfigSnapshot {
name,
environment_id,
model_id,
base_prompt,
mcp_servers,
profile_id: None,
worker_host,
skill_spec: cli.skill_spec,
computer_use_enabled,
harness: cli.harness,
harness_auth_secrets: cli.harness_auth_secrets,
}
}
/// Merge MCP servers from two sources.
///
/// Returns the merged map, or None if both inputs are None/empty.
pub fn merge_mcp_servers(
file_mcp: Option<Map<String, Value>>,
cli_mcp: Option<Map<String, Value>>,
) -> Option<Map<String, Value>> {
match (file_mcp, cli_mcp) {
(None, None) => None,
(Some(map), None) => {
if map.is_empty() {
None
} else {
Some(map)
}
}
(None, Some(map)) => {
if map.is_empty() {
None
} else {
Some(map)
}
}
(Some(mut file_map), Some(cli_map)) => {
for (k, v) in cli_map {
file_map.insert(k, v);
}
if file_map.is_empty() {
None
} else {
Some(file_map)
}
}
}
}
#[cfg(test)]
#[path = "config_file_tests.rs"]
mod tests;
+214
View File
@@ -0,0 +1,214 @@
#![cfg(not(target_family = "wasm"))]
use std::io::Write as _;
use serde_json::json;
use crate::ai::ambient_agents::AgentConfigSnapshot;
use warp_cli::mcp::MCPSpec;
fn write_temp(suffix: &str, contents: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::Builder::new().suffix(suffix).tempfile().unwrap();
file.write_all(contents.as_bytes()).unwrap();
file
}
#[test]
fn loads_json_and_validates_mcp_servers() {
let contents = json!({
"model_id": "gpt-4o",
"environment_id": "env-123",
"base_prompt": "be helpful",
"mcp_servers": {
"s": { "command": "npx", "args": [] }
}
})
.to_string();
let file = write_temp(".json", &contents);
let loaded = super::load_config_file(file.path()).unwrap();
assert_eq!(loaded.file.model_id.as_deref(), Some("gpt-4o"));
assert_eq!(loaded.file.environment_id.as_deref(), Some("env-123"));
assert_eq!(loaded.file.base_prompt.as_deref(), Some("be helpful"));
assert!(loaded.file.mcp_servers.is_some());
assert!(loaded.file.mcp_servers.as_ref().unwrap().contains_key("s"));
}
#[test]
fn loads_yaml() {
let contents = r#"
model_id: gpt-4o
mcp_servers:
s:
command: npx
args: []
"#;
let file = write_temp(".yaml", contents);
let loaded = super::load_config_file(file.path()).unwrap();
assert_eq!(loaded.file.model_id.as_deref(), Some("gpt-4o"));
assert!(loaded.file.mcp_servers.as_ref().unwrap().contains_key("s"));
}
#[test]
fn unknown_keys_are_rejected() {
let contents = json!({
"model_id": "gpt-4o",
"typo_model": "oops"
})
.to_string();
let file = write_temp(".json", &contents);
let err = super::load_config_file(file.path()).unwrap_err();
let err_str = format!("{err:#}");
assert!(err_str.contains("Supported keys"));
}
#[test]
fn mcp_must_be_under_mcp_servers_key() {
let contents = json!({
"model_id": "gpt-4o",
"mcpServers": { "s": { "command": "npx", "args": [] } }
})
.to_string();
let file = write_temp(".json", &contents);
let err = super::load_config_file(file.path()).unwrap_err();
let err_str = format!("{err:#}");
assert!(err_str.contains("Supported keys"));
}
#[test]
fn merge_precedence_cli_over_file_and_merges_mcp() {
let contents = json!({
"model_id": "file-model",
"mcp_servers": {
"a": { "url": "https://example.com/mcp" }
}
})
.to_string();
let file = write_temp(".json", &contents);
let loaded = super::load_config_file(file.path()).unwrap();
let cli = AgentConfigSnapshot {
name: Some("cli-name".to_string()),
environment_id: None,
model_id: Some("cli-model".to_string()),
base_prompt: None,
mcp_servers: Some(serde_json::Map::from_iter([(
"a".to_string(),
json!({"command": "npx", "args": []}),
)])),
profile_id: None,
worker_host: None,
skill_spec: None,
computer_use_enabled: None,
harness: None,
harness_auth_secrets: None,
};
let merged = super::merge_with_precedence(Some(&loaded), cli);
assert_eq!(merged.name.as_deref(), Some("cli-name"));
assert_eq!(merged.model_id.as_deref(), Some("cli-model"));
let a = merged.mcp_servers.as_ref().unwrap().get("a").unwrap();
assert_eq!(a.get("command").and_then(|v| v.as_str()), Some("npx"));
}
#[test]
fn file_empty_mcp_servers_is_loaded_as_empty_map() {
let contents = json!({
"mcp_servers": {}
})
.to_string();
let file = write_temp(".json", &contents);
let loaded = super::load_config_file(file.path()).unwrap();
assert!(loaded.file.mcp_servers.is_some());
assert!(loaded.file.mcp_servers.as_ref().unwrap().is_empty());
}
#[test]
fn mcp_servers_map_converts_to_runtime_specs() {
let contents = json!({
"mcp_servers": {
"existing": { "warp_id": "550e8400-e29b-41d4-a716-446655440000" },
"ephemeral": { "command": "npx", "args": [] }
}
})
.to_string();
let file = write_temp(".json", &contents);
let loaded = super::load_config_file(file.path()).unwrap();
let map = loaded.file.mcp_servers.as_ref().unwrap();
let specs = super::mcp_specs_from_mcp_servers(map).unwrap();
assert!(specs.iter().any(|s| matches!(s, MCPSpec::Uuid(_))));
assert!(specs.iter().any(|s| matches!(s, MCPSpec::Json(_))));
}
#[test]
fn loads_computer_use_enabled_from_json() {
let contents = json!({
"computer_use_enabled": true
})
.to_string();
let file = write_temp(".json", &contents);
let loaded = super::load_config_file(file.path()).unwrap();
assert_eq!(loaded.file.computer_use_enabled, Some(true));
}
#[test]
fn loads_computer_use_enabled_from_yaml() {
let contents = "computer_use_enabled: false\n";
let file = write_temp(".yaml", contents);
let loaded = super::load_config_file(file.path()).unwrap();
assert_eq!(loaded.file.computer_use_enabled, Some(false));
}
#[test]
fn merge_precedence_cli_computer_use_enabled_over_file() {
let contents = json!({
"computer_use_enabled": false
})
.to_string();
let file = write_temp(".json", &contents);
let loaded = super::load_config_file(file.path()).unwrap();
let cli = AgentConfigSnapshot {
computer_use_enabled: Some(true),
..Default::default()
};
let merged = super::merge_with_precedence(Some(&loaded), cli);
assert_eq!(merged.computer_use_enabled, Some(true));
}
#[test]
fn merge_precedence_file_computer_use_enabled_when_cli_none() {
let contents = json!({
"computer_use_enabled": true
})
.to_string();
let file = write_temp(".json", &contents);
let loaded = super::load_config_file(file.path()).unwrap();
let cli = AgentConfigSnapshot::default();
let merged = super::merge_with_precedence(Some(&loaded), cli);
assert_eq!(merged.computer_use_enabled, Some(true));
}
File diff suppressed because it is too large Load Diff
+313
View File
@@ -0,0 +1,313 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Context;
use base64::{engine::general_purpose, Engine};
use futures::future::join_all;
use futures::TryStreamExt as _;
use mime_guess::from_path;
use tokio::fs;
use tokio_util::io::StreamReader;
use warp_core::features::FeatureFlag;
use crate::ai::agent_sdk::retry::with_bounded_retry;
use crate::ai::ambient_agents::task::{AttachmentInput, TaskAttachment};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::attachment_utils::MAX_ATTACHMENT_SIZE_BYTES;
use crate::server::server_api::ai::AIClient;
use crate::server::server_api::presigned_upload::HttpStatusError;
use crate::server::server_api::ServerApi;
use crate::util::image::MIN_IMAGE_HEADER_SIZE;
/// Maximum number of file attachments for a cloud agent task.
pub const MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY: usize = 25;
/// Fetches task attachments via GraphQL and downloads them to the filesystem.
/// Returns the attachments directory path if any attachments were downloaded,
/// so the caller can pass it to the server via `StartFromAmbientRunPrompt`.
///
/// `attachments_dir` is the per-session directory where files should be downloaded.
///
/// Makes a best-effort attempt to download all attachments.
/// Individual download failures are logged but don't cause the entire function to fail.
pub(crate) async fn fetch_and_download_attachments(
ai_client: Arc<dyn AIClient>,
http_client: Arc<ServerApi>,
task_id: String,
attachments_dir: PathBuf,
) -> anyhow::Result<Option<String>> {
if !FeatureFlag::AmbientAgentsImageUpload.is_enabled() {
return Ok(None);
}
let attachments = ai_client
.get_task_attachments(task_id.clone())
.await
.context("Failed to fetch task attachments")?;
log::info!("Fetched {} task attachments", attachments.len());
if attachments.is_empty() {
return Ok(None);
}
download_and_write_attachments(attachments, &attachments_dir, &http_client).await?;
Ok(Some(attachments_dir.to_string_lossy().into_owned()))
}
/// Fetches handoff snapshot attachments for the active execution and downloads
/// them into `{attachments_dir}/handoff/{attachment_uuid}` so the runtime's
/// rehydration prompt references always point at a file that exists on disk.
///
/// Returns `Some(attachments_dir)` when at least one attachment wrote to disk, mirroring
/// the contract of the sibling [`fetch_and_download_attachments`]. Partial failures are
/// logged at WARN level inside this function; per-file errors are not surfaced to callers.
///
/// Fatal failures (listing the attachments, creating the handoff dir) return `Err`.
pub(crate) async fn fetch_and_download_handoff_snapshot_attachments(
ai_client: Arc<dyn AIClient>,
http_client: &http_client::Client,
task_id: AmbientAgentTaskId,
attachments_dir: PathBuf,
) -> anyhow::Result<Option<String>> {
if !FeatureFlag::OzHandoff.is_enabled() {
log::error!(
"fetch_and_download_handoff_snapshot_attachments called with OzHandoff disabled; \
call sites should gate on the flag before invoking"
);
return Ok(None);
}
let attachments = ai_client
.get_handoff_snapshot_attachments(&task_id)
.await
.context("Failed to fetch handoff snapshot attachments")?;
if attachments.is_empty() {
return Ok(None);
}
let handoff_dir = attachments_dir.join("handoff");
fs::create_dir_all(&handoff_dir)
.await
.context("Failed to create handoff attachments directory")?;
let attempts = attachments.len();
let download_futures = attachments.into_iter().map(|attachment| {
let file_path = handoff_dir.join(&attachment.file_id);
download_handoff_entry(attachment, file_path, http_client)
});
let results = join_all(download_futures).await;
let mut succeeded: usize = 0;
let mut failures: Vec<(String, String)> = Vec::new();
for result in results {
match result {
Ok(()) => succeeded += 1,
Err((filename, err)) => failures.push((filename, err)),
}
}
if failures.is_empty() {
log::info!("Handoff snapshot attachments: {succeeded}/{attempts} downloaded");
} else {
let detail = failures
.iter()
.map(|(filename, err)| format!("{filename}: {err}"))
.collect::<Vec<_>>()
.join("; ");
log::warn!(
"Handoff snapshot attachments: {succeeded}/{attempts} downloaded; {} failed ({detail})",
failures.len()
);
}
// Only surface the attachments dir if at least one file made it to disk. Passing a dir
// with zero usable entries downstream would make the rehydration prompt reference a
// phantom path.
if succeeded == 0 {
return Ok(None);
}
Ok(Some(attachments_dir.to_string_lossy().into_owned()))
}
/// Downloads task attachments from presigned URLs and writes them to the filesystem.
/// Downloads are performed concurrently using `join_all`.
/// Makes a best-effort attempt to download all attachments, logging warnings for failures.
/// The filename is already formatted by the server with UUID prefix (e.g., "uuid_filename.png").
async fn download_and_write_attachments(
attachments: Vec<TaskAttachment>,
attachment_dir: &Path,
http_client: &ServerApi,
) -> anyhow::Result<()> {
fs::create_dir_all(attachment_dir)
.await
.context("Failed to create attachments directory")?;
log::info!(
"Created attachments directory at: {}",
attachment_dir.display()
);
let http = http_client.http_client();
let download_futures = attachments
.into_iter()
.map(|attachment| download_task_attachment(attachment, attachment_dir, http));
let results = join_all(download_futures).await;
let mut successful = 0;
let mut failed = 0;
for result in results {
match result {
Ok(()) => successful += 1,
Err(_) => failed += 1,
}
}
log::info!("Attachment download complete: {successful} successful, {failed} failed");
Ok(())
}
/// Download a single task attachment into `attachment_dir/<sanitized filename>`.
///
/// Delegates to [`download_attachment`] so transient failures retry on the shared schedule.
async fn download_task_attachment(
attachment: TaskAttachment,
attachment_dir: &Path,
http_client: &http_client::Client,
) -> anyhow::Result<()> {
let safe_filename = Path::new(&attachment.filename)
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!("Invalid filename for file_id={}", attachment.file_id))?
.to_string();
let file_path = attachment_dir.join(&safe_filename);
log::info!(
"Downloading attachment: {} -> {}",
attachment.filename,
file_path.display()
);
download_attachment(http_client, &attachment.download_url, &file_path).await?;
log::info!("Successfully wrote attachment to: {}", file_path.display());
Ok(())
}
/// Download a single handoff attachment into `file_path`, mapping failure to
/// `(filename, error_message)` so the aggregator in
/// [`fetch_and_download_handoff_snapshot_attachments`] can log and count per-file outcomes.
async fn download_handoff_entry(
attachment: TaskAttachment,
file_path: PathBuf,
http_client: &http_client::Client,
) -> Result<(), (String, String)> {
// Factor `file_id` and `download_url` out before the retry closure so `attachment` is fully
// consumed up-front. The closure borrows the two fields it needs as references.
let TaskAttachment {
file_id,
download_url,
..
} = attachment;
download_attachment(http_client, &download_url, &file_path)
.await
.map_err(|e| (file_id, format!("{e:#}")))
}
/// Shared download primitive: GET `download_url`, write the body to `file_path`, and retry
/// transient HTTP failures on the shared bounded-backoff schedule. Non-2xx responses surface
/// an [`HttpStatusError`] so the retry classifier can decide whether to retry.
async fn download_attachment(
http_client: &http_client::Client,
download_url: &str,
file_path: &Path,
) -> anyhow::Result<()> {
let operation = format!("download attachment '{}'", file_path.display());
with_bounded_retry(&operation, || async {
let response = http_client
.get(download_url)
.send()
.await
.context("Failed to send download request")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow::Error::new(HttpStatusError {
status: status.as_u16(),
body: body.clone(),
})
.context(format!("Download failed with status {status}: {body}")));
}
// Stream the response body directly to disk instead of buffering the full payload
// in memory.
let mut file = fs::File::create(file_path)
.await
.context("Failed to create file")?;
let mut response_stream =
StreamReader::new(response.bytes_stream().map_err(std::io::Error::other));
tokio::io::copy(&mut response_stream, &mut file)
.await
.context("Failed to write file")?;
Ok(())
})
.await
}
/// Process a file attachment for ambient agent upload.
/// Returns AttachmentInput with base64-encoded data.
/// All file types share the same 10MB size limit.
pub fn process_attachment(
attachment_path: &PathBuf,
index: usize,
) -> anyhow::Result<AttachmentInput> {
let file_bytes = std::fs::read(attachment_path).map_err(|e| {
anyhow::anyhow!(
"Failed to read attachment file '{}': {e}",
attachment_path.display()
)
})?;
// Detect MIME type from file data using infer crate, fall back to file extension
let mime_type = if file_bytes.len() >= MIN_IMAGE_HEADER_SIZE {
infer::get(&file_bytes).map(|kind| kind.mime_type().to_string())
} else {
None
};
// If infer couldn't detect, fall back to file extension
let mime_type = mime_type.unwrap_or_else(|| {
from_path(attachment_path)
.first_or_octet_stream()
.to_string()
});
if file_bytes.len() > MAX_ATTACHMENT_SIZE_BYTES {
return Err(anyhow::anyhow!(
"File is too large ({}MB). Maximum size is 10MB.",
file_bytes.len() / (1024 * 1024)
));
}
let base64_data = general_purpose::STANDARD.encode(&file_bytes);
let file_name = attachment_path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("task_attachment_{index}"));
Ok(AttachmentInput {
file_name,
mime_type,
data: base64_data,
})
}
#[cfg(test)]
#[path = "attachments_tests.rs"]
mod tests;
@@ -0,0 +1,389 @@
use std::fs;
use std::io::Write;
use std::sync::Arc;
use mockito::{Matcher, Server};
use tempfile::{Builder as TempDirBuilder, NamedTempFile, TempDir};
use super::*;
use crate::ai::agent_sdk::test_support::build_test_http_client;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::ai::MockAIClient;
#[test]
fn process_attachment_text_file() {
let mut f = NamedTempFile::with_suffix(".txt").unwrap();
write!(f, "hello world").unwrap();
let result = process_attachment(&f.path().to_path_buf(), 0).unwrap();
assert_eq!(
result.file_name,
f.path().file_name().unwrap().to_str().unwrap()
);
assert_eq!(result.mime_type, "text/plain");
assert_eq!(
general_purpose::STANDARD.decode(&result.data).unwrap(),
b"hello world"
);
}
#[test]
fn process_attachment_too_large() {
let mut f = NamedTempFile::with_suffix(".bin").unwrap();
let data = vec![0u8; MAX_ATTACHMENT_SIZE_BYTES + 1];
f.write_all(&data).unwrap();
let err = process_attachment(&f.path().to_path_buf(), 0).unwrap_err();
assert!(err.to_string().contains("too large"));
}
#[test]
fn process_attachment_nonexistent_file() {
let path = std::path::PathBuf::from("/tmp/nonexistent_attachment_test_file.xyz");
let err = process_attachment(&path, 0).unwrap_err();
assert!(err.to_string().contains("Failed to read"));
}
// End-to-end handoff snapshot download tests. Each test drives the real
// `fetch_and_download_handoff_snapshot_attachments` pipeline (including the shared
// `with_bounded_retry` helper) against a `mockito::Server` + a real `http_client::Client`,
// with `MockAIClient` stubbing only the listing call.
fn handoff_tempdir() -> TempDir {
TempDirBuilder::new()
.prefix("handoff-test")
.tempdir()
.unwrap()
}
/// Construct a `TaskAttachment` pointing at `{server_base_url}/download/{file_id}` so each test
/// can register matching mocks without fishing for URLs.
fn make_attachment(server_base_url: &str, file_id: &str, filename: &str) -> TaskAttachment {
TaskAttachment {
file_id: file_id.to_string(),
filename: filename.to_string(),
download_url: format!("{server_base_url}/download/{file_id}"),
mime_type: "application/octet-stream".to_string(),
}
}
/// Regex path matcher for `/download/<file_id>`.
fn download_path(file_id: &str) -> Matcher {
Matcher::Regex(format!("^/download/{file_id}$"))
}
/// Pre-parsed task id for tests that exercise the outer function. Any valid UUID works; the
/// mocked `AIClient` consumes this opaquely.
fn fake_task_id() -> AmbientAgentTaskId {
"550e8400-e29b-41d4-a716-446655440000".parse().unwrap()
}
/// Build a `MockAIClient` whose `get_handoff_snapshot_attachments` returns `attachments`.
fn mock_client_returning(attachments: Vec<TaskAttachment>) -> Arc<MockAIClient> {
let mut mock = MockAIClient::new();
mock.expect_get_handoff_snapshot_attachments()
.times(1)
.returning(move |_task_id| Ok(attachments.clone()));
Arc::new(mock)
}
#[tokio::test]
async fn e2e_happy_path_downloads_all_and_writes_to_disk() {
// Two attachments, both served 200 with distinct payloads. The pipeline must write each
// byte stream to `{attachments_dir}/handoff/{file_id}` and report the dir back.
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
let tempdir = handoff_tempdir();
let attachments_dir = tempdir.path().to_path_buf();
let http = build_test_http_client();
let mut server = Server::new_async().await;
let first_mock = server
.mock("GET", download_path("alpha-uuid"))
.with_status(200)
.with_body("alpha-body")
.expect(1)
.create_async()
.await;
let second_mock = server
.mock("GET", download_path("beta-uuid"))
.with_status(200)
.with_body("beta-body")
.expect(1)
.create_async()
.await;
let attachments = vec![
make_attachment(&server.url(), "alpha-uuid", "alpha.patch"),
make_attachment(&server.url(), "beta-uuid", "beta.patch"),
];
let result = fetch_and_download_handoff_snapshot_attachments(
mock_client_returning(attachments),
&http,
fake_task_id(),
attachments_dir.clone(),
)
.await
.expect("should not be fatal");
assert_eq!(result.as_deref(), Some(&*attachments_dir.to_string_lossy()));
assert_eq!(
fs::read(attachments_dir.join("handoff").join("alpha-uuid")).unwrap(),
b"alpha-body"
);
assert_eq!(
fs::read(attachments_dir.join("handoff").join("beta-uuid")).unwrap(),
b"beta-body"
);
first_mock.assert();
second_mock.assert();
}
#[tokio::test]
async fn e2e_transient_5xx_retried_then_succeeds() {
// Declare the failing 503 mock first, then the success mock. Mockito serves them in
// registration order, so attempt #1 hits 503 and attempt #2 hits 200.
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
let tempdir = handoff_tempdir();
let attachments_dir = tempdir.path().to_path_buf();
let http = build_test_http_client();
let mut server = Server::new_async().await;
let flaky_fail = server
.mock("GET", download_path("flaky-uuid"))
.with_status(503)
.with_body("temporarily unavailable")
.expect(1)
.create_async()
.await;
let flaky_ok = server
.mock("GET", download_path("flaky-uuid"))
.with_status(200)
.with_body("finally-here")
.expect(1)
.create_async()
.await;
let attachments = vec![make_attachment(&server.url(), "flaky-uuid", "flaky.patch")];
let result = fetch_and_download_handoff_snapshot_attachments(
mock_client_returning(attachments),
&http,
fake_task_id(),
attachments_dir.clone(),
)
.await
.unwrap();
assert_eq!(result.as_deref(), Some(&*attachments_dir.to_string_lossy()));
assert_eq!(
fs::read(attachments_dir.join("handoff").join("flaky-uuid")).unwrap(),
b"finally-here"
);
flaky_fail.assert_async().await;
flaky_ok.assert_async().await;
}
#[tokio::test]
async fn e2e_permanent_4xx_fails_fast_without_retries() {
// 404 is a permanent error; the retry loop must NOT retry. Exactly one GET is expected.
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
let tempdir = handoff_tempdir();
let attachments_dir = tempdir.path().to_path_buf();
let http = build_test_http_client();
let mut server = Server::new_async().await;
let missing = server
.mock("GET", download_path("missing-uuid"))
.with_status(404)
.with_body("not found")
.expect(1) // exactly one attempt
.create_async()
.await;
let attachments = vec![make_attachment(&server.url(), "missing-uuid", "gone.patch")];
let result = fetch_and_download_handoff_snapshot_attachments(
mock_client_returning(attachments),
&http,
fake_task_id(),
attachments_dir.clone(),
)
.await
.unwrap();
assert!(result.is_none(), "no file landed, dir should be None");
assert!(
!attachments_dir
.join("handoff")
.join("missing-uuid")
.exists(),
"no file should be written on permanent failure"
);
missing.assert_async().await;
}
#[tokio::test]
async fn e2e_retry_exhaustion_marks_failed() {
// Three persistent 5xxs: the retry loop bails out after MAX_ATTEMPTS (3); no file lands.
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
let tempdir = handoff_tempdir();
let attachments_dir = tempdir.path().to_path_buf();
let http = build_test_http_client();
let mut server = Server::new_async().await;
let persistent = server
.mock("GET", download_path("dead-uuid"))
.with_status(502)
.with_body("bad gateway")
.expect(3) // exactly MAX_ATTEMPTS
.create_async()
.await;
let attachments = vec![make_attachment(&server.url(), "dead-uuid", "dead.patch")];
let result = fetch_and_download_handoff_snapshot_attachments(
mock_client_returning(attachments),
&http,
fake_task_id(),
attachments_dir.clone(),
)
.await
.unwrap();
assert!(result.is_none());
assert!(!attachments_dir.join("handoff").join("dead-uuid").exists());
persistent.assert_async().await;
}
#[tokio::test]
async fn e2e_partial_success_returns_dir_with_downloaded_subset() {
// One attachment succeeds, one fails permanently. The dir is returned so the caller can
// still see `{handoff_dir}/ok-uuid` downstream; the failed sibling's file is absent.
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
let tempdir = handoff_tempdir();
let attachments_dir = tempdir.path().to_path_buf();
let http = build_test_http_client();
let mut server = Server::new_async().await;
let ok_mock = server
.mock("GET", download_path("ok-uuid"))
.with_status(200)
.with_body("present")
.expect(1)
.create_async()
.await;
let bad_mock = server
.mock("GET", download_path("bad-uuid"))
.with_status(403)
.with_body("forbidden")
.expect(1)
.create_async()
.await;
let attachments = vec![
make_attachment(&server.url(), "ok-uuid", "ok.patch"),
make_attachment(&server.url(), "bad-uuid", "bad.patch"),
];
let result = fetch_and_download_handoff_snapshot_attachments(
mock_client_returning(attachments),
&http,
fake_task_id(),
attachments_dir.clone(),
)
.await
.unwrap();
assert_eq!(result.as_deref(), Some(&*attachments_dir.to_string_lossy()));
assert_eq!(
fs::read(attachments_dir.join("handoff").join("ok-uuid")).unwrap(),
b"present"
);
assert!(!attachments_dir.join("handoff").join("bad-uuid").exists());
ok_mock.assert_async().await;
bad_mock.assert_async().await;
}
#[tokio::test]
async fn e2e_empty_attachment_list_returns_none_without_creating_dir() {
// With zero attachments listed, the function returns None early and does NOT create the
// handoff dir — nothing to land there.
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
let tempdir = handoff_tempdir();
let attachments_dir = tempdir.path().to_path_buf();
let http = build_test_http_client();
let result = fetch_and_download_handoff_snapshot_attachments(
mock_client_returning(Vec::new()),
&http,
fake_task_id(),
attachments_dir.clone(),
)
.await
.expect("empty list should not be a fatal error");
assert!(result.is_none());
assert!(
!attachments_dir.join("handoff").exists(),
"handoff dir should not be created when there are no attachments"
);
}
#[tokio::test]
async fn e2e_get_handoff_snapshot_attachments_failure_is_fatal() {
// When the listing call errors, the function must return Err wrapping the underlying
// message with a context describing where it happened.
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
let tempdir = handoff_tempdir();
let http = build_test_http_client();
let mut mock = MockAIClient::new();
mock.expect_get_handoff_snapshot_attachments()
.times(1)
.returning(|_task_id| Err(anyhow::anyhow!("simulated listing failure")));
let err = fetch_and_download_handoff_snapshot_attachments(
Arc::new(mock),
&http,
fake_task_id(),
tempdir.path().to_path_buf(),
)
.await
.expect_err("listing failure must be fatal");
let chain: Vec<String> = err.chain().map(|c| c.to_string()).collect();
assert!(
chain
.iter()
.any(|s| s.contains("Failed to fetch handoff snapshot attachments")),
"expected context-wrapped error in chain: {chain:?}"
);
assert!(
chain
.iter()
.any(|s| s.contains("simulated listing failure")),
"expected underlying error in chain: {chain:?}"
);
}
#[tokio::test]
async fn e2e_returns_none_when_oz_handoff_flag_is_disabled() {
// With the feature flag off, the function short-circuits to None without calling the
// AIClient. Any call site that forgot to gate on the flag would log an error; here we
// just verify the return value.
let _guard = FeatureFlag::OzHandoff.override_enabled(false);
let tempdir = handoff_tempdir();
let attachments_dir = tempdir.path().to_path_buf();
let http = build_test_http_client();
// No expect_get_handoff_snapshot_attachments: if the function calls it, the mock panics.
let mock = MockAIClient::new();
let result = fetch_and_download_handoff_snapshot_attachments(
Arc::new(mock),
&http,
fake_task_id(),
attachments_dir.clone(),
)
.await
.expect("flag-disabled path should not be fatal");
assert!(result.is_none());
assert!(!attachments_dir.join("handoff").exists());
}
@@ -0,0 +1,84 @@
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin};
use anyhow::Error;
use warpui::ModelSpawner;
use super::terminal::TerminalDriver;
use crate::ai::cloud_environments::ProvidersConfig;
mod aws;
mod gcp;
pub(crate) type Result<T> = std::result::Result<T, CloudProviderSetupError>;
#[derive(Debug, thiserror::Error)]
#[error("{provider_name} setup failed")]
pub(crate) struct CloudProviderSetupError {
provider_name: &'static str,
#[source]
source: Error,
}
impl CloudProviderSetupError {
pub(crate) fn new(provider_name: &'static str, source: impl Into<Error>) -> Self {
Self {
provider_name,
source: source.into(),
}
}
}
/// A cloud provider that we configure automatic Oz access to.
pub(crate) trait CloudProvider: Send {
/// Return environment variables that should be injected into the terminal
/// session.
fn env_vars(&self) -> Result<HashMap<OsString, OsString>>;
/// Perform any async setup that requires the terminal session to be running.
fn setup(
&mut self,
_spawner: ModelSpawner<TerminalDriver>,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
Box::pin(async { Ok(()) })
}
/// Best-effort cleanup of any resources created during setup.
///
/// The default implementation is a no-op.
fn cleanup(self: Box<Self>) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
Box::pin(async { Ok(()) })
}
}
/// Build the set of cloud providers from an environment's provider configuration.
pub(crate) fn load_providers(
providers: &ProvidersConfig,
run_id: &str,
) -> Result<Vec<Box<dyn CloudProvider>>> {
let mut result: Vec<Box<dyn CloudProvider>> = Vec::new();
if let Some(aws) = &providers.aws {
result.push(Box::new(aws::AwsCloudProvider::new(aws, run_id)?));
}
if let Some(gcp) = &providers.gcp {
result.push(Box::new(gcp::GcpCloudProvider::new(gcp, run_id)?));
}
Ok(result)
}
/// Collect all environment variables from a list of providers.
pub(crate) fn collect_env_vars(
providers: &[Box<dyn CloudProvider>],
vars: &mut HashMap<OsString, OsString>,
) -> Result<()> {
for provider in providers {
vars.extend(provider.env_vars()?);
}
Ok(())
}
#[cfg(test)]
#[path = "cloud_provider_tests.rs"]
mod tests;
@@ -0,0 +1,130 @@
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin, time::Duration};
use anyhow::Context;
use tempfile::{Builder, NamedTempFile};
use vec1::Vec1;
use warp_core::safe_info;
use warp_managed_secrets::ManagedSecretManager;
use warpui::{ModelSpawner, SingletonEntity};
use crate::ai::aws_credentials::aws_role_session_name;
use crate::ai::cloud_environments::AwsProviderConfig;
use super::super::terminal::TerminalDriver;
use super::{CloudProvider, CloudProviderSetupError, Result};
/// Default duration for OIDC identity tokens issued for cloud provider auth.
/// The AWS CLI doesn't offer a mechanism for refreshing web identity tokens, so we
/// set this to the current maximum task duration.
const IDENTITY_TOKEN_DURATION: Duration = Duration::from_hours(3);
/// AWS STS audience for Warp Oz OIDC federation.
const AWS_AUDIENCE: &str = "sts.amazonaws.com";
/// Provides AWS Web Identity credentials for the agent session.
pub(crate) struct AwsCloudProvider {
/// ARN of the role to assume.
role_arn: String,
session_name: String,
/// File containing the OIDC token that the AWS CLI will use to assume the role.
token_file: NamedTempFile,
}
impl AwsCloudProvider {
const PROVIDER_NAME: &'static str = "aws";
pub fn new(config: &AwsProviderConfig, run_id: &str) -> Result<Self> {
// The `tempfile` crate defaults to creating temporary files with user-only permissions.
let token_file = Builder::new()
.prefix(&format!("oz_aws_oidc_{run_id}_"))
.suffix(".token")
.tempfile()
.context("Failed to create temporary AWS OIDC token file")
.map_err(|error| CloudProviderSetupError::new(Self::PROVIDER_NAME, error))?;
Ok(Self {
role_arn: config.role_arn.clone(),
session_name: aws_role_session_name(run_id),
token_file,
})
}
}
impl CloudProvider for AwsCloudProvider {
fn env_vars(&self) -> Result<HashMap<OsString, OsString>> {
// Set variables that the AWS CLI and SDKs check for assuming a role with web identity:
// https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html#cli-configure-role-oidc
let mut vars = HashMap::new();
vars.insert(
OsString::from("AWS_ROLE_ARN"),
OsString::from(&self.role_arn),
);
vars.insert(
OsString::from("AWS_ROLE_SESSION_NAME"),
OsString::from(&self.session_name),
);
vars.insert(
OsString::from("AWS_WEB_IDENTITY_TOKEN_FILE"),
self.token_file.path().as_os_str().to_owned(),
);
Ok(vars)
}
fn setup(
&mut self,
spawner: ModelSpawner<TerminalDriver>,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
Box::pin(async move {
let token_file_path = self.token_file.path();
safe_info!(
safe: ("Setting up AWS cloud provider credentials"),
full: ("Setting up AWS cloud provider: role_arn={}, session={}", self.role_arn, self.session_name)
);
// 1. Issue an OIDC identity token.
let audience = AWS_AUDIENCE.to_string();
let duration = IDENTITY_TOKEN_DURATION;
// Use the scoped principal as the subject, since AWS can't match directly
// on the team claim.
let subject_template = Vec1::new("scoped_principal".into());
let token = spawner
.spawn(move |_, ctx| {
ManagedSecretManager::handle(ctx)
.as_ref(ctx)
.issue_task_identity_token(
warp_managed_secrets::client::IdentityTokenOptions {
audience,
requested_duration: duration,
subject_template,
},
)
})
.await
.map_err(|err| CloudProviderSetupError::new(Self::PROVIDER_NAME, err))?
.await
.map_err(|err| CloudProviderSetupError::new(Self::PROVIDER_NAME, err))?;
// 2. Write the token to the pre-created temporary file.
async_fs::write(&token_file_path, token.token.as_bytes())
.await
.map_err(|err| CloudProviderSetupError::new(Self::PROVIDER_NAME, err))?;
safe_info!(
safe: ("AWS cloud provider setup complete"),
full: ("AWS cloud provider setup complete: token_file={}", token_file_path.display())
);
Ok(())
})
}
fn cleanup(self: Box<Self>) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
Box::pin(async move {
let Self { token_file, .. } = *self;
token_file
.close()
.context("Failed to remove AWS OIDC token file")
.map_err(|err| CloudProviderSetupError::new(Self::PROVIDER_NAME, err))
})
}
}
@@ -0,0 +1,56 @@
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin, time::Duration};
use anyhow::Context as _;
use warp_managed_secrets::{GcpCredentials, GcpFederationConfig};
use crate::ai::cloud_environments::GcpProviderConfig;
use super::{CloudProvider, CloudProviderSetupError, Result};
/// Token lifetime for GCP executable-sourced credentials. The GCP client
/// libraries handle refreshing automatically, so we keep this short.
const TOKEN_LIFETIME: Duration = Duration::from_secs(30 * 60);
/// Provides GCP Workload Identity Federation credentials for the agent session.
///
/// The credential config file is written eagerly during construction. GCP SDKs
/// discover it via `GOOGLE_APPLICATION_CREDENTIALS` and invoke the embedded
/// executable to obtain tokens on demand.
pub(crate) struct GcpCloudProvider {
credentials: GcpCredentials,
}
impl GcpCloudProvider {
const PROVIDER_NAME: &'static str = "gcp";
pub fn new(config: &GcpProviderConfig, run_id: &str) -> Result<Self> {
let federation_config = GcpFederationConfig {
project_number: config.project_number.clone(),
pool_id: config.workload_identity_federation_pool_id.clone(),
provider_id: config.workload_identity_federation_provider_id.clone(),
service_account_email: config.service_account_email.clone(),
token_lifetime: Some(TOKEN_LIFETIME),
};
let credentials = GcpCredentials::federated(run_id, &federation_config)
.context("Failed to prepare GCP federation credentials")
.map_err(|error| CloudProviderSetupError::new(Self::PROVIDER_NAME, error))?;
Ok(Self { credentials })
}
}
impl CloudProvider for GcpCloudProvider {
fn env_vars(&self) -> Result<HashMap<OsString, OsString>> {
Ok(self.credentials.env_vars())
}
fn cleanup(self: Box<Self>) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
Box::pin(async move {
self.credentials
.cleanup()
.context("Failed to remove GCP credential files")
.map_err(|err| CloudProviderSetupError::new(Self::PROVIDER_NAME, err))
})
}
}
@@ -0,0 +1,126 @@
use std::{collections::HashMap, ffi::OsString, path::PathBuf};
use crate::ai::cloud_environments::{AwsProviderConfig, GcpProviderConfig, ProvidersConfig};
use super::{
aws::AwsCloudProvider, collect_env_vars, gcp::GcpCloudProvider, load_providers, CloudProvider,
};
#[test]
fn aws_provider_env_vars_before_setup() {
let config = AwsProviderConfig {
role_arn: "arn:aws:iam::123456789012:role/MyRole".to_string(),
};
let provider = AwsCloudProvider::new(&config, "abc-123").unwrap();
let vars = provider.env_vars().unwrap();
assert_eq!(
vars.get(&OsString::from("AWS_ROLE_ARN")),
Some(&OsString::from("arn:aws:iam::123456789012:role/MyRole"))
);
assert_eq!(
vars.get(&OsString::from("AWS_ROLE_SESSION_NAME")),
Some(&OsString::from("Oz_Run_abc-123"))
);
let token_file = PathBuf::from(
vars.get(&OsString::from("AWS_WEB_IDENTITY_TOKEN_FILE"))
.unwrap(),
);
assert!(token_file
.extension()
.is_some_and(|extension| extension == "token"));
}
#[test]
fn extract_cloud_providers_empty_when_no_providers() {
let config = ProvidersConfig {
gcp: None,
aws: None,
};
let providers = load_providers(&config, "run-1").unwrap();
assert!(providers.is_empty());
}
#[test]
fn extract_cloud_providers_creates_aws_provider() {
let config = ProvidersConfig {
gcp: None,
aws: Some(AwsProviderConfig {
role_arn: "arn:aws:iam::111111111111:role/TestRole".to_string(),
}),
};
let providers = load_providers(&config, "run-42").unwrap();
assert_eq!(providers.len(), 1);
let vars = providers[0].env_vars().unwrap();
assert_eq!(
vars.get(&OsString::from("AWS_ROLE_ARN")),
Some(&OsString::from("arn:aws:iam::111111111111:role/TestRole"))
);
assert_eq!(
vars.get(&OsString::from("AWS_ROLE_SESSION_NAME")),
Some(&OsString::from("Oz_Run_run-42"))
);
}
#[test]
fn gcp_provider_env_vars() {
let config = GcpProviderConfig {
project_number: "123456789".to_string(),
workload_identity_federation_pool_id: "my-pool".to_string(),
workload_identity_federation_provider_id: "my-provider".to_string(),
service_account_email: None,
};
let provider = GcpCloudProvider::new(&config, "run-99").unwrap();
let vars = provider.env_vars().unwrap();
assert!(vars.contains_key(&OsString::from("GOOGLE_APPLICATION_CREDENTIALS")));
assert_eq!(
vars.get(&OsString::from("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES")),
Some(&OsString::from("1"))
);
}
#[test]
fn extract_cloud_providers_creates_gcp_provider() {
let config = ProvidersConfig {
gcp: Some(GcpProviderConfig {
project_number: "111".to_string(),
workload_identity_federation_pool_id: "pool".to_string(),
workload_identity_federation_provider_id: "prov".to_string(),
service_account_email: None,
}),
aws: None,
};
let providers = load_providers(&config, "run-1").unwrap();
assert_eq!(providers.len(), 1);
let vars = providers[0].env_vars().unwrap();
assert!(vars.contains_key(&OsString::from("GOOGLE_APPLICATION_CREDENTIALS")));
}
#[test]
fn collect_provider_env_vars_merges_all_providers() {
let config = ProvidersConfig {
gcp: Some(GcpProviderConfig {
project_number: "222".to_string(),
workload_identity_federation_pool_id: "pool".to_string(),
workload_identity_federation_provider_id: "prov".to_string(),
service_account_email: None,
}),
aws: Some(AwsProviderConfig {
role_arn: "arn:aws:iam::999:role/R".to_string(),
}),
};
let providers = load_providers(&config, "id-7").unwrap();
assert_eq!(providers.len(), 2);
let mut vars = HashMap::new();
collect_env_vars(&providers, &mut vars).unwrap();
// AWS variables.
assert!(vars.contains_key(&OsString::from("AWS_ROLE_ARN")));
assert!(vars.contains_key(&OsString::from("AWS_ROLE_SESSION_NAME")));
// GCP variables.
assert!(vars.contains_key(&OsString::from("GOOGLE_APPLICATION_CREDENTIALS")));
}
+537
View File
@@ -0,0 +1,537 @@
use std::{
collections::HashMap,
future::Future,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use crate::ai::cloud_environments::{AmbientAgentEnvironment, GithubRepo};
use crate::terminal::model::session::command_executor::shell_escape_single_quotes;
use crate::terminal::shell::ShellType;
use ai::index::full_source_code_embedding::manager::{
CodebaseIndexManager, CodebaseIndexManagerEvent,
};
use futures::{channel::oneshot, future::join_all};
use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource};
use warp_completer::completer::CommandExitStatus;
use warp_core::{command::ExitCode, safe_info, safe_warn};
use warpui::{r#async::FutureExt, ModelContext, ModelSpawner, SingletonEntity};
use super::{terminal::TerminalDriver, AgentDriverError};
use warp_cli::agent::Harness;
const CODEBASE_INDEX_SYNC_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, thiserror::Error)]
pub enum PrepareEnvironmentError {
#[error("Invalid runtime state - please file a bug report.")]
InvalidRuntimeState,
#[error("Failed to clone {repo_name}")]
CloneRepo { repo_name: String },
#[error("Failed to run setup command: {command}")]
SetupCommand { command: String },
#[error("Failed to change directory into {repo_name}")]
ChangeDirectory { repo_name: String },
#[error("Terminal driver error while preparing environment: {source}")]
TerminalDriver { source: AgentDriverError },
}
/// Prepare a cloud agent environment within a terminal session. This will:
/// 1. Clone all repositories, skipping any that are already cloned.
/// 2. Begin codebase indexing for all repositories (Oz harness only).
/// 3. Run any setup commands.
/// 4. If there is only one repository, navigate into it.
///
/// `is_sandbox` tells the preparer that `working_dir` only exists inside a
/// Docker sandbox container and therefore the host filesystem can't be used
/// for repo detection or indexing. This is an explicit signal from the
/// caller rather than a path-prefix inference, so non-sandbox callers that
/// happen to pass a path like `/home/agent/...` don't silently flip into
/// sandbox-only mode.
pub fn prepare_environment(
environment: AmbientAgentEnvironment,
working_dir: PathBuf,
is_sandbox: bool,
harness: Harness,
ctx: &mut ModelContext<TerminalDriver>,
) -> impl Future<Output = Result<(), PrepareEnvironmentError>> {
let spawner = ctx.spawner();
async move {
let AmbientAgentEnvironment {
github_repos,
setup_commands,
..
} = environment;
// Only index the codebase for the Oz harness; third-party harnesses (e.g. Claude)
// have their own methods for navigating a codebase.
let should_index_codebase = harness == Harness::Oz;
let should_subscribe_to_index_updates = should_index_codebase && !github_repos.is_empty();
let repo_channels = Arc::new(Mutex::new(HashMap::<PathBuf, oneshot::Sender<()>>::new()));
if should_subscribe_to_index_updates {
subscribe_to_codebase_index_events(&spawner, Arc::clone(&repo_channels)).await?;
}
let result = prepare_environment_impl(
&spawner,
working_dir.as_path(),
is_sandbox,
&github_repos,
setup_commands,
should_index_codebase,
Arc::clone(&repo_channels),
)
.await;
if should_subscribe_to_index_updates {
let _ = spawner
.spawn(|_, ctx| {
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.await;
}
result
}
}
async fn prepare_environment_impl(
spawner: &ModelSpawner<TerminalDriver>,
working_dir: &Path,
is_sandbox: bool,
github_repos: &[GithubRepo],
setup_commands: Vec<String>,
should_index_codebase: bool,
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
) -> Result<(), PrepareEnvironmentError> {
let working_dir_string = working_dir.to_string_lossy().to_string();
// Position the session in `working_dir` before running any probes / clones.
// Routed through the silent executor so we don't add a user-visible `cd`
// block to the blocklist — in the common case (cloud agents) the session
// is already cd'd here by its startup dir, so this is a no-op re-cd and
// shouldn't appear in the user's terminal history.
if !cd_in_terminal_silent(working_dir_string.clone(), spawner).await? {
return Err(PrepareEnvironmentError::ChangeDirectory {
repo_name: working_dir_string,
});
}
let mut codebase_context_receivers = Vec::new();
for repo in github_repos {
let repo_name = format!("{}/{}", repo.owner, repo.repo);
let repo_url = format!("https://github.com/{repo_name}.git");
// We do a partial clone here to speed up environment setup time.
let command = format!("git clone --filter=tree:0 {repo_url}");
let repo_dir = working_dir.join(&repo.repo);
// Always ask the session whether the repo dir already exists, rather
// than stat'ing from the host. The session knows about sandbox-only
// paths, and this goes through the silent executor so `test -d` is
// not added to the user-visible blocklist. Pass the absolute path
// explicitly so the probe doesn't rely on the session's CWD.
let dir_exists = terminal_directory_exists(&repo_dir.to_string_lossy(), spawner).await?;
if dir_exists {
safe_warn!(
safe: ("We already have a directory with the same repository name in the terminal working directory, skipping clone..."),
full: (
"We already have a directory with the name {} in the terminal working directory, skipping clone...",
repo.repo)
);
} else {
safe_info!(
safe: ("Cloning repository via terminal"),
full: ("Cloning repository via terminal: {repo_name}")
);
let exit_code = execute_command(command, spawner).await?;
if exit_code != 0.into() {
return Err(PrepareEnvironmentError::CloneRepo {
repo_name: repo_name.clone(),
});
}
safe_info!(
safe: ("Successfully cloned repository"),
full: ("Successfully cloned: {repo_name}")
);
}
// Register the repo with DetectedRepositories so that the skill watcher
// and other repo-aware subsystems can discover it before the first query.
//
// TODO(advait): When the remote code server lands for Docker sandboxes,
// sandbox-only working directories will be reachable from the host and
// we should register + index them here too (likely via a remote-aware
// path instead of `detect_possible_git_repo`/`index_directory`, which
// both assume a local filesystem). For now, skip so we don't try to
// stat paths that only exist inside the sandbox.
if is_sandbox {
safe_info!(
safe: ("Skipping local repo detection for sandbox-only working directory"),
full: (
"Skipping local repo detection and indexing for sandbox-only working directory {}",
working_dir.display()
)
);
} else {
let repo_dir_str = repo_dir.to_string_lossy().to_string();
let detect_future = spawner
.spawn(move |_, ctx| {
DetectedRepositories::handle(ctx).update(ctx, |repos, ctx| {
repos.detect_possible_git_repo(
&repo_dir_str,
RepoDetectionSource::CloudEnvironmentPrep,
ctx,
)
})
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?;
// Await detection so the repo is registered in DirectoryWatcher
// before the agent's first query.
if detect_future.await.is_none() {
safe_warn!(
safe: ("Repository detection returned no path"),
full: ("Repository detection returned no path for {}", repo_dir.display())
);
}
if should_index_codebase {
let receiver = index_repo_codebase(
&repo.repo,
working_dir,
Arc::clone(&repo_channels),
spawner,
)
.await?;
if let Some(receiver) = receiver {
codebase_context_receivers.push(receiver);
}
}
}
}
let has_setup_commands = !setup_commands.is_empty();
if has_setup_commands {
// Set CI=true so setup commands run in a CI-like environment. This should help us run
// non-interactive versions of setup commands, as many command line tools recognize the CI
// environment variable.
execute_command("export CI=true".to_string(), spawner).await?;
}
for command in setup_commands {
let command_for_error = command.clone();
safe_info!(
safe: ("Running setup command"),
full: ("Running setup command: {command}")
);
let exit_code = execute_command(command, spawner).await?;
if exit_code != 0.into() {
return Err(PrepareEnvironmentError::SetupCommand {
command: command_for_error,
});
}
let working_dir_string = working_dir.to_string_lossy().to_string();
if let Err(error) = cd_in_terminal(working_dir_string, spawner).await {
log::warn!("Failed to reset working directory after setup command: {error}");
}
safe_info!(
safe: ("Successfully completed setup command"),
full: ("Successfully completed setup command: {command_for_error}")
);
}
if has_setup_commands {
// Unset CI after setup commands complete so the agent session
// does not run with CI=true.
execute_command("unset CI".to_string(), spawner).await?;
}
if !github_repos.is_empty() {
// Wait for codebase indexing for all repositories after running setup commands.
// We skip this if running in Docker sandboxes since they don't have a cache volume.
// We also skip this in Namespace to reduce startup time.
#[cfg(not(target_family = "wasm"))]
let should_wait_for_indexing = !matches!(
warp_isolation_platform::detect(),
Some(
warp_isolation_platform::IsolationPlatformType::DockerSandbox
| warp_isolation_platform::IsolationPlatformType::Namespace
)
);
#[cfg(target_family = "wasm")]
let should_wait_for_indexing = true;
if should_wait_for_indexing {
let repos_indexed = join_all(codebase_context_receivers);
if repos_indexed
.with_timeout(CODEBASE_INDEX_SYNC_TIMEOUT)
.await
.is_err()
{
log::warn!(
"Timed out waiting for codebase index sync; continuing without guaranteed codebase context",
);
}
} else {
drop(codebase_context_receivers);
log::info!("Not waiting for codebase index sync");
}
}
// If there's only one repo in the environment, start the agent in that repo.
// This way, it doesn't have to locate the correct repo to work on.
if let Some(repo_name) = single_repo_name(github_repos) {
safe_info!(
safe: ("Changing directory into single repository"),
full: ("Changing directory into single repository: {repo_name}")
);
let exit_code = cd_in_terminal(repo_name.clone(), spawner).await?;
if exit_code != 0.into() {
return Err(PrepareEnvironmentError::ChangeDirectory { repo_name });
}
}
Ok(())
}
async fn subscribe_to_codebase_index_events(
spawner: &ModelSpawner<TerminalDriver>,
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
) -> Result<(), PrepareEnvironmentError> {
spawner
.spawn(move |_, ctx| {
let repo_channels = Arc::clone(&repo_channels);
ctx.subscribe_to_model(
&CodebaseIndexManager::handle(ctx),
move |_, event, ctx| {
if !matches!(event, CodebaseIndexManagerEvent::SyncStateUpdated) {
return;
}
let manager = CodebaseIndexManager::as_ref(ctx);
let mut repos_to_notify = Vec::new();
let mut channels = repo_channels
.lock()
.expect("repo channel map lock should not be poisoned");
for repo in channels.keys() {
let Some(status) =
manager.get_codebase_index_status_for_path(repo, ctx)
else {
continue;
};
if status.has_synced_version() {
repos_to_notify.push(repo.clone());
continue;
}
if !status.has_pending() && status.last_sync_successful() == Some(false) {
safe_warn!(
safe: ("Codebase index sync failed for a repo; unblocking environment setup"),
full: ("Codebase index sync failed for {repo:?}; unblocking environment setup")
);
repos_to_notify.push(repo.clone());
}
}
for repo in repos_to_notify {
if let Some(tx) = channels.remove(&repo) {
let _ = tx.send(());
}
}
},
);
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
}
async fn index_repo_codebase(
repo_name: &str,
working_dir: &Path,
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<Option<oneshot::Receiver<()>>, PrepareEnvironmentError> {
let repo_path = working_dir.join(repo_name);
safe_info!(
safe: ("Trying to index repository for codebase context"),
full: ("Trying to index {:?} for codebase context", repo_path)
);
let repo_path_for_spawn = repo_path.clone();
spawner
.spawn(move |_, ctx| {
CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| {
manager.index_directory(repo_path_for_spawn.clone(), ctx);
});
let status = CodebaseIndexManager::as_ref(ctx)
.get_codebase_index_status_for_path(&repo_path_for_spawn, ctx);
match status {
Some(status) if status.has_synced_version() => {
safe_info!(
safe: ("Not waiting on codebase index for repository; we have one already"),
full: ("Not waiting on codebase index for {:?}, we have one already", repo_path_for_spawn)
);
None
}
_ => {
safe_info!(
safe: ("Waiting on codebase index for repository"),
full: ("Waiting on codebase index for {:?}", repo_path_for_spawn)
);
let (tx, rx) = oneshot::channel::<()>();
repo_channels
.lock()
.expect("repo channel map lock should not be poisoned")
.insert(repo_path_for_spawn, tx);
Some(rx)
}
}
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
}
/// Execute a command in the context of a terminal session.
async fn execute_command(
command: String,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<ExitCode, PrepareEnvironmentError> {
spawner
.spawn(move |terminal_driver, ctx| terminal_driver.execute_command(&command, ctx))
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?
.map_err(|error| match error {
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
source => PrepareEnvironmentError::TerminalDriver { source },
})?
.await
.map_err(|error| match error {
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
source => PrepareEnvironmentError::TerminalDriver { source },
})?
.await
.map_err(|error| match error {
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
source => PrepareEnvironmentError::TerminalDriver { source },
})
}
/// Change the current directory in the context of a terminal session (using `cd {dir}`).
async fn cd_in_terminal(
target: String,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<ExitCode, PrepareEnvironmentError> {
spawner
.spawn(move |terminal_driver, ctx| terminal_driver.cd(&target, ctx))
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?
.map_err(|error| match error {
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
source => PrepareEnvironmentError::TerminalDriver { source },
})?
.await
.map_err(|error| match error {
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
source => PrepareEnvironmentError::TerminalDriver { source },
})?
.await
.map_err(|error| match error {
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
source => PrepareEnvironmentError::TerminalDriver { source },
})
}
fn single_repo_name(repos: &[GithubRepo]) -> Option<String> {
if repos.len() != 1 {
return None;
}
Some(repos[0].repo.clone())
}
/// Change the active terminal session's working directory via `cd <target>`,
/// silently.
///
/// Thin wrapper around [`TerminalDriver::cd_silent`] so the call stays
/// consistent with the other `*_in_terminal` / `terminal_*` helpers in this
/// module. Uses the same [`ShellFamily::shell_escape`] logic as the visible
/// [`TerminalDriver::cd`] path, so it's safe across bash/zsh/fish/pwsh host
/// shells.
///
/// Returns `true` if the `cd` exited successfully.
async fn cd_in_terminal_silent(
target: String,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<bool, PrepareEnvironmentError> {
let output = spawner
.spawn(move |driver, ctx| driver.cd_silent(&target, ctx))
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?
.await
.map_err(|error| match error {
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
source => PrepareEnvironmentError::TerminalDriver { source },
})?;
Ok(output.status == CommandExitStatus::Success)
}
/// Returns whether the given path resolves to an existing directory from the
/// perspective of the active terminal session.
///
/// Runs `test -d <path>` through the session's in-band command executor, so
/// the check is invisible in the user-facing blocklist and works for paths
/// that only exist inside a remote/sandbox filesystem. The path is escaped
/// using the *session's* actual shell type (bash/zsh use the `'"'"'` trick,
/// fish uses a backslash, PowerShell doubles the quote) rather than assuming
/// bash.
///
/// Prefer passing an absolute path: relative paths resolve against the
/// session's current working directory, which couples the caller to
/// whatever `cd` state the session happens to be in.
///
/// TODO(advait): `test -d ...` itself is POSIX-only. When we support
/// environment prep on Windows host shells (PowerShell / cmd.exe), also
/// branch on `ShellType` to emit the appropriate probe (e.g.
/// `Test-Path -PathType Container <path>` for PowerShell).
async fn terminal_directory_exists(
path: &str,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<bool, PrepareEnvironmentError> {
let path = path.to_owned();
let output = spawner
.spawn(move |driver, ctx| {
// Fall back to Bash if the session's shell type isn't known yet
// (e.g. pre-bootstrap). Bash-style escaping is a safe default for
// every POSIX shell we currently support.
let shell_type = driver
.active_session_shell_type(ctx)
.unwrap_or(ShellType::Bash);
let escaped = shell_escape_single_quotes(&path, shell_type);
let command = format!("test -d '{escaped}'");
driver.execute_silent_command(command, ctx)
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?
.await
.map_err(|error| match error {
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
source => PrepareEnvironmentError::TerminalDriver { source },
})?;
Ok(output.status == CommandExitStatus::Success)
}
#[cfg(test)]
#[path = "environment_tests.rs"]
mod tests;
@@ -0,0 +1,24 @@
use super::single_repo_name;
use crate::ai::cloud_environments::GithubRepo;
#[test]
fn single_repo_name_returns_repo_when_exactly_one_repo() {
let repos = vec![GithubRepo::new(
"warpdotdev".to_string(),
"warp-internal".to_string(),
)];
let selected_repo = single_repo_name(&repos);
assert_eq!(selected_repo, Some("warp-internal".to_string()));
}
#[test]
fn single_repo_name_returns_none_for_zero_or_many_repos() {
let no_repos = Vec::<GithubRepo>::new();
assert_eq!(single_repo_name(&no_repos), None);
let two_repos = vec![
GithubRepo::new("warpdotdev".to_string(), "warp-internal".to_string()),
GithubRepo::new("warpdotdev".to_string(), "warp-server".to_string()),
];
assert_eq!(single_repo_name(&two_repos), None);
}
@@ -0,0 +1,307 @@
use crate::ai::blocklist::task_status_sync_model::classify_renderable_error;
use crate::server::server_api::ai::TaskStatusUpdate;
use warp_graphql::ai::{AgentTaskState, PlatformErrorCode};
use super::terminal::ShareSessionError;
use super::AgentDriverError;
/// Classify an `AgentDriverError` into a task state and a `TaskStatusUpdate`
/// suitable for reporting via `update_agent_task`.
pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskStatusUpdate) {
match error {
// --- Warp-side errors (task → ERROR) ---
AgentDriverError::TerminalUnavailable | AgentDriverError::InvalidRuntimeState => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
"An internal error occurred. Please try running your task again. If the issue persists, contact support.",
PlatformErrorCode::InternalError,
),
),
AgentDriverError::BootstrapFailed => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
"Terminal session failed to start. Please try running your task again.",
PlatformErrorCode::InternalError,
),
),
AgentDriverError::ShareSessionFailed { error: share_err } => {
let message = match share_err {
ShareSessionError::Internal(_) => {
"Failed to share agent session due to an internal error. Please try running your task again.".to_string()
}
ShareSessionError::Failed(reason) => {
// The reason string comes from the session-sharing layer and is aimed at
// interactive users (e.g. "try sharing again"). Provide a cloud-agent-
// appropriate message instead of wrapping it, which would produce
// repetitive "try again" text.
format!("Failed to share agent session: {reason}")
}
ShareSessionError::Disabled => {
"Session sharing is not enabled for your account. This is likely because \
an administrator has disabled session sharing for your team. Please \
verify that session sharing is enabled in your team settings, or try \
running without the --share flag."
.to_string()
}
ShareSessionError::Timeout => {
"Failed to share agent session: timed out waiting for the session sharing \
server to respond. Please check your network connection and try again."
.to_string()
}
ShareSessionError::Interrupted => {
"Session sharing was interrupted before it could complete. Please try running your task again.".to_string()
}
};
(
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
message,
match share_err {
ShareSessionError::Disabled => PlatformErrorCode::FeatureNotAvailable,
_ => PlatformErrorCode::InternalError,
},
),
)
}
AgentDriverError::WarpDriveSyncFailed => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
"Warp Drive failed to sync. Please check your network connection and try again.",
PlatformErrorCode::InternalError,
),
),
AgentDriverError::NotLoggedIn => {
let bin = warp_cli::binary_name().unwrap_or_else(|| "warp".to_string());
(
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
format!(
"Authentication required. Log in via '{bin} login', provide an API key via '--api-key', or set the WARP_API_KEY environment variable."
),
PlatformErrorCode::AuthenticationRequired,
),
)
}
AgentDriverError::CloudProviderSetupFailed(err) => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
format!("Error configuring cloud access: {err:#}"),
PlatformErrorCode::InternalError,
),
),
// --- User-side errors (task → FAILED) ---
AgentDriverError::MCPServerNotFound(uuid) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"MCP server {uuid} was not found. Verify the server exists in your Warp Drive and the UUID is correct."
),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::MCPStartupFailed => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
"One or more MCP servers failed to start. Check that your MCP server configuration is valid and the server process is runnable.",
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::MCPJsonParseError(msg) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!("Failed to parse MCP server JSON configuration: {msg}"),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::MCPMissingVariables => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
"MCP server configuration is missing required variables. Provide all required environment variables or template values.",
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::ProfileError(name) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"Agent profile \"{name}\" not found. Check the profile ID and ensure it exists in your team's Warp Drive."
),
PlatformErrorCode::ResourceNotFound,
),
),
AgentDriverError::AIWorkflowNotFound(id) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"Saved prompt not found for ID {id}. Verify the prompt exists in your Warp Drive."
),
PlatformErrorCode::ResourceNotFound,
),
),
AgentDriverError::EnvironmentNotFound(id) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"Environment '{id}' not found. Verify the environment ID and ensure it exists in your team settings."
),
PlatformErrorCode::ResourceNotFound,
),
),
AgentDriverError::EnvironmentSetupFailed(msg) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"Environment setup failed: {msg}. Check your repository URLs and setup commands."
),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::InvalidWorkingDirectory { path, .. } => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"Working directory '{}' does not exist or is not a directory. Verify the path in your environment configuration.",
path.display()
),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
// --- Conversation errors ---
// Delegate to classify_renderable_error for proper ERROR vs FAILED
// distinction and PlatformErrorCode. This is a belt-and-suspenders
// fallback — TaskStatusSyncModel handles most conversation errors,
// but the driver catches them too if the conversation ends with an error.
AgentDriverError::ConversationError { error } => {
let (state, update) = classify_renderable_error(error);
(
state,
update.unwrap_or_else(|| {
TaskStatusUpdate::with_error_code(
error.to_string(),
PlatformErrorCode::InternalError,
)
}),
)
}
// --- Cancellation / Blocked (no error code) ---
AgentDriverError::ConversationCancelled { .. } => (
AgentTaskState::Cancelled,
TaskStatusUpdate::message("Task cancelled."),
),
AgentDriverError::ConversationBlocked { blocked_action } => (
AgentTaskState::Blocked,
TaskStatusUpdate::message(format!(
"The agent got stuck waiting for user confirmation on the action: {blocked_action}"
)),
),
// --- Setup errors ---
AgentDriverError::TeamMetadataRefreshTimeout => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
"Timed out refreshing team metadata. Please check your network connection and try again.",
PlatformErrorCode::InternalError,
),
),
AgentDriverError::SkillResolutionFailed(msg) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!("Skill resolution failed: {msg}"),
PlatformErrorCode::ResourceNotFound,
),
),
AgentDriverError::ConfigBuildFailed(err) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!("Failed to build agent configuration: {err}"),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::PromptResolutionFailed(err) => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
format!("Failed to resolve prompt for the run: {err}"),
PlatformErrorCode::InternalError,
),
),
AgentDriverError::SecretsFetchFailed(err) => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
format!("Failed to fetch task secrets: {err}"),
PlatformErrorCode::InternalError,
),
),
AgentDriverError::AwsBedrockCredentialsFailed(msg) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!("Failed to initialize AWS Bedrock credentials: {msg}"),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::ConversationLoadFailed(msg) => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
format!("Failed to load conversation: {msg}"),
PlatformErrorCode::InternalError,
),
),
AgentDriverError::ConversationHarnessMismatch { conversation_id, expected, got } => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"Conversation {conversation_id} was produced by the {expected} harness, but --harness {got} was requested. \
Re-run with --harness {expected} (or omit --harness) to continue this conversation."
),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::TaskHarnessMismatch { task_id, expected, got } => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"Task {task_id} was created with the {expected} harness, but --harness {got} was requested. \
Re-run with --harness {expected} (or omit --harness) to continue this task."
),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::ConversationResumeStateMissing { harness, conversation_id } => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"Conversation {conversation_id} has no stored transcript for the {harness} harness. \
The prior run may have crashed before saving any state."
),
PlatformErrorCode::ResourceNotFound,
),
),
AgentDriverError::HarnessCommandFailed { exit_code } => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!("Harness command exited with code {exit_code}"),
PlatformErrorCode::InternalError,
),
),
AgentDriverError::HarnessSetupFailed { harness, reason } => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!("Harness '{harness}' validation failed: {reason}"),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::HarnessConfigSetupFailed { harness, error } => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!("Harness '{harness}' config setup failed: {error}"),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
}
}
#[cfg(test)]
#[path = "error_classification_tests.rs"]
mod tests;
@@ -0,0 +1,155 @@
use warp_graphql::ai::{AgentTaskState, PlatformErrorCode};
use super::classify_driver_error;
use crate::ai::agent_sdk::driver::terminal::ShareSessionError;
use crate::ai::agent_sdk::driver::AgentDriverError;
fn assert_state_and_code(
error: AgentDriverError,
expected_state: AgentTaskState,
expected_code: Option<PlatformErrorCode>,
) {
let (state, update) = classify_driver_error(&error);
assert_eq!(state, expected_state, "unexpected state for {error}");
assert_eq!(
update.error_code, expected_code,
"unexpected error_code for {error}"
);
}
// --- Infrastructure errors → ERROR ---
#[test]
fn bootstrap_failed_is_error_with_internal() {
assert_state_and_code(
AgentDriverError::BootstrapFailed,
AgentTaskState::Error,
Some(PlatformErrorCode::InternalError),
);
}
#[test]
fn terminal_unavailable_is_error_with_internal() {
assert_state_and_code(
AgentDriverError::TerminalUnavailable,
AgentTaskState::Error,
Some(PlatformErrorCode::InternalError),
);
}
#[test]
fn not_logged_in_is_error_with_auth_required() {
let (state, update) = classify_driver_error(&AgentDriverError::NotLoggedIn);
assert_eq!(state, AgentTaskState::Error);
assert_eq!(
update.error_code,
Some(PlatformErrorCode::AuthenticationRequired)
);
assert!(
update.message.contains("WARP_API_KEY"),
"message should mention WARP_API_KEY: {:?}",
update.message
);
}
#[test]
fn warp_drive_sync_failed_is_error() {
assert_state_and_code(
AgentDriverError::WarpDriveSyncFailed,
AgentTaskState::Error,
Some(PlatformErrorCode::InternalError),
);
}
// --- Config/user errors → FAILED ---
#[test]
fn mcp_server_not_found_is_failed_with_env_setup() {
assert_state_and_code(
AgentDriverError::MCPServerNotFound(uuid::Uuid::nil()),
AgentTaskState::Failed,
Some(PlatformErrorCode::EnvironmentSetupFailed),
);
}
#[test]
fn environment_setup_failed_is_failed() {
assert_state_and_code(
AgentDriverError::EnvironmentSetupFailed("bad repo".into()),
AgentTaskState::Failed,
Some(PlatformErrorCode::EnvironmentSetupFailed),
);
}
#[test]
fn profile_error_is_failed_with_resource_not_found() {
assert_state_and_code(
AgentDriverError::ProfileError("my-profile".into()),
AgentTaskState::Failed,
Some(PlatformErrorCode::ResourceNotFound),
);
}
#[test]
fn environment_not_found_is_failed_with_resource_not_found() {
assert_state_and_code(
AgentDriverError::EnvironmentNotFound("env-123".into()),
AgentTaskState::Failed,
Some(PlatformErrorCode::ResourceNotFound),
);
}
// --- ShareSessionFailed variants ---
#[test]
fn share_session_disabled_gets_feature_not_available() {
let (state, update) = classify_driver_error(&AgentDriverError::ShareSessionFailed {
error: ShareSessionError::Disabled,
});
assert_eq!(state, AgentTaskState::Error);
assert_eq!(
update.error_code,
Some(PlatformErrorCode::FeatureNotAvailable)
);
assert!(update.message.contains("not enabled"));
assert!(update.message.contains("--share flag"));
}
#[test]
fn share_session_timeout_gets_internal_error() {
let (state, update) = classify_driver_error(&AgentDriverError::ShareSessionFailed {
error: ShareSessionError::Timeout,
});
assert_eq!(state, AgentTaskState::Error);
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
assert!(update.message.contains("timed out"));
}
#[test]
fn share_session_failed_includes_reason() {
let (state, update) = classify_driver_error(&AgentDriverError::ShareSessionFailed {
error: ShareSessionError::Failed("server rejected".into()),
});
assert_eq!(state, AgentTaskState::Error);
assert!(update.message.contains("server rejected"));
}
// --- Conversation-level outcomes ---
#[test]
fn conversation_cancelled_is_cancelled() {
let (state, update) = classify_driver_error(&AgentDriverError::ConversationCancelled {
reason: crate::ai::agent::CancellationReason::ManuallyCancelled,
});
assert_eq!(state, AgentTaskState::Cancelled);
assert!(update.error_code.is_none());
}
#[test]
fn conversation_blocked_is_blocked() {
let (state, update) = classify_driver_error(&AgentDriverError::ConversationBlocked {
blocked_action: "rm -rf /".into(),
});
assert_eq!(state, AgentTaskState::Blocked);
assert!(update.message.contains("rm -rf /"));
}
@@ -0,0 +1,664 @@
use std::collections::HashMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tempfile::NamedTempFile;
use uuid::Uuid;
use warp_cli::agent::Harness;
use warpui::{ModelHandle, ModelSpawner};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
use crate::server::server_api::ServerApi;
use crate::terminal::model::block::BlockId;
use crate::terminal::model::session::ExecuteCommandOptions;
use crate::terminal::CLIAgent;
use super::super::terminal::{CommandHandle, TerminalDriver};
use super::super::{AgentDriver, AgentDriverError};
use super::claude_transcript::{
claude_config_dir, read_envelope, write_envelope, write_session_index_entry, ClaudeResumeInfo,
ClaudeTranscriptEnvelope,
};
use super::json_utils::{read_json_file_or_default, write_json_file};
use super::{
write_temp_file, HarnessRunner, ManagedSecretValue, ResumePayload, SavePoint, ThirdPartyHarness,
};
mod parent_bridge;
#[cfg(test)]
use super::super::OZ_MESSAGE_LISTENER_STATE_ROOT_ENV;
use parent_bridge::MessageBridge;
#[cfg(test)]
use parent_bridge::{
acknowledge_parent_bridge_hook_output, ensure_parent_bridge_state_dir,
parent_bridge_char_count, parent_bridge_hook_output_ack_file, parent_bridge_hook_output_file,
parent_bridge_root, parent_bridge_staged_message_path, parent_bridge_surfaced_message_path,
prepare_parent_bridge_hook_output, render_parent_bridge_message_block,
stage_parent_bridge_message, MessageBridgeHookOutput, MessageBridgeMessageRecord,
MESSAGE_BRIDGE_CONTEXT_PREAMBLE,
};
pub(crate) struct ClaudeHarness;
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl ThirdPartyHarness for ClaudeHarness {
fn harness(&self) -> Harness {
Harness::Claude
}
fn cli_agent(&self) -> CLIAgent {
CLIAgent::Claude
}
fn install_docs_url(&self) -> Option<&'static str> {
Some("https://code.claude.com/docs/en/quickstart")
}
fn prepare_environment_config(
&self,
working_dir: &Path,
_system_prompt: Option<&str>,
secrets: &HashMap<String, ManagedSecretValue>,
) -> Result<(), AgentDriverError> {
prepare_claude_environment_config(working_dir, secrets).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
}
})
}
/// Fetch the Claude Code transcript for the current task's conversation and wrap it
/// into a [`ResumePayload::Claude`]. Maps a server 404 to
/// [`AgentDriverError::ConversationResumeStateMissing`] tagged as the `claude` harness
/// so the user sees a resume-specific error rather than a generic load failure.
async fn fetch_resume_payload(
&self,
conversation_id: &AIConversationId,
harness_support_client: Arc<dyn HarnessSupportClient>,
) -> Result<Option<ResumePayload>, AgentDriverError> {
let conversation_id_str = conversation_id.to_string();
let bytes = harness_support_client
.fetch_transcript()
.await
.map_err(|err| {
// A 404 from the server maps to "no stored transcript" so the CLI can tell
// the user the prior run never saved state.
let message = format!("{err:#}").to_lowercase();
if message.contains("status 404") {
AgentDriverError::ConversationResumeStateMissing {
harness: "claude".to_string(),
conversation_id: conversation_id_str.clone(),
}
} else {
AgentDriverError::ConversationLoadFailed(format!("{err:#}"))
}
})?;
let envelope: ClaudeTranscriptEnvelope = serde_json::from_slice(&bytes).map_err(|err| {
AgentDriverError::ConversationLoadFailed(format!(
"Failed to deserialize Claude transcript for {conversation_id_str}: {err:#}"
))
})?;
let session_id = envelope.uuid;
Ok(Some(ResumePayload::Claude(ClaudeResumeInfo {
conversation_id: *conversation_id,
session_id,
envelope,
})))
}
fn build_runner(
&self,
prompt: &str,
system_prompt: Option<&str>,
resumption_prompt: Option<&str>,
working_dir: &Path,
task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ResumePayload>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
// Extract the Claude variant; any other variant is ignored since it belongs to a
// different harness. Today there are no other variants, but this keeps the shape
// ready for future CLI-specific payloads.
let claude_resume = resume.map(|payload| match payload {
ResumePayload::Claude(info) => info,
});
// Claude treats the user-turn message as immediate intent, so the resumption preamble
// is most reliable when prepended directly to the prompt that gets piped into the CLI.
let owned_prompt = match resumption_prompt {
Some(preamble) if !preamble.is_empty() => format!("{preamble}\n\n{prompt}"),
_ => prompt.to_string(),
};
Ok(Box::new(ClaudeHarnessRunner::new(
self.cli_agent().command_prefix(),
&owned_prompt,
system_prompt,
working_dir,
task_id,
server_api,
terminal_driver,
claude_resume,
)?))
}
}
/// Format slug sent to the server when creating a Claude Code conversation.
const CLAUDE_CODE_FORMAT: &str = "claude_code_cli";
/// Command used to exit claude.
const CLAUDE_EXIT_COMMAND: &str = "/exit";
/// Build the shell command that launches the Claude CLI for a given session and
/// prompt file.
///
/// When `resuming` is true we pass `--resume <uuid>` so Claude picks up the
/// existing on-disk session; otherwise we pass `--session-id <uuid>` to pin a
/// fresh session to that id. If `system_prompt_path` is provided, the CLI is
/// told to append its contents to the base system prompt.
fn claude_command(
cli_name: &str,
session_id: &Uuid,
prompt_path: &str,
system_prompt_path: Option<&str>,
resuming: bool,
) -> String {
let flag = if resuming { "--resume" } else { "--session-id" };
let mut cmd = format!("{cli_name} {flag} {session_id} --dangerously-skip-permissions");
if let Some(sp_path) = system_prompt_path {
let _ = write!(cmd, " --append-system-prompt-file '{sp_path}'");
}
format!("{cmd} < '{prompt_path}'")
}
/// Runtime state of a [`ClaudeHarnessRunner`].
enum ClaudeRunnerState {
/// Runner is built but [`HarnessRunner::start`] has not been called yet.
Preexec,
/// The harness command is running (or has finished).
Running {
conversation_id: AIConversationId,
block_id: BlockId,
},
}
struct ClaudeHarnessRunner {
command: String,
/// The CLI name used to invoke Claude Code.
cli_name: String,
/// Held so the temp file is cleaned up when the runner is dropped.
_temp_prompt_file: NamedTempFile,
/// Held so the system prompt temp file is cleaned up when the runner is dropped.
_temp_system_prompt_file: Option<NamedTempFile>,
client: Arc<dyn HarnessSupportClient>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
state: Mutex<ClaudeRunnerState>,
session_id: Uuid,
working_dir: PathBuf,
parent_bridge: Option<MessageBridge>,
/// Lazily cached output of `claude --version`.
claude_version: Mutex<Option<String>>,
/// When resuming an existing conversation, we pin the runner's server conversation id
/// up front instead of calling `create_external_conversation` in [`HarnessRunner::start`].
/// Subsequent saves overwrite the same GCS objects keyed by this id.
preexisting_conversation_id: Option<AIConversationId>,
}
impl ClaudeHarnessRunner {
#[allow(clippy::too_many_arguments)]
fn new(
cli_command: &str,
prompt: &str,
system_prompt: Option<&str>,
working_dir: &Path,
task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ClaudeResumeInfo>,
) -> Result<Self, AgentDriverError> {
// Write the prompt to a temp file so we can feed it via stdin redirect,
// avoiding shell-quoting issues with complex content (e.g. skill instructions).
let temp_file = write_temp_file("oz_prompt_", prompt)?;
let prompt_path = temp_file.path().display().to_string();
let (session_id, preexisting_conversation_id, resuming) = match resume {
Some(ClaudeResumeInfo {
conversation_id,
session_id,
mut envelope,
}) => {
// Rehydrate the stored envelope under the current working directory so
// `claude --resume <uuid>` finds the jsonl under ~/.claude/projects/<encoded_cwd>/.
// The original envelope's cwd usually points at the cloud sandbox path, which
// doesn't exist locally.
envelope.cwd = working_dir.to_path_buf();
let config_root = claude_config_dir().map_err(|e| {
AgentDriverError::ConfigBuildFailed(
e.context("Failed to resolve Claude config dir"),
)
})?;
write_envelope(&envelope, &config_root).map_err(|e| {
AgentDriverError::ConfigBuildFailed(
e.context("Failed to rehydrate Claude transcript"),
)
})?;
// Index write is best-effort: upstream Claude versions vary in how they use
// `sessions-index.json`, so losing the index entry shouldn't abort the run.
if let Err(e) = write_session_index_entry(session_id, working_dir, &config_root) {
log::warn!("Failed to update Claude sessions-index.json: {e:#}");
}
(session_id, Some(conversation_id), true)
}
None => (Uuid::new_v4(), None, false),
};
let temp_system_prompt_file = system_prompt
.map(|sp| write_temp_file("oz_system_prompt_", sp))
.transpose()?;
let system_prompt_path = temp_system_prompt_file
.as_ref()
.map(|f| f.path().display().to_string());
let parent_bridge = task_id
.map(|task_id| MessageBridge::new(task_id.to_string(), session_id))
.transpose()
.map_err(AgentDriverError::ConfigBuildFailed)?;
let client: Arc<dyn HarnessSupportClient> = server_api.clone();
Ok(Self {
command: claude_command(
cli_command,
&session_id,
&prompt_path,
system_prompt_path.as_deref(),
resuming,
),
cli_name: cli_command.to_string(),
_temp_prompt_file: temp_file,
_temp_system_prompt_file: temp_system_prompt_file,
client,
server_api,
terminal_driver,
state: Mutex::new(ClaudeRunnerState::Preexec),
session_id,
working_dir: working_dir.to_path_buf(),
parent_bridge,
claude_version: Mutex::new(None),
preexisting_conversation_id,
})
}
}
impl ClaudeHarnessRunner {
async fn handle_parent_bridge_session_update(&self) -> Result<()> {
let Some(parent_bridge) = self.parent_bridge.as_ref() else {
return Ok(());
};
parent_bridge
.handle_session_update(self.server_api.clone())
.await
}
async fn flush_parent_bridge_acks(&self) -> Result<()> {
let Some(parent_bridge) = self.parent_bridge.as_ref() else {
return Ok(());
};
parent_bridge.flush_acks(self.server_api.clone()).await
}
/// Return the cached Claude Code version, or resolve it by running
/// `<cli_name> --version`.
async fn resolve_claude_version(
&self,
foreground: &ModelSpawner<AgentDriver>,
) -> Option<String> {
if let Some(cached) = self.claude_version.lock().clone() {
return Some(cached);
}
let terminal_driver = self.terminal_driver.clone();
let session = foreground
.spawn(move |_, ctx| {
let tv = terminal_driver.as_ref(ctx).terminal_view().as_ref(ctx);
tv.active_session().as_ref(ctx).session(ctx)
})
.await
.ok()?;
let session = session?;
let cli_name = &self.cli_name;
let output = session
.execute_command(
&format!("{cli_name} --version"),
None,
None,
ExecuteCommandOptions::default(),
)
.await
.ok()?;
let version = output.to_string().ok()?.trim().to_string();
if version.is_empty() {
return None;
}
*self.claude_version.lock() = Some(version.clone());
Some(version)
}
async fn start_parent_bridge(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
let Some(parent_bridge) = self.parent_bridge.as_ref() else {
return Ok(());
};
parent_bridge
.start(foreground, self.server_api.clone())
.await
}
fn cleanup_parent_bridge(&self) -> Result<()> {
if let Some(parent_bridge) = self.parent_bridge.as_ref() {
parent_bridge.cleanup()?;
}
Ok(())
}
}
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl HarnessRunner for ClaudeHarnessRunner {
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<CommandHandle, AgentDriverError> {
// When resuming, we already have a server conversation id from the prior run.
// Otherwise create a fresh external conversation record for this run.
// TODO(REMOTE-1149): `create_external_conversation` currently won't work for local CLI
// runs. We should either support it or have a fallback.
let conversation_id = match self.preexisting_conversation_id {
Some(id) => {
log::info!("Resuming external conversation {id}");
id
}
None => {
let id = self
.client
.create_external_conversation(CLAUDE_CODE_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})?;
log::info!("Created external conversation {id}");
id
}
};
self.start_parent_bridge(foreground)
.await
.map_err(AgentDriverError::ConfigBuildFailed)?;
let command = self.command.clone();
let terminal_driver = self.terminal_driver.clone();
let command_handle = match foreground
.spawn(move |_, ctx| {
terminal_driver.update(ctx, |driver, ctx| driver.execute_command(&command, ctx))
})
.await??
.await
{
Ok(command_handle) => command_handle,
Err(err) => {
self.cleanup_parent_bridge()
.map_err(AgentDriverError::ConfigBuildFailed)?;
return Err(err);
}
};
// Only store conversation info once the CLI command has started.
*self.state.lock() = ClaudeRunnerState::Running {
conversation_id,
block_id: command_handle.block_id().clone(),
};
Ok(command_handle)
}
async fn exit(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
log::info!("Sending /exit to Claude Code CLI");
let terminal_driver = self.terminal_driver.clone();
foreground
.spawn(move |_, ctx| {
terminal_driver.update(ctx, |driver, ctx| {
driver.send_text_to_cli(CLAUDE_EXIT_COMMAND.to_string(), ctx);
});
})
.await
.map_err(|_| anyhow::anyhow!("Agent driver dropped while sending /exit"))
}
async fn handle_session_update(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
self.handle_parent_bridge_session_update().await
}
async fn save_conversation(
&self,
save_point: SavePoint,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
if matches!(save_point, SavePoint::Periodic)
&& !super::has_running_cli_agent(&self.terminal_driver, foreground).await
{
log::debug!("Will not save conversation, Claude Code not in progress");
return Ok(());
}
let (conversation_id, block_id) = match &*self.state.lock() {
ClaudeRunnerState::Preexec => {
log::warn!("save_conversation called before start");
return Ok(());
}
ClaudeRunnerState::Running {
conversation_id,
block_id,
} => (*conversation_id, block_id.clone()),
};
let claude_version = self.resolve_claude_version(foreground).await;
let client = self.client.as_ref();
let session_id = self.session_id;
let working_dir = &self.working_dir;
futures::try_join!(
super::upload_current_block_snapshot(
foreground,
&self.terminal_driver,
client,
conversation_id,
block_id,
),
upload_transcript(
client,
conversation_id,
session_id,
working_dir,
claude_version
),
)?;
Ok(())
}
async fn cleanup(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
self.flush_parent_bridge_acks().await?;
self.cleanup_parent_bridge()
}
}
/// Upload the Claude Code session transcript to the server.
async fn upload_transcript(
client: &dyn HarnessSupportClient,
conversation_id: AIConversationId,
session_id: Uuid,
working_dir: &Path,
claude_version: Option<String>,
) -> Result<()> {
log::info!("Uploading Claude Code transcript to conversation {conversation_id}");
let config_dir = claude_config_dir().context("Failed to resolve Claude config dir")?;
let working_dir = working_dir.to_path_buf();
let body = tokio::task::spawn_blocking(move || {
let mut envelope = read_envelope(session_id, &working_dir, &config_dir)
.with_context(|| format!("Failed to read transcript for session {session_id}"))?;
envelope.claude_version = claude_version;
serde_json::to_vec(&envelope).context("Failed to serialize transcript envelope")
})
.await
.context("read_envelope task panicked")??;
let target = client
.get_transcript_upload_target(&conversation_id)
.await
.with_context(|| format!("Failed to get transcript upload target for {conversation_id}"))?;
upload_to_target(client.http_client(), &target, body).await
}
fn prepare_claude_environment_config(
working_dir: &Path,
secrets: &HashMap<String, ManagedSecretValue>,
) -> Result<()> {
let home_dir =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
let claude_json_path = home_dir.join(CLAUDE_JSON_FILE_NAME);
let claude_settings_path = claude_config_dir()?.join(CLAUDE_SETTINGS_FILE_NAME);
let api_key_suffix = resolve_anthropic_api_key_suffix(secrets);
prepare_claude_config(&claude_json_path, working_dir, api_key_suffix.as_deref())?;
prepare_claude_settings(&claude_settings_path)?;
Ok(())
}
fn prepare_claude_config(
claude_json_path: &Path,
working_dir: &Path,
api_key_suffix: Option<&str>,
) -> Result<()> {
let mut claude_config: ClaudeConfig = read_json_file_or_default(claude_json_path)?;
claude_config.has_completed_onboarding = true;
claude_config.lsp_recommendation_disabled = true;
claude_config
.projects
.entry(working_dir.to_string_lossy().into_owned())
.or_default()
.has_trust_dialog_accepted = true;
if let Some(suffix) = api_key_suffix {
let responses = claude_config
.custom_api_key_responses
.get_or_insert_with(CustomApiKeyResponses::default);
if !responses.approved.iter().any(|s| s == suffix) {
responses.approved.push(suffix.to_owned());
}
}
write_json_file(
claude_json_path,
&claude_config,
"Failed to serialize Claude config",
)?;
Ok(())
}
fn prepare_claude_settings(claude_settings_path: &Path) -> Result<()> {
let mut settings: ClaudeSettings = read_json_file_or_default(claude_settings_path)?;
settings.skip_dangerous_mode_permission_prompt = true;
write_json_file(
claude_settings_path,
&settings,
"Failed to serialize Claude settings",
)?;
Ok(())
}
const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
const CLAUDE_JSON_FILE_NAME: &str = ".claude.json";
const CLAUDE_SETTINGS_FILE_NAME: &str = "settings.json";
const ANTHROPIC_API_KEY_SUFFIX_LEN: usize = 20;
#[derive(Default, Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct ClaudeConfig {
#[serde(default)]
has_completed_onboarding: bool,
#[serde(default)]
lsp_recommendation_disabled: bool,
#[serde(default)]
projects: HashMap<String, ClaudeProjectConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
custom_api_key_responses: Option<CustomApiKeyResponses>,
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Default, Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct CustomApiKeyResponses {
#[serde(default)]
approved: Vec<String>,
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Default, Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct ClaudeProjectConfig {
#[serde(default)]
has_trust_dialog_accepted: bool,
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Default, Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
struct ClaudeSettings {
#[serde(default)]
skip_dangerous_mode_permission_prompt: bool,
#[serde(flatten)]
extra: Map<String, Value>,
}
/// Try to get the last 20 chars of the ANTHROPIC_API_KEY from the secrets map,
/// where 20 chars is the suffix length that Claude Code truncates keys to.
/// Falls back to the environment variable.
fn resolve_anthropic_api_key_suffix(
secrets: &HashMap<String, ManagedSecretValue>,
) -> Option<String> {
// First, check for an AnthropicApiKey variant anywhere in the secrets map,
// since the secret name doesn't necessarily match the env var.
for secret in secrets.values() {
if let ManagedSecretValue::AnthropicApiKey { api_key } = secret {
return suffix_of(api_key).map(str::to_owned);
}
}
// Then check for a RawValue stored under the env var name.
if let Some(ManagedSecretValue::RawValue { value }) = secrets.get(ANTHROPIC_API_KEY_ENV) {
return suffix_of(value).map(str::to_owned);
}
// Fall back to the environment variable, which a user may have set separately in the env.
std::env::var(ANTHROPIC_API_KEY_ENV)
.ok()
.and_then(|k| suffix_of(&k).map(str::to_owned))
}
fn suffix_of(key: &str) -> Option<&str> {
if key.len() >= ANTHROPIC_API_KEY_SUFFIX_LEN {
key.get(key.len() - ANTHROPIC_API_KEY_SUFFIX_LEN..)
} else {
None
}
}
#[cfg(test)]
#[path = "claude_code_tests.rs"]
mod tests;
@@ -0,0 +1,621 @@
//! Bridges durable lead-agent messages into Claude Code's hook-driven
//! next-turn context.
//!
//! The bridge uses an on-disk three-stage state machine inside the per-session
//! state directory:
//! - `staged/` holds newly observed message IDs from the event stream.
//! - `surfaced/` holds the fully hydrated records currently exposed to Claude.
//! - `pending-hook-output.json` plus `pending-hook-output.ack` coordinates the
//! handoff between Warp's driver and the Claude hook process.
use std::fmt::Write as _;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use tokio::sync::Mutex as AsyncMutex;
use uuid::Uuid;
use warpui::r#async::SpawnedFutureHandle;
use warpui::ModelSpawner;
use crate::ai::agent_events::{
run_agent_event_driver, AgentEventConsumer, AgentEventConsumerControlFlow,
AgentEventDriverConfig, MessageHydrator, ServerApiAgentEventSource,
};
use crate::ai::agent_sdk::driver::{AgentDriver, OZ_MESSAGE_LISTENER_STATE_ROOT_ENV};
use crate::server::server_api::ai::AgentRunEvent;
use crate::server::server_api::ServerApi;
const LEGACY_MESSAGE_LISTENER_STATE_ROOT_ENV: &str = "OZ_PARENT_STATE_ROOT";
const PARENT_BRIDGE_DEFAULT_STATE_ROOT: &str = ".claude-code/oz-parent-bridge";
const PARENT_BRIDGE_SURFACED_DIR_NAME: &str = "surfaced";
const PARENT_BRIDGE_HOOK_OUTPUT_FILE_NAME: &str = "pending-hook-output.json";
const PARENT_BRIDGE_HOOK_OUTPUT_ACK_FILE_NAME: &str = "pending-hook-output.ack";
const PARENT_BRIDGE_MAX_CONTEXT_CHARS_ENV: &str = "OZ_PARENT_MAX_CONTEXT_CHARS";
const PARENT_BRIDGE_DEFAULT_MAX_CONTEXT_CHARS: usize = 6000;
pub(super) const MESSAGE_BRIDGE_CONTEXT_PREAMBLE: &str = "Lead-agent updates arrived from Oz. Treat the latest lead-agent instructions below as authoritative.\n";
const PARENT_BRIDGE_REMAINING_MESSAGES_NOTE: &str =
"\n\nMore lead-agent messages are still staged and will be surfaced on a later turn.";
pub(super) struct MessageBridge {
run_id: String,
state_dir: PathBuf,
runtime: Mutex<Option<MessageBridgeRuntime>>,
state_lock: AsyncMutex<()>,
}
struct MessageBridgeRuntime {
task: SpawnedFutureHandle,
}
struct MessageBridgeEventConsumer {
run_id: String,
state_dir: PathBuf,
}
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
#[cfg_attr(not(target_family = "wasm"), async_trait)]
impl AgentEventConsumer for MessageBridgeEventConsumer {
async fn on_event(
&mut self,
event: AgentRunEvent,
) -> anyhow::Result<AgentEventConsumerControlFlow> {
if event.event_type != "new_message" || event.run_id != self.run_id {
return Ok(AgentEventConsumerControlFlow::Continue);
}
let Some(message_id) = event.ref_id else {
return Ok(AgentEventConsumerControlFlow::Continue);
};
if let Err(err) = stage_parent_bridge_message(
&self.state_dir,
&MessageBridgeMessageRecord {
sequence: event.sequence,
message_id: message_id.clone(),
sender_run_id: String::new(),
subject: String::new(),
body: String::new(),
occurred_at: event.occurred_at,
},
) {
log::warn!(
"Failed to stage Claude lead-agent message {message_id} at sequence {}: {err:#}",
event.sequence
);
}
Ok(AgentEventConsumerControlFlow::Continue)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(super) struct MessageBridgeMessageRecord {
pub sequence: i64,
pub message_id: String,
#[serde(default)]
pub sender_run_id: String,
#[serde(default)]
pub subject: String,
#[serde(default)]
pub body: String,
pub occurred_at: String,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(super) struct MessageBridgeHookOutput {
pub additional_context: String,
pub remaining_staged_count: usize,
pub surfaced_count: usize,
}
struct RenderedMessageBridgeMessage {
block: String,
block_chars: usize,
}
struct SelectedMessageBridgeMessage {
path: PathBuf,
record: MessageBridgeMessageRecord,
rendered: RenderedMessageBridgeMessage,
}
struct SelectedMessageBridgeMessages {
messages: Vec<SelectedMessageBridgeMessage>,
context_chars: usize,
total_available_count: usize,
}
impl MessageBridge {
pub(super) fn new(run_id: String, session_id: Uuid) -> Result<Self> {
Ok(Self {
run_id,
state_dir: parent_bridge_root()?.join(session_id.to_string()),
runtime: Mutex::new(None),
state_lock: AsyncMutex::new(()),
})
}
pub(super) async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
server_api: Arc<ServerApi>,
) -> Result<()> {
if self.runtime.lock().is_some() {
return Ok(());
}
ensure_parent_bridge_state_dir(&self.state_dir)?;
let run_id = self.run_id.clone();
let state_dir = self.state_dir.clone();
let task = foreground
.spawn(move |_, ctx| {
ctx.spawn(
async move {
if let Err(err) =
run_parent_bridge_forever(server_api, run_id, state_dir.clone()).await
{
log::warn!(
"Claude message bridge stopped for {}: {err:#}",
state_dir.display()
);
}
},
|_, _, _| {},
)
})
.await
.map_err(|_| anyhow!("Agent driver dropped while starting Claude message bridge"))?;
*self.runtime.lock() = Some(MessageBridgeRuntime { task });
Ok(())
}
pub(super) async fn handle_session_update(&self, server_api: Arc<ServerApi>) -> Result<()> {
if !self.state_dir.exists() {
return Ok(());
}
let hydrator = MessageHydrator::new(server_api);
let _guard = self.state_lock.lock().await;
acknowledge_parent_bridge_hook_output(&hydrator, &self.state_dir).await?;
prepare_parent_bridge_hook_output(
&hydrator,
&self.state_dir,
parent_bridge_max_context_chars(),
)
.await
}
pub(super) async fn flush_acks(&self, server_api: Arc<ServerApi>) -> Result<()> {
if !self.state_dir.exists() {
return Ok(());
}
let hydrator = MessageHydrator::new(server_api);
let _guard = self.state_lock.lock().await;
acknowledge_parent_bridge_hook_output(&hydrator, &self.state_dir).await
}
pub(super) fn cleanup(&self) -> Result<()> {
if let Some(runtime) = self.runtime.lock().take() {
runtime.task.abort();
}
match fs::remove_dir_all(&self.state_dir) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(anyhow::Error::from(err).context(format!(
"Failed to remove Claude message bridge state dir {}",
self.state_dir.display()
)));
}
}
Ok(())
}
}
pub(super) fn parent_bridge_root() -> Result<PathBuf> {
for env_name in [
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
LEGACY_MESSAGE_LISTENER_STATE_ROOT_ENV,
] {
if let Ok(dir) = std::env::var(env_name) {
if !dir.is_empty() {
return Ok(PathBuf::from(dir));
}
}
}
dirs::home_dir()
.map(|home| home.join(PARENT_BRIDGE_DEFAULT_STATE_ROOT))
.ok_or_else(|| anyhow!("could not determine home directory"))
}
fn parent_bridge_staged_dir(state_dir: &Path) -> PathBuf {
state_dir.join("staged")
}
fn parent_bridge_surfaced_dir(state_dir: &Path) -> PathBuf {
state_dir.join(PARENT_BRIDGE_SURFACED_DIR_NAME)
}
pub(super) fn parent_bridge_hook_output_file(state_dir: &Path) -> PathBuf {
state_dir.join(PARENT_BRIDGE_HOOK_OUTPUT_FILE_NAME)
}
pub(super) fn parent_bridge_hook_output_ack_file(state_dir: &Path) -> PathBuf {
state_dir.join(PARENT_BRIDGE_HOOK_OUTPUT_ACK_FILE_NAME)
}
fn parent_bridge_message_path(dir: &Path, sequence: i64, message_id: &str) -> PathBuf {
dir.join(format!("{sequence:020}-{message_id}.json"))
}
pub(super) fn parent_bridge_staged_message_path(
state_dir: &Path,
sequence: i64,
message_id: &str,
) -> PathBuf {
parent_bridge_message_path(&parent_bridge_staged_dir(state_dir), sequence, message_id)
}
pub(super) fn parent_bridge_surfaced_message_path(
state_dir: &Path,
sequence: i64,
message_id: &str,
) -> PathBuf {
parent_bridge_message_path(&parent_bridge_surfaced_dir(state_dir), sequence, message_id)
}
pub(super) fn ensure_parent_bridge_state_dir(state_dir: &Path) -> Result<()> {
fs::create_dir_all(parent_bridge_staged_dir(state_dir))
.with_context(|| format!("Failed to create {}", state_dir.display()))?;
fs::create_dir_all(parent_bridge_surfaced_dir(state_dir))
.with_context(|| format!("Failed to create {}", state_dir.display()))?;
Ok(())
}
pub(super) fn stage_parent_bridge_message(
state_dir: &Path,
record: &MessageBridgeMessageRecord,
) -> Result<()> {
let target = parent_bridge_staged_message_path(state_dir, record.sequence, &record.message_id);
if !target.exists() {
write_parent_bridge_json_atomically(&target, record)?;
}
Ok(())
}
fn parent_bridge_max_context_chars() -> usize {
std::env::var(PARENT_BRIDGE_MAX_CONTEXT_CHARS_ENV)
.ok()
.and_then(|value| value.trim().parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or(PARENT_BRIDGE_DEFAULT_MAX_CONTEXT_CHARS)
}
fn parent_bridge_sorted_message_paths(dir: &Path) -> Result<Vec<PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut paths = fs::read_dir(dir)
.with_context(|| format!("Failed to read {}", dir.display()))?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
.collect::<Vec<_>>();
paths.sort();
Ok(paths)
}
fn parent_bridge_message_records(dir: &Path) -> Result<Vec<(PathBuf, MessageBridgeMessageRecord)>> {
parent_bridge_sorted_message_paths(dir)?
.into_iter()
.map(|path| {
let record = serde_json::from_slice::<MessageBridgeMessageRecord>(
&fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?,
)
.with_context(|| format!("Failed to parse {}", path.display()))?;
Ok((path, record))
})
.collect()
}
pub(super) fn parent_bridge_char_count(text: &str) -> usize {
text.chars().count()
}
fn parent_bridge_truncate_chars(text: &str, limit: usize) -> String {
text.chars().take(limit).collect()
}
pub(super) fn render_parent_bridge_message_block(record: &MessageBridgeMessageRecord) -> String {
let subject = if record.subject.is_empty() {
"(no subject)"
} else {
record.subject.as_str()
};
let mut block = String::from("---\nLead-agent message");
if record.sequence != 0 {
let _ = write!(block, " #{}", record.sequence);
}
if !record.sender_run_id.is_empty() {
let _ = write!(block, " from {}", record.sender_run_id);
}
let _ = write!(block, "\nSubject: {subject}\n\n{}", record.body);
block
}
fn render_parent_bridge_message(
record: &MessageBridgeMessageRecord,
) -> RenderedMessageBridgeMessage {
let block = render_parent_bridge_message_block(record);
let block_chars = parent_bridge_char_count(&block);
RenderedMessageBridgeMessage { block, block_chars }
}
fn truncate_parent_bridge_message(rendered: &mut RenderedMessageBridgeMessage, max_chars: usize) {
if rendered.block_chars <= max_chars || max_chars <= 3 {
return;
}
rendered.block = parent_bridge_truncate_chars(&rendered.block, max_chars - 3);
rendered.block.push_str("...");
rendered.block_chars = parent_bridge_char_count(&rendered.block);
}
fn build_parent_bridge_hook_output(
selected: &SelectedMessageBridgeMessages,
max_context_chars: usize,
) -> Option<MessageBridgeHookOutput> {
if selected.messages.is_empty() {
return None;
}
let mut additional_context = String::from(MESSAGE_BRIDGE_CONTEXT_PREAMBLE);
for (index, message) in selected.messages.iter().enumerate() {
if index > 0 {
additional_context.push_str("\n\n");
}
additional_context.push_str(&message.rendered.block);
}
let remaining_staged_count = selected
.total_available_count
.saturating_sub(selected.messages.len());
let remaining_note_chars = parent_bridge_char_count(PARENT_BRIDGE_REMAINING_MESSAGES_NOTE);
if remaining_staged_count > 0
&& selected.context_chars + remaining_note_chars <= max_context_chars
{
additional_context.push_str(PARENT_BRIDGE_REMAINING_MESSAGES_NOTE);
}
Some(MessageBridgeHookOutput {
additional_context,
remaining_staged_count,
surfaced_count: selected.messages.len(),
})
}
fn write_parent_bridge_hook_output(
state_dir: &Path,
output: &MessageBridgeHookOutput,
) -> Result<()> {
let path = parent_bridge_hook_output_file(state_dir);
write_parent_bridge_json_atomically(&path, output)
}
fn remove_file_if_exists(path: &Path) -> Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => {
Err(anyhow::Error::from(err).context(format!("Failed to remove {}", path.display())))
}
}
}
async fn hydrate_parent_bridge_message_record(
hydrator: &MessageHydrator,
record: &MessageBridgeMessageRecord,
) -> Result<MessageBridgeMessageRecord> {
if !record.sender_run_id.is_empty() {
return Ok(record.clone());
}
let message = hydrator
.read_message_with_timeout(&record.message_id)
.await
.with_context(|| format!("Failed to read lead-agent message {}", record.message_id))?;
Ok(MessageBridgeMessageRecord {
sequence: record.sequence,
message_id: message.message_id,
sender_run_id: message.sender_run_id,
subject: message.subject,
body: message.body,
occurred_at: record.occurred_at.clone(),
})
}
async fn select_parent_bridge_messages_for_hook_output(
hydrator: &MessageHydrator,
records: Vec<(PathBuf, MessageBridgeMessageRecord)>,
max_context_chars: usize,
) -> Result<SelectedMessageBridgeMessages> {
let total_available_count = records.len();
let mut messages = Vec::new();
let mut context_chars = parent_bridge_char_count(MESSAGE_BRIDGE_CONTEXT_PREAMBLE);
for (path, record) in records {
let separator_chars = if messages.is_empty() { 0 } else { 2 };
let remaining = max_context_chars.saturating_sub(context_chars + separator_chars);
if remaining <= 3 && !messages.is_empty() {
break;
}
let record = hydrate_parent_bridge_message_record(hydrator, &record).await?;
let mut rendered = render_parent_bridge_message(&record);
if context_chars + separator_chars + rendered.block_chars > max_context_chars {
if remaining > 3 && rendered.block_chars > remaining {
truncate_parent_bridge_message(&mut rendered, remaining);
} else if !messages.is_empty() {
break;
}
}
context_chars += separator_chars + rendered.block_chars;
messages.push(SelectedMessageBridgeMessage {
path,
record,
rendered,
});
}
Ok(SelectedMessageBridgeMessages {
messages,
context_chars,
total_available_count,
})
}
pub(super) async fn prepare_parent_bridge_hook_output(
hydrator: &MessageHydrator,
state_dir: &Path,
max_context_chars: usize,
) -> Result<()> {
let hook_output_path = parent_bridge_hook_output_file(state_dir);
if hook_output_path.exists() {
return Ok(());
}
let surfaced_records = parent_bridge_message_records(&parent_bridge_surfaced_dir(state_dir))?;
if !surfaced_records.is_empty() {
let selected = select_parent_bridge_messages_for_hook_output(
hydrator,
surfaced_records,
max_context_chars,
)
.await?;
for message in &selected.messages {
write_parent_bridge_json_atomically(&message.path, &message.record)?;
}
if let Some(output) = build_parent_bridge_hook_output(&selected, max_context_chars) {
write_parent_bridge_hook_output(state_dir, &output)?;
}
return Ok(());
}
let staged_records = parent_bridge_message_records(&parent_bridge_staged_dir(state_dir))?;
if staged_records.is_empty() {
return Ok(());
}
let selected =
select_parent_bridge_messages_for_hook_output(hydrator, staged_records, max_context_chars)
.await?;
let Some(output) = build_parent_bridge_hook_output(&selected, max_context_chars) else {
return Ok(());
};
for message in &selected.messages {
let target = parent_bridge_surfaced_message_path(
state_dir,
message.record.sequence,
&message.record.message_id,
);
fs::rename(&message.path, &target).with_context(|| {
format!(
"Failed to move message bridge record {} to {}",
message.path.display(),
target.display()
)
})?;
write_parent_bridge_json_atomically(&target, &message.record)?;
}
remove_file_if_exists(&parent_bridge_hook_output_ack_file(state_dir))?;
write_parent_bridge_hook_output(state_dir, &output)
}
pub(super) async fn acknowledge_parent_bridge_hook_output(
hydrator: &MessageHydrator,
state_dir: &Path,
) -> Result<()> {
let ack_path = parent_bridge_hook_output_ack_file(state_dir);
if !ack_path.exists() {
return Ok(());
}
// Remove the hook output first so an acknowledged block cannot be re-emitted
// if the harness restarts while delivery cleanup is still in progress.
remove_file_if_exists(&parent_bridge_hook_output_file(state_dir))?;
let surfaced_records = parent_bridge_message_records(&parent_bridge_surfaced_dir(state_dir))?;
let message_ids = surfaced_records
.iter()
.map(|(_, record)| record.message_id.clone())
.collect::<Vec<_>>();
let delivery_failures = hydrator
.mark_messages_delivered_best_effort(message_ids.iter().map(String::as_str))
.await;
for (message_id, err) in delivery_failures {
log::warn!(
"Failed to mark Claude message bridge message {message_id} as delivered: {err:#}"
);
}
for (path, _) in surfaced_records {
remove_file_if_exists(&path)?;
}
remove_file_if_exists(&ack_path)
}
async fn run_parent_bridge_forever(
server_api: Arc<ServerApi>,
run_id: String,
state_dir: PathBuf,
) -> Result<()> {
ensure_parent_bridge_state_dir(&state_dir)?;
// The shared driver keeps `since_sequence` in memory across its own retry
// loop, which is all this per-session bridge needs because the state dir is
// not reused across sessions.
let config = AgentEventDriverConfig::retry_forever(vec![run_id.clone()], 0);
let source = ServerApiAgentEventSource::new(server_api);
let mut consumer = MessageBridgeEventConsumer { run_id, state_dir };
run_agent_event_driver(source, config, &mut consumer).await
}
fn write_parent_bridge_json_atomically<T: Serialize>(path: &Path, value: &T) -> Result<()> {
write_parent_bridge_bytes_atomically(path, &serde_json::to_vec(value)?)
}
fn write_parent_bridge_bytes_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
let Some(parent) = path.parent() else {
return Err(anyhow!("{} has no parent directory", path.display()));
};
fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
let prefix = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("parent-bridge");
let mut temp_file = NamedTempFile::new_in(parent)
.with_context(|| format!("Failed to create temp file for {}", path.display()))?;
temp_file
.write_all(bytes)
.with_context(|| format!("Failed to write temp file for {}", path.display()))?;
temp_file
.flush()
.with_context(|| format!("Failed to flush temp file for {}", path.display()))?;
temp_file
.persist(path)
.map(|_| ())
.map_err(|err| {
anyhow::Error::from(err.error).context(format!("Failed to write {}", path.display()))
})
.with_context(|| format!("Failed to persist temporary {prefix} file"))?;
Ok(())
}
@@ -0,0 +1,590 @@
use mockall::predicate::eq;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use tempfile::TempDir;
use uuid::Uuid;
use super::*;
use crate::ai::agent_events::MessageHydrator;
use crate::server::server_api::ai::{MockAIClient, ReadAgentMessageResponse};
fn sample_parent_bridge_message(
sequence: i64,
message_id: &str,
subject: &str,
body: &str,
) -> MessageBridgeMessageRecord {
MessageBridgeMessageRecord {
sequence,
message_id: message_id.to_string(),
sender_run_id: "parent-run-456".to_string(),
subject: subject.to_string(),
body: body.to_string(),
occurred_at: "2026-04-17T15:46:00Z".to_string(),
}
}
fn sample_staged_parent_bridge_message(
sequence: i64,
message_id: &str,
) -> MessageBridgeMessageRecord {
MessageBridgeMessageRecord {
sequence,
message_id: message_id.to_string(),
sender_run_id: String::new(),
subject: String::new(),
body: String::new(),
occurred_at: "2026-04-17T15:46:00Z".to_string(),
}
}
fn write_surfaced_parent_bridge_message(state_dir: &Path, record: &MessageBridgeMessageRecord) {
fs::write(
parent_bridge_surfaced_message_path(state_dir, record.sequence, &record.message_id),
serde_json::to_vec(record).unwrap(),
)
.unwrap();
}
#[test]
fn claude_command_uses_session_id_when_not_resuming() {
let uuid = Uuid::new_v4();
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, false);
assert!(
cmd.contains(&format!("--session-id {uuid}")),
"expected --session-id flag in non-resume command, got: {cmd}"
);
assert!(
!cmd.contains("--resume"),
"non-resume command should not contain --resume, got: {cmd}"
);
}
#[test]
fn claude_command_uses_resume_flag_when_resuming() {
let uuid = Uuid::new_v4();
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, true);
assert!(
cmd.contains(&format!("--resume {uuid}")),
"expected --resume flag in resume command, got: {cmd}"
);
assert!(
!cmd.contains("--session-id"),
"resume command should not contain --session-id, got: {cmd}"
);
}
#[test]
fn claude_command_pipes_prompt_path() {
let uuid = Uuid::new_v4();
let cmd = claude_command("claude", &uuid, "/tmp/prompt with spaces.txt", None, true);
assert!(
cmd.contains("< '/tmp/prompt with spaces.txt'"),
"expected single-quoted stdin redirect of the prompt path, got: {cmd}"
);
assert!(
cmd.contains("--dangerously-skip-permissions"),
"expected --dangerously-skip-permissions, got: {cmd}"
);
}
#[test]
#[serial_test::serial]
fn parent_bridge_root_prefers_environment_override() {
let tmp = TempDir::new().unwrap();
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, tmp.path());
let root = parent_bridge_root().unwrap();
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
assert_eq!(root, tmp.path());
}
#[test]
fn stage_parent_bridge_message_writes_message_record() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
let record = sample_staged_parent_bridge_message(42, "msg-123");
stage_parent_bridge_message(&state_dir, &record).unwrap();
let staged_path = parent_bridge_staged_message_path(&state_dir, 42, "msg-123");
let staged_record: MessageBridgeMessageRecord =
serde_json::from_slice(&fs::read(&staged_path).unwrap()).unwrap();
assert_eq!(staged_record.sequence, 42);
assert_eq!(staged_record.message_id, "msg-123");
assert!(staged_record.sender_run_id.is_empty());
}
#[tokio::test]
async fn prepare_parent_bridge_hook_output_moves_selected_messages_to_surfaced_dir() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
let first = sample_parent_bridge_message(
42,
"msg-123",
"Please pivot",
"Inspect the failing tests first.",
);
stage_parent_bridge_message(
&state_dir,
&sample_staged_parent_bridge_message(42, "msg-123"),
)
.unwrap();
stage_parent_bridge_message(
&state_dir,
&sample_staged_parent_bridge_message(43, "msg-456"),
)
.unwrap();
let mut ai_client = MockAIClient::new();
let expected_first = first.clone();
ai_client
.expect_read_agent_message()
.with(eq("msg-123"))
.times(1)
.returning(move |_| {
Ok(ReadAgentMessageResponse {
message_id: expected_first.message_id.clone(),
sender_run_id: expected_first.sender_run_id.clone(),
subject: expected_first.subject.clone(),
body: expected_first.body.clone(),
sent_at: "2026-04-17T15:46:00Z".to_string(),
delivered_at: None,
read_at: Some("2026-04-17T15:46:02Z".to_string()),
})
});
let hydrator = MessageHydrator::new(
Arc::new(ai_client) as Arc<dyn crate::server::server_api::ai::AIClient>
);
let max_context_chars = parent_bridge_char_count(MESSAGE_BRIDGE_CONTEXT_PREAMBLE)
+ parent_bridge_char_count(&render_parent_bridge_message_block(&first));
prepare_parent_bridge_hook_output(&hydrator, &state_dir, max_context_chars)
.await
.unwrap();
let hook_output: MessageBridgeHookOutput =
serde_json::from_slice(&fs::read(parent_bridge_hook_output_file(&state_dir)).unwrap())
.unwrap();
assert_eq!(hook_output.surfaced_count, 1);
assert_eq!(hook_output.remaining_staged_count, 1);
assert!(hook_output.additional_context.contains("Please pivot"));
assert!(!hook_output.additional_context.contains("Second update"));
let surfaced_path = parent_bridge_surfaced_message_path(&state_dir, 42, "msg-123");
assert!(surfaced_path.exists());
assert!(parent_bridge_staged_message_path(&state_dir, 43, "msg-456").exists());
assert!(!parent_bridge_staged_message_path(&state_dir, 42, "msg-123").exists());
let surfaced_record: MessageBridgeMessageRecord =
serde_json::from_slice(&fs::read(&surfaced_path).unwrap()).unwrap();
assert_eq!(surfaced_record.subject, first.subject);
assert_eq!(surfaced_record.body, first.body);
}
#[tokio::test]
async fn acknowledge_parent_bridge_hook_output_marks_messages_delivered_and_clears_state() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
let record = sample_parent_bridge_message(
42,
"msg-123",
"Please pivot",
"Inspect the failing tests first.",
);
write_surfaced_parent_bridge_message(&state_dir, &record);
fs::write(
parent_bridge_hook_output_file(&state_dir),
serde_json::to_vec(&MessageBridgeHookOutput {
additional_context: "context".to_string(),
remaining_staged_count: 0,
surfaced_count: 1,
})
.unwrap(),
)
.unwrap();
fs::write(parent_bridge_hook_output_ack_file(&state_dir), "").unwrap();
let mut ai_client = MockAIClient::new();
ai_client
.expect_mark_message_delivered()
.with(eq("msg-123"))
.times(1)
.returning(|_| Ok(()));
let hydrator = MessageHydrator::new(
Arc::new(ai_client) as Arc<dyn crate::server::server_api::ai::AIClient>
);
acknowledge_parent_bridge_hook_output(&hydrator, &state_dir)
.await
.unwrap();
assert!(!parent_bridge_surfaced_message_path(&state_dir, 42, "msg-123").exists());
assert!(!parent_bridge_hook_output_file(&state_dir).exists());
assert!(!parent_bridge_hook_output_ack_file(&state_dir).exists());
}
#[tokio::test]
async fn prepare_parent_bridge_hook_output_reuses_surfaced_records_without_rehydrating() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
let record = sample_parent_bridge_message(
42,
"msg-123",
"Please pivot",
"Inspect the failing tests first.",
);
write_surfaced_parent_bridge_message(&state_dir, &record);
let mut ai_client = MockAIClient::new();
ai_client.expect_read_agent_message().times(0);
let hydrator = MessageHydrator::new(
Arc::new(ai_client) as Arc<dyn crate::server::server_api::ai::AIClient>
);
let max_context_chars = parent_bridge_char_count(MESSAGE_BRIDGE_CONTEXT_PREAMBLE)
+ parent_bridge_char_count(&render_parent_bridge_message_block(&record));
prepare_parent_bridge_hook_output(&hydrator, &state_dir, max_context_chars)
.await
.unwrap();
let hook_output: MessageBridgeHookOutput =
serde_json::from_slice(&fs::read(parent_bridge_hook_output_file(&state_dir)).unwrap())
.unwrap();
assert_eq!(hook_output.surfaced_count, 1);
assert_eq!(hook_output.remaining_staged_count, 0);
assert!(hook_output.additional_context.contains(&record.subject));
}
#[tokio::test]
async fn prepare_parent_bridge_hook_output_truncates_single_large_message() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
stage_parent_bridge_message(
&state_dir,
&sample_staged_parent_bridge_message(42, "msg-123"),
)
.unwrap();
let long_body = "x".repeat(200);
let mut ai_client = MockAIClient::new();
ai_client
.expect_read_agent_message()
.with(eq("msg-123"))
.times(1)
.returning(move |_| {
Ok(ReadAgentMessageResponse {
message_id: "msg-123".to_string(),
sender_run_id: "parent-run-456".to_string(),
subject: "Please pivot".to_string(),
body: long_body.clone(),
sent_at: "2026-04-17T15:46:00Z".to_string(),
delivered_at: None,
read_at: Some("2026-04-17T15:46:02Z".to_string()),
})
});
let hydrator = MessageHydrator::new(
Arc::new(ai_client) as Arc<dyn crate::server::server_api::ai::AIClient>
);
let max_context_chars = parent_bridge_char_count(MESSAGE_BRIDGE_CONTEXT_PREAMBLE) + 48;
prepare_parent_bridge_hook_output(&hydrator, &state_dir, max_context_chars)
.await
.unwrap();
let hook_output: MessageBridgeHookOutput =
serde_json::from_slice(&fs::read(parent_bridge_hook_output_file(&state_dir)).unwrap())
.unwrap();
assert_eq!(hook_output.surfaced_count, 1);
assert!(
hook_output.additional_context.ends_with("..."),
"expected truncated context, got: {}",
hook_output.additional_context
);
assert!(parent_bridge_char_count(&hook_output.additional_context) <= max_context_chars);
}
#[tokio::test]
async fn acknowledge_parent_bridge_hook_output_ignores_missing_ack_marker() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
let record = sample_parent_bridge_message(
42,
"msg-123",
"Please pivot",
"Inspect the failing tests first.",
);
write_surfaced_parent_bridge_message(&state_dir, &record);
let mut ai_client = MockAIClient::new();
ai_client.expect_mark_message_delivered().times(0);
let hydrator = MessageHydrator::new(
Arc::new(ai_client) as Arc<dyn crate::server::server_api::ai::AIClient>
);
acknowledge_parent_bridge_hook_output(&hydrator, &state_dir)
.await
.unwrap();
assert!(parent_bridge_surfaced_message_path(&state_dir, 42, "msg-123").exists());
}
#[test]
fn prepare_claude_config_creates_config_file_without_api_suffix() {
let tmp = TempDir::new().unwrap();
let claude_json_path = tmp.path().join(".claude.json");
let working_dir = tmp.path().join("workspace/project");
prepare_claude_config(&claude_json_path, &working_dir, None).unwrap();
let claude_config: Value =
serde_json::from_slice(&fs::read(claude_json_path).unwrap()).unwrap();
assert_eq!(claude_config["hasCompletedOnboarding"], Value::Bool(true));
assert_eq!(
claude_config["lspRecommendationDisabled"],
Value::Bool(true)
);
let working_dir_key = working_dir.to_string_lossy().to_string();
assert_eq!(
claude_config["projects"][working_dir_key]["hasTrustDialogAccepted"],
Value::Bool(true)
);
assert_eq!(claude_config.get("customApiKeyResponses"), None);
}
#[test]
fn prepare_claude_config_creates_config_file_with_api_suffix() {
let tmp = TempDir::new().unwrap();
let claude_json_path = tmp.path().join(".claude.json");
let working_dir = tmp.path().join("workspace/project");
prepare_claude_config(
&claude_json_path,
&working_dir,
Some("QLWn-dUnuwQ-hIhDiAAA"),
)
.unwrap();
let claude_config: Value =
serde_json::from_slice(&fs::read(claude_json_path).unwrap()).unwrap();
assert_eq!(
claude_config["customApiKeyResponses"]["approved"],
serde_json::json!(["QLWn-dUnuwQ-hIhDiAAA"]),
);
}
#[test]
fn prepare_claude_config_merges_existing_config() {
let tmp = TempDir::new().unwrap();
let claude_json_path = tmp.path().join(".claude.json");
fs::write(
&claude_json_path,
r#"{"theme":"dark","projects":{"/existing/project":{"allowedTools":["Bash"],"nested":{"value":2}}},"customApiKeyResponses":{"approved":["existing-suffix-12345"]}}"#,
)
.unwrap();
let working_dir = tmp.path().join("workspace/project");
prepare_claude_config(
&claude_json_path,
&working_dir,
Some("new-suffix-1234567890"),
)
.unwrap();
let claude_config: Value =
serde_json::from_slice(&fs::read(claude_json_path).unwrap()).unwrap();
assert_eq!(claude_config["theme"], "dark");
assert_eq!(
claude_config["lspRecommendationDisabled"],
Value::Bool(true)
);
assert_eq!(
claude_config["projects"]["/existing/project"]["allowedTools"],
serde_json::json!(["Bash"])
);
assert_eq!(
claude_config["projects"]["/existing/project"]["nested"]["value"],
2
);
// Both existing and new suffixes should be present.
assert_eq!(
claude_config["customApiKeyResponses"]["approved"],
serde_json::json!(["existing-suffix-12345", "new-suffix-1234567890"]),
);
let working_dir_key = working_dir.to_string_lossy().to_string();
assert_eq!(
claude_config["projects"][working_dir_key]["hasTrustDialogAccepted"],
Value::Bool(true)
);
}
#[test]
fn prepare_claude_config_no_duplicate_suffix() {
let tmp = TempDir::new().unwrap();
let claude_json_path = tmp.path().join(".claude.json");
fs::write(
&claude_json_path,
r#"{"customApiKeyResponses":{"approved":["QLWn-dUnuwQ-hIhDiAAA"]}}"#,
)
.unwrap();
let working_dir = tmp.path().join("workspace/project");
prepare_claude_config(
&claude_json_path,
&working_dir,
Some("QLWn-dUnuwQ-hIhDiAAA"),
)
.unwrap();
let claude_config: Value =
serde_json::from_slice(&fs::read(claude_json_path).unwrap()).unwrap();
assert_eq!(
claude_config["customApiKeyResponses"]["approved"],
serde_json::json!(["QLWn-dUnuwQ-hIhDiAAA"]),
);
}
#[test]
fn prepare_claude_config_none_suffix_preserves_existing_responses() {
let tmp = TempDir::new().unwrap();
let claude_json_path = tmp.path().join(".claude.json");
fs::write(
&claude_json_path,
r#"{"customApiKeyResponses":{"approved":["existing-suffix-12345"],"rejected":["bad-key"]}}"#,
)
.unwrap();
let working_dir = tmp.path().join("workspace/project");
prepare_claude_config(&claude_json_path, &working_dir, None).unwrap();
let claude_config: Value =
serde_json::from_slice(&fs::read(claude_json_path).unwrap()).unwrap();
assert_eq!(
claude_config["customApiKeyResponses"]["approved"],
serde_json::json!(["existing-suffix-12345"]),
);
assert_eq!(
claude_config["customApiKeyResponses"]["rejected"],
serde_json::json!(["bad-key"]),
);
}
#[test]
fn resolve_suffix_from_raw_value_secret() {
let key = "sk-ant-api03-abcdefghij1234567890ABCDEFGHIJ1234567890abcdefghij1234567890QLWn-dUnuwQ-hIhDiAAA";
let secrets = HashMap::from([(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::raw_value(key),
)]);
let suffix = resolve_anthropic_api_key_suffix(&secrets);
assert_eq!(suffix.as_deref(), Some("QLWn-dUnuwQ-hIhDiAAA"));
}
#[test]
fn resolve_suffix_from_anthropic_api_key_secret() {
let key = "sk-ant-api03-abcdefghij1234567890ABCDEFGHIJ1234567890abcdefghij1234567890QLWn-dUnuwQ-hIhDiAAA";
let secrets = HashMap::from([(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::anthropic_api_key(key),
)]);
let suffix = resolve_anthropic_api_key_suffix(&secrets);
assert_eq!(suffix.as_deref(), Some("QLWn-dUnuwQ-hIhDiAAA"));
}
#[test]
fn resolve_suffix_from_anthropic_api_key_with_different_secret_name() {
let key = "sk-ant-api03-abcdefghij1234567890ABCDEFGHIJ1234567890abcdefghij1234567890QLWn-dUnuwQ-hIhDiAAA";
// Secret name doesn't match the env var, but the AnthropicApiKey variant
// should still be found by iterating all secrets.
let secrets = HashMap::from([(
"my-anthropic-key".to_string(),
ManagedSecretValue::anthropic_api_key(key),
)]);
let suffix = resolve_anthropic_api_key_suffix(&secrets);
assert_eq!(suffix.as_deref(), Some("QLWn-dUnuwQ-hIhDiAAA"));
}
#[test]
fn resolve_suffix_prefers_anthropic_api_key_variant_over_raw_value() {
let anthropic_key = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-anthropic-suffix";
let raw_key = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB-raw-suffix";
let secrets = HashMap::from([
(
"my-anthropic-key".to_string(),
ManagedSecretValue::anthropic_api_key(anthropic_key),
),
(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::raw_value(raw_key),
),
]);
let suffix = resolve_anthropic_api_key_suffix(&secrets);
// AnthropicApiKey variant should be preferred.
assert_eq!(suffix.as_deref(), Some("AAA-anthropic-suffix"));
}
#[test]
fn resolve_suffix_returns_none_for_short_key() {
let secrets = HashMap::from([(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::raw_value("short"),
)]);
assert_eq!(resolve_anthropic_api_key_suffix(&secrets), None);
}
#[test]
fn resolve_suffix_returns_none_for_short_anthropic_api_key() {
let secrets = HashMap::from([(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::anthropic_api_key("short"),
)]);
assert_eq!(resolve_anthropic_api_key_suffix(&secrets), None);
}
#[test]
fn prepare_claude_settings_creates_settings_file() {
let tmp = TempDir::new().unwrap();
let claude_settings_path = tmp.path().join(".claude/settings.json");
prepare_claude_settings(&claude_settings_path).unwrap();
let claude_settings: Value =
serde_json::from_slice(&fs::read(claude_settings_path).unwrap()).unwrap();
assert_eq!(
claude_settings["skipDangerousModePermissionPrompt"],
Value::Bool(true)
);
}
#[test]
fn prepare_claude_settings_merges_existing_settings() {
let tmp = TempDir::new().unwrap();
let claude_settings_path = tmp.path().join("settings.json");
fs::write(
&claude_settings_path,
r#"{"editor":"vim","nested":{"value":1}}"#,
)
.unwrap();
prepare_claude_settings(&claude_settings_path).unwrap();
let claude_settings: Value =
serde_json::from_slice(&fs::read(claude_settings_path).unwrap()).unwrap();
assert_eq!(claude_settings["editor"], "vim");
assert_eq!(claude_settings["nested"]["value"], 1);
assert_eq!(
claude_settings["skipDangerousModePermissionPrompt"],
Value::Bool(true)
);
}
@@ -0,0 +1,343 @@
//! Claude Code transcript layout + rehydration helpers.
//!
//! This module owns:
//! - [`ClaudeTranscriptEnvelope`] — the on-wire/on-GCS shape of a saved Claude session
//! (main jsonl entries + subagent jsonl files + per-agent todo JSONs), plus reader/writer
//! functions that interoperate with Claude's own `~/.claude` layout.
//! - [`ClaudeResumeInfo`] — everything the harness runner needs to resume an existing
//! Claude conversation: the Warp server conversation id to reuse, the Claude session uuid
//! to pass to `claude --resume`, and the decoded envelope to rehydrate onto disk.
//! - [`write_session_index_entry`] — best-effort update of `~/.claude/sessions-index.json`
//! so Claude's `--resume <uuid>` lookup can find the freshly-rehydrated jsonl. Upstream
//! versions vary in how they use this index (claude-code#33912, #39667, #5768); we write
//! a conservative entry and log on failure.
//!
//! Split out from `claude_code.rs` so the `AIClient` transcript-fetch impl can deserialize
//! envelopes without pulling in the rest of the harness runner.
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use warp_core::safe_warn;
use crate::ai::agent::conversation::AIConversationId;
/// JSON envelope sent to the server representing a complete Claude Code session.
///
/// Bundles the main session transcript, any subagent transcripts, and
/// per-agent TODO lists assembled from the Claude state directory.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct ClaudeTranscriptEnvelope {
/// The directory that the Claude Code session started in.
pub(crate) cwd: PathBuf,
/// Unique session identifier.
pub(crate) uuid: Uuid,
/// Claude Code version, if available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) claude_version: Option<String>,
/// List of messages in the main agent conversation.
pub(crate) entries: Vec<Value>,
/// Messages in each subagent conversation, keyed by the agent filename (e.g. `"agent-aac0b7f3db6bccfaf"`).
pub(crate) subagents: HashMap<String, Vec<Value>>,
/// TODO lists for each agent, keyed on the session and agent (e.g. `"<session_uuid>-agent-<agent_id>"`).
pub(crate) todos: HashMap<String, Value>,
}
/// Everything needed to resume an existing Claude conversation.
///
/// Populated from a `--conversation` id after the client fetches the stored envelope from
/// the server. Passed into `ClaudeHarnessRunner::new` so the runner reuses the existing
/// session and server conversation ids instead of minting fresh ones.
#[derive(Debug)]
pub(crate) struct ClaudeResumeInfo {
/// The Warp server-side conversation id. The runner stores this instead of calling
/// `create_external_conversation` so subsequent transcript/block-snapshot uploads overwrite
/// the same GCS objects.
pub(crate) conversation_id: AIConversationId,
/// The Claude session uuid to pass to `claude --resume`. Matches `envelope.uuid`.
pub(crate) session_id: Uuid,
/// Envelope from the server. Its `cwd` field is rewritten to the current run's working
/// directory before being written to disk, so `claude --resume <uuid>` finds the jsonl under
/// `~/.claude/projects/<encoded(new_cwd)>/`.
pub(crate) envelope: ClaudeTranscriptEnvelope,
}
/// Encode a filesystem path as a Claude config directory name, matching the
/// Claude CLI convention of replacing every `/` with `-`.
///
/// Example: `/Users/ben/src/foo` → `-Users-ben-src-foo`
pub(crate) fn encode_cwd(cwd: &Path) -> String {
cwd.to_string_lossy().replace(['/', '.'], "-")
}
/// Resolve the Claude config directory.
///
/// Reads `$CLAUDE_CONFIG_DIR` if set, otherwise falls back to `~/.claude`.
//
/// TODO(REMOTE-1209): Use the transcript path reported by our hook.
pub(crate) fn claude_config_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
return Ok(PathBuf::from(dir));
}
dirs::home_dir()
.map(|h| h.join(".claude"))
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
}
/// Assemble a [`ClaudeTranscriptEnvelope`] from the Claude config directory.
///
/// Reads:
/// - `<config_root>/projects/<encoded_cwd>/<session_uuid>.jsonl` - main transcript
/// - `<config_root>/projects/<encoded_cwd>/<session_uuid>/subagents/*.jsonl` - subagents
/// - `<config_root>/todos/<session_uuid>-agent-*.json` - per-agent todo lists
///
/// If the main JSONL does not exist yet (e.g. during an early periodic save)
/// the envelope is returned with an empty `entries` list rather than an error.
pub(crate) fn read_envelope(
session_uuid: Uuid,
cwd: &Path,
config_root: &Path,
) -> Result<ClaudeTranscriptEnvelope> {
let encoded = encode_cwd(cwd);
let projects_dir = config_root.join("projects").join(&encoded);
// Main session transcript.
let session_file = projects_dir.join(format!("{session_uuid}.jsonl"));
let entries = read_jsonl(&session_file)?;
// Subagents are stored in a directory named after the session UUID.
let mut subagents: HashMap<String, Vec<Value>> = HashMap::new();
let subagents_dir = projects_dir
.join(session_uuid.to_string())
.join("subagents");
if subagents_dir.is_dir() {
for entry in std::fs::read_dir(&subagents_dir)
.with_context(|| format!("Failed to read subagents dir {}", subagents_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
subagents.insert(stem.to_owned(), read_jsonl(&path)?);
}
}
// Per-agent todo lists.
let mut todos: HashMap<String, Value> = HashMap::new();
let todos_dir = config_root.join("todos");
let todos_prefix = format!("{session_uuid}-agent-");
if todos_dir.is_dir() {
for entry in std::fs::read_dir(&todos_dir)
.with_context(|| format!("Failed to read todos dir {}", todos_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if !stem.starts_with(&todos_prefix) {
continue;
}
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str(&content) {
Ok(value) => {
todos.insert(stem.to_owned(), value);
}
Err(e) => log::warn!("Failed to parse todos file {}: {e}", path.display()),
},
Err(e) => log::warn!("Failed to read todos file {}: {e}", path.display()),
}
}
}
Ok(ClaudeTranscriptEnvelope {
cwd: cwd.to_path_buf(),
uuid: session_uuid,
claude_version: None,
entries,
subagents,
todos,
})
}
/// Write a [`ClaudeTranscriptEnvelope`] back to disk using the same layout
/// that Claude Code uses.
///
/// Creates:
/// - `<config_root>/projects/<encoded_cwd>/<uuid>.jsonl` - main transcript
/// - `<config_root>/projects/<encoded_cwd>/<uuid>/subagents/<stem>.jsonl` - subagents
/// - `<config_root>/todos/<stem>.json` - per-agent todo lists
pub(crate) fn write_envelope(
envelope: &ClaudeTranscriptEnvelope,
config_root: &Path,
) -> Result<()> {
let encoded = encode_cwd(&envelope.cwd);
let projects_dir = config_root.join("projects").join(&encoded);
std::fs::create_dir_all(&projects_dir)
.with_context(|| format!("Failed to create {}", projects_dir.display()))?;
// Main session JSONL.
let session_file = projects_dir.join(format!("{}.jsonl", envelope.uuid));
std::fs::write(&session_file, entries_to_jsonl(&envelope.entries)?)
.with_context(|| format!("Failed to write {}", session_file.display()))?;
// Subagent JSONLs.
if !envelope.subagents.is_empty() {
let subagents_dir = projects_dir
.join(envelope.uuid.to_string())
.join("subagents");
std::fs::create_dir_all(&subagents_dir)
.with_context(|| format!("Failed to create {}", subagents_dir.display()))?;
for (stem, entries) in &envelope.subagents {
let path = subagents_dir.join(format!("{stem}.jsonl"));
std::fs::write(&path, entries_to_jsonl(entries)?)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
// Per-agent todo lists.
if !envelope.todos.is_empty() {
let todos_dir = config_root.join("todos");
std::fs::create_dir_all(&todos_dir)
.with_context(|| format!("Failed to create {}", todos_dir.display()))?;
for (stem, value) in &envelope.todos {
let path = todos_dir.join(format!("{stem}.json"));
std::fs::write(&path, serde_json::to_vec(value)?)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
Ok(())
}
/// Filename of Claude's global session index.
const SESSIONS_INDEX_FILENAME: &str = "sessions-index.json";
/// Upsert an entry for `session_uuid` into `<config_root>/sessions-index.json` so Claude's
/// `claude --resume <uuid>` lookup can find the rehydrated jsonl.
///
/// Upstream Claude versions vary in how the index is keyed and what fields they read; this
/// writer uses a conservative session-uuid-keyed schema (session id, cwd, jsonl path) that
/// mirrors the fragments documented in claude-code#33912 / #39667 / #5768. Unknown fields are
/// preserved on existing entries, and we never remove other entries.
///
/// Best-effort: callers should log a warning on failure rather than aborting the run — if the
/// index is missing or wrong, `--resume` simply falls back to "No conversation found" and the
/// resumed run surfaces the expected resume-failure error.
pub(crate) fn write_session_index_entry(
session_uuid: Uuid,
cwd: &Path,
config_root: &Path,
) -> Result<()> {
let index_path = config_root.join(SESSIONS_INDEX_FILENAME);
// Read the existing index if present. Missing or malformed files are treated as empty —
// we'd rather clobber an unparseable file than fail the whole resume.
let mut index: serde_json::Map<String, Value> = match std::fs::read_to_string(&index_path) {
Ok(content) => match serde_json::from_str::<Value>(&content) {
Ok(Value::Object(map)) => map,
Ok(_) => {
safe_warn!(
safe: ("sessions-index.json is not a JSON object; overwriting"),
full: ("sessions-index.json at {} is not a JSON object; overwriting", index_path.display())
);
serde_json::Map::new()
}
Err(e) => {
safe_warn!(
safe: ("Failed to parse sessions-index.json; overwriting"),
full: ("Failed to parse sessions-index.json at {}: {e}; overwriting", index_path.display())
);
serde_json::Map::new()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::Map::new(),
Err(e) => {
return Err(
anyhow::Error::from(e).context(format!("Failed to read {}", index_path.display()))
);
}
};
let encoded = encode_cwd(cwd);
let transcript_path = format!("projects/{encoded}/{session_uuid}.jsonl");
let entry = serde_json::json!({
"sessionId": session_uuid.to_string(),
"cwd": cwd.to_string_lossy(),
"projectPath": encoded,
"transcriptPath": transcript_path,
});
index.insert(session_uuid.to_string(), entry);
if let Some(parent) = index_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
std::fs::write(
&index_path,
serde_json::to_vec_pretty(&Value::Object(index))
.context("Failed to serialize sessions-index.json")?,
)
.with_context(|| format!("Failed to write {}", index_path.display()))?;
Ok(())
}
/// Serialize a slice of JSON values as a JSONL byte string (one value per line).
fn entries_to_jsonl(entries: &[Value]) -> Result<Vec<u8>> {
let mut buf = Vec::new();
for entry in entries {
serde_json::to_writer(&mut buf, entry)?;
buf.push(b'\n');
}
Ok(buf)
}
/// Read a JSONL file, returning one parsed [`Value`] per non-blank line.
///
/// Lines that fail to parse as JSON are skipped with a warning rather than
/// causing the entire read to fail. A missing file returns an empty [`Vec`].
pub(crate) fn read_jsonl(path: &Path) -> Result<Vec<Value>> {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(
anyhow::Error::from(e).context(format!("Failed to open {}", path.display()))
);
}
};
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
let line = line.with_context(|| format!("Failed to read line from {}", path.display()))?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str(trimmed) {
Ok(value) => entries.push(value),
Err(e) => {
safe_warn!(
safe: ("Skipping malformed JSONL entry"),
full: ("Skipping malformed JSONL entry in {}: {e}", path.display())
);
}
}
}
Ok(entries)
}
#[cfg(test)]
#[path = "claude_transcript_tests.rs"]
mod tests;
@@ -0,0 +1,246 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use uuid::Uuid;
use super::*;
fn write_file(dir: &Path, name: &str, content: &str) {
fs::write(dir.join(name), content).unwrap();
}
#[test]
fn encode_cwd_replaces_slashes_and_dots() {
assert_eq!(
encode_cwd(Path::new("/Users/ben/src/foo")),
"-Users-ben-src-foo"
);
assert_eq!(encode_cwd(Path::new("/")), "-");
assert_eq!(encode_cwd(Path::new("/a/b")), "-a-b");
assert_eq!(
encode_cwd(Path::new("/Users/ben/.config/foo")),
"-Users-ben--config-foo"
);
assert_eq!(encode_cwd(Path::new("/a.b/c.d")), "-a-b-c-d");
}
#[test]
fn read_envelope_main_only() {
let tmp = TempDir::new().unwrap();
let cwd = Path::new("/my/project");
let uuid = Uuid::new_v4();
let encoded = encode_cwd(cwd);
let projects_dir = tmp.path().join("projects").join(&encoded);
fs::create_dir_all(&projects_dir).unwrap();
write_file(
&projects_dir,
&format!("{uuid}.jsonl"),
"{\"type\":\"user\"}\n{\"type\":\"assistant\"}\n",
);
let envelope = read_envelope(uuid, cwd, tmp.path()).unwrap();
assert_eq!(
envelope.entries,
vec![
serde_json::json!({"type": "user"}),
serde_json::json!({"type": "assistant"}),
]
);
assert_eq!(envelope.uuid, uuid);
assert_eq!(envelope.cwd, cwd);
assert!(envelope.subagents.is_empty());
assert!(envelope.todos.is_empty());
}
#[test]
fn read_envelope_with_subagents() {
let tmp = TempDir::new().unwrap();
let cwd = Path::new("/my/project");
let uuid = Uuid::new_v4();
let encoded = encode_cwd(cwd);
let projects_dir = tmp.path().join("projects").join(&encoded);
fs::create_dir_all(&projects_dir).unwrap();
write_file(&projects_dir, &format!("{uuid}.jsonl"), "");
let subagents_dir = projects_dir.join(uuid.to_string()).join("subagents");
fs::create_dir_all(&subagents_dir).unwrap();
write_file(
&subagents_dir,
"agent-abc123def456.jsonl",
"{\"type\":\"user\"}\n",
);
let envelope = read_envelope(uuid, cwd, tmp.path()).unwrap();
assert_eq!(
envelope.subagents["agent-abc123def456"],
vec![serde_json::json!({"type": "user"})]
);
}
#[test]
fn read_envelope_missing_session_file() {
let tmp = TempDir::new().unwrap();
let cwd = Path::new("/my/project");
let uuid = Uuid::new_v4();
// No files created - should return Ok with empty entries rather than an error.
let envelope = read_envelope(uuid, cwd, tmp.path()).unwrap();
assert!(envelope.entries.is_empty());
assert!(envelope.subagents.is_empty());
assert!(envelope.todos.is_empty());
}
#[test]
fn write_envelope_creates_files() {
let tmp = TempDir::new().unwrap();
let cwd = Path::new("/my/project");
let uuid = Uuid::new_v4();
let todo_stem = format!("{uuid}-agent-{uuid}");
let envelope = ClaudeTranscriptEnvelope {
cwd: cwd.to_path_buf(),
uuid,
claude_version: None,
entries: vec![serde_json::json!({"type": "user"})],
subagents: HashMap::from([(
"agent-abc".to_string(),
vec![serde_json::json!({"type": "assistant"})],
)]),
todos: HashMap::from([(
todo_stem.clone(),
serde_json::json!([{"id": "1", "title": "Do it"}]),
)]),
};
write_envelope(&envelope, tmp.path()).unwrap();
let encoded = encode_cwd(cwd);
let projects_dir = tmp.path().join("projects").join(&encoded);
// Main session JSONL.
let session_file = projects_dir.join(format!("{uuid}.jsonl"));
assert!(session_file.exists(), "session JSONL missing");
assert_eq!(read_jsonl(&session_file).unwrap(), envelope.entries);
// Subagent JSONL.
let subagent_file = projects_dir
.join(uuid.to_string())
.join("subagents")
.join("agent-abc.jsonl");
assert!(subagent_file.exists(), "subagent JSONL missing");
assert_eq!(
read_jsonl(&subagent_file).unwrap(),
envelope.subagents["agent-abc"]
);
// Todo JSON.
let todo_file = tmp.path().join("todos").join(format!("{todo_stem}.json"));
assert!(todo_file.exists(), "todo JSON missing");
let todo_on_disk: serde_json::Value =
serde_json::from_slice(&fs::read(&todo_file).unwrap()).unwrap();
assert_eq!(todo_on_disk, envelope.todos[&todo_stem]);
}
#[test]
fn write_envelope_round_trip() {
let tmp = TempDir::new().unwrap();
let cwd = Path::new("/test/dir");
let uuid = Uuid::new_v4();
let original = ClaudeTranscriptEnvelope {
cwd: cwd.to_path_buf(),
uuid,
claude_version: None,
entries: vec![serde_json::json!({"type": "user"})],
subagents: HashMap::new(),
todos: HashMap::new(),
};
write_envelope(&original, tmp.path()).unwrap();
let decoded = read_envelope(uuid, cwd, tmp.path()).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn write_session_index_entry_creates_missing_file() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
let cwd = Path::new("/my/project");
write_session_index_entry(uuid, cwd, tmp.path()).unwrap();
let index: serde_json::Value =
serde_json::from_slice(&fs::read(tmp.path().join("sessions-index.json")).unwrap()).unwrap();
let entry = &index[uuid.to_string()];
assert_eq!(entry["sessionId"], uuid.to_string());
assert_eq!(entry["cwd"], "/my/project");
assert_eq!(entry["projectPath"], "-my-project");
assert_eq!(
entry["transcriptPath"],
format!("projects/-my-project/{uuid}.jsonl")
);
}
#[test]
fn write_session_index_entry_preserves_other_entries() {
let tmp = TempDir::new().unwrap();
let other_uuid = Uuid::new_v4();
fs::write(
tmp.path().join("sessions-index.json"),
serde_json::to_vec_pretty(&serde_json::json!({
other_uuid.to_string(): {"sessionId": other_uuid.to_string(), "custom_field": 42},
}))
.unwrap(),
)
.unwrap();
let new_uuid = Uuid::new_v4();
write_session_index_entry(new_uuid, Path::new("/my/project"), tmp.path()).unwrap();
let index: serde_json::Value =
serde_json::from_slice(&fs::read(tmp.path().join("sessions-index.json")).unwrap()).unwrap();
// New entry landed.
assert_eq!(
index[new_uuid.to_string()]["sessionId"],
new_uuid.to_string()
);
// Old entry preserved verbatim, including unknown fields.
assert_eq!(
index[other_uuid.to_string()]["sessionId"],
other_uuid.to_string()
);
assert_eq!(index[other_uuid.to_string()]["custom_field"], 42);
}
#[test]
fn write_session_index_entry_overwrites_same_session() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
write_session_index_entry(uuid, Path::new("/old/cwd"), tmp.path()).unwrap();
write_session_index_entry(uuid, Path::new("/new/cwd"), tmp.path()).unwrap();
let index: serde_json::Value =
serde_json::from_slice(&fs::read(tmp.path().join("sessions-index.json")).unwrap()).unwrap();
// Only one entry for this session id, with the newer cwd.
assert_eq!(index.as_object().unwrap().len(), 1);
assert_eq!(index[uuid.to_string()]["cwd"], "/new/cwd");
}
#[test]
fn write_session_index_entry_overwrites_malformed_file() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("sessions-index.json"), b"not json").unwrap();
let uuid = Uuid::new_v4();
write_session_index_entry(uuid, Path::new("/my/project"), tmp.path()).unwrap();
let index: serde_json::Value =
serde_json::from_slice(&fs::read(tmp.path().join("sessions-index.json")).unwrap()).unwrap();
assert_eq!(index[uuid.to_string()]["sessionId"], uuid.to_string());
}
@@ -0,0 +1,335 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tempfile::NamedTempFile;
use warp_cli::agent::Harness;
use warp_managed_secrets::ManagedSecretValue;
use warpui::{ModelHandle, ModelSpawner};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::harness_support::HarnessSupportClient;
use crate::server::server_api::ServerApi;
use crate::terminal::model::block::BlockId;
use crate::terminal::CLIAgent;
use super::super::terminal::{CommandHandle, TerminalDriver};
use super::super::{AgentDriver, AgentDriverError};
use super::json_utils::{read_json_file_or_default, write_json_file};
use super::{write_temp_file, HarnessRunner, ResumePayload, SavePoint, ThirdPartyHarness};
pub(crate) struct GeminiHarness;
/// Format slug sent to the server when creating a Gemini conversation.
const GEMINI_CLI_FORMAT: &str = "gemini_cli";
/// Slash command Gemini's TUI recognises as a graceful shutdown.
const GEMINI_EXIT_COMMAND: &str = "/quit";
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl ThirdPartyHarness for GeminiHarness {
fn harness(&self) -> Harness {
Harness::Gemini
}
fn cli_agent(&self) -> CLIAgent {
CLIAgent::Gemini
}
fn install_docs_url(&self) -> Option<&'static str> {
Some("https://geminicli.com/")
}
fn prepare_environment_config(
&self,
working_dir: &Path,
system_prompt: Option<&str>,
_secrets: &HashMap<String, ManagedSecretValue>,
) -> Result<(), AgentDriverError> {
prepare_gemini_environment_config(working_dir, system_prompt).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
}
})
}
fn build_runner(
&self,
prompt: &str,
system_prompt: Option<&str>,
_resumption_prompt: Option<&str>,
working_dir: &Path,
_task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
_resume: Option<ResumePayload>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
// Gemini does not support conversation resume yet. When it does, it will add its
// own `ResumePayload::Gemini(..)` variant and override `fetch_resume_payload`,
// and decide how to surface the user-turn resumption preamble.
let client: Arc<dyn HarnessSupportClient> = server_api;
Ok(Box::new(GeminiHarnessRunner::new(
self.cli_agent().command_prefix(),
prompt,
system_prompt,
working_dir,
client,
terminal_driver,
)?))
}
}
/// Build the shell command that launches the Gemini TUI.
///
/// `--yolo` auto-approves tool call. `-i` seeds the initial prompt and
/// continues in interactive TUI mode.
fn gemini_command(cli_name: &str, prompt_path: &str) -> String {
format!("{cli_name} --yolo -i \"$(cat '{prompt_path}')\"")
}
enum GeminiRunnerState {
Preexec,
Running {
conversation_id: AIConversationId,
block_id: BlockId,
},
}
struct GeminiHarnessRunner {
command: String,
/// Held so the temp file is cleaned up when the runner is dropped.
_temp_prompt_file: NamedTempFile,
client: Arc<dyn HarnessSupportClient>,
terminal_driver: ModelHandle<TerminalDriver>,
state: Mutex<GeminiRunnerState>,
}
impl GeminiHarnessRunner {
fn new(
cli_command: &str,
prompt: &str,
_system_prompt: Option<&str>,
_working_dir: &Path,
client: Arc<dyn HarnessSupportClient>,
terminal_driver: ModelHandle<TerminalDriver>,
) -> Result<Self, AgentDriverError> {
let temp_file = write_temp_file("oz_prompt_", prompt)?;
let prompt_path = temp_file.path().display().to_string();
Ok(Self {
command: gemini_command(cli_command, &prompt_path),
_temp_prompt_file: temp_file,
client,
terminal_driver,
state: Mutex::new(GeminiRunnerState::Preexec),
})
}
}
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl HarnessRunner for GeminiHarnessRunner {
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<CommandHandle, AgentDriverError> {
// Create the external conversation record on the server.
let conversation_id = self
.client
.create_external_conversation(GEMINI_CLI_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})?;
log::info!("Created external conversation {conversation_id}");
let command = self.command.clone();
let terminal_driver = self.terminal_driver.clone();
let command_handle = foreground
.spawn(move |_, ctx| {
terminal_driver.update(ctx, |driver, ctx| driver.execute_command(&command, ctx))
})
.await??
.await?;
// Only store conversation info once the CLI command has started.
*self.state.lock() = GeminiRunnerState::Running {
conversation_id,
block_id: command_handle.block_id().clone(),
};
Ok(command_handle)
}
async fn exit(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
log::info!("Sending /quit to Gemini CLI");
let terminal_driver = self.terminal_driver.clone();
foreground
.spawn(move |_, ctx| {
terminal_driver.update(ctx, |driver, ctx| {
driver.send_text_to_cli(GEMINI_EXIT_COMMAND.to_string(), ctx);
});
})
.await
.map_err(|_| anyhow::anyhow!("Agent driver dropped while sending /quit"))
}
async fn save_conversation(
&self,
save_point: SavePoint,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
if matches!(save_point, SavePoint::Periodic)
&& !super::has_running_cli_agent(&self.terminal_driver, foreground).await
{
log::debug!("Will not save conversation, Gemini not in progress");
return Ok(());
}
let (conversation_id, block_id) = match &*self.state.lock() {
GeminiRunnerState::Preexec => {
log::warn!("save_conversation called before start");
return Ok(());
}
GeminiRunnerState::Running {
conversation_id,
block_id,
} => (*conversation_id, block_id.clone()),
};
// TODO(REMOTE-1408) Also save the conversation transcript.
super::upload_current_block_snapshot(
foreground,
&self.terminal_driver,
self.client.as_ref(),
conversation_id,
block_id,
)
.await
}
}
fn prepare_gemini_environment_config(
working_dir: &Path,
system_prompt: Option<&str>,
) -> Result<()> {
let home_dir =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
let gemini_dir = home_dir.join(GEMINI_CONFIG_DIR);
prepare_gemini_settings(
&gemini_dir.join(GEMINI_SETTINGS_FILE_NAME),
system_prompt.is_some(),
)?;
prepare_gemini_trusted_folders(
&gemini_dir.join(GEMINI_TRUSTED_FOLDERS_FILE_NAME),
working_dir,
)?;
if let Some(prompt) = system_prompt {
let prompt_path = gemini_dir.join(GEMINI_SYSTEM_PROMPT_FILE_NAME);
std::fs::write(&prompt_path, prompt).with_context(|| {
format!(
"Failed to write Gemini system prompt to {}",
prompt_path.display()
)
})?;
}
Ok(())
}
fn prepare_gemini_settings(settings_path: &Path, has_system_prompt: bool) -> Result<()> {
let mut settings: GeminiSettings = read_json_file_or_default(settings_path)?;
settings
.security
.get_or_insert_with(GeminiSecurity::default)
.auth
.get_or_insert_with(GeminiAuth::default)
.selected_type = Some(GEMINI_API_KEY_AUTH_TYPE.to_owned());
if has_system_prompt {
let context = settings.context.get_or_insert_with(GeminiContext::default);
let file_name = GEMINI_SYSTEM_PROMPT_FILE_NAME.to_owned();
if !context.file_name.contains(&file_name) {
context.file_name.push(file_name);
}
}
write_json_file(
settings_path,
&settings,
"Failed to serialize Gemini settings",
)
}
fn prepare_gemini_trusted_folders(trusted_path: &Path, working_dir: &Path) -> Result<()> {
let mut trusted: HashMap<String, String> = read_json_file_or_default(trusted_path)?;
trusted.insert(
working_dir.to_string_lossy().into_owned(),
GEMINI_TRUST_LEVEL_FOLDER.to_owned(),
);
write_json_file(
trusted_path,
&trusted,
"Failed to serialize Gemini trusted folders",
)
}
const GEMINI_CONFIG_DIR: &str = ".gemini";
const GEMINI_SETTINGS_FILE_NAME: &str = "settings.json";
const GEMINI_TRUSTED_FOLDERS_FILE_NAME: &str = "trustedFolders.json";
const GEMINI_SYSTEM_PROMPT_FILE_NAME: &str = "OZ_SYSTEM_PROMPT.md";
/// Auth-type discriminant for API-key auth — matches `AuthType.USE_GEMINI` in
/// Gemini's `packages/core/src/core/contentGenerator.ts`.
const GEMINI_API_KEY_AUTH_TYPE: &str = "gemini-api-key";
/// Trust level discriminant that grants full trust to a single folder — matches
/// Gemini's `TrustLevel.TRUST_FOLDER` in `packages/cli/src/config/trustedFolders.ts`.
const GEMINI_TRUST_LEVEL_FOLDER: &str = "TRUST_FOLDER";
#[derive(Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GeminiSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
security: Option<GeminiSecurity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<GeminiContext>,
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GeminiSecurity {
#[serde(default, skip_serializing_if = "Option::is_none")]
auth: Option<GeminiAuth>,
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GeminiAuth {
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_type: Option<String>,
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GeminiContext {
#[serde(default)]
file_name: Vec<String>,
#[serde(flatten)]
extra: Map<String, Value>,
}
#[cfg(test)]
#[path = "gemini_tests.rs"]
mod tests;
@@ -0,0 +1,166 @@
use std::fs;
use serde_json::Value;
use tempfile::TempDir;
use super::*;
#[test]
fn prepare_gemini_settings_creates_file_with_api_key_auth() {
let tmp = TempDir::new().unwrap();
let settings_path = tmp.path().join("settings.json");
prepare_gemini_settings(&settings_path, false).unwrap();
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
assert_eq!(
settings["security"]["auth"]["selectedType"],
Value::String("gemini-api-key".to_owned()),
);
}
#[test]
fn prepare_gemini_settings_preserves_unrelated_keys() {
let tmp = TempDir::new().unwrap();
let settings_path = tmp.path().join("settings.json");
fs::write(
&settings_path,
r#"{
"ui": {"theme": "dark"},
"security": {
"folderTrust": {"enabled": true},
"auth": {"enforcedType": "vertex-ai"}
}
}"#,
)
.unwrap();
prepare_gemini_settings(&settings_path, false).unwrap();
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
assert_eq!(settings["ui"]["theme"], "dark");
assert_eq!(settings["security"]["folderTrust"]["enabled"], true);
// Sibling auth fields survive, and selectedType is set alongside them.
assert_eq!(settings["security"]["auth"]["enforcedType"], "vertex-ai");
assert_eq!(
settings["security"]["auth"]["selectedType"],
"gemini-api-key",
);
}
#[test]
fn prepare_gemini_settings_surfaces_malformed_json_as_error() {
let tmp = TempDir::new().unwrap();
let settings_path = tmp.path().join("settings.json");
// `security` typed as a string instead of an object is a parse error;
// we prefer surfacing that to silently rewriting user-owned state.
fs::write(
&settings_path,
r#"{"ui":{"theme":"dark"},"security":"broken"}"#,
)
.unwrap();
assert!(prepare_gemini_settings(&settings_path, false).is_err());
}
#[test]
fn prepare_gemini_trusted_folders_creates_file_with_working_dir() {
let tmp = TempDir::new().unwrap();
let trusted_path = tmp.path().join("trustedFolders.json");
let working_dir = tmp.path().join("workspace/project");
prepare_gemini_trusted_folders(&trusted_path, &working_dir).unwrap();
let trusted: Value = serde_json::from_slice(&fs::read(trusted_path).unwrap()).unwrap();
let working_dir_key = working_dir.to_string_lossy().to_string();
assert_eq!(trusted[working_dir_key], "TRUST_FOLDER");
}
#[test]
fn prepare_gemini_trusted_folders_preserves_existing_entries() {
let tmp = TempDir::new().unwrap();
let trusted_path = tmp.path().join("trustedFolders.json");
fs::write(
&trusted_path,
r#"{"/other/project":"TRUST_PARENT","/do/not/trust":"DO_NOT_TRUST"}"#,
)
.unwrap();
let working_dir = tmp.path().join("workspace/project");
prepare_gemini_trusted_folders(&trusted_path, &working_dir).unwrap();
let trusted: Value = serde_json::from_slice(&fs::read(trusted_path).unwrap()).unwrap();
assert_eq!(trusted["/other/project"], "TRUST_PARENT");
assert_eq!(trusted["/do/not/trust"], "DO_NOT_TRUST");
let working_dir_key = working_dir.to_string_lossy().to_string();
assert_eq!(trusted[working_dir_key], "TRUST_FOLDER");
}
#[test]
fn prepare_gemini_settings_adds_context_file_name_when_system_prompt_present() {
let tmp = TempDir::new().unwrap();
let settings_path = tmp.path().join("settings.json");
prepare_gemini_settings(&settings_path, true).unwrap();
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
assert_eq!(
settings["context"]["fileName"],
Value::Array(vec![Value::String("OZ_SYSTEM_PROMPT.md".to_owned())]),
);
}
#[test]
fn prepare_gemini_settings_appends_to_existing_context_file_name() {
let tmp = TempDir::new().unwrap();
let settings_path = tmp.path().join("settings.json");
fs::write(
&settings_path,
r#"{"context":{"fileName":["existing.md"]}}"#,
)
.unwrap();
prepare_gemini_settings(&settings_path, true).unwrap();
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
let file_names: Vec<String> = settings["context"]["fileName"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(file_names, vec!["existing.md", "OZ_SYSTEM_PROMPT.md"]);
}
#[test]
fn prepare_gemini_settings_does_not_duplicate_system_prompt_file_name() {
let tmp = TempDir::new().unwrap();
let settings_path = tmp.path().join("settings.json");
fs::write(
&settings_path,
r#"{"context":{"fileName":["OZ_SYSTEM_PROMPT.md"]}}"#,
)
.unwrap();
prepare_gemini_settings(&settings_path, true).unwrap();
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
let file_names: Vec<String> = settings["context"]["fileName"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_owned())
.collect();
assert_eq!(file_names, vec!["OZ_SYSTEM_PROMPT.md"]);
}
#[test]
fn prepare_gemini_settings_omits_context_when_no_system_prompt() {
let tmp = TempDir::new().unwrap();
let settings_path = tmp.path().join("settings.json");
prepare_gemini_settings(&settings_path, false).unwrap();
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
assert!(settings.get("context").is_none());
}
@@ -0,0 +1,56 @@
//! Shared JSON read/merge/write helpers for third-party harness config prep.
//!
//! Third-party CLIs like Claude Code and Gemini CLI persist onboarding, trust,
//! and auth state in JSON files. The harness preparation step
//! needs to set a few keys on those files without clobbering
//! user-owned state. These helpers allow us to read and merge with existing
//! JSON file state easily.
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
/// Read a JSON file as `T`, or return `T::default()` if the file does not exist.
///
/// Returns an error if the file exists but cannot be read or parsed.
pub(super) fn read_json_file_or_default<T>(path: &Path) -> Result<T>
where
T: Default + for<'de> Deserialize<'de>,
{
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(T::default());
}
Err(e) => {
return Err(
anyhow::Error::from(e).context(format!("Failed to read {}", path.display()))
);
}
};
serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
}
/// Serialize `value` as pretty JSON and write it to `path`, creating parent
/// directories as needed. `serialize_error` is used as the context for the
/// serialization step so the caller-facing error is specific to the config
/// file being written.
pub(super) fn write_json_file<T>(
path: &Path,
value: &T,
serialize_error: &'static str,
) -> Result<()>
where
T: Serialize,
{
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
std::fs::write(
path,
serde_json::to_vec_pretty(value).context(serialize_error)?,
)
.with_context(|| format!("Failed to write {}", path.display()))
}
+461
View File
@@ -0,0 +1,461 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::ffi::OsString;
use std::fmt;
use std::io::Write;
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use async_trait::async_trait;
use tempfile::NamedTempFile;
use warp_cli::agent::Harness;
use warp_managed_secrets::ManagedSecretValue;
use warpui::{ModelHandle, ModelSpawner, SingletonEntity};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
use crate::server::server_api::ServerApi;
use crate::terminal::cli_agent_sessions::{CLIAgentSessionStatus, CLIAgentSessionsModel};
use crate::terminal::model::block::{BlockId, SerializedBlock};
use crate::terminal::CLIAgent;
use crate::util::path::resolve_executable;
use warp_cli::{
OZ_CLI_ENV, OZ_HARNESS_ENV, OZ_PARENT_RUN_ID_ENV, OZ_RUN_ID_ENV, SERVER_ROOT_URL_OVERRIDE_ENV,
SESSION_SHARING_SERVER_URL_OVERRIDE_ENV, WS_SERVER_URL_OVERRIDE_ENV,
};
use warp_core::channel::ChannelState;
use super::terminal::{CommandHandle, TerminalDriver};
use super::{
AgentDriver, AgentDriverError, LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV,
LEGACY_OZ_PARENT_STATE_ROOT_ENV, OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
};
mod claude_code;
pub(crate) mod claude_transcript;
mod gemini;
mod json_utils;
pub(crate) use claude_code::ClaudeHarness;
use claude_transcript::ClaudeResumeInfo;
use gemini::GeminiHarness;
/// Harness-agnostic payload describing how to resume an existing conversation.
///
/// Each variant carries the data a specific harness needs to rehydrate state before its CLI
/// launches. Harnesses match on the variant they produce and ignore others; new CLIs that
/// want resume support add a new variant and override [`ThirdPartyHarness::fetch_resume_payload`].
pub(crate) enum ResumePayload {
/// Claude Code session state fetched from the server's transcript endpoint.
Claude(ClaudeResumeInfo),
}
/// Trait for third-party agent harnesses that execute prompts via their own CLIs.
///
/// Each new external harness (e.g. Claude, Codex) implements this to be used with cloud agents.
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
pub(crate) trait ThirdPartyHarness: Send + Sync {
/// Returns the [`Harness`] variant this implementation corresponds to.
fn harness(&self) -> Harness;
/// Returns the CLIAgent type associated with this harness.
fn cli_agent(&self) -> CLIAgent;
/// URL to install instructions for this harness's CLI, surfaced in the
/// default [`validate`] impl when the CLI is not on `PATH`.
fn install_docs_url(&self) -> Option<&'static str> {
None
}
/// Validate that the harness is ready to run. Default impl checks that the
/// CLI is installed on `PATH`; override for additional checks.
fn validate(&self) -> Result<(), AgentDriverError> {
validate_cli_installed(self.cli_agent().command_prefix(), self.install_docs_url())
}
/// Prepare CLI-specific config files before launching the harness command.
fn prepare_environment_config(
&self,
_working_dir: &Path,
_system_prompt: Option<&str>,
_secrets: &HashMap<String, ManagedSecretValue>,
) -> Result<(), AgentDriverError> {
Ok(())
}
/// Fetch the harness-specific resume payload for an existing conversation.
///
/// The driver calls this when the user passes `--conversation <id>` and the harness
/// matches the stored conversation's harness. Harnesses that don't support resume
/// use the default impl, which returns `Ok(None)` and causes the run to start fresh.
///
/// Implementations download the raw transcript via [`HarnessSupportClient::fetch_transcript`]
/// (which derives the conversation from the current task's `agent_conversation_id`) and
/// own all harness-specific deserialization and error mapping (e.g. a 404 maps to
/// [`AgentDriverError::ConversationResumeStateMissing`] tagged with the harness label).
async fn fetch_resume_payload(
&self,
_conversation_id: &AIConversationId,
_harness_support_client: Arc<dyn HarnessSupportClient>,
) -> Result<Option<ResumePayload>, AgentDriverError> {
Ok(None)
}
/// Build a runner for executing this harness with the given prompt.
///
/// If `resume` is `Some`, the harness matches on its own [`ResumePayload`] variant and
/// reuses the stored session/conversation ids instead of minting fresh ones. Variants
/// belonging to other harnesses are ignored.
///
/// `resumption_prompt`, when non-empty, is a short user-turn preamble the server emits
/// during a resumed session. Each harness decides exactly how to surface it (e.g. Claude
/// prepends it to the user-turn prompt that gets piped into the CLI). Harnesses that
/// don't yet support resumption can ignore it.
#[allow(clippy::too_many_arguments)]
fn build_runner(
&self,
prompt: &str,
system_prompt: Option<&str>,
resumption_prompt: Option<&str>,
working_dir: &Path,
task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ResumePayload>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError>;
}
/// Harness type for driver dispatch.
pub(crate) enum HarnessKind {
Oz,
/// Third-party CLI-backed harness (e.g. Claude, Gemini).
ThirdParty(Box<dyn ThirdPartyHarness>),
/// Harnesses that exist in the shared CLI enum but are not supported by the
/// standalone agent driver.
Unsupported(Harness),
}
impl HarnessKind {
/// Corresponding [`Harness`] enum value.
pub(crate) fn harness(&self) -> Harness {
match self {
HarnessKind::Oz => Harness::Oz,
HarnessKind::ThirdParty(h) => h.harness(),
HarnessKind::Unsupported(harness) => *harness,
}
}
}
impl fmt::Debug for HarnessKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Use the `Display` method on the [`Harness`] enum.
write!(f, "{}", self.harness())
}
}
/// Build a [`HarnessKind`] for the given [`Harness`].
///
/// We shouldn't ever get a `--harness unknown` here because clap should handle
/// it.
pub(crate) fn harness_kind(harness: Harness) -> Result<HarnessKind, AgentDriverError> {
match harness {
Harness::Oz => Ok(HarnessKind::Oz),
Harness::Claude => Ok(HarnessKind::ThirdParty(Box::new(ClaudeHarness))),
Harness::OpenCode => Ok(HarnessKind::Unsupported(Harness::OpenCode)),
Harness::Gemini => Ok(HarnessKind::ThirdParty(Box::new(GeminiHarness))),
Harness::Unknown => Err(AgentDriverError::InvalidRuntimeState),
}
}
/// Check that `cli` is installed and on PATH, returning a `HarnessSetupFailed`
/// error with an optional install-docs link when it isn't.
pub(crate) fn validate_cli_installed(
cli: &str,
install_docs_url: Option<&str>,
) -> Result<(), AgentDriverError> {
if resolve_executable(cli).is_none() {
let mut reason = format!("'{cli}' CLI not found on your machine.");
if let Some(url) = install_docs_url {
reason.push_str(&format!(" Install it first: {url}"));
}
return Err(AgentDriverError::HarnessSetupFailed {
harness: cli.into(),
reason,
});
}
Ok(())
}
fn insert_non_empty_task_env_var(
env_vars: &mut HashMap<OsString, OsString>,
key: &'static str,
value: String,
) {
if value.is_empty() {
return;
}
env_vars.insert(OsString::from(key), OsString::from(value));
}
fn insert_task_env_var_aliases(
env_vars: &mut HashMap<OsString, OsString>,
keys: &[&'static str],
value: &str,
) {
for key in keys {
env_vars.insert(OsString::from(key), OsString::from(value));
}
}
fn message_listener_state_root() -> Option<String> {
[
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
LEGACY_OZ_PARENT_STATE_ROOT_ENV,
]
.into_iter()
.find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty()))
}
fn task_env_vars_for_harness_name(
task_id: Option<&AmbientAgentTaskId>,
parent_run_id: Option<&str>,
selected_harness: Harness,
) -> HashMap<OsString, OsString> {
let mut env_vars = HashMap::with_capacity(7);
if let Some(id) = task_id {
env_vars.insert(
OsString::from(OZ_RUN_ID_ENV),
OsString::from(id.to_string()),
);
}
if let Some(parent_run_id) = parent_run_id.filter(|id| !id.is_empty()) {
env_vars.insert(
OsString::from(OZ_PARENT_RUN_ID_ENV),
OsString::from(parent_run_id),
);
}
env_vars.insert(
OsString::from(OZ_CLI_ENV),
OsString::from(
std::env::current_exe()
.unwrap_or_else(|_| ChannelState::channel().cli_command_name().into()),
),
);
// `OZ_HARNESS` is only consumed by child orchestration telemetry when the child
// CLI emits `run message *` events.
env_vars.insert(
OsString::from(OZ_HARNESS_ENV),
OsString::from(selected_harness.to_string()),
);
if selected_harness == Harness::Claude && task_id.is_some() {
insert_task_env_var_aliases(
&mut env_vars,
&[
OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV,
],
"1",
);
if let Some(state_root) = message_listener_state_root() {
insert_task_env_var_aliases(
&mut env_vars,
&[
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
LEGACY_OZ_PARENT_STATE_ROOT_ENV,
],
&state_root,
);
}
}
// Server URL overrides are disabled on release channels, so there's no
// override to propagate to child processes there.
if ChannelState::channel().allows_server_url_overrides() {
insert_non_empty_task_env_var(
&mut env_vars,
SERVER_ROOT_URL_OVERRIDE_ENV,
ChannelState::server_root_url().into_owned(),
);
insert_non_empty_task_env_var(
&mut env_vars,
WS_SERVER_URL_OVERRIDE_ENV,
ChannelState::ws_server_url().into_owned(),
);
if let Some(url) = ChannelState::session_sharing_server_url()
.map(Cow::into_owned)
.filter(|url| !url.is_empty())
{
env_vars.insert(
OsString::from(SESSION_SHARING_SERVER_URL_OVERRIDE_ENV),
OsString::from(url),
);
}
}
env_vars
}
pub(crate) fn task_env_vars(
task_id: Option<&AmbientAgentTaskId>,
parent_run_id: Option<&str>,
selected_harness: Harness,
) -> HashMap<OsString, OsString> {
task_env_vars_for_harness_name(task_id, parent_run_id, selected_harness)
}
/// Indicates when the harness conversation is being saved.
/// Implementations may use this to customize the saved data, such as
/// recording additional metadata on completion.
pub(crate) enum SavePoint {
/// A periodic auto-save to minimize data loss.
Periodic,
/// The final save of conversation state, after the harness has completed.
Final,
/// A save after the harness reports it finished an agent turn.
PostTurn,
}
/// Stateful per-run representation of an external harness produced
/// by [`ThirdPartyHarness::build_runner`].
///
/// All `HarnessRunner` methods take `&self` as a parameter, but may mutate internal
/// state. There are no `&mut self` methods, as this would require that the `AgentDriver`
/// store the runner in a mutex and lock it across `await` points.
///
/// The driver uses this to manage the lifecycle of a particular third-party harness.
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
pub(crate) trait HarnessRunner: Send + Sync {
/// Create the external conversation on the server and start the harness
/// command in the terminal.
///
/// Returns a [`CommandHandle`] that resolves to the exit code. The runner
/// stores the conversation ID and block ID internally for use in
/// [`save_conversation`].
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<CommandHandle, AgentDriverError>;
/// Save the current conversation state (transcript upload, etc.).
async fn save_conversation(
&self,
save_point: SavePoint,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<()>;
/// Gracefully ask the harness to exit.
async fn exit(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()>;
/// Handle a CLI session update such as a prompt submit or completed tool use.
async fn handle_session_update(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
Ok(())
}
/// Clean up any harness-owned background state after the harness exits.
async fn cleanup(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
Ok(())
}
}
/// Returns `true` if the terminal tracked by `terminal_driver` has a CLI agent session
/// that is currently in progress.
pub(crate) async fn has_running_cli_agent(
terminal_driver: &ModelHandle<TerminalDriver>,
foreground: &ModelSpawner<AgentDriver>,
) -> bool {
let driver = terminal_driver.clone();
let Ok(running) = foreground
.spawn(move |_, ctx| {
let terminal_view_id = driver.as_ref(ctx).terminal_view().id();
CLIAgentSessionsModel::handle(ctx)
.as_ref(ctx)
.session(terminal_view_id)
.is_some_and(|s| s.status == CLIAgentSessionStatus::InProgress)
})
.await
else {
return false;
};
running
}
/// Create a [`NamedTempFile`] with the given prefix and write `content` into it.
///
/// Used by third-party harnesses to stage prompts / system prompts on disk
/// before launching the CLI, avoiding shell-quoting issues with complex input.
pub(super) fn write_temp_file(
prefix: &str,
content: &str,
) -> Result<NamedTempFile, AgentDriverError> {
let mut file = tempfile::Builder::new()
.prefix(prefix)
.suffix(".txt")
.tempfile()
.map_err(|e| {
AgentDriverError::ConfigBuildFailed(anyhow::anyhow!(
"Failed to create temp file '{prefix}': {e}"
))
})?;
file.write_all(content.as_bytes()).map_err(|e| {
AgentDriverError::ConfigBuildFailed(anyhow::anyhow!(
"Failed to write temp file '{prefix}': {e}"
))
})?;
Ok(file)
}
/// Upload a [`SerializedBlock`] as the JSON block snapshot for a third-party harness conversation.
pub(crate) async fn upload_block_snapshot(
client: &dyn HarnessSupportClient,
conversation_id: AIConversationId,
block: SerializedBlock,
) -> Result<()> {
log::info!("Uploading block snapshot for CLI agent to conversation {conversation_id}");
let target = client
.get_block_snapshot_upload_target(&conversation_id)
.await
.with_context(|| {
format!("Unable to get block upload slot for conversation {conversation_id}")
})?;
let body = block
.to_json()
.with_context(|| format!("Unable to serialize block for conversation {conversation_id}"))?;
upload_to_target(client.http_client(), &target, body).await
}
/// Fetch the current block snapshot for `block_id` and upload it to the server.
///
/// If the snapshot cannot be fetched, logs a warning and returns `Ok(())`.
pub(super) async fn upload_current_block_snapshot(
foreground: &ModelSpawner<AgentDriver>,
terminal_driver: &ModelHandle<TerminalDriver>,
client: &dyn HarnessSupportClient,
conversation_id: AIConversationId,
block_id: BlockId,
) -> Result<()> {
let td = terminal_driver.clone();
let snapshot = foreground
.spawn(move |_, ctx| td.as_ref(ctx).block_snapshot(&block_id, ctx))
.await
.map_err(|_| anyhow::anyhow!("Agent driver dropped"))?;
match snapshot {
Some(block) => upload_block_snapshot(client, conversation_id, block).await,
None => {
log::warn!("No block snapshot found for harness command");
Ok(())
}
}
}
#[cfg(test)]
#[path = "mod_test.rs"]
mod tests;
@@ -0,0 +1,33 @@
use super::validate_cli_installed;
use crate::ai::agent_sdk::driver::AgentDriverError;
fn assert_harness_setup_failed(err: &AgentDriverError) -> (&str, &str) {
match err {
AgentDriverError::HarnessSetupFailed { harness, reason } => (harness, reason),
other => panic!("expected HarnessSetupFailed, got: {other}"),
}
}
#[cfg(not(windows))]
#[test]
fn validate_cli_installed_succeeds_for_known_binary() {
assert!(validate_cli_installed("ls", None).is_ok());
}
#[test]
fn validate_cli_installed_fails_for_missing_binary() {
let err = validate_cli_installed("__nonexistent_cli_abc123__", None).unwrap_err();
let (harness, reason) = assert_harness_setup_failed(&err);
assert_eq!(harness, "__nonexistent_cli_abc123__");
assert!(reason.contains("not found"));
assert!(!reason.contains("Install it first"));
}
#[test]
fn validate_cli_installed_includes_docs_url_in_error() {
let url = "https://example.com/install";
let err = validate_cli_installed("__nonexistent_cli_abc123__", Some(url)).unwrap_err();
let (_, reason) = assert_harness_setup_failed(&err);
assert!(reason.contains(url));
assert!(reason.contains("Install it first"));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+648
View File
@@ -0,0 +1,648 @@
use std::{
collections::HashMap,
ffi::OsString,
future::Future,
path::PathBuf,
pin::Pin,
sync::Arc,
task::{Context, Poll},
time::Duration,
};
use futures::channel::oneshot;
use session_sharing_protocol::common::{Role, SessionId};
use session_sharing_protocol::sharer::SessionSourceType;
use warp_cli::share::{ShareAccessLevel, ShareRequest, ShareSubject};
use warp_completer::completer::CommandOutput;
use warp_core::command::ExitCode;
use warp_core::features::FeatureFlag;
use warp_util::path::ShellFamily;
use warpui::{
r#async::FutureExt, AppContext, Entity, ModelContext, ModelHandle, SingletonEntity as _,
ViewHandle,
};
use crate::terminal::model::session::ExecuteCommandOptions;
use crate::{
ai::ambient_agents::AmbientAgentTaskId,
pane_group::NewTerminalOptions,
root_view::{open_new_with_workspace_source, NewWorkspaceSource},
terminal::{
model::block::{BlockId, SerializedBlock},
shared_session::{self, IsSharedSessionCreator},
shell::ShellType,
view::ConversationRestorationInNewPaneType,
TerminalView,
},
util::sync::Condition,
workspaces::user_workspaces::UserWorkspaces,
};
use crate::ai::attachment_utils::attachments_download_dir;
use super::AgentDriverError;
/// Describes why an agent's session-sharing request failed.
#[derive(Debug, thiserror::Error)]
pub(crate) enum ShareSessionError {
/// Connection to the session-sharing server failed.
#[error("Internal error")]
Internal(#[source] Arc<anyhow::Error>),
/// The server rejected the session-sharing request.
#[error("{0}")]
Failed(String),
/// Session sharing is disabled for this user or team.
#[error(
"Session sharing is not enabled. This is likely because an administrator has disabled session sharing for your team."
)]
Disabled,
/// The session-sharing request timed out.
#[error("Timed out waiting for session sharing to start")]
Timeout,
/// The session-sharing channel was dropped before completing.
#[error("Session sharing was interrupted")]
Interrupted,
}
const TERMINAL_SESSION_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(60);
const TERMINAL_SESSION_SHARE_DELAY: Duration = Duration::from_secs(10);
/// Options for creating the terminal view before constructing a [`TerminalDriver`].
pub(crate) struct TerminalDriverOptions {
pub working_dir: PathBuf,
pub env_vars: HashMap<OsString, OsString>,
pub should_share: bool,
pub task_id: Option<AmbientAgentTaskId>,
pub conversation_restoration: Option<ConversationRestorationInNewPaneType>,
}
/// Events emitted by [`TerminalDriver`] for [`super::AgentDriver`] to react to.
pub(crate) enum TerminalDriverEvent {
/// Terminal bootstrap is taking unusually long.
SlowBootstrap,
/// The terminal session has established a shared session.
EstablishedSharedSession {
session_id: session_sharing_protocol::common::SessionId,
join_url: String,
},
}
/// Manages the terminal session lifecycle for the agent driver.
///
/// Responsibilities:
/// - Monitoring for terminal bootstrapping to be done
/// - Configuring session sharing and applying guest requests
/// - Executing commands in the session
/// - Detecting block completion
pub(crate) struct TerminalDriver {
terminal_view: ViewHandle<TerminalView>,
session_bootstrapped: Condition,
/// The session ID once sharing has been established.
shared_session_id: Option<SessionId>,
/// Receiver for the session sharing result. Present when sharing is expected
/// and `wait_for_session_shared` has not yet been called.
session_share_rx: Option<oneshot::Receiver<Result<(), ShareSessionError>>>,
pending_share_requests: Vec<ShareRequest>,
waiting_command: Option<oneshot::Sender<ExitCode>>,
/// State for the pending command we're expecting to start executing.
/// The `String` is the expected command text, and the sender is used
/// to send the block ID to the waiting caller.
pending_command_start: Option<(String, oneshot::Sender<BlockId>)>,
}
impl Entity for TerminalDriver {
type Event = TerminalDriverEvent;
}
/// Create the terminal window and extract the [`ViewHandle<TerminalView>`].
///
/// This is separate from [`TerminalDriver::new`] because [`AppContext::add_model`]
/// requires an infallible constructor; the fallible window/view creation must happen first.
fn create_terminal_view(
options: TerminalDriverOptions,
ctx: &mut AppContext,
) -> Result<ViewHandle<TerminalView>, AgentDriverError> {
let is_shared_session_creator = if options.should_share {
IsSharedSessionCreator::Yes {
source_type: SessionSourceType::AmbientAgent {
task_id: options.task_id.map(|t| t.to_string()),
},
}
} else {
IsSharedSessionCreator::No
};
let (_, root_view) = open_new_with_workspace_source(
NewWorkspaceSource::Session {
options: Box::new(NewTerminalOptions {
is_shared_session_creator,
initial_directory: Some(options.working_dir),
env_vars: options.env_vars,
conversation_restoration: options.conversation_restoration,
..Default::default()
}),
},
ctx,
);
root_view
.as_ref(ctx)
.workspace_view()
.ok_or(AgentDriverError::TerminalUnavailable)?
.as_ref(ctx)
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
.ok_or(AgentDriverError::TerminalUnavailable)
}
impl TerminalDriver {
/// Create a terminal view from the given options and wrap it in a new `TerminalDriver` model.
pub(crate) fn create(
options: TerminalDriverOptions,
ctx: &mut AppContext,
) -> Result<ModelHandle<Self>, AgentDriverError> {
let should_share = options.should_share;
let task_id = options.task_id;
let working_dir = options.working_dir.clone();
let terminal_view = create_terminal_view(options, ctx)?;
Ok(ctx.add_model(|ctx| Self::new(terminal_view, should_share, task_id, working_dir, ctx)))
}
/// Wrap an already-created terminal view in a new `TerminalDriver` model.
///
/// Unlike [`Self::create`], this does not open a new window — it reuses an
/// existing view (e.g. a docker sandbox pane). Session sharing is disabled
/// and no task ID is associated.
pub(crate) fn create_from_existing_view(
terminal_view: ViewHandle<TerminalView>,
ctx: &mut AppContext,
) -> ModelHandle<Self> {
ctx.add_model(|ctx| Self::new(terminal_view, false, None, PathBuf::default(), ctx))
}
/// Set up event subscriptions and session-sharing conditions for an
/// already-created terminal view.
fn new(
terminal_view: ViewHandle<TerminalView>,
should_share: bool,
task_id: Option<AmbientAgentTaskId>,
working_dir: PathBuf,
ctx: &mut ModelContext<Self>,
) -> Self {
let session_bootstrapped = Condition::new();
// Create a oneshot channel for session sharing when sharing is expected.
// When sharing is disabled (or running against ngrok), leave both halves
// as None so that `wait_for_session_shared` returns immediately.
let sharing_expected =
should_share && !warp_core::channel::ChannelState::server_root_url().contains("ngrok");
let (mut session_share_tx, session_share_rx) = if sharing_expected {
if !FeatureFlag::CreatingSharedSessions.is_enabled() {
// Session sharing was requested but the feature is not enabled for this
// user/team (typically an enterprise/admin setting). Fail immediately
// with a clear error rather than waiting for a timeout.
log::warn!(
"Session sharing requested but the CreatingSharedSessions feature flag \
is not enabled. This is likely due to a team administrator disabling \
session sharing."
);
let (tx, rx) = oneshot::channel();
let _ = tx.send(Err(ShareSessionError::Disabled));
(None, Some(rx))
} else {
let (tx, rx) = oneshot::channel();
(Some(tx), Some(rx))
}
} else {
(None, None)
};
// Set the task_id and attachments download dir on the AI controller right away
// so they're available for session sharing and file downloads.
// Only set the download dir when a task_id is present (cloud mode),
// since attachments require a task to fetch presigned URLs.
if let Some(tid) = task_id {
let attachments_dir = attachments_download_dir(&working_dir);
terminal_view.update(ctx, |terminal, ctx| {
terminal.ai_controller().update(ctx, |controller, ctx| {
controller.set_ambient_agent_task_id(Some(tid), ctx);
controller.set_attachments_download_dir(attachments_dir);
});
});
}
ctx.subscribe_to_view(&terminal_view, move |me, event, ctx| {
me.handle_terminal_view_event(event, &mut session_share_tx, ctx);
});
// If the session already bootstrapped before we subscribed, set the
// condition immediately so callers of `wait_for_session_bootstrapped`
// don't block forever.
let already_bootstrapped = terminal_view.read(ctx, |terminal, _| {
terminal
.model
.lock()
.block_list()
.is_bootstrapping_precmd_done()
});
if already_bootstrapped {
session_bootstrapped.set();
}
Self {
terminal_view,
session_bootstrapped,
shared_session_id: None,
session_share_rx,
pending_share_requests: Vec::new(),
waiting_command: None,
pending_command_start: None,
}
}
/// Get a handle to the backing terminal view.
pub fn terminal_view(&self) -> &ViewHandle<TerminalView> {
&self.terminal_view
}
/// Provide mutable access to the terminal view through a closure.
pub fn with_terminal_view(
&self,
ctx: &mut ModelContext<Self>,
f: impl FnOnce(&mut TerminalView, &mut warpui::ViewContext<TerminalView>),
) {
self.terminal_view.update(ctx, f);
}
/// Request that the terminal session be shared with the given participants.
///
/// This has no effect if the session is not being shared.
pub fn add_share_requests(
&mut self,
share_requests: impl IntoIterator<Item = ShareRequest>,
ctx: &mut ModelContext<Self>,
) {
self.pending_share_requests.extend(share_requests);
if self.shared_session_id.is_some() {
self.apply_share_requests(ctx);
}
}
/// Apply pending session-sharing guest requests.
fn apply_share_requests(&mut self, ctx: &mut ModelContext<Self>) {
if self.pending_share_requests.is_empty() {
return;
}
let share_requests = std::mem::take(&mut self.pending_share_requests);
self.terminal_view.update(ctx, |terminal_view, ctx| {
let mut viewer_emails = Vec::new();
let mut editor_emails = Vec::new();
for request in share_requests {
let role = match request.access_level {
ShareAccessLevel::View => Role::Reader,
ShareAccessLevel::Edit => Role::Executor,
};
match request.subject {
ShareSubject::Team => {
if let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() {
terminal_view.update_session_team_permissions(
Some(role),
team_uid.to_string(),
ctx,
);
}
}
ShareSubject::Public => {
// Apply an anyone-with-link ACL at the requested role.
// This uses the same path as the share modal's
// "anyone with link" toggle. The workspace-level
// anyone-with-link setting on the server still gates
// whether the ACL write succeeds.
terminal_view.update_session_link_permissions(Some(role), ctx);
}
ShareSubject::User { email } => match request.access_level {
ShareAccessLevel::View => viewer_emails.push(email),
ShareAccessLevel::Edit => editor_emails.push(email),
},
}
}
if !viewer_emails.is_empty() {
terminal_view.add_guests(viewer_emails, Role::Reader, ctx);
}
if !editor_emails.is_empty() {
terminal_view.add_guests(editor_emails, Role::Executor, ctx);
}
});
}
/// Submit `text` to the active CLI agent on the terminal PTY using the
/// agent-specific submission strategy.
///
/// Used to send exit commands to third-party harnesses.
pub(super) fn send_text_to_cli(&self, text: String, ctx: &mut ModelContext<Self>) {
self.terminal_view.update(ctx, |terminal, ctx| {
terminal.submit_text_to_cli_agent_pty(text, ctx);
});
}
/// Return a snapshot of the block with the given ID.
pub fn block_snapshot(&self, block_id: &BlockId, ctx: &AppContext) -> Option<SerializedBlock> {
let terminal = self.terminal_view.as_ref(ctx);
let model = terminal.model.lock();
model
.block_list()
.block_with_id(block_id)
.map(SerializedBlock::from)
}
/// Execute a command in the terminal and return a future that resolves to a
/// [`CommandHandle`] once the command starts executing.
pub fn execute_command(
&mut self,
command: &str,
ctx: &mut ModelContext<Self>,
) -> Result<impl Future<Output = Result<CommandHandle, AgentDriverError>>, AgentDriverError>
{
let (exit_tx, exit_rx) = oneshot::channel::<ExitCode>();
let (start_tx, start_rx) = oneshot::channel::<BlockId>();
// We should not be able to execute a command while we are still waiting on another one.
// This is enforced by the caller by waiting on rx before continuing.
if self.waiting_command.is_some() || self.pending_command_start.is_some() {
return Err(AgentDriverError::InvalidRuntimeState);
}
let command_string = command.to_string();
self.terminal_view.update(ctx, |terminal, ctx| {
self.waiting_command = Some(exit_tx);
self.pending_command_start = Some((command_string, start_tx));
terminal.execute_command_or_set_pending(command, ctx);
});
Ok(async move {
let block_id = start_rx
.await
.map_err(|_| AgentDriverError::InvalidRuntimeState)?;
Ok(CommandHandle {
exit_status_rx: exit_rx,
block_id,
})
})
}
/// Execute a command through the active session's in-band command
/// executor, without adding a block to the user-visible blocklist.
///
/// Intended for silent probes (e.g. `test -d`) that the agent needs to
/// drive through the terminal session (so they run against the correct
/// filesystem, including inside a Docker sandbox) but should not clutter
/// the user's command history.
pub fn execute_silent_command(
&self,
command: String,
ctx: &ModelContext<Self>,
) -> impl Future<Output = Result<CommandOutput, AgentDriverError>> {
let session = self.terminal_view.read(ctx, |terminal, app| {
terminal
.active_block_session_id()
.and_then(|id| terminal.sessions_model().as_ref(app).get(id))
});
async move {
let session = session.ok_or(AgentDriverError::InvalidRuntimeState)?;
session
.execute_command(&command, None, None, ExecuteCommandOptions::default())
.await
.map_err(|e| {
log::warn!("silent command failed: {e:#}");
AgentDriverError::InvalidRuntimeState
})
}
}
/// Returns the shell type of the active terminal session, if known.
pub fn active_session_shell_type(&self, ctx: &AppContext) -> Option<ShellType> {
self.terminal_view
.read(ctx, |terminal, app| terminal.active_session_shell_type(app))
}
/// Build the shell-aware `cd <escaped>` command for the active session.
///
/// Shared between [`Self::cd`] and [`Self::cd_silent`] so both paths use
/// the same [`ShellFamily::shell_escape`] logic (posix single-quoting,
/// fish backslash, pwsh double-quote doubling) and don't drift.
fn build_cd_command(&self, target: &str, ctx: &AppContext) -> String {
let shell_family = self.terminal_view.read(ctx, |terminal, app| {
terminal
.active_session_shell_type(app)
.map(ShellFamily::from)
.unwrap_or(ShellFamily::Posix)
});
let escaped_target = shell_family.shell_escape(target);
format!("cd {escaped_target}")
}
/// Change directory within the active terminal session.
pub fn cd(
&mut self,
target: &str,
ctx: &mut ModelContext<Self>,
) -> Result<impl Future<Output = Result<CommandHandle, AgentDriverError>>, AgentDriverError>
{
let cd_command = self.build_cd_command(target, ctx);
self.execute_command(&cd_command, ctx)
}
/// Change directory within the active terminal session, silently — no
/// visible block is added to the user-facing blocklist.
///
/// Uses the same shell-aware escaping as [`Self::cd`] but dispatches
/// through [`Self::execute_silent_command`]. Intended for callers that
/// need to position the session's CWD as an implementation detail of a
/// larger setup step (e.g. positioning the session before running
/// silent probes).
pub fn cd_silent(
&self,
target: &str,
ctx: &ModelContext<Self>,
) -> impl Future<Output = Result<CommandOutput, AgentDriverError>> {
let cd_command = self.build_cd_command(target, ctx);
self.execute_silent_command(cd_command, ctx)
}
/// The current working directory of the active terminal session, if known.
#[allow(dead_code)]
pub fn current_directory(&self, ctx: &AppContext) -> Option<PathBuf> {
// TODO(ben): This should handle non-local paths.
self.terminal_view
.as_ref(ctx)
.active_session_path_if_local(ctx)
}
/// Returns a future that resolves when the session has bootstrapped.
///
/// This only waits for the `SessionBootstrapped` terminal view event.
pub fn wait_for_session_bootstrapped(
&self,
) -> impl Future<Output = Result<(), AgentDriverError>> {
let session_bootstrapped = self.session_bootstrapped.clone();
async move {
session_bootstrapped
.wait()
.with_timeout(TERMINAL_SESSION_BOOTSTRAP_TIMEOUT)
.await
.map_err(|_| {
log::error!("Timed out waiting for session bootstrap");
AgentDriverError::BootstrapFailed
})
}
}
/// Returns a future that resolves when (optional) session sharing has started.
///
/// This is separate from `wait_for_session_bootstrapped` so that callers can:
/// - wait for terminal bootstrap early (e.g. before starting MCP servers)
/// - wait for session sharing later (e.g. right before running visible commands)
pub fn wait_for_session_shared(
&mut self,
) -> impl Future<Output = Result<(), AgentDriverError>> {
let rx = self.session_share_rx.take();
async move {
let Some(rx) = rx else {
// Sharing is disabled or already resolved.
return Ok(());
};
match rx.with_timeout(TERMINAL_SESSION_SHARE_DELAY).await {
Ok(Ok(Ok(()))) => Ok(()),
Ok(Ok(Err(error))) => {
log::error!("Session sharing failed: {error}");
Err(AgentDriverError::ShareSessionFailed { error })
}
Ok(Err(_canceled)) => {
log::error!("Session sharing channel dropped");
Err(AgentDriverError::ShareSessionFailed {
error: ShareSessionError::Interrupted,
})
}
Err(_timeout) => {
log::error!(
"Timed out waiting for session sharing to start after {}s",
TERMINAL_SESSION_SHARE_DELAY.as_secs()
);
Err(AgentDriverError::ShareSessionFailed {
error: ShareSessionError::Timeout,
})
}
}
}
}
}
/// A handle to a running terminal command.
///
/// Resolves to the command's [`ExitCode`] when the block completes.
/// Also carries the [`BlockId`] so callers can retrieve the block snapshot
/// after completion.
pub(crate) struct CommandHandle {
exit_status_rx: oneshot::Receiver<ExitCode>,
block_id: BlockId,
}
impl CommandHandle {
/// The block ID of the command that was executed.
pub fn block_id(&self) -> &BlockId {
&self.block_id
}
}
impl Future for CommandHandle {
type Output = Result<ExitCode, AgentDriverError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
Pin::new(&mut self.exit_status_rx)
.poll(cx)
.map(|result| result.map_err(|_| AgentDriverError::InvalidRuntimeState))
}
}
impl TerminalDriver {
/// Handle terminal view events.
fn handle_terminal_view_event(
&mut self,
event: &crate::terminal::view::Event,
session_share_tx: &mut Option<oneshot::Sender<Result<(), ShareSessionError>>>,
ctx: &mut ModelContext<Self>,
) {
match event {
crate::terminal::view::Event::SessionBootstrapped => {
self.session_bootstrapped.set();
}
crate::terminal::view::Event::SlowBootstrap => {
ctx.emit(TerminalDriverEvent::SlowBootstrap);
}
crate::terminal::view::Event::EstablishedSharedSession { session_id } => {
self.shared_session_id = Some(*session_id);
if let Some(tx) = session_share_tx.take() {
let _ = tx.send(Ok(()));
}
// Apply any pending share requests now that the session is established.
self.apply_share_requests(ctx);
ctx.emit(TerminalDriverEvent::EstablishedSharedSession {
session_id: *session_id,
join_url: shared_session::join_link(session_id),
});
}
crate::terminal::view::Event::FailedToShareSession { reason, cause } => {
if let Some(tx) = session_share_tx.take() {
let error = match cause {
Some(cause) => ShareSessionError::Internal(cause.clone()),
None => ShareSessionError::Failed(reason.clone()),
};
let _ = tx.send(Err(error));
}
}
crate::terminal::view::Event::ExecuteCommand(event) => {
if let Some((_expected_command, sender)) = self
.pending_command_start
.take_if(|(cmd, _)| *cmd == event.command)
{
let block_id = self.terminal_view.read(ctx, |terminal, _| {
terminal.model.lock().block_list().active_block_id().clone()
});
let _ = sender.send(block_id);
}
}
crate::terminal::view::Event::BlockCompleted { block, .. } => {
if let Some(sender) = self.waiting_command.take_if(|_| {
let bootstrapping_done = self.terminal_view.read(ctx, |terminal, _| {
terminal
.model
.lock()
.block_list()
.is_bootstrapping_precmd_done()
});
// This was originally checking `bootstrapping_done && block.did_execute`.
// Oddly, we've seen cases where we missed the preexec hook, so
// `block.did_execute` is false even though the command actually did run.
// To hedge against this while we're still figuring out the root cause,
// we instead simply make sure it was not a background block.
bootstrapping_done && !block.is_background
}) {
let _ = sender.send(block.exit_code);
}
}
_ => (),
}
}
}
+388
View File
@@ -0,0 +1,388 @@
use std::{ffi::OsString, sync::Arc, time::Duration};
use futures::channel::oneshot;
use warp_cli::agent::Harness;
use warp_cli::{
OZ_CLI_ENV, OZ_HARNESS_ENV, OZ_PARENT_RUN_ID_ENV, OZ_RUN_ID_ENV, SERVER_ROOT_URL_OVERRIDE_ENV,
SESSION_SHARING_SERVER_URL_OVERRIDE_ENV, WS_SERVER_URL_OVERRIDE_ENV,
};
use warp_core::channel::ChannelState;
use super::{
IdleTimeoutSender, LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV,
LEGACY_OZ_PARENT_STATE_ROOT_ENV, OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
};
use crate::ai::agent::{
task::TaskId, AIAgentActionResult, AIAgentActionResultType, AIAgentInput, AIAgentOutput,
AIAgentOutputMessage, ArtifactCreatedData, MessageId, UploadArtifactResult,
};
use crate::ai::mcp::parsing::normalize_mcp_json;
use crate::ai::{agent_sdk::task_env_vars, ambient_agents::AmbientAgentTaskId};
#[test]
fn test_normalize_single_cli_server() {
let input = r#"{"command": "npx", "args": ["-y", "mcp-server"]}"#;
let result = normalize_mcp_json(input).unwrap();
// Should wrap with a generated name
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let parsed = parsed.as_object().unwrap();
assert_eq!(parsed.len(), 1);
let (_name, server) = parsed.iter().next().unwrap();
assert_eq!(server["command"].as_str().unwrap(), "npx");
}
#[test]
fn test_normalize_single_sse_server() {
let input = r#"{"url": "http://localhost:3000/mcp", "headers": {"API_KEY": "value"}}"#;
let result = normalize_mcp_json(input).unwrap();
// Should wrap with a generated name
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let parsed = parsed.as_object().unwrap();
assert_eq!(parsed.len(), 1);
let (_name, server) = parsed.iter().next().unwrap();
assert_eq!(server["url"].as_str().unwrap(), "http://localhost:3000/mcp");
}
#[test]
fn test_normalize_already_wrapped_server() {
let input = r#"{"my-server": {"command": "npx", "args": []}}"#;
let result = normalize_mcp_json(input).unwrap();
// Should return as-is (no command/url at top level)
assert_eq!(result, input);
}
#[test]
fn test_normalize_mcp_servers_wrapper() {
let input = r#"{"mcpServers": {"server-name": {"command": "npx", "args": []}}}"#;
let result = normalize_mcp_json(input).unwrap();
// Should return as-is (no command/url at top level)
assert_eq!(result, input);
}
#[test]
fn test_normalize_servers_wrapper() {
let input = r#"{"servers": {"server-name": {"url": "http://example.com"}}}"#;
let result = normalize_mcp_json(input).unwrap();
// Should return as-is (no command/url at top level)
assert_eq!(result, input);
}
#[test]
fn test_normalize_invalid_json() {
let input = "not valid json";
let result = normalize_mcp_json(input);
assert!(result.is_err());
}
#[test]
fn test_normalize_cli_server_with_env() {
let input = r#"{"command": "npx", "args": ["-y", "mcp-server"], "env": {"API_KEY": "secret"}}"#;
let result = normalize_mcp_json(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let parsed = parsed.as_object().unwrap();
assert_eq!(parsed.len(), 1);
let (_name, server) = parsed.iter().next().unwrap();
assert_eq!(server["env"]["API_KEY"].as_str().unwrap(), "secret");
}
#[test]
fn test_normalize_sse_server_with_headers() {
let input =
r#"{"url": "http://localhost:5000/mcp", "headers": {"Authorization": "Bearer token"}}"#;
let result = normalize_mcp_json(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let parsed = parsed.as_object().unwrap();
assert_eq!(parsed.len(), 1);
let (_name, server) = parsed.iter().next().unwrap();
assert_eq!(
server["headers"]["Authorization"].as_str().unwrap(),
"Bearer token"
);
}
// ── IdleTimeoutSender tests ──────────────────────────────────────────────────────
#[test]
fn idle_timeout_sender_send_now_delivers_value() {
let (tx, mut rx) = oneshot::channel::<i32>();
let idle_timeout = IdleTimeoutSender::new(tx);
idle_timeout.end_run_now(42);
assert_eq!(rx.try_recv().unwrap(), Some(42));
}
#[test]
fn idle_timeout_sender_send_now_only_delivers_once() {
let (tx, mut rx) = oneshot::channel::<i32>();
let idle_timeout = IdleTimeoutSender::new(tx);
idle_timeout.end_run_now(1);
idle_timeout.end_run_now(2);
assert_eq!(rx.try_recv().unwrap(), Some(1));
}
#[test]
fn idle_timeout_sender_send_after_delivers_after_timeout() {
let (tx, mut rx) = oneshot::channel::<i32>();
let idle_timeout = IdleTimeoutSender::new(tx);
idle_timeout.end_run_after(Duration::from_millis(50), 99);
// Not yet delivered.
assert_eq!(rx.try_recv().unwrap(), None);
std::thread::sleep(Duration::from_millis(100));
assert_eq!(rx.try_recv().unwrap(), Some(99));
}
#[test]
fn idle_timeout_sender_cancel_prevents_delivery() {
let (tx, mut rx) = oneshot::channel::<i32>();
let idle_timeout = IdleTimeoutSender::new(tx);
idle_timeout.end_run_after(Duration::from_millis(50), 99);
idle_timeout.cancel_idle_timeout();
std::thread::sleep(Duration::from_millis(100));
// Sender was not consumed, so the channel is still open but empty.
assert_eq!(rx.try_recv().unwrap(), None);
}
#[test]
fn idle_timeout_sender_cancel_then_send_now_delivers() {
let (tx, mut rx) = oneshot::channel::<i32>();
let idle_timeout = IdleTimeoutSender::new(tx);
idle_timeout.end_run_after(Duration::from_millis(50), 1);
idle_timeout.cancel_idle_timeout();
idle_timeout.end_run_now(2);
assert_eq!(rx.try_recv().unwrap(), Some(2));
}
#[test]
fn idle_timeout_sender_later_send_after_supersedes_earlier() {
let (tx, mut rx) = oneshot::channel::<i32>();
let idle_timeout = IdleTimeoutSender::new(tx);
// First timer: long timeout.
idle_timeout.end_run_after(Duration::from_secs(10), 1);
// Second timer: short timeout. The first is implicitly cancelled.
idle_timeout.end_run_after(Duration::from_millis(50), 2);
std::thread::sleep(Duration::from_millis(100));
assert_eq!(rx.try_recv().unwrap(), Some(2));
}
#[test]
fn task_env_vars_include_parent_run_id_when_present() {
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
let env_vars = task_env_vars(Some(&task_id), Some("parent-run-123"), Harness::Claude);
let overrides_allowed = ChannelState::channel().allows_server_url_overrides();
assert_eq!(
env_vars.get(&OsString::from(OZ_RUN_ID_ENV)),
Some(&OsString::from(task_id.to_string()))
);
assert_eq!(
env_vars.get(&OsString::from(OZ_PARENT_RUN_ID_ENV)),
Some(&OsString::from("parent-run-123"))
);
assert_eq!(
env_vars.get(&OsString::from(OZ_HARNESS_ENV)),
Some(&OsString::from("claude"))
);
assert_eq!(
env_vars.get(&OsString::from(OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV)),
Some(&OsString::from("1"))
);
assert_eq!(
env_vars.get(&OsString::from(
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV
)),
Some(&OsString::from("1"))
);
assert!(env_vars
.get(&OsString::from(OZ_CLI_ENV))
.is_some_and(|value| !value.is_empty()));
let server_root_url = ChannelState::server_root_url().into_owned();
if overrides_allowed && !server_root_url.is_empty() {
assert_eq!(
env_vars.get(&OsString::from(SERVER_ROOT_URL_OVERRIDE_ENV)),
Some(&OsString::from(server_root_url))
);
} else {
assert!(!env_vars.contains_key(&OsString::from(SERVER_ROOT_URL_OVERRIDE_ENV)));
}
let ws_server_url = ChannelState::ws_server_url().into_owned();
if overrides_allowed && !ws_server_url.is_empty() {
assert_eq!(
env_vars.get(&OsString::from(WS_SERVER_URL_OVERRIDE_ENV)),
Some(&OsString::from(ws_server_url))
);
} else {
assert!(!env_vars.contains_key(&OsString::from(WS_SERVER_URL_OVERRIDE_ENV)));
}
if overrides_allowed {
match ChannelState::session_sharing_server_url() {
Some(url) if !url.is_empty() => assert_eq!(
env_vars.get(&OsString::from(SESSION_SHARING_SERVER_URL_OVERRIDE_ENV)),
Some(&OsString::from(url.into_owned()))
),
_ => {
assert!(!env_vars
.contains_key(&OsString::from(SESSION_SHARING_SERVER_URL_OVERRIDE_ENV)))
}
}
} else {
assert!(!env_vars.contains_key(&OsString::from(SESSION_SHARING_SERVER_URL_OVERRIDE_ENV)));
}
}
#[test]
fn task_env_vars_omit_parent_run_id_when_absent() {
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
let env_vars = task_env_vars(Some(&task_id), None, Harness::Oz);
let overrides_allowed = ChannelState::channel().allows_server_url_overrides();
assert_eq!(
env_vars.get(&OsString::from(OZ_RUN_ID_ENV)),
Some(&OsString::from(task_id.to_string()))
);
assert!(!env_vars.contains_key(&OsString::from(OZ_PARENT_RUN_ID_ENV)));
assert_eq!(
env_vars.get(&OsString::from(OZ_HARNESS_ENV)),
Some(&OsString::from("oz"))
);
assert!(!env_vars.contains_key(&OsString::from(OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV)));
assert!(!env_vars.contains_key(&OsString::from(
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV
)));
assert_eq!(
env_vars.contains_key(&OsString::from(SERVER_ROOT_URL_OVERRIDE_ENV)),
overrides_allowed && !ChannelState::server_root_url().is_empty()
);
assert_eq!(
env_vars.contains_key(&OsString::from(WS_SERVER_URL_OVERRIDE_ENV)),
overrides_allowed && !ChannelState::ws_server_url().is_empty()
);
}
#[test]
fn task_env_vars_enable_external_parent_listener_for_claude_runs_without_parent_run_id() {
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440002".parse().unwrap();
let env_vars = task_env_vars(Some(&task_id), None, Harness::Claude);
assert_eq!(
env_vars.get(&OsString::from(OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV)),
Some(&OsString::from("1"))
);
assert_eq!(
env_vars.get(&OsString::from(
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV
)),
Some(&OsString::from("1"))
);
}
#[test]
#[serial_test::serial]
fn task_env_vars_propagate_message_listener_state_root_with_legacy_alias() {
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440003".parse().unwrap();
std::env::set_var(
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
"/tmp/message-listener-root",
);
let env_vars = task_env_vars(Some(&task_id), None, Harness::Claude);
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
assert_eq!(
env_vars.get(&OsString::from(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV)),
Some(&OsString::from("/tmp/message-listener-root"))
);
assert_eq!(
env_vars.get(&OsString::from(LEGACY_OZ_PARENT_STATE_ROOT_ENV)),
Some(&OsString::from("/tmp/message-listener-root"))
);
}
#[test]
fn task_env_vars_can_use_opencode_harness() {
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440004".parse().unwrap();
let env_vars = task_env_vars(Some(&task_id), Some("parent-run-456"), Harness::OpenCode);
assert_eq!(
env_vars.get(&OsString::from(OZ_HARNESS_ENV)),
Some(&OsString::from("opencode"))
);
}
#[test]
fn json_format_output_includes_filename_for_file_artifact_created_event() {
let output = AIAgentOutput {
messages: vec![AIAgentOutputMessage::artifact_created(
MessageId::new("message-1".to_string()),
ArtifactCreatedData::File {
artifact_uid: "artifact-uid".to_string(),
filepath: "outputs/report.txt".to_string(),
filename: "report.txt".to_string(),
mime_type: "text/plain".to_string(),
description: Some("Build output for the latest run".to_string()),
size_bytes: 42,
},
)],
..Default::default()
};
let mut bytes = Vec::new();
super::output::json::format_output(&output, &mut bytes).expect("json formatting should work");
let value: serde_json::Value =
serde_json::from_slice(&bytes).expect("output should be valid json");
assert_eq!(value["type"], "artifact_created");
assert_eq!(value["artifact_type"], "file");
assert_eq!(value["artifact_uid"], "artifact-uid");
assert_eq!(value["filepath"], "outputs/report.txt");
assert_eq!(value["filename"], "report.txt");
assert_eq!(value["mime_type"], "text/plain");
assert_eq!(value["description"], "Build output for the latest run");
assert_eq!(value["size_bytes"], 42);
}
#[test]
fn json_format_input_omits_filepath_and_description_for_proto_upload_result() {
let input = AIAgentInput::ActionResult {
result: AIAgentActionResult {
id: "tool-call-1".to_string().into(),
task_id: TaskId::new("task-1".to_string()),
result: AIAgentActionResultType::UploadArtifact(UploadArtifactResult::Success {
artifact_uid: "artifact-123".to_string(),
filepath: None,
mime_type: "text/plain".to_string(),
description: None,
size_bytes: 42,
}),
},
context: Arc::from([]),
};
let mut bytes = Vec::new();
super::output::json::format_input(&input, &mut bytes).expect("json formatting should work");
let value: serde_json::Value =
serde_json::from_slice(&bytes).expect("output should be valid json");
assert_eq!(value["type"], "tool_result");
assert_eq!(value["tool"], "upload_artifact");
assert_eq!(value["artifact_uid"], "artifact-123");
assert_eq!(value["mime_type"], "text/plain");
assert_eq!(value["size_bytes"], 42);
assert!(value.get("filepath").is_none());
assert!(value.get("description").is_none());
}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
use std::process;
use anyhow::{anyhow, Result};
use serde_json::json;
use warp_cli::federate::{FederateCommand, IssueGcpTokenArgs, IssueTokenArgs};
use warp_cli::{agent::OutputFormat, GlobalOptions};
use warp_core::{features::FeatureFlag, report_error};
use warp_managed_secrets::ManagedSecretManager;
use warpui::{platform::TerminationMode, AppContext, SingletonEntity as _};
use super::common::set_ambient_task_context_from_run_id;
/// Run identity federation commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: FederateCommand,
) -> Result<()> {
if !FeatureFlag::OzIdentityFederation.is_enabled() {
return Err(anyhow::anyhow!("This feature is not enabled"));
}
match command {
FederateCommand::IssueToken(args) => issue_token(ctx, args, global_options.output_format),
FederateCommand::IssueGcpToken(args) => issue_gcp_token(ctx, args),
}
}
fn issue_token(
ctx: &mut AppContext,
args: IssueTokenArgs,
output_format: OutputFormat,
) -> Result<()> {
// Set the task ID so the ambient workload token header is sent.
set_ambient_task_context_from_run_id(ctx, &args.run_id)?;
let duration: std::time::Duration = args.duration.into();
let audience = args.audience;
let subject_template = match args.subject_template {
Some(template) => vec1::Vec1::try_from_vec(template)
.map_err(|_| anyhow::anyhow!("--subject-template requires at least one value"))?,
None => vec1::vec1!["principal".to_owned()],
};
ManagedSecretManager::handle(ctx).update(ctx, move |manager, ctx| {
let future =
manager.issue_task_identity_token(warp_managed_secrets::client::IdentityTokenOptions {
audience,
requested_duration: duration,
subject_template,
});
ctx.spawn(future, move |_, result, ctx| match result {
Ok(token) => {
let token_value = token.token;
let expires_at = token.expires_at.to_rfc3339();
let issuer = token.issuer;
match output_format {
OutputFormat::Json | OutputFormat::Ndjson => {
let output = json!({
"token": token_value,
"expires_at": expires_at,
"issuer": issuer,
});
let output =
serde_json::to_string(&output).expect("token output should serialize");
println!("{output}");
}
OutputFormat::Text => {
println!("{token_value}");
}
OutputFormat::Pretty => {
println!("Token: {token_value}");
println!("Expires at: {expires_at}");
println!("Issuer: {issuer}");
}
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
Ok(())
}
fn issue_gcp_token(ctx: &mut AppContext, args: IssueGcpTokenArgs) -> Result<()> {
// Set the task ID so the ambient workload token header is sent.
set_ambient_task_context_from_run_id(ctx, &args.run_id)?;
let duration: std::time::Duration = args.duration.into();
let audience = args.audience;
let token_type = args.token_type;
let output_file = args.output_file;
ManagedSecretManager::handle(ctx).update(ctx, move |manager, ctx| {
let future =
manager.issue_gcp_workload_identity_federation_token(audience, token_type, duration);
ctx.spawn(future, move |_, result, ctx| match result {
Ok(token) => {
let output =
serde_json::to_string(&token).expect("gcp token output should serialize");
// If we can't cache the token, report an error but don't fail the command.
if let Some(output_path) = output_file {
if let Err(err) = std::fs::write(&output_path, &output) {
report_error!(anyhow!(err)
.context(format!("Error writing GCP token to {output_path}")));
}
}
println!("{output}");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
// The GCP SDK requires the executable to print a JSON error to stderr
// and exit with a non-zero status code. Because of this, we exit
// directly instead of via `ctx.terminate_app` (which would print a
// non-JSON error).
let output =
serde_json::to_string(&err).expect("gcp error output should serialize");
eprintln!("{output}");
process::exit(1);
}
});
});
Ok(())
}
+213
View File
@@ -0,0 +1,213 @@
//! `warp harness-support` CLI dispatch and the singleton model all subcommands run async work on.
//!
//! Subcommands:
//! - [`ping`] — fetches the current run by task ID and prints its info.
//! - [`report_artifact`] — reports an artifact (e.g. a PR) back to the Oz platform.
use anyhow::Result;
use warp_cli::agent::OutputFormat;
use warp_cli::harness_support::{
FinishTaskArgs, HarnessSupportArgs, HarnessSupportCommand, NotifyUserArgs, ReportArtifactArgs,
ReportArtifactCommand, TaskStatus,
};
use warp_cli::GlobalOptions;
use warp_core::features::FeatureFlag;
use warpui::{platform::TerminationMode, AppContext, ModelHandle, SingletonEntity};
use super::common::set_ambient_task_context_from_run_id;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::artifacts::Artifact;
use crate::server::server_api::ServerApiProvider;
/// Run harness-support commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
args: HarnessSupportArgs,
) -> Result<()> {
if !FeatureFlag::AgentHarness.is_enabled() {
return Err(anyhow::anyhow!("This feature is not enabled"));
}
// Store the run ID so that it's included on all server requests, along with a workload token.
let task_id = set_ambient_task_context_from_run_id(ctx, &args.run_id)?;
let runner = ctx.add_singleton_model(|_| HarnessSupportRunner);
match args.command {
HarnessSupportCommand::Ping => ping(ctx, runner, task_id, global_options.output_format),
HarnessSupportCommand::ReportArtifact(report_args) => {
report_artifact(ctx, runner, report_args, global_options.output_format)
}
HarnessSupportCommand::NotifyUser(notify_args) => {
notify_user(ctx, runner, notify_args, global_options.output_format)
}
HarnessSupportCommand::FinishTask(finish_args) => {
finish_task(ctx, runner, finish_args, global_options.output_format)
}
}
}
/// Fetch the current run by ID and print its info.
fn ping(
ctx: &mut AppContext,
runner: ModelHandle<HarnessSupportRunner>,
task_id: AmbientAgentTaskId,
output_format: OutputFormat,
) -> Result<()> {
runner.update(ctx, |_, ctx| {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
ctx.spawn(
async move {
let task = ai_client.get_ambient_agent_task(&task_id).await?;
Ok(task)
},
move |_, result, ctx| match result {
Ok(task) => {
match output_format {
OutputFormat::Json | OutputFormat::Ndjson => {
let json = serde_json::to_string(&task).unwrap_or_else(|e| {
serde_json::json!({"error": e.to_string()}).to_string()
});
println!("{json}");
}
OutputFormat::Pretty | OutputFormat::Text => {
super::ambient::print_tasks(&[task]);
}
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
},
);
});
Ok(())
}
/// Report an artifact back to the Oz platform.
fn report_artifact(
ctx: &mut AppContext,
runner: ModelHandle<HarnessSupportRunner>,
args: ReportArtifactArgs,
output_format: OutputFormat,
) -> Result<()> {
runner.update(ctx, |_, ctx| {
let client = ServerApiProvider::as_ref(ctx).get_harness_support_client();
let artifact = match args.command {
ReportArtifactCommand::PullRequest(pr_args) => Artifact::PullRequest {
url: pr_args.url,
branch: pr_args.branch,
repo: None,
number: None,
},
};
ctx.spawn(
async move { client.report_artifact(&artifact).await },
move |_, result, ctx| match result {
Ok(response) => {
match output_format {
OutputFormat::Json | OutputFormat::Ndjson => {
let json = serde_json::to_string(&response).unwrap_or_else(|e| {
serde_json::json!({"error": e.to_string()}).to_string()
});
println!("{json}");
}
OutputFormat::Pretty | OutputFormat::Text => {
println!("Artifact reported: {}", response.artifact_uid);
}
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
},
);
});
Ok(())
}
/// Send a progress notification to the task's originating platform.
fn notify_user(
ctx: &mut AppContext,
runner: ModelHandle<HarnessSupportRunner>,
args: NotifyUserArgs,
output_format: OutputFormat,
) -> Result<()> {
runner.update(ctx, |_, ctx| {
let client = ServerApiProvider::as_ref(ctx).get_harness_support_client();
ctx.spawn(
async move { client.notify_user(&args.message).await },
move |_, result, ctx| match result {
Ok(()) => {
match output_format {
OutputFormat::Json | OutputFormat::Ndjson => {
println!("{{}}");
}
OutputFormat::Pretty | OutputFormat::Text => {
println!("Notification sent.");
}
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
},
);
});
Ok(())
}
/// Report task completion or failure.
fn finish_task(
ctx: &mut AppContext,
runner: ModelHandle<HarnessSupportRunner>,
args: FinishTaskArgs,
output_format: OutputFormat,
) -> Result<()> {
runner.update(ctx, |_, ctx| {
let client = ServerApiProvider::as_ref(ctx).get_harness_support_client();
ctx.spawn(
async move {
let success = args.status == TaskStatus::Success;
client.finish_task(success, &args.summary).await
},
move |_, result, ctx| match result {
Ok(()) => {
match output_format {
OutputFormat::Json | OutputFormat::Ndjson => {
println!("{{}}");
}
OutputFormat::Pretty | OutputFormat::Text => {
println!("Task finished.");
}
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
},
);
});
Ok(())
}
/// Singleton model for running async harness-support operations.
struct HarnessSupportRunner;
impl warpui::Entity for HarnessSupportRunner {
type Event = ();
}
impl SingletonEntity for HarnessSupportRunner {}
+524
View File
@@ -0,0 +1,524 @@
use crate::server::server_api::ServerApiProvider;
use futures::future;
use warp_cli::{
integration::{CreateIntegrationArgs, IntegrationCommand, UpdateIntegrationArgs},
provider::ProviderType,
GlobalOptions,
};
use warp_graphql::mutations::create_simple_integration::CreateSimpleIntegrationOutput;
use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
use warp_graphql::queries::get_simple_integrations::SimpleIntegrationsOutput;
use warpui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
use super::common::{EnvironmentChoice, ResolveConfigurationError};
use super::integration_output;
use super::oauth_flow::poll_oauth_until_terminal;
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: IntegrationCommand,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| IntegrationCommandRunner);
match command {
IntegrationCommand::Create(args) => {
runner.update(ctx, |runner, ctx| runner.create(args, ctx));
}
IntegrationCommand::Update(args) => {
runner.update(ctx, |runner, ctx| runner.update(args, ctx));
}
IntegrationCommand::List => {
runner.update(ctx, |runner, ctx| runner.list(global_options, ctx));
}
}
Ok(())
}
struct IntegrationCommandRunner;
impl IntegrationCommandRunner {
fn list(&self, global_options: GlobalOptions, ctx: &mut ModelContext<Self>) {
// Hardcoded set of providers that this client knows how to render.
let providers = vec![ProviderType::Linear, ProviderType::Slack];
let provider_slugs: Vec<String> = providers.into_iter().map(|p| p.slug()).collect();
let integrations_client = ServerApiProvider::as_ref(ctx).get_integrations_client();
let list_future = async move {
integrations_client
.list_simple_integrations(provider_slugs)
.await
};
ctx.spawn(
list_future,
move |_, result: anyhow::Result<SimpleIntegrationsOutput>, ctx| match result {
Ok(output) => {
integration_output::print_integrations(&output, global_options.output_format);
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
}
},
);
}
fn create(&self, args: CreateIntegrationArgs, ctx: &mut ModelContext<Self>) {
let refresh_future = super::common::refresh_workspace_metadata(ctx);
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
let setup_future = future::try_join(refresh_future, warp_drive_sync_future);
ctx.spawn(setup_future, move |runner, setup_result, ctx| {
if let Err(err) = setup_result {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
return;
}
let loaded_file = match args.config_file.file.as_deref() {
Some(path) => match super::config_file::load_config_file(path) {
Ok(file) => Some(file),
Err(err) => {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
return;
}
},
None => None,
};
let integration_type = args.provider.slug();
let enabled = true;
let is_update = false;
let cli_mcp_servers =
match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) {
Ok(mcp_servers) => mcp_servers,
Err(err) => {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
return;
}
};
let mut merged_config = super::config_file::merge_with_precedence(
loaded_file.as_ref(),
crate::ai::ambient_agents::AgentConfigSnapshot {
name: None,
environment_id: args.environment.environment.clone(),
model_id: args.model.model.clone(),
base_prompt: args.prompt.clone(),
mcp_servers: cli_mcp_servers,
profile_id: None,
worker_host: args.worker_host.clone(),
skill_spec: None,
// TODO(QUALITY-295): Support computer use flag in integrations.
computer_use_enabled: None,
// TODO(REMOTE-1134): Support harness selection for integrations.
harness: None,
harness_auth_secrets: None,
},
);
// We must wait until after workspace metadata is refreshed to check available LLMs.
let model_id = match merged_config
.model_id
.as_deref()
.map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx))
.transpose()
{
Ok(model_id) => model_id.map(|model_id| model_id.to_string()),
Err(err) => {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
return;
}
};
let base_prompt = merged_config.base_prompt.take();
let worker_host = merged_config.worker_host.take();
let mcp_servers_json = match merged_config.mcp_servers.take() {
Some(map) => match serde_json::to_string(&map) {
Ok(json) => Some(json),
Err(err) => {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err.into())));
return;
}
},
None => None,
};
//If the user didn't explicitly request no environment, load environment from the config
let mut environment_args = args.environment;
if environment_args.environment.is_none() && !environment_args.no_environment {
environment_args.environment = merged_config.environment_id.take();
}
let environment_uid = match EnvironmentChoice::resolve_for_create(environment_args, ctx)
{
Ok(EnvironmentChoice::None) => {
eprintln!("Creating integration without an environment.");
None
}
Ok(EnvironmentChoice::Environment { id, .. }) => {
eprintln!("Creating integration with environment {id}.");
Some(id)
}
Err(ResolveConfigurationError::Canceled) => {
eprintln!("Integration creation canceled.");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(anyhow::anyhow!(err), ctx);
return;
}
};
runner.start_create_or_update_flow(
ctx,
integration_type,
environment_uid,
base_prompt,
model_id,
mcp_servers_json,
None,
worker_host,
enabled,
is_update,
1,
);
});
}
#[allow(clippy::too_many_arguments)]
fn start_create_or_update_flow(
&self,
ctx: &mut ModelContext<Self>,
integration_type: String,
environment_uid: Option<String>,
base_prompt: Option<String>,
model_id: Option<String>,
mcp_servers_json: Option<String>,
remove_mcp_server_names: Option<Vec<String>>,
worker_host: Option<String>,
enabled: bool,
is_update: bool,
attempt: u32,
) {
const MAX_CREATE_ATTEMPTS: u32 = 8;
let action = if is_update { "update" } else { "creation" };
if attempt > MAX_CREATE_ATTEMPTS {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!(
"Exceeded maximum number of integration creation attempts ({}). Retry.",
MAX_CREATE_ATTEMPTS
))),
);
return;
}
let integrations_client = ServerApiProvider::as_ref(ctx).get_integrations_client();
let future_integration_type = integration_type.clone();
let future_environment_uid = environment_uid.clone();
let future_base_prompt = base_prompt.clone();
let future_model_id = model_id.clone();
let future_mcp_servers_json = mcp_servers_json.clone();
let future_remove_mcp_server_names = remove_mcp_server_names.clone();
let future_worker_host = worker_host.clone();
let future_is_update = is_update;
let create_future = async move {
integrations_client
.create_or_update_simple_integration(
future_integration_type,
future_is_update,
future_environment_uid,
future_base_prompt,
future_model_id,
future_mcp_servers_json,
future_remove_mcp_server_names,
future_worker_host,
enabled,
)
.await
};
ctx.spawn(
create_future,
move |_runner, result: anyhow::Result<CreateSimpleIntegrationOutput>, ctx| {
match result {
Ok(output) => {
println!("{}", output.message);
let auth_url = output.auth_url;
let tx_id = output.tx_id;
match (auth_url, tx_id) {
(Some(auth_url), Some(tx_id)) => {
// We have another auth step: open URL and poll txId.
println!("Authorize the provider here: {auth_url}\n");
ctx.open_url(&auth_url);
let integrations_client = ServerApiProvider::as_ref(ctx)
.get_integrations_client();
let tx_id = tx_id.into_inner();
let poll_future =
poll_oauth_until_terminal(integrations_client, tx_id);
let next_integration_type = integration_type.clone();
let next_environment_uid = environment_uid.clone();
let next_base_prompt = base_prompt.clone();
let next_model_id = model_id.clone();
let next_mcp_servers_json = mcp_servers_json.clone();
let next_remove_mcp_server_names = remove_mcp_server_names.clone();
let next_worker_host = worker_host.clone();
let next_enabled = enabled;
let next_is_update = is_update;
let next_attempt = attempt + 1;
ctx.spawn(
poll_future,
move |runner, poll_result, ctx| {
match poll_result {
Ok(OauthConnectTxStatus::Completed) => {
// Inner loop done; try create or update again (outer loop).
// This may happen multiple times if the user needs to authorize multiple services.
runner.start_create_or_update_flow(
ctx,
next_integration_type,
next_environment_uid,
next_base_prompt,
next_model_id,
next_mcp_servers_json,
next_remove_mcp_server_names,
next_worker_host,
next_enabled,
next_is_update,
next_attempt,
);
}
Ok(OauthConnectTxStatus::Failed) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!("OAuth authorization failed."))),
);
}
Ok(OauthConnectTxStatus::Expired) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!("OAuth authorization expired."))),
);
}
Ok(OauthConnectTxStatus::Pending)
| Ok(OauthConnectTxStatus::InProgress) => {
// Should not be returned by poll_oauth_until_terminal.
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!("Unexpected non-terminal OAuth status returned"))),
);
}
Err(err) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!("Error polling OAuth status: {err}"))),
);
}
}
},
);
}
(Some(auth_url), None) => {
println!("Authorize the provider here: {auth_url}\n");
ctx.open_url(&auth_url);
println!(
"After authorizing, re-run the command to continue the integration {action} process.",
);
ctx.terminate_app(
TerminationMode::ForceTerminate,
None,
);
}
(None, Some(_)) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!("Server did not return an authURL for the integration creation process."))),
);
}
(None, None) => {
// No more auth steps; finalize.
if output.success {
ctx.terminate_app(
TerminationMode::ForceTerminate,
None,
);
} else {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(anyhow::anyhow!("Integration creation reported failure: {}", output.message))),
);
}
}
}
}
Err(err) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(err)),
);
}
}
},
);
}
fn update(&self, args: UpdateIntegrationArgs, ctx: &mut ModelContext<Self>) {
let refresh_future = super::common::refresh_workspace_metadata(ctx);
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
let setup_future = future::try_join(refresh_future, warp_drive_sync_future);
ctx.spawn(setup_future, move |runner, setup_result, ctx| {
if let Err(err) = setup_result {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
return;
}
let loaded_file = match args.config_file.file.as_deref() {
Some(path) => match super::config_file::load_config_file(path) {
Ok(file) => Some(file),
Err(err) => {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
return;
}
},
None => None,
};
let remove_mcp = args.remove_mcp.clone();
let integration_type = args.provider.slug();
let enabled = true;
let is_update = true;
let cli_mcp_servers =
match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) {
Ok(mcp_servers) => mcp_servers,
Err(err) => {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
return;
}
};
let mut merged_config = super::config_file::merge_with_precedence(
loaded_file.as_ref(),
crate::ai::ambient_agents::AgentConfigSnapshot {
name: None,
environment_id: args.environment.environment.clone(),
model_id: args.model.model.clone(),
base_prompt: args.prompt.clone(),
mcp_servers: cli_mcp_servers,
profile_id: None,
worker_host: args.worker_host.clone(),
skill_spec: None,
// TODO(QUALITY-295): Support computer use flag in integrations.
computer_use_enabled: None,
// TODO(REMOTE-1134): Support harness selection for integrations.
harness: None,
harness_auth_secrets: None,
},
);
// We must wait until after workspace metadata is refreshed to check available LLMs.
let model_id = match merged_config
.model_id
.as_deref()
.map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx))
.transpose()
{
Ok(model_id) => model_id.map(|model_id| model_id.to_string()),
Err(err) => {
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
return;
}
};
let base_prompt = merged_config.base_prompt.take();
let worker_host = merged_config.worker_host.take();
// MCP update semantics are patch-only:
// - `mcp_servers_json` adds/overwrites MCP servers.
// - `remove_mcp_server_names` removes MCP servers.
// If both are present, removals win by filtering removed names out of the JSON payload.
let mcp_servers_json = match merged_config.mcp_servers.take() {
Some(mut map) => {
for name in &remove_mcp {
map.remove(name);
}
if map.is_empty() {
None
} else {
match serde_json::to_string(&map) {
Ok(json) => Some(json),
Err(err) => {
ctx.terminate_app(
TerminationMode::ForceTerminate,
Some(Err(err.into())),
);
return;
}
}
}
}
None => None,
};
let remove_mcp_server_names = if args.remove_mcp.is_empty() {
None
} else {
Some(args.remove_mcp)
};
if args.environment.remove_environment {
// Explicitly requested to update without an environment.
runner.start_create_or_update_flow(
ctx,
integration_type,
Some(String::new()),
base_prompt,
model_id,
mcp_servers_json,
remove_mcp_server_names,
worker_host,
enabled,
is_update,
1,
);
return;
}
let environment_uid = merged_config.environment_id.take();
runner.start_create_or_update_flow(
ctx,
integration_type,
environment_uid,
base_prompt,
model_id,
mcp_servers_json,
remove_mcp_server_names,
worker_host,
enabled,
is_update,
1,
);
});
}
}
impl warpui::Entity for IntegrationCommandRunner {
type Event = ();
}
impl SingletonEntity for IntegrationCommandRunner {}
+355
View File
@@ -0,0 +1,355 @@
use chrono::{DateTime, Utc};
use comfy_table::{presets::UTF8_FULL, Cell, Table};
use serde::Serialize;
use serde_json::{Map, Value};
use warp_cli::agent::OutputFormat;
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::util::time_format::format_approx_duration_from_now_utc;
use warp_graphql::queries::get_simple_integrations::{
ListedSimpleIntegrationConfig, SimpleIntegration, SimpleIntegrationConnectionStatus,
SimpleIntegrationsOutput,
};
const MAX_LINE_WIDTH: usize = 90;
/// Print simple integrations.
pub fn print_integrations(graphql_output: &SimpleIntegrationsOutput, output_format: OutputFormat) {
if let Some(message) = &graphql_output.message {
eprintln!("{message}");
return;
}
let integrations = &graphql_output.integrations;
if integrations.is_empty() {
println!("No integrations found.");
return;
}
match output_format {
OutputFormat::Json | OutputFormat::Ndjson => {
// Convert to serializable format and use common output utilities
let integration_infos: Vec<IntegrationInfo> = integrations
.iter()
.map(IntegrationInfo::from_graphql)
.collect();
output::print_list(integration_infos, output_format);
}
OutputFormat::Pretty | OutputFormat::Text => {
// Use the existing card-style layout for pretty/text output
if integrations.len() == 1 {
println!("\nIntegration:");
} else {
println!("\nIntegrations:");
}
for integration in integrations {
print_integration_card(integration);
}
}
}
}
fn render_labeled_wrapped_lines(label: &str, lines: &[String], width: usize) -> String {
let indent = " ".repeat(label.len() + 2); // align under "{label}: "
let mut out = String::new();
for (idx, line) in lines.iter().enumerate() {
let wrapped = crate::ai::agent_sdk::text_layout::word_wrap(line, width);
for (widx, wline) in wrapped.iter().enumerate() {
if !out.is_empty() {
out.push('\n');
}
if idx == 0 && widx == 0 {
out.push_str(&format!("{label}: {wline}"));
} else {
out.push_str(&indent);
out.push_str(wline);
}
}
}
out
}
fn format_mcp_server_display(name: &str, config: &Value) -> String {
let Some(obj) = config.as_object() else {
return name.to_string();
};
if let Some(url) = obj
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
{
return format!("{name}: {url}");
}
if let Some(command) = obj
.get("command")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
{
let args = obj
.get("args")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<String>>()
})
.unwrap_or_default();
if args.is_empty() {
return format!("{name}: {command}");
}
return format!("{name}: {command} {}", args.join(" "));
}
if let Some(warp_id) = obj
.get("warp_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
{
return format!("{name}: warp_id={warp_id}");
}
name.to_string()
}
fn mcp_server_display_lines(config: &ListedSimpleIntegrationConfig) -> Vec<String> {
let json = config.mcp_servers_json.trim();
if json.is_empty() || json == "{}" {
return Vec::new();
}
let Ok(map) = serde_json::from_str::<Map<String, Value>>(json) else {
return Vec::new();
};
let mut entries: Vec<(String, Value)> = map.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
entries
.into_iter()
.map(|(name, cfg)| format_mcp_server_display(&name, &cfg))
.collect()
}
fn print_integration_card(integration: &SimpleIntegration) {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
// Row 1: provider name (title-cased slug) and description, no label
let provider_name =
crate::ai::agent_sdk::text_layout::title_case_identifier(&integration.provider_slug);
let title_row = crate::ai::agent_sdk::text_layout::render_labeled_wrapped_field(
&provider_name,
&integration.description,
MAX_LINE_WIDTH,
);
table.add_row(vec![title_row]);
// Row 2: Status: <emoji> Status description
let emoji = status_emoji(integration.connection_status);
let explanation = status_explanation(integration.connection_status);
let status_text = format!("{emoji} {explanation}");
let status_row = crate::ai::agent_sdk::text_layout::render_labeled_wrapped_field(
"Status",
&status_text,
MAX_LINE_WIDTH,
);
table.add_row(vec![status_row]);
// Environment row.
let env_value = match &integration.integration_config {
Some(ListedSimpleIntegrationConfig {
environment_uid, ..
}) if !environment_uid.is_empty() => environment_uid.clone(),
_ => "(none)".to_string(),
};
let env_row = crate::ai::agent_sdk::text_layout::render_labeled_wrapped_field(
"Environment",
&env_value,
MAX_LINE_WIDTH,
);
table.add_row(vec![env_row]);
// Model row (only if present).
if let Some(ListedSimpleIntegrationConfig { model_id, .. }) = &integration.integration_config {
if !model_id.is_empty() {
let model_row = crate::ai::agent_sdk::text_layout::render_labeled_wrapped_field(
"Model",
model_id,
MAX_LINE_WIDTH,
);
table.add_row(vec![model_row]);
}
}
// Base prompt row (only if present).
if let Some(ListedSimpleIntegrationConfig { base_prompt, .. }) = &integration.integration_config
{
if !base_prompt.is_empty() {
let base_prompt_row = crate::ai::agent_sdk::text_layout::render_labeled_wrapped_field(
"Base prompt",
base_prompt,
MAX_LINE_WIDTH,
);
table.add_row(vec![base_prompt_row]);
}
}
// MCP servers row (only if present).
if let Some(config) = &integration.integration_config {
let lines = mcp_server_display_lines(config);
if !lines.is_empty() {
let row = render_labeled_wrapped_lines("MCP servers", &lines, MAX_LINE_WIDTH);
table.add_row(vec![row]);
}
}
// Timestamps: keep created/updated in a single row, no label.
let mut created_updated = String::new();
if let Some(created) = integration.created_at {
let dt = created.utc();
let formatted = format_approx_duration_from_now_utc(dt);
created_updated.push_str(&format!("Created: {formatted}"));
}
if let Some(updated) = integration.updated_at {
let dt = updated.utc();
let formatted = format_approx_duration_from_now_utc(dt);
if !created_updated.is_empty() {
created_updated.push_str(" | ");
}
created_updated.push_str(&format!("Updated: {formatted}"));
}
if !created_updated.is_empty() {
let wrapped =
crate::ai::agent_sdk::text_layout::word_wrap(&created_updated, MAX_LINE_WIDTH);
let ts_cell = wrapped.join("\n");
table.add_row(vec![ts_cell]);
}
println!("{table}");
}
fn status_emoji(status: SimpleIntegrationConnectionStatus) -> &'static str {
match status {
SimpleIntegrationConnectionStatus::NotConnected => "❌",
// TODO(bens): these warning emojis render weirdly, maybe switch?
SimpleIntegrationConnectionStatus::ConnectionError => "⚠️",
SimpleIntegrationConnectionStatus::IntegrationNotConfigured => "⚠️",
SimpleIntegrationConnectionStatus::NotEnabled => "⚠️",
SimpleIntegrationConnectionStatus::Active => "✅",
}
}
fn status_explanation(status: SimpleIntegrationConnectionStatus) -> &'static str {
match status {
SimpleIntegrationConnectionStatus::NotConnected => "This integration is not connected.",
SimpleIntegrationConnectionStatus::ConnectionError => {
"This provider is connected but there is an error."
}
SimpleIntegrationConnectionStatus::IntegrationNotConfigured => {
"Connection is active, but the agent integration has not been configured yet."
}
SimpleIntegrationConnectionStatus::NotEnabled => {
"Integration is configured but currently disabled."
}
SimpleIntegrationConnectionStatus::Active => "Integration is connected and enabled.",
}
}
/// Serializable integration info for output.
#[derive(Serialize)]
struct IntegrationInfo {
provider: String,
description: String,
status: String,
environment_uid: Option<String>,
base_prompt: Option<String>,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
created_at_formatted: String,
#[serde(skip_serializing)]
updated_at_formatted: String,
}
impl IntegrationInfo {
fn from_graphql(integration: &SimpleIntegration) -> Self {
let provider =
crate::ai::agent_sdk::text_layout::title_case_identifier(&integration.provider_slug);
let status = status_explanation(integration.connection_status).to_string();
let environment_uid = integration.integration_config.as_ref().and_then(|config| {
if config.environment_uid.is_empty() {
None
} else {
Some(config.environment_uid.clone())
}
});
let base_prompt = integration.integration_config.as_ref().and_then(|config| {
if config.base_prompt.is_empty() {
None
} else {
Some(config.base_prompt.clone())
}
});
let created_at = integration.created_at.map(|t| t.utc());
let updated_at = integration.updated_at.map(|t| t.utc());
let created_at_formatted = created_at
.map(format_approx_duration_from_now_utc)
.unwrap_or_else(|| "Unknown".to_string());
let updated_at_formatted = updated_at
.map(format_approx_duration_from_now_utc)
.unwrap_or_else(|| "Unknown".to_string());
Self {
provider,
description: integration.description.clone(),
status,
environment_uid,
base_prompt,
created_at,
updated_at,
created_at_formatted,
updated_at_formatted,
}
}
}
impl TableFormat for IntegrationInfo {
fn header() -> Vec<Cell> {
vec![
Cell::new("Provider"),
Cell::new("Description"),
Cell::new("Status"),
Cell::new("Environment"),
Cell::new("Created"),
Cell::new("Updated"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.provider),
Cell::new(&self.description),
Cell::new(&self.status),
Cell::new(self.environment_uid.as_deref().unwrap_or("(none)")),
Cell::new(&self.created_at_formatted),
Cell::new(&self.updated_at_formatted),
]
}
}
+68
View File
@@ -0,0 +1,68 @@
use comfy_table::Cell;
use serde::Serialize;
use warp_cli::{mcp::MCPCommand, GlobalOptions};
use warpui::{AppContext, ModelContext, SingletonEntity};
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::ai::mcp::TemplatableMCPServerManager;
use crate::server::cloud_objects::update_manager::UpdateManager;
/// Handle MCP-related CLI commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: MCPCommand,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| MCPCommandRunner);
match command {
MCPCommand::List => {
runner.update(ctx, |runner, ctx| runner.list(global_options, ctx));
Ok(())
}
}
}
/// Singleton model for running async work as part of MCP CLI commands.
struct MCPCommandRunner;
impl MCPCommandRunner {
fn list(&self, global_options: GlobalOptions, ctx: &mut ModelContext<Self>) {
let initial_sync = UpdateManager::as_ref(ctx).initial_load_complete();
ctx.spawn(initial_sync, move |_, _, ctx| {
let mut servers = TemplatableMCPServerManager::get_all_runnable_mcp_servers(ctx);
servers.sort_by_key(|(uuid, _)| *uuid);
output::print_list(
servers
.into_iter()
.map(|(uuid, name)| MCPServerInfo { uuid, name }),
global_options.output_format,
);
ctx.terminate_app(warpui::platform::TerminationMode::ForceTerminate, None);
});
}
}
impl warpui::Entity for MCPCommandRunner {
type Event = ();
}
impl SingletonEntity for MCPCommandRunner {}
/// MCP server information that's shown in the `list` command.
#[derive(Serialize)]
struct MCPServerInfo {
uuid: uuid::Uuid,
name: String,
}
impl TableFormat for MCPServerInfo {
fn header() -> Vec<Cell> {
vec![Cell::new("UUID"), Cell::new("Name")]
}
fn row(&self) -> Vec<Cell> {
vec![Cell::new(self.uuid), Cell::new(&self.name)]
}
}
+213
View File
@@ -0,0 +1,213 @@
use anyhow::Context as _;
use serde_json::{Map, Value};
use warp_cli::mcp::MCPSpec;
use crate::ai::mcp::TemplatableMCPServer;
/// Build the `mcp_servers` map to send to the public ambient-agent API.
///
/// Returns the unwrapped server map (`{ <server_name>: <server_config>, ... }`).
/// If user input includes wrapper shapes like `{ "mcpServers": { ... } }`, we unpack them.
///
/// Notes:
/// - UUID specs are coerced into `{"<uuid>": {"warp_id": "<uuid>"}}`.
/// - We do light validation to catch obvious config errors before sending the request.
pub(super) fn build_mcp_servers_from_specs(
specs: &[MCPSpec],
) -> anyhow::Result<Option<Map<String, Value>>> {
if specs.is_empty() {
return Ok(None);
}
let mut merged = Map::new();
for spec in specs {
match spec {
MCPSpec::Uuid(uuid) => {
// TODO: Look up and use the real MCP server name from MCP managers instead of using the UUID.
let name = uuid.to_string();
insert_unique(
&mut merged,
name.clone(),
Value::Object({
let mut obj = Map::new();
obj.insert("warp_id".to_string(), Value::String(name));
obj
}),
)?;
}
MCPSpec::Json(json_str) => {
let json_str = normalize_mcp_json_for_single_server(json_str)?;
let value = parse_json_with_optional_braces(&json_str)?;
let server_map = TemplatableMCPServer::find_template_map(value)
.context("Failed to parse MCP server map")?;
for (name, config) in server_map {
insert_unique(&mut merged, name, config)?;
}
}
}
}
validate_mcp_servers(&merged)?;
if merged.is_empty() {
Ok(None)
} else {
Ok(Some(merged))
}
}
fn insert_unique(map: &mut Map<String, Value>, name: String, config: Value) -> anyhow::Result<()> {
if map.contains_key(&name) {
anyhow::bail!("Duplicate MCP server name '{name}' specified multiple times");
}
map.insert(name, config);
Ok(())
}
fn parse_json_with_optional_braces(input: &str) -> anyhow::Result<Value> {
// Some docs don't show curly braces around the json object, so add them if necessary.
let json = input.trim();
let json = if json.starts_with('{') {
json.to_owned()
} else {
format!("{{{json}}}")
};
serde_json::from_str(&json).with_context(|| "Invalid MCP JSON".to_string())
}
#[cfg(not(target_family = "wasm"))]
fn normalize_mcp_json_for_single_server(input: &str) -> anyhow::Result<String> {
crate::ai::mcp::parsing::normalize_mcp_json(input)
.map_err(|e| anyhow::anyhow!(e))
.context("Failed to normalize MCP JSON")
}
// The CLI + ambient-agent API isn’t used in WASM builds, but this module still needs to compile.
// Implement the same normalization behavior (single-server shorthand wrap) locally.
#[cfg(target_family = "wasm")]
fn normalize_mcp_json_for_single_server(input: &str) -> anyhow::Result<String> {
let json = input.trim();
let json_for_parsing = if json.starts_with('{') {
json.to_owned()
} else {
format!("{{{json}}}")
};
let value: Value =
serde_json::from_str(&json_for_parsing).with_context(|| "Invalid MCP JSON".to_string())?;
let is_single_server = value.get("command").is_some() || value.get("url").is_some();
if is_single_server {
let name = uuid::Uuid::new_v4().to_string();
let mut map = Map::new();
map.insert(name, value);
Ok(Value::Object(map).to_string())
} else {
Ok(input.to_string())
}
}
pub(super) fn validate_mcp_servers(mcp_servers: &Map<String, Value>) -> anyhow::Result<()> {
for (name, config) in mcp_servers {
validate_server_config(name, config)?;
}
Ok(())
}
fn validate_server_config(server_name: &str, config: &Value) -> anyhow::Result<()> {
let obj = config.as_object().ok_or_else(|| {
anyhow::anyhow!("MCP server '{server_name}' config must be a JSON object")
})?;
let has_warp_id = obj.contains_key("warp_id");
let has_command = obj.contains_key("command");
let has_url = obj.contains_key("url");
let kind_count = usize::from(has_warp_id) + usize::from(has_command) + usize::from(has_url);
if kind_count != 1 {
anyhow::bail!(
"MCP server '{server_name}' must have exactly one of: 'warp_id', 'command', or 'url'"
);
}
if has_warp_id {
let warp_id = obj.get("warp_id").and_then(Value::as_str).ok_or_else(|| {
anyhow::anyhow!("MCP server '{server_name}' field 'warp_id' must be a string")
})?;
uuid::Uuid::parse_str(warp_id).with_context(|| {
format!("MCP server '{server_name}' field 'warp_id' must be a UUID")
})?;
}
if has_command {
let command = obj.get("command").and_then(Value::as_str).ok_or_else(|| {
anyhow::anyhow!("MCP server '{server_name}' field 'command' must be a string")
})?;
if command.is_empty() {
anyhow::bail!("MCP server '{server_name}' field 'command' must be non-empty");
}
if let Some(args) = obj.get("args") {
let args = args.as_array().ok_or_else(|| {
anyhow::anyhow!("MCP server '{server_name}' field 'args' must be an array")
})?;
for (idx, arg) in args.iter().enumerate() {
if !arg.is_string() {
anyhow::bail!(
"MCP server '{server_name}' field 'args[{idx}]' must be a string"
);
}
}
}
}
if has_url {
let url = obj.get("url").and_then(Value::as_str).ok_or_else(|| {
anyhow::anyhow!("MCP server '{server_name}' field 'url' must be a string")
})?;
if url.is_empty() {
anyhow::bail!("MCP server '{server_name}' field 'url' must be non-empty");
}
}
validate_string_map_field(obj, server_name, "env")?;
validate_string_map_field(obj, server_name, "headers")?;
Ok(())
}
fn validate_string_map_field(
obj: &Map<String, Value>,
server_name: &str,
field: &str,
) -> anyhow::Result<()> {
let Some(value) = obj.get(field) else {
return Ok(());
};
let map = value.as_object().ok_or_else(|| {
anyhow::anyhow!("MCP server '{server_name}' field '{field}' must be an object")
})?;
for (key, value) in map {
if !value.is_string() {
anyhow::bail!("MCP server '{server_name}' field '{field}.{key}' must be a string");
}
}
Ok(())
}
#[cfg(test)]
#[path = "mcp_config_tests.rs"]
mod tests;
+300
View File
@@ -0,0 +1,300 @@
use serde_json::{json, Map, Value};
use warp_cli::mcp::MCPSpec;
use super::build_mcp_servers_from_specs;
fn build(specs: Vec<MCPSpec>) -> Map<String, Value> {
build_mcp_servers_from_specs(&specs)
.expect("builder should not error")
.unwrap_or_default()
}
#[test]
fn empty_specs_returns_none() {
assert!(build_mcp_servers_from_specs(&[]).unwrap().is_none());
}
#[test]
fn uuid_spec_is_coerced_to_warp_id() {
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let servers = build(vec![MCPSpec::Uuid(uuid)]);
let entry = servers.get(&uuid.to_string()).unwrap();
assert_eq!(
entry["warp_id"].as_str(),
Some("550e8400-e29b-41d4-a716-446655440000")
);
}
#[test]
fn wrapper_mcp_servers_is_unpacked() {
let spec = json!({
"mcpServers": {
"github": { "command": "npx", "args": ["-y", "server"] }
}
})
.to_string();
let servers = build(vec![MCPSpec::Json(spec)]);
assert!(servers.contains_key("github"));
assert_eq!(servers["github"]["command"].as_str(), Some("npx"));
assert!(servers.get("mcpServers").is_none());
}
#[test]
fn wrapper_mcp_servers_snake_case_is_unpacked() {
let spec = json!({
"mcp_servers": {
"s": { "url": "https://example.com/mcp" }
}
})
.to_string();
let servers = build(vec![MCPSpec::Json(spec)]);
assert!(servers.contains_key("s"));
assert_eq!(
servers["s"]["url"].as_str(),
Some("https://example.com/mcp")
);
assert!(servers.get("mcp_servers").is_none());
}
#[test]
fn wrapper_servers_is_unpacked() {
let spec = json!({
"servers": {
"s": { "command": "python", "args": ["mcp.py"] }
}
})
.to_string();
let servers = build(vec![MCPSpec::Json(spec)]);
assert!(servers.contains_key("s"));
assert_eq!(servers["s"]["command"].as_str(), Some("python"));
assert!(servers.get("servers").is_none());
}
#[test]
fn wrapper_mcp_dot_servers_is_unpacked() {
let spec = json!({
"mcp": {
"servers": {
"s": { "url": "https://example.com/mcp" }
}
}
})
.to_string();
let servers = build(vec![MCPSpec::Json(spec)]);
assert!(servers.contains_key("s"));
assert_eq!(
servers["s"]["url"].as_str(),
Some("https://example.com/mcp")
);
assert!(servers.get("mcp").is_none());
}
#[test]
fn plain_map_is_accepted() {
let spec = json!({
"github": { "command": "npx", "args": [] },
"remote": { "url": "https://example.com/mcp" }
})
.to_string();
let servers = build(vec![MCPSpec::Json(spec)]);
assert!(servers.contains_key("github"));
assert!(servers.contains_key("remote"));
}
#[test]
fn missing_outer_braces_is_accepted() {
// Emulate copying docs that omit the top-level `{}`.
let full = json!({
"mcpServers": {
"s": { "command": "npx", "args": [] }
}
})
.to_string();
let inner = &full[1..full.len() - 1];
let spec = format!(" {inner} ");
let servers = build(vec![MCPSpec::Json(spec)]);
assert!(servers.contains_key("s"));
assert_eq!(servers["s"]["command"].as_str(), Some("npx"));
}
#[test]
fn single_server_shorthand_command_is_wrapped() {
let spec = json!({ "command": "npx", "args": [] }).to_string();
let servers = build(vec![MCPSpec::Json(spec)]);
assert_eq!(servers.len(), 1);
let (_name, config) = servers.iter().next().unwrap();
assert_eq!(config["command"].as_str(), Some("npx"));
}
#[test]
fn command_without_args_is_accepted() {
// args should be optional for command-based MCP servers
let spec = json!({
"mcpServers": {
"s": { "command": "uvx" }
}
})
.to_string();
let servers = build(vec![MCPSpec::Json(spec)]);
assert!(servers.contains_key("s"));
assert_eq!(servers["s"]["command"].as_str(), Some("uvx"));
}
#[test]
fn single_server_shorthand_url_is_wrapped() {
let spec = json!({ "url": "https://example.com/mcp" }).to_string();
let servers = build(vec![MCPSpec::Json(spec)]);
assert_eq!(servers.len(), 1);
let (_name, config) = servers.iter().next().unwrap();
assert_eq!(config["url"].as_str(), Some("https://example.com/mcp"));
}
#[test]
fn merge_multiple_specs_and_duplicate_name_errors() {
let s1 = json!({ "mcpServers": { "a": { "command": "npx", "args": [] } } }).to_string();
let s2 = json!({ "mcpServers": { "b": { "url": "https://example.com/mcp" } } }).to_string();
let servers = build(vec![MCPSpec::Json(s1.clone()), MCPSpec::Json(s2)]);
assert!(servers.contains_key("a"));
assert!(servers.contains_key("b"));
let err =
build_mcp_servers_from_specs(&[MCPSpec::Json(s1.clone()), MCPSpec::Json(s1)]).unwrap_err();
assert!(err.to_string().contains("Duplicate MCP server name 'a'"));
}
#[test]
fn preserves_escaped_strings_in_env_values() {
let spec = json!({
"mcpServers": {
"s": {
"command": "npx",
"args": [],
"env": {
"TOKEN": "a\"b\\c\n"
}
}
}
})
.to_string();
let servers = build(vec![MCPSpec::Json(spec)]);
let token = servers["s"]["env"]["TOKEN"].as_str().unwrap();
// `serde_json` will decode escapes.
assert_eq!(token, "a\"b\\c\n");
}
#[test]
fn validation_rejects_invalid_entries() {
// Both command and url.
let spec = json!({
"mcpServers": {
"bad": { "command": "npx", "url": "https://example.com" }
}
})
.to_string();
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
assert!(err
.to_string()
.contains("must have exactly one of: 'warp_id', 'command', or 'url'"));
// warp_id must be a UUID string.
let spec = json!({ "mcpServers": { "bad": { "warp_id": "not-a-uuid" } } }).to_string();
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
assert!(err.to_string().contains("field 'warp_id' must be a UUID"));
// args must be array.
let spec = json!({ "mcpServers": { "bad": { "command": "npx", "args": "nope" } } }).to_string();
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
assert!(err.to_string().contains("field 'args' must be an array"));
// args entries must be strings.
let spec = json!({ "mcpServers": { "bad": { "command": "npx", "args": [1] } } }).to_string();
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
assert!(err.to_string().contains("args[0]"));
// env values must be strings.
let spec = json!({
"mcpServers": { "bad": { "command": "npx", "args": [], "env": { "X": 1 } } }
})
.to_string();
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
assert!(err.to_string().contains("env.X"));
// headers values must be strings.
let spec = json!({
"mcpServers": { "bad": { "url": "https://example.com", "headers": { "X": 1 } } }
})
.to_string();
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
assert!(err.to_string().contains("headers.X"));
// server config must be an object.
let spec = json!({ "mcpServers": { "bad": 1 } }).to_string();
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
assert!(err.to_string().contains("config must be a JSON object"));
}
#[test]
fn serializes_mcp_servers_as_object_not_string() {
use crate::ai::ambient_agents::AgentConfigSnapshot;
use crate::server::server_api::ai::SpawnAgentRequest;
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let mcp_servers = build_mcp_servers_from_specs(&[MCPSpec::Uuid(uuid)])
.unwrap()
.unwrap();
let request = SpawnAgentRequest {
prompt: "hello".to_string(),
config: Some(AgentConfigSnapshot {
mcp_servers: Some(mcp_servers),
..Default::default()
}),
title: None,
team: None,
skill: None,
attachments: vec![],
interactive: None,
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
};
let value = serde_json::to_value(&request).unwrap();
let config = value.get("config").unwrap();
let mcp_servers = config.get("mcp_servers").unwrap();
assert!(mcp_servers.is_object());
assert!(mcp_servers.get("mcpServers").is_none());
let server = mcp_servers.get(uuid.to_string()).unwrap();
assert_eq!(
server.get("warp_id").unwrap(),
&Value::String(uuid.to_string())
);
}
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
use serde_json::json;
use warp_cli::{
artifact::{ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs},
task::{MessageCommand, MessageSendArgs, MessageWatchArgs, TaskCommand},
CliCommand,
};
use warp_core::telemetry::TelemetryEvent;
use super::{command_requires_auth, command_to_telemetry_event};
#[test]
fn logout_does_not_require_auth() {
assert!(!command_requires_auth(&CliCommand::Logout));
}
#[test]
fn login_does_not_require_auth() {
assert!(!command_requires_auth(&CliCommand::Login));
}
#[test]
fn artifact_download_requires_auth() {
assert!(command_requires_auth(&CliCommand::Artifact(
ArtifactCommand::Download(DownloadArtifactArgs {
artifact_uid: "artifact-123".to_string(),
out: None,
},)
)));
}
#[test]
fn run_message_send_requires_auth() {
assert!(command_requires_auth(&CliCommand::Run(
TaskCommand::Message(MessageCommand::Send(MessageSendArgs {
to: vec!["run-456".to_string()],
subject: "subject".to_string(),
body: "body".to_string(),
sender_run_id: "run-123".to_string(),
}),)
)));
}
#[test]
fn artifact_get_requires_auth() {
assert!(command_requires_auth(&CliCommand::Artifact(
ArtifactCommand::Get(GetArtifactArgs {
artifact_uid: "artifact-123".to_string(),
},)
)));
}
#[test]
fn artifact_upload_requires_auth() {
assert!(command_requires_auth(&CliCommand::Artifact(
ArtifactCommand::Upload(UploadArtifactArgs {
path: "artifact.txt".into(),
run_id: Some("run-123".to_string()),
conversation_id: None,
description: None,
},)
)));
}
#[test]
#[serial_test::serial]
fn run_message_send_telemetry_uses_canonical_harness_from_env() {
std::env::set_var("OZ_HARNESS", " CLAUDE ");
let event = command_to_telemetry_event(&CliCommand::Run(TaskCommand::Message(
MessageCommand::Send(MessageSendArgs {
to: vec!["run-456".to_string()],
subject: "subject".to_string(),
body: "body".to_string(),
sender_run_id: "run-123".to_string(),
}),
)));
std::env::remove_var("OZ_HARNESS");
assert_eq!(event.payload(), Some(json!({ "harness": "claude" })));
}
#[test]
#[serial_test::serial]
fn run_message_send_telemetry_supports_claude_code_alias() {
std::env::set_var("OZ_HARNESS", "CLAUDE_CODE");
let event = command_to_telemetry_event(&CliCommand::Run(TaskCommand::Message(
MessageCommand::Send(MessageSendArgs {
to: vec!["run-456".to_string()],
subject: "subject".to_string(),
body: "body".to_string(),
sender_run_id: "run-123".to_string(),
}),
)));
std::env::remove_var("OZ_HARNESS");
assert_eq!(event.payload(), Some(json!({ "harness": "claude" })));
}
#[test]
#[serial_test::serial]
fn run_message_send_telemetry_supports_opencode_harness() {
std::env::set_var("OZ_HARNESS", "opencode");
let event = command_to_telemetry_event(&CliCommand::Run(TaskCommand::Message(
MessageCommand::Send(MessageSendArgs {
to: vec!["run-456".to_string()],
subject: "subject".to_string(),
body: "body".to_string(),
sender_run_id: "run-123".to_string(),
}),
)));
std::env::remove_var("OZ_HARNESS");
assert_eq!(event.payload(), Some(json!({ "harness": "opencode" })));
}
#[test]
#[serial_test::serial]
fn run_message_send_telemetry_defaults_to_unknown_harness() {
std::env::remove_var("OZ_HARNESS");
let event = command_to_telemetry_event(&CliCommand::Run(TaskCommand::Message(
MessageCommand::Send(MessageSendArgs {
to: vec!["run-456".to_string()],
subject: "subject".to_string(),
body: "body".to_string(),
sender_run_id: "run-123".to_string(),
}),
)));
assert_eq!(event.payload(), Some(json!({ "harness": "unknown" })));
}
#[test]
#[serial_test::serial]
fn run_message_watch_telemetry_defaults_to_unknown_harness() {
std::env::remove_var("OZ_HARNESS");
let event = command_to_telemetry_event(&CliCommand::Run(TaskCommand::Message(
MessageCommand::Watch(MessageWatchArgs {
run_id: "run-123".to_string(),
since_sequence: 0,
}),
)));
assert_eq!(event.payload(), Some(json!({ "harness": "unknown" })));
}
+82
View File
@@ -0,0 +1,82 @@
use std::collections::BTreeSet;
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::ai::llms::LLMPreferences;
use comfy_table::Cell;
use serde::Serialize;
use warp_cli::{model::ModelCommand, GlobalOptions};
use warpui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
/// Handle model-related CLI commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: ModelCommand,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| ModelCommandRunner);
match command {
ModelCommand::List => {
runner.update(ctx, |runner, ctx| runner.list(global_options, ctx));
Ok(())
}
}
}
/// Singleton model for running async work as part of model CLI commands.
struct ModelCommandRunner;
impl ModelCommandRunner {
fn list(&self, global_options: GlobalOptions, ctx: &mut ModelContext<Self>) {
let output_format = global_options.output_format;
// Ensure workspace metadata is refreshed so LLM preferences are up-to-date.
let refresh_future = super::common::refresh_workspace_metadata(ctx);
ctx.spawn(refresh_future, move |_, refresh_result, ctx| {
if refresh_result.is_err() {
super::report_fatal_error(
anyhow::anyhow!("Timed out refreshing workspace metadata"),
ctx,
);
return;
}
let llm_prefs = LLMPreferences::as_ref(ctx);
let mut ids = BTreeSet::new();
for info in llm_prefs.get_base_llm_choices_for_agent_mode() {
ids.insert(info.id.to_string());
}
let items = ids
.into_iter()
.map(|id| ModelListItem { id })
.collect::<Vec<_>>();
output::print_list(items, output_format);
ctx.terminate_app(TerminationMode::ForceTerminate, None);
});
}
}
impl warpui::Entity for ModelCommandRunner {
type Event = ();
}
impl SingletonEntity for ModelCommandRunner {}
/// Model information that's shown in the `list` command.
#[derive(Serialize)]
struct ModelListItem {
id: String,
}
impl TableFormat for ModelListItem {
fn header() -> Vec<Cell> {
vec![Cell::new("MODEL ID")]
}
fn row(&self) -> Vec<Cell> {
vec![Cell::new(&self.id)]
}
}
+47
View File
@@ -0,0 +1,47 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Result};
use warpui::r#async::Timer;
use crate::server::server_api::integrations::IntegrationsClient;
use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
/// Shared helpers for OAuth-based connect flows (txId + polling).
///
/// This is used by the integration CLI create flow, and can be reused by
/// other command flows that rely on the same OAuth connect transaction API.
pub async fn poll_oauth_until_terminal(
integrations_client: Arc<dyn IntegrationsClient>,
tx_id: String,
) -> Result<OauthConnectTxStatus> {
const POLL_INTERVAL: Duration = Duration::from_secs(5);
const MAX_ATTEMPTS: u32 = 120; // 10 minutes total
// TODO(bens): render some kind of spinner here
println!(
"Waiting for authorization to complete... If this doesn't update after authorizing, please restart the command and try again.\n"
);
for attempt in 1..=MAX_ATTEMPTS {
Timer::after(POLL_INTERVAL).await;
let status = integrations_client
.poll_oauth_connect_status(tx_id.clone())
.await?;
match status {
OauthConnectTxStatus::Completed
| OauthConnectTxStatus::Failed
| OauthConnectTxStatus::Expired => {
return Ok(status);
}
OauthConnectTxStatus::Pending | OauthConnectTxStatus::InProgress => {
if attempt % 5 == 0 {
log::debug!("Still waiting for authorization... ({attempt}/{MAX_ATTEMPTS})",);
}
}
}
}
Err(anyhow!("Timed out waiting for OAuth authorization"))
}
+327
View File
@@ -0,0 +1,327 @@
use std::io::Write as _;
use anyhow::Context;
use comfy_table::modifiers::UTF8_ROUND_CORNERS;
use comfy_table::presets::UTF8_FULL;
use comfy_table::{Cell, ContentArrangement, Table};
use jaq_all::data::Runner;
use jaq_all::fmts::write::Writer;
use jaq_all::fmts::Format;
// Use jaq_json directly to ensure serde support is included.
use jaq_json::{write as jaq_write, Val};
use serde::Serialize;
use tabwriter::TabWriter;
use warp_cli::agent::OutputFormat;
use warp_cli::json_filter::{JqFilter, JsonOutput};
pub fn standard_table() -> Table {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.apply_modifier(UTF8_ROUND_CORNERS)
.set_content_arrangement(ContentArrangement::Dynamic);
table
}
/// Trait for types that can be printed as a table.
pub trait TableFormat {
fn header() -> Vec<Cell>;
fn row(&self) -> Vec<Cell>;
}
/// Print a list of items to stdout, respecting the `output_format`.
pub fn print_list<I, T>(items: I, output_format: OutputFormat)
where
I: IntoIterator<Item = T>,
T: TableFormat + Serialize,
{
if let Err(err) = write_list(items, output_format, &mut std::io::stdout()) {
// If we can't write to stdout, try reporting to the log file.
log::warn!("Unable to write to stdout: {err}");
}
}
/// Write a serializable value to `output` as pretty JSON.
pub fn write_json<T, W>(value: &T, mut output: W) -> anyhow::Result<()>
where
T: Serialize,
W: std::io::Write,
{
serde_json::to_writer_pretty(&mut output, value).context("unable to write JSON output")?;
writeln!(&mut output)?;
Ok(())
}
/// Write a serializable value to `output` as a single-line JSON record.
pub fn write_json_line<T, W>(value: &T, mut output: W) -> anyhow::Result<()>
where
T: Serialize,
W: std::io::Write,
{
serde_json::to_writer(&mut output, value).context("unable to write JSON output")?;
writeln!(&mut output)?;
Ok(())
}
/// RAII guard that locks stdout and, on Unix, temporarily clears `O_NONBLOCK`
/// so writes block instead of returning `EAGAIN`. Restores the original flags
/// and releases the stdout lock on drop.
///
/// It's possible that stdout has `O_NONBLOCK` set, especially if it's a PTY.
/// If we're writing a large payload faster than it can be read, we can fill
/// up the PTY buffer, and get an `EAGAIN` error instead of blocking until the
/// reader consumes the data.
///
/// Rather than reimplement backoff based on `EAGAIN` ourselves, we can ensure
/// that writes will block for the lifetime of the guard.
struct StdoutBlockingGuard {
/// Locked stdout handle. Declared first so that on drop, the `O_NONBLOCK`
/// flag is restored (via `Drop for StdoutBlockingGuard`) before the lock
/// is released.
lock: std::io::StdoutLock<'static>,
/// The original stdout flags to restore on drop, or `None` if stdout was
/// already blocking (or we could not read/modify the flags).
#[cfg(unix)]
original_flags: Option<libc::c_int>,
}
impl StdoutBlockingGuard {
fn new() -> Self {
let lock = std::io::stdout().lock();
#[cfg(unix)]
{
use libc::{fcntl, F_GETFL, F_SETFL, O_NONBLOCK, STDOUT_FILENO};
// SAFETY: `fcntl` with `F_GETFL` / `F_SETFL` only reads and writes
// the file status flags on stdout; it does not mutate Rust memory.
let original_flags = unsafe {
let flags = fcntl(STDOUT_FILENO, F_GETFL, 0);
if flags < 0 {
let err = std::io::Error::last_os_error();
log::warn!("Unable to read stdout flags: {err}");
None
} else if flags & O_NONBLOCK == 0 {
// Already blocking; nothing to change and nothing to restore.
None
} else if fcntl(STDOUT_FILENO, F_SETFL, flags & !O_NONBLOCK) < 0 {
let err = std::io::Error::last_os_error();
log::warn!("Unable to clear O_NONBLOCK on stdout: {err}");
None
} else {
Some(flags)
}
};
Self {
lock,
original_flags,
}
}
#[cfg(not(unix))]
{
Self { lock }
}
}
}
impl std::io::Write for StdoutBlockingGuard {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.lock.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.lock.flush()
}
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
self.lock.write_all(buf)
}
}
impl Drop for StdoutBlockingGuard {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(flags) = self.original_flags {
use libc::{fcntl, F_SETFL, STDOUT_FILENO};
// SAFETY: `fcntl` with `F_SETFL` only updates the file status
// flags on stdout; it does not mutate Rust memory.
unsafe {
if fcntl(STDOUT_FILENO, F_SETFL, flags) < 0 {
let err = std::io::Error::last_os_error();
log::warn!("Unable to restore stdout flags: {err}");
}
}
}
}
}
/// Write a raw `serde_json::Value` to stdout as pretty-printed JSON.
///
/// Useful for CLI commands that want to pass through server responses verbatim
/// when `--output-format json` is set, rather than re-serializing through a
/// typed model.
///
/// When `json_output.filter` is `Some`, the JSON value is instead passed
/// through the given filter, each output of which is printed to stdout.
pub fn print_raw_json(value: serde_json::Value, json_output: &JsonOutput) -> anyhow::Result<()> {
// Ensure that writes to stdout are blocking, to enforce backpressure as we
// potentially write a large amount of data. Otherwise, writes may fail with
// `EAGAIN`, which we can't easily handle while serializing JSON. The guard
// also locks stdout for the duration of the write, and restores the prior
// blocking mode when it goes out of scope.
//
// Stream directly to stdout so we don't buffer a large `String`
// unnecessarily, and so we can return I/O errors rather than potentially
// panicking (as `println!` would).
let mut out = StdoutBlockingGuard::new();
match json_output.filter.as_ref() {
None => {
serde_json::to_writer_pretty(&mut out, &value)
.context("unable to write JSON output")?;
writeln!(&mut out)?;
}
Some(filter) => run_jq_filter(value, filter, &mut out)?,
}
out.flush()?;
Ok(())
}
/// Run `jq_filter` on `value` and write each output to `out` on its own line.
///
/// Top-level scalar outputs are written as raw text (see [`write_filter_output`]).
/// Runtime errors from the filter are returned as `anyhow::Error`; any outputs
/// produced before the error are still written to `out`, matching jq's behavior.
fn run_jq_filter<W: std::io::Write>(
value: serde_json::Value,
jq_filter: &JqFilter,
out: &mut W,
) -> anyhow::Result<()> {
let input_result = serde_json::from_value::<Val>(value);
let runner = Runner {
null_input: false,
color_err: false,
writer: Writer {
format: Format::Json,
pp: pretty_pp(),
join: false,
},
};
jaq_all::data::run(
&runner,
jq_filter,
Default::default(),
[input_result].into_iter(),
// Callback to format invalid input errors.
|err| anyhow::anyhow!("Invalid data: {err}"),
// Callback to handle filter outputs.
|result| match result {
Ok(val) => write_filter_output(&val, out),
Err(err) => anyhow::bail!("jq filter error: {err}"),
},
)?;
Ok(())
}
/// Pretty-printer configuration used for non-scalar filter output. Matches
/// `serde_json`'s pretty printer: two-space indent, space after `:`, no
/// trailing space after `,` (since commas sit at end-of-line).
fn pretty_pp() -> jaq_write::Pp {
jaq_write::Pp {
indent: Some(" ".to_string()),
sep_space: true,
..jaq_write::Pp::default()
}
}
/// Write a single filter output, unwrapping top-level scalars to raw text.
///
/// - `Null` -> `null`
/// - `Bool` -> `true` / `false`
/// - `Num` -> its decimal form
/// - `TStr` / `BStr` -> the unescaped string content (no surrounding quotes)
/// - `Arr` / `Obj` -> pretty-printed JSON via `jaq_json::write`, with the same
/// formatting conventions as the non-filtered `--output-format json` path.
///
/// Every output is followed by a newline.
fn write_filter_output<W: std::io::Write>(val: &Val, out: &mut W) -> anyhow::Result<()> {
match val {
Val::Null => writeln!(out, "null")?,
Val::Bool(b) => writeln!(out, "{b}")?,
Val::Num(n) => writeln!(out, "{n}")?,
Val::TStr(bytes) | Val::BStr(bytes) => {
out.write_all(bytes)?;
writeln!(out)?;
}
Val::Arr(_) | Val::Obj(_) => {
jaq_write::write(&mut *out, &pretty_pp(), 0, val)
.context("unable to write jq output as JSON")?;
writeln!(out)?;
}
}
Ok(())
}
/// Write a list of items to `output`, respecting the `output_format`.
pub fn write_list<I, T, W>(
items: I,
output_format: OutputFormat,
mut output: W,
) -> anyhow::Result<()>
where
I: IntoIterator<Item = T>,
T: TableFormat + Serialize,
W: std::io::Write,
{
match output_format {
OutputFormat::Json => {
let items = items.into_iter().collect::<Vec<_>>();
serde_json::to_writer(&mut output, &items).context("unable to write JSON output")
}
OutputFormat::Ndjson => {
for item in items {
write_json_line(&item, &mut output)?;
}
Ok(())
}
OutputFormat::Pretty => {
// Use comfy-table to print a table with terminal formatting.
let mut table = standard_table();
table.set_header(T::header());
for item in items {
table.add_row(T::row(&item));
}
writeln!(&mut output, "{table}")?;
Ok(())
}
OutputFormat::Text => {
// Print a plain-text table.
let mut tw = TabWriter::new(output);
for (idx, column) in T::header().iter().enumerate() {
if idx > 0 {
write!(&mut tw, "\t")?;
}
write!(&mut tw, "{}", column.content())?;
}
writeln!(&mut tw)?;
for item in items {
for (idx, column) in T::row(&item).iter().enumerate() {
if idx > 0 {
write!(&mut tw, "\t")?;
}
write!(&mut tw, "{}", column.content())?;
}
writeln!(&mut tw)?;
}
tw.flush()?;
Ok(())
}
}
}
#[cfg(test)]
#[path = "output_tests.rs"]
mod tests;
+230
View File
@@ -0,0 +1,230 @@
use super::{
run_jq_filter, write_filter_output, write_json, write_json_line, write_list, TableFormat,
};
use comfy_table::Cell;
use serde::Serialize;
use serde_json::json;
use warp_cli::agent::OutputFormat;
use warp_cli::json_filter::parse_jq_filter;
#[derive(Serialize)]
struct TestItem {
id: &'static str,
subject: &'static str,
}
impl TableFormat for TestItem {
fn header() -> Vec<Cell> {
vec![Cell::new("ID"), Cell::new("SUBJECT")]
}
fn row(&self) -> Vec<Cell> {
vec![Cell::new(self.id), Cell::new(self.subject)]
}
}
#[test]
fn write_list_emits_json_for_json_output_format() {
let mut output = Vec::new();
let items = [TestItem {
id: "message-1",
subject: "Build update",
}];
write_list(items, OutputFormat::Json, &mut output).unwrap();
let rendered = String::from_utf8(output).unwrap();
assert_eq!(rendered, r#"[{"id":"message-1","subject":"Build update"}]"#);
}
#[test]
fn write_list_emits_ndjson_for_ndjson_output_format() {
let mut output = Vec::new();
let items = [
TestItem {
id: "message-1",
subject: "Build update",
},
TestItem {
id: "message-2",
subject: "Pivot",
},
];
write_list(items, OutputFormat::Ndjson, &mut output).unwrap();
let rendered = String::from_utf8(output).unwrap();
assert_eq!(
rendered,
"{\"id\":\"message-1\",\"subject\":\"Build update\"}\n{\"id\":\"message-2\",\"subject\":\"Pivot\"}\n"
);
}
#[test]
fn write_json_emits_pretty_json_with_trailing_newline() {
let mut output = Vec::new();
let item = TestItem {
id: "message-1",
subject: "Build update",
};
write_json(&item, &mut output).unwrap();
let rendered = String::from_utf8(output).unwrap();
assert_eq!(
rendered,
"{\n \"id\": \"message-1\",\n \"subject\": \"Build update\"\n}\n"
);
}
#[test]
fn write_json_line_emits_compact_json_with_trailing_newline() {
let mut output = Vec::new();
let item = TestItem {
id: "message-1",
subject: "Build update",
};
write_json_line(&item, &mut output).unwrap();
let rendered = String::from_utf8(output).unwrap();
assert_eq!(
rendered,
"{\"id\":\"message-1\",\"subject\":\"Build update\"}\n"
);
}
/// A small fixture that matches the shape of `GET /api/v1/agent/runs`.
fn list_response_fixture() -> serde_json::Value {
json!({
"runs": [
{ "task_id": "01HX0000000000000000000001", "title": "Alpha", "state": "succeeded" },
{ "task_id": "01HX0000000000000000000002", "title": "Beta", "state": "failed" },
],
"page_info": { "has_next_page": false, "next_cursor": null }
})
}
/// Run a filter against `value` and return its output as a UTF-8 string.
fn run(value: serde_json::Value, filter: &str) -> String {
let filter = parse_jq_filter(filter).expect("filter compiles");
let mut buf = Vec::new();
run_jq_filter(value, &filter, &mut buf).expect("filter runs without error");
String::from_utf8(buf).expect("output is valid utf-8")
}
#[test]
fn identity_filter_matches_non_filtered_json_output() {
let fixture = list_response_fixture();
let filtered = run(fixture.clone(), ".");
let mut expected = Vec::new();
serde_json::to_writer_pretty(&mut expected, &fixture).unwrap();
expected.push(b'\n');
assert_eq!(filtered.as_bytes(), expected.as_slice());
}
#[test]
fn scalar_string_is_unwrapped() {
let fixture = list_response_fixture();
let out = run(fixture, ".runs[0].task_id");
assert_eq!(out, "01HX0000000000000000000001\n");
}
#[test]
fn scalar_number_is_unwrapped() {
let fixture = list_response_fixture();
let out = run(fixture, ".runs | length");
assert_eq!(out, "2\n");
}
#[test]
fn scalar_bool_and_null_are_unwrapped() {
let fixture = list_response_fixture();
assert_eq!(run(fixture.clone(), ".page_info.has_next_page"), "false\n");
assert_eq!(run(fixture.clone(), ".page_info.next_cursor"), "null\n");
assert_eq!(run(fixture, "true"), "true\n");
}
#[test]
fn multiple_scalar_outputs_each_on_own_line() {
let fixture = list_response_fixture();
let out = run(fixture, ".runs[].task_id");
assert_eq!(
out,
"01HX0000000000000000000001\n01HX0000000000000000000002\n"
);
}
#[test]
fn non_scalar_output_is_pretty_json() {
let fixture = list_response_fixture();
let out = run(fixture, ".runs[0]");
let expected = serde_json::to_string_pretty(&json!({
"task_id": "01HX0000000000000000000001",
"title": "Alpha",
"state": "succeeded",
}))
.unwrap();
assert_eq!(out, format!("{expected}\n"));
}
#[test]
fn inner_scalars_stay_json_encoded() {
let fixture = list_response_fixture();
let out = run(fixture, ".runs");
assert!(
out.contains(r#""title": "Alpha""#),
"inner strings should remain JSON-encoded, got:\n{out}"
);
assert!(
out.contains(r#""task_id": "01HX0000000000000000000001""#),
"inner task_id should remain JSON-encoded, got:\n{out}"
);
}
#[test]
fn empty_filter_produces_no_output() {
let fixture = list_response_fixture();
let out = run(fixture, "empty");
assert_eq!(out, "");
}
#[test]
fn runtime_error_is_surfaced_after_partial_output() {
// `.runs[].title | .[0]` succeeds on the first string but fails on a
// later element if we introduce a non-indexable value. Build a fixture
// where the second element has a numeric title, which triggers a runtime
// error when we try to index it as a string.
let fixture = json!({
"runs": [
{ "title": "hello" },
{ "title": 42 },
]
});
let filter = parse_jq_filter(".runs[].title | .[0:1]").expect("filter compiles");
let mut buf = Vec::new();
let result = run_jq_filter(fixture, &filter, &mut buf);
// The filter should fail at runtime on the integer title.
assert!(result.is_err(), "expected runtime error");
// The valid output from the first element should already be on the buffer.
let rendered = String::from_utf8(buf).unwrap();
assert!(
rendered.starts_with("h\n"),
"expected partial output before the error, got: {rendered:?}"
);
}
#[test]
fn write_filter_output_respects_scalar_unwrapping_for_direct_vals() {
use jaq_json::Val;
// Exercise `write_filter_output` directly with jaq values to lock in the
// expected rendering of each scalar variant.
let mut buf = Vec::new();
write_filter_output(&Val::Null, &mut buf).unwrap();
write_filter_output(&Val::Bool(true), &mut buf).unwrap();
write_filter_output(&Val::Bool(false), &mut buf).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "null\ntrue\nfalse\n");
}
+80
View File
@@ -0,0 +1,80 @@
use comfy_table::Cell;
use serde::Serialize;
use warp_cli::{agent::AgentProfileCommand, GlobalOptions};
use warpui::{AppContext, ModelContext, SingletonEntity};
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::cloud_object::model::generic_string_model::StringModel;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::SyncId;
/// Handle Agent Profile-related CLI commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: AgentProfileCommand,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| ProfilesCommandRunner);
match command {
AgentProfileCommand::List => {
runner.update(ctx, |runner, ctx| runner.list(global_options, ctx));
Ok(())
}
}
}
/// Singleton model that runs async work for profile CLI commands.
struct ProfilesCommandRunner;
impl ProfilesCommandRunner {
fn list(&self, global_options: GlobalOptions, ctx: &mut ModelContext<Self>) {
// Ensure initial cloud sync completes so profiles from the server are available.
let initial_sync = UpdateManager::as_ref(ctx).initial_load_complete();
ctx.spawn(initial_sync, move |_, _, ctx| {
let profiles_model = AIExecutionProfilesModel::as_ref(ctx);
let profile_ids = profiles_model.get_all_profile_ids();
let profiles: Vec<_> = profile_ids
.iter()
.flat_map(|id| profiles_model.get_profile_by_id(*id, ctx))
.map(|profile| {
let name = profile.data().display_name().to_string();
let id = match profile.sync_id() {
Some(SyncId::ServerId(server_id)) => server_id.to_string(),
_ => "Unsynced".to_string(),
};
ProfileInfo { id, name }
})
.collect();
output::print_list(profiles, global_options.output_format);
ctx.terminate_app(warpui::platform::TerminationMode::ForceTerminate, None);
});
}
}
impl warpui::Entity for ProfilesCommandRunner {
type Event = ();
}
impl SingletonEntity for ProfilesCommandRunner {}
/// Profile information that's shown in the `list` command.
#[derive(Serialize)]
struct ProfileInfo {
id: String,
name: String,
}
impl TableFormat for ProfileInfo {
fn header() -> Vec<Cell> {
vec![Cell::new("ID"), Cell::new("Name")]
}
fn row(&self) -> Vec<Cell> {
vec![Cell::new(&self.id), Cell::new(&self.name)]
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Provider command for linking third-party services.
use crate::workspaces::user_workspaces::UserWorkspaces;
use comfy_table::Cell;
use serde::Serialize;
use warp_cli::{
provider::{ProviderCommand, ProviderType},
GlobalOptions,
};
use warp_core::channel::ChannelState;
use warpui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
use crate::ai::agent_sdk::output::{self, TableFormat};
/// Handle provider-related CLI commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: ProviderCommand,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| ProviderCommandRunner);
match command {
ProviderCommand::Setup(args) => runner.update(ctx, |runner, ctx| {
runner.setup(args.provider_type, args.team, args.personal, ctx)
}),
ProviderCommand::List => runner.update(ctx, |runner, ctx| runner.list(global_options, ctx)),
}
}
/// Singleton model for running provider CLI commands.
struct ProviderCommandRunner;
impl ProviderCommandRunner {
// This shouldn't need to be done, it's usually done as part of create
fn setup(
&self,
provider_type: ProviderType,
team: bool,
personal: bool,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
// Construct the OAuth connect URL
let server_url = ChannelState::server_root_url();
let mut use_team_auth = team;
if !team && !personal {
if provider_type.allowed_in_team_context()
&& provider_type.allowed_in_personal_context()
{
return Err(anyhow::anyhow!(
"Provider '{}' must be setup for either a team or personal account",
provider_type.slug()
));
}
use_team_auth = provider_type.allowed_in_team_context();
} else if personal {
use_team_auth = false;
}
// TODO(bens): initiate the OAuth flow and use the login-less auth URL
let slug = provider_type.slug();
let url = if use_team_auth {
let team_uid = match UserWorkspaces::as_ref(ctx).current_team_uid() {
Some(uid) => uid,
None => {
return Err(anyhow::anyhow!("User is not on a team"));
}
};
format!("{server_url}/oauth/connect/{slug}?principalType=team&principalId={team_uid}")
} else {
format!("{server_url}/oauth/connect/{slug}")
};
println!("To authenticate {slug}, open this URL in your browser: {url}");
// Open the URL in the default browser
ctx.open_url(&url);
// TODO(bens): poll/subscribe until connection is created
ctx.terminate_app(TerminationMode::ForceTerminate, None);
Ok(())
}
fn list(
&self,
global_options: GlobalOptions,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let providers = vec![ProviderType::Linear, ProviderType::Slack];
let provider_infos: Vec<_> = providers
.into_iter()
.map(|provider| {
let name = provider.name();
let slug = provider.slug();
let mut allowed_for = Vec::new();
if provider.allowed_in_personal_context() {
allowed_for.push("personal");
}
if provider.allowed_in_team_context() {
allowed_for.push("team");
}
let allowed_str = allowed_for.join(", ");
let status = "❌ Not Connected".to_string(); // TODO(bens): get this from gql
ProviderInfo {
name,
slug,
allowed_for: allowed_str,
status,
}
})
.collect();
output::print_list(provider_infos, global_options.output_format);
ctx.terminate_app(TerminationMode::ForceTerminate, None);
Ok(())
}
}
impl warpui::Entity for ProviderCommandRunner {
type Event = ();
}
impl SingletonEntity for ProviderCommandRunner {}
/// Provider information that's shown in the `list` command.
#[derive(Serialize)]
struct ProviderInfo {
name: String,
slug: String,
allowed_for: String,
status: String,
}
impl TableFormat for ProviderInfo {
fn header() -> Vec<Cell> {
vec![
Cell::new("NAME"),
Cell::new("SLUG"),
Cell::new("ALLOWED FOR"),
Cell::new("STATUS"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.name),
Cell::new(&self.slug),
Cell::new(&self.allowed_for),
Cell::new(&self.status),
]
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Shared retry primitives for HTTP-backed operations in the agent SDK.
//!
//! Both the end-of-run snapshot upload pipeline and the handoff snapshot download pipeline
//! need to retry transient HTTP failures on a bounded, predictable schedule. This module
//! centralizes the backoff policy, transient-vs-permanent classification, and retry loop so
//! the two call sites share a single source of truth.
use std::future::Future;
use std::time::Duration;
use anyhow::{anyhow, Result};
use warpui::duration_with_jitter;
use warpui::r#async::Timer;
pub(crate) use crate::server::retry_strategies::is_transient_http_error;
/// Maximum total attempts per operation (initial attempt plus retries on transient errors).
pub(crate) const MAX_ATTEMPTS: usize = 3;
/// Base backoff between retry attempts; each subsequent attempt multiplies by [`BACKOFF_FACTOR`].
pub(crate) const INITIAL_BACKOFF: Duration = Duration::from_millis(500);
/// Exponential growth factor for retry backoff.
pub(crate) const BACKOFF_FACTOR: f32 = 2.0;
/// Maximum jitter as a fraction of the backoff interval.
pub(crate) const BACKOFF_JITTER: f32 = 0.3;
/// Run `attempt_fn` with bounded exponential-backoff retries on transient failures.
///
/// `operation` is included in retry logs so concurrent callers can be distinguished.
///
/// `attempt_fn` is called repeatedly with a fresh `Future` per attempt, so callers that need
/// per-attempt state (e.g. cloning a request body) own that inside their closure.
///
/// Transient errors are retried up to [`MAX_ATTEMPTS`] total. Permanent errors return
/// immediately. A warning is logged between attempts so retries are visible in logs.
pub(crate) async fn with_bounded_retry<T, F, Fut>(operation: &str, mut attempt_fn: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T>>,
{
let mut delay = INITIAL_BACKOFF;
for attempt in 1..=MAX_ATTEMPTS {
match attempt_fn().await {
Ok(value) => return Ok(value),
Err(e) if attempt >= MAX_ATTEMPTS || !is_transient_http_error(&e) => return Err(e),
Err(e) => {
log::warn!("{operation}: attempt {attempt}/{MAX_ATTEMPTS} failed, retrying: {e:#}");
Timer::after(duration_with_jitter(delay, BACKOFF_JITTER)).await;
delay = delay.mul_f32(BACKOFF_FACTOR);
}
}
}
// Unreachable when MAX_ATTEMPTS >= 1.
Err(anyhow!(
"retry loop exhausted without attempting operation (MAX_ATTEMPTS={MAX_ATTEMPTS})"
))
}
#[cfg(test)]
#[path = "retry_tests.rs"]
mod tests;
+101
View File
@@ -0,0 +1,101 @@
use std::cell::Cell;
use std::rc::Rc;
use anyhow::anyhow;
use futures::executor::block_on;
use super::*;
use crate::server::server_api::presigned_upload::HttpStatusError;
fn http_err(status: u16) -> anyhow::Error {
HttpStatusError {
status,
body: format!("status {status} body"),
}
.into()
}
#[test]
fn transient_5xx_status_codes_are_retryable() {
assert!(is_transient_http_error(&http_err(503)));
assert!(is_transient_http_error(&http_err(500)));
}
#[test]
fn transient_408_and_429_are_retryable() {
assert!(is_transient_http_error(&http_err(408)));
assert!(is_transient_http_error(&http_err(429)));
}
#[test]
fn permanent_4xx_status_codes_are_not_retryable() {
assert!(!is_transient_http_error(&http_err(403)));
assert!(!is_transient_http_error(&http_err(404)));
assert!(!is_transient_http_error(&http_err(400)));
}
#[test]
fn errors_without_http_status_are_treated_as_transient() {
// Network-layer errors (connection reset, timeout, DNS failure) aren't `HttpStatusError`;
// treat them as transient so the retry loop gives them a chance.
let err = anyhow!("connection reset by peer");
assert!(is_transient_http_error(&err));
let err = anyhow!("Failed to send request: timed out");
assert!(is_transient_http_error(&err));
}
#[test]
fn retry_loop_succeeds_on_first_attempt() {
let attempts = Rc::new(Cell::new(0));
let attempts_clone = attempts.clone();
let result: Result<()> = block_on(with_bounded_retry("test retry", || {
attempts_clone.set(attempts_clone.get() + 1);
async { Ok(()) }
}));
result.unwrap();
assert_eq!(attempts.get(), 1);
}
#[test]
fn retry_loop_retries_transient_and_eventually_succeeds() {
let attempts = Rc::new(Cell::new(0));
let attempts_clone = attempts.clone();
let result: Result<u32> = block_on(with_bounded_retry("test retry", || {
let n = attempts_clone.get() + 1;
attempts_clone.set(n);
async move {
if n < 2 {
Err(http_err(503))
} else {
Ok(n)
}
}
}));
assert_eq!(result.unwrap(), 2);
assert_eq!(attempts.get(), 2);
}
#[test]
fn retry_loop_stops_at_max_attempts_on_persistent_transient() {
let attempts = Rc::new(Cell::new(0));
let attempts_clone = attempts.clone();
let result: Result<()> = block_on(with_bounded_retry("test retry", || {
attempts_clone.set(attempts_clone.get() + 1);
async { Err(http_err(503)) }
}));
assert!(result.is_err());
assert_eq!(attempts.get(), MAX_ATTEMPTS);
}
#[test]
fn retry_loop_fails_fast_on_permanent_error() {
let attempts = Rc::new(Cell::new(0));
let attempts_clone = attempts.clone();
let result: Result<()> = block_on(with_bounded_retry("test retry", || {
attempts_clone.set(attempts_clone.get() + 1);
async { Err(http_err(403)) }
}));
assert!(result.is_err());
assert_eq!(attempts.get(), 1, "permanent errors should not retry");
}
+668
View File
@@ -0,0 +1,668 @@
use chrono::{DateTime, Utc};
use comfy_table::Cell;
use futures::future;
use serde::Serialize;
use warp_cli::schedule::{
CreateScheduleArgs, DeleteScheduleArgs, GetScheduleArgs, PauseScheduleArgs, ScheduleCommand,
ScheduleSubcommand, UnpauseScheduleArgs, UpdateScheduleArgs,
};
use warp_cli::{agent::OutputFormat, GlobalOptions};
use warp_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory;
use warpui::platform::TerminationMode;
use warpui::{AppContext, SingletonEntity};
use crate::ai::ambient_agents::scheduled::{
CloudScheduledAmbientAgent, ScheduledAgentManager, ScheduledAmbientAgent, UpdateScheduleParams,
};
use crate::ai::ambient_agents::AgentConfigSnapshot;
use crate::cloud_object::CloudObject;
use crate::server::ids::{ServerId, SyncId};
use crate::util::time_format::format_approx_duration_from_now_utc;
use super::common::{EnvironmentChoice, ResolveConfigurationError};
use super::output::{self, TableFormat};
/// Run a scheduled agent command.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: ScheduleCommand,
) -> anyhow::Result<()> {
let output_format = global_options.output_format;
match command.into_subcommand() {
ScheduleSubcommand::Create(args) => create(ctx, args),
ScheduleSubcommand::List => list(ctx, output_format),
ScheduleSubcommand::Get(args) => get(ctx, output_format, args),
ScheduleSubcommand::Pause(args) => pause(ctx, args),
ScheduleSubcommand::Unpause(args) => unpause(ctx, args),
ScheduleSubcommand::Update(args) => update(ctx, args),
ScheduleSubcommand::Delete(args) => delete(ctx, args),
}
}
fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> {
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let refresh_future = super::common::refresh_workspace_metadata(ctx);
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
let setup_future = future::try_join(refresh_future, warp_drive_sync_future);
ctx.spawn(setup_future, move |manager, setup_result, ctx| {
if let Err(err) = setup_result {
super::report_fatal_error(err, ctx);
return;
}
let loaded_file = match args.config_file.file.as_deref() {
Some(path) => match super::config_file::load_config_file(path) {
Ok(file) => Some(file),
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
},
None => None,
};
let mut environment_args = args.environment;
if environment_args.environment.is_none() && !environment_args.no_environment {
if let Some(environment_id) = loaded_file
.as_ref()
.and_then(|f| f.file.environment_id.clone())
{
environment_args.environment = Some(environment_id);
}
}
let environment_id = match EnvironmentChoice::resolve_for_create(environment_args, ctx)
{
Ok(EnvironmentChoice::None) => {
eprintln!("Scheduling agent to run without an environment.");
None
}
Ok(EnvironmentChoice::Environment { id, .. }) => Some(id),
Err(ResolveConfigurationError::Canceled) => {
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(anyhow::anyhow!(err), ctx);
return;
}
};
let owner =
match super::common::resolve_owner(args.scope.team, args.scope.personal, ctx) {
Ok(owner) => owner,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let cli_mcp_servers =
match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) {
Ok(mcp_servers) => mcp_servers,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let merged_config = super::config_file::merge_with_precedence(
loaded_file.as_ref(),
crate::ai::ambient_agents::AgentConfigSnapshot {
name: None,
environment_id,
model_id: args.model.model.clone(),
base_prompt: None,
mcp_servers: cli_mcp_servers,
profile_id: None,
worker_host: args.worker_host,
skill_spec: args.skill.map(|s| s.to_string()),
// TODO(QUALITY-294): Support computer use flag in scheduled agents.
computer_use_enabled: None,
// TODO(REMOTE-1134): Support harness flag for scheduled agents.
harness: None,
harness_auth_secrets: None,
},
);
// We must wait until after workspace metadata is refreshed to check available LLMs.
let model_id = match merged_config
.model_id
.as_deref()
.map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx))
.transpose()
{
Ok(id) => id.map(|id| id.to_string()),
Err(err) => {
super::report_fatal_error(anyhow::anyhow!(err), ctx);
return;
}
};
let mut agent_config = merged_config;
agent_config.model_id = model_id;
let prompt = args.prompt.unwrap_or_default();
let mut config = ScheduledAmbientAgent::new(args.name, args.cron, true, prompt);
config.agent_config = agent_config;
// Print something here because scheduling an agent can take a while.
println!("Scheduling agent {}...", config.name);
let create_future = manager.create_schedule(config, owner, ctx);
ctx.spawn(create_future, |_manager, result, ctx| match result {
Ok(sync_id) => {
println!("Scheduled agent: {sync_id}");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
#[derive(Serialize)]
struct ScheduleInfo {
id: String,
name: String,
cron_schedule: String,
paused: bool,
last_ran: Option<DateTime<Utc>>,
next_run: Option<DateTime<Utc>>,
scope: String,
prompt: String,
last_spawn_error: Option<String>,
agent_config: AgentConfigSnapshot,
}
impl ScheduleInfo {
fn new(
id: String,
scope: String,
config: ScheduledAmbientAgent,
history: Option<&ScheduledAgentHistory>,
) -> Self {
let last_ran = history.and_then(|h| h.last_ran.as_ref().map(|t| t.utc()));
let next_run = history.and_then(|h| h.next_run.as_ref().map(|t| t.utc()));
ScheduleInfo {
id,
name: config.name,
cron_schedule: config.cron_schedule,
paused: !config.enabled,
last_ran,
next_run,
scope,
prompt: config.prompt,
last_spawn_error: config.last_spawn_error,
agent_config: config.agent_config,
}
}
fn last_ran_display(&self) -> String {
let timestamp = self
.last_ran
.map(format_approx_duration_from_now_utc)
.unwrap_or("-".to_string());
if self.last_spawn_error.is_some() {
format!("❌ {}", timestamp)
} else {
timestamp
}
}
fn next_run_display(&self) -> String {
// We can't use format_approx_duration_from_now_utc because the date is in the future.
// We use RTC 2822 rather than RFC 3339 because it's more human-readable.
self.next_run
.map(|dt| dt.to_rfc2822())
.unwrap_or("-".to_string())
}
}
impl TableFormat for ScheduleInfo {
fn header() -> Vec<Cell> {
vec![
Cell::new("ID"),
Cell::new("Name"),
Cell::new("Schedule"),
Cell::new("Paused"),
Cell::new("Last ran"),
Cell::new("Next run"),
Cell::new("Scope"),
]
}
fn row(&self) -> Vec<Cell> {
let paused_display = if self.paused { "Yes" } else { "No" };
vec![
Cell::new(&self.id),
Cell::new(&self.name),
Cell::new(&self.cron_schedule),
Cell::new(paused_display),
Cell::new(self.last_ran_display()),
Cell::new(self.next_run_display()),
Cell::new(&self.scope),
]
}
}
fn print_schedule_info(info: &ScheduleInfo, output_format: OutputFormat) -> anyhow::Result<()> {
let paused_display = if info.paused { "Yes" } else { "No" };
match output_format {
OutputFormat::Json => {
serde_json::to_writer(std::io::stdout(), info)?;
Ok(())
}
OutputFormat::Ndjson => output::write_json_line(info, std::io::stdout()),
OutputFormat::Text => {
println!("Name: {}", info.name);
println!("Cron schedule: {}", info.cron_schedule);
println!("Paused: {paused_display}");
let last_ran = info.last_ran_display();
let next_run = info.next_run_display();
println!("Last ran: {last_ran}");
if let Some(error) = &info.last_spawn_error {
println!("Last error: {error}");
}
println!("Next run: {next_run}");
println!("Prompt: {}", info.prompt);
if let Some(environment_id) = &info.agent_config.environment_id {
println!("Environment ID: {environment_id}");
}
if let Some(model_id) = &info.agent_config.model_id {
println!("Model ID: {model_id}");
}
if let Some(agent_name) = &info.agent_config.name {
println!("Agent name: {agent_name}");
}
if let Some(skill_spec) = &info.agent_config.skill_spec {
println!("Skill: {skill_spec}");
}
if let Some(worker_host) = &info.agent_config.worker_host {
println!("Host: {worker_host}");
}
Ok(())
}
OutputFormat::Pretty => {
let mut table = output::standard_table();
table.add_row(vec![Cell::new("Name"), Cell::new(&info.name)]);
table.add_row(vec![
Cell::new("Cron schedule"),
Cell::new(&info.cron_schedule),
]);
table.add_row(vec![Cell::new("Paused"), Cell::new(paused_display)]);
let last_ran = info.last_ran_display();
let next_run = info.next_run_display();
table.add_row(vec![Cell::new("Last ran"), Cell::new(last_ran)]);
if let Some(error) = &info.last_spawn_error {
table.add_row(vec![Cell::new("Last error"), Cell::new(error)]);
}
table.add_row(vec![Cell::new("Next run"), Cell::new(next_run)]);
table.add_row(vec![Cell::new("Prompt"), Cell::new(&info.prompt)]);
if let Some(environment_id) = &info.agent_config.environment_id {
table.add_row(vec![Cell::new("Environment ID"), Cell::new(environment_id)]);
}
if let Some(model_id) = &info.agent_config.model_id {
table.add_row(vec![Cell::new("Model ID"), Cell::new(model_id)]);
}
if let Some(agent_name) = &info.agent_config.name {
table.add_row(vec![Cell::new("Agent name"), Cell::new(agent_name)]);
}
if let Some(skill_spec) = &info.agent_config.skill_spec {
table.add_row(vec![Cell::new("Skill"), Cell::new(skill_spec)]);
}
if let Some(worker_host) = &info.agent_config.worker_host {
table.add_row(vec![Cell::new("Host"), Cell::new(worker_host)]);
}
println!("{table}");
Ok(())
}
}
}
fn pause(ctx: &mut AppContext, args: PauseScheduleArgs) -> anyhow::Result<()> {
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
if let Err(err) = result {
super::report_fatal_error(err, ctx);
return;
}
println!("Pausing agent...");
let pause_future = manager.pause_schedule(schedule_id, ctx);
ctx.spawn(pause_future, |_manager, result, ctx| match result {
Ok(()) => {
println!("Schedule paused");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
fn unpause(ctx: &mut AppContext, args: UnpauseScheduleArgs) -> anyhow::Result<()> {
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
if let Err(err) = result {
super::report_fatal_error(err, ctx);
return;
}
println!("Resuming agent...");
let unpause_future = manager.unpause_schedule(schedule_id, ctx);
ctx.spawn(unpause_future, |_manager, result, ctx| match result {
Ok(()) => {
println!("Schedule unpaused");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
fn update(ctx: &mut AppContext, args: UpdateScheduleArgs) -> anyhow::Result<()> {
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let refresh_future = super::common::refresh_workspace_metadata(ctx);
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
let setup_future = future::try_join(refresh_future, warp_drive_sync_future);
ctx.spawn(setup_future, move |manager, setup_result, ctx| {
if let Err(err) = setup_result {
super::report_fatal_error(err, ctx);
return;
}
let loaded_file = match args.config_file.file.as_deref() {
Some(path) => match super::config_file::load_config_file(path) {
Ok(file) => Some(file),
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
},
None => None,
};
let file_config = loaded_file.as_ref().map(|f| &f.file);
// We must wait until after workspace metadata is refreshed to check available LLMs.
let model_id = match args
.model
.model
.as_deref()
.or_else(|| file_config.and_then(|f| f.model_id.as_deref()))
.map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx))
.transpose()
{
Ok(id) => id.map(|id| id.to_string()),
Err(err) => {
super::report_fatal_error(anyhow::anyhow!(err), ctx);
return;
}
};
let mut environment_args = args.environment;
if environment_args.environment.is_none() && !environment_args.remove_environment {
if let Some(environment_id) = file_config.and_then(|f| f.environment_id.clone()) {
environment_args.environment = Some(environment_id);
}
}
let environment_id = match EnvironmentChoice::resolve_for_update(environment_args, ctx)
{
Ok(choice) => choice.map(|c| match c {
EnvironmentChoice::None => None,
EnvironmentChoice::Environment { id, .. } => Some(id),
}),
Err(ResolveConfigurationError::Canceled) => {
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(anyhow::anyhow!(err), ctx);
return;
}
};
// MCP update semantics are patch-only:
// - file and CLI MCP servers are treated as upserts (CLI wins on key conflicts)
// - `--remove-mcp` removes keys
// If both are present, removals win by filtering removed names out of the upsert payload.
let mut mcp_servers_upsert = match file_config.and_then(|f| f.mcp_servers.clone()) {
Some(map) if map.is_empty() => None,
Some(map) => Some(map),
None => None,
};
let cli_mcp_servers_upsert =
match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) {
Ok(mcp_servers) => mcp_servers,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
if let Some(cli_map) = cli_mcp_servers_upsert {
let merged = mcp_servers_upsert.get_or_insert_with(serde_json::Map::new);
for (name, config_value) in cli_map {
merged.insert(name, config_value);
}
}
if let Some(map) = mcp_servers_upsert.as_mut() {
for name in &args.remove_mcp {
map.remove(name);
}
if map.is_empty() {
mcp_servers_upsert = None;
}
}
// Handle skill update semantics: --skill sets it, --remove-skill clears it
let skill_spec = if args.remove_skill {
Some(None)
} else {
args.skill.map(|s| Some(s.to_string()))
};
println!("Updating agent...");
let update_future = manager.update_schedule(
schedule_id,
UpdateScheduleParams {
name: args.name,
cron: args.cron,
model_id,
environment_id,
base_prompt: file_config.and_then(|f| f.base_prompt.clone()),
prompt: args.prompt,
mcp_servers_upsert,
remove_mcp_server_names: args.remove_mcp,
skill_spec,
worker_host: args.worker_host,
},
ctx,
);
ctx.spawn(update_future, |_manager, result, ctx| match result {
Ok(()) => {
println!("Schedule updated");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
/// List all scheduled agents available to the current user.
fn list(ctx: &mut AppContext, output_format: OutputFormat) -> anyhow::Result<()> {
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
if let Err(err) = result {
super::report_fatal_error(err, ctx);
return;
}
let mut schedules = manager.list_schedules(ctx);
schedules.sort_by_key(|schedule| schedule.model().string_model.name.clone());
let futures = schedules.into_iter().map(|schedule| {
let config = schedule.model().string_model.clone();
let sync_id = schedule.sync_id();
let scope = super::common::format_owner(&schedule.permissions().owner).to_string();
// TODO(ben): Consider a bulk lookup API for scheduled agent history.
let history_future = manager.fetch_schedule_history(sync_id, ctx);
async move {
// Try to fetch the scheduled agent history, but still show output if this fails.
let history = match history_future.await {
Ok(v) => v,
Err(err) => {
log::warn!("Failed to fetch scheduled agent history: {err:#}");
None
}
};
let id = match sync_id {
SyncId::ServerId(server_id) => server_id.to_string(),
SyncId::ClientId(_) => "Unsynced".to_string(),
};
ScheduleInfo::new(id, scope, config, history.as_ref())
}
});
let output_format = output_format;
ctx.spawn(
futures::future::join_all(futures),
move |_manager, infos, ctx| {
output::print_list(infos, output_format);
ctx.terminate_app(TerminationMode::ForceTerminate, None);
},
);
});
});
Ok(())
}
fn get(
ctx: &mut AppContext,
output_format: OutputFormat,
args: GetScheduleArgs,
) -> anyhow::Result<()> {
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
if let Err(err) = result {
super::report_fatal_error(err, ctx);
return;
}
let Some(schedule) = CloudScheduledAmbientAgent::get_by_id(&schedule_id, ctx) else {
super::report_fatal_error(anyhow::anyhow!("Schedule not found"), ctx);
return;
};
let id = match &schedule_id {
SyncId::ServerId(server_id) => server_id.to_string(),
SyncId::ClientId(_) => "Unsynced".to_string(),
};
let scope = super::common::format_owner(&schedule.permissions().owner).to_string();
let config = schedule.model().string_model.clone();
// Don't hold references into the CloudObject store across an async spawn.
let history_future = manager.fetch_schedule_history(schedule_id, ctx);
ctx.spawn(history_future, move |_manager, history, ctx| {
let history = match history {
Ok(v) => v,
Err(err) => {
log::warn!("Failed to fetch scheduled agent history: {err:#}");
None
}
};
let info = ScheduleInfo::new(id, scope, config, history.as_ref());
if let Err(err) = print_schedule_info(&info, output_format) {
super::report_fatal_error(err, ctx);
return;
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
});
});
});
Ok(())
}
fn delete(ctx: &mut AppContext, args: DeleteScheduleArgs) -> anyhow::Result<()> {
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
if let Err(err) = result {
super::report_fatal_error(err, ctx);
return;
}
println!("Deleting agent...");
let delete_future = manager.delete_schedule(schedule_id, ctx);
ctx.spawn(delete_future, |_manager, result, ctx| match result {
Ok(()) => {
println!("Schedule deleted");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
+776
View File
@@ -0,0 +1,776 @@
use std::{
fs,
io::{self, IsTerminal as _, Read},
};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use comfy_table::Cell;
use inquire::{Confirm, InquireError, Password};
use serde::Serialize;
use warp_cli::{
agent::OutputFormat,
scope::ObjectScope,
secret::{
AnthropicMethod, CreateProvider, CreateSecretArgs, DeleteSecretArgs, ListSecretsArgs,
SecretCommand, SecretType, UpdateSecretArgs, ValueArgs,
},
GlobalOptions,
};
use warp_core::features::FeatureFlag;
use warp_graphql::{
managed_secrets::{ManagedSecret, ManagedSecretType},
object::SpaceType,
};
use warp_managed_secrets::{client::SecretOwner, ManagedSecretManager, ManagedSecretValue};
use warpui::{platform::TerminationMode, AppContext, SingletonEntity as _};
use crate::{
auth::UserUid, cloud_object::Owner, server::ids::ServerId,
util::time_format::format_approx_duration_from_now_utc,
};
use super::output::{self, TableFormat};
#[derive(Serialize)]
struct SecretInfo {
name: String,
scope: String,
#[serde(rename = "type")]
secret_type: ManagedSecretType,
#[serde(rename = "created")]
created_at: DateTime<Utc>,
#[serde(rename = "updated")]
updated_at: DateTime<Utc>,
}
impl TableFormat for SecretInfo {
fn header() -> Vec<Cell> {
vec![
Cell::new("Name"),
Cell::new("Scope"),
Cell::new("Type"),
Cell::new("Created"),
Cell::new("Updated"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.name),
Cell::new(&self.scope),
Cell::new(format_secret_type(&self.secret_type)),
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
Cell::new(format_approx_duration_from_now_utc(self.updated_at)),
]
}
}
/// Run secret-related commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: SecretCommand,
) -> Result<()> {
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
return Err(anyhow::anyhow!("This feature is not enabled"));
}
match command {
SecretCommand::Create(args) => create_secret(ctx, args),
SecretCommand::Delete(args) => delete_secret(ctx, args),
SecretCommand::Update(args) => update_secret(ctx, args),
SecretCommand::List(args) => list_secrets(ctx, global_options.output_format, args),
}
}
/// Deferred secret value reader. Constructed during argument parsing but read after validation
/// (refresh metadata, resolve owner) so users are not prompted for secrets before we know the
/// request is valid.
enum SecretInput {
/// Single-field secret types (raw value, Anthropic API key).
Simple {
secret_type: SecretType,
value_args: ValueArgs,
},
/// Multi-field Bedrock API key secret with dedicated CLI flags.
Bedrock {
bedrock_api_key: Option<String>,
region: Option<String>,
},
/// Multi-field Bedrock access key secret with dedicated CLI flags.
BedrockAccessKey {
access_key_id: Option<String>,
secret_access_key: Option<String>,
session_token: Option<String>,
region: Option<String>,
},
}
impl SecretInput {
/// Read the secret value, prompting the user if necessary.
/// Returns `Ok(None)` when the user cancels the prompt.
fn read(self) -> Result<Option<ManagedSecretValue>> {
match self {
SecretInput::Simple {
secret_type,
value_args,
} => {
let raw = match read_simple_secret_value(&value_args)? {
Some(v) => v,
None => return Ok(None),
};
Ok(Some(make_simple_secret_value(secret_type, &raw)))
}
SecretInput::Bedrock {
bedrock_api_key,
region,
} => read_bedrock_secret_value(bedrock_api_key, region),
SecretInput::BedrockAccessKey {
access_key_id,
secret_access_key,
session_token,
region,
} => read_bedrock_access_key_secret_value(
access_key_id,
secret_access_key,
session_token,
region,
),
}
}
}
/// Create a new secret. Dispatches to the provider subcommand if present.
fn create_secret(ctx: &mut AppContext, args: CreateSecretArgs) -> Result<()> {
// Resolve provider subcommand into common fields plus a deferred value reader.
let (name, input, description, scope) = match args.provider {
Some(CreateProvider::Anthropic(anthropic)) => match anthropic.method {
AnthropicMethod::ApiKey(a) => (
a.common.name,
SecretInput::Simple {
secret_type: SecretType::AnthropicApiKey,
value_args: a.value,
},
a.common.description,
a.common.scope,
),
AnthropicMethod::BedrockApiKey(a) => (
a.common.name,
SecretInput::Bedrock {
bedrock_api_key: a.bedrock_api_key,
region: a.region,
},
a.common.description,
a.common.scope,
),
AnthropicMethod::BedrockAccessKey(a) => (
a.common.name,
SecretInput::BedrockAccessKey {
access_key_id: a.access_key_id,
secret_access_key: a.secret_access_key,
session_token: a.session_token,
region: a.region,
},
a.common.description,
a.common.scope,
),
},
None => {
let name = args.name.ok_or_else(|| {
anyhow::anyhow!("Secret name is required. Usage: oz secret create <NAME>")
})?;
(
name,
SecretInput::Simple {
secret_type: args.secret_type,
value_args: args.value,
},
args.description,
args.scope,
)
}
};
create_secret_with_input(ctx, name, input, description, scope)
}
/// Shared creation logic: refreshes metadata, resolves the owner, reads the secret value, and
/// creates the secret.
fn create_secret_with_input(
ctx: &mut AppContext,
name: String,
input: SecretInput,
description: Option<String>,
scope: ObjectScope,
) -> Result<()> {
ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| {
// Perform as much validation as possible up-front, before prompting the user for a secret.
// It's a bad UX if we make them type in a secret and then fail on something we could have
// checked beforehand.
let refresh_future = super::common::refresh_workspace_metadata(ctx);
ctx.spawn(refresh_future, move |manager, refresh_result, ctx| {
if let Err(err) = refresh_result {
super::report_fatal_error(err, ctx);
return;
}
let owner = match super::common::resolve_owner(scope.team, scope.personal, ctx) {
Ok(owner) => owner,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let managed_value = match input.read() {
Ok(Some(value)) => value,
Ok(None) => {
// Treat this as a cancellation.
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let secret_owner = match owner {
Owner::User { .. } => SecretOwner::CurrentUser,
Owner::Team { team_uid } => SecretOwner::Team {
team_uid: team_uid.uid(),
},
};
let create_future = manager.create_secret(
secret_owner,
name.clone(),
managed_value,
description.clone(),
);
ctx.spawn(create_future, move |_, result, ctx| match result {
Ok(secret) => {
println!("Secret '{}' created", secret.name);
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
/// Delete a secret.
fn delete_secret(ctx: &mut AppContext, args: DeleteSecretArgs) -> Result<()> {
let name = args.name;
let force = args.force;
let team = args.scope.team;
let personal = args.scope.personal;
ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| {
let refresh_future = super::common::refresh_workspace_metadata(ctx);
let name = name.clone();
ctx.spawn(refresh_future, move |manager, refresh_result, ctx| {
if let Err(err) = refresh_result {
super::report_fatal_error(err, ctx);
return;
}
let owner = match super::common::resolve_owner(team, personal, ctx) {
Ok(owner) => owner,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let secret_owner = match owner {
Owner::User { .. } => SecretOwner::CurrentUser,
Owner::Team { team_uid } => SecretOwner::Team {
team_uid: team_uid.uid(),
},
};
if !force {
if !io::stdin().is_terminal() {
super::report_fatal_error(
anyhow::anyhow!(
"Refusing to delete secret without confirmation in non-interactive mode (use --force to bypass)"
),
ctx,
);
return;
}
let scope = match owner {
Owner::User { .. } => "personal",
Owner::Team { .. } => "team",
};
let should_delete = match Confirm::new(&format!("Delete {scope} secret '{name}'?"))
.with_default(false)
.with_help_message("This action cannot be undone")
.prompt()
{
Ok(should_delete) => should_delete,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
ctx
.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(err.into(), ctx);
return;
}
};
if !should_delete {
println!("Deletion cancelled");
ctx
.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
}
let delete_future = manager.delete_secret(secret_owner, name.clone());
ctx.spawn(delete_future, move |_, result, ctx| match result {
Ok(()) => {
println!("Secret '{name}' deleted");
ctx
.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
/// Update a secret.
fn update_secret(ctx: &mut AppContext, args: UpdateSecretArgs) -> Result<()> {
ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| {
// Perform as much validation as possible up-front, before prompting the user for a secret.
let refresh_future = super::common::refresh_workspace_metadata(ctx);
ctx.spawn(refresh_future, move |manager, refresh_result, ctx| {
if let Err(err) = refresh_result {
super::report_fatal_error(err, ctx);
return;
}
let owner =
match super::common::resolve_owner(args.scope.team, args.scope.personal, ctx) {
Ok(owner) => owner,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
// Read the secret value if either --value or --value-file is provided.
let secret_value = if args.value || args.value_args.value_file.is_some() {
// Create ValueArgs to handle reading from file or prompting
match read_simple_secret_value(&args.value_args) {
Ok(Some(value)) => Some(value),
Ok(None) => {
// Treat this as a cancellation.
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
}
} else {
None
};
let secret_owner = match owner {
Owner::User { .. } => SecretOwner::CurrentUser,
Owner::Team { team_uid } => SecretOwner::Team {
team_uid: team_uid.uid(),
},
};
if let Some(secret_value) = secret_value {
// Look up the existing secret's type so we use the correct ManagedSecretValue variant.
let list_future = manager.list_secrets();
ctx.spawn(list_future, move |manager, list_result, ctx| {
let secrets = match list_result {
Ok(secrets) => secrets,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let secret_type = match find_secret_type(&secrets, &args.name, &secret_owner) {
Some(t) => t,
None => {
super::report_fatal_error(
anyhow::anyhow!("Secret '{}' not found", args.name),
ctx,
);
return;
}
};
let managed_secret_value =
match make_secret_value_from_gql_type(secret_type, &secret_value) {
Ok(v) => v,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let update_future = manager.update_secret(
secret_owner,
args.name.clone(),
Some(managed_secret_value),
args.description.clone(),
);
ctx.spawn(update_future, move |_, result, ctx| match result {
Ok(secret) => {
println!("Secret '{}' updated", secret.name);
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
} else {
// Description-only update; no encryption needed.
let update_future = manager.update_secret(
secret_owner,
args.name.clone(),
None,
args.description.clone(),
);
ctx.spawn(update_future, move |_, result, ctx| match result {
Ok(secret) => {
println!("Secret '{}' updated", secret.name);
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
}
});
});
Ok(())
}
/// List secrets.
fn list_secrets(
ctx: &mut AppContext,
output_format: OutputFormat,
_args: ListSecretsArgs,
) -> Result<()> {
ManagedSecretManager::handle(ctx).update(ctx, |manager, ctx| {
ctx.spawn(manager.list_secrets(), move |_, result, ctx| match result {
Ok(secrets) => {
let secret_infos = secrets.into_iter().map(|secret| {
let owner = match secret.owner.type_ {
SpaceType::User => Owner::User {
user_uid: UserUid::new(secret.owner.uid.inner()),
},
SpaceType::Team => Owner::Team {
team_uid: ServerId::from_string_lossy(secret.owner.uid.inner()),
},
};
SecretInfo {
name: secret.name,
scope: super::common::format_owner(&owner).to_string(),
secret_type: secret.type_,
created_at: secret.created_at.utc(),
updated_at: secret.updated_at.utc(),
}
});
output::print_list(secret_infos, output_format);
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
Ok(())
}
/// Read a raw secret string from either the provided file or stdin.
fn read_simple_secret_value(args: &ValueArgs) -> Result<Option<String>> {
if let Some(value_file) = args.value_file.as_ref() {
let value = fs::read_to_string(value_file).with_context(|| {
format!("Failed to read secret value from: {}", value_file.display())
})?;
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
} else if io::stdin().is_terminal() {
let result = Password::new("Secret value:")
.with_display_toggle_enabled()
.without_confirmation()
.prompt();
match result {
Ok(value) => {
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
Err(err) => Err(err.into()),
}
} else {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf)?;
if buf.is_empty() {
Ok(None)
} else {
Ok(Some(buf))
}
}
}
/// Constructs the appropriate [`ManagedSecretValue`] for single-field CLI secret types.
fn make_simple_secret_value(secret_type: SecretType, raw: &str) -> ManagedSecretValue {
match secret_type {
SecretType::RawValue => ManagedSecretValue::raw_value(raw),
SecretType::AnthropicApiKey => ManagedSecretValue::anthropic_api_key(raw),
SecretType::AnthropicBedrockApiKey => {
// Bedrock secrets are multi-field and handled via SecretInput::Bedrock.
unreachable!("Bedrock secrets should not go through make_simple_secret_value")
}
}
}
/// Constructs the appropriate [`ManagedSecretValue`] for the given GraphQL secret type.
/// Used when updating an existing secret whose type is fetched from the server.
fn make_secret_value_from_gql_type(
gql_type: ManagedSecretType,
raw: &str,
) -> Result<ManagedSecretValue> {
match gql_type {
ManagedSecretType::RawValue | ManagedSecretType::Dotenvx => {
Ok(ManagedSecretValue::raw_value(raw))
}
ManagedSecretType::AnthropicApiKey => Ok(ManagedSecretValue::anthropic_api_key(raw)),
ManagedSecretType::AnthropicBedrockAccessKey => {
// Bedrock access key secrets cannot be updated through the generic raw-string path.
Err(anyhow::anyhow!(
"Bedrock access key secrets cannot be updated via `--value`; re-create the secret instead"
))
}
ManagedSecretType::AnthropicBedrockApiKey => {
// Bedrock secrets cannot be updated through the generic raw-string path.
// The caller should use the dedicated Bedrock creation flow instead.
Err(anyhow::anyhow!(
"Bedrock API key secrets cannot be updated via `--value`; re-create the secret instead"
))
}
}
}
/// Read a Bedrock API key secret from dedicated CLI flags or interactive prompts.
fn read_bedrock_secret_value(
bedrock_api_key: Option<String>,
region: Option<String>,
) -> Result<Option<ManagedSecretValue>> {
let api_key = match bedrock_api_key {
Some(k) if !k.is_empty() => k,
_ => {
if !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(
"Bedrock secrets require --bedrock-api-key and --region in non-interactive mode"
));
}
let result = Password::new("Bedrock API key:")
.with_display_toggle_enabled()
.without_confirmation()
.prompt();
match result {
Ok(value) if !value.is_empty() => value,
Ok(_) => return Ok(None),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Ok(None);
}
Err(err) => return Err(err.into()),
}
}
};
let region = match region {
Some(r) if !r.is_empty() => r,
_ => {
if !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(
"Bedrock secrets require --bedrock-api-key and --region in non-interactive mode"
));
}
let result = inquire::Text::new("AWS Region:").prompt();
match result {
Ok(value) if !value.is_empty() => value,
Ok(_) => return Ok(None),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Ok(None);
}
Err(err) => return Err(err.into()),
}
}
};
Ok(Some(ManagedSecretValue::anthropic_bedrock_api_key(
api_key, region,
)))
}
/// Read a Bedrock access key secret from dedicated CLI flags or interactive prompts.
///
/// `session_token` is optional: if the user passes an empty `--session-token`
/// value or hits Enter at the interactive prompt, no session token is stored.
/// This supports persistent IAM credentials, which do not require a session token.
fn read_bedrock_access_key_secret_value(
access_key_id: Option<String>,
secret_access_key: Option<String>,
session_token: Option<String>,
region: Option<String>,
) -> Result<Option<ManagedSecretValue>> {
// Error message used for all three required fields when running non-interactively.
// --session-token is intentionally omitted because it is optional.
const NON_INTERACTIVE_REQUIRED_MSG: &str = "Bedrock access key secrets require --access-key-id, --secret-access-key, and --region in non-interactive mode";
let access_key_id = match access_key_id {
Some(v) if !v.is_empty() => v,
_ => {
if !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(NON_INTERACTIVE_REQUIRED_MSG));
}
match inquire::Text::new("AWS Access Key ID:").prompt() {
Ok(value) if !value.is_empty() => value,
Ok(_) => return Ok(None),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Ok(None);
}
Err(err) => return Err(err.into()),
}
}
};
let secret_access_key = match secret_access_key {
Some(v) if !v.is_empty() => v,
_ => {
if !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(NON_INTERACTIVE_REQUIRED_MSG));
}
match Password::new("AWS Secret Access Key:")
.with_display_toggle_enabled()
.without_confirmation()
.prompt()
{
Ok(value) if !value.is_empty() => value,
Ok(_) => return Ok(None),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Ok(None);
}
Err(err) => return Err(err.into()),
}
}
};
// The session token is optional. An empty --session-token flag or an empty
// interactive submission is treated as "no token" rather than as a cancel.
let session_token: Option<String> = match session_token {
Some(v) if !v.is_empty() => Some(v),
Some(_) => None,
None => {
if !io::stdin().is_terminal() {
// In non-interactive mode, omitting --session-token is fine:
// persistent IAM credentials do not need a session token.
None
} else {
match Password::new("AWS Session Token (optional, press Enter to skip):")
.with_display_toggle_enabled()
.without_confirmation()
.prompt()
{
Ok(value) if !value.is_empty() => Some(value),
// Empty input signals "no session token", not a cancel.
Ok(_) => None,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Ok(None);
}
Err(err) => return Err(err.into()),
}
}
}
};
let region = match region {
Some(r) if !r.is_empty() => r,
_ => {
if !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(NON_INTERACTIVE_REQUIRED_MSG));
}
match inquire::Text::new("AWS Region:").prompt() {
Ok(value) if !value.is_empty() => value,
Ok(_) => return Ok(None),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Ok(None);
}
Err(err) => return Err(err.into()),
}
}
};
Ok(Some(ManagedSecretValue::anthropic_bedrock_access_key(
access_key_id,
secret_access_key,
session_token,
region,
)))
}
/// Finds the type of an existing secret by name and owner scope.
fn find_secret_type(
secrets: &[ManagedSecret],
name: &str,
owner: &SecretOwner,
) -> Option<ManagedSecretType> {
secrets
.iter()
.find(|s| {
s.name == name
&& match owner {
SecretOwner::CurrentUser => matches!(s.owner.type_, SpaceType::User),
SecretOwner::Team { team_uid } => {
matches!(s.owner.type_, SpaceType::Team) && s.owner.uid.inner() == team_uid
}
}
})
.map(|s| s.type_)
}
fn format_secret_type(type_: &ManagedSecretType) -> String {
match type_ {
ManagedSecretType::RawValue => "Raw Value".to_string(),
ManagedSecretType::Dotenvx => "dotenvx".to_string(),
ManagedSecretType::AnthropicApiKey => "Anthropic API Key".to_string(),
ManagedSecretType::AnthropicBedrockAccessKey => "Anthropic Bedrock Access Key".to_string(),
ManagedSecretType::AnthropicBedrockApiKey => "Anthropic Bedrock API Key".to_string(),
}
}
+424
View File
@@ -0,0 +1,424 @@
use crate::features::FeatureFlag;
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub(super) enum CliTelemetryEvent {
/// Executing `warp agent run`
AgentRun {
gui: bool,
requested_mcp_servers: usize,
has_environment: bool,
/// Optional task ID when running against an ambient agent task.
task_id: Option<String>,
/// Which execution harness was selected (e.g. "oz", "claude").
harness: String,
},
/// Executing `warp agent run-ambient`
AgentRunAmbient,
/// Executing `warp agent profile list`
AgentProfileList,
/// Executing `warp agent list`
AgentList,
/// Executing `warp environment list`
EnvironmentList,
/// Executing `warp environment create`
EnvironmentCreate,
/// Executing `warp environment delete`
EnvironmentDelete,
/// Executing `warp environment update`
EnvironmentUpdate,
/// Executing `warp environment get`
EnvironmentGet,
/// Executing `warp environment image list`
EnvironmentImageList,
/// Executing `warp mcp list`
MCPList,
/// Executing `warp model list`
ModelList,
/// Executing `warp task list`
TaskList,
/// Executing `warp task get`
TaskGet,
/// Executing `warp run conversation get`
ConversationGet,
/// Executing `warp run get <id> --conversation`
RunConversationGet,
/// Executing `warp run message watch`
RunMessageWatch { harness: &'static str },
/// Executing `warp run message send`
RunMessageSend { harness: &'static str },
/// Executing `warp run message list`
RunMessageList { harness: &'static str },
/// Executing `warp run message read`
RunMessageRead { harness: &'static str },
/// Executing `warp run message mark-delivered`
RunMessageMarkDelivered { harness: &'static str },
/// Executing `warp login`
Login,
/// Executing `warp logout`
Logout,
/// Executing `warp whoami`
Whoami,
/// Executing `warp provider setup`
ProviderSetup,
/// Executing `warp provider list`
ProviderList,
/// Executing `warp integration create`
IntegrationCreate,
/// Executing `warp integration update`
IntegrationUpdate,
/// Executing `warp integration list`
IntegrationList,
/// Executing `warp artifact upload`
ArtifactUpload,
/// Executing `warp artifact get`
ArtifactGet,
/// Executing `warp artifact download`
ArtifactDownload,
/// Executing `warp schedule create`
ScheduleCreate,
/// Executing `warp schedule list`
ScheduleList,
/// Executing `warp schedule get`
ScheduleGet,
/// Executing `warp schedule pause`
SchedulePause,
/// Executing `warp schedule unpause`
ScheduleUnpause,
/// Executing `warp schedule update`
ScheduleUpdate,
/// Executing `warp schedule delete`
ScheduleDelete,
/// Executing `warp secret create`
SecretCreate,
/// Executing `warp secret delete`
SecretDelete,
/// Executing `warp secret update`
SecretUpdate,
/// Executing `warp secret list`
SecretList,
/// Executing `warp federate issue-token`
FederateIssueToken,
/// Executing `warp federate issue-gcp-token`
FederateIssueGcpToken,
/// Executing `warp harness-support ping`
HarnessSupportPing,
/// Executing `warp harness-support report-artifact`
HarnessSupportReportArtifact { artifact_type: &'static str },
/// Executing `warp harness-support notify-user`
HarnessSupportNotifyUser,
/// Executing `warp harness-support finish-task`
HarnessSupportFinishTask { success: bool },
}
impl TelemetryEvent for CliTelemetryEvent {
fn name(&self) -> &'static str {
CliTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<Value> {
match self {
CliTelemetryEvent::AgentRun {
gui,
requested_mcp_servers,
has_environment,
task_id,
harness,
} => Some(json!({
"gui": gui,
"requested_mcp_servers": requested_mcp_servers,
"has_environment": has_environment,
"task_id": task_id,
"harness": harness,
})),
CliTelemetryEvent::AgentRunAmbient => None,
CliTelemetryEvent::AgentProfileList => None,
CliTelemetryEvent::AgentList => None,
CliTelemetryEvent::EnvironmentList => None,
CliTelemetryEvent::EnvironmentCreate => None,
CliTelemetryEvent::EnvironmentDelete => None,
CliTelemetryEvent::EnvironmentUpdate => None,
CliTelemetryEvent::EnvironmentGet => None,
CliTelemetryEvent::EnvironmentImageList => None,
CliTelemetryEvent::MCPList => None,
CliTelemetryEvent::ModelList => None,
CliTelemetryEvent::TaskList => None,
CliTelemetryEvent::TaskGet => None,
CliTelemetryEvent::ConversationGet => None,
CliTelemetryEvent::RunConversationGet => None,
CliTelemetryEvent::RunMessageWatch { harness } => Some(json!({ "harness": harness })),
CliTelemetryEvent::RunMessageSend { harness } => Some(json!({ "harness": harness })),
CliTelemetryEvent::RunMessageList { harness } => Some(json!({ "harness": harness })),
CliTelemetryEvent::RunMessageRead { harness } => Some(json!({ "harness": harness })),
CliTelemetryEvent::RunMessageMarkDelivered { harness } => {
Some(json!({ "harness": harness }))
}
CliTelemetryEvent::Login => None,
CliTelemetryEvent::Logout => None,
CliTelemetryEvent::Whoami => None,
CliTelemetryEvent::ProviderSetup => None,
CliTelemetryEvent::ProviderList => None,
CliTelemetryEvent::IntegrationCreate => None,
CliTelemetryEvent::IntegrationUpdate => None,
CliTelemetryEvent::IntegrationList => None,
CliTelemetryEvent::ArtifactUpload => None,
CliTelemetryEvent::ArtifactGet => None,
CliTelemetryEvent::ArtifactDownload => None,
CliTelemetryEvent::ScheduleCreate => None,
CliTelemetryEvent::ScheduleList => None,
CliTelemetryEvent::ScheduleGet => None,
CliTelemetryEvent::SchedulePause => None,
CliTelemetryEvent::ScheduleUnpause => None,
CliTelemetryEvent::ScheduleUpdate => None,
CliTelemetryEvent::ScheduleDelete => None,
CliTelemetryEvent::SecretCreate => None,
CliTelemetryEvent::SecretDelete => None,
CliTelemetryEvent::SecretUpdate => None,
CliTelemetryEvent::SecretList => None,
CliTelemetryEvent::FederateIssueToken => None,
CliTelemetryEvent::FederateIssueGcpToken => None,
CliTelemetryEvent::HarnessSupportPing => None,
CliTelemetryEvent::HarnessSupportReportArtifact { artifact_type } => {
Some(json!({ "artifact_type": artifact_type }))
}
CliTelemetryEvent::HarnessSupportNotifyUser => None,
CliTelemetryEvent::HarnessSupportFinishTask { success } => {
Some(json!({ "success": success }))
}
}
}
fn description(&self) -> &'static str {
CliTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
CliTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
false
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
warp_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
CliTelemetryEventDiscriminants::AgentRun => "CLI.Execute.Agent.Run",
CliTelemetryEventDiscriminants::AgentRunAmbient => "CLI.Execute.Agent.RunAmbient",
CliTelemetryEventDiscriminants::AgentProfileList => "CLI.Execute.Agent.Profile.List",
CliTelemetryEventDiscriminants::AgentList => "CLI.Execute.Agent.List",
CliTelemetryEventDiscriminants::EnvironmentList => "CLI.Execute.Environment.List",
CliTelemetryEventDiscriminants::EnvironmentCreate => "CLI.Execute.Environment.Create",
CliTelemetryEventDiscriminants::EnvironmentDelete => "CLI.Execute.Environment.Delete",
CliTelemetryEventDiscriminants::EnvironmentUpdate => "CLI.Execute.Environment.Update",
CliTelemetryEventDiscriminants::EnvironmentGet => "CLI.Execute.Environment.Get",
CliTelemetryEventDiscriminants::EnvironmentImageList => {
"CLI.Execute.Environment.Image.List"
}
CliTelemetryEventDiscriminants::MCPList => "CLI.Execute.MCP.List",
CliTelemetryEventDiscriminants::ModelList => "CLI.Execute.Model.List",
CliTelemetryEventDiscriminants::TaskList => "CLI.Execute.Task.List",
CliTelemetryEventDiscriminants::TaskGet => "CLI.Execute.Task.Get",
CliTelemetryEventDiscriminants::ConversationGet => "CLI.Execute.Conversation.Get",
CliTelemetryEventDiscriminants::RunConversationGet => {
"CLI.Execute.Run.Conversation.Get"
}
CliTelemetryEventDiscriminants::RunMessageWatch => "CLI.Execute.Run.Message.Watch",
CliTelemetryEventDiscriminants::RunMessageSend => "CLI.Execute.Run.Message.Send",
CliTelemetryEventDiscriminants::RunMessageList => "CLI.Execute.Run.Message.List",
CliTelemetryEventDiscriminants::RunMessageRead => "CLI.Execute.Run.Message.Read",
CliTelemetryEventDiscriminants::RunMessageMarkDelivered => {
"CLI.Execute.Run.Message.MarkDelivered"
}
CliTelemetryEventDiscriminants::Login => "CLI.Execute.Login",
CliTelemetryEventDiscriminants::Logout => "CLI.Execute.Logout",
CliTelemetryEventDiscriminants::Whoami => "CLI.Execute.Whoami",
CliTelemetryEventDiscriminants::ProviderSetup => "CLI.Execute.Provider.Setup",
CliTelemetryEventDiscriminants::ProviderList => "CLI.Execute.Provider.List",
CliTelemetryEventDiscriminants::IntegrationCreate => "CLI.Execute.Integration.Create",
CliTelemetryEventDiscriminants::IntegrationUpdate => "CLI.Execute.Integration.Update",
CliTelemetryEventDiscriminants::IntegrationList => "CLI.Execute.Integration.List",
CliTelemetryEventDiscriminants::ArtifactUpload => "CLI.Execute.Artifact.Upload",
CliTelemetryEventDiscriminants::ArtifactGet => "CLI.Execute.Artifact.Get",
CliTelemetryEventDiscriminants::ArtifactDownload => "CLI.Execute.Artifact.Download",
CliTelemetryEventDiscriminants::ScheduleCreate => "CLI.Execute.Schedule.Create",
CliTelemetryEventDiscriminants::ScheduleList => "CLI.Execute.Schedule.List",
CliTelemetryEventDiscriminants::ScheduleGet => "CLI.Execute.Schedule.Get",
CliTelemetryEventDiscriminants::SchedulePause => "CLI.Execute.Schedule.Pause",
CliTelemetryEventDiscriminants::ScheduleUnpause => "CLI.Execute.Schedule.Unpause",
CliTelemetryEventDiscriminants::ScheduleUpdate => "CLI.Execute.Schedule.Update",
CliTelemetryEventDiscriminants::ScheduleDelete => "CLI.Execute.Schedule.Delete",
CliTelemetryEventDiscriminants::SecretCreate => "CLI.Execute.Secret.Create",
CliTelemetryEventDiscriminants::SecretDelete => "CLI.Execute.Secret.Delete",
CliTelemetryEventDiscriminants::SecretUpdate => "CLI.Execute.Secret.Update",
CliTelemetryEventDiscriminants::SecretList => "CLI.Execute.Secret.List",
CliTelemetryEventDiscriminants::FederateIssueToken => "CLI.Execute.Federate.IssueToken",
CliTelemetryEventDiscriminants::FederateIssueGcpToken => {
"CLI.Execute.Federate.IssueGcpToken"
}
CliTelemetryEventDiscriminants::HarnessSupportPing => "CLI.Execute.HarnessSupport.Ping",
CliTelemetryEventDiscriminants::HarnessSupportReportArtifact => {
"CLI.Execute.HarnessSupport.ReportArtifact"
}
CliTelemetryEventDiscriminants::HarnessSupportNotifyUser => {
"CLI.Execute.HarnessSupport.NotifyUser"
}
CliTelemetryEventDiscriminants::HarnessSupportFinishTask => {
"CLI.Execute.HarnessSupport.FinishTask"
}
}
}
fn description(&self) -> &'static str {
match self {
CliTelemetryEventDiscriminants::AgentRun => "Ran an agent from the Warp CLI",
CliTelemetryEventDiscriminants::AgentRunAmbient => {
"Ran an ambient agent from the Warp CLI"
}
CliTelemetryEventDiscriminants::AgentProfileList => {
"Listed agent profiles from the Warp CLI"
}
CliTelemetryEventDiscriminants::AgentList => "Listed agents from the Warp CLI",
CliTelemetryEventDiscriminants::EnvironmentList => {
"Listed cloud environments from the Warp CLI"
}
CliTelemetryEventDiscriminants::EnvironmentCreate => {
"Created a cloud environment from the Warp CLI"
}
CliTelemetryEventDiscriminants::EnvironmentDelete => {
"Deleted a cloud environment from the Warp CLI"
}
CliTelemetryEventDiscriminants::EnvironmentUpdate => {
"Updated a cloud environment from the Warp CLI"
}
CliTelemetryEventDiscriminants::EnvironmentGet => {
"Got cloud environment details from the Warp CLI"
}
CliTelemetryEventDiscriminants::EnvironmentImageList => {
"Listed available base images from the Warp CLI"
}
CliTelemetryEventDiscriminants::MCPList => "Listed MCP servers from the Warp CLI",
CliTelemetryEventDiscriminants::ModelList => "Listed models from the Warp CLI",
CliTelemetryEventDiscriminants::TaskList => "Listed tasks from the Warp CLI",
CliTelemetryEventDiscriminants::TaskGet => "Got status of task from the Warp CLI",
CliTelemetryEventDiscriminants::ConversationGet => {
"Got conversation by ID from the Warp CLI"
}
CliTelemetryEventDiscriminants::RunConversationGet => {
"Got run conversation from the Warp CLI"
}
CliTelemetryEventDiscriminants::RunMessageWatch => {
"Watched run messages from the Warp CLI"
}
CliTelemetryEventDiscriminants::RunMessageSend => {
"Sent a run message from the Warp CLI"
}
CliTelemetryEventDiscriminants::RunMessageList => {
"Listed run messages from the Warp CLI"
}
CliTelemetryEventDiscriminants::RunMessageRead => {
"Read a run message from the Warp CLI"
}
CliTelemetryEventDiscriminants::RunMessageMarkDelivered => {
"Marked a run message as delivered from the Warp CLI"
}
CliTelemetryEventDiscriminants::Login => "Logged in via the Warp CLI",
CliTelemetryEventDiscriminants::Logout => "Logged out via the Warp CLI",
CliTelemetryEventDiscriminants::Whoami => "Printed current user info from the Warp CLI",
CliTelemetryEventDiscriminants::ProviderSetup => "Set up a provider via the Warp CLI",
CliTelemetryEventDiscriminants::ProviderList => "Listed providers from the Warp CLI",
CliTelemetryEventDiscriminants::IntegrationCreate => {
"Created an integration from the Warp CLI"
}
CliTelemetryEventDiscriminants::IntegrationUpdate => {
"Updated an integration from the Warp CLI"
}
CliTelemetryEventDiscriminants::IntegrationList => {
"Listed integrations from the Warp CLI"
}
CliTelemetryEventDiscriminants::ArtifactUpload => {
"Uploaded an artifact from the Warp CLI"
}
CliTelemetryEventDiscriminants::ArtifactGet => {
"Got artifact metadata from the Warp CLI"
}
CliTelemetryEventDiscriminants::ArtifactDownload => {
"Downloaded an artifact from the Warp CLI"
}
CliTelemetryEventDiscriminants::ScheduleCreate => {
"Created a scheduled agent from the Warp CLI"
}
CliTelemetryEventDiscriminants::ScheduleList => {
"Listed scheduled agents from the Warp CLI"
}
CliTelemetryEventDiscriminants::ScheduleGet => {
"Got scheduled agent configuration from the Warp CLI"
}
CliTelemetryEventDiscriminants::SchedulePause => {
"Paused a scheduled agent from the Warp CLI"
}
CliTelemetryEventDiscriminants::ScheduleUnpause => {
"Unpaused a scheduled agent from the Warp CLI"
}
CliTelemetryEventDiscriminants::ScheduleUpdate => {
"Updated a scheduled agent from the Warp CLI"
}
CliTelemetryEventDiscriminants::ScheduleDelete => {
"Deleted a scheduled agent from the Warp CLI"
}
CliTelemetryEventDiscriminants::SecretCreate => "Created a secret from the Warp CLI",
CliTelemetryEventDiscriminants::SecretDelete => "Deleted a secret from the Warp CLI",
CliTelemetryEventDiscriminants::SecretUpdate => "Updated a secret from the Warp CLI",
CliTelemetryEventDiscriminants::SecretList => "Listed secrets from the Warp CLI",
CliTelemetryEventDiscriminants::FederateIssueToken => {
"Issued a federated identity token from the Warp CLI"
}
CliTelemetryEventDiscriminants::FederateIssueGcpToken => {
"Issued a GCP federated identity token from the Warp CLI"
}
CliTelemetryEventDiscriminants::HarnessSupportPing => {
"Pinged harness-support from the Warp CLI"
}
CliTelemetryEventDiscriminants::HarnessSupportReportArtifact => {
"Reported an artifact via harness-support from the Warp CLI"
}
CliTelemetryEventDiscriminants::HarnessSupportNotifyUser => {
"Sent a user notification via harness-support from the Warp CLI"
}
CliTelemetryEventDiscriminants::HarnessSupportFinishTask => {
"Reported task completion via harness-support from the Warp CLI"
}
}
}
fn enablement_state(&self) -> EnablementState {
match self {
Self::FederateIssueToken | Self::FederateIssueGcpToken => {
EnablementState::Flag(FeatureFlag::OzIdentityFederation)
}
Self::HarnessSupportPing
| Self::HarnessSupportReportArtifact
| Self::HarnessSupportNotifyUser
| Self::HarnessSupportFinishTask => EnablementState::Flag(FeatureFlag::AgentHarness),
Self::ArtifactUpload | Self::ArtifactGet | Self::ArtifactDownload => {
EnablementState::Flag(FeatureFlag::ArtifactCommand)
}
Self::RunMessageWatch
| Self::RunMessageSend
| Self::RunMessageList
| Self::RunMessageRead
| Self::RunMessageMarkDelivered => EnablementState::Flag(FeatureFlag::OrchestrationV2),
_ => EnablementState::Always,
}
}
}
warp_core::register_telemetry_event!(CliTelemetryEvent);
+18
View File
@@ -0,0 +1,18 @@
//! Test-only helpers shared across `agent_sdk` unit tests.
/// Build a reqwest-backed `http_client::Client` with TLS/proxy disabled and the connection
/// pool disabled (`pool_max_idle_per_host(0)`).
///
/// The pool setting is the key bit: reqwest's default 90-second idle timeout holds sockets
/// open past a test's return, which nextest's leak detector flags on retry-exhaustion tests
/// that spin up multiple connections.
pub(super) fn build_test_http_client() -> http_client::Client {
let builder = reqwest::ClientBuilder::new()
.tls_built_in_native_certs(false)
.tls_built_in_root_certs(false)
.tls_built_in_webpki_certs(false)
.no_proxy()
.pool_max_idle_per_host(0);
http_client::Client::from_client_builder(builder)
.expect("should not fail to build test http client")
}
+97
View File
@@ -0,0 +1,97 @@
/// Helper function to wrap text to fit within a maximum width, breaking on word boundaries.
pub fn word_wrap(text: &str, max_width: usize) -> Vec<String> {
if text.trim().is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
let needs_space = !current.is_empty();
let prospective_len = current.len() + if needs_space { 1 } else { 0 } + word.len();
if current.is_empty() {
if word.len() <= max_width {
current.push_str(word);
} else {
// Single word longer than max width: emit it as its own line
lines.push(word.to_string());
}
} else if prospective_len <= max_width {
if needs_space {
current.push(' ');
}
current.push_str(word);
} else {
// Push current line and start a new one with the word (or emit if too long)
if !current.is_empty() {
if current.len() < max_width {
let pad = max_width - current.len();
current.extend(std::iter::repeat_n(' ', pad));
}
lines.push(std::mem::take(&mut current));
}
if word.len() <= max_width {
current.push_str(word);
} else {
lines.push(word.to_string());
}
}
}
if !current.is_empty() {
if current.len() < max_width {
let pad = max_width - current.len();
current.extend(std::iter::repeat_n(' ', pad));
}
lines.push(current);
}
if lines.is_empty() {
vec![String::new()]
} else {
lines
}
}
/// Render a labeled, wrapped field as a single multi-line cell.
pub fn render_labeled_wrapped_field(label: &str, text: &str, width: usize) -> String {
let mut out = String::new();
let wrapped = word_wrap(text, width);
let indent = " ".repeat(label.len() + 2); // align under "{label}: "
for (i, line) in wrapped.iter().enumerate() {
if i == 0 {
out.push_str(&format!("{label}: {line}"));
} else {
out.push('\n');
out.push_str(&indent);
out.push_str(line);
}
}
out
}
/// Convert an identifier like a slug (e.g. "slack", "github-actions") into a
/// human-friendly, title-cased name (e.g. "Slack", "Github Actions").
pub fn title_case_identifier(s: &str) -> String {
let mut parts = Vec::new();
for part in s.split(|c: char| !c.is_alphanumeric()) {
if part.is_empty() {
continue;
}
let mut chars = part.chars();
let first = chars
.next()
.expect("non-empty identifier component must have a first character");
let mut word = String::new();
word.extend(first.to_uppercase());
word.push_str(&chars.as_str().to_lowercase());
parts.push(word);
}
if parts.is_empty() {
s.to_string()
} else {
parts.join(" ")
}
}