first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+2
View File
@@ -4,6 +4,8 @@ use anyhow::{Context, Result};
use galaxy_cli::agent::OutputFormat;
use galaxyui::{platform::TerminationMode, AppContext, SingletonEntity};
use serde::Serialize;
use galaxyui::platform::TerminationMode;
use galaxyui::{AppContext, SingletonEntity};
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::auth::user::PrincipalType;
+12 -10
View File
@@ -1,13 +1,15 @@
//! Commands to interact with available agents via the public API.
use warp_cli::agent::ListAgentSkillsArgs;
use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
use warp_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
use warpui::platform::TerminationMode;
use warpui::{AppContext, ModelContext, SingletonEntity};
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::ai::AgentSkillItem;
use crate::server::server_api::ServerApiProvider;
use galaxy_cli::agent::ListAgentConfigsArgs;
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
use galaxy_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
const MAX_LINE_WIDTH: usize = 90;
const MAX_AUTH_ATTEMPTS: u32 = 8;
@@ -15,8 +17,8 @@ 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<()> {
/// List all available agent skills.
pub fn list_skills(ctx: &mut AppContext, args: ListAgentSkillsArgs) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentConfigRunner);
runner.update(ctx, |runner, ctx| runner.list(args.repo.clone(), ctx))
}
@@ -220,7 +222,7 @@ impl AgentConfigRunner {
println!("Fetching agent skills from your Warp environments...");
}
let list_future = async move { ai_client.list_agents(repo).await };
let list_future = async move { ai_client.list_skills(repo).await };
ctx.spawn(list_future, |_, result, ctx| match result {
Ok(agents) => {
@@ -234,9 +236,9 @@ impl AgentConfigRunner {
}
/// Print a list of agents in a card-style format.
fn print_agents_table(agents: &[AgentListItem]) {
fn print_agents_table(agents: &[AgentSkillItem]) {
if agents.is_empty() {
println!("No agents found.");
println!("No skills found.");
return;
}
+515
View File
@@ -0,0 +1,515 @@
//! Commands to manage named agents via the public API.
use std::cmp::Ordering;
use std::io::Write as _;
use anyhow::anyhow;
use comfy_table::Cell;
use serde::Serialize;
use warp_cli::agent::{
AgentCreateArgs, AgentDeleteArgs, AgentGetArgs, AgentListArgs, AgentSortByArg, AgentUpdateArgs,
OutputFormat,
};
use warp_cli::json_filter::JsonOutput;
use warp_cli::SortOrderArg;
use warpui::platform::TerminationMode;
use warpui::{AppContext, ModelContext, SingletonEntity};
use super::output::TableFormat;
use crate::server::server_api::ai::{
AgentResponse, CreateAgentRequest, SecretRef, UpdateAgentRequest,
};
use crate::server::server_api::ServerApiProvider;
/// Singleton model that runs async work for named-agent CLI commands.
struct AgentManagementRunner;
pub fn list_agents(
ctx: &mut AppContext,
output_format: OutputFormat,
args: AgentListArgs,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
runner.update(ctx, |runner, ctx| runner.list(args, output_format, ctx))
}
pub fn get_agent(
ctx: &mut AppContext,
output_format: OutputFormat,
args: AgentGetArgs,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
runner.update(ctx, |runner, ctx| runner.get(args, output_format, ctx))
}
pub fn create_agent(
ctx: &mut AppContext,
output_format: OutputFormat,
args: AgentCreateArgs,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
runner.update(ctx, |runner, ctx| runner.create(args, output_format, ctx))
}
pub fn update_agent(
ctx: &mut AppContext,
output_format: OutputFormat,
args: AgentUpdateArgs,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
runner.update(ctx, |runner, ctx| runner.update(args, output_format, ctx))
}
pub fn delete_agent(
ctx: &mut AppContext,
output_format: OutputFormat,
args: AgentDeleteArgs,
) -> anyhow::Result<()> {
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
runner.update(ctx, |runner, ctx| runner.delete(args, output_format, ctx))
}
impl AgentManagementRunner {
fn spawn_command(
&self,
future: impl warpui::r#async::Spawnable<Output = anyhow::Result<()>>,
ctx: &mut ModelContext<Self>,
) {
ctx.spawn(future, |_, result, ctx| match result {
Ok(()) => {
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
}
fn list(
&self,
args: AgentListArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let future = async move {
ensure_json_sort_is_not_requested(output_format, &args.json_output, &args)?;
if matches!(output_format, OutputFormat::Json) || args.json_output.force_json_output() {
let response = ai_client.list_agents_raw().await?;
super::output::print_raw_json(response, &args.json_output)?;
} else {
let mut agents = ai_client.list_agents().await?;
sort_agents(&mut agents, args.sort_by, args.sort_order);
print_agents(&agents, output_format)?;
}
Ok(())
};
self.spawn_command(future, ctx);
Ok(())
}
fn get(
&self,
args: AgentGetArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let future = async move {
if matches!(output_format, OutputFormat::Json) || args.json_output.force_json_output() {
let response = ai_client.get_agent_raw(&args.uid).await?;
super::output::print_raw_json(response, &args.json_output)?;
} else {
let agent = ai_client.get_agent(&args.uid).await?;
print_single_agent(&agent, output_format)?;
}
Ok(())
};
self.spawn_command(future, ctx);
Ok(())
}
fn create(
&self,
args: AgentCreateArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let future = async move {
let json_output = args.json_output.clone();
let request = CreateAgentRequest {
name: args.name,
description: args.description,
secrets: secret_refs(args.secrets),
skills: args.skills,
base_model: args.base_model,
environment_id: args.environment,
};
if matches!(output_format, OutputFormat::Json) || json_output.force_json_output() {
let response = ai_client.create_agent_raw(request).await?;
super::output::print_raw_json(response, &json_output)?;
} else {
let agent = ai_client.create_agent(request).await?;
print_single_agent(&agent, output_format)?;
}
Ok(())
};
self.spawn_command(future, ctx);
Ok(())
}
fn update(
&self,
args: AgentUpdateArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let future = async move {
let uid = args.uid.clone();
let json_output = args.json_output.clone();
let current_agent = if args.add_secrets.is_empty()
&& args.remove_secrets.is_empty()
&& args.add_skills.is_empty()
&& args.remove_skills.is_empty()
{
None
} else {
Some(ai_client.get_agent(&uid).await?)
};
let request = UpdateAgentRequest {
name: args.name,
description: if args.remove_description {
Some(String::new())
} else {
args.description
},
secrets: if args.remove_all_secrets {
Some(vec![])
} else if args.add_secrets.is_empty() && args.remove_secrets.is_empty() {
None
} else {
let current_agent = current_agent
.as_ref()
.expect("current agent is fetched when applying secret deltas");
Some(apply_secret_deltas(
&current_agent.secrets,
args.add_secrets,
args.remove_secrets,
))
},
skills: if args.remove_all_skills {
Some(vec![])
} else if args.add_skills.is_empty() && args.remove_skills.is_empty() {
None
} else {
let current_agent = current_agent
.as_ref()
.expect("current agent is fetched when applying skill deltas");
Some(apply_string_deltas(
&current_agent.skills,
args.add_skills,
args.remove_skills,
))
},
base_model: if args.remove_base_model {
Some(String::new())
} else {
args.base_model
},
environment_id: if args.remove_environment {
Some(String::new())
} else {
args.environment
},
};
if request_is_empty(&request) {
return Err(anyhow!("No updates requested"));
}
if matches!(output_format, OutputFormat::Json) || json_output.force_json_output() {
let response = ai_client.update_agent_raw(&uid, request).await?;
super::output::print_raw_json(response, &json_output)?;
} else {
let agent = ai_client.update_agent(&uid, request).await?;
print_single_agent(&agent, output_format)?;
}
Ok(())
};
self.spawn_command(future, ctx);
Ok(())
}
fn delete(
&self,
args: AgentDeleteArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let future = async move {
ai_client.delete_agent(&args.uid).await?;
print_delete_result(&args.uid, output_format)?;
Ok(())
};
self.spawn_command(future, ctx);
Ok(())
}
}
fn secret_refs(secrets: Vec<String>) -> Vec<SecretRef> {
secrets.into_iter().map(|name| SecretRef { name }).collect()
}
/// Add and remove the requested secrets, starting with `current` as a baseline.
fn apply_secret_deltas(
current: &[SecretRef],
add_secrets: Vec<String>,
remove_secrets: Vec<String>,
) -> Vec<SecretRef> {
let names = apply_string_deltas(
&current
.iter()
.map(|secret| secret.name.clone())
.collect::<Vec<_>>(),
add_secrets,
remove_secrets,
);
secret_refs(names)
}
/// Add and remove the requested values, starting with `current` as a baseline.
fn apply_string_deltas(
current: &[String],
add_values: Vec<String>,
remove_values: Vec<String>,
) -> Vec<String> {
let mut values = current
.iter()
.filter(|value| !remove_values.contains(value))
.cloned()
.collect::<Vec<_>>();
for value in add_values {
if !values.contains(&value) {
values.push(value);
}
}
values
}
fn request_is_empty(request: &UpdateAgentRequest) -> bool {
request.name.is_none()
&& request.description.is_none()
&& request.secrets.is_none()
&& request.skills.is_none()
&& request.base_model.is_none()
&& request.environment_id.is_none()
}
fn ensure_json_sort_is_not_requested(
output_format: OutputFormat,
json_output: &JsonOutput,
args: &AgentListArgs,
) -> anyhow::Result<()> {
if (matches!(output_format, OutputFormat::Json) || json_output.force_json_output())
&& (args.sort_by.is_some() || args.sort_order.is_some())
{
return Err(anyhow!(
"--sort-by and --sort-order are not supported with JSON output"
));
}
Ok(())
}
fn sort_agents(
agents: &mut [AgentResponse],
sort_by: Option<AgentSortByArg>,
sort_order: Option<SortOrderArg>,
) {
let sort_by = sort_by.unwrap_or(AgentSortByArg::Name);
let default_order = match sort_by {
AgentSortByArg::Name => SortOrderArg::Asc,
AgentSortByArg::CreatedAt => SortOrderArg::Desc,
};
let sort_order = sort_order.unwrap_or(default_order);
agents.sort_by(|left, right| {
let ordering = match sort_by {
AgentSortByArg::Name => left
.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.uid.cmp(&right.uid)),
AgentSortByArg::CreatedAt => left
.created_at
.cmp(&right.created_at)
.then_with(|| left.uid.cmp(&right.uid)),
};
match sort_order {
SortOrderArg::Asc => ordering,
SortOrderArg::Desc => match ordering {
Ordering::Less => Ordering::Greater,
Ordering::Equal => Ordering::Equal,
Ordering::Greater => Ordering::Less,
},
}
});
}
impl TableFormat for AgentResponse {
fn header() -> Vec<Cell> {
vec![
Cell::new("UID"),
Cell::new("Name"),
Cell::new("Created"),
Cell::new("Description"),
Cell::new("Secrets"),
Cell::new("Skills"),
Cell::new("Base model"),
Cell::new("Environment"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.uid),
Cell::new(&self.name),
Cell::new(self.created_at.to_rfc3339()),
Cell::new(display_optional(self.description.as_deref())),
Cell::new(display_list(
self.secrets.iter().map(|secret| secret.name.as_str()),
)),
Cell::new(display_list(self.skills.iter().map(String::as_str))),
Cell::new(display_optional(self.base_model.as_deref())),
Cell::new(display_optional(self.environment_id.as_deref())),
]
}
}
fn print_agents(agents: &[AgentResponse], output_format: OutputFormat) -> anyhow::Result<()> {
match output_format {
OutputFormat::Pretty | OutputFormat::Text => {
let (visible_agents, hidden_count) = visible_agents_and_hidden_count(agents);
match output_format {
OutputFormat::Pretty if visible_agents.is_empty() => {
println!("No agents found.");
print_skills_hint();
}
OutputFormat::Pretty => {
super::output::write_list(visible_agents, output_format, std::io::stdout())?;
print_skills_hint();
}
OutputFormat::Text => {
super::output::write_list(visible_agents, output_format, std::io::stdout())?;
}
OutputFormat::Json | OutputFormat::Ndjson => {
unreachable!("handled by outer match")
}
}
print_disabled_agents_hidden_notice(hidden_count);
}
OutputFormat::Ndjson => {
for agent in agents {
super::output::write_json_line(agent, std::io::stdout())?;
}
}
OutputFormat::Json => unreachable!("JSON output is handled by the raw API path"),
}
Ok(())
}
fn visible_agents_and_hidden_count(agents: &[AgentResponse]) -> (Vec<AgentResponse>, usize) {
let visible_agents = agents
.iter()
.filter(|agent| agent.available)
.cloned()
.collect::<Vec<_>>();
let hidden_count = agents.len() - visible_agents.len();
(visible_agents, hidden_count)
}
fn print_disabled_agents_hidden_notice(hidden_count: usize) {
if hidden_count > 0 {
eprintln!("{hidden_count} disabled agents hidden");
}
}
fn print_single_agent(agent: &AgentResponse, output_format: OutputFormat) -> anyhow::Result<()> {
match output_format {
OutputFormat::Pretty | OutputFormat::Text => {
super::output::write_list([agent.clone()], output_format, std::io::stdout())?;
}
OutputFormat::Ndjson => {
super::output::write_json_line(agent, std::io::stdout())?;
}
OutputFormat::Json => unreachable!("JSON output is handled by the raw API path"),
}
Ok(())
}
fn print_skills_hint() {
let binary_name = warp_cli::binary_name().unwrap_or_else(|| "warp".to_string());
println!("\n\nLooking for your agent skills? Use `{binary_name} agent skills` instead.");
}
#[derive(Serialize)]
struct DeleteAgentResult<'a> {
uid: &'a str,
deleted: bool,
}
fn print_delete_result(uid: &str, output_format: OutputFormat) -> anyhow::Result<()> {
match output_format {
OutputFormat::Pretty => {
println!("Deleted agent {uid}.");
}
OutputFormat::Text => {
let mut stdout = std::io::stdout();
writeln!(stdout, "{uid}")?;
}
OutputFormat::Ndjson => {
super::output::write_json_line(
&DeleteAgentResult { uid, deleted: true },
std::io::stdout(),
)?;
}
OutputFormat::Json => {
super::output::write_json(
&DeleteAgentResult { uid, deleted: true },
std::io::stdout(),
)?;
}
}
Ok(())
}
fn display_optional(value: Option<&str>) -> String {
value
.filter(|value| !value.is_empty())
.unwrap_or("-")
.to_string()
}
fn display_list<'a>(values: impl IntoIterator<Item = &'a str>) -> String {
let values = values
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
if values.is_empty() {
"-".to_string()
} else {
values.join(", ")
}
}
impl warpui::Entity for AgentManagementRunner {
type Event = ();
}
impl SingletonEntity for AgentManagementRunner {}
#[cfg(test)]
#[path = "agent_management_tests.rs"]
mod tests;
@@ -0,0 +1,170 @@
use chrono::{TimeZone as _, Utc};
use super::*;
fn agent(uid: &str, name: &str, created_at_seconds: i64) -> AgentResponse {
agent_with_available(uid, name, created_at_seconds, true)
}
fn agent_with_available(
uid: &str,
name: &str,
created_at_seconds: i64,
available: bool,
) -> AgentResponse {
AgentResponse {
uid: uid.to_string(),
name: name.to_string(),
description: None,
available,
created_at: Utc
.timestamp_opt(created_at_seconds, 0)
.single()
.expect("valid timestamp"),
secrets: vec![],
skills: vec![],
base_model: None,
environment_id: None,
}
}
#[test]
fn table_format_does_not_include_available_column() {
let header = AgentResponse::header()
.into_iter()
.map(|cell| cell.content().to_string())
.collect::<Vec<_>>();
let row = agent("1", "agent", 1).row();
assert_eq!(
header,
[
"UID",
"Name",
"Created",
"Description",
"Secrets",
"Skills",
"Base model",
"Environment",
]
);
assert_eq!(row.len(), header.len());
}
#[test]
fn visible_agents_and_hidden_count_filters_disabled_agents() {
let agents = vec![
agent_with_available("1", "enabled", 1, true),
agent_with_available("2", "disabled", 2, false),
];
let (visible_agents, hidden_count) = visible_agents_and_hidden_count(&agents);
assert_eq!(visible_agents.len(), 1);
assert_eq!(visible_agents[0].name, "enabled");
assert_eq!(hidden_count, 1);
}
#[test]
fn sort_agents_defaults_to_name_ascending() {
let mut agents = vec![agent("2", "zeta", 2), agent("1", "alpha", 1)];
sort_agents(&mut agents, None, None);
assert_eq!(agents[0].name, "alpha");
assert_eq!(agents[1].name, "zeta");
}
#[test]
fn sort_agents_defaults_created_at_to_descending() {
let mut agents = vec![agent("1", "old", 1), agent("2", "new", 2)];
sort_agents(&mut agents, Some(AgentSortByArg::CreatedAt), None);
assert_eq!(agents[0].name, "new");
assert_eq!(agents[1].name, "old");
}
#[test]
fn sort_agents_respects_explicit_sort_order_without_sort_field() {
let mut agents = vec![agent("1", "alpha", 1), agent("2", "zeta", 2)];
sort_agents(&mut agents, None, Some(SortOrderArg::Desc));
assert_eq!(agents[0].name, "zeta");
assert_eq!(agents[1].name, "alpha");
}
#[test]
fn update_request_omits_unset_fields_and_serializes_clears() {
let request = UpdateAgentRequest {
description: Some(String::new()),
secrets: Some(vec![]),
base_model: Some(String::new()),
..Default::default()
};
let json = serde_json::to_value(request).expect("request serializes");
assert_eq!(
json,
serde_json::json!({
"description": "",
"secrets": [],
"base_model": "",
})
);
}
#[test]
fn rejects_sort_for_json_output() {
let args = AgentListArgs {
sort_by: Some(AgentSortByArg::Name),
sort_order: None,
json_output: JsonOutput { filter: None },
};
let err = ensure_json_sort_is_not_requested(OutputFormat::Json, &args.json_output, &args)
.unwrap_err();
assert!(err.to_string().contains("not supported with JSON output"));
}
#[test]
fn apply_string_deltas_removes_and_appends_without_duplicates() {
let values = apply_string_deltas(
&["old".to_string(), "keep".to_string()],
vec!["new".to_string(), "keep".to_string()],
vec!["old".to_string()],
);
assert_eq!(values, ["keep", "new"]);
}
#[test]
fn apply_secret_deltas_uses_secret_names() {
let values = apply_secret_deltas(
&[
SecretRef {
name: "OLD_TOKEN".to_string(),
},
SecretRef {
name: "KEEP_TOKEN".to_string(),
},
],
vec!["NEW_TOKEN".to_string()],
vec!["OLD_TOKEN".to_string()],
);
assert_eq!(
values,
[
SecretRef {
name: "KEEP_TOKEN".to_string()
},
SecretRef {
name: "NEW_TOKEN".to_string()
},
]
);
}
+260 -77
View File
@@ -3,54 +3,50 @@ use std::io::Write as _;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context as _};
use comfy_table::Cell;
use futures::{future, StreamExt};
use serde::Serialize;
use warp_cli::agent::{Harness, OutputFormat, Prompt, RunCloudArgs};
use warp_cli::json_filter::JsonOutput;
use warp_cli::task::{
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, MessageCommand, MessageDeliveredArgs,
MessageListArgs, MessageReadArgs, MessageSendArgs, MessageWatchArgs, RunSortByArg,
RunSourceArg, RunStateArg, TaskGetArgs,
};
use warp_cli::{GlobalOptions, SortOrderArg};
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use warpui::platform::TerminationMode;
use warpui::r#async::{Spawnable, Timer};
use warpui::{AppContext, ModelContext, SingletonEntity};
use super::common::{parse_ambient_task_id, EnvironmentChoice, ResolveConfigurationError};
use crate::ai::agent::{extract_user_query_mode, UserQueryMode};
use crate::ai::agent_sdk::driver::attachments::{
process_attachment, MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY,
};
use crate::ai::ambient_agents::spawn::{
spawn_task, AmbientAgentEvent, SessionJoinInfo, TASK_STATUS_POLLING_DURATION,
};
use crate::ai::ambient_agents::task::HarnessConfig;
use crate::ai::ambient_agents::AmbientAgentTaskState;
use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTask};
use crate::ai::ambient_agents::{
AgentConfigSnapshot, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState,
};
use crate::ai::artifacts::Artifact;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::server::ids::{ServerId, SyncId};
use crate::server::server_api::ai::{
AIClient, AgentMessageHeader, AgentRunEvent, AgentSource, ArtifactType, ExecutionLocation,
ListAgentMessagesRequest, ReadAgentMessageResponse, RunSortBy, RunSortOrder,
SendAgentMessageRequest, SendAgentMessageResponse, SpawnAgentRequest, TaskListFilter,
};
use crate::server::server_api::ServerApi;
use crate::terminal::shared_session;
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{
terminal::shared_session, util::time_format::format_approx_duration_from_now_utc,
ServerApiProvider,
};
use anyhow::{anyhow, Context as _};
use comfy_table::Cell;
use futures::{future, StreamExt};
use serde::Serialize;
use galaxy_cli::{
agent::{Harness, OutputFormat, Prompt, RunCloudArgs},
json_filter::JsonOutput,
task::{
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, MessageCommand, MessageDeliveredArgs,
MessageListArgs, MessageReadArgs, MessageSendArgs, MessageWatchArgs, RunSortByArg,
RunSortOrderArg, RunSourceArg, RunStateArg, TaskGetArgs,
},
GlobalOptions,
};
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use galaxyui::r#async::Timer;
use galaxyui::{
platform::TerminationMode, r#async::Spawnable, AppContext, ModelContext, SingletonEntity,
};
use crate::ai::agent_sdk::driver::attachments::{
process_attachment, MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY,
};
use crate::cloud_object::model::persistence::CloudModel;
use crate::server::ids::{ServerId, SyncId};
use super::common::{EnvironmentChoice, ResolveConfigurationError};
use crate::ServerApiProvider;
const MAX_LINE_WIDTH: usize = 90;
const STREAM_RETRY_BACKOFF_STEPS: &[u64] = &[1, 2, 5, 10];
@@ -185,10 +181,10 @@ fn sort_by_from_arg(arg: RunSortByArg) -> RunSortBy {
}
}
fn sort_order_from_arg(arg: RunSortOrderArg) -> RunSortOrder {
fn sort_order_from_arg(arg: SortOrderArg) -> RunSortOrder {
match arg {
RunSortOrderArg::Asc => RunSortOrder::Asc,
RunSortOrderArg::Desc => RunSortOrder::Desc,
SortOrderArg::Asc => RunSortOrder::Asc,
SortOrderArg::Desc => RunSortOrder::Desc,
}
}
@@ -266,11 +262,8 @@ impl AmbientAgentRunner {
);
return;
}
// TODO: Consider making the server's prompt field optional when skill is provided,
// rather than sending an empty string for skill-only invocations.
let prompt_string = match prompt {
Some(Prompt::PlainText(text)) => text,
let prompt = match prompt {
Some(Prompt::PlainText(text)) => Some(text),
Some(Prompt::SavedPrompt(id)) => {
// Resolve the saved prompt to pass along as the ambient agent query.
// We look up the prompt text here, rather than passing along the saved prompt ID,
@@ -294,7 +287,7 @@ impl AmbientAgentRunner {
match workflow {
Some(cloud_workflow) => match cloud_workflow.model().data.prompt() {
Some(prompt_text) => prompt_text.to_string(),
Some(prompt_text) => Some(prompt_text.to_string()),
None => {
super::report_fatal_error(
anyhow::anyhow!("'{id}' is not a saved prompt"),
@@ -312,8 +305,7 @@ impl AmbientAgentRunner {
}
}
}
// Skill-only invocation: use empty prompt, skill provides instructions
None => String::new(),
None => None,
};
let loaded_file = match args.config_file.file.as_deref() {
@@ -327,6 +319,23 @@ impl AmbientAgentRunner {
None => None,
};
// The `--runner` CLI flag is gated in `run_agent`, but a config file
// can also set `runner_id`, which would otherwise bypass the gate.
if loaded_file
.as_ref()
.and_then(|f| f.file.runner_id.as_ref())
.is_some()
&& !FeatureFlag::CloudRunners.is_enabled()
{
super::report_fatal_error(
anyhow::anyhow!(
"`runner_id` is set in the config file but runner support is not enabled"
),
ctx,
);
return;
}
// Validate and process attachments early, before environment selection
// This ensures users don't have to go through env selection if attachment validation fails
if args.attachment_paths.len() > MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY {
@@ -416,10 +425,13 @@ impl AmbientAgentRunner {
let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig {
harness_type: args.harness,
model_id: None,
reasoning_level: None,
});
let harness_auth_secrets = args.claude_auth_secret.clone().map(|name| {
crate::ai::ambient_agents::task::HarnessAuthSecretsConfig {
claude_auth_secret_name: Some(name),
codex_auth_secret_name: None,
}
});
@@ -428,6 +440,7 @@ impl AmbientAgentRunner {
AgentConfigSnapshot {
name: args.name,
environment_id,
runner_id: args.runner,
model_id: args.model.model.clone(),
base_prompt: None,
mcp_servers: cli_mcp_servers,
@@ -471,8 +484,16 @@ impl AmbientAgentRunner {
None
};
let (prompt, mode) = match prompt {
Some(prompt) => {
let (prompt, mode) = extract_user_query_mode(prompt);
(Some(prompt), mode)
}
None => (None, UserQueryMode::Normal),
};
let request = SpawnAgentRequest {
prompt: prompt_string,
prompt,
mode,
config,
title: None,
team: match (args.scope.team, args.scope.personal) {
@@ -480,12 +501,17 @@ impl AmbientAgentRunner {
(_, true) => Some(false),
_ => None,
},
agent_identity_uid: args.agent_uid,
skill,
attachments,
interactive: None,
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
conversation_id: args.conversation,
initial_snapshot_token: None,
snapshot_disabled: None,
orchestration_handoff: None,
};
let should_open = args.open;
@@ -640,17 +666,42 @@ impl AmbientAgentRunner {
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let provider = ServerApiProvider::as_ref(ctx);
let ai_client = provider.get_ai_client();
let server_api = provider.get();
let scoped_task_id = task_id_for_message_send(&args.sender_run_id)?;
let future = async move {
let response = ai_client
.send_agent_message(SendAgentMessageRequest {
to: args.to,
subject: args.subject,
body: args.body,
sender_run_id: args.sender_run_id,
})
.await?;
let request = SendAgentMessageRequest {
to: args.to,
subject: args.subject,
body: args.body,
sender_run_id: args.sender_run_id,
};
let log_context = SendAgentMessageLogContext::new(&request, scoped_task_id.as_ref());
log_context.log_start();
let send_message = async move {
match scoped_task_id {
Some(task_id) => {
server_api
.send_agent_message_for_task(&task_id, request)
.await
}
None => ai_client.send_agent_message(request).await,
}
};
let response = match send_message.await {
Ok(response) => {
log_context.log_success(&response);
response
}
Err(err) => {
let err = err.context(log_context.error_context());
log_context.log_error(&err);
eprintln!("{err:#}");
return Err(err);
}
};
print_send_message_response(&response, output_format)?;
Ok(())
};
@@ -658,25 +709,31 @@ impl AmbientAgentRunner {
Ok(())
}
fn list_messages(
&self,
args: MessageListArgs,
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let provider = ServerApiProvider::as_ref(ctx);
let ai_client = provider.get_ai_client();
let server_api = provider.get();
let future = async move {
let messages = ai_client
.list_agent_messages(
&args.run_id,
ListAgentMessagesRequest {
unread_only: args.unread,
since: args.since,
limit: args.limit,
},
)
.await?;
let request = ListAgentMessagesRequest {
unread_only: args.unread,
since: args.since,
limit: args.limit,
};
let messages = match task_id_from_run_id(&args.run_id) {
Some(task_id) => {
server_api
.list_agent_messages_for_task(&task_id, &args.run_id, request)
.await?
}
None => ai_client.list_agent_messages(&args.run_id, request).await?,
};
super::output::print_list(messages, output_format);
Ok(())
};
@@ -708,10 +765,20 @@ impl AmbientAgentRunner {
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let provider = ServerApiProvider::as_ref(ctx);
let ai_client = provider.get_ai_client();
let server_api = provider.get();
let scoped_task_id = task_id_from_oz_run_id_env()?;
let future = async move {
let message = ai_client.read_agent_message(&args.message_id).await?;
let message = match scoped_task_id {
Some(task_id) => {
server_api
.read_agent_message_for_task(&task_id, &args.message_id)
.await?
}
None => ai_client.read_agent_message(&args.message_id).await?,
};
print_read_message_response(&message, output_format)?;
Ok(())
};
@@ -726,10 +793,20 @@ impl AmbientAgentRunner {
output_format: OutputFormat,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<()> {
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let provider = ServerApiProvider::as_ref(ctx);
let ai_client = provider.get_ai_client();
let server_api = provider.get();
let scoped_task_id = task_id_from_oz_run_id_env()?;
let future = async move {
ai_client.mark_message_delivered(&args.message_id).await?;
match scoped_task_id {
Some(task_id) => {
server_api
.mark_message_delivered_for_task(&task_id, &args.message_id)
.await?
}
None => ai_client.mark_message_delivered(&args.message_id).await?,
}
print_mark_message_delivered_result(&args.message_id, output_format)?;
Ok(())
};
@@ -790,6 +867,10 @@ impl AmbientAgentRunner {
table.add_row(vec![title_cell]);
}
if let Some(executor) = task.executor_display_name() {
table.add_row(vec![format!("Executed as: {executor}")]);
}
// Agent config snapshot (if available)
if let Some(config) = task.agent_config_snapshot.as_ref() {
let config_str =
@@ -933,6 +1014,91 @@ fn write_stream_record<T: Serialize>(record: &T) -> anyhow::Result<()> {
stdout.flush().context("unable to flush stdout")?;
Ok(())
}
fn task_id_from_run_id(run_id: &str) -> Option<AmbientAgentTaskId> {
run_id.parse().ok()
}
fn task_id_from_oz_run_id_env() -> anyhow::Result<Option<AmbientAgentTaskId>> {
match std::env::var(warp_cli::OZ_RUN_ID_ENV) {
Ok(run_id) => parse_ambient_task_id(&run_id, "Invalid OZ_RUN_ID").map(Some),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(anyhow!(
"{} is set but is not valid Unicode",
warp_cli::OZ_RUN_ID_ENV
)),
}
}
fn task_id_for_message_send(sender_run_id: &str) -> anyhow::Result<Option<AmbientAgentTaskId>> {
match task_id_from_run_id(sender_run_id) {
Some(task_id) => Ok(Some(task_id)),
None => task_id_from_oz_run_id_env(),
}
}
#[derive(Debug, Clone)]
struct SendAgentMessageLogContext {
sender_run_id: String,
task_id: Option<String>,
target_agent_ids: Vec<String>,
subject: String,
body_len: usize,
}
impl SendAgentMessageLogContext {
fn new(request: &SendAgentMessageRequest, task_id: Option<&AmbientAgentTaskId>) -> Self {
Self {
sender_run_id: request.sender_run_id.clone(),
task_id: task_id.map(|task_id| task_id.to_string()),
target_agent_ids: request.to.clone(),
subject: request.subject.clone(),
body_len: request.body.chars().count(),
}
}
fn error_context(&self) -> String {
format!(
"Failed to send agent message (sender_run_id={:?}, task_id={:?}, target_agent_ids={:?})",
self.sender_run_id, self.task_id, self.target_agent_ids
)
}
fn log_start(&self) {
log::info!(
"Sending ambient agent message: sender_run_id={:?} task_id={:?} target_agent_ids={:?} subject={:?} body_len={}",
self.sender_run_id,
self.task_id,
self.target_agent_ids,
self.subject,
self.body_len
);
}
fn log_success(&self, response: &SendAgentMessageResponse) {
log::info!(
"Sent ambient agent message: sender_run_id={:?} task_id={:?} target_agent_ids={:?} subject={:?} body_len={} message_ids={:?}",
self.sender_run_id,
self.task_id,
self.target_agent_ids,
self.subject,
self.body_len,
response.message_ids
);
}
fn log_error(&self, err: &anyhow::Error) {
log::warn!(
"Failed to send ambient agent message: sender_run_id={:?} task_id={:?} target_agent_ids={:?} subject={:?} body_len={} error={err:#}",
self.sender_run_id,
self.task_id,
self.target_agent_ids,
self.subject,
self.body_len
);
}
}
async fn watch_messages_forever(
server_api: Arc<ServerApi>,
ai_client: Arc<dyn AIClient>,
@@ -940,15 +1106,25 @@ async fn watch_messages_forever(
) -> anyhow::Result<()> {
let run_id = args.run_id;
let watched_run_ids = vec![run_id.clone()];
let scoped_task_id = task_id_from_run_id(&run_id);
let mut last_seen_sequence = args.since_sequence;
let mut initial_connect = true;
let mut failures = 0usize;
loop {
let mut stream = match server_api
.stream_agent_events(&watched_run_ids, last_seen_sequence)
.await
{
let stream_result = match scoped_task_id.as_ref() {
Some(task_id) => {
server_api
.stream_agent_events_for_task(task_id, &watched_run_ids, last_seen_sequence)
.await
}
None => {
server_api
.stream_agent_events(&watched_run_ids, last_seen_sequence)
.await
}
};
let mut stream = match stream_result {
Ok(stream) => {
if !initial_connect {
eprintln!(
@@ -1004,8 +1180,15 @@ async fn watch_messages_forever(
last_seen_sequence = event.sequence;
continue;
};
let message = match ai_client.read_agent_message(&message_id).await {
let message_result = match scoped_task_id.as_ref() {
Some(task_id) => {
server_api
.read_agent_message_for_task(task_id, &message_id)
.await
}
None => ai_client.read_agent_message(&message_id).await,
};
let message = match message_result {
Ok(message) => message,
Err(err) => {
failures += 1;
+53 -7
View File
@@ -1,17 +1,17 @@
//! Unit tests for `filter_from_args`. Verifies the clap enums are faithfully translated into
//! `TaskListFilter` without dropping any fields.
//! Unit tests for ambient agent CLI argument mapping and message helpers.
use chrono::{TimeZone, Utc};
use galaxy_cli::json_filter::JsonOutput;
use galaxy_cli::task::{
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, RunSortByArg, RunSortOrderArg,
RunSourceArg, RunStateArg,
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, RunSortByArg, RunSourceArg, RunStateArg,
};
use warp_cli::SortOrderArg;
use super::*;
use crate::server::server_api::ai::{ArtifactType, ExecutionLocation, RunSortBy, RunSortOrder};
const TASK_ID: &str = "00000000-0000-0000-0000-000000000001";
const OTHER_TASK_ID: &str = "00000000-0000-0000-0000-000000000002";
/// A `ListTasksArgs` whose fields are all at their defaults.
fn empty_args() -> ListTasksArgs {
ListTasksArgs {
@@ -138,7 +138,7 @@ fn every_field_maps_through() {
updated_after: Some(updated_after),
query: Some("oz run".to_string()),
sort_by: Some(RunSortByArg::CreatedAt),
sort_order: Some(RunSortOrderArg::Asc),
sort_order: Some(SortOrderArg::Asc),
cursor: Some("abcd==".to_string()),
json_output: JsonOutput::default(),
};
@@ -167,3 +167,49 @@ fn every_field_maps_through() {
assert_eq!(filter.sort_order, Some(RunSortOrder::Asc));
assert_eq!(filter.cursor.as_deref(), Some("abcd=="));
}
#[test]
fn task_id_from_run_id_accepts_task_uuid() {
let task_id = task_id_from_run_id(TASK_ID).expect("valid task id");
assert_eq!(task_id.to_string(), TASK_ID);
}
#[test]
fn task_id_from_run_id_ignores_non_task_ids() {
assert!(task_id_from_run_id("local-child-run").is_none());
}
#[test]
#[serial_test::serial]
fn task_id_for_message_send_prefers_sender_run_id() {
std::env::set_var(warp_cli::OZ_RUN_ID_ENV, OTHER_TASK_ID);
let task_id = task_id_for_message_send(TASK_ID)
.expect("valid task id")
.expect("task id");
std::env::remove_var(warp_cli::OZ_RUN_ID_ENV);
assert_eq!(task_id.to_string(), TASK_ID);
}
#[test]
#[serial_test::serial]
fn task_id_for_message_send_falls_back_to_oz_run_id() {
std::env::set_var(warp_cli::OZ_RUN_ID_ENV, TASK_ID);
let task_id = task_id_for_message_send("local-child-run")
.expect("valid env task id")
.expect("task id");
std::env::remove_var(warp_cli::OZ_RUN_ID_ENV);
assert_eq!(task_id.to_string(), TASK_ID);
}
#[test]
#[serial_test::serial]
fn task_id_from_oz_run_id_env_rejects_invalid_value() {
std::env::set_var(warp_cli::OZ_RUN_ID_ENV, "not-a-task-id");
let err = task_id_from_oz_run_id_env().expect_err("invalid task id");
std::env::remove_var(warp_cli::OZ_RUN_ID_ENV);
assert!(err.to_string().contains("Invalid OZ_RUN_ID"));
}
+485
View File
@@ -0,0 +1,485 @@
use std::cmp::Reverse;
use std::fmt;
use std::io::{self, IsTerminal as _};
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use comfy_table::Cell;
use inquire::{Confirm, InquireError, Select};
use serde::Serialize;
use warp_cli::agent::OutputFormat;
use warp_cli::api_key::{
ApiKeyCommand, ApiKeyExpirationArgs, ApiKeySortByArg, CreateApiKeyArgs, ExpireApiKeyArgs,
ListApiKeysArgs,
};
use warp_cli::{GlobalOptions, SortOrderArg};
use warp_graphql::mutations::expire_api_key::ExpireApiKeyResult;
use warp_graphql::mutations::generate_api_key::GenerateApiKeyResult;
use warp_graphql::queries::api_keys::ApiKeyProperties;
use warp_graphql::scalars::Time;
use warpui::platform::TerminationMode;
use warpui::{AppContext, ModelContext, SingletonEntity};
use super::output::{self, TableFormat};
use crate::server::ids::ApiKeyUid;
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::ServerApiProvider;
/// Run API key-related commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: ApiKeyCommand,
) -> Result<()> {
let runner = ctx.add_singleton_model(|_ctx| ApiKeyCommandRunner);
match command {
ApiKeyCommand::List(args) => {
runner.update(ctx, |runner, ctx| {
runner.list(global_options.output_format, args, ctx)
});
Ok(())
}
ApiKeyCommand::Create(args) => {
runner.update(ctx, |runner, ctx| {
runner.create(global_options.output_format, args, ctx)
});
Ok(())
}
ApiKeyCommand::Expire(args) => {
runner.update(ctx, |runner, ctx| {
runner.expire(global_options.output_format, args, ctx)
});
Ok(())
}
}
}
struct ApiKeyCommandRunner;
impl ApiKeyCommandRunner {
fn list(
&self,
output_format: OutputFormat,
args: ListApiKeysArgs,
ctx: &mut ModelContext<Self>,
) {
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
ctx.spawn(
async move {
let mut keys: Vec<_> = auth_client
.list_api_keys()
.await?
.into_iter()
.map(ApiKeyInfo::from)
.collect();
sort_api_keys(&mut keys, args.sort_by, args.sort_order);
if args.json_output.force_json_output() {
output::print_raw_json(serde_json::to_value(&keys)?, &args.json_output)?;
} else {
output::print_list(keys, output_format);
}
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn create(
&self,
output_format: OutputFormat,
args: CreateApiKeyArgs,
ctx: &mut ModelContext<Self>,
) {
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
ctx.spawn(
async move {
let json_output = args.json_output;
let expires_at = expires_at_from_args(args.expiration)?;
let agent_uid = args.agent_uid.map(cynic::Id::new);
let result = auth_client
.create_api_key(args.name, None, agent_uid, expires_at)
.await?;
let result = match result {
GenerateApiKeyResult::GenerateApiKeyOutput(output) => CreatedApiKeyInfo {
raw_api_key: output.raw_api_key,
api_key: ApiKeyInfo::from(output.api_key),
},
GenerateApiKeyResult::UserFacingError(e) => {
return Err(anyhow!(
warp_graphql::client::get_user_facing_error_message(e)
));
}
GenerateApiKeyResult::Unknown => {
return Err(anyhow!("failed to create API key"))
}
};
print_created_api_key(result, output_format, json_output)?;
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn expire(
&self,
output_format: OutputFormat,
args: ExpireApiKeyArgs,
ctx: &mut ModelContext<Self>,
) {
let key_identifier = args.key_uid;
let force = args.force;
let json_output = args.json_output;
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
ctx.spawn(
async move {
let keys = auth_client
.list_api_keys()
.await?
.into_iter()
.map(ApiKeyInfo::from)
.collect();
Ok(keys)
},
move |_, result: Result<Vec<ApiKeyInfo>>, ctx| {
let keys = match result {
Ok(keys) => keys,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let key = match resolve_api_key_identifier(&keys, &key_identifier) {
Ok(Some(key)) => key,
Ok(None) => {
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
if !force {
if !io::stdin().is_terminal() {
super::report_fatal_error(
anyhow!(
"Refusing to expire API key without confirmation in non-interactive mode (use --force to bypass)"
),
ctx,
);
return;
}
let prompt = format!("Expire API key '{key}'?");
let should_expire = match Confirm::new(&prompt)
.with_default(false)
.with_help_message("This action takes effect immediately")
.prompt()
{
Ok(should_expire) => should_expire,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(err.into(), ctx);
return;
}
};
if !should_expire {
println!("Expiration cancelled");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
}
let uid = ApiKeyUid::from(key.uid);
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
ctx.spawn(
async move {
let result = auth_client.expire_api_key(&uid).await?;
let expired = match result {
ExpireApiKeyResult::ExpireApiKeyOutput(output) => output.success,
ExpireApiKeyResult::UserFacingError(e) => {
return Err(anyhow!(
warp_graphql::client::get_user_facing_error_message(e)
));
}
ExpireApiKeyResult::Unknown => {
return Err(anyhow!("failed to expire API key"))
}
};
print_expire_api_key_result(
uid.to_string(),
expired,
output_format,
json_output,
)?;
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
},
);
}
}
impl warpui::Entity for ApiKeyCommandRunner {
type Event = ();
}
impl SingletonEntity for ApiKeyCommandRunner {}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
struct ApiKeyInfo {
uid: String,
name: String,
key_suffix: String,
scope: String,
created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
}
impl From<ApiKeyProperties> for ApiKeyInfo {
fn from(key: ApiKeyProperties) -> Self {
Self {
uid: key.uid.into_inner(),
name: key.name,
key_suffix: key.key_suffix,
scope: key.owner_type.to_string(),
created_at: key.created_at.utc(),
last_used_at: key.last_used_at.map(|t| t.utc()),
expires_at: key.expires_at.map(|t| t.utc()),
}
}
}
impl fmt::Display for ApiKeyInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = &self.name;
let uid = &self.uid;
let created_at = self.created_at.format("%Y-%m-%d %H:%M:%S UTC");
write!(f, "{name} ({uid}, created {created_at})")
}
}
impl TableFormat for ApiKeyInfo {
fn header() -> Vec<Cell> {
vec![
Cell::new("UID"),
Cell::new("Name"),
Cell::new("Key"),
Cell::new("Scope"),
Cell::new("Created"),
Cell::new("Last Used"),
Cell::new("Expires At"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.uid),
Cell::new(&self.name),
Cell::new(format!("wk-**{}", self.key_suffix)),
Cell::new(&self.scope),
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
Cell::new(
self.last_used_at
.map(format_approx_duration_from_now_utc)
.unwrap_or_else(|| "Never".to_string()),
),
Cell::new(
self.expires_at
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| "Never".to_string()),
),
]
}
}
fn resolve_api_key_identifier(
keys: &[ApiKeyInfo],
key_identifier: &str,
) -> Result<Option<ApiKeyInfo>> {
if let Some(key) = keys.iter().find(|key| key.uid == key_identifier) {
return Ok(Some(key.clone()));
}
let mut matches = keys
.iter()
.filter(|key| key.name == key_identifier)
.cloned()
.collect::<Vec<_>>();
matches.sort_by_key(|key| Reverse(key.created_at));
if matches.is_empty() {
return Err(anyhow!("API key '{key_identifier}' not found"));
} else if matches.len() == 1 {
return Ok(Some(matches[0].clone()));
}
if io::stdin().is_terminal() {
return match Select::new(
&format!("Multiple API keys match '{key_identifier}'. Select a key to expire:"),
matches,
)
.prompt()
{
Ok(key) => Ok(Some(key)),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
Err(err) => Err(err.into()),
};
}
println!("Multiple API keys match '{key_identifier}':");
for key in matches {
println!(" {key}");
}
Err(anyhow!(
"Multiple API keys match '{key_identifier}'; specify the key by UID"
))
}
#[derive(Debug, Clone, Serialize)]
struct CreatedApiKeyInfo {
raw_api_key: String,
api_key: ApiKeyInfo,
}
#[derive(Debug, Clone, Serialize)]
struct ExpiredApiKeyInfo {
key_uid: String,
expired: bool,
}
fn sort_api_keys(
keys: &mut [ApiKeyInfo],
sort_by: Option<ApiKeySortByArg>,
sort_order: Option<SortOrderArg>,
) {
let Some(sort_by) = sort_by else {
return;
};
let descending = matches!(sort_order, Some(SortOrderArg::Desc));
match sort_by {
ApiKeySortByArg::Name => {
if descending {
keys.sort_by_key(|k| Reverse(k.name.to_lowercase()));
} else {
keys.sort_by_key(|k| k.name.to_lowercase());
}
}
ApiKeySortByArg::CreatedAt => {
if descending {
keys.sort_by_key(|k| Reverse(k.created_at));
} else {
keys.sort_by_key(|k| k.created_at);
}
}
ApiKeySortByArg::LastUsedAt => {
if descending {
keys.sort_by_key(|k| Reverse(k.last_used_at));
} else {
keys.sort_by_key(|k| k.last_used_at);
}
}
ApiKeySortByArg::ExpiresAt => {
if descending {
keys.sort_by_key(|k| Reverse(k.expires_at));
} else {
keys.sort_by_key(|k| k.expires_at);
}
}
ApiKeySortByArg::Scope => {
if descending {
keys.sort_by_key(|k| Reverse(k.scope.clone()));
} else {
keys.sort_by_key(|k| k.scope.clone());
}
}
}
}
fn expires_at_from_args(args: ApiKeyExpirationArgs) -> Result<Option<Time>> {
if args.no_expiration {
return Ok(None);
}
if let Some(expires_at) = args.expires_at {
return Ok(Some(Time::from(expires_at)));
}
if let Some(expires_in) = args.expires_in {
let duration = chrono::Duration::from_std(expires_in.into())
.map_err(|_| anyhow!("expiration duration is too large"))?;
return Ok(Some(Time::from(Utc::now() + duration)));
}
Err(anyhow!("expiration behavior is required"))
}
fn print_created_api_key(
result: CreatedApiKeyInfo,
output_format: OutputFormat,
json_output: warp_cli::json_filter::JsonOutput,
) -> Result<()> {
if json_output.force_json_output() {
output::print_raw_json(serde_json::to_value(&result)?, &json_output)?;
return Ok(());
}
match output_format {
OutputFormat::Json => output::write_json(&result, std::io::stdout())?,
OutputFormat::Ndjson => output::write_json_line(&result, std::io::stdout())?,
OutputFormat::Pretty | OutputFormat::Text => {
println!("API key '{}' created.", result.api_key.name);
println!("UID: {}", result.api_key.uid);
println!("Raw API key: {}", result.raw_api_key);
println!("This secret key is shown only once. Store it securely.");
}
}
Ok(())
}
fn print_expire_api_key_result(
key_uid: String,
expired: bool,
output_format: OutputFormat,
json_output: warp_cli::json_filter::JsonOutput,
) -> Result<()> {
let result = ExpiredApiKeyInfo { key_uid, expired };
if json_output.force_json_output() {
output::print_raw_json(serde_json::to_value(&result)?, &json_output)?;
return Ok(());
}
match output_format {
OutputFormat::Json => output::write_json(&result, std::io::stdout())?,
OutputFormat::Ndjson => output::write_json_line(&result, std::io::stdout())?,
OutputFormat::Pretty | OutputFormat::Text => {
if expired {
println!("API key '{}' expired.", result.key_uid);
} else {
println!("API key '{}' was not expired.", result.key_uid);
}
}
}
Ok(())
}
fn finish_command(result: Result<()>, ctx: &mut ModelContext<ApiKeyCommandRunner>) {
match result {
Ok(()) => ctx.terminate_app(TerminationMode::ForceTerminate, None),
Err(err) => super::report_fatal_error(err, ctx),
}
}
#[cfg(test)]
#[path = "api_key_tests.rs"]
mod tests;
+115
View File
@@ -0,0 +1,115 @@
use super::*;
fn key(name: &str, scope: &str, created_at: DateTime<Utc>) -> ApiKeyInfo {
key_with_uid(name, name, scope, created_at)
}
fn key_with_uid(uid: &str, name: &str, scope: &str, created_at: DateTime<Utc>) -> ApiKeyInfo {
ApiKeyInfo {
uid: uid.to_string(),
name: name.to_string(),
key_suffix: "abcd".to_string(),
scope: scope.to_string(),
created_at,
last_used_at: None,
expires_at: None,
}
}
#[test]
fn sort_api_keys_sorts_by_name_ascending() {
let created_at = Utc::now();
let mut keys = vec![
key("beta", "Team", created_at),
key("alpha", "Personal", created_at),
];
sort_api_keys(
&mut keys,
Some(ApiKeySortByArg::Name),
Some(SortOrderArg::Asc),
);
assert_eq!(keys[0].name, "alpha");
assert_eq!(keys[1].name, "beta");
}
#[test]
fn sort_api_keys_sorts_by_created_at_descending() {
let older = Utc::now() - chrono::Duration::days(1);
let newer = Utc::now();
let mut keys = vec![key("older", "Team", older), key("newer", "Personal", newer)];
sort_api_keys(
&mut keys,
Some(ApiKeySortByArg::CreatedAt),
Some(SortOrderArg::Desc),
);
assert_eq!(keys[0].name, "newer");
assert_eq!(keys[1].name, "older");
}
#[test]
fn resolve_api_key_identifier_prefers_uid_match() {
let created_at = Utc::now();
let keys = vec![
key_with_uid("target", "other-name", "Team", created_at),
key_with_uid("other-uid", "target", "Team", created_at),
];
assert_eq!(
resolve_api_key_identifier(&keys, "target")
.unwrap()
.unwrap(),
keys[0].clone()
);
}
#[test]
fn resolve_api_key_identifier_falls_back_to_name_match() {
let created_at = Utc::now();
let keys = vec![key_with_uid("uid-1", "deploy-key", "Team", created_at)];
assert_eq!(
resolve_api_key_identifier(&keys, "deploy-key")
.unwrap()
.unwrap(),
keys[0].clone()
);
}
#[test]
fn resolve_api_key_identifier_errors_for_ambiguous_name_matches() {
let created_at = Utc::now();
let keys = vec![
key_with_uid("uid-1", "deploy-key", "Team", created_at),
key_with_uid("uid-2", "deploy-key", "Personal", created_at),
];
let err = resolve_api_key_identifier(&keys, "deploy-key").unwrap_err();
assert_eq!(
err.to_string(),
"Multiple API keys match 'deploy-key'; specify the key by UID"
);
}
#[test]
fn resolve_api_key_identifier_errors_when_not_found() {
let created_at = Utc::now();
let keys = vec![key_with_uid("uid-1", "deploy-key", "Team", created_at)];
let err = resolve_api_key_identifier(&keys, "missing-key").unwrap_err();
assert_eq!(err.to_string(), "API key 'missing-key' not found");
}
#[test]
fn api_key_display_includes_creation_date() {
let created_at = "2026-01-02T03:04:05Z".parse().unwrap();
let key = key_with_uid("uid-1", "deploy-key", "Team", created_at);
assert_eq!(
key.to_string(),
"deploy-key (uid-1, created 2026-01-02 03:04:05 UTC)"
);
}
+7 -7
View File
@@ -1,4 +1,5 @@
use std::{path::PathBuf, sync::Arc};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use galaxy_cli::agent::OutputFormat;
@@ -6,19 +7,18 @@ use galaxy_cli::artifact::{
ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs,
};
use galaxy_cli::GlobalOptions;
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
use serde::Serialize;
use galaxyui::platform::TerminationMode;
use galaxyui::{AppContext, ModelContext, SingletonEntity};
use super::artifact_upload::{
CompletedFileArtifactUpload, FileArtifactUploadRequest, FileArtifactUploader,
};
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,
+1
View File
@@ -1,6 +1,7 @@
use galaxy_cli::agent::OutputFormat;
use std::path::PathBuf;
use super::*;
fn sample_completed_upload() -> CompletedFileArtifactUpload {
+3 -10
View File
@@ -7,7 +7,6 @@ use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use blocking::unblock;
use galaxy_cli::artifact::UploadArtifactArgs;
use mime_guess::from_path;
use super::common::parse_ambient_task_id;
use crate::ai::agent::api::ServerConversationToken;
@@ -17,10 +16,11 @@ use crate::server::server_api::ai::{
AIClient, CreateFileArtifactUploadRequest, CreateFileArtifactUploadResponse,
FileArtifactRecord, FileArtifactUploadTargetInfo,
};
use crate::server::server_api::harness_support::FileUploadBody;
use crate::server::server_api::presigned_upload::upload_file_to_target;
use crate::server::server_api::ServerApi;
use crate::util::image::{infer_mime_type, MIME_SNIFF_BYTES};
const MIME_SNIFF_BYTES: usize = 8 * 1024;
const OZ_RUN_ID_ENV_VAR: &str = "OZ_RUN_ID";
#[derive(Debug, Clone, Eq, PartialEq)]
@@ -165,8 +165,7 @@ impl FileArtifactUploader {
upload_file_to_target(
self.server_api.http_client(),
target,
&artifact.path,
artifact.file_size,
FileUploadBody::new(artifact.path.clone()),
)
.await
}
@@ -234,12 +233,6 @@ 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()))?;
@@ -1,6 +1,5 @@
use std::env;
use std::fs;
use std::path::PathBuf;
use std::{env, fs};
use chrono::Utc;
use galaxy_cli::artifact::UploadArtifactArgs;
@@ -39,15 +38,14 @@ fn create_conversation_metadata(
was_summarized: false,
context_window_usage: 0.0,
credits_spent: 0.0,
platform_credits_spent: 0.0,
credits_spent_for_last_block: None,
token_usage: vec![],
tool_usage_metadata: Default::default(),
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_cache_miss_tokens: 0,
total_cost_cents: 0.0,
context_window_segments: Vec::new(),
},
metadata: create_mock_server_metadata(),
creator: None,
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()),
+4 -21
View File
@@ -18,7 +18,7 @@ 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::cloud_object::{CloudObject, CloudObjectLookup as _, Owner};
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::{ServerId, SyncId};
use crate::server::server_api::ai::AIClient;
@@ -37,7 +37,7 @@ pub fn validate_agent_mode_base_model_id(
let llm_id: LLMId = model_id.into();
let valid_ids = llm_prefs
.get_base_llm_choices_for_agent_mode()
.get_base_llm_choices_for_agent_mode(ctx)
.map(|info| info.id.clone())
.collect::<Vec<_>>();
@@ -329,22 +329,5 @@ impl fmt::Display for EnvironmentChoice {
}
#[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'"));
}
}
#[path = "common_tests.rs"]
mod tests;
+16
View File
@@ -0,0 +1,16 @@
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'"));
}
+5 -1
View File
@@ -20,6 +20,8 @@ pub struct AgentConfigSnapshotFile {
#[serde(default)]
pub environment_id: Option<String>,
#[serde(default)]
pub runner_id: Option<String>,
#[serde(default)]
pub model_id: Option<String>,
#[serde(default)]
pub base_prompt: Option<String>,
@@ -93,7 +95,7 @@ fn parse_yaml(input: &str) -> anyhow::Result<AgentConfigSnapshotFile> {
}
fn supported_keys_context() -> String {
"Supported keys: name, environment_id, model_id, base_prompt, mcp_servers, host, computer_use_enabled".to_string()
"Supported keys: name, environment_id, runner_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.
@@ -148,6 +150,7 @@ pub fn merge_with_precedence(
let name = cli.name.or_else(|| file.name.clone());
let environment_id = cli.environment_id.or_else(|| file.environment_id.clone());
let runner_id = cli.runner_id.or_else(|| file.runner_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());
@@ -158,6 +161,7 @@ pub fn merge_with_precedence(
AgentConfigSnapshot {
name,
environment_id,
runner_id,
model_id,
base_prompt,
mcp_servers,
+2 -1
View File
@@ -3,9 +3,9 @@
use std::io::Write as _;
use serde_json::json;
use warp_cli::mcp::MCPSpec;
use crate::ai::ambient_agents::AgentConfigSnapshot;
use galaxy_cli::mcp::MCPSpec;
fn write_temp(suffix: &str, contents: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::Builder::new().suffix(suffix).tempfile().unwrap();
@@ -96,6 +96,7 @@ fn merge_precedence_cli_over_file_and_merges_mcp() {
let cli = AgentConfigSnapshot {
name: Some("cli-name".to_string()),
environment_id: None,
runner_id: None,
model_id: Some("cli-model".to_string()),
base_prompt: None,
mcp_servers: Some(serde_json::Map::from_iter([(
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -2,7 +2,8 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Context;
use base64::{engine::general_purpose, Engine};
use base64::engine::general_purpose;
use base64::Engine;
use futures::future::join_all;
use futures::TryStreamExt as _;
use galaxy_core::features::FeatureFlag;
@@ -30,6 +31,7 @@ pub const MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY: usize = 25;
///
/// Makes a best-effort attempt to download all attachments.
/// Individual download failures are logged but don't cause the entire function to fail.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
pub(crate) async fn fetch_and_download_attachments(
ai_client: Arc<dyn AIClient>,
http_client: Arc<ServerApi>,
@@ -65,6 +67,7 @@ pub(crate) async fn fetch_and_download_attachments(
/// 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`.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
pub(crate) async fn fetch_and_download_handoff_snapshot_attachments(
ai_client: Arc<dyn AIClient>,
http_client: &http_client::Client,
@@ -219,13 +222,20 @@ async fn download_handoff_entry(
/// 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.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
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 {
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
async fn attempt(
http_client: &http_client::Client,
download_url: &str,
file_path: &Path,
) -> anyhow::Result<()> {
let response = http_client
.get(download_url)
.send()
@@ -254,6 +264,10 @@ async fn download_attachment(
.context("Failed to write file")?;
Ok(())
}
with_bounded_retry(&operation, || async {
attempt(http_client, download_url, file_path).await
})
.await
}
@@ -0,0 +1,170 @@
/// Proactive Bedrock OIDC credential refresh for cloud agent sandboxes.
///
/// The `OidcManaged` Bedrock credential path mints an OIDC token at agent startup,
/// exchanges it for 1-hour STS temporary credentials via `AssumeRoleWithWebIdentity`,
/// and stores them in `ApiKeyManager`. Without a proactive refresh, those credentials
/// expire after ~1 hour and all subsequent Bedrock LLM calls fail.
///
/// This module provides a background `refresh_loop` — modelled on `git_credentials`'s
/// loop — that proactively re-mints the OIDC token and re-calls STS every 50 minutes,
/// well ahead of the 1-hour STS expiry. The loop is raced against the run execution
/// future via `futures::select!` and dropped automatically when the run completes.
use std::time::{Duration, SystemTime};
use ai::api_keys::{ApiKeyManager, AwsCredentials, AwsCredentialsState};
use anyhow::{Context as _, Result};
use vec1::vec1;
use warp_managed_secrets::client::IdentityTokenOptions;
use warp_managed_secrets::ManagedSecretManager;
use warpui::{ModelSpawner, SingletonEntity};
use super::AgentDriver;
use crate::ai::aws_credentials::{
aws_role_session_name, sts_client, AWS_BEDROCK_STS_AUDIENCE, BEDROCK_IDENTITY_TOKEN_DURATION,
};
/// How long to wait between Bedrock credential refresh attempts — well ahead of the
/// 1-hour STS temporary credential expiry, matching the approach used for git credentials.
pub(crate) const BEDROCK_CREDENTIALS_REFRESH_INTERVAL: Duration = Duration::from_secs(50 * 60);
/// Perform one Bedrock OIDC credential refresh attempt.
///
/// Returns `Ok(())` on success. Returns `Err` when token minting or the STS call
/// fails — these are transient failures worth retrying.
#[tracing::instrument(
name = "bedrock_credentials::try_refresh",
skip_all,
err,
fields(tags.cloud_agent = true, task_id)
)]
async fn try_refresh(
task_id: &str,
role_arn: &str,
region: &str,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
// Step 1: Mint a new OIDC identity token via the model context.
let token_future = foreground
.spawn(|_, ctx| {
ManagedSecretManager::handle(ctx)
.as_ref(ctx)
.issue_task_identity_token(IdentityTokenOptions {
audience: AWS_BEDROCK_STS_AUDIENCE.to_string(),
requested_duration: BEDROCK_IDENTITY_TOKEN_DURATION,
subject_template: vec1!["scoped_principal".to_string()],
})
})
.await
.context("Failed to dispatch OIDC token request for Bedrock refresh")?;
let token = token_future
.await
.context("Failed to mint OIDC identity token for Bedrock refresh")?;
// Step 2: Exchange the OIDC token for fresh STS temporary credentials.
let client = sts_client(region).await;
let session_name = aws_role_session_name(task_id);
let sts_creds = client
.assume_role_with_web_identity()
.role_arn(role_arn)
.role_session_name(&session_name)
.web_identity_token(&token.token)
.send()
.await
.map_err(|err| {
log::error!("Bedrock OIDC refresh: STS AssumeRoleWithWebIdentity error: {err:#?}");
let detail = err
.as_service_error()
.map(|e| e.to_string())
.unwrap_or_else(|| err.to_string());
anyhow::anyhow!("STS AssumeRoleWithWebIdentity failed: {detail}")
})?
.credentials
.context("STS response did not include credentials")?;
let aws_creds = AwsCredentials::new(
sts_creds.access_key_id().to_string(),
sts_creds.secret_access_key().to_string(),
Some(sts_creds.session_token().to_string()),
SystemTime::try_from(*sts_creds.expiration()).ok(),
);
// Step 3: Update ApiKeyManager with the fresh credentials.
foreground
.spawn(move |_, ctx| {
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
manager.set_aws_credentials_state(
AwsCredentialsState::Loaded {
credentials: aws_creds,
loaded_at: SystemTime::now(),
},
ctx,
);
});
})
.await
.context("Failed to dispatch Bedrock credential update to ApiKeyManager")?;
log::info!("Bedrock OIDC: proactive credential refresh succeeded for task {task_id}");
Ok(())
}
/// Infinite async loop that proactively refreshes AWS Bedrock OIDC credentials every
/// [`BEDROCK_CREDENTIALS_REFRESH_INTERVAL`], keeping long-running agents authenticated
/// for their entire duration.
///
/// On each iteration:
/// 1. Issue a new OIDC identity token via warp-server.
/// 2. Call STS `AssumeRoleWithWebIdentity` to get fresh temporary credentials.
/// 3. Update `ApiKeyManager` with the new credentials.
///
/// On transient failure, retries up to three times with exponential backoff
/// (1 min, 2 min, 4 min), keeping all retries well within the buffer before
/// the 1-hour STS credentials expire. If all retries fail, a warning is logged
/// and the next refresh is scheduled after the normal interval.
///
/// This future never resolves — it is designed to be raced with the run execution
/// future via `futures::select!` and dropped automatically when the run completes.
pub(crate) async fn refresh_loop(
task_id: String,
role_arn: String,
region: String,
foreground: &ModelSpawner<AgentDriver>,
) {
loop {
warpui::r#async::Timer::after(BEDROCK_CREDENTIALS_REFRESH_INTERVAL).await;
log::info!("Proactively refreshing AWS Bedrock OIDC credentials for task {task_id}");
let backoff_delays = [
Duration::from_secs(60),
Duration::from_secs(2 * 60),
Duration::from_secs(4 * 60),
];
let mut attempt = 0usize;
loop {
match try_refresh(&task_id, &role_arn, &region, foreground).await {
Ok(()) => break,
Err(e) if attempt < backoff_delays.len() => {
let delay = backoff_delays[attempt];
log::warn!(
"Bedrock credentials refresh failed (attempt {}): {e:#}; \
retrying in {}s",
attempt + 1,
delay.as_secs()
);
warpui::r#async::Timer::after(delay).await;
attempt += 1;
}
Err(e) => {
log::warn!(
"Bedrock credentials refresh failed after {} attempts: {e:#}; \
credentials may expire before next refresh cycle",
attempt + 1
);
break;
}
}
}
}
}
@@ -1,4 +1,7 @@
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin};
use std::collections::HashMap;
use std::ffi::OsString;
use std::future::Future;
use std::pin::Pin;
use anyhow::Error;
use galaxyui::ModelSpawner;
@@ -1,4 +1,8 @@
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin, time::Duration};
use std::collections::HashMap;
use std::ffi::OsString;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use anyhow::Context;
use galaxy_core::safe_info;
@@ -7,11 +11,10 @@ use galaxyui::{ModelSpawner, SingletonEntity};
use tempfile::{Builder, NamedTempFile};
use vec1::Vec1;
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};
use crate::ai::aws_credentials::aws_role_session_name;
use crate::ai::cloud_environments::AwsProviderConfig;
/// 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
@@ -1,11 +1,14 @@
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin, time::Duration};
use std::collections::HashMap;
use std::ffi::OsString;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use anyhow::Context as _;
use galaxy_managed_secrets::{GcpCredentials, GcpFederationConfig};
use crate::ai::cloud_environments::GcpProviderConfig;
use super::{CloudProvider, CloudProviderSetupError, Result};
use crate::ai::cloud_environments::GcpProviderConfig;
/// Token lifetime for GCP executable-sourced credentials. The GCP client
/// libraries handle refreshing automatically, so we keep this short.
@@ -1,11 +1,12 @@
use std::{collections::HashMap, ffi::OsString, path::PathBuf};
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use super::aws::AwsCloudProvider;
use super::gcp::GcpCloudProvider;
use super::{collect_env_vars, load_providers, CloudProvider};
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 {
+376 -188
View File
@@ -1,25 +1,28 @@
use std::{
collections::HashMap,
future::Future,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use std::collections::HashMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::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 galaxy_completer::completer::CommandExitStatus;
use galaxy_core::{command::ExitCode, safe_info, safe_warn};
use galaxyui::{r#async::FutureExt, ModelContext, ModelSpawner, SingletonEntity};
use futures::channel::oneshot;
use futures::future::join_all;
use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource};
use super::{terminal::TerminalDriver, AgentDriverError};
use galaxy_cli::agent::Harness;
use galaxy_completer::completer::CommandExitStatus;
use galaxy_core::command::ExitCode;
use galaxy_core::{safe_info, safe_warn};
use galaxyui::r#async::FutureExt;
use galaxyui::{ModelContext, ModelSpawner, SingletonEntity};
use super::terminal::TerminalDriver;
use super::AgentDriverError;
use crate::ai::agent_sdk::setup_observability::{SetupClientEventReporter, SetupStep};
use crate::ai::cloud_environments::{AmbientAgentEnvironment, SourceRepo};
use crate::terminal::model::session::command_executor::shell_escape_single_quotes;
use crate::terminal::shell::ShellType;
const CODEBASE_INDEX_SYNC_TIMEOUT: Duration = Duration::from_secs(60);
@@ -54,20 +57,18 @@ pub fn prepare_environment(
working_dir: PathBuf,
is_sandbox: bool,
harness: Harness,
setup_events: SetupClientEventReporter,
ctx: &mut ModelContext<TerminalDriver>,
) -> impl Future<Output = Result<(), PrepareEnvironmentError>> {
let spawner = ctx.spawner();
async move {
let AmbientAgentEnvironment {
github_repos,
setup_commands,
..
} = environment;
let source_repos = environment.effective_repos();
let setup_commands = environment.setup_commands;
// 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 should_subscribe_to_index_updates = should_index_codebase && !source_repos.is_empty();
let repo_channels = Arc::new(Mutex::new(HashMap::<PathBuf, oneshot::Sender<()>>::new()));
if should_subscribe_to_index_updates {
@@ -78,14 +79,15 @@ pub fn prepare_environment(
&spawner,
working_dir.as_path(),
is_sandbox,
&github_repos,
&source_repos,
setup_commands,
should_index_codebase,
Arc::clone(&repo_channels),
setup_events,
)
.await;
if should_subscribe_to_index_updates {
if should_subscribe_to_index_updates && result.is_err() {
let _ = spawner
.spawn(|_, ctx| {
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
@@ -97,14 +99,16 @@ pub fn prepare_environment(
}
}
#[allow(clippy::too_many_arguments)]
async fn prepare_environment_impl(
spawner: &ModelSpawner<TerminalDriver>,
working_dir: &Path,
is_sandbox: bool,
github_repos: &[GithubRepo],
source_repos: &[SourceRepo],
setup_commands: Vec<String>,
should_index_codebase: bool,
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
setup_events: SetupClientEventReporter,
) -> Result<(), PrepareEnvironmentError> {
let working_dir_string = working_dir.to_string_lossy().to_string();
@@ -120,175 +124,95 @@ async fn prepare_environment_impl(
}
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,
if !source_repos.is_empty() {
setup_events
.record_result(SetupStep::EnvironmentRepoClone, async {
clone_repos(source_repos, working_dir, spawner).await?;
for repo in source_repos {
register_cloned_repo(repo, working_dir, is_sandbox, spawner).await?;
if !is_sandbox && should_index_codebase {
let receiver = index_repo_codebase(
&repo.repo,
working_dir,
Arc::clone(&repo_channels),
spawner,
)
})
})
.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);
.await?;
if let Some(receiver) = receiver {
codebase_context_receivers.push(receiver);
}
}
}
}
Ok::<(), PrepareEnvironmentError>(())
})
.await?;
if should_index_codebase {
record_codebase_indexing(
setup_events.clone(),
spawner.clone(),
codebase_context_receivers,
);
}
}
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?;
setup_events
.record_result(SetupStep::EnvironmentSetupCommands, async {
// 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}")
);
}
// Unset CI after setup commands complete so the agent session
// does not run with CI=true.
execute_command("unset CI".to_string(), spawner).await?;
Ok::<(), PrepareEnvironmentError>(())
})
.await?;
} else if should_index_codebase && source_repos.is_empty() {
let _ = spawner
.spawn(|_, ctx| {
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.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!(
galaxy_isolation_platform::detect(),
Some(
galaxy_isolation_platform::IsolationPlatformType::DockerSandbox
| galaxy_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 should_index_codebase && source_repos.is_empty() {
log::info!("No repositories to index for codebase context");
}
// 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) {
if let Some(repo_name) = single_repo_name(source_repos) {
safe_info!(
safe: ("Changing directory into single repository"),
full: ("Changing directory into single repository: {repo_name}")
@@ -302,6 +226,269 @@ async fn prepare_environment_impl(
Ok(())
}
fn record_codebase_indexing(
setup_events: SetupClientEventReporter,
spawner: ModelSpawner<TerminalDriver>,
codebase_context_receivers: Vec<oneshot::Receiver<()>>,
) {
if codebase_context_receivers.is_empty() {
setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move {
let _ = spawner
.spawn(|_, ctx| {
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.await;
});
return;
}
setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move {
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",
);
}
let _ = spawner
.spawn(|_, ctx| {
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
})
.await;
});
}
fn build_parallel_clone_command(repos: &[SourceRepo], shell_type: ShellType) -> String {
let mut script = String::from(
r#"set +e
failed=0
pids=""
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/warp-clone-logs.XXXXXX")"
cleanup_clone_logs() {
rm -rf "$tmp_dir"
}
trap cleanup_clone_logs EXIT
clone_repo() {
repo_name="$1"
repo_url="$2"
target="$3"
if [ -d "$target" ]; then
printf '%s\n' "Repository directory $target already exists, skipping clone..."
return 0
fi
printf '%s\n' "Cloning repository $repo_name..."
git clone --filter=tree:0 "$repo_url" "$target"
}
"#,
);
let mut log_outputs = String::new();
for (index, repo) in repos.iter().enumerate() {
let repo_name = format!("{}/{}", repo.owner, repo.repo);
let repo_url = repo.https_clone_url();
let escaped_repo_name = shell_escape_single_quotes(&repo_name, ShellType::Bash);
let escaped_repo_url = shell_escape_single_quotes(&repo_url, ShellType::Bash);
let escaped_target = shell_escape_single_quotes(&repo.repo, ShellType::Bash);
let log_var = format!("log_file_{index}");
script.push_str(&format!(
"{log_var}=\"$tmp_dir/repo-{index}.log\"\n\
clone_repo '{escaped_repo_name}' '{escaped_repo_url}' '{escaped_target}' >\"${log_var}\" 2>&1 &\n"
));
script.push_str("pids=\"$pids $!\"\n");
log_outputs.push_str(&format!(
"printf '%s\\n' '===== {escaped_repo_name} ====='\n\
if [ -s \"${log_var}\" ]; then\n\
\tcat \"${log_var}\"\n\
else\n\
\tprintf '%s\\n' '(no output)'\n\
fi\n"
));
}
script.push_str(
r#"for pid in $pids; do
if ! wait "$pid"; then
failed=1
fi
done
"#,
);
script.push_str(&log_outputs);
script.push_str(
r#"
exit "$failed"
"#,
);
let escaped_script = shell_escape_single_quotes(&script, shell_type);
format!("sh -c '{escaped_script}'")
}
/// Clone all source repositories to `{working_dir}/{repo.repo}` if they do not already exist.
/// Multiple repositories are cloned in parallel to reduce environment setup time.
pub(super) async fn clone_repos(
repos: &[SourceRepo],
working_dir: &Path,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<(), PrepareEnvironmentError> {
match repos {
[] => Ok(()),
[repo] => clone_repo(repo, working_dir, spawner).await,
repos => {
let shell_type = spawner
.spawn(|driver, ctx| {
driver
.active_session_shell_type(ctx)
.unwrap_or(ShellType::Bash)
})
.await
.unwrap_or(ShellType::Bash);
let repo_names = repos
.iter()
.map(|repo| format!("{}/{}", repo.owner, repo.repo))
.collect::<Vec<_>>();
safe_info!(
safe: ("Cloning repositories via terminal"),
full: ("Cloning repositories via terminal: {}", repo_names.join(", "))
);
let command = build_parallel_clone_command(repos, shell_type);
let exit_code = execute_command(command, spawner).await?;
if exit_code != 0.into() {
return Err(PrepareEnvironmentError::CloneRepo {
repo_name: repo_names.join(", "),
});
}
safe_info!(
safe: ("Successfully cloned repositories"),
full: ("Successfully cloned repositories: {}", repo_names.join(", "))
);
Ok(())
}
}
}
/// Clone a source repository to `{working_dir}/{repo.repo}` if it does not already exist.
/// This only performs the clone -- it does NOT register the repo with `DetectedRepositories`.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo))]
pub(super) async fn clone_repo(
repo: &SourceRepo,
working_dir: &Path,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<(), PrepareEnvironmentError> {
let repo_name = format!("{}/{}", repo.owner, repo.repo);
let repo_url = repo.https_clone_url();
// Get the session's shell type for proper escaping, falling back to Bash
// when the session is not yet bootstrapped or the spawn fails.
let shell_type = spawner
.spawn(|driver, ctx| {
driver
.active_session_shell_type(ctx)
.unwrap_or(ShellType::Bash)
})
.await
.unwrap_or(ShellType::Bash);
let escaped_url = shell_escape_single_quotes(&repo_url, shell_type);
// We do a partial clone here to speed up environment setup time.
let command = format!("git clone --filter=tree:0 '{escaped_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}")
);
}
Ok(())
}
/// Register a cloned source repository with `DetectedRepositories` so that the
/// skill watcher and other repo-aware subsystems can discover it.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo, is_sandbox = is_sandbox))]
pub(super) async fn register_cloned_repo(
repo: &SourceRepo,
working_dir: &Path,
is_sandbox: bool,
spawner: &ModelSpawner<TerminalDriver>,
) -> Result<(), PrepareEnvironmentError> {
let repo_dir = working_dir.join(&repo.repo);
// 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_local_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_local_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())
);
}
}
Ok(())
}
async fn subscribe_to_codebase_index_events(
spawner: &ModelSpawner<TerminalDriver>,
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
@@ -309,10 +496,11 @@ async fn subscribe_to_codebase_index_events(
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) {
ctx.subscribe_to_model(&CodebaseIndexManager::handle(ctx), move |_, _, event, ctx| {
if !matches!(
event,
CodebaseIndexManagerEvent::SyncStateUpdated { .. }
) {
return;
}
@@ -348,13 +536,13 @@ async fn subscribe_to_codebase_index_events(
let _ = tx.send(());
}
}
},
);
});
})
.await
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
}
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo_name))]
async fn index_repo_codebase(
repo_name: &str,
working_dir: &Path,
@@ -454,7 +642,7 @@ async fn cd_in_terminal(
})
}
fn single_repo_name(repos: &[GithubRepo]) -> Option<String> {
fn single_repo_name(repos: &[SourceRepo]) -> Option<String> {
if repos.len() != 1 {
return None;
}
@@ -1,9 +1,13 @@
use super::single_repo_name;
use crate::ai::cloud_environments::GithubRepo;
use cloud_object_models::CodeForge;
use super::{build_parallel_clone_command, single_repo_name};
use crate::ai::cloud_environments::SourceRepo;
use crate::terminal::shell::ShellType;
#[test]
fn single_repo_name_returns_repo_when_exactly_one_repo() {
let repos = vec![GithubRepo::new(
let repos = vec![SourceRepo::new(
CodeForge::GitHub,
"warpdotdev".to_string(),
"warp-internal".to_string(),
)];
@@ -13,12 +17,60 @@ fn single_repo_name_returns_repo_when_exactly_one_repo() {
#[test]
fn single_repo_name_returns_none_for_zero_or_many_repos() {
let no_repos = Vec::<GithubRepo>::new();
let no_repos = Vec::<SourceRepo>::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()),
SourceRepo::new(
CodeForge::GitHub,
"warpdotdev".to_string(),
"warp-internal".to_string(),
),
SourceRepo::new(
CodeForge::GitHub,
"warpdotdev".to_string(),
"warp-server".to_string(),
),
];
assert_eq!(single_repo_name(&two_repos), None);
}
#[test]
fn parallel_clone_command_runs_repos_in_background_and_waits() {
let repos = vec![
SourceRepo::new(
CodeForge::GitHub,
"warpdotdev".to_string(),
"warp".to_string(),
),
SourceRepo::new(
CodeForge::GitLab,
"platform/backend".to_string(),
"api".to_string(),
),
];
let command = build_parallel_clone_command(&repos, ShellType::Bash);
assert!(command.starts_with("sh -c '"));
assert!(command.contains("warpdotdev/warp"));
assert!(command.contains("https://github.com/warpdotdev/warp.git"));
assert!(command.contains("platform/backend/api"));
assert!(command.contains("https://gitlab.com/platform/backend/api.git"));
assert_eq!(command.matches("clone_repo").count(), 3);
assert_eq!(command.matches("2>&1 &").count(), 2);
assert!(command.contains("mktemp -d"));
assert!(command.contains("warp-clone-logs"));
assert!(command.contains("trap cleanup_clone_logs EXIT"));
assert!(command.contains("repo-0.log"));
assert!(command.contains("repo-1.log"));
assert!(command.contains(">\"$log_file_0\" 2>&1 &"));
assert!(command.contains(">\"$log_file_1\" 2>&1 &"));
assert!(command.contains("pids=\"$pids $!\""));
assert!(command.contains("wait \"$pid\""));
assert!(command.contains("===== warpdotdev/warp ====="));
assert!(command.contains("cat \"$log_file_0\""));
assert!(command.contains("===== platform/backend/api ====="));
assert!(command.contains("cat \"$log_file_1\""));
assert!(command.contains("exit \"$failed\""));
}
@@ -1,9 +1,9 @@
use crate::ai::blocklist::task_status_sync_model::classify_renderable_error;
use crate::server::server_api::ai::TaskStatusUpdate;
use galaxy_graphql::ai::{AgentTaskState, PlatformErrorCode};
use super::terminal::ShareSessionError;
use super::AgentDriverError;
use crate::ai::blocklist::local_agent_task_sync_model::classify_renderable_error;
use crate::server::server_api::ai::TaskStatusUpdate;
/// Classify an `AgentDriverError` into a task state and a `TaskStatusUpdate`
/// suitable for reporting via `update_agent_task`.
@@ -17,10 +17,10 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::InternalError,
),
),
AgentDriverError::BootstrapFailed => (
AgentDriverError::BootstrapFailed { error } => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
"Terminal session failed to start. Please try running your task again.",
format!("Terminal session failed to start: {error}"),
PlatformErrorCode::InternalError,
),
),
@@ -100,13 +100,29 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::MCPStartupFailed => (
AgentDriverError::ManagedMcpResolutionFailed { uid, message } => (
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.",
format!("Managed MCP server {uid} could not be resolved: {message}"),
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::MCPStartupFailed { details } => {
let server_lines = details
.iter()
.map(|detail| format!("- {detail}"))
.collect::<Vec<_>>()
.join("\n");
(
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"One or more MCP servers failed to start:\n\n{server_lines}\n\nCheck that each server's configuration is valid and that it is reachable from the agent's environment."
),
PlatformErrorCode::EnvironmentSetupFailed,
),
)
}
AgentDriverError::MCPJsonParseError(msg) => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
@@ -171,7 +187,7 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
// --- 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,
// fallback — LocalAgentTaskSyncModel 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);
@@ -248,7 +264,11 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::InternalError,
),
),
AgentDriverError::ConversationHarnessMismatch { conversation_id, expected, got } => (
AgentDriverError::ConversationHarnessMismatch {
conversation_id,
expected,
got,
} => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
@@ -258,7 +278,11 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::TaskHarnessMismatch { task_id, expected, got } => (
AgentDriverError::TaskHarnessMismatch {
task_id,
expected,
got,
} => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
@@ -268,7 +292,10 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::ConversationResumeStateMissing { harness, conversation_id } => (
AgentDriverError::ConversationResumeStateMissing {
harness,
conversation_id,
} => (
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
@@ -299,6 +326,41 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
PlatformErrorCode::EnvironmentSetupFailed,
),
),
AgentDriverError::HarnessAuthCheckFailed { harness, detail } => {
let message = format!(
"Harness '{harness}' authentication check failed: login credentials \
are invalid or expired. Verify that the authentication secret \
configured for this harness is correct."
);
log::error!("Preflight detail for {harness}: {detail}");
(
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
message,
PlatformErrorCode::AuthenticationRequired,
),
)
}
AgentDriverError::HarnessRuntimeFailureDetected {
harness,
pattern,
excerpt,
} => {
let message = format!(
"Harness '{harness}' could not make a successful API request. \
Matched failure pattern '{pattern}' in harness output: \"{excerpt}\". \
This usually means the API key is invalid, out of credits, or the \
account is misconfigured."
);
log::error!("Runtime failure for {harness}: pattern={pattern}, excerpt={excerpt}");
(
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
message,
PlatformErrorCode::AuthenticationRequired,
),
)
}
}
}
@@ -1,7 +1,7 @@
use galaxy_graphql::ai::{AgentTaskState, PlatformErrorCode};
use super::classify_driver_error;
use crate::ai::agent_sdk::driver::terminal::ShareSessionError;
use crate::ai::agent_sdk::driver::terminal::{BootstrapError, ShareSessionError};
use crate::ai::agent_sdk::driver::AgentDriverError;
fn assert_state_and_code(
@@ -20,14 +20,58 @@ fn assert_state_and_code(
// --- Infrastructure errors → ERROR ---
#[test]
fn bootstrap_failed_is_error_with_internal() {
assert_state_and_code(
AgentDriverError::BootstrapFailed,
AgentTaskState::Error,
Some(PlatformErrorCode::InternalError),
fn bootstrap_pty_spawn_failed_with_reason_includes_reason_in_message() {
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
error: BootstrapError::PtySpawnFailed {
reason: Some("Argument list too long (os error 7)".to_string()),
},
});
assert_eq!(state, AgentTaskState::Error);
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
assert!(
update.message.contains("Argument list too long"),
"message should include the specific failure reason: {:?}",
update.message
);
}
#[test]
fn bootstrap_pty_spawn_failed_without_reason_is_generic() {
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
error: BootstrapError::PtySpawnFailed { reason: None },
});
assert_eq!(state, AgentTaskState::Error);
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
assert!(
update.message.contains("Shell spawn failed"),
"message should describe the spawn failure: {:?}",
update.message
);
}
#[test]
fn bootstrap_timed_out_is_error_with_internal() {
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
error: BootstrapError::TimedOut,
});
assert_eq!(state, AgentTaskState::Error);
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
assert!(
update.message.contains("did not start within"),
"message should describe the timeout: {:?}",
update.message
);
}
#[test]
fn bootstrap_internal_error_is_error_with_internal() {
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
error: BootstrapError::InternalError,
});
assert_eq!(state, AgentTaskState::Error);
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
}
#[test]
fn terminal_unavailable_is_error_with_internal() {
assert_state_and_code(
@@ -72,6 +116,40 @@ fn mcp_server_not_found_is_failed_with_env_setup() {
);
}
#[test]
fn managed_mcp_resolution_failed_is_failed_with_env_setup() {
assert_state_and_code(
AgentDriverError::ManagedMcpResolutionFailed {
uid: uuid::Uuid::nil(),
message: "not active".into(),
},
AgentTaskState::Failed,
Some(PlatformErrorCode::EnvironmentSetupFailed),
);
}
#[test]
fn mcp_startup_failed_is_failed_with_env_setup_and_per_server_details() {
let (state, update) = classify_driver_error(&AgentDriverError::MCPStartupFailed {
details: vec![
"'devin' failed to start: connection refused".to_string(),
"'datadog' did not start within 20s".to_string(),
],
});
assert_eq!(state, AgentTaskState::Failed);
assert_eq!(
update.error_code,
Some(PlatformErrorCode::EnvironmentSetupFailed)
);
// Each unavailable server is rendered as its own bullet line.
assert!(update
.message
.contains("- 'devin' failed to start: connection refused"));
assert!(update
.message
.contains("- 'datadog' did not start within 20s"));
}
#[test]
fn environment_setup_failed_is_failed() {
assert_state_and_code(
@@ -99,6 +177,35 @@ fn environment_not_found_is_failed_with_resource_not_found() {
);
}
#[test]
fn conversation_harness_mismatch_is_failed_with_env_setup() {
let (state, update) = classify_driver_error(&AgentDriverError::ConversationHarnessMismatch {
conversation_id: "conv-123".into(),
expected: "claude".into(),
got: "oz".into(),
});
assert_eq!(state, AgentTaskState::Failed);
assert_eq!(
update.error_code,
Some(PlatformErrorCode::EnvironmentSetupFailed)
);
assert!(update.message.contains("conv-123"));
assert!(update.message.contains("--harness claude"));
}
#[test]
fn conversation_resume_state_missing_is_failed_with_resource_not_found() {
let (state, update) =
classify_driver_error(&AgentDriverError::ConversationResumeStateMissing {
harness: "claude".into(),
conversation_id: "conv-123".into(),
});
assert_eq!(state, AgentTaskState::Failed);
assert_eq!(update.error_code, Some(PlatformErrorCode::ResourceNotFound));
assert!(update.message.contains("conv-123"));
assert!(update.message.contains("claude"));
}
// --- ShareSessionFailed variants ---
#[test]
@@ -153,3 +260,41 @@ fn conversation_blocked_is_blocked() {
assert_eq!(state, AgentTaskState::Blocked);
assert!(update.message.contains("rm -rf /"));
}
// --- Harness auth preflight errors ---
#[test]
fn harness_auth_check_failed_is_failed_with_auth_required() {
let (state, update) = classify_driver_error(&AgentDriverError::HarnessAuthCheckFailed {
harness: "claude".into(),
detail: "exit code 1".into(),
});
assert_eq!(state, AgentTaskState::Failed);
assert_eq!(
update.error_code,
Some(PlatformErrorCode::AuthenticationRequired)
);
assert!(update.message.contains("authentication check failed"));
assert!(update.message.contains("claude"));
}
// --- Runtime failure detection ---
#[test]
fn harness_runtime_failure_detected_is_failed_with_auth_required() {
let (state, update) = classify_driver_error(&AgentDriverError::HarnessRuntimeFailureDetected {
harness: "claude".into(),
pattern: "credit balance is too low".into(),
excerpt: "Error: Your credit balance is too low to make this request.".into(),
});
assert_eq!(state, AgentTaskState::Failed);
assert_eq!(
update.error_code,
Some(PlatformErrorCode::AuthenticationRequired)
);
// The user-visible message must surface both the matched pattern and
// the excerpt so on-call/users have actionable context.
assert!(update.message.contains("claude"));
assert!(update.message.contains("credit balance is too low"));
assert!(update.message.contains("Your credit balance is too low"));
}
@@ -0,0 +1,417 @@
/// Git credentials management for cloud agent sandboxes.
///
/// This module handles:
/// - Writing provider credentials to `~/.git-credentials`, plus GitHub
/// credentials to `~/.config/gh/hosts.yml`, without requiring environment
/// variables.
/// - One-time git configuration (`credential.helper store`, SSH→HTTPS URL
/// rewrites).
/// - Configuring the git user identity from the server-returned username/email.
/// - An async refresh loop that periodically fetches a fresh token from the
/// server and overwrites the credential files, keeping long-running agents
/// authenticated for their entire duration.
use std::{path::PathBuf, sync::Arc, time::Duration};
use anyhow::{Context, Result};
// Use the project's allowed Command wrapper (not std::process::Command, which is
// disallowed by clippy rules because it flashes a terminal window on Windows).
use command::blocking::Command as BlockingCommand;
use crate::server::server_api::ai::{AIClient, GitCredential};
/// How long to wait between credential refresh attempts (~50 minutes, staying
/// well ahead of the shortest-lived one-hour token expiry).
pub(crate) const GIT_CREDENTIALS_REFRESH_INTERVAL: Duration = Duration::from_secs(50 * 60);
const DEFAULT_GIT_NAME: &str = "Oz";
const DEFAULT_GIT_EMAIL: &str = "oz-agent@warp.dev";
const GITHUB_HOST: &str = "github.com";
const GH_HOSTS_FILENAME: &str = "hosts.yml";
const GLAB_HOST: &str = "gitlab.com";
const GLAB_CONFIG_FILENAME: &str = "config.yml";
fn home_dir() -> Result<PathBuf> {
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
}
/// Write `content` to `path` using owner-only (0600) permissions.
///
/// On Unix the file is created with mode 0600 so no other user can read the
/// credential material. On non-Unix platforms the function falls back to the
/// standard write, relying on OS default permissions.
fn write_secret_file(path: &std::path::Path, content: &str) -> Result<()> {
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("Failed to open {} for writing", path.display()))?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("Failed to set permissions on {}", path.display()))?;
file.write_all(content.as_bytes())
.with_context(|| format!("Failed to write {}", path.display()))?;
}
#[cfg(not(unix))]
{
std::fs::write(path, content)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
Ok(())
}
fn git_credentials_file_content(credentials: &[GitCredential]) -> String {
let mut content = String::new();
for cred in credentials {
let userinfo = match &cred.username {
Some(username) => format!("{username}:{}", cred.token),
None => format!("x-access-token:{}", cred.token),
};
content.push_str(&format!("https://{}@{}\n", userinfo, cred.host));
}
content
}
/// Write `~/.git-credentials` with the given credentials.
///
/// Each credential entry is formatted as:
/// - `https://{username}:{token}@{host}` when a username is present
/// - `https://x-access-token:{token}@{host}` for service-account tokens
///
/// The write is done atomically: a temporary file is written then renamed.
fn write_git_credentials_file(credentials: &[GitCredential]) -> Result<()> {
if credentials.is_empty() {
return Ok(());
}
let home = home_dir()?;
let path = home.join(".git-credentials");
let tmp_path = home.join(".git-credentials.tmp");
let content = git_credentials_file_content(credentials);
write_secret_file(&tmp_path, &content)?;
std::fs::rename(&tmp_path, &path).with_context(|| {
format!(
"Failed to rename {} to {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
/// Write `~/.config/gh/hosts.yml` so the `gh` CLI is authenticated.
///
/// The YAML format is stable for `gh` v2+:
/// ```yaml
/// github.com:
/// oauth_token: TOKEN
/// git_protocol: https
/// user: USERNAME
/// ```
///
/// The write is atomic: a temporary file is written then renamed.
fn write_gh_hosts_yml(credentials: &[GitCredential], home: &std::path::Path) -> Result<()> {
let github_credentials = credentials
.iter()
.filter(|credential| credential.host == GITHUB_HOST)
.collect::<Vec<_>>();
if github_credentials.is_empty() {
return Ok(());
}
let gh_config_dir = home.join(".config").join("gh");
std::fs::create_dir_all(&gh_config_dir)
.with_context(|| format!("Failed to create {}", gh_config_dir.display()))?;
let path = gh_config_dir.join(GH_HOSTS_FILENAME);
let tmp_path = gh_config_dir.join(format!("{GH_HOSTS_FILENAME}.tmp"));
let mut yaml = String::new();
for cred in github_credentials {
yaml.push_str(&format!("{}:\n", cred.host));
yaml.push_str(&format!(" oauth_token: {}\n", cred.token));
yaml.push_str(" git_protocol: https\n");
if let Some(username) = &cred.username {
yaml.push_str(&format!(" user: {username}\n"));
}
}
write_secret_file(&tmp_path, &yaml)?;
std::fs::rename(&tmp_path, &path).with_context(|| {
format!(
"Failed to rename {} to {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
/// Write `~/.config/glab-cli/config.yml` so the `glab` CLI is authenticated.
///
/// The YAML format for glab is:
/// ```yaml
/// hosts:
/// gitlab.com:
/// token: TOKEN
/// git_protocol: https
/// api_protocol: https
/// ```
///
/// The write is atomic: a temporary file is written then renamed.
fn write_glab_config(credentials: &[GitCredential], home: &std::path::Path) -> Result<()> {
let gitlab_credentials = credentials
.iter()
.filter(|credential| credential.host == GLAB_HOST)
.collect::<Vec<_>>();
if gitlab_credentials.is_empty() {
return Ok(());
}
let glab_config_dir = home.join(".config").join("glab-cli");
std::fs::create_dir_all(&glab_config_dir)
.with_context(|| format!("Failed to create {}", glab_config_dir.display()))?;
let path = glab_config_dir.join(GLAB_CONFIG_FILENAME);
let tmp_path = glab_config_dir.join(format!("{GLAB_CONFIG_FILENAME}.tmp"));
let mut yaml = String::new();
yaml.push_str("hosts:\n");
for cred in gitlab_credentials {
yaml.push_str(&format!(" {}:\n", cred.host));
yaml.push_str(&format!(" token: {}\n", cred.token));
yaml.push_str(" git_protocol: https\n");
yaml.push_str(" api_protocol: https\n");
}
write_secret_file(&tmp_path, &yaml)?;
std::fs::rename(&tmp_path, &path).with_context(|| {
format!(
"Failed to rename {} to {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
/// Formats non-sensitive metadata for verifying local credential injection.
pub(crate) fn credential_diagnostics(credentials: &[GitCredential]) -> String {
credentials
.iter()
.map(|credential| {
format!(
"{}(token_present={}, username_present={})",
credential.host,
!credential.token.is_empty(),
credential.username.is_some()
)
})
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn write_git_credentials(credentials: &[GitCredential]) -> Result<()> {
if credentials.is_empty() {
return Ok(());
}
write_git_credentials_file(credentials)?;
let home = home_dir()?;
write_gh_hosts_yml(credentials, &home)?;
write_glab_config(credentials, &home)?;
log::info!(
"Wrote {} git credential(s) to the local credential store: {}",
credentials.len(),
credential_diagnostics(credentials)
);
Ok(())
}
pub(crate) fn configure_git_credentials(credentials: &[GitCredential]) -> Result<()> {
if credentials.is_empty() {
return Ok(());
}
setup_git_config(credentials);
configure_git_identity(credentials);
write_git_credentials(credentials)
}
/// Run a git config command, logging a warning on failure rather than
/// propagating the error (git may not be installed in all sandboxes).
fn run_git_config(key: &str, value: &str) {
match BlockingCommand::new("git")
.args(["config", "--global", key, value])
.output()
{
Ok(output) if output.status.success() => {}
Ok(output) => {
log::warn!(
"git config --global {key} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(e) => {
log::warn!("Failed to run git config --global {key}: {e}");
}
}
}
/// Like [`run_git_config`] but passes `--add` so the new value is appended to
/// any existing values for `key` rather than replacing them.
fn run_git_config_add(key: &str, value: &str) {
match BlockingCommand::new("git")
.args(["config", "--global", "--add", key, value])
.output()
{
Ok(output) if output.status.success() => {}
Ok(output) => {
log::warn!(
"git config --global --add {key} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(e) => {
log::warn!("Failed to run git config --global --add {key}: {e}");
}
}
}
/// Run one-time git configuration that is set at startup and never needs to
/// be refreshed:
/// - `credential.helper store` so git reads `~/.git-credentials`
/// - SSH→HTTPS URL rewrites for each credential host, covering both the
/// scp-style (`git@{host}:`) and explicit-protocol (`ssh://git@{host}/`)
/// URL forms, so operations on either form use HTTPS credentials instead
/// of looking for an SSH key.
pub(crate) fn setup_git_config(credentials: &[GitCredential]) {
run_git_config("credential.helper", "store");
// Use --add for both forms per host so all values coexist as a
// multi-value key rather than each entry overwriting the previous one.
for cred in credentials {
let host = &cred.host;
run_git_config_add(
&format!("url.https://{host}/.insteadOf"),
&format!("ssh://git@{host}/"),
);
run_git_config_add(
&format!("url.https://{host}/.insteadOf"),
&format!("git@{host}:"),
);
}
}
/// Configure the git user identity from the server-returned credential.
///
/// Uses the first credential's `username`/`email` fields, falling back to the
/// Oz defaults when either is absent (e.g. service-account principals).
pub(crate) fn configure_git_identity(credentials: &[GitCredential]) {
let (name, email) = credentials
.first()
.map(|c| {
(
c.username.as_deref().unwrap_or(DEFAULT_GIT_NAME),
c.email.as_deref().unwrap_or(DEFAULT_GIT_EMAIL),
)
})
.unwrap_or((DEFAULT_GIT_NAME, DEFAULT_GIT_EMAIL));
run_git_config("user.name", name);
run_git_config("user.email", email);
}
/// Perform one git credentials refresh attempt.
///
/// Returns `Ok(())` on success (including when the server returns no
/// credentials). Returns `Err` when the workload-token issuance or the server
/// API call fails — these are transient failures worth retrying.
#[tracing::instrument(name = "git_credentials::try_refresh", skip_all, err, fields(
tags.cloud_agent = true,
task_id,
))]
async fn try_refresh(task_id: &str, ai_client: &Arc<dyn AIClient>) -> Result<()> {
let workload_token =
warp_isolation_platform::issue_workload_token(Some(Duration::from_secs(5 * 60)))
.await
.context("Failed to issue workload token for git credentials refresh")?
.token;
let credentials = ai_client
.get_task_git_credentials(task_id.to_string(), workload_token)
.await
.context("Failed to fetch git credentials from server")?;
if credentials.is_empty() {
log::debug!("No git credentials returned during refresh; skipping file write");
return Ok(());
}
if let Err(e) = write_git_credentials(&credentials) {
log::warn!("Failed to write refreshed git credentials: {e:#}");
} else {
log::info!("Git credentials refreshed successfully");
}
Ok(())
}
/// Infinite async loop that refreshes git credentials every
/// [`GIT_CREDENTIALS_REFRESH_INTERVAL`].
///
/// On each iteration:
/// 1. Issue a short-lived workload token.
/// 2. Call `taskGitCredentials` to get a fresh token from the server.
/// 3. Overwrite `~/.git-credentials` and refresh GitHub credentials in
/// `~/.config/gh/hosts.yml`.
///
/// On transient failure, the refresh is retried up to three times with
/// exponential backoff (1 min, 2 min, 4 min), keeping all retries within the
/// ~10-minute buffer before the one-hour token expires. If all retries fail,
/// a warning is logged and the next refresh is scheduled after the normal
/// interval.
///
/// This future never resolves — it is designed to be raced with the harness
/// execution future via `futures::select!` and dropped when the harness
/// completes.
pub(crate) async fn refresh_loop(task_id: String, ai_client: Arc<dyn AIClient>) {
loop {
warpui::r#async::Timer::after(GIT_CREDENTIALS_REFRESH_INTERVAL).await;
log::info!("Refreshing git credentials for task {task_id}");
let backoff_delays = [
Duration::from_secs(60),
Duration::from_secs(2 * 60),
Duration::from_secs(4 * 60),
];
let mut attempt = 0usize;
loop {
match try_refresh(&task_id, &ai_client).await {
Ok(()) => break,
Err(e) if attempt < backoff_delays.len() => {
let delay = backoff_delays[attempt];
log::warn!(
"Git credentials refresh failed (attempt {}): {e:#}; retrying in {}s",
attempt + 1,
delay.as_secs()
);
warpui::r#async::Timer::after(delay).await;
attempt += 1;
}
Err(e) => {
log::warn!(
"Git credentials refresh failed after {} attempts: {e:#}; \
credentials may expire before next refresh cycle",
attempt + 1
);
break;
}
}
}
}
}
#[cfg(test)]
#[path = "git_credentials_tests.rs"]
mod tests;
@@ -0,0 +1,204 @@
use super::*;
#[test]
fn write_gh_hosts_yml_uses_gh_cli_filename() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let gh_config_dir = temp_dir.path().join(".config").join("gh");
write_gh_hosts_yml(
&[GitCredential {
token: "token".to_string(),
username: Some("octocat".to_string()),
email: Some("octocat@example.com".to_string()),
host: "github.com".to_string(),
}],
temp_dir.path(),
)?;
let hosts_path = gh_config_dir.join(GH_HOSTS_FILENAME);
assert!(hosts_path.exists());
assert!(!gh_config_dir
.join(format!("{GH_HOSTS_FILENAME}.tmp"))
.exists());
let hosts = std::fs::read_to_string(hosts_path)?;
assert!(hosts.contains("github.com:"));
assert!(hosts.contains(" oauth_token: token"));
assert!(hosts.contains(" git_protocol: https"));
assert!(hosts.contains(" user: octocat"));
Ok(())
}
#[test]
fn write_gh_hosts_yml_excludes_gitlab_credentials() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let gh_config_dir = temp_dir.path().join(".config").join("gh");
write_gh_hosts_yml(
&[
GitCredential {
token: "github-token".to_string(),
username: Some("octocat".to_string()),
email: None,
host: "github.com".to_string(),
},
GitCredential {
token: "gitlab-token".to_string(),
username: Some("oauth2".to_string()),
email: None,
host: "gitlab.com".to_string(),
},
],
temp_dir.path(),
)?;
let hosts = std::fs::read_to_string(gh_config_dir.join(GH_HOSTS_FILENAME))?;
assert!(hosts.contains("github.com:"));
assert!(!hosts.contains("gitlab.com:"));
assert!(!hosts.contains("gitlab-token"));
Ok(())
}
#[test]
fn write_gh_hosts_yml_skips_gitlab_only_credentials() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
write_gh_hosts_yml(
&[GitCredential {
token: "gitlab-token".to_string(),
username: Some("oauth2".to_string()),
email: None,
host: "gitlab.com".to_string(),
}],
temp_dir.path(),
)?;
assert!(!temp_dir.path().join(".config").join("gh").exists());
Ok(())
}
#[test]
fn git_credentials_file_content_includes_each_provider_host() {
let content = git_credentials_file_content(&[
GitCredential {
token: "github-token".to_string(),
username: None,
email: None,
host: "github.com".to_string(),
},
GitCredential {
token: "gitlab-token".to_string(),
username: Some("oauth2".to_string()),
email: None,
host: "gitlab.com".to_string(),
},
]);
assert_eq!(
content,
"https://x-access-token:github-token@github.com\n\
https://oauth2:gitlab-token@gitlab.com\n"
);
}
#[test]
fn credential_diagnostics_reports_presence_without_values() {
let diagnostics = credential_diagnostics(&[GitCredential {
token: "secret-token".to_string(),
username: Some("oauth2".to_string()),
email: Some("user@example.com".to_string()),
host: "gitlab.com".to_string(),
}]);
assert_eq!(
diagnostics,
"gitlab.com(token_present=true, username_present=true)"
);
assert!(!diagnostics.contains("secret-token"));
assert!(!diagnostics.contains("oauth2"));
assert!(!diagnostics.contains("user@example.com"));
}
#[test]
fn write_glab_config_uses_glab_cli_filename() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let glab_config_dir = temp_dir.path().join(".config").join("glab-cli");
write_glab_config(
&[GitCredential {
token: "gitlab-token".to_string(),
username: Some("oauth2".to_string()),
email: Some("user@example.com".to_string()),
host: "gitlab.com".to_string(),
}],
temp_dir.path(),
)?;
let config_path = glab_config_dir.join(GLAB_CONFIG_FILENAME);
assert!(config_path.exists());
assert!(!glab_config_dir
.join(format!("{GLAB_CONFIG_FILENAME}.tmp"))
.exists());
let config = std::fs::read_to_string(config_path)?;
assert!(config.contains("hosts:"));
assert!(config.contains(" gitlab.com:"));
assert!(config.contains(" token: gitlab-token"));
assert!(config.contains(" git_protocol: https"));
assert!(config.contains(" api_protocol: https"));
Ok(())
}
#[test]
fn write_glab_config_excludes_github_credentials() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let glab_config_dir = temp_dir.path().join(".config").join("glab-cli");
write_glab_config(
&[
GitCredential {
token: "github-token".to_string(),
username: Some("octocat".to_string()),
email: None,
host: "github.com".to_string(),
},
GitCredential {
token: "gitlab-token".to_string(),
username: Some("oauth2".to_string()),
email: None,
host: "gitlab.com".to_string(),
},
],
temp_dir.path(),
)?;
let config = std::fs::read_to_string(glab_config_dir.join(GLAB_CONFIG_FILENAME))?;
assert!(config.contains("gitlab.com:"));
assert!(!config.contains("github.com:"));
assert!(!config.contains("github-token"));
Ok(())
}
#[test]
fn write_glab_config_skips_github_only_credentials() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
write_glab_config(
&[GitCredential {
token: "github-token".to_string(),
username: Some("octocat".to_string()),
email: None,
host: "github.com".to_string(),
}],
temp_dir.path(),
)?;
assert!(!temp_dir.path().join(".config").join("glab-cli").exists());
Ok(())
}
+281 -127
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -12,42 +13,55 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tempfile::NamedTempFile;
use uuid::Uuid;
use galaxy_managed_secrets::ManagedSecretValue;
use super::super::terminal::{CommandHandle, TerminalDriver};
use super::super::{AgentDriver, AgentDriverError};
use super::claude_transcript::{
claude_config_dir, home_dir_for_claude_config, read_envelope, rehydrate_claude_transcript,
ClaudeResumeInfo, ClaudeTranscriptEnvelope,
};
use super::json_utils::{read_json_file_or_default, write_json_file};
use super::{
cli_agent_session_status, write_temp_file, HarnessCleanupDisposition, HarnessRunner,
JSONMCPServer, ResumePayload, SavePoint, ThirdPartyHarness,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_sdk::setup_observability::{
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
};
use crate::ai::ambient_agents::task::HarnessModelConfig;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::mcp::JSONTransportType;
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;
mod wake_driver;
#[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,
parent_bridge_char_count, parent_bridge_event_cursor_file, 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,
prime_parent_bridge_staged_for_self_managed_wake, read_parent_bridge_event_cursor,
render_parent_bridge_message_block, stage_parent_bridge_message,
write_parent_bridge_event_cursor, MessageBridgeHookOutput, MessageBridgeMessageRecord,
MESSAGE_BRIDGE_CONTEXT_PREAMBLE,
};
use parent_bridge::{MessageBridge, MessageBridgeCleanupDisposition};
#[cfg(test)]
use shell_words::quote as shell_quote;
#[cfg(test)]
use wake_driver::{ClaudeWakeRemoteContext, CLAUDE_WAKE_PROMPT_FILE_NAME};
#[cfg(test)]
use super::super::OZ_MESSAGE_LISTENER_STATE_ROOT_ENV;
pub(crate) struct ClaudeHarness;
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl ThirdPartyHarness for ClaudeHarness {
@@ -63,18 +77,33 @@ impl ThirdPartyHarness for ClaudeHarness {
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,
}
})
fn auth_check_command(&self) -> Option<String> {
let cli = self.cli_agent().command_prefix();
Some(format!("{cli} auth status --json"))
}
fn runtime_error_patterns(&self) -> &'static [&'static str] {
&[
// Out-of-credits / billing.
"Credit balance too low",
// Plan/usage limits emitted as `You've hit your <kind> limit`.
// We match on the common prefix so the variants (session,
// weekly, Opus, etc.) all hit.
"You've hit your",
// Invalid or malformed API key.
"Invalid API key",
"This organization has been disabled",
"belongs to a disabled organization",
// OAuth / login state.
"Not logged in",
"OAuth token revoked",
"OAuth token has expired",
// Routines disabled by org policy.
"Routines are disabled by your organization's policy",
// Generic upstream API failures Claude Code surfaces verbatim.
"API Error: Request rejected (429)",
"authentication_error",
]
}
/// Fetch the Claude Code transcript for the current task's conversation and wrap it
@@ -86,28 +115,9 @@ impl ThirdPartyHarness for ClaudeHarness {
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 envelope: ClaudeTranscriptEnvelope =
super::fetch_transcript_envelope("claude", conversation_id, harness_support_client)
.await?;
let session_id = envelope.uuid;
Ok(Some(ResumePayload::Claude(ClaudeResumeInfo {
conversation_id: *conversation_id,
@@ -121,24 +131,43 @@ impl ThirdPartyHarness for ClaudeHarness {
prompt: &str,
system_prompt: Option<&str>,
resumption_prompt: Option<&str>,
context: Option<&str>,
working_dir: &Path,
task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ResumePayload>,
resolved_env_vars: &HashMap<OsString, OsString>,
_resolved_secrets: &HashMap<String, ManagedSecretValue>,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
_third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> 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,
});
// Prepare the environment config files.
prepare_claude_environment_config(working_dir, resolved_env_vars).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
}
})?;
// The ResumePayload shouldn't contain non-Claude information, error if it does.
let claude_resume = resume.map(ClaudeResumeInfo::try_from).transpose()?;
// 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(),
};
// and server context are most reliable when prepended directly to the prompt that gets
// piped into the CLI. Order: resumption_prompt → context → prompt
let mut parts: Vec<&str> = Vec::new();
if let Some(preamble) = resumption_prompt {
if !preamble.is_empty() {
parts.push(preamble);
}
}
if let Some(ctx) = context {
if !ctx.is_empty() {
parts.push(ctx);
}
}
parts.push(prompt);
let owned_prompt = parts.join("\n\n");
Ok(Box::new(ClaudeHarnessRunner::new(
self.cli_agent().command_prefix(),
&owned_prompt,
@@ -148,8 +177,13 @@ impl ThirdPartyHarness for ClaudeHarness {
server_api,
terminal_driver,
claude_resume,
resolved_mcp_servers,
)?))
}
fn requires_verified_platform_plugin(&self) -> bool {
true
}
}
/// Format slug sent to the server when creating a Claude Code conversation.
@@ -169,6 +203,7 @@ fn claude_command(
session_id: &Uuid,
prompt_path: &str,
system_prompt_path: Option<&str>,
mcp_config_path: Option<&str>,
resuming: bool,
) -> String {
let flag = if resuming { "--resume" } else { "--session-id" };
@@ -176,6 +211,9 @@ fn claude_command(
if let Some(sp_path) = system_prompt_path {
let _ = write!(cmd, " --append-system-prompt-file '{sp_path}'");
}
if let Some(mcp_path) = mcp_config_path {
let _ = write!(cmd, " --mcp-config '{mcp_path}'");
}
format!("{cmd} < '{prompt_path}'")
}
@@ -198,6 +236,8 @@ struct ClaudeHarnessRunner {
_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>,
/// Held so the MCP config temp file lives until the CLI exits.
_temp_mcp_config_file: Option<NamedTempFile>,
client: Arc<dyn HarnessSupportClient>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
@@ -224,49 +264,44 @@ impl ClaudeHarnessRunner {
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ClaudeResumeInfo>,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
) -> 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 temp_file = write_temp_file("oz_prompt_", prompt, ".txt")?;
let prompt_path = temp_file.path().display().to_string();
let (session_id, preexisting_conversation_id, resuming) = match resume {
let (session_id, preexisting_conversation_id) = 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)
rehydrate_claude_transcript(&mut envelope, working_dir)
.map_err(AgentDriverError::ConfigBuildFailed)?;
(session_id, Some(conversation_id))
}
None => (Uuid::new_v4(), None, false),
None => (Uuid::new_v4(), None),
};
let temp_system_prompt_file = system_prompt
.map(|sp| write_temp_file("oz_system_prompt_", sp))
.map(|sp| write_temp_file("oz_system_prompt_", sp, ".txt"))
.transpose()?;
let system_prompt_path = temp_system_prompt_file
.as_ref()
.map(|f| f.path().display().to_string());
let temp_mcp_config_file = (!resolved_mcp_servers.is_empty())
.then(|| {
let mcp_json = serialize_claude_mcp_config(resolved_mcp_servers)
.map_err(AgentDriverError::ConfigBuildFailed)?;
write_temp_file("oz_mcp_config_", &mcp_json, ".json")
})
.transpose()?;
let mcp_config_path = temp_mcp_config_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()
@@ -279,11 +314,13 @@ impl ClaudeHarnessRunner {
&session_id,
&prompt_path,
system_prompt_path.as_deref(),
resuming,
mcp_config_path.as_deref(),
preexisting_conversation_id.is_some(),
),
cli_name: cli_command.to_string(),
_temp_prompt_file: temp_file,
_temp_system_prompt_file: temp_system_prompt_file,
_temp_mcp_config_file: temp_mcp_config_file,
client,
server_api,
terminal_driver,
@@ -362,9 +399,33 @@ impl ClaudeHarnessRunner {
.await
}
fn cleanup_parent_bridge(&self) -> Result<()> {
async fn should_preserve_parent_bridge(
&self,
cleanup_disposition: HarnessCleanupDisposition,
foreground: &ModelSpawner<AgentDriver>,
) -> bool {
if !matches!(
cleanup_disposition,
HarnessCleanupDisposition::PreserveResumptionStateIfSupported
) {
return false;
}
!matches!(
cli_agent_session_status(&self.terminal_driver, foreground).await,
Some(crate::terminal::cli_agent_sessions::CLIAgentSessionStatus::Blocked { .. })
| Some(crate::terminal::cli_agent_sessions::CLIAgentSessionStatus::InProgress)
)
}
fn cleanup_parent_bridge(&self, preserve_state: bool) -> Result<()> {
if let Some(parent_bridge) = self.parent_bridge.as_ref() {
parent_bridge.cleanup()?;
let cleanup_disposition = if preserve_state {
MessageBridgeCleanupDisposition::PreserveState
} else {
MessageBridgeCleanupDisposition::RemoveState
};
parent_bridge.cleanup(cleanup_disposition)?;
}
Ok(())
}
@@ -373,9 +434,14 @@ impl ClaudeHarnessRunner {
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl HarnessRunner for ClaudeHarnessRunner {
fn harness_name(&self) -> &str {
&self.cli_name
}
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
setup_events: &SetupClientEventReporter,
) -> 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.
@@ -387,14 +453,17 @@ impl HarnessRunner for ClaudeHarnessRunner {
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)
})?;
let id = setup_events
.record_result(SetupStep::ThirdPartyHarnessExternalConversation, async {
self.client
.create_external_conversation(CLAUDE_CODE_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})
})
.await?;
log::info!("Created external conversation {id}");
id
}
@@ -414,7 +483,7 @@ impl HarnessRunner for ClaudeHarnessRunner {
{
Ok(command_handle) => command_handle,
Err(err) => {
self.cleanup_parent_bridge()
self.cleanup_parent_bridge(false)
.map_err(AgentDriverError::ConfigBuildFailed)?;
return Err(err);
}
@@ -426,6 +495,10 @@ impl HarnessRunner for ClaudeHarnessRunner {
block_id: command_handle.block_id().clone(),
};
setup_events
.post_timeline_event(OzRunTimelineEvent::AgentStarted)
.await;
Ok(command_handle)
}
@@ -494,9 +567,16 @@ impl HarnessRunner for ClaudeHarnessRunner {
Ok(())
}
async fn cleanup(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
async fn cleanup(
&self,
cleanup_disposition: HarnessCleanupDisposition,
foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
self.flush_parent_bridge_acks().await?;
self.cleanup_parent_bridge()
let preserve_state = self
.should_preserve_parent_bridge(cleanup_disposition, foreground)
.await;
self.cleanup_parent_bridge(preserve_state)
}
}
@@ -526,21 +606,31 @@ async fn upload_transcript(
.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(
pub(crate) fn prepare_claude_environment_config(
working_dir: &Path,
secrets: &HashMap<String, ManagedSecretValue>,
resolved_env_vars: &HashMap<OsString, OsString>,
) -> 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_json_path = claude_global_config_path()?;
let claude_settings_path = claude_config_dir()?.join(CLAUDE_SETTINGS_FILE_NAME);
let api_key_suffix = resolve_anthropic_api_key_suffix(secrets);
let api_key_suffix = resolve_anthropic_api_key_suffix(resolved_env_vars);
prepare_claude_config(&claude_json_path, working_dir, api_key_suffix.as_deref())?;
prepare_claude_settings(&claude_settings_path)?;
Ok(())
}
// This function is used specifically for determining where to land `.claude.json`.
fn claude_global_config_path() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
if !dir.is_empty() {
return Ok(PathBuf::from(dir).join(CLAUDE_JSON_FILE_NAME));
}
}
home_dir_for_claude_config()
.map(|home| home.join(CLAUDE_JSON_FILE_NAME))
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
}
fn prepare_claude_config(
claude_json_path: &Path,
working_dir: &Path,
@@ -628,27 +718,23 @@ struct ClaudeSettings {
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.
/// Try to get the last 20 chars of the ANTHROPIC_API_KEY, where 20 chars is the
/// suffix length that Claude Code truncates keys to.
fn resolve_anthropic_api_key_suffix(
secrets: &HashMap<String, ManagedSecretValue>,
resolved_env_vars: &HashMap<OsString, OsString>,
) -> 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);
// Worker-injected process env wins.
if let Ok(key) = std::env::var(ANTHROPIC_API_KEY_ENV) {
if !key.is_empty() {
return suffix_of(&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))
// Otherwise use the resolved value from the secrets map.
resolved_env_vars
.get(OsStr::new(ANTHROPIC_API_KEY_ENV))
.and_then(|v| v.to_str())
.and_then(suffix_of)
.map(str::to_owned)
}
fn suffix_of(key: &str) -> Option<&str> {
@@ -659,6 +745,74 @@ fn suffix_of(key: &str) -> Option<&str> {
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ClaudeMcpConfig {
mcp_servers: HashMap<String, ClaudeMcpServerEntry>,
}
#[derive(Serialize)]
#[serde(tag = "type")]
enum ClaudeMcpServerEntry {
#[serde(rename = "stdio")]
Stdio {
command: String,
args: Vec<String>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
env: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
},
#[serde(rename = "http")]
Http {
url: String,
#[serde(skip_serializing_if = "HashMap::is_empty")]
headers: HashMap<String, String>,
},
}
impl ClaudeMcpServerEntry {
fn from_json_mcp_server(server: &JSONMCPServer) -> Self {
match &server.transport_type {
JSONTransportType::CLIServer {
command,
args,
env,
working_directory,
} => Self::Stdio {
command: command.clone(),
args: args.clone(),
env: env.clone(),
cwd: working_directory.clone(),
},
JSONTransportType::SSEServer { url, headers } => Self::Http {
url: url.clone(),
headers: headers.clone(),
},
}
}
}
/// Serialize resolved MCP servers into Claude Code's `--mcp-config` JSON format.
///
/// Produces `{ "mcpServers": { "name": { "type": "stdio"|"http", ... }, ... } }`.
pub(crate) fn serialize_claude_mcp_config(
servers: &HashMap<String, JSONMCPServer>,
) -> Result<String> {
let config = ClaudeMcpConfig {
mcp_servers: servers
.iter()
.map(|(name, server)| {
(
name.clone(),
ClaudeMcpServerEntry::from_json_mcp_server(server),
)
})
.collect(),
};
serde_json::to_string_pretty(&config).context("Failed to serialize Claude MCP config")
}
#[cfg(test)]
#[path = "claude_code_tests.rs"]
mod tests;
@@ -25,22 +25,25 @@ use uuid::Uuid;
use crate::ai::agent_events::{
run_agent_event_driver, AgentEventConsumer, AgentEventConsumerControlFlow,
AgentEventDriverConfig, MessageHydrator, ServerApiAgentEventSource,
AgentEventDriverConfig, AgentMessageEventMetadata, MessageHydrator, ServerApiAgentEventSource,
};
use crate::ai::agent_sdk::driver::{AgentDriver, OZ_MESSAGE_LISTENER_STATE_ROOT_ENV};
use crate::server::server_api::ai::AgentRunEvent;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::ai::{AIClient, 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_EVENT_CURSOR_FILE_NAME: &str = "event-cursor.json";
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";
pub(super) const MESSAGE_BRIDGE_CONTEXT_PREAMBLE: &str =
"Oz mailbox update for this child run.\nSource: lead agent\nContext type: user-level coordination messages\n";
const PARENT_BRIDGE_REMAINING_MESSAGES_NOTE: &str =
"\n\nMore lead-agent messages are still staged and will be surfaced on a later turn.";
"\n\nAdditional lead agent mailbox messages remain queued and may be surfaced later.";
pub(super) struct MessageBridge {
run_id: String,
@@ -48,6 +51,16 @@ pub(super) struct MessageBridge {
runtime: Mutex<Option<MessageBridgeRuntime>>,
state_lock: AsyncMutex<()>,
}
pub(super) enum MessageBridgeCleanupDisposition {
RemoveState,
PreserveState,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct MessageBridgeEventCursor {
since_sequence: i64,
}
struct MessageBridgeRuntime {
task: SpawnedFutureHandle,
}
@@ -55,6 +68,7 @@ struct MessageBridgeRuntime {
struct MessageBridgeEventConsumer {
run_id: String,
state_dir: PathBuf,
server_api: Arc<ServerApi>,
}
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
@@ -91,6 +105,13 @@ impl AgentEventConsumer for MessageBridgeEventConsumer {
Ok(AgentEventConsumerControlFlow::Continue)
}
async fn persist_cursor(&mut self, sequence: i64) -> anyhow::Result<()> {
write_parent_bridge_event_cursor(&self.state_dir, sequence)?;
self.server_api
.update_event_sequence_on_server(&self.run_id, sequence)
.await
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -130,6 +151,12 @@ struct SelectedMessageBridgeMessages {
}
impl MessageBridge {
fn hydrator(&self, server_api: Arc<ServerApi>) -> MessageHydrator {
match self.run_id.parse::<AmbientAgentTaskId>() {
Ok(task_id) => MessageHydrator::for_task(server_api, task_id),
Err(_) => MessageHydrator::new(server_api),
}
}
pub(super) fn new(run_id: String, session_id: Uuid) -> Result<Self> {
Ok(Self {
run_id,
@@ -177,8 +204,7 @@ impl MessageBridge {
if !self.state_dir.exists() {
return Ok(());
}
let hydrator = MessageHydrator::new(server_api);
let hydrator = self.hydrator(server_api);
let _guard = self.state_lock.lock().await;
acknowledge_parent_bridge_hook_output(&hydrator, &self.state_dir).await?;
prepare_parent_bridge_hook_output(
@@ -193,16 +219,18 @@ impl MessageBridge {
if !self.state_dir.exists() {
return Ok(());
}
let hydrator = MessageHydrator::new(server_api);
let hydrator = self.hydrator(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<()> {
pub(super) fn cleanup(&self, disposition: MessageBridgeCleanupDisposition) -> Result<()> {
if let Some(runtime) = self.runtime.lock().take() {
runtime.task.abort();
}
if matches!(disposition, MessageBridgeCleanupDisposition::PreserveState) {
return Ok(());
}
match fs::remove_dir_all(&self.state_dir) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
@@ -249,6 +277,10 @@ pub(super) fn parent_bridge_hook_output_ack_file(state_dir: &Path) -> PathBuf {
state_dir.join(PARENT_BRIDGE_HOOK_OUTPUT_ACK_FILE_NAME)
}
pub(super) fn parent_bridge_event_cursor_file(state_dir: &Path) -> PathBuf {
state_dir.join(PARENT_BRIDGE_EVENT_CURSOR_FILE_NAME)
}
fn parent_bridge_message_path(dir: &Path, sequence: i64, message_id: &str) -> PathBuf {
dir.join(format!("{sequence:020}-{message_id}.json"))
}
@@ -277,6 +309,28 @@ pub(super) fn ensure_parent_bridge_state_dir(state_dir: &Path) -> Result<()> {
Ok(())
}
pub(super) fn read_parent_bridge_event_cursor(state_dir: &Path) -> Result<i64> {
let path = parent_bridge_event_cursor_file(state_dir);
if !path.exists() {
return Ok(0);
}
let cursor = serde_json::from_slice::<MessageBridgeEventCursor>(
&fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?,
)
.with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(cursor.since_sequence)
}
pub(super) fn write_parent_bridge_event_cursor(state_dir: &Path, sequence: i64) -> Result<()> {
write_parent_bridge_json_atomically(
&parent_bridge_event_cursor_file(state_dir),
&MessageBridgeEventCursor {
since_sequence: sequence,
},
)
}
pub(super) fn stage_parent_bridge_message(
state_dir: &Path,
record: &MessageBridgeMessageRecord,
@@ -288,7 +342,57 @@ pub(super) fn stage_parent_bridge_message(
Ok(())
}
fn parent_bridge_max_context_chars() -> usize {
pub(super) async fn prime_parent_bridge_staged_for_self_managed_wake(
hydrator: &MessageHydrator,
state_dir: &Path,
wake_message: Option<&AgentMessageEventMetadata>,
) -> Result<()> {
remove_file_if_exists(&parent_bridge_hook_output_file(state_dir))?;
remove_file_if_exists(&parent_bridge_hook_output_ack_file(state_dir))?;
move_parent_bridge_surfaced_messages_to_staged(state_dir)?;
let Some(wake_message) = wake_message else {
return Ok(());
};
let record = hydrate_parent_bridge_message_record(
hydrator,
&MessageBridgeMessageRecord {
sequence: wake_message.sequence,
message_id: wake_message.message_id.clone(),
sender_run_id: String::new(),
subject: String::new(),
body: String::new(),
occurred_at: wake_message.occurred_at.clone(),
},
)
.await?;
stage_parent_bridge_message(state_dir, &record)?;
write_parent_bridge_event_cursor(state_dir, wake_message.sequence)
}
fn move_parent_bridge_surfaced_messages_to_staged(state_dir: &Path) -> Result<()> {
let surfaced_records = parent_bridge_message_records(&parent_bridge_surfaced_dir(state_dir))?;
for (path, record) in surfaced_records {
let target =
parent_bridge_staged_message_path(state_dir, record.sequence, &record.message_id);
if target.exists() {
write_parent_bridge_json_atomically(&target, &record)?;
remove_file_if_exists(&path)?;
} else {
fs::rename(&path, &target).with_context(|| {
format!(
"Failed to move message bridge record {} back to {}",
path.display(),
target.display()
)
})?;
}
}
Ok(())
}
pub(super) 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())
@@ -338,7 +442,7 @@ pub(super) fn render_parent_bridge_message_block(record: &MessageBridgeMessageRe
record.subject.as_str()
};
let mut block = String::from("---\nLead-agent message");
let mut block = String::from("---\nLead agent mailbox message");
if record.sequence != 0 {
let _ = write!(block, " #{}", record.sequence);
}
@@ -579,15 +683,50 @@ async fn run_parent_bridge_forever(
state_dir: PathBuf,
) -> Result<()> {
ensure_parent_bridge_state_dir(&state_dir)?;
let since_sequence =
read_parent_bridge_resume_cursor(server_api.as_ref(), &run_id, &state_dir).await?;
// 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 };
// loop and we also persist it inside the session state dir so dormant runs
// can resume without replaying already handled events.
let config =
AgentEventDriverConfig::retry_forever_run_ids(vec![run_id.clone()], since_sequence);
let source = ServerApiAgentEventSource::new(server_api.clone());
let mut consumer = MessageBridgeEventConsumer {
run_id,
state_dir,
server_api,
};
run_agent_event_driver(source, config, &mut consumer).await
}
async fn read_parent_bridge_resume_cursor(
server_api: &ServerApi,
run_id: &str,
state_dir: &Path,
) -> Result<i64> {
// The server cursor is the durable cross-client source of truth, but the
// bridge also keeps a local cursor for same-machine recovery. If Warp or
// Claude restarts after the bridge has staged events locally but before the
// server cursor update is visible, the local cursor prevents replaying
// messages already handed to this Claude session.
let local_sequence = read_parent_bridge_event_cursor(state_dir)?;
let Ok(task_id) = run_id.parse() else {
return Ok(local_sequence);
};
let server_sequence = match server_api.get_ambient_agent_task(&task_id).await {
Ok(task) => task.last_event_sequence.unwrap_or(0),
Err(err) => {
log::warn!(
"Failed to read server-backed event cursor for Claude message bridge run {run_id}: {err:#}"
);
0
}
};
Ok(local_sequence.max(server_sequence))
}
fn write_parent_bridge_json_atomically<T: Serialize>(path: &Path, value: &T) -> Result<()> {
write_parent_bridge_bytes_atomically(path, &serde_json::to_vec(value)?)
}
@@ -0,0 +1,296 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use shell_words::quote as shell_quote;
use uuid::Uuid;
use warp_cli::agent::Harness;
use warp_graphql::ai::AgentTaskState;
use super::super::claude_transcript::{
claude_config_dir, write_envelope, write_session_index_entry, ClaudeTranscriptEnvelope,
};
use super::super::{remove_claude_externally_managed_listener_env_vars, task_env_vars};
use super::parent_bridge::{
ensure_parent_bridge_state_dir, parent_bridge_root,
prime_parent_bridge_staged_for_self_managed_wake,
};
use super::{claude_command, prepare_claude_environment_config, ClaudeHarness};
use crate::ai::agent::conversation::{AIConversation, ConversationStatus};
use crate::ai::agent_events::{AgentMessageEventMetadata, MessageHydrator};
use crate::ai::ambient_agents::{AmbientAgentTaskId, AmbientAgentTaskState};
use crate::server::server_api::ai::AIClient;
use crate::server::server_api::harness_support::ResolvePromptRequest;
use crate::server::server_api::ServerApi;
use crate::terminal::CLIAgent;
const CLAUDE_WAKE_PROMPT: &str =
"A lead agent mailbox message is available for this child run. Review the mailbox context and continue the task.";
pub(super) const CLAUDE_WAKE_PROMPT_FILE_NAME: &str = "wake-turn-prompt.txt";
#[derive(Debug)]
pub(super) struct ClaudeWakeRemoteContext {
pub(super) session_id: Uuid,
pub(super) envelope: ClaudeTranscriptEnvelope,
pub(super) wake_prompt: String,
}
struct ClaudeWakeCandidate {
task_id: AmbientAgentTaskId,
parent_run_id: Option<String>,
working_dir: Option<PathBuf>,
}
impl ClaudeHarness {
pub(crate) async fn wake_dormant_session(
server_api: Arc<ServerApi>,
conversation: AIConversation,
parent_conversation: Option<AIConversation>,
working_dir: Option<PathBuf>,
wake_message: Option<AgentMessageEventMetadata>,
) -> Result<Option<String>> {
let Some(candidate) =
Self::local_wake_candidate(&conversation, parent_conversation.as_ref(), working_dir)
else {
return Ok(None);
};
let ClaudeWakeCandidate {
task_id,
parent_run_id,
working_dir,
} = candidate;
let task = server_api.get_ambient_agent_task(&task_id).await?;
let harness = task
.agent_config_snapshot
.as_ref()
.and_then(|snapshot| snapshot.harness.as_ref())
.map(|config| config.harness_type);
log::info!(
"Evaluating dormant Claude wake: task_id={task_id} server_task_state={:?} harness={harness:?}",
task.state
);
if !is_local_wake_task_state_ready(task.state.clone()) || harness != Some(Harness::Claude) {
log::info!(
"Skipping dormant Claude wake: task_id={task_id} server_task_state={:?} harness={harness:?}",
task.state
);
return Ok(None);
}
let remote = Self::fetch_local_wake_remote_context(task_id, server_api.clone()).await?;
let command = Self::prepare_local_wake_command(
server_api.clone(),
task_id,
parent_run_id,
working_dir,
remote,
wake_message,
)
.await?;
log::info!("Reopening dormant Claude task before wake command: task_id={task_id}");
server_api
.update_agent_task(task_id, Some(AgentTaskState::InProgress), None, None, None)
.await
.map_err(|err| {
anyhow::anyhow!(
"Failed to reopen dormant Claude task {task_id} before wake: {err:#}"
)
})?;
log::info!("Reopened dormant Claude task before wake command: task_id={task_id}");
Ok(Some(command))
}
fn local_wake_candidate(
conversation: &AIConversation,
parent_conversation: Option<&AIConversation>,
working_dir: Option<PathBuf>,
) -> Option<ClaudeWakeCandidate> {
let conversation_id = conversation.id();
if !matches!(conversation.status(), ConversationStatus::Success) {
log::info!(
"Skipping dormant Claude wake candidate: conversation_id={conversation_id:?} reason=not_success status={:?}",
conversation.status()
);
return None;
}
if !conversation.is_child_agent_conversation() || conversation.is_remote_child() {
log::info!(
"Skipping dormant Claude wake candidate: conversation_id={conversation_id:?} reason=not_local_child is_child_agent_conversation={} is_remote_child={}",
conversation.is_child_agent_conversation(),
conversation.is_remote_child()
);
return None;
}
let Some(task_id) = conversation.task_id() else {
log::info!(
"Skipping dormant Claude wake candidate: conversation_id={conversation_id:?} reason=missing_task_id"
);
return None;
};
let parent_run_id = conversation
.parent_agent_id()
.map(str::to_owned)
.or_else(|| parent_conversation.and_then(AIConversation::run_id));
Some(ClaudeWakeCandidate {
task_id,
parent_run_id,
working_dir,
})
}
async fn fetch_local_wake_remote_context(
task_id: AmbientAgentTaskId,
server_api: Arc<ServerApi>,
) -> Result<ClaudeWakeRemoteContext> {
let resolved = server_api
.resolve_prompt_for_task(
&task_id,
ResolvePromptRequest {
skill: None,
attachments_dir: None,
},
)
.await
.with_context(|| format!("Failed to resolve Claude wake prompt for task {task_id}"))?;
let bytes = server_api
.fetch_transcript_for_task(&task_id)
.await
.with_context(|| format!("Failed to fetch Claude transcript for task {task_id}"))?;
let envelope: ClaudeTranscriptEnvelope =
serde_json::from_slice(&bytes).with_context(|| {
format!("Failed to deserialize Claude transcript for wake task {task_id}")
})?;
let wake_prompt = match resolved.resumption_prompt {
Some(resumption_prompt) if !resumption_prompt.is_empty() => {
format!(
"{resumption_prompt}
{CLAUDE_WAKE_PROMPT}"
)
}
_ => CLAUDE_WAKE_PROMPT.to_string(),
};
Ok(ClaudeWakeRemoteContext {
session_id: envelope.uuid,
envelope,
wake_prompt,
})
}
pub(super) async fn prepare_local_wake_command(
server_api: Arc<ServerApi>,
task_id: AmbientAgentTaskId,
parent_run_id: Option<String>,
working_dir: Option<PathBuf>,
mut remote: ClaudeWakeRemoteContext,
wake_message: Option<AgentMessageEventMetadata>,
) -> Result<String> {
let working_dir = working_dir.unwrap_or_else(|| remote.envelope.cwd.clone());
prepare_claude_environment_config(&working_dir, &HashMap::new())
.context("Failed to prepare Claude environment for wake")?;
remote.envelope.cwd = working_dir.clone();
let config_root = claude_config_dir().context("Failed to resolve Claude config dir")?;
write_envelope(&remote.envelope, &config_root)
.context("Failed to rehydrate Claude transcript for wake")?;
if let Err(error) = write_session_index_entry(remote.session_id, &working_dir, &config_root)
{
log::warn!("Failed to update Claude sessions-index.json for wake: {error:#}");
}
let state_dir = parent_bridge_root()?.join(remote.session_id.to_string());
ensure_parent_bridge_state_dir(&state_dir)?;
let hydrator = MessageHydrator::for_task(server_api, task_id);
prime_parent_bridge_staged_for_self_managed_wake(
&hydrator,
&state_dir,
wake_message.as_ref(),
)
.await?;
let prompt_path = state_dir.join(CLAUDE_WAKE_PROMPT_FILE_NAME);
std::fs::write(&prompt_path, remote.wake_prompt.as_bytes())
.with_context(|| format!("Failed to write {}", prompt_path.display()))?;
let command = claude_command(
CLIAgent::Claude.command_prefix(),
&remote.session_id,
&prompt_path.display().to_string(),
None,
None,
true,
);
let env_vars = local_wake_task_env_vars(Some(&task_id), parent_run_id.as_deref());
Ok(prefix_command_with_env_vars(command, env_vars))
}
}
fn local_wake_task_env_vars(
task_id: Option<&AmbientAgentTaskId>,
parent_run_id: Option<&str>,
) -> HashMap<OsString, OsString> {
let mut env_vars = task_env_vars(task_id, parent_run_id, Harness::Claude);
// The local wake command is executed directly in the existing child
// terminal, not through `AgentDriver::run_harness`, so Warp does not start
// `MessageBridge` for this resumed Claude process. Leave the listener in
// the Claude plugin's self-managed mode; otherwise the hook waits for
// state files that no managed bridge is producing and the wake message is
// never surfaced to Claude.
remove_claude_externally_managed_listener_env_vars(&mut env_vars);
env_vars
}
fn is_local_wake_task_state_ready(state: AmbientAgentTaskState) -> bool {
match state {
AmbientAgentTaskState::Succeeded => true,
// The local conversation status is already gated on `Success` before
// this function is called. The server task update is fire-and-forget,
// so it can still report `InProgress` for a short window after the
// local Claude process has actually stopped. Treat that stale server
// state as wakeable for local children.
AmbientAgentTaskState::InProgress => true,
AmbientAgentTaskState::Queued
| AmbientAgentTaskState::Pending
| AmbientAgentTaskState::Claimed
| AmbientAgentTaskState::Failed
| AmbientAgentTaskState::Error
| AmbientAgentTaskState::Blocked
| AmbientAgentTaskState::Cancelled
| AmbientAgentTaskState::Unknown => false,
}
}
fn prefix_command_with_env_vars(command: String, env_vars: HashMap<OsString, OsString>) -> String {
if env_vars.is_empty() {
return command;
}
let mut env_pairs = env_vars
.into_iter()
.map(|(key, value)| {
(
key.to_string_lossy().into_owned(),
value.to_string_lossy().into_owned(),
)
})
.collect::<Vec<_>>();
env_pairs.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
let assignments = env_pairs
.into_iter()
.map(|(key, value)| format!("{key}={}", shell_quote(&value)))
.collect::<Vec<_>>()
.join(" ");
format!("env {assignments} {command}")
}
#[cfg(test)]
#[path = "wake_driver_tests.rs"]
mod tests;
@@ -0,0 +1,24 @@
use super::*;
#[test]
fn local_wake_task_state_ready_allows_success_and_stale_in_progress() {
assert!(is_local_wake_task_state_ready(
AmbientAgentTaskState::Succeeded
));
assert!(is_local_wake_task_state_ready(
AmbientAgentTaskState::InProgress
));
for state in [
AmbientAgentTaskState::Queued,
AmbientAgentTaskState::Pending,
AmbientAgentTaskState::Claimed,
AmbientAgentTaskState::Failed,
AmbientAgentTaskState::Error,
AmbientAgentTaskState::Blocked,
AmbientAgentTaskState::Cancelled,
AmbientAgentTaskState::Unknown,
] {
assert!(!is_local_wake_task_state_ready(state));
}
}
@@ -1,15 +1,21 @@
use mockall::predicate::eq;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use mockall::predicate::eq;
use tempfile::TempDir;
use uuid::Uuid;
use warp_cli::{OZ_HARNESS_ENV, OZ_PARENT_RUN_ID_ENV, OZ_RUN_ID_ENV};
use super::*;
use crate::ai::agent_events::MessageHydrator;
use crate::server::server_api::ai::{MockAIClient, ReadAgentMessageResponse};
use crate::ai::agent_events::{AgentMessageEventMetadata, MessageHydrator};
use crate::ai::agent_sdk::driver::harness::claude_transcript::{
encode_cwd, write_session_index_entry,
};
use crate::ai::agent_sdk::driver::OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV;
use crate::server::server_api::ai::{AIClient, MockAIClient, ReadAgentMessageResponse};
use crate::server::server_api::ServerApiProvider;
fn sample_parent_bridge_message(
sequence: i64,
@@ -52,7 +58,7 @@ fn write_surfaced_parent_bridge_message(state_dir: &Path, record: &MessageBridge
#[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);
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, None, false);
assert!(
cmd.contains(&format!("--session-id {uuid}")),
"expected --session-id flag in non-resume command, got: {cmd}"
@@ -66,7 +72,7 @@ fn claude_command_uses_session_id_when_not_resuming() {
#[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);
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, None, true);
assert!(
cmd.contains(&format!("--resume {uuid}")),
"expected --resume flag in resume command, got: {cmd}"
@@ -80,7 +86,14 @@ fn claude_command_uses_resume_flag_when_resuming() {
#[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);
let cmd = claude_command(
"claude",
&uuid,
"/tmp/prompt with spaces.txt",
None,
None,
true,
);
assert!(
cmd.contains("< '/tmp/prompt with spaces.txt'"),
"expected single-quoted stdin redirect of the prompt path, got: {cmd}"
@@ -91,6 +104,111 @@ fn claude_command_pipes_prompt_path() {
);
}
#[test]
fn write_session_index_entry_creates_expected_entry() {
let tmp = TempDir::new().unwrap();
let cwd = Path::new("/my/project");
let session_id = Uuid::new_v4();
write_session_index_entry(session_id, cwd, tmp.path()).unwrap();
let index_path = tmp.path().join("sessions-index.json");
let index: Value = serde_json::from_slice(&fs::read(index_path).unwrap()).unwrap();
let session_key = session_id.to_string();
let entry = &index[&session_key];
let encoded = encode_cwd(cwd);
assert_eq!(entry["sessionId"], Value::String(session_key.clone()));
assert_eq!(
entry["cwd"],
Value::String(cwd.to_string_lossy().into_owned())
);
assert_eq!(entry["projectPath"], Value::String(encoded.clone()));
assert_eq!(
entry["transcriptPath"],
Value::String(format!("projects/{encoded}/{session_id}.jsonl"))
);
}
#[test]
fn serialize_claude_mcp_config_cli_server() {
let servers = HashMap::from([(
"test-server".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "node".to_string(),
args: vec!["server.js".to_string()],
env: HashMap::from([("API_KEY".to_string(), "secret".to_string())]),
working_directory: None,
},
},
)]);
let json = serialize_claude_mcp_config(&servers).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let server = &parsed["mcpServers"]["test-server"];
assert_eq!(server["type"], "stdio");
assert_eq!(server["command"], "node");
assert_eq!(server["args"][0], "server.js");
assert_eq!(server["env"]["API_KEY"], "secret");
}
#[test]
fn serialize_claude_mcp_config_cli_server_with_cwd() {
let servers = HashMap::from([(
"test-server".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "node".to_string(),
args: vec!["server.js".to_string()],
env: HashMap::new(),
working_directory: Some("/opt/mcp".to_string()),
},
},
)]);
let json = serialize_claude_mcp_config(&servers).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let server = &parsed["mcpServers"]["test-server"];
assert_eq!(server["cwd"], "/opt/mcp");
}
#[test]
fn serialize_claude_mcp_config_cli_server_omits_cwd_when_none() {
let servers = HashMap::from([(
"test-server".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "node".to_string(),
args: vec![],
env: HashMap::new(),
working_directory: None,
},
},
)]);
let json = serialize_claude_mcp_config(&servers).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let server = &parsed["mcpServers"]["test-server"];
assert!(server.get("cwd").is_none());
}
#[test]
fn serialize_claude_mcp_config_sse_server() {
let servers = HashMap::from([(
"remote".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::SSEServer {
url: "https://mcp.example.com".to_string(),
headers: HashMap::from([("Authorization".to_string(), "Bearer tok".to_string())]),
},
},
)]);
let json = serialize_claude_mcp_config(&servers).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let server = &parsed["mcpServers"]["remote"];
assert_eq!(server["type"], "http");
assert_eq!(server["url"], "https://mcp.example.com");
assert_eq!(server["headers"]["Authorization"], "Bearer tok");
}
#[test]
#[serial_test::serial]
fn parent_bridge_root_prefers_environment_override() {
@@ -119,6 +237,77 @@ fn stage_parent_bridge_message_writes_message_record() {
assert!(staged_record.sender_run_id.is_empty());
}
#[tokio::test]
async fn parent_bridge_event_cursor_defaults_to_zero_when_missing() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 0);
assert!(!parent_bridge_event_cursor_file(&state_dir).exists());
}
#[tokio::test]
async fn parent_bridge_event_cursor_round_trips() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
write_parent_bridge_event_cursor(&state_dir, 42).unwrap();
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 42);
assert!(parent_bridge_event_cursor_file(&state_dir).exists());
}
#[test]
#[serial_test::serial]
fn message_bridge_cleanup_preserves_state_for_wakeable_runs() {
let tmp = TempDir::new().unwrap();
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, tmp.path());
let session_id = Uuid::new_v4();
let bridge = MessageBridge::new("run-123".to_string(), session_id).unwrap();
let state_dir = tmp.path().join(session_id.to_string());
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();
write_parent_bridge_event_cursor(&state_dir, 42).unwrap();
bridge
.cleanup(MessageBridgeCleanupDisposition::PreserveState)
.unwrap();
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
assert!(state_dir.exists());
assert!(parent_bridge_staged_message_path(&state_dir, 42, "msg-123").exists());
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 42);
}
#[test]
#[serial_test::serial]
fn message_bridge_cleanup_removes_state_for_non_wakeable_runs() {
let tmp = TempDir::new().unwrap();
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, tmp.path());
let session_id = Uuid::new_v4();
let bridge = MessageBridge::new("run-123".to_string(), session_id).unwrap();
let state_dir = tmp.path().join(session_id.to_string());
ensure_parent_bridge_state_dir(&state_dir).unwrap();
stage_parent_bridge_message(
&state_dir,
&sample_staged_parent_bridge_message(42, "msg-123"),
)
.unwrap();
write_parent_bridge_event_cursor(&state_dir, 42).unwrap();
bridge
.cleanup(MessageBridgeCleanupDisposition::RemoveState)
.unwrap();
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
assert!(!state_dir.exists());
}
#[tokio::test]
async fn prepare_parent_bridge_hook_output_moves_selected_messages_to_surfaced_dir() {
let tmp = TempDir::new().unwrap();
@@ -481,75 +670,262 @@ fn prepare_claude_config_none_suffix_preserves_existing_responses() {
}
#[test]
fn resolve_suffix_from_raw_value_secret() {
#[serial_test::serial]
fn prepare_claude_environment_config_without_config_dir_uses_home_global_config() {
let home_dir = TempDir::new().unwrap();
let old_home = std::env::var_os("HOME");
let old_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
std::env::set_var("HOME", home_dir.path());
std::env::remove_var("CLAUDE_CONFIG_DIR");
let working_dir = home_dir.path().join("workspace/project");
prepare_claude_environment_config(&working_dir, &HashMap::new()).unwrap();
assert!(home_dir.path().join(CLAUDE_JSON_FILE_NAME).exists());
assert!(home_dir
.path()
.join(".claude")
.join(CLAUDE_SETTINGS_FILE_NAME)
.exists());
assert!(!home_dir
.path()
.join(".claude")
.join(CLAUDE_JSON_FILE_NAME)
.exists());
match old_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
match old_config_dir {
Some(dir) => std::env::set_var("CLAUDE_CONFIG_DIR", dir),
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
}
}
#[test]
#[serial_test::serial]
fn prepare_claude_environment_config_with_config_dir_uses_dir_global_config() {
let home_dir = TempDir::new().unwrap();
let claude_config_dir = TempDir::new().unwrap();
let old_home = std::env::var_os("HOME");
let old_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
std::env::set_var("HOME", home_dir.path());
std::env::set_var("CLAUDE_CONFIG_DIR", claude_config_dir.path());
let working_dir = home_dir.path().join("workspace/project");
prepare_claude_environment_config(&working_dir, &HashMap::new()).unwrap();
assert!(claude_config_dir
.path()
.join(CLAUDE_JSON_FILE_NAME)
.exists());
assert!(claude_config_dir
.path()
.join(CLAUDE_SETTINGS_FILE_NAME)
.exists());
assert!(!home_dir.path().join(CLAUDE_JSON_FILE_NAME).exists());
match old_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
match old_config_dir {
Some(dir) => std::env::set_var("CLAUDE_CONFIG_DIR", dir),
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
}
}
#[test]
#[serial_test::serial]
fn resolve_suffix_from_resolved_env_vars() {
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
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);
let resolved = HashMap::from([(OsString::from("ANTHROPIC_API_KEY"), OsString::from(key))]);
let suffix = resolve_anthropic_api_key_suffix(&resolved);
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]
#[serial_test::serial]
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);
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
let resolved = HashMap::from([(OsString::from("ANTHROPIC_API_KEY"), OsString::from("short"))]);
assert_eq!(resolve_anthropic_api_key_suffix(&resolved), 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"),
#[serial_test::serial]
fn resolve_suffix_returns_none_when_empty() {
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
assert_eq!(resolve_anthropic_api_key_suffix(&HashMap::new()), None);
}
#[test]
#[serial_test::serial]
fn prepare_local_wake_command_rehydrates_transcript_with_self_managed_listener() {
let home_dir = TempDir::new().unwrap();
let claude_config_dir = TempDir::new().unwrap();
let bridge_state_root = TempDir::new().unwrap();
let working_dir = home_dir.path().join("workspace/project");
fs::create_dir_all(&working_dir).unwrap();
std::env::set_var("HOME", home_dir.path());
std::env::set_var("CLAUDE_CONFIG_DIR", claude_config_dir.path());
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, bridge_state_root.path());
let session_id = Uuid::new_v4();
let remote = ClaudeWakeRemoteContext {
session_id,
envelope: ClaudeTranscriptEnvelope {
cwd: Path::new("/stale/cwd").to_path_buf(),
uuid: session_id,
claude_version: None,
entries: vec![serde_json::json!({"type": "assistant", "text": "done"})],
subagents: HashMap::new(),
todos: HashMap::new(),
},
wake_prompt: "resume prompt\n\nwake prompt".to_string(),
};
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440010".parse().unwrap();
let parent_run_id = "parent-run-456".to_string();
let command = futures::executor::block_on(ClaudeHarness::prepare_local_wake_command(
ServerApiProvider::new_for_test().get(),
task_id,
Some(parent_run_id.clone()),
Some(working_dir.clone()),
remote,
None,
))
.unwrap();
let state_dir = bridge_state_root.path().join(session_id.to_string());
let prompt_path = state_dir.join(CLAUDE_WAKE_PROMPT_FILE_NAME);
assert!(command.contains("--resume"));
assert!(command.starts_with("env "));
assert!(command.contains(&session_id.to_string()));
assert!(command.contains(CLAUDE_WAKE_PROMPT_FILE_NAME));
assert!(command.contains(&format!(
"{OZ_RUN_ID_ENV}={}",
shell_quote(&task_id.to_string())
)));
assert!(command.contains(&format!(
"{OZ_PARENT_RUN_ID_ENV}={}",
shell_quote(&parent_run_id)
)));
assert!(command.contains(&format!("{OZ_HARNESS_ENV}={}", shell_quote("claude"))));
assert!(!command.contains(OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV));
assert!(!command.contains("OZ_PARENT_LISTENER_MANAGED_EXTERNALLY"));
assert_eq!(
fs::read_to_string(&prompt_path).unwrap(),
"resume prompt\n\nwake prompt"
);
assert!(!parent_bridge_hook_output_file(&state_dir).exists());
let restored_envelope =
read_envelope(session_id, &working_dir, claude_config_dir.path()).unwrap();
assert_eq!(restored_envelope.cwd, working_dir);
assert_eq!(
restored_envelope.entries,
vec![serde_json::json!({"type": "assistant", "text": "done"})]
);
assert!(claude_config_dir
.path()
.join(CLAUDE_JSON_FILE_NAME)
.exists());
assert!(claude_config_dir
.path()
.join(CLAUDE_SETTINGS_FILE_NAME)
.exists());
std::env::remove_var("HOME");
std::env::remove_var("CLAUDE_CONFIG_DIR");
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
}
#[tokio::test]
async fn prime_parent_bridge_staged_for_self_managed_wake_keeps_message_in_staged() {
let tmp = TempDir::new().unwrap();
let state_dir = tmp.path().join("session-123");
ensure_parent_bridge_state_dir(&state_dir).unwrap();
let stale = sample_parent_bridge_message(
41,
"stale-msg",
"Old direction",
"This message should be returned to staged.",
);
write_surfaced_parent_bridge_message(&state_dir, &stale);
fs::write(parent_bridge_hook_output_file(&state_dir), "stale context").unwrap();
fs::write(parent_bridge_hook_output_ack_file(&state_dir), "").unwrap();
let wake_message = AgentMessageEventMetadata {
sequence: 42,
message_id: "msg-123".to_string(),
occurred_at: "2026-04-17T15:47:00Z".to_string(),
};
let expected = sample_parent_bridge_message(
42,
"msg-123",
"Please pivot",
"Inspect the failing tests first.",
);
let mut ai_client = MockAIClient::new();
let expected_message = expected.clone();
ai_client
.expect_read_agent_message()
.with(eq("msg-123"))
.times(1)
.returning(move |_| {
Ok(ReadAgentMessageResponse {
message_id: expected_message.message_id.clone(),
sender_run_id: expected_message.sender_run_id.clone(),
subject: expected_message.subject.clone(),
body: expected_message.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 AIClient>);
prime_parent_bridge_staged_for_self_managed_wake(&hydrator, &state_dir, Some(&wake_message))
.await
.unwrap();
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 42);
assert!(!parent_bridge_hook_output_file(&state_dir).exists());
assert!(!parent_bridge_hook_output_ack_file(&state_dir).exists());
assert!(parent_bridge_staged_message_path(&state_dir, 41, "stale-msg").exists());
assert!(!parent_bridge_surfaced_message_path(&state_dir, 41, "stale-msg").exists());
let staged_path = parent_bridge_staged_message_path(&state_dir, 42, "msg-123");
assert!(staged_path.exists());
assert!(!parent_bridge_surfaced_message_path(&state_dir, 42, "msg-123").exists());
let staged_record: MessageBridgeMessageRecord =
serde_json::from_slice(&fs::read(&staged_path).unwrap()).unwrap();
assert_eq!(staged_record.subject, expected.subject);
assert_eq!(staged_record.body, expected.body);
assert_eq!(staged_record.occurred_at, wake_message.occurred_at);
}
#[test]
#[serial_test::serial]
fn suffix_uses_worker_injected_env_when_present() {
let worker_key = "sk-ant-api03-WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW-worker-suffix!";
std::env::set_var(ANTHROPIC_API_KEY_ENV, worker_key);
// Even when the resolved map has a different value, the worker env wins.
let resolved = HashMap::from([(
OsString::from("ANTHROPIC_API_KEY"),
OsString::from(
"sk-ant-api03-RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR-resolved-val!",
),
)]);
assert_eq!(resolve_anthropic_api_key_suffix(&secrets), None);
let suffix = resolve_anthropic_api_key_suffix(&resolved);
let expected = &worker_key[worker_key.len() - 20..];
assert_eq!(suffix.as_deref(), Some(expected));
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
}
#[test]
@@ -15,7 +15,8 @@
//! 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::fs::{create_dir_all, write};
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
@@ -24,6 +25,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use super::json_utils::entries_to_jsonl;
use crate::ai::agent::conversation::AIConversationId;
/// JSON envelope sent to the server representing a complete Claude Code session.
@@ -66,6 +68,11 @@ pub(crate) struct ClaudeResumeInfo {
pub(crate) envelope: ClaudeTranscriptEnvelope,
}
#[derive(Debug)]
pub(crate) struct ClaudeLocalContinuation {
pub(crate) command: String,
}
/// Encode a filesystem path as a Claude config directory name, matching the
/// Claude CLI convention of replacing every `/` with `-`.
///
@@ -83,11 +90,23 @@ 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()
home_dir_for_claude_config()
.map(|h| h.join(".claude"))
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
}
/// In tests on Windows, `dirs::home_dir()` ignores `HOME`, so we check it
/// manually so that tests can override the home directory.
pub(super) fn home_dir_for_claude_config() -> Option<PathBuf> {
#[cfg(test)]
if let Some(home) = std::env::var_os("HOME") {
if !home.is_empty() {
return Some(PathBuf::from(home));
}
}
dirs::home_dir()
}
/// Assemble a [`ClaudeTranscriptEnvelope`] from the Claude config directory.
///
/// Reads:
@@ -184,12 +203,12 @@ pub(crate) fn write_envelope(
) -> Result<()> {
let encoded = encode_cwd(&envelope.cwd);
let projects_dir = config_root.join("projects").join(&encoded);
std::fs::create_dir_all(&projects_dir)
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)?)
write(&session_file, entries_to_jsonl(&envelope.entries)?)
.with_context(|| format!("Failed to write {}", session_file.display()))?;
// Subagent JSONLs.
@@ -197,11 +216,11 @@ pub(crate) fn write_envelope(
let subagents_dir = projects_dir
.join(envelope.uuid.to_string())
.join("subagents");
std::fs::create_dir_all(&subagents_dir)
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)?)
write(&path, entries_to_jsonl(entries)?)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
@@ -209,11 +228,11 @@ pub(crate) fn write_envelope(
// Per-agent todo lists.
if !envelope.todos.is_empty() {
let todos_dir = config_root.join("todos");
std::fs::create_dir_all(&todos_dir)
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)?)
write(&path, serde_json::to_vec(value)?)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
@@ -221,10 +240,108 @@ pub(crate) fn write_envelope(
Ok(())
}
pub(crate) fn rehydrate_claude_transcript(
envelope: &mut ClaudeTranscriptEnvelope,
local_cwd: &Path,
) -> Result<ClaudeLocalContinuation> {
envelope.cwd = local_cwd.to_path_buf();
let session_id = envelope.uuid;
let config_root = claude_config_dir().context("Failed to resolve Claude config dir")?;
write_envelope(envelope, &config_root).context("Failed to rehydrate Claude transcript")?;
if let Err(e) = write_session_index_entry(session_id, local_cwd, &config_root) {
log::warn!("Failed to update Claude sessions-index.json: {e:#}");
}
Ok(ClaudeLocalContinuation {
command: format!("claude --resume {session_id}"),
})
}
/// Write a [`ClaudeTranscriptEnvelope`] to a project directory derived from `storage_cwd`,
/// without mutating `envelope.cwd`.
///
/// Used by the local continuation path so the transcript's recorded working directory
/// (the original cloud cwd) is preserved as-is while the file is placed under
/// `~/.claude/projects/<encoded(storage_cwd)>/` where Claude's per-project lookup can find it.
/// Cloud resume uses [`write_envelope`] instead, which derives the path from `envelope.cwd`.
///
/// Creates:
/// - `<config_root>/projects/<encoded(storage_cwd)>/<uuid>.jsonl` — main transcript
/// - `<config_root>/projects/<encoded(storage_cwd)>/<uuid>/subagents/<stem>.jsonl` — subagents
/// - `<config_root>/todos/<stem>.json` — per-agent todo lists (same location as cloud resume)
pub(crate) fn write_envelope_for_local_continuation(
envelope: &ClaudeTranscriptEnvelope,
storage_cwd: &Path,
config_root: &Path,
) -> Result<()> {
let projects_dir = config_root.join("projects").join(encode_cwd(storage_cwd));
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));
write(&session_file, entries_to_jsonl(&envelope.entries)?)
.with_context(|| format!("Failed to write {}", session_file.display()))?;
// Subagent JSONLs — same relative layout as write_envelope.
if !envelope.subagents.is_empty() {
let subagents_dir = projects_dir
.join(envelope.uuid.to_string())
.join("subagents");
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"));
write(&path, entries_to_jsonl(entries)?)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
// Per-agent todo lists are written to the same global location as cloud resume.
if !envelope.todos.is_empty() {
let todos_dir = config_root.join("todos");
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"));
write(&path, serde_json::to_vec(value)?)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
}
Ok(())
}
/// Rehydrate a Claude transcript downloaded from a remote cloud run for local continuation.
///
/// Unlike [`rehydrate_claude_transcript`] (used by the cloud resume harness runner), this
/// function does **not** mutate the envelope's `cwd` field — the remote session's original
/// working directory is preserved as-is in the transcript. The session file is stored under
/// `~/.claude/projects/<encoded(home_dir)>/` so Claude's per-project session lookup finds it
/// when the user runs `claude --resume <uuid>` from their home directory.
pub(crate) fn rehydrate_claude_transcript_from_reader(
reader: impl Read,
) -> Result<ClaudeLocalContinuation> {
let envelope: ClaudeTranscriptEnvelope =
serde_json::from_reader(reader).context("Failed to parse Claude transcript envelope")?;
let session_id = envelope.uuid;
let config_root = claude_config_dir().context("Failed to resolve Claude config dir")?;
let home_dir = home_dir_for_claude_config()
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
write_envelope_for_local_continuation(&envelope, &home_dir, &config_root)
.context("Failed to rehydrate Claude transcript for local continuation")?;
if let Err(e) = write_session_index_entry(session_id, &home_dir, &config_root) {
log::warn!("Failed to update Claude sessions-index.json: {e:#}");
}
Ok(ClaudeLocalContinuation {
command: format!("claude --resume {session_id}"),
})
}
/// 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
/// 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
@@ -243,7 +360,7 @@ pub(crate) fn write_session_index_entry(
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.
// we'd rather clobber an unparsable 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,
@@ -281,10 +398,9 @@ pub(crate) fn write_session_index_entry(
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()))?;
create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
}
std::fs::write(
write(
&index_path,
serde_json::to_vec_pretty(&Value::Object(index))
.context("Failed to serialize sessions-index.json")?,
@@ -293,16 +409,6 @@ pub(crate) fn write_session_index_entry(
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
@@ -0,0 +1,939 @@
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
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 galaxy_core::features::FeatureFlag;
use warp_managed_secrets::ManagedSecretValue;
use warpui::{ModelHandle, ModelSpawner, SingletonEntity};
use super::super::terminal::{CommandHandle, TerminalDriver};
use super::super::{AgentDriver, AgentDriverError};
use super::claude_transcript::read_jsonl;
use super::codex_transcript::{
codex_sessions_root, find_session_file, parse_session_meta, rehydrate_codex_transcript,
CodexResumeInfo, CodexTranscriptEnvelope,
};
use super::json_utils::read_json_file_or_default;
use super::{
write_temp_file, HarnessRunner, JSONMCPServer, ResumePayload, SavePoint, ThirdPartyHarness,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_sdk::setup_observability::{
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
};
use crate::ai::ambient_agents::task::HarnessModelConfig;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::mcp::JSONTransportType;
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
use crate::server::server_api::ServerApi;
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::terminal::model::block::BlockId;
use crate::terminal::CLIAgent;
pub(crate) struct CodexHarness;
/// Format slug sent to the server when creating a Codex conversation.
const CODEX_CLI_FORMAT: &str = "codex_cli";
/// Slash command Codex's TUI recognises as a graceful shutdown.
const CODEX_EXIT_COMMAND: &str = "/exit";
/// Allow the Warp-installed Codex plugin hooks to run in vetted driver sessions
/// without requiring an unattended `/hooks` review step.
const CODEX_BYPASS_HOOK_TRUST_FLAG: &str = "--dangerously-bypass-hook-trust";
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl ThirdPartyHarness for CodexHarness {
fn harness(&self) -> Harness {
Harness::Codex
}
fn cli_agent(&self) -> CLIAgent {
CLIAgent::Codex
}
fn install_docs_url(&self) -> Option<&'static str> {
Some("https://developers.openai.com/codex/cli")
}
fn auth_check_command(&self) -> Option<String> {
let cli = self.cli_agent().command_prefix();
Some(format!("{cli} login status"))
}
fn runtime_error_patterns(&self) -> &'static [&'static str] {
&[
// Quota / billing.
"Quota exceeded. Check your plan and billing details.",
"You've hit your usage limit",
// Upstream HTTP failures Codex surfaces verbatim. The 401 form
// matches invalid-API-key and wrong-endpoint variants.
"unexpected status 401",
"Incorrect API key provided",
"invalid API key",
// Region/endpoint block (Anthropic-style global vs US-only
// routing surfaced through Codex's upstream client).
"Access blocked by Cloudflare",
// OAuth refresh failures — all five Codex variants share this
// substring (see upstream session/token messages).
"could not be refreshed",
]
}
fn requires_verified_platform_plugin(&self) -> bool {
FeatureFlag::CodexPlugin.is_enabled()
}
/// Fetch the codex transcript for the current task's conversation and wrap it into a
/// [`ResumePayload::Codex`].
async fn fetch_resume_payload(
&self,
conversation_id: &AIConversationId,
harness_support_client: Arc<dyn HarnessSupportClient>,
) -> Result<Option<ResumePayload>, AgentDriverError> {
let envelope: CodexTranscriptEnvelope =
super::fetch_transcript_envelope("codex", conversation_id, harness_support_client)
.await?;
let session_id = envelope.session_id;
Ok(Some(ResumePayload::Codex(CodexResumeInfo {
conversation_id: *conversation_id,
session_id,
envelope,
})))
}
fn build_runner(
&self,
prompt: &str,
system_prompt: Option<&str>,
resumption_prompt: Option<&str>,
context: Option<&str>,
working_dir: &Path,
_task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ResumePayload>,
resolved_env_vars: &HashMap<OsString, OsString>,
resolved_secrets: &HashMap<String, ManagedSecretValue>,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
// Prepare the environment config files.
prepare_codex_environment_config(
working_dir,
system_prompt,
resolved_env_vars,
resolved_secrets,
resolved_mcp_servers,
third_party_harness_model_config,
)
.map_err(|error| AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
})?;
// The ResumePayload shouldn't contain non-Codex information, error if it does.
let codex_resume = resume.map(CodexResumeInfo::try_from).transpose()?;
// Mirror Claude harness behavior: prepend the resumption preamble and server context
// to the user-turn prompt so codex treats it as immediate intent.
// Order: resumption_prompt → context → prompt
let mut parts: Vec<&str> = Vec::new();
if let Some(preamble) = resumption_prompt {
if !preamble.is_empty() {
parts.push(preamble);
}
}
if let Some(ctx) = context {
if !ctx.is_empty() {
parts.push(ctx);
}
}
parts.push(prompt);
let owned_prompt = parts.join("\n\n");
let client: Arc<dyn HarnessSupportClient> = server_api;
Ok(Box::new(CodexHarnessRunner::new(
self.cli_agent().command_prefix(),
&owned_prompt,
system_prompt,
working_dir,
client,
terminal_driver,
codex_resume,
)?))
}
}
/// Build the shell command that launches the Codex TUI.
///
/// `--dangerously-bypass-approvals-and-sandbox` disables both the sandbox and approval
/// prompts so the agent can run autonomously.
/// `--dangerously-bypass-hook-trust` allows the orchestration plugin hooks installed by
/// Warp to run without a manual hook review in unattended driver sessions. Driver setup
/// verifies the Codex platform plugin before launching commands with this flag.
/// `Some(session_id)` indicates that we want to resume that prior session. Unlike claude,
/// codex does not support assigning a session_id to a new conversation.
fn codex_command(cli_name: &str, session_id: Option<&Uuid>, prompt_path: &str) -> String {
match session_id {
Some(session_id) => format!(
"{cli_name} resume --dangerously-bypass-approvals-and-sandbox {CODEX_BYPASS_HOOK_TRUST_FLAG} {session_id} \
\"$(cat '{prompt_path}')\""
),
None => {
format!(
"{cli_name} --dangerously-bypass-approvals-and-sandbox {CODEX_BYPASS_HOOK_TRUST_FLAG} \"$(cat '{prompt_path}')\""
)
}
}
}
enum CodexRunnerState {
Preexec,
Running {
conversation_id: AIConversationId,
block_id: BlockId,
},
}
struct CodexHarnessRunner {
command: String,
cli_name: 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<CodexRunnerState>,
/// Codex session UUID. Populated lazily by [`HarnessRunner::handle_session_update`]
/// once the codex hooks emit `SessionStart`. Set once (using `OnceLock`).
session_id: OnceLock<Uuid>,
/// Path to the codex session rollout JSONL file. Populated by the first
/// successful [`find_session_file`] walk so that subsequent saves skip the YYYY/MM/DD
/// directory walk and read the JSONL file directly.
transcript_path: OnceLock<PathBuf>,
/// Optionally supply an existing conversation ID.
preexisting_conversation_id: Option<AIConversationId>,
}
impl CodexHarnessRunner {
#[allow(clippy::too_many_arguments)]
fn new(
cli_command: &str,
prompt: &str,
_system_prompt: Option<&str>,
_working_dir: &Path,
client: Arc<dyn HarnessSupportClient>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<CodexResumeInfo>,
) -> Result<Self, AgentDriverError> {
let temp_file = write_temp_file("oz_prompt_", prompt, ".txt")?;
let prompt_path = temp_file.path().display().to_string();
let (session_id, preexisting_conversation_id, transcript_path) = match resume {
Some(CodexResumeInfo {
conversation_id,
session_id,
mut envelope,
}) => {
let continuation = rehydrate_codex_transcript(&mut envelope, _working_dir)
.map_err(AgentDriverError::ConfigBuildFailed)?;
(
Some(session_id),
Some(conversation_id),
Some(continuation.transcript_path),
)
}
None => (None, None, None),
};
let command = codex_command(cli_command, session_id.as_ref(), &prompt_path);
let session_id_cell: OnceLock<Uuid> = OnceLock::new();
if let Some(id) = session_id {
let _ = session_id_cell.set(id);
}
let transcript_path_cell: OnceLock<PathBuf> = OnceLock::new();
if let Some(p) = transcript_path {
let _ = transcript_path_cell.set(p);
}
Ok(Self {
command,
cli_name: cli_command.to_string(),
_temp_prompt_file: temp_file,
client,
terminal_driver,
state: Mutex::new(CodexRunnerState::Preexec),
session_id: session_id_cell,
transcript_path: transcript_path_cell,
preexisting_conversation_id,
})
}
/// Return the filepath for the session transcript, walking the codex sessions tree to find it on the
/// first save call.
async fn resolve_transcript_path(&self) -> Option<PathBuf> {
if let Some(cached) = self.transcript_path.get() {
return Some(cached.clone());
}
let session_id = self.session_id.get().copied()?;
let resolved = tokio::task::spawn_blocking(move || -> Option<PathBuf> {
let root = codex_sessions_root().ok()?;
find_session_file(&root, session_id)
})
.await
.ok()
.flatten()?;
let _ = self.transcript_path.set(resolved.clone());
Some(resolved)
}
}
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl HarnessRunner for CodexHarnessRunner {
fn harness_name(&self) -> &str {
&self.cli_name
}
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
setup_events: &SetupClientEventReporter,
) -> Result<CommandHandle, AgentDriverError> {
// Resume runs reuse the prior server conversation id; fresh runs mint a new one.
let conversation_id = match self.preexisting_conversation_id {
Some(id) => {
log::info!("Resuming external conversation {id}");
id
}
None => {
let id = setup_events
.record_result(SetupStep::ThirdPartyHarnessExternalConversation, async {
self.client
.create_external_conversation(CODEX_CLI_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})
})
.await?;
log::info!("Created external conversation {id}");
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?;
*self.state.lock() = CodexRunnerState::Running {
conversation_id,
block_id: command_handle.block_id().clone(),
};
setup_events
.post_timeline_event(OzRunTimelineEvent::AgentStarted)
.await;
Ok(command_handle)
}
async fn exit(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
log::info!("Sending /exit to Codex CLI");
let terminal_driver = self.terminal_driver.clone();
foreground
.spawn(move |_, ctx| {
terminal_driver.update(ctx, |driver, ctx| {
driver.send_text_to_cli(CODEX_EXIT_COMMAND.to_string(), ctx);
});
})
.await
.map_err(|_| anyhow::anyhow!("Agent driver dropped while sending /exit"))
}
/// Capture the codex session ID from the `SessionStart` event picked up by the `CLIAgentSessionsModel`.
///
/// Relies on codex hooks being set up to emit this event correctly.
async fn handle_session_update(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
if self.session_id.get().is_some() {
return Ok(());
}
let terminal_driver = self.terminal_driver.clone();
let session_id_str = foreground
.spawn(move |_, ctx| {
let terminal_view_id = terminal_driver.as_ref(ctx).terminal_view().id();
CLIAgentSessionsModel::handle(ctx)
.as_ref(ctx)
.session(terminal_view_id)
.and_then(|s| s.session_context.session_id.clone())
})
.await
.ok()
.flatten();
let Some(session_id_str) = session_id_str else {
return Ok(());
};
match Uuid::parse_str(&session_id_str) {
Ok(uuid) => {
log::info!("Captured codex session id {uuid}");
let _ = self.session_id.set(uuid);
}
Err(e) => log::warn!("Failed to parse codex session id '{session_id_str}': {e}"),
}
Ok(())
}
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, Codex not in progress");
return Ok(());
}
let (conversation_id, block_id) = match &*self.state.lock() {
CodexRunnerState::Preexec => {
log::warn!("save_conversation called before start");
return Ok(());
}
CodexRunnerState::Running {
conversation_id,
block_id,
} => (*conversation_id, block_id.clone()),
};
let session_id = self.session_id.get().copied();
let rollout_path = self.resolve_transcript_path().await;
let client = self.client.as_ref();
let is_final = matches!(save_point, SavePoint::Final);
futures::try_join!(
super::upload_current_block_snapshot(
foreground,
&self.terminal_driver,
client,
conversation_id,
block_id,
),
upload_transcript(client, conversation_id, session_id, rollout_path, is_final),
)?;
Ok(())
}
}
/// Upload the codex session transcript to the server. No-ops if the session UUID hasn't
/// been captured yet or no rollout file is on disk yet.
async fn upload_transcript(
client: &dyn HarnessSupportClient,
conversation_id: AIConversationId,
session_id: Option<Uuid>,
transcript_path: Option<PathBuf>,
is_final: bool,
) -> Result<()> {
let Some(session_id) = session_id else {
if is_final {
log::warn!(
"Codex session id still unknown at final save; transcript was never uploaded"
);
} else {
log::debug!("Codex session id not yet known; skipping transcript upload");
}
return Ok(());
};
let Some(transcript_path) = transcript_path else {
if is_final {
log::warn!(
"No codex rollout file found at final save for session {session_id}; transcript was never uploaded"
);
} else {
log::debug!("No codex rollout file yet for session {session_id}");
}
return Ok(());
};
log::info!("Uploading codex transcript to conversation {conversation_id}");
let body = tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
let entries = read_jsonl(&transcript_path)?;
let metadata = parse_session_meta(entries.first()).unwrap_or_default();
let envelope = CodexTranscriptEnvelope::new(session_id, metadata, entries);
serde_json::to_vec(&envelope).context("Failed to serialize codex transcript")
})
.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?;
Ok(())
}
const CODEX_CONFIG_DIR: &str = ".codex";
const CODEX_HOME_ENV: &str = "CODEX_HOME";
const CODEX_AGENTS_OVERRIDE_FILE_NAME: &str = "AGENTS.override.md";
const CODEX_AUTH_FILE_NAME: &str = "auth.json";
const CODEX_CONFIG_TOML_FILE_NAME: &str = "config.toml";
const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
const CODEX_AUTH_MODE_API_KEY: &str = "apikey";
/// Lowercase string Codex's `TrustLevel` enum serializes to (codex
/// `protocol/src/config_types.rs::TrustLevel`).
const CODEX_TRUST_LEVEL_TRUSTED: &str = "trusted";
/// Top-level config key codex reads to override the built-in `openai` provider's base URL
/// (codex `core/src/config/mod.rs`).
const CODEX_OPENAI_BASE_URL_KEY: &str = "openai_base_url";
const CODEX_CHECK_FOR_UPDATE_ON_STARTUP_KEY: &str = "check_for_update_on_startup";
const CODEX_MODEL_KEY: &str = "model";
const CODEX_MODEL_REASONING_EFFORT_KEY: &str = "model_reasoning_effort";
/// Target model for the `[notice.model_migrations]` table that suppresses Codex's
/// "choose a newer model" upgrade prompt at session launch. We stamp this for any
/// pinned model id (even when it already matches the target) so the unattended
/// cloud run never blocks on the prompt.
///
/// TODO: Ideally, we would make this server-driven so we don't depend on a client
/// release to change this.
const CODEX_MODEL_MIGRATIONS_TARGET: &str = "gpt-5.4";
fn prepare_codex_environment_config(
working_dir: &Path,
system_prompt: Option<&str>,
resolved_env_vars: &HashMap<OsString, OsString>,
resolved_secrets: &HashMap<String, ManagedSecretValue>,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> Result<()> {
let codex_dir = codex_config_dir()?;
if let Some(prompt) = system_prompt {
write_codex_agents_override(&codex_dir, prompt)?;
}
match resolve_openai_api_key(resolved_env_vars) {
Some(api_key) => prepare_codex_auth(&codex_dir.join(CODEX_AUTH_FILE_NAME), &api_key)?,
None => log::info!("No OPENAI_API_KEY available; skipping Codex auth.json seed"),
}
// Resolve the base URL directly from the typed OpenAI secret. This avoids
// leaking base_url into the child process environment and ensures we only
// apply it when the typed secret is the active API key source.
let openai_base_url = resolve_openai_base_url_from_secret(resolved_secrets, resolved_env_vars);
prepare_codex_config_toml(
&codex_dir.join(CODEX_CONFIG_TOML_FILE_NAME),
working_dir,
resolved_mcp_servers,
third_party_harness_model_config,
openai_base_url.as_deref(),
)?;
Ok(())
}
fn codex_config_dir() -> Result<PathBuf> {
if let Ok(dir) = std::env::var(CODEX_HOME_ENV) {
if !dir.is_empty() {
return Ok(PathBuf::from(dir));
}
}
dirs::home_dir()
.map(|home| home.join(CODEX_CONFIG_DIR))
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
}
fn write_codex_agents_override(codex_dir: &Path, system_prompt: &str) -> Result<()> {
fs::create_dir_all(codex_dir).with_context(|| {
format!(
"Failed to create Codex config dir at {}",
codex_dir.display()
)
})?;
// Note: this currently works because we are only doing this for cloud agents; if we enable
// this for local runs we'll want to make sure we don't clobber any existing file overrides.
let prompt_path = codex_dir.join(CODEX_AGENTS_OVERRIDE_FILE_NAME);
fs::write(&prompt_path, system_prompt).with_context(|| {
format!(
"Failed to write Codex system prompt to {}",
prompt_path.display()
)
})
}
/// Mirrors the subset of Codex's `AuthDotJson` (codex `login/src/auth/storage.rs`) that we
/// need to seed. Unknown fields (`tokens`, `last_refresh`, `agent_identity`, ...) are
/// preserved via `extra` so we don't clobber an existing login.
#[derive(Default, Deserialize, Serialize, Debug)]
struct CodexAuthDotJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
auth_mode: Option<String>,
#[serde(
rename = "OPENAI_API_KEY",
default,
skip_serializing_if = "Option::is_none"
)]
openai_api_key: Option<String>,
#[serde(flatten)]
extra: Map<String, Value>,
}
fn prepare_codex_auth(auth_path: &Path, api_key: &str) -> Result<()> {
let mut auth: CodexAuthDotJson = read_json_file_or_default(auth_path)?;
auth.openai_api_key = Some(api_key.to_owned());
if auth.auth_mode.is_none() {
auth.auth_mode = Some(CODEX_AUTH_MODE_API_KEY.to_owned());
}
write_codex_auth_json(auth_path, &auth)
}
/// Write Codex's `auth.json` with restrictive (0o600) permissions, mirroring how
/// codex sets up this file itself.
fn write_codex_auth_json(path: &Path, auth: &CodexAuthDotJson) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let bytes = serde_json::to_vec_pretty(auth).context("Failed to serialize Codex auth.json")?;
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("Failed to open {} for writing", path.display()))?;
file.set_permissions(fs::Permissions::from_mode(0o600))
.with_context(|| format!("Failed to set permissions on {}", path.display()))?;
file.write_all(&bytes)
.with_context(|| format!("Failed to write {}", path.display()))?;
}
#[cfg(not(unix))]
fs::write(path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
/// Returns the OpenAI API key for Codex auth.
///
/// Checks the worker-injected process env first (not in the resolved map since
/// `build_secret_env_vars` skips env vars already present in the process env),
/// then falls back to the resolved secret env vars map.
fn resolve_openai_api_key(resolved_env_vars: &HashMap<OsString, OsString>) -> Option<String> {
// Worker-injected process env wins.
if let Ok(value) = std::env::var(OPENAI_API_KEY_ENV) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
}
// Otherwise use the resolved value from the secrets map.
resolved_env_vars
.get(OsStr::new(OPENAI_API_KEY_ENV))
.and_then(|v| v.to_str())
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
/// Returns the OpenAI base URL from the typed secret, if applicable.
///
/// The base URL is only used when the typed `OpenaiApiKey` secret is the active
/// source of `OPENAI_API_KEY`. If a worker-injected process env already provides
/// the API key, the typed-secret base URL is not applied (the worker controls
/// both the key and endpoint).
fn resolve_openai_base_url_from_secret(
secrets: &HashMap<String, ManagedSecretValue>,
resolved_env_vars: &HashMap<OsString, OsString>,
) -> Option<String> {
// If the worker already injected an API key, the typed secret lost
// precedence — do not apply its base URL.
if std::env::var(OPENAI_API_KEY_ENV)
.ok()
.is_some_and(|v| !v.trim().is_empty())
{
return None;
}
// Only apply when the resolved env vars actually contain OPENAI_API_KEY
// from the typed secret (i.e. the secret was not skipped).
resolved_env_vars.get(OsStr::new(OPENAI_API_KEY_ENV))?;
secrets.values().find_map(|secret| match secret {
ManagedSecretValue::OpenaiApiKey { base_url, .. } => base_url
.as_ref()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty()),
_ => None,
})
}
/// Edit `~/.codex/config.toml` via `toml_edit` to seed the harness defaults
/// while preserving anything that might already exist there. We handle:
/// - project trust: for a working dir and all of its git repo subdirectories,
/// set the projects to `trusted`.
/// - base URL: when `openai_base_url` is provided (from the secret's `base_url`
/// field), write it to config.toml. When absent, skip the key entirely so
/// Codex uses the provider's default global endpoint.
/// - update checks: disable Codex's startup update prompt for unattended runs.
/// - model override: when a non-default harness model config is
/// supplied, write the top-level `model` key so Codex pins the chosen model
/// for new sessions.
fn prepare_codex_config_toml(
config_toml_path: &Path,
working_dir: &Path,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
third_party_harness_model_config: Option<&HarnessModelConfig>,
openai_base_url: Option<&str>,
) -> Result<()> {
let existing = match fs::read_to_string(config_toml_path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(anyhow::Error::from(e).context(format!(
"Failed to read Codex config.toml at {}",
config_toml_path.display()
)));
}
};
let mut doc: toml_edit::DocumentMut = existing.parse().with_context(|| {
format!(
"Failed to parse Codex config.toml at {}",
config_toml_path.display()
)
})?;
// Only write openai_base_url when the secret specifies one.
if let Some(url) = openai_base_url {
set_codex_openai_base_url(&mut doc, url);
}
set_codex_check_for_update_on_startup(&mut doc, false);
set_codex_model(&mut doc, third_party_harness_model_config);
set_codex_model_reasoning_effort(&mut doc, third_party_harness_model_config);
let canonical = working_dir.canonicalize().with_context(|| {
format!(
"Failed to canonicalize Codex working dir at {}",
working_dir.display()
)
})?;
let project_key = canonical.to_string_lossy().into_owned();
set_codex_project_trust_level(&mut doc, &project_key, CODEX_TRUST_LEVEL_TRUSTED);
// Codex's trust check is not recursive (see openai/codex#19426) -- since we
// clone the git repos into workspace/ for cloud agents, we usually have git
// repo children that we also want to trust.
for child_repo in find_child_git_repos(&canonical) {
let key = child_repo.to_string_lossy().into_owned();
set_codex_project_trust_level(&mut doc, &key, CODEX_TRUST_LEVEL_TRUSTED);
}
write_codex_mcp_servers(&mut doc, resolved_mcp_servers);
if let Some(parent) = config_toml_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create Codex config dir at {}", parent.display())
})?;
}
fs::write(config_toml_path, doc.to_string()).with_context(|| {
format!(
"Failed to write Codex config.toml at {}",
config_toml_path.display()
)
})
}
/// Set the top-level `openai_base_url` key, overwriting any existing value.
fn set_codex_openai_base_url(doc: &mut toml_edit::DocumentMut, base_url: &str) {
doc[CODEX_OPENAI_BASE_URL_KEY] = toml_edit::value(base_url);
}
fn set_codex_check_for_update_on_startup(doc: &mut toml_edit::DocumentMut, enabled: bool) {
doc[CODEX_CHECK_FOR_UPDATE_ON_STARTUP_KEY] = toml_edit::value(enabled);
}
fn set_codex_model_reasoning_effort(
doc: &mut toml_edit::DocumentMut,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) {
let Some(reasoning_level) = third_party_harness_model_config
.and_then(|config| config.reasoning_level.as_deref())
.filter(|level| !level.is_empty())
else {
doc.remove(CODEX_MODEL_REASONING_EFFORT_KEY);
return;
};
doc[CODEX_MODEL_REASONING_EFFORT_KEY] = toml_edit::value(reasoning_level);
}
fn set_codex_model(
doc: &mut toml_edit::DocumentMut,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) {
let Some(model_id) = third_party_harness_model_config
.map(|config| config.model_id.as_str())
.filter(|id| !id.is_empty() && *id != "default")
else {
// No model specified or "default" selected — remove any pre-existing
// key so Codex uses its own default.
doc.remove(CODEX_MODEL_KEY);
return;
};
doc[CODEX_MODEL_KEY] = toml_edit::value(model_id);
// Codex's TUI prompts the user to upgrade older models on session launch even when
// a `model` key has been pinned. Stamping a migration entry keyed on the chosen
// model id suppresses that prompt for the unattended cloud run. We do this
// unconditionally rather than enumerating a list of "old" models on the client:
// mapping the migration target to itself (e.g. `gpt-5.4 = "gpt-5.4"`) is a no-op
// for Codex, and keeping the client free of model-version knowledge means we
// don't have to ship a client update every time Anthropic/OpenAI ages out a model.
set_codex_model_migration(doc, model_id, CODEX_MODEL_MIGRATIONS_TARGET);
}
fn set_codex_model_migration(
doc: &mut toml_edit::DocumentMut,
from_model_id: &str,
to_model_id: &str,
) {
if !doc.contains_table("notice") {
let mut notice_tbl = toml_edit::Table::new();
notice_tbl.set_implicit(true);
doc.insert("notice", toml_edit::Item::Table(notice_tbl));
}
let migrations_tbl = doc["notice"]
.as_table_mut()
.expect("notice table inserted above")
.entry("model_migrations")
.or_insert_with(toml_edit::table)
.as_table_mut()
.expect("model_migrations entry is a table");
migrations_tbl.set_implicit(false);
migrations_tbl[from_model_id] = toml_edit::value(to_model_id);
}
/// Return immediate subdirectories of `dir` that contain a `.git`.
fn find_child_git_repos(dir: &Path) -> Vec<std::path::PathBuf> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
(path.is_dir() && path.join(".git").exists()).then_some(path)
})
.collect()
}
/// Insert/update `[projects."<project_key>"] trust_level = <trust_level>`.
///
/// Codex itself always writes `projects` as an explicit table, so we don't
/// handle the inline-table form here.
fn set_codex_project_trust_level(
doc: &mut toml_edit::DocumentMut,
project_key: &str,
trust_level: &str,
) {
if !doc.contains_table("projects") {
let mut projects_tbl = toml_edit::Table::new();
projects_tbl.set_implicit(true);
doc.insert("projects", toml_edit::Item::Table(projects_tbl));
}
let proj_tbl = doc["projects"]
.as_table_mut()
.expect("projects table inserted above")
.entry(project_key)
.or_insert_with(toml_edit::table)
.as_table_mut()
.expect("project entry is a table");
proj_tbl.set_implicit(false);
proj_tbl["trust_level"] = toml_edit::value(trust_level);
}
/// Write resolved MCP servers into `[mcp_servers.<name>]` sections in the Codex config.
fn write_codex_mcp_servers(
doc: &mut toml_edit::DocumentMut,
servers: &HashMap<String, JSONMCPServer>,
) {
if servers.is_empty() {
return;
}
if !doc.contains_table("mcp_servers") {
let mut tbl = toml_edit::Table::new();
tbl.set_implicit(true);
doc.insert("mcp_servers", toml_edit::Item::Table(tbl));
}
let mcp_tbl = doc["mcp_servers"]
.as_table_mut()
.expect("mcp_servers table inserted above");
for (name, server) in servers {
let entry = mcp_tbl
.entry(name)
.or_insert_with(toml_edit::table)
.as_table_mut()
.expect("mcp_servers entry is a table");
entry.set_implicit(false);
match &server.transport_type {
JSONTransportType::CLIServer {
command,
args,
env,
working_directory,
} => {
entry["command"] = toml_edit::value(command.as_str());
if !args.is_empty() {
let mut arr = toml_edit::Array::new();
for arg in args {
arr.push(arg.as_str());
}
entry["args"] = toml_edit::value(arr);
}
if !env.is_empty() {
let mut env_tbl = toml_edit::InlineTable::new();
for (k, v) in env {
env_tbl.insert(k, v.as_str().into());
}
entry["env"] = toml_edit::value(env_tbl);
}
if let Some(cwd) = working_directory {
entry["cwd"] = toml_edit::value(cwd.as_str());
}
}
JSONTransportType::SSEServer { url, headers } => {
entry["url"] = toml_edit::value(url.as_str());
if !headers.is_empty() {
let mut hdrs_tbl = toml_edit::InlineTable::new();
for (k, v) in headers {
hdrs_tbl.insert(k, v.as_str().into());
}
entry["http_headers"] = toml_edit::value(hdrs_tbl);
}
}
}
}
}
#[cfg(test)]
#[path = "codex_tests.rs"]
mod tests;
@@ -0,0 +1,849 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::fs;
use std::sync::Arc;
use serde_json::Value;
use tempfile::TempDir;
use uuid::Uuid;
use super::super::codex_transcript::CodexTranscriptEnvelope;
use super::*;
use crate::ai::agent::conversation::AIConversationId;
use crate::server::server_api::harness_support::MockHarnessSupportClient;
#[test]
fn prepare_codex_auth_writes_fresh_file_with_api_key_mode() {
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join(".codex/auth.json");
prepare_codex_auth(&auth_path, "sk-test-key").unwrap();
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
assert_eq!(auth["OPENAI_API_KEY"], "sk-test-key");
assert_eq!(auth["auth_mode"], "apikey");
}
#[test]
fn prepare_codex_auth_preserves_unrelated_fields() {
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join("auth.json");
fs::write(
&auth_path,
r#"{"tokens":{"access_token":"tok"},"last_refresh":"2026-01-01T00:00:00Z"}"#,
)
.unwrap();
prepare_codex_auth(&auth_path, "sk-new-key").unwrap();
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
assert_eq!(auth["OPENAI_API_KEY"], "sk-new-key");
assert_eq!(auth["auth_mode"], "apikey");
assert_eq!(auth["tokens"]["access_token"], "tok");
assert_eq!(auth["last_refresh"], "2026-01-01T00:00:00Z");
}
#[test]
fn prepare_codex_auth_does_not_overwrite_existing_auth_mode() {
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join("auth.json");
fs::write(&auth_path, r#"{"auth_mode":"Chatgpt"}"#).unwrap();
prepare_codex_auth(&auth_path, "sk-new-key").unwrap();
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
assert_eq!(auth["auth_mode"], "Chatgpt");
assert_eq!(auth["OPENAI_API_KEY"], "sk-new-key");
}
#[test]
fn prepare_codex_auth_overwrites_stale_openai_api_key() {
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join("auth.json");
fs::write(
&auth_path,
r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-old"}"#,
)
.unwrap();
prepare_codex_auth(&auth_path, "sk-new").unwrap();
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
assert_eq!(auth["OPENAI_API_KEY"], "sk-new");
}
#[cfg(unix)]
#[test]
fn prepare_codex_auth_writes_with_0600_perms() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let auth_path = tmp.path().join(".codex/auth.json");
prepare_codex_auth(&auth_path, "sk-test-key").unwrap();
let mode = fs::metadata(&auth_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn resolve_openai_api_key_returns_value_from_resolved_map() {
let resolved = HashMap::from([(
OsString::from("OPENAI_API_KEY"),
OsString::from("sk-from-secret"),
)]);
assert_eq!(
resolve_openai_api_key(&resolved).as_deref(),
Some("sk-from-secret")
);
}
#[test]
#[serial_test::serial]
fn resolve_openai_api_key_falls_back_to_env_var() {
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::set_var(OPENAI_API_KEY_ENV, "sk-from-env");
let result = resolve_openai_api_key(&HashMap::new());
match prev {
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
None => std::env::remove_var(OPENAI_API_KEY_ENV),
}
assert_eq!(result.as_deref(), Some("sk-from-env"));
}
#[test]
#[serial_test::serial]
fn resolve_openai_api_key_returns_none_when_map_and_env_empty() {
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::remove_var(OPENAI_API_KEY_ENV);
let result = resolve_openai_api_key(&HashMap::new());
if let Some(v) = prev {
std::env::set_var(OPENAI_API_KEY_ENV, v);
}
assert_eq!(result, None);
}
#[test]
#[serial_test::serial]
fn resolve_openai_api_key_prefers_env_over_resolved_map() {
// Worker-injected env var wins over the resolved secret map because
// build_secret_env_vars skips secrets that collide with process env.
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::set_var(OPENAI_API_KEY_ENV, "sk-from-env");
let resolved = HashMap::from([(
OsString::from("OPENAI_API_KEY"),
OsString::from("sk-from-secret"),
)]);
let result = resolve_openai_api_key(&resolved);
match prev {
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
None => std::env::remove_var(OPENAI_API_KEY_ENV),
}
assert_eq!(result.as_deref(), Some("sk-from-env"));
}
#[test]
#[serial_test::serial]
fn resolve_openai_api_key_uses_resolved_map_when_env_empty() {
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::set_var(OPENAI_API_KEY_ENV, " ");
let resolved = HashMap::from([(
OsString::from("OPENAI_API_KEY"),
OsString::from("sk-from-secret"),
)]);
let result = resolve_openai_api_key(&resolved);
match prev {
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
None => std::env::remove_var(OPENAI_API_KEY_ENV),
}
assert_eq!(result.as_deref(), Some("sk-from-secret"));
}
#[test]
#[serial_test::serial]
fn prepare_codex_environment_config_honors_codex_home() {
let tmp = TempDir::new().unwrap();
let codex_home = tmp.path().join("codex-home");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let prev_codex_home = std::env::var(CODEX_HOME_ENV).ok();
let prev_openai_api_key = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::set_var(CODEX_HOME_ENV, &codex_home);
std::env::remove_var(OPENAI_API_KEY_ENV);
let resolved = HashMap::from([(
OsString::from(OPENAI_API_KEY_ENV),
OsString::from("sk-from-secret"),
)]);
let model_config = harness_model_config("gpt-5.5", None);
let result = prepare_codex_environment_config(
&working_dir,
Some("system prompt"),
&resolved,
&HashMap::new(),
&HashMap::new(),
Some(&model_config),
);
match prev_codex_home {
Some(v) => std::env::set_var(CODEX_HOME_ENV, v),
None => std::env::remove_var(CODEX_HOME_ENV),
}
match prev_openai_api_key {
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
None => std::env::remove_var(OPENAI_API_KEY_ENV),
}
result.unwrap();
assert_eq!(
fs::read_to_string(codex_home.join(CODEX_AGENTS_OVERRIDE_FILE_NAME)).unwrap(),
"system prompt"
);
let auth: Value =
serde_json::from_slice(&fs::read(codex_home.join(CODEX_AUTH_FILE_NAME)).unwrap()).unwrap();
assert_eq!(auth["OPENAI_API_KEY"], "sk-from-secret");
let cfg = read_codex_config(&codex_home.join(CODEX_CONFIG_TOML_FILE_NAME));
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
assert!(!cfg.contains_key("openai_base_url"));
assert!(!tmp.path().join(CODEX_CONFIG_DIR).exists());
}
fn read_codex_config(path: &std::path::Path) -> toml::Table {
let content = fs::read_to_string(path).unwrap();
toml::from_str(&content).unwrap()
}
fn harness_model_config(model_id: &str, reasoning_level: Option<&str>) -> HarnessModelConfig {
HarnessModelConfig {
model_id: model_id.to_string(),
reasoning_level: reasoning_level.map(str::to_string),
}
}
#[test]
fn prepare_codex_config_toml_writes_fresh_config() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join(".codex/config.toml");
let working_dir = tmp.path().join("workspace/proj");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let canonical = working_dir.canonicalize().unwrap();
let key = canonical.to_string_lossy().into_owned();
let cfg = read_codex_config(&config_path);
assert_eq!(cfg["check_for_update_on_startup"].as_bool(), Some(false));
assert_eq!(
cfg["projects"][&key]["trust_level"].as_str(),
Some("trusted")
);
}
#[test]
fn prepare_codex_config_toml_preserves_unrelated_keys() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
fs::write(
&config_path,
"model = \"gpt-5\"\n\n[projects.\"/other/path\"]\ntrust_level = \"trusted\"\n",
)
.unwrap();
// Pass `None` — the `model` key is intentionally removed (managed
// key), but unrelated keys like existing project entries are kept.
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let canonical = working_dir.canonicalize().unwrap();
let key = canonical.to_string_lossy().into_owned();
let cfg = read_codex_config(&config_path);
// `model` is a managed key — removed when no override is provided.
assert!(!cfg.contains_key("model"));
assert_eq!(
cfg["projects"]["/other/path"]["trust_level"].as_str(),
Some("trusted")
);
assert_eq!(
cfg["projects"][&key]["trust_level"].as_str(),
Some("trusted")
);
}
#[test]
fn prepare_codex_config_toml_is_idempotent() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let after_first = fs::read_to_string(&config_path).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let after_second = fs::read_to_string(&config_path).unwrap();
assert_eq!(after_first, after_second);
let canonical = working_dir.canonicalize().unwrap();
let key = canonical.to_string_lossy().into_owned();
let cfg: toml::Table = toml::from_str(&after_second).unwrap();
let projects = cfg["projects"].as_table().unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[&key]["trust_level"].as_str(), Some("trusted"));
}
#[test]
fn prepare_codex_config_toml_upgrades_untrusted_entry() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let canonical = working_dir.canonicalize().unwrap();
let key = canonical.to_string_lossy().into_owned();
// Use a TOML literal-string key ('...') so Windows backslashes in `key`
// (e.g. `\\?\C:\...`) are not interpreted as escape sequences.
fs::write(
&config_path,
format!("[projects.'{key}']\ntrust_level = \"untrusted\"\n"),
)
.unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let cfg = read_codex_config(&config_path);
assert_eq!(
cfg["projects"][&key]["trust_level"].as_str(),
Some("trusted")
);
}
#[test]
fn prepare_codex_config_toml_trusts_multiple_child_repos() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
let repo_a = working_dir.join("a");
let repo_b = working_dir.join("b");
fs::create_dir_all(repo_a.join(".git")).unwrap();
fs::create_dir_all(repo_b.join(".git")).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let cfg = read_codex_config(&config_path);
let projects = cfg["projects"].as_table().unwrap();
let canonical_a = repo_a.canonicalize().unwrap();
let canonical_b = repo_b.canonicalize().unwrap();
assert_eq!(
projects[canonical_a.to_str().unwrap()]["trust_level"].as_str(),
Some("trusted")
);
assert_eq!(
projects[canonical_b.to_str().unwrap()]["trust_level"].as_str(),
Some("trusted")
);
}
#[test]
fn prepare_codex_config_toml_overwrites_stale_openai_base_url() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
fs::write(
&config_path,
"openai_base_url = \"https://api.openai.com/v1\"\n",
)
.unwrap();
prepare_codex_config_toml(
&config_path,
&working_dir,
&HashMap::new(),
None,
Some("https://custom.api.openai.com/v1"),
)
.unwrap();
let cfg = read_codex_config(&config_path);
assert_eq!(
cfg["openai_base_url"].as_str(),
Some("https://custom.api.openai.com/v1")
);
}
#[test]
fn write_codex_mcp_servers_cli_server() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let servers = HashMap::from([(
"my-mcp".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "npx".to_string(),
args: vec!["-y".to_string(), "@some/mcp".to_string()],
env: HashMap::from([("TOKEN".to_string(), "abc".to_string())]),
working_directory: None,
},
},
)]);
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
let cfg = read_codex_config(&config_path);
let mcp = &cfg["mcp_servers"]["my-mcp"];
assert_eq!(mcp["command"].as_str(), Some("npx"));
let args: Vec<&str> = mcp["args"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(args, vec!["-y", "@some/mcp"]);
assert_eq!(mcp["env"]["TOKEN"].as_str(), Some("abc"));
}
#[test]
fn write_codex_mcp_servers_sse_server() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let servers = HashMap::from([(
"remote-mcp".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::SSEServer {
url: "https://mcp.example.com/sse".to_string(),
headers: HashMap::from([("X-Key".to_string(), "val".to_string())]),
},
},
)]);
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
let cfg = read_codex_config(&config_path);
let mcp = &cfg["mcp_servers"]["remote-mcp"];
assert_eq!(mcp["url"].as_str(), Some("https://mcp.example.com/sse"));
assert_eq!(mcp["http_headers"]["X-Key"].as_str(), Some("val"));
}
#[test]
fn write_codex_mcp_servers_cli_server_with_cwd() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let servers = HashMap::from([(
"my-mcp".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "node".to_string(),
args: vec!["server.js".to_string()],
env: HashMap::new(),
working_directory: Some("/opt/mcp-server".to_string()),
},
},
)]);
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
let cfg = read_codex_config(&config_path);
let mcp = &cfg["mcp_servers"]["my-mcp"];
assert_eq!(mcp["command"].as_str(), Some("node"));
assert_eq!(mcp["cwd"].as_str(), Some("/opt/mcp-server"));
}
#[test]
fn write_codex_mcp_servers_cli_server_without_cwd_omits_key() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
let servers = HashMap::from([(
"my-mcp".to_string(),
JSONMCPServer {
transport_type: JSONTransportType::CLIServer {
command: "npx".to_string(),
args: vec![],
env: HashMap::new(),
working_directory: None,
},
},
)]);
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
let cfg = read_codex_config(&config_path);
let mcp = &cfg["mcp_servers"]["my-mcp"];
assert!(mcp.get("cwd").is_none());
}
#[test]
fn prepare_codex_config_toml_writes_model_when_specified() {
// A non-default model id is written to the top-level `model` key so Codex pins it
// for new sessions launched from this `~/.codex/config.toml`. Even for the
// current target model, we stamp a self-referential migration entry so the
// upgrade prompt is suppressed regardless of what the user selected.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(
&config_path,
&working_dir,
&HashMap::new(),
Some(&harness_model_config("gpt-5.5", None)),
None,
)
.unwrap();
let cfg = read_codex_config(&config_path);
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
assert_eq!(
cfg["notice"]["model_migrations"]["gpt-5.5"].as_str(),
Some(CODEX_MODEL_MIGRATIONS_TARGET),
);
}
#[test]
fn prepare_codex_config_toml_writes_model_migration_for_older_model() {
// For an older model id, the migration entry maps it to the current target
// so Codex's "choose a newer model" prompt is suppressed at session launch.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(
&config_path,
&working_dir,
&HashMap::new(),
Some(&harness_model_config("gpt-5.2", None)),
None,
)
.unwrap();
let cfg = read_codex_config(&config_path);
assert_eq!(cfg["model"].as_str(), Some("gpt-5.2"));
assert_eq!(
cfg["notice"]["model_migrations"]["gpt-5.2"].as_str(),
Some(CODEX_MODEL_MIGRATIONS_TARGET),
);
}
#[test]
fn prepare_codex_config_toml_skips_model_for_default_sentinel() {
// The literal "default" sentinel means "let Codex pick its own default model";
// we should NOT write a `model` key (or a migration entry) in that case.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(
&config_path,
&working_dir,
&HashMap::new(),
Some(&harness_model_config("default", None)),
None,
)
.unwrap();
let cfg = read_codex_config(&config_path);
assert!(
cfg.get("model").is_none(),
"`model` should not be written for the default sentinel"
);
assert!(
cfg.get("notice").is_none(),
"`[notice]` table should not be written without a pinned model id"
);
}
#[test]
fn prepare_codex_config_toml_skips_model_when_none() {
// No model id supplied means the user didn't pick one; we should not write a
// `model` key or any `[notice.model_migrations]` entries.
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let cfg = read_codex_config(&config_path);
assert!(
cfg.get("model").is_none(),
"`model` should not be written when no override is supplied"
);
assert!(
cfg.get("notice").is_none(),
"`[notice]` table should not be written without a pinned model id"
);
}
#[test]
fn prepare_codex_config_toml_writes_model_reasoning_effort_when_specified() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
prepare_codex_config_toml(
&config_path,
&working_dir,
&HashMap::new(),
Some(&harness_model_config("gpt-5.5", Some("medium"))),
None,
)
.unwrap();
let cfg = read_codex_config(&config_path);
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
assert_eq!(cfg["model_reasoning_effort"].as_str(), Some("medium"));
}
#[test]
fn prepare_codex_config_toml_removes_stale_model_reasoning_effort_when_none() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let working_dir = tmp.path().join("workspace");
fs::create_dir_all(&working_dir).unwrap();
fs::write(&config_path, "model_reasoning_effort = \"high\"\n").unwrap();
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
let cfg = read_codex_config(&config_path);
assert!(cfg.get("model_reasoning_effort").is_none());
}
#[test]
fn find_child_git_repos_returns_only_repo_children() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let repo = workspace.join("repo");
let other = workspace.join("other");
fs::create_dir_all(repo.join(".git")).unwrap();
fs::create_dir_all(&other).unwrap();
let found = find_child_git_repos(&workspace);
let canonical_repo = repo.canonicalize().unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].canonicalize().unwrap(), canonical_repo);
}
#[test]
fn find_child_git_repos_returns_empty_when_dir_missing() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("does-not-exist");
assert!(find_child_git_repos(&missing).is_empty());
}
#[test]
fn codex_command_with_session_id_invokes_resume_subcommand() {
let uuid = Uuid::new_v4();
let cmd = codex_command("codex", Some(&uuid), "/tmp/prompt.txt");
assert!(
cmd.contains(&format!(
"resume --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust {uuid}"
)),
"resume command should pass UUID to `resume`: {cmd}"
);
assert!(
cmd.contains("\"$(cat '/tmp/prompt.txt')\""),
"resume command should pipe prompt: {cmd}"
);
}
#[test]
fn codex_command_without_session_id_bypasses_hook_trust() {
let cmd = codex_command("codex", None, "/tmp/prompt.txt");
assert!(
cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
"command should bypass approvals and sandbox: {cmd}"
);
assert!(
cmd.contains("--dangerously-bypass-hook-trust"),
"command should bypass hook trust for driver-installed hooks: {cmd}"
);
assert!(
cmd.contains("\"$(cat '/tmp/prompt.txt')\""),
"command should pipe prompt: {cmd}"
);
}
#[tokio::test]
async fn fetch_resume_payload_maps_404_to_resume_state_missing() {
let mut mock = MockHarnessSupportClient::new();
mock.expect_fetch_transcript()
.returning(|| Err(anyhow::anyhow!("upstream returned status 404")));
let conversation_id = AIConversationId::new();
let result = CodexHarness
.fetch_resume_payload(&conversation_id, Arc::new(mock))
.await;
match result {
Err(AgentDriverError::ConversationResumeStateMissing { harness, .. }) => {
assert_eq!(harness, "codex");
}
other => panic!("expected ConversationResumeStateMissing, got {other:?}"),
}
}
#[tokio::test]
async fn fetch_resume_payload_maps_other_errors_to_load_failed() {
let mut mock = MockHarnessSupportClient::new();
mock.expect_fetch_transcript()
.returning(|| Err(anyhow::anyhow!("connection reset")));
let conversation_id = AIConversationId::new();
let result = CodexHarness
.fetch_resume_payload(&conversation_id, Arc::new(mock))
.await;
assert!(
matches!(result, Err(AgentDriverError::ConversationLoadFailed(_))),
"expected ConversationLoadFailed, got {result:?}"
);
}
#[test]
#[serial_test::serial]
fn resolve_openai_base_url_from_secret_returns_base_url_when_typed_secret_active() {
// When the typed OpenAI secret is the active API key source, the base URL
// should be extracted from the structured secret.
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::remove_var(OPENAI_API_KEY_ENV);
let secrets = HashMap::from([(
"openai-key".to_string(),
ManagedSecretValue::openai_api_key(
"sk-test",
Some("https://us.api.openai.com/v1".to_string()),
),
)]);
let resolved_env =
HashMap::from([(OsString::from("OPENAI_API_KEY"), OsString::from("sk-test"))]);
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
if let Some(v) = prev {
std::env::set_var(OPENAI_API_KEY_ENV, v);
}
assert_eq!(result.as_deref(), Some("https://us.api.openai.com/v1"));
}
#[test]
#[serial_test::serial]
fn resolve_openai_base_url_from_secret_returns_none_when_worker_env_wins() {
// When a worker-injected OPENAI_API_KEY already exists in process env,
// the typed-secret base_url should NOT be applied.
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::set_var(OPENAI_API_KEY_ENV, "sk-worker-key");
let secrets = HashMap::from([(
"openai-key".to_string(),
ManagedSecretValue::openai_api_key(
"sk-secret",
Some("https://us.api.openai.com/v1".to_string()),
),
)]);
let resolved_env = HashMap::new();
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
match prev {
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
None => std::env::remove_var(OPENAI_API_KEY_ENV),
}
assert_eq!(result, None);
}
#[test]
#[serial_test::serial]
fn resolve_openai_base_url_from_secret_returns_none_when_no_base_url() {
// When the typed OpenAI secret has no base_url, None is returned.
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::remove_var(OPENAI_API_KEY_ENV);
let secrets = HashMap::from([(
"openai-key".to_string(),
ManagedSecretValue::openai_api_key("sk-test", None),
)]);
let resolved_env =
HashMap::from([(OsString::from("OPENAI_API_KEY"), OsString::from("sk-test"))]);
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
if let Some(v) = prev {
std::env::set_var(OPENAI_API_KEY_ENV, v);
}
assert_eq!(result, None);
}
#[test]
#[serial_test::serial]
fn resolve_openai_base_url_from_secret_returns_none_when_api_key_not_in_resolved() {
// When OPENAI_API_KEY is not in the resolved env vars (e.g. the secret was
// skipped due to collision), the base URL should not be applied.
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
std::env::remove_var(OPENAI_API_KEY_ENV);
let secrets = HashMap::from([(
"openai-key".to_string(),
ManagedSecretValue::openai_api_key(
"sk-test",
Some("https://us.api.openai.com/v1".to_string()),
),
)]);
let resolved_env = HashMap::new();
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
if let Some(v) = prev {
std::env::set_var(OPENAI_API_KEY_ENV, v);
}
assert_eq!(result, None);
}
#[tokio::test]
async fn fetch_resume_payload_returns_codex_variant_on_success() {
let uuid = Uuid::new_v4();
let envelope = CodexTranscriptEnvelope {
cwd: "/cloud/work".into(),
session_id: uuid,
codex_version: Some("0.55.0".to_string()),
session_start_timestamp: None,
entries: vec![serde_json::json!({"type": "event_msg"})],
};
let bytes = serde_json::to_vec(&envelope).unwrap();
let mut mock = MockHarnessSupportClient::new();
mock.expect_fetch_transcript()
.returning(move || Ok(bytes::Bytes::from(bytes.clone())));
let conversation_id = AIConversationId::new();
let payload = CodexHarness
.fetch_resume_payload(&conversation_id, Arc::new(mock))
.await
.unwrap()
.unwrap();
match payload {
ResumePayload::Codex(info) => {
assert_eq!(info.session_id, uuid);
assert_eq!(info.conversation_id, conversation_id);
assert_eq!(info.envelope.codex_version.as_deref(), Some("0.55.0"));
}
other => panic!("expected ResumePayload::Codex, got {other:?}"),
}
}
@@ -0,0 +1,248 @@
//! Codex session transcript envelope + rehydration helpers.
//!
//! Owns:
//! - [`CodexTranscriptEnvelope`] — the on-wire/on-GCS shape of a saved Codex rollout
//! (parsed JSONL entries plus session-level metadata). Reader/writer functions
//! interoperate with Codex's own `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`
//! layout (codex `rollout/src/recorder.rs`).
//! - [`CodexResumeInfo`] — everything the harness runner needs to resume an existing
//! Codex conversation: the Warp server conversation id to reuse, the codex session
//! uuid (`ThreadId`) to pass to `codex resume`, and the decoded envelope to rehydrate
//! onto disk.
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Datelike, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use super::json_utils::entries_to_jsonl;
use crate::ai::agent::conversation::AIConversationId;
/// Env var codex honors to override `~/.codex` (see codex `core/src/config/mod.rs`).
const CODEX_HOME_ENV: &str = "CODEX_HOME";
const CODEX_HOME_DIRNAME: &str = ".codex";
/// Subdirectory under `$CODEX_HOME` where rollouts live.
const CODEX_SESSIONS_SUBDIR: &str = "sessions";
/// JSON envelope sent to the server representing a complete Codex session.
///
/// The transcript is the parsed JSONL content of the rollout file; codex's resume
/// path re-reads this JSONL line by line.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct CodexTranscriptEnvelope {
/// The directory the codex session started in (recovered from the `SessionMeta` line).
pub(crate) cwd: PathBuf,
/// Codex session/thread UUID. Matches the trailing `-<uuid>` in the rollout filename.
pub(crate) session_id: Uuid,
/// `cli_version` from `SessionMeta`, surfaced separately for the server.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) codex_version: Option<String>,
/// Timestamp from the `SessionMeta` line, used to derive the YYYY/MM/DD directory
/// path when writing the rollout file back to disk.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) session_start_timestamp: Option<DateTime<Utc>>,
/// Parsed JSONL entries.
pub(crate) entries: Vec<Value>,
}
impl CodexTranscriptEnvelope {
pub(crate) fn new(session_id: Uuid, meta: CodexSessionMetadata, entries: Vec<Value>) -> Self {
Self {
cwd: meta.cwd,
session_id,
codex_version: meta.codex_version,
session_start_timestamp: meta.session_start_timestamp,
entries,
}
}
}
/// Session-level metadata pulled from the rollout's `SessionMeta` line.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct CodexSessionMetadata {
pub(crate) cwd: PathBuf,
pub(crate) codex_version: Option<String>,
pub(crate) session_start_timestamp: Option<DateTime<Utc>>,
}
/// Everything needed to resume an existing Codex conversation.
///
/// Built from a `--conversation` id after the client fetches the stored envelope from
/// the server. Passed into `CodexHarnessRunner::new` so the runner reuses the existing
/// session and server conversation ids instead of minting fresh ones.
#[derive(Debug)]
pub(crate) struct CodexResumeInfo {
/// Warp server-side conversation id. Reused so subsequent transcript/block-snapshot
/// uploads overwrite the same GCS objects.
pub(crate) conversation_id: AIConversationId,
/// Codex session uuid passed to `codex resume <session_id>`. Matches `envelope.session_id`.
pub(crate) session_id: Uuid,
/// Envelope fetched from the server, written back to disk before launching codex.
pub(crate) envelope: CodexTranscriptEnvelope,
}
#[derive(Debug)]
pub(crate) struct CodexLocalContinuation {
pub(crate) command: String,
pub(crate) transcript_path: PathBuf,
}
/// Resolve the codex sessions root, honoring `$CODEX_HOME` then falling back to `~/.codex`.
pub(crate) fn codex_sessions_root() -> anyhow::Result<PathBuf> {
let home = if let Ok(dir) = std::env::var(CODEX_HOME_ENV) {
PathBuf::from(dir)
} else {
dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?
.join(CODEX_HOME_DIRNAME)
};
Ok(home.join(CODEX_SESSIONS_SUBDIR))
}
/// Walk `<sessions_root>/YYYY/MM/DD/` looking for a `rollout-*-<session_id>.jsonl`.
///
/// Returns `None` if `sessions_root` doesn't exist yet or no matching file is found.
pub(crate) fn find_session_file(sessions_root: &Path, session_id: Uuid) -> Option<PathBuf> {
if !sessions_root.exists() {
return None;
}
let suffix = format!("-{session_id}.jsonl");
for year_dir in read_subdirs(sessions_root) {
for month_dir in read_subdirs(&year_dir) {
for day_dir in read_subdirs(&month_dir) {
let entries = match fs::read_dir(&day_dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with("rollout-") && name.ends_with(&suffix) {
return Some(path);
}
}
}
}
}
None
}
fn read_subdirs(parent: &Path) -> impl Iterator<Item = PathBuf> {
fs::read_dir(parent)
.into_iter()
.flatten()
.filter_map(|entry| {
let entry = entry.ok()?;
entry.file_type().ok()?.is_dir().then(|| entry.path())
})
}
/// Pull `cwd` and `cli_version` out of the first JSONL line if it's a `SessionMeta`.
pub(crate) fn parse_session_meta(first: Option<&Value>) -> Option<CodexSessionMetadata> {
let entry = first?;
if entry.get("type").and_then(|v| v.as_str()) != Some("session_meta") {
return None;
}
let payload = entry.get("payload")?;
let cwd = PathBuf::from(payload.get("cwd").and_then(|v| v.as_str())?);
let codex_version = payload
.get("cli_version")
.and_then(|v| v.as_str())
.map(str::to_owned);
let session_start_timestamp = payload
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc));
Some(CodexSessionMetadata {
cwd,
codex_version,
session_start_timestamp,
})
}
/// Write `envelope` back under `<sessions_root>/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`.
///
/// YYYY/MM/DD and `<ts>` come from `envelope.session_start_timestamp`. Falls back to
/// today's UTC date if absent — codex's lookup is by UUID so the precise path doesn't
/// matter for resume to work.
pub(crate) fn write_envelope(
envelope: &CodexTranscriptEnvelope,
sessions_root: &Path,
) -> Result<PathBuf> {
let timestamp = envelope.session_start_timestamp.unwrap_or_else(Utc::now);
let day_dir = sessions_root
.join(format!("{:04}", timestamp.year()))
.join(format!("{:02}", timestamp.month()))
.join(format!("{:02}", timestamp.day()));
fs::create_dir_all(&day_dir)
.with_context(|| format!("Failed to create {}", day_dir.display()))?;
// Codex's filename format: `[year]-[month]-[day]T[hour]-[minute]-[second]`
// (codex `rollout/src/recorder.rs::precompute_log_file_info`).
let date_str = timestamp.format("%Y-%m-%dT%H-%M-%S").to_string();
let file_path = day_dir.join(format!(
"rollout-{date_str}-{session_id}.jsonl",
session_id = envelope.session_id
));
fs::write(&file_path, entries_to_jsonl(&envelope.entries)?)
.with_context(|| format!("Failed to write {}", file_path.display()))?;
Ok(file_path)
}
pub(crate) fn rehydrate_codex_transcript(
envelope: &mut CodexTranscriptEnvelope,
local_cwd: &Path,
) -> Result<CodexLocalContinuation> {
envelope.cwd = local_cwd.to_path_buf();
if let Some(Value::Object(entry)) = envelope.entries.first_mut() {
if entry.get("type").and_then(|value| value.as_str()) == Some("session_meta") {
if let Some(Value::Object(payload)) = entry.get_mut("payload") {
payload.insert(
"cwd".to_string(),
Value::String(local_cwd.to_string_lossy().to_string()),
);
}
}
}
let session_id = envelope.session_id;
let sessions_root = codex_sessions_root().context("Failed to resolve codex sessions root")?;
let transcript_path =
write_envelope(envelope, &sessions_root).context("Failed to rehydrate codex transcript")?;
Ok(CodexLocalContinuation {
command: format!("codex resume {session_id}"),
transcript_path,
})
}
/// Rehydrate a Codex transcript downloaded from a remote cloud run for local continuation.
///
/// Unlike [`rehydrate_codex_transcript`] (used by the cloud resume harness runner), this
/// function does **not** mutate the envelope's `cwd` field or patch the `session_meta` payload
/// — the remote session's working directory is preserved as-is in the transcript.
pub(crate) fn rehydrate_codex_transcript_from_reader(
reader: impl Read,
) -> Result<CodexLocalContinuation> {
let envelope: CodexTranscriptEnvelope =
serde_json::from_reader(reader).context("Failed to parse codex transcript envelope")?;
let session_id = envelope.session_id;
let sessions_root = codex_sessions_root().context("Failed to resolve codex sessions root")?;
// Write as-is: no cwd mutation, no session_meta patch.
let transcript_path = write_envelope(&envelope, &sessions_root)
.context("Failed to rehydrate codex transcript")?;
Ok(CodexLocalContinuation {
command: format!("codex resume {session_id}"),
transcript_path,
})
}
#[cfg(test)]
#[path = "codex_transcript_tests.rs"]
mod tests;
@@ -0,0 +1,205 @@
use std::fs;
use std::path::Path;
use anyhow::Result;
use tempfile::TempDir;
use uuid::Uuid;
use super::super::claude_transcript::read_jsonl;
use super::*;
/// Walk `sessions_root` for `session_id`'s rollout and assemble an envelope.
fn read_envelope(
session_id: Uuid,
sessions_root: &Path,
) -> Result<Option<CodexTranscriptEnvelope>> {
let Some(path) = find_session_file(sessions_root, session_id) else {
return Ok(None);
};
let entries = read_jsonl(&path)?;
let meta = parse_session_meta(entries.first()).unwrap_or_default();
Ok(Some(CodexTranscriptEnvelope::new(
session_id, meta, entries,
)))
}
/// Minimal SessionMeta line in the same shape codex writes (codex
/// `protocol/src/protocol.rs::RolloutItem`): `{type, payload}`.
fn session_meta_line(uuid: Uuid, cwd: &str, timestamp: &str, cli_version: &str) -> String {
serde_json::json!({
"type": "session_meta",
"payload": {
"id": uuid.to_string(),
"timestamp": timestamp,
"cwd": cwd,
"originator": "test",
"cli_version": cli_version,
},
})
.to_string()
}
#[test]
#[serial_test::serial]
fn codex_sessions_root_honors_codex_home_env() {
let tmp = TempDir::new().unwrap();
let prev = std::env::var(CODEX_HOME_ENV).ok();
std::env::set_var(CODEX_HOME_ENV, tmp.path());
let root = codex_sessions_root().unwrap();
match prev {
Some(v) => std::env::set_var(CODEX_HOME_ENV, v),
None => std::env::remove_var(CODEX_HOME_ENV),
}
assert_eq!(root, tmp.path().join(CODEX_SESSIONS_SUBDIR));
}
#[test]
fn find_session_file_walks_yyyy_mm_dd_tree() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
let day = tmp.path().join("2026").join("04").join("30");
fs::create_dir_all(&day).unwrap();
let file = day.join(format!("rollout-ignored-ts-{uuid}.jsonl"));
fs::write(&file, "").unwrap();
let found = find_session_file(tmp.path(), uuid);
assert_eq!(found, Some(file));
}
#[test]
fn find_session_file_returns_none_when_no_match() {
let tmp = TempDir::new().unwrap();
let day = tmp.path().join("2026").join("04").join("30");
fs::create_dir_all(&day).unwrap();
fs::write(
day.join(format!("rollout-ignored-ts-{}.jsonl", Uuid::new_v4())),
"",
)
.unwrap();
assert!(find_session_file(tmp.path(), Uuid::new_v4()).is_none());
}
#[test]
fn find_session_file_returns_none_when_root_missing() {
let tmp = TempDir::new().unwrap();
assert!(find_session_file(&tmp.path().join("missing"), Uuid::new_v4()).is_none());
}
#[test]
fn read_envelope_recovers_cwd_and_version_from_session_meta() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
let day = tmp.path().join("2026").join("04").join("30");
fs::create_dir_all(&day).unwrap();
let meta = session_meta_line(uuid, "/work/proj", "2026-04-30T01:54:20.000Z", "0.55.0");
let body = format!("{meta}\n{{\"type\":\"event_msg\",\"payload\":{{\"x\":1}}}}\n");
fs::write(day.join(format!("rollout-ignored-ts-{uuid}.jsonl")), body).unwrap();
let envelope = read_envelope(uuid, tmp.path()).unwrap().unwrap();
assert_eq!(envelope.session_id, uuid);
assert_eq!(envelope.cwd, std::path::PathBuf::from("/work/proj"));
assert_eq!(envelope.codex_version.as_deref(), Some("0.55.0"));
assert_eq!(envelope.entries.len(), 2);
}
#[test]
fn read_envelope_returns_none_when_missing() {
let tmp = TempDir::new().unwrap();
assert!(read_envelope(Uuid::new_v4(), tmp.path()).unwrap().is_none());
}
#[test]
fn write_envelope_uses_session_meta_timestamp_for_path() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
let envelope = CodexTranscriptEnvelope {
cwd: "/work".into(),
session_id: uuid,
codex_version: Some("0.55.0".to_string()),
session_start_timestamp: Some(
chrono::DateTime::parse_from_rfc3339("2026-04-30T01:54:20.000Z")
.unwrap()
.with_timezone(&chrono::Utc),
),
entries: vec![
serde_json::from_str::<serde_json::Value>(&session_meta_line(
uuid,
"/work",
"2026-04-30T01:54:20.000Z",
"0.55.0",
))
.unwrap(),
],
};
let path = write_envelope(&envelope, tmp.path()).unwrap();
let expected = tmp
.path()
.join("2026")
.join("04")
.join("30")
.join(format!("rollout-2026-04-30T01-54-20-{uuid}.jsonl"));
assert_eq!(path, expected);
assert!(path.exists());
}
#[test]
fn write_envelope_round_trip_preserves_entries() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
let entries = vec![
serde_json::from_str::<serde_json::Value>(&session_meta_line(
uuid,
"/work",
"2026-04-30T01:54:20.000Z",
"0.55.0",
))
.unwrap(),
serde_json::json!({"type": "event_msg", "payload": {"x": 1}}),
];
let original = CodexTranscriptEnvelope {
cwd: "/work".into(),
session_id: uuid,
codex_version: Some("0.55.0".to_string()),
session_start_timestamp: Some(
chrono::DateTime::parse_from_rfc3339("2026-04-30T01:54:20.000Z")
.unwrap()
.with_timezone(&chrono::Utc),
),
entries: entries.clone(),
};
write_envelope(&original, tmp.path()).unwrap();
let decoded = read_envelope(uuid, tmp.path()).unwrap().unwrap();
assert_eq!(decoded.session_id, uuid);
assert_eq!(decoded.entries, entries);
// `cwd` and `codex_version` are recovered from the SessionMeta line.
assert_eq!(decoded.cwd, std::path::PathBuf::from("/work"));
assert_eq!(decoded.codex_version.as_deref(), Some("0.55.0"));
}
#[test]
fn write_envelope_falls_back_to_today_when_timestamp_missing() {
let tmp = TempDir::new().unwrap();
let uuid = Uuid::new_v4();
// No SessionMeta first line — just one event entry.
let envelope = CodexTranscriptEnvelope {
cwd: "/work".into(),
session_id: uuid,
codex_version: None,
session_start_timestamp: None,
entries: vec![serde_json::json!({"type": "event_msg"})],
};
let path = write_envelope(&envelope, tmp.path()).unwrap();
// Path should still be under sessions_root and findable by uuid.
assert!(path.starts_with(tmp.path()));
let found = find_session_file(tmp.path(), uuid);
assert_eq!(found, Some(path));
}
+63 -29
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::Path;
use std::sync::Arc;
@@ -12,18 +13,24 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tempfile::NamedTempFile;
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, HarnessCleanupDisposition, HarnessRunner, JSONMCPServer, ResumePayload,
SavePoint, ThirdPartyHarness,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_sdk::setup_observability::{
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
};
use crate::ai::ambient_agents::task::HarnessModelConfig;
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.
@@ -46,38 +53,42 @@ impl ThirdPartyHarness for GeminiHarness {
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>,
context: Option<&str>,
working_dir: &Path,
_task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
_resume: Option<ResumePayload>,
_resolved_env_vars: &HashMap<OsString, OsString>,
_resolved_secrets: &HashMap<String, ManagedSecretValue>,
_resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
_third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
// Prepare the environment config files.
prepare_gemini_environment_config(working_dir, system_prompt).map_err(|error| {
AgentDriverError::HarnessConfigSetupFailed {
harness: self.cli_agent().command_prefix().to_owned(),
error,
}
})?;
// 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.
// Prepend server context to the prompt if available.
let effective_prompt = match context {
Some(ctx) if !ctx.is_empty() => format!("{ctx}\n\n{prompt}"),
_ => prompt.to_string(),
};
let client: Arc<dyn HarnessSupportClient> = server_api;
Ok(Box::new(GeminiHarnessRunner::new(
self.cli_agent().command_prefix(),
prompt,
&effective_prompt,
system_prompt,
working_dir,
client,
@@ -104,6 +115,8 @@ enum GeminiRunnerState {
struct GeminiHarnessRunner {
command: String,
/// The CLI name used to invoke Gemini.
cli_name: String,
/// Held so the temp file is cleaned up when the runner is dropped.
_temp_prompt_file: NamedTempFile,
client: Arc<dyn HarnessSupportClient>,
@@ -120,11 +133,12 @@ impl GeminiHarnessRunner {
client: Arc<dyn HarnessSupportClient>,
terminal_driver: ModelHandle<TerminalDriver>,
) -> Result<Self, AgentDriverError> {
let temp_file = write_temp_file("oz_prompt_", prompt)?;
let temp_file = write_temp_file("oz_prompt_", prompt, ".txt")?;
let prompt_path = temp_file.path().display().to_string();
Ok(Self {
command: gemini_command(cli_command, &prompt_path),
cli_name: cli_command.to_string(),
_temp_prompt_file: temp_file,
client,
terminal_driver,
@@ -136,19 +150,27 @@ impl GeminiHarnessRunner {
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl HarnessRunner for GeminiHarnessRunner {
fn harness_name(&self) -> &str {
&self.cli_name
}
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
setup_events: &SetupClientEventReporter,
) -> 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)
})?;
let conversation_id = setup_events
.record_result(SetupStep::ThirdPartyHarnessExternalConversation, async {
self.client
.create_external_conversation(GEMINI_CLI_FORMAT)
.await
.map_err(|e| {
log::error!("Failed to create external conversation: {e}");
AgentDriverError::ConfigBuildFailed(e)
})
})
.await?;
log::info!("Created external conversation {conversation_id}");
let command = self.command.clone();
@@ -166,6 +188,10 @@ impl HarnessRunner for GeminiHarnessRunner {
block_id: command_handle.block_id().clone(),
};
setup_events
.post_timeline_event(OzRunTimelineEvent::AgentStarted)
.await;
Ok(command_handle)
}
@@ -215,6 +241,14 @@ impl HarnessRunner for GeminiHarnessRunner {
)
.await
}
async fn cleanup(
&self,
_cleanup_disposition: HarnessCleanupDisposition,
_foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
Ok(())
}
}
fn prepare_gemini_environment_config(
@@ -10,6 +10,7 @@ use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Read a JSON file as `T`, or return `T::default()` if the file does not exist.
///
@@ -54,3 +55,13 @@ where
)
.with_context(|| format!("Failed to write {}", path.display()))
}
/// Serialize a slice of JSON values as a JSONL byte string (one value per line).
pub(super) 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)
}
+208 -36
View File
@@ -1,6 +1,6 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::ffi::OsString;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io::Write;
use std::path::Path;
@@ -12,15 +12,6 @@ use galaxy_cli::agent::Harness;
use galaxy_managed_secrets::ManagedSecretValue;
use galaxyui::{ModelHandle, ModelSpawner, SingletonEntity};
use tempfile::NamedTempFile;
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 galaxy_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,
@@ -33,24 +24,97 @@ use super::{
LEGACY_OZ_PARENT_STATE_ROOT_ENV, OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_sdk::setup_observability::SetupClientEventReporter;
use crate::ai::ambient_agents::task::HarnessModelConfig;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::mcp::JSONMCPServer;
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;
mod claude_code;
pub(crate) mod claude_code;
pub(crate) mod claude_transcript;
mod codex;
pub(crate) mod codex_transcript;
mod gemini;
mod json_utils;
mod telemetry;
pub(crate) use claude_code::ClaudeHarness;
use claude_transcript::ClaudeResumeInfo;
use codex::CodexHarness;
use codex_transcript::CodexResumeInfo;
use gemini::GeminiHarness;
pub(crate) use telemetry::ThirdPartyHarnessTelemetryEvent;
/// 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`].
#[derive(Debug)]
pub(crate) enum ResumePayload {
/// Claude Code session state fetched from the server's transcript endpoint.
Claude(ClaudeResumeInfo),
/// Codex session state fetched from the server's transcript endpoint.
Codex(CodexResumeInfo),
}
impl TryFrom<ResumePayload> for ClaudeResumeInfo {
type Error = AgentDriverError;
fn try_from(payload: ResumePayload) -> Result<Self, Self::Error> {
match payload {
ResumePayload::Claude(info) => Ok(info),
_ => {
log::error!("ClaudeHarness given non-Claude ResumePayload variant");
Err(AgentDriverError::InvalidRuntimeState)
}
}
}
}
impl TryFrom<ResumePayload> for CodexResumeInfo {
type Error = AgentDriverError;
fn try_from(payload: ResumePayload) -> Result<Self, Self::Error> {
match payload {
ResumePayload::Codex(info) => Ok(info),
_ => {
log::error!("CodexHarness given non-Codex ResumePayload variant");
Err(AgentDriverError::InvalidRuntimeState)
}
}
}
}
/// Fetch the harness transcript for `conversation_id` and deserialize it into `E`.
pub(super) async fn fetch_transcript_envelope<E: serde::de::DeserializeOwned>(
harness_label: &str,
conversation_id: &AIConversationId,
client: Arc<dyn HarnessSupportClient>,
) -> Result<E, AgentDriverError> {
let bytes = 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: harness_label.to_string(),
conversation_id: conversation_id.to_string(),
}
} else {
AgentDriverError::ConversationLoadFailed(format!("{err:#}"))
}
})?;
serde_json::from_slice(&bytes).map_err(|err| {
AgentDriverError::ConversationLoadFailed(format!(
"Failed to deserialize {harness_label} transcript for {conversation_id}: {err:#}"
))
})
}
/// Trait for third-party agent harnesses that execute prompts via their own CLIs.
@@ -77,14 +141,27 @@ pub(crate) trait ThirdPartyHarness: Send + Sync {
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(())
/// Shell command to verify authentication credentials are valid.
/// Exit code 0 = pass; non-zero = fail.
fn auth_check_command(&self) -> Option<String> {
None
}
/// Substrings to scan for in the running harness block's output. A hit
/// indicates the harness can't make a successful API request (e.g.
/// invalid key, no billing, quota exhausted). The driver matches
/// case-insensitively against the block's plaintext via the same DFA
/// machinery used by the find feature.
fn runtime_error_patterns(&self) -> &'static [&'static str] {
&[]
}
/// Whether this harness must verify its Oz platform plugin before launch.
/// Codex opts into this because its unattended launch command bypasses hook
/// trust globally, so we should fail setup instead of running without the
/// Warp-installed orchestration hooks at the required version.
fn requires_verified_platform_plugin(&self) -> bool {
false
}
/// Fetch the harness-specific resume payload for an existing conversation.
@@ -107,25 +184,34 @@ pub(crate) trait ThirdPartyHarness: Send + Sync {
/// 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.
/// Responsible for all harness-specific setup: writing config files (auth,
/// trust, system prompt, MCP, etc.) and constructing the runner that will
/// execute the CLI command.
///
/// `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.
/// `resolved_env_vars` contains already-resolved secret env vars (worker
/// env > typed secrets > raw values precedence already applied).
///
/// `resolved_secrets` provides the raw typed managed secrets so harnesses
/// can read structured fields (e.g. `base_url`) without relying on env vars.
///
/// If `resume` is `Some`, the harness matches on its own [`ResumePayload`]
/// variant and reuses stored session/conversation ids.
#[allow(clippy::too_many_arguments)]
fn build_runner(
&self,
prompt: &str,
system_prompt: Option<&str>,
resumption_prompt: Option<&str>,
context: Option<&str>,
working_dir: &Path,
task_id: Option<AmbientAgentTaskId>,
server_api: Arc<ServerApi>,
terminal_driver: ModelHandle<TerminalDriver>,
resume: Option<ResumePayload>,
resolved_env_vars: &HashMap<OsString, OsString>,
resolved_secrets: &HashMap<String, ManagedSecretValue>,
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> Result<Box<dyn HarnessRunner>, AgentDriverError>;
}
@@ -165,12 +251,30 @@ pub(crate) fn harness_kind(harness: Harness) -> Result<HarnessKind, AgentDriverE
match harness {
Harness::Oz => Ok(HarnessKind::Oz),
Harness::Claude => Ok(HarnessKind::ThirdParty(Box::new(ClaudeHarness))),
Harness::Codex => Ok(HarnessKind::ThirdParty(Box::new(CodexHarness))),
Harness::OpenCode => Ok(HarnessKind::Unsupported(Harness::OpenCode)),
Harness::Gemini => Ok(HarnessKind::ThirdParty(Box::new(GeminiHarness))),
Harness::Unknown => Err(AgentDriverError::InvalidRuntimeState),
}
}
/// Returns the harness's auth-check preflight command, if any.
///
/// The viewer uses this to recognize preflight blocks via exact string
/// equality (so they stay grouped under "Set up environment commands"
/// rather than being mistaken for the main harness invocation, which
/// shares the same CLI prefix).
///
/// Returns `None` for [`Harness::Oz`], for unsupported harnesses, and
/// for any third-party harness whose `auth_check_command` returns `None`
/// (e.g. Gemini today).
pub(crate) fn auth_check_command_for(harness: Harness) -> Option<String> {
let HarnessKind::ThirdParty(third_party) = harness_kind(harness).ok()? else {
return None;
};
third_party.auth_check_command()
}
/// 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(
@@ -302,6 +406,17 @@ fn task_env_vars_for_harness_name(
env_vars
}
pub(crate) fn remove_claude_externally_managed_listener_env_vars(
env_vars: &mut HashMap<OsString, OsString>,
) {
for env_name in [
OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV,
] {
env_vars.remove(OsStr::new(env_name));
}
}
pub(crate) fn task_env_vars(
task_id: Option<&AmbientAgentTaskId>,
parent_run_id: Option<&str>,
@@ -310,6 +425,34 @@ pub(crate) fn task_env_vars(
task_env_vars_for_harness_name(task_id, parent_run_id, selected_harness)
}
/// Returns environment variables that configure the model for a third-party harness.
/// Returns an empty map for Oz or when no model is specified.
///
/// We use the `ANTHROPIC_MODEL` env var rather than the `--model` CLI flag because
/// the env var is the most reliable mechanism and avoids precedence conflicts with
/// Claude Code's `settings.json`.
pub(crate) fn harness_model_env_vars(
selected_harness: Harness,
third_party_harness_model_config: Option<&HarnessModelConfig>,
) -> HashMap<OsString, OsString> {
let mut env_vars = HashMap::new();
let Some(model_id) = third_party_harness_model_config
.map(|config| config.model_id.as_str())
.filter(|id| !id.is_empty())
else {
return env_vars;
};
match selected_harness {
Harness::Claude => {
env_vars.insert(OsString::from("ANTHROPIC_MODEL"), OsString::from(model_id));
}
Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Codex | Harness::Unknown => {}
}
env_vars
}
/// Indicates when the harness conversation is being saved.
/// Implementations may use this to customize the saved data, such as
/// recording additional metadata on completion.
@@ -322,6 +465,18 @@ pub(crate) enum SavePoint {
PostTurn,
}
/// Controls how much harness-owned state should survive cleanup after the CLI
/// exits.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum HarnessCleanupDisposition {
/// Tear down all harness-owned resume and wake state.
DropResumptionState,
/// The harness exited cleanly and its final save completed, so wake/resume
/// state may be preserved if the harness-specific runtime also considers
/// the run complete.
PreserveResumptionStateIfSupported,
}
/// Stateful per-run representation of an external harness produced
/// by [`ThirdPartyHarness::build_runner`].
///
@@ -333,6 +488,8 @@ pub(crate) enum SavePoint {
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
pub(crate) trait HarnessRunner: Send + Sync {
fn harness_name(&self) -> &str;
/// Create the external conversation on the server and start the harness
/// command in the terminal.
///
@@ -342,6 +499,7 @@ pub(crate) trait HarnessRunner: Send + Sync {
async fn start(
&self,
foreground: &ModelSpawner<AgentDriver>,
setup_events: &SetupClientEventReporter,
) -> Result<CommandHandle, AgentDriverError>;
/// Save the current conversation state (transcript upload, etc.).
@@ -359,7 +517,11 @@ pub(crate) trait HarnessRunner: Send + Sync {
}
/// Clean up any harness-owned background state after the harness exits.
async fn cleanup(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
async fn cleanup(
&self,
_cleanup_disposition: HarnessCleanupDisposition,
_foreground: &ModelSpawner<AgentDriver>,
) -> Result<()> {
Ok(())
}
}
@@ -370,20 +532,29 @@ pub(crate) async fn has_running_cli_agent(
terminal_driver: &ModelHandle<TerminalDriver>,
foreground: &ModelSpawner<AgentDriver>,
) -> bool {
matches!(
cli_agent_session_status(terminal_driver, foreground).await,
Some(CLIAgentSessionStatus::InProgress)
)
}
/// Returns the tracked CLI agent session status for the terminal, if any.
pub(crate) async fn cli_agent_session_status(
terminal_driver: &ModelHandle<TerminalDriver>,
foreground: &ModelSpawner<AgentDriver>,
) -> Option<CLIAgentSessionStatus> {
let driver = terminal_driver.clone();
let Ok(running) = foreground
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)
.map(|session| session.status.clone())
})
.await
else {
return false;
};
running
.ok()
.flatten()
}
/// Create a [`NamedTempFile`] with the given prefix and write `content` into it.
@@ -393,10 +564,11 @@ pub(crate) async fn has_running_cli_agent(
pub(super) fn write_temp_file(
prefix: &str,
content: &str,
suffix: &str,
) -> Result<NamedTempFile, AgentDriverError> {
let mut file = tempfile::Builder::new()
.prefix(prefix)
.suffix(".txt")
.suffix(suffix)
.tempfile()
.map_err(|e| {
AgentDriverError::ConfigBuildFailed(anyhow::anyhow!(
@@ -457,5 +629,5 @@ pub(super) async fn upload_current_block_snapshot(
}
#[cfg(test)]
#[path = "mod_test.rs"]
#[path = "mod_tests.rs"]
mod tests;
@@ -1,33 +0,0 @@
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"));
}
@@ -0,0 +1,82 @@
use warp_cli::agent::Harness;
use super::{auth_check_command_for, 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"));
}
// --- Runtime error pattern tests ---
#[test]
fn claude_runtime_error_patterns_returns_slice() {
use super::claude_code::ClaudeHarness;
use super::ThirdPartyHarness;
// Patterns are initially empty until validated needles are filled in.
// The trait method must still be callable.
let _: &[&str] = ClaudeHarness.runtime_error_patterns();
}
#[test]
fn codex_runtime_error_patterns_returns_slice() {
use super::codex::CodexHarness;
let _: &[&str] = CodexHarness.runtime_error_patterns();
}
#[test]
fn gemini_runtime_error_patterns_is_empty_by_default() {
use super::gemini::GeminiHarness;
assert!(GeminiHarness.runtime_error_patterns().is_empty());
}
#[test]
fn auth_check_command_for_gemini_is_none() {
assert!(auth_check_command_for(Harness::Gemini).is_none());
}
#[test]
fn auth_check_command_for_oz_is_none() {
assert!(auth_check_command_for(Harness::Oz).is_none());
}
#[test]
fn auth_check_command_for_unsupported_is_none() {
// OpenCode is mapped to HarnessKind::Unsupported and therefore has no
// auth check command of its own.
assert!(auth_check_command_for(Harness::OpenCode).is_none());
}
#[test]
fn auth_check_command_for_unknown_is_none() {
// Harness::Unknown causes harness_kind to return Err; the helper still
// returns None instead of panicking.
assert!(auth_check_command_for(Harness::Unknown).is_none());
}
@@ -0,0 +1,84 @@
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
/// Telemetry events emitted by the third-party harness runtime layer.
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub(crate) enum ThirdPartyHarnessTelemetryEvent {
/// The runtime output scanner observed one of the harness's known
/// failure substrings. Fires once per detection, before any suppression
/// logic, so dashboards can compare raw trigger volume vs. detections
/// that actually fail the run.
RuntimeErrorDetected {
/// CLI command prefix for the harness whose block was scanned
/// (e.g. `"claude"`, `"codex"`).
harness: String,
/// The originating needle from `runtime_error_patterns` that hit.
pattern: String,
},
}
impl TelemetryEvent for ThirdPartyHarnessTelemetryEvent {
fn name(&self) -> &'static str {
ThirdPartyHarnessTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<Value> {
match self {
ThirdPartyHarnessTelemetryEvent::RuntimeErrorDetected { harness, pattern } => {
Some(json!({
"harness": harness,
"pattern": pattern,
}))
}
}
}
fn description(&self) -> &'static str {
ThirdPartyHarnessTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
ThirdPartyHarnessTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
match self {
ThirdPartyHarnessTelemetryEvent::RuntimeErrorDetected { .. } => false,
}
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
galaxy_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for ThirdPartyHarnessTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
ThirdPartyHarnessTelemetryEventDiscriminants::RuntimeErrorDetected => {
"AmbientAgents.ThirdPartyHarness.RuntimeError.Detected"
}
}
}
fn description(&self) -> &'static str {
match self {
ThirdPartyHarnessTelemetryEventDiscriminants::RuntimeErrorDetected => {
"Runtime output scanner detected a known failure substring in a third-party \
harness block."
}
}
}
fn enablement_state(&self) -> EnablementState {
match self {
ThirdPartyHarnessTelemetryEventDiscriminants::RuntimeErrorDetected => {
EnablementState::Always
}
}
}
}
galaxy_core::register_telemetry_event!(ThirdPartyHarnessTelemetryEvent);
@@ -0,0 +1,217 @@
//! Background monitor that scans the running harness block for known runtime
//! failure substrings (e.g. invalid API key, exhausted credits) and reports
//! the first hit so the driver can fail the task fast instead of letting the
//! harness hang.
use std::sync::Arc;
use std::time::Duration;
use regex::escape;
use warpui::ModelSpawner;
use super::terminal::BlockOutputMatch;
use super::AgentDriver;
use crate::terminal::cli_agent_sessions::CLIAgentSessionStatus;
use crate::terminal::model::block::BlockId;
use crate::terminal::model::find::RegexDFAs;
const SCAN_INTERVALS: &[Duration] = &[
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(15),
Duration::from_secs(15),
Duration::from_secs(15),
Duration::from_secs(15),
];
const STALL_POLL_INTERVAL: Duration = Duration::from_secs(10);
const STALL_CONFIRMATION_BUDGET: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub(crate) struct DetectedHarnessError {
pub pattern: String,
pub excerpt: String,
}
/// Build a combined case-insensitive DFA from the harness's static patterns.
pub(crate) fn build_dfas(patterns: &[&'static str]) -> Option<RegexDFAs> {
if patterns.is_empty() {
return None;
}
let escaped: Vec<String> = patterns.iter().map(|p| escape(p)).collect();
let refs: Vec<&str> = escaped.iter().map(String::as_str).collect();
match RegexDFAs::new_many(
&refs, false, // enable_unicode_word_boundary
false, // case_sensitive
) {
Ok(dfas) => Some(dfas),
Err(err) => {
log::warn!("Failed to build harness output DFAs: {err}");
None
}
}
}
/// Map a `matched_text` produced by the combined DFA back to the originating
/// needle. The DFA matched `(p1|p2|…)`, so the matched substring is exactly
/// one of `patterns` up to case — we lowercase-compare to identify it.
pub(crate) fn pattern_for_match(
matched_text: &str,
patterns: &[&'static str],
) -> Option<&'static str> {
let matched_lower = matched_text.to_lowercase();
patterns
.iter()
.copied()
.find(|p| p.to_lowercase() == matched_lower)
}
fn outputs_stalled(before: Option<&str>, after: Option<&str>) -> bool {
matches!((before, after), (Some(a), Some(b)) if a == b)
}
async fn find_match_once(
block_id: &BlockId,
dfas: &Arc<RegexDFAs>,
foreground: &ModelSpawner<AgentDriver>,
) -> Option<BlockOutputMatch> {
let block_id_for_tick = block_id.clone();
let dfas_for_tick = Arc::clone(dfas);
foreground
.spawn(move |me, ctx| {
me.terminal_driver
.as_ref(ctx)
.find_first_match_in_block_output(&block_id_for_tick, &dfas_for_tick, ctx)
})
.await
.ok()
.flatten()
}
async fn fetch_plaintext(
block_id: &BlockId,
foreground: &ModelSpawner<AgentDriver>,
) -> Option<String> {
let block_id_for_tick = block_id.clone();
foreground
.spawn(move |me, ctx| {
me.terminal_driver
.as_ref(ctx)
.block_output_plaintext(&block_id_for_tick, ctx)
})
.await
.ok()
.flatten()
}
/// Run the stall-confirmation loop after a pattern hit.
async fn confirm_stall(
block_id: &BlockId,
dfas: &Arc<RegexDFAs>,
foreground: &ModelSpawner<AgentDriver>,
) -> (Option<BlockOutputMatch>, Duration) {
let mut previous = fetch_plaintext(block_id, foreground).await;
let mut elapsed = Duration::ZERO;
while elapsed < STALL_CONFIRMATION_BUDGET {
warpui::r#async::Timer::after(STALL_POLL_INTERVAL).await;
elapsed += STALL_POLL_INTERVAL;
let current = fetch_plaintext(block_id, foreground).await;
if outputs_stalled(previous.as_deref(), current.as_deref()) {
// Output settled. Re-run the DFA so we report the post-dwell
// match in case the row positions shifted while we waited.
return (find_match_once(block_id, dfas, foreground).await, elapsed);
}
previous = current;
}
(None, elapsed)
}
/// Watch the given block for harness output errors on the
/// [`SCAN_INTERVALS`] cadence.
///
/// On every pattern hit, run a stall-confirmation loop (up to
/// [`STALL_CONFIRMATION_BUDGET`]) and only resolve with
/// `Some(DetectedHarnessError)` when the harness output stabilizes with
/// the pattern still present. If the harness keeps producing output
/// (e.g. spinner frames during an automatic retry), the detection is
/// dropped and the scanner resumes normal polling.
///
/// Returns `None` when the schedule completes without a confirmed hit
/// (or when `patterns` is empty / DFA construction fails).
/// Cancellation-safe: dropping the future stops both loops.
pub(crate) async fn watch_block_for_errors(
block_id: BlockId,
patterns: &'static [&'static str],
foreground: &ModelSpawner<AgentDriver>,
) -> Option<DetectedHarnessError> {
if patterns.is_empty() {
return None;
}
let dfas = Arc::new(build_dfas(patterns)?);
// Total observation budget = sum of all scan intervals. Used as an
// early-exit guard after stall confirmation in case a flaky harness
// has burned most of the window into confirmation loops.
let total_budget: Duration = SCAN_INTERVALS.iter().copied().sum();
let mut elapsed = Duration::ZERO;
for &interval in SCAN_INTERVALS {
warpui::r#async::Timer::after(interval).await;
elapsed += interval;
if find_match_once(&block_id, &dfas, foreground)
.await
.is_none()
{
continue;
}
// Candidate match observed. Confirm via the stall loop so we
// don't false-positive while the harness is mid-retry.
let (confirmed, confirmation_elapsed) = confirm_stall(&block_id, &dfas, foreground).await;
elapsed += confirmation_elapsed;
let Some(hit) = confirmed else {
if elapsed >= total_budget {
break;
}
continue;
};
// The DFA matched one of our needles, so `pattern_for_match`
// should always resolve. Fall back to the matched substring
// verbatim if it somehow doesn't.
let pattern = pattern_for_match(&hit.matched_text, patterns)
.map(str::to_owned)
.unwrap_or_else(|| hit.matched_text.clone());
return Some(DetectedHarnessError {
pattern,
excerpt: cap_excerpt(&hit.excerpt),
});
}
None
}
pub(crate) fn should_suppress_runtime_failure(status: Option<&CLIAgentSessionStatus>) -> bool {
matches!(status, Some(CLIAgentSessionStatus::Success))
}
/// Cap excerpt length so we don't blow up status messages or logs with
/// terminal-width rows.
const EXCERPT_MAX_LEN: usize = 240;
fn cap_excerpt(excerpt: &str) -> String {
if excerpt.chars().count() <= EXCERPT_MAX_LEN {
return excerpt.to_owned();
}
let mut out: String = excerpt.chars().take(EXCERPT_MAX_LEN).collect();
out.push('…');
out
}
#[cfg(test)]
#[path = "harness_output_monitor_tests.rs"]
mod tests;
@@ -0,0 +1,79 @@
use std::time::Duration;
use super::{outputs_stalled, pattern_for_match, STALL_CONFIRMATION_BUDGET, STALL_POLL_INTERVAL};
#[test]
fn pattern_for_match_returns_originating_needle() {
let patterns: &[&'static str] = &["credit balance is too low", "invalid_api_key"];
// The DFA gives us the matched substring (e.g. lifted from the grid as
// mixed case); we must map back to the original `'static` needle.
let resolved = pattern_for_match("Credit Balance Is Too Low", patterns);
assert_eq!(resolved, Some("credit balance is too low"));
}
#[test]
fn pattern_for_match_is_case_insensitive() {
let patterns: &[&'static str] = &["INVALID_API_KEY"];
let resolved = pattern_for_match("invalid_api_key", patterns);
assert_eq!(resolved, Some("INVALID_API_KEY"));
}
#[test]
fn pattern_for_match_returns_none_when_no_match() {
let patterns: &[&'static str] = &["needle a", "needle b"];
assert!(pattern_for_match("entirely different text", patterns).is_none());
}
#[test]
fn pattern_for_match_picks_first_matching_needle() {
// When two needles lowercase-equal the same matched text (degenerate
// case), we deterministically return the first one in the slice.
let patterns: &[&'static str] = &["Foo", "foo"];
let resolved = pattern_for_match("FOO", patterns);
assert_eq!(resolved, Some("Foo"));
}
// --- outputs_stalled / stall confirmation ---
#[test]
fn outputs_stalled_returns_true_when_inputs_equal() {
let snapshot = "Error: credit balance is too low\n";
assert!(outputs_stalled(Some(snapshot), Some(snapshot)));
}
#[test]
fn outputs_stalled_returns_false_when_inputs_differ_by_any_byte() {
// Spinner case: a single character differs between frames. The
// confirmation loop must treat this as "still moving" and not
// mistakenly declare the harness stalled.
let before = "Retrying \u{2807}";
let after = "Retrying \u{2826}";
assert!(!outputs_stalled(Some(before), Some(after)));
}
#[test]
fn outputs_stalled_returns_false_when_either_input_is_none() {
// A failed snapshot fetch must default to "not confirmed" rather than
// killing the harness on a transient lookup error.
assert!(!outputs_stalled(None, Some("data")));
assert!(!outputs_stalled(Some("data"), None));
assert!(!outputs_stalled(None, None));
}
#[test]
fn stall_confirmation_budget_matches_six_poll_intervals() {
// The loop guarantees up to BUDGET/INTERVAL iterations. Pin the ratio
// so a careless tweak to either constant can't accidentally turn the
// confirmation into a single comparison or extend it indefinitely.
assert_eq!(
STALL_CONFIRMATION_BUDGET.as_secs() / STALL_POLL_INTERVAL.as_secs(),
6
);
// And that they're cleanly divisible (no leftover sub-interval window).
assert_eq!(
STALL_CONFIRMATION_BUDGET.as_secs() % STALL_POLL_INTERVAL.as_secs(),
0
);
// Sanity: STALL_POLL_INTERVAL must be non-zero or the loop would spin.
assert!(STALL_POLL_INTERVAL > Duration::ZERO);
}
+52 -42
View File
@@ -1,26 +1,22 @@
pub mod text {
use std::{
collections::HashSet,
fmt,
io::{self, Write},
};
use std::collections::HashSet;
use std::fmt;
use std::io::{self, Write};
const CANCELLED_MESSAGE: &str = "<cancelled>";
use ai::agent::action_result::{FetchConversationResult, ReadSkillResult, UseComputerResult};
use itertools::Itertools;
use crate::{
ai::agent::{
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessageType, AIAgentTodo,
ArtifactCreatedData, CallMCPToolResult, FileGlobResult, FileGlobV2Result, GrepResult,
ReadFilesResult, ReadMCPResourceResult, RequestCommandOutputResult,
RequestFileEditsResult, SearchCodebaseResult, SuggestNewConversationResult,
SuggestPromptResult, TodoOperation, UploadArtifactResult, WebFetchStatus,
WebSearchStatus, WriteToLongRunningShellCommandResult,
},
AIAgentActionResultType,
use crate::ai::agent::{
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessageType, AIAgentTodo,
ArtifactCreatedData, CallMCPToolResult, FileGlobResult, FileGlobV2Result, GrepResult,
ReadFilesResult, ReadMCPResourceResult, RequestCommandOutputResult, RequestFileEditsResult,
SearchCodebaseResult, SuggestNewConversationResult, SuggestPromptResult, TodoOperation,
UploadArtifactResult, WebFetchStatus, WebSearchStatus,
WriteToLongRunningShellCommandResult,
};
use crate::AIAgentActionResultType;
/// Format an agent input as a human-readable string. For action results, it's assumed that
/// the action is shown immediately before this result.
@@ -42,7 +38,8 @@ pub mod text {
| AIAgentInput::StartFromAmbientRunPrompt { .. }
| AIAgentInput::MessagesReceivedFromAgents { .. }
| AIAgentInput::PassiveSuggestionResult { .. }
| AIAgentInput::EventsFromAgents { .. } => {
| AIAgentInput::EventsFromAgents { .. }
| AIAgentInput::OrchestrationConfigUpdate { .. } => {
// Do not include the user query, since it's already provided as input to the agent.
Ok(())
}
@@ -303,6 +300,10 @@ pub mod text {
// SendMessageToAgent is a client-side orchestration action, not used in SDK
AIAgentActionResultType::SendMessageToAgent(_) => Ok(()),
AIAgentActionResultType::AskUserQuestion(_) => Ok(()),
// RunAgents is a desktop-client-only action; not used in the SDK.
AIAgentActionResultType::RunAgents(_) => Ok(()),
// No user-visible payload to emit.
AIAgentActionResultType::WaitForEvents(_) => Ok(()),
},
}
}
@@ -427,6 +428,9 @@ pub mod text {
)?;
}
AIAgentActionType::AskUserQuestion { .. } => (),
// RunAgents is desktop-client-only; SDK driver renders nothing.
AIAgentActionType::RunAgents(_) => (),
AIAgentActionType::WaitForEvents { .. } => (),
},
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
TodoOperation::UpdateTodos { todos } => {
@@ -559,26 +563,20 @@ pub mod text {
}
pub mod json {
use crate::{
ai::agent::{
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessage,
AIAgentOutputMessageType, AIAgentTodo, ArtifactCreatedData, CallMCPToolResult,
FileContext, FileGlobResult, FileGlobV2Result, GrepResult, ReadFilesResult,
ReadMCPResourceResult, RequestCommandOutputResult, RequestFileEditsResult,
SearchCodebaseResult, SubagentCall, TodoOperation, UploadArtifactResult,
WriteToLongRunningShellCommandResult,
},
AIAgentActionResultType,
};
use std::borrow::Cow;
use std::ops::Range;
use serde::Serialize;
use crate::ai::agent::comment::ReviewComment;
use serde::Serialize;
use std::path::Path;
use std::{
borrow::Cow,
io::{self, Write},
ops::Range,
use crate::ai::agent::{
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessage,
AIAgentOutputMessageType, AIAgentTodo, ArtifactCreatedData, CallMCPToolResult, FileContext,
FileGlobResult, FileGlobV2Result, GrepResult, ReadFilesResult, ReadMCPResourceResult,
RequestCommandOutputResult, RequestFileEditsResult, SearchCodebaseResult, SubagentCall,
TodoOperation, UploadArtifactResult, WriteToLongRunningShellCommandResult,
};
use crate::code::buffer_location::LocalOrRemotePath;
/// JSON representation of messages in an agent conversation. This is intentionally not 1:1 with our internal `AIAgent*` types - it's
/// a stable interface for callers.
@@ -742,7 +740,7 @@ pub mod json {
#[derive(Serialize)]
struct JsonComment<'a> {
comment_text: &'a str,
file_path: Option<&'a Path>,
file_path: Option<String>,
line_number: Option<usize>,
head_title: Option<&'a str>,
}
@@ -791,7 +789,8 @@ pub mod json {
| AIAgentInput::StartFromAmbientRunPrompt { .. }
| AIAgentInput::MessagesReceivedFromAgents { .. }
| AIAgentInput::EventsFromAgents { .. }
| AIAgentInput::PassiveSuggestionResult { .. } => None,
| AIAgentInput::PassiveSuggestionResult { .. }
| AIAgentInput::OrchestrationConfigUpdate { .. } => None,
// These input types should not occur in a SDK-run agent.
AIAgentInput::ResumeConversation { .. }
| AIAgentInput::TriggerPassiveSuggestion { .. } => None,
@@ -1108,6 +1107,10 @@ pub mod json {
| AIAgentActionType::SendMessageToAgent { .. }
| AIAgentActionType::TransferShellCommandControlToUser { .. } => None,
AIAgentActionType::AskUserQuestion { .. } => None,
// RunAgents is desktop-client-only; SDK has no JSON
// representation for it.
AIAgentActionType::RunAgents(_) => None,
AIAgentActionType::WaitForEvents { .. } => None,
},
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
TodoOperation::UpdateTodos { todos } => Some(JsonMessage::UpdateTodos {
@@ -1169,7 +1172,11 @@ pub mod json {
fn from(review_comment: &'a ReviewComment) -> Self {
Self {
comment_text: review_comment.content.as_str(),
file_path: review_comment.diff.file_path.as_deref(),
file_path: review_comment
.diff
.file_path
.as_ref()
.map(LocalOrRemotePath::display_path),
line_number: review_comment.diff.line_number,
head_title: review_comment.head_title.as_deref(),
}
@@ -1286,10 +1293,12 @@ pub mod json {
}
}
use std::io::{self, BufWriter, Write};
use galaxy_core::channel::ChannelState;
use crate::ai::agent::{AIAgentText, AIAgentTextSection};
use crate::code::editor_management::CodeSource;
use galaxy_core::channel::ChannelState;
use std::io::{self, BufWriter, Write};
/// Constructs the Oz dashboard URL for a given run ID.
fn run_url(run_id: &str) -> String {
@@ -1328,8 +1337,8 @@ fn format_agent_text<W: Write>(text: &AIAgentText, w: &mut W) -> io::Result<()>
}
match source {
Some(CodeSource::ProjectRules { path }) => {
writeln!(w, " rules_path={}", path.display())?;
Some(CodeSource::ProjectRules { location }) => {
writeln!(w, " rules_path={}", location.display_path())?;
}
Some(CodeSource::Link {
path,
@@ -1348,12 +1357,13 @@ fn format_agent_text<W: Write>(text: &AIAgentText, w: &mut W) -> io::Result<()>
writeln!(w)?;
}
Some(CodeSource::Skill { path, .. }) => {
writeln!(w, " skill_path={}", path.display())?;
Some(CodeSource::Skill { location, .. }) => {
writeln!(w, " skill_path={}", location.display_path())?;
}
Some(CodeSource::AIAction { .. })
| Some(CodeSource::New { .. })
| Some(CodeSource::FileTree { .. })
| Some(CodeSource::CommandPalette { .. })
| Some(CodeSource::Finder { .. })
| None => {}
}
+454 -75
View File
@@ -30,15 +30,23 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use anyhow::{Context as _, Result};
use command::r#async::Command;
use command::Stdio;
use futures::future::join_all;
use tokio::fs::{self as tokio_fs, OpenOptions};
use tokio::io::AsyncWriteExt as _;
use tokio::sync::{mpsc, oneshot};
use galaxy_core::report_error;
use galaxyui::r#async::executor::Background;
use galaxyui::r#async::FutureExt as _;
use crate::ai::agent_sdk::retry::with_bounded_retry;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::ai::{
AIClient, InitialSnapshotToken, SnapshotUploadFileInfo as AiSnapshotUploadFileInfo,
UploadLocalHandoffSnapshotRequest,
};
use crate::server::server_api::harness_support::{
upload_to_target, HarnessSupportClient, SnapshotFileInfo, SnapshotUploadRequest, UploadTarget,
};
@@ -99,6 +107,15 @@ struct DeclarationLine {
path: String,
}
/// Serialize-only sibling of [`DeclarationLine`] used by the writer task to emit `file`
/// entries with a fixed `version` and `kind`.
#[derive(serde::Serialize)]
struct FileDeclaration<'a> {
version: u32,
kind: &'a str,
path: &'a str,
}
/// Invoke `snapshot-declarations.sh` to (re)generate the declarations file consumed by the
/// rest of the upload pipeline.
///
@@ -107,10 +124,11 @@ struct DeclarationLine {
/// from `task_id`. The script appends to the file if it already exists, so
/// repeated invocations within a single run accumulate repos instead of clobbering.
///
/// A missing env var, a missing script, a non-zero exit status, a spawn failure, or a runtime
/// exceeding `script_timeout` are each logged at `log::error!` and returned without aborting the
/// caller — if a previous invocation already produced a declarations file on disk it remains
/// usable; otherwise the upload pipeline becomes a no-op.
/// A missing env var is expected in some paths and is logged at `log::info!`. A missing script,
/// a non-zero exit status, a spawn failure, or a runtime exceeding `script_timeout` are each
/// logged at `log::error!` and returned without aborting the caller — if a previous invocation
/// already produced a declarations file on disk it remains usable; otherwise the upload pipeline
/// becomes a no-op.
///
/// Exposed as a standalone helper so future call sites can trigger declarations generation at
/// other points in the run lifecycle (e.g. periodic mid-run snapshots).
@@ -120,7 +138,7 @@ pub(super) async fn run_declarations_script(
script_timeout: Duration,
) {
let Some(script_path) = std::env::var_os(DECLARATIONS_SCRIPT_PATH_ENV_VAR) else {
log::error!(
log::info!(
"{DECLARATIONS_SCRIPT_PATH_ENV_VAR} is not set; skipping snapshot declarations script (task {task_id})"
);
return;
@@ -199,7 +217,7 @@ fn resolve_declarations_path(task_id: Option<&AmbientAgentTaskId>) -> PathBuf {
/// 3. `{DEFAULT_DECLARATIONS_DIR}/{DEFAULT_DECLARATIONS_FILENAME}` as a final fallback.
fn resolve_declarations_path_with_override(
task_id: Option<&AmbientAgentTaskId>,
override_path: Option<std::ffi::OsString>,
override_path: Option<OsString>,
) -> PathBuf {
if let Some(override_path) = override_path {
return PathBuf::from(override_path);
@@ -216,31 +234,28 @@ fn resolve_declarations_path_with_override(
///
/// Returns `None` when the file is missing, unreadable, or yields no valid entries; logs a
/// WARN describing why in each case. A returned `Some(entries)` is guaranteed non-empty.
fn read_and_parse_declarations(
path: &Path,
task_id: &AmbientAgentTaskId,
) -> Option<Vec<DeclarationEntry>> {
fn read_and_parse_declarations(path: &Path) -> Option<Vec<DeclarationEntry>> {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::warn!(
"Snapshot declarations file not found at '{}'; skipping upload (task {task_id})",
"Snapshot declarations file not found at '{}'; skipping upload",
path.display()
);
return None;
}
Err(e) => {
log::warn!(
"Failed to read snapshot declarations file '{}': {e:#}; skipping upload (task {task_id})",
"Failed to read snapshot declarations file '{}': {e:#}; skipping upload",
path.display()
);
return None;
}
};
let entries = parse_declarations(&contents, task_id);
let entries = parse_declarations(&contents);
if entries.is_empty() {
log::warn!(
"Snapshot declarations file '{}' has no valid entries; skipping upload (task {task_id})",
"Snapshot declarations file '{}' has no valid entries; skipping upload",
path.display()
);
return None;
@@ -254,7 +269,7 @@ fn read_and_parse_declarations(
/// and `path` (absolute path). Blank lines are ignored. Malformed lines (invalid JSON, missing
/// fields, unsupported versions, unknown kind, non-absolute path) are logged at WARN and skipped;
/// they never abort parsing.
fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<DeclarationEntry> {
fn parse_declarations(contents: &str) -> Vec<DeclarationEntry> {
let mut entries = Vec::new();
let mut seen = HashSet::new();
for (index, raw) in contents.lines().enumerate() {
@@ -267,26 +282,26 @@ fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<Decla
Ok(declaration) => declaration,
Err(e) => {
log::warn!(
"Malformed snapshot declarations JSONL line {line_number}: {e:#}: {raw:?} (task {task_id})"
"Malformed snapshot declarations JSONL line {line_number}: {e:#}: {raw:?}"
);
continue;
}
};
if declaration.version != Some(DECLARATION_VERSION) {
log::warn!(
"Malformed snapshot declarations line {line_number} (missing or unsupported version): {raw:?} (task {task_id})"
"Malformed snapshot declarations line {line_number} (missing or unsupported version): {raw:?}"
);
continue;
}
if declaration.path.is_empty() {
log::warn!(
"Malformed snapshot declarations line {line_number} (missing path): {raw:?} (task {task_id})"
"Malformed snapshot declarations line {line_number} (missing path): {raw:?}"
);
continue;
}
if !Path::new(&declaration.path).is_absolute() {
log::warn!(
"Malformed snapshot declarations line {line_number} (non-absolute path): {raw:?} (task {task_id})"
"Malformed snapshot declarations line {line_number} (non-absolute path): {raw:?}"
);
continue;
}
@@ -295,7 +310,7 @@ fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<Decla
"file" => EntryKind::File,
other => {
log::warn!(
"Malformed snapshot declarations line {line_number} (unknown kind '{other}'): {raw:?} (task {task_id})"
"Malformed snapshot declarations line {line_number} (unknown kind '{other}'): {raw:?}"
);
continue;
}
@@ -311,6 +326,243 @@ fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<Decla
entries
}
/// Drop `file` declarations whose path is already covered by a declared `repo` path so the
/// gather step does not double-upload files the repo patch already carries.
fn drop_files_covered_by_repos(entries: Vec<DeclarationEntry>) -> Vec<DeclarationEntry> {
let repo_paths: Vec<PathBuf> = entries
.iter()
.filter(|entry| entry.kind == EntryKind::Repo)
.map(|entry| PathBuf::from(&entry.path))
.collect();
if repo_paths.is_empty() {
return entries;
}
entries
.into_iter()
.filter(|entry| {
if entry.kind != EntryKind::File {
return true;
}
let file_path = Path::new(&entry.path);
for repo in &repo_paths {
if file_path.starts_with(repo) {
log::info!(
"Dropping file declaration '{}' covered by repo '{}'",
entry.path,
repo.display()
);
return false;
}
}
true
})
.collect()
}
// --- Declarations writer: SDK driver → declarations file ---
/// Commands accepted by the async declarations writer task.
enum WriterCommand {
/// Append `file` entries for the given paths to the declarations file.
Append(Vec<String>),
/// Acknowledge once every previously-queued command has finished its fs writes.
Flush(oneshot::Sender<()>),
}
/// Handle used by the SDK driver to enqueue `file` declaration appends from the subscription
/// thread without ever touching the filesystem inline.
///
/// The handle owns an unbounded `mpsc` sender into a dedicated writer task spawned by
/// [`DeclarationsWriterHandle::new`]. The writer task owns the `seen: HashSet<String>` and
/// the resolved declarations path, and processes commands sequentially, which serializes
/// writes within the process. Handles are cheaply cloneable because the underlying sender is;
/// dropping every handle closes the channel and lets the writer task exit cleanly.
#[derive(Clone)]
pub(super) struct DeclarationsWriterHandle {
tx: mpsc::UnboundedSender<WriterCommand>,
}
impl DeclarationsWriterHandle {
/// Spawn the writer task on `background` and return a fire-and-forget handle.
pub(super) fn new(
task_id: AmbientAgentTaskId,
working_dir: PathBuf,
background: &Background,
) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let declarations_path = resolve_declarations_path(Some(&task_id));
background
.spawn(writer_task(rx, declarations_path, working_dir, task_id))
.detach();
Self { tx }
}
/// Test-facing constructor that bypasses env-var-dependent path resolution and uses
/// `tokio::spawn` directly so tests can run without standing up a `Background`.
#[cfg(all(test, not(windows)))]
pub(super) fn new_at_path(
declarations_path: PathBuf,
working_dir: PathBuf,
task_id: AmbientAgentTaskId,
) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(writer_task(rx, declarations_path, working_dir, task_id));
Self { tx }
}
/// Enqueue `paths` for appending as `file` entries.
///
/// Non-blocking; the subscription handler can call this from a sync context. Empty
/// input is a no-op.
pub(super) fn append(&self, paths: Vec<String>) {
if paths.is_empty() {
return;
}
if let Err(e) = self.tx.send(WriterCommand::Append(paths)) {
log::warn!("Declarations writer channel closed; dropping append: {e}");
}
}
/// Awaits until every previously-queued `append` has finished its fs writes.
///
/// Called once from `AgentDriver::run_snapshot_upload` immediately before
/// `snapshot::run_declarations_script`, so no driver-side write is in flight when the
/// bash script starts its own appends.
pub(super) async fn flush(&self) {
let (ack_tx, ack_rx) = oneshot::channel();
if self.tx.send(WriterCommand::Flush(ack_tx)).is_err() {
// Writer task has already exited; nothing is queued, nothing to drain.
return;
}
if ack_rx.await.is_err() {
log::warn!("Declarations writer flush oneshot dropped without ack");
}
}
}
/// Writer task loop: owns the `seen` set, lazily opens the file per write, and services
/// `Append` and `Flush` commands in order.
async fn writer_task(
mut rx: mpsc::UnboundedReceiver<WriterCommand>,
declarations_path: PathBuf,
working_dir: PathBuf,
task_id: AmbientAgentTaskId,
) {
let mut seen: HashSet<String> = HashSet::new();
while let Some(cmd) = rx.recv().await {
match cmd {
WriterCommand::Append(paths) => {
for path in paths {
process_append_path(
path,
&declarations_path,
&working_dir,
&task_id,
&mut seen,
)
.await;
}
}
WriterCommand::Flush(ack) => {
let _ = ack.send(());
}
}
}
}
/// Normalize, preempt against existing repos, and write one JSONL line for `raw_path`.
/// All failures log at WARN and return without advancing `seen`.
async fn process_append_path(
raw_path: String,
declarations_path: &Path,
working_dir: &Path,
task_id: &AmbientAgentTaskId,
seen: &mut HashSet<String>,
) {
let candidate = Path::new(&raw_path);
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
working_dir.join(candidate)
};
if !absolute.is_absolute() {
log::warn!(
"Skipping non-absolute file-edit path {absolute:?} for declarations (task {task_id})"
);
return;
}
let Some(absolute_str) = absolute.to_str().map(str::to_owned) else {
log::warn!(
"Skipping non-UTF-8 file-edit path {absolute:?} for declarations (task {task_id})"
);
return;
};
if seen.contains(&absolute_str) {
return;
}
if path_is_under_existing_repo(&absolute).await {
log::debug!(
"Skipping file declaration for '{absolute_str}': already inside an existing git repo (task {task_id})"
);
seen.insert(absolute_str);
return;
}
match append_declaration_line(declarations_path, &absolute_str).await {
Ok(()) => {
seen.insert(absolute_str);
}
Err(e) => {
log::warn!(
"Failed to append file declaration for '{absolute_str}': {e:#} (task {task_id})"
);
}
}
}
/// Walk ancestors of `path` and return `true` if any of them already contains a `.git`
/// directory. Cheap enough to run per path: one `stat(2)` per ancestor up to `/`.
async fn path_is_under_existing_repo(path: &Path) -> bool {
let mut current = path.parent();
while let Some(dir) = current {
let git_dir = dir.join(".git");
if tokio_fs::try_exists(&git_dir).await.unwrap_or(false) {
return true;
}
current = dir.parent();
}
false
}
/// Open the declarations file in append-create mode and write one JSONL line for `path`.
/// The serialized shape matches the schema the parser expects.
async fn append_declaration_line(declarations_path: &Path, path: &str) -> Result<()> {
if let Some(parent) = declarations_path.parent() {
tokio_fs::create_dir_all(parent)
.await
.with_context(|| format!("create_dir_all {}", parent.display()))?;
}
let mut line = serde_json::to_string(&FileDeclaration {
version: DECLARATION_VERSION,
kind: "file",
path,
})
.context("serialize file declaration")?;
line.push('\n');
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open(declarations_path)
.await
.with_context(|| format!("open declarations file {}", declarations_path.display()))?;
file.write_all(line.as_bytes())
.await
.with_context(|| format!("write declarations file {}", declarations_path.display()))?;
file.flush()
.await
.with_context(|| format!("flush declarations file {}", declarations_path.display()))?;
Ok(())
}
// --- Gather phase: upload blobs and per-entry results ---
struct SnapshotUploadFile {
@@ -447,7 +699,7 @@ pub(super) async fn upload_snapshot_from_declarations(
task_id: &AmbientAgentTaskId,
) {
let declarations_path = resolve_declarations_path(Some(task_id));
let _ = upload_snapshot_from_declarations_file(&declarations_path, client, task_id).await;
let _ = upload_snapshot_from_declarations_file(&declarations_path, client).await;
}
/// Internal entry that reads from an explicit path and returns the structured outcome so tests
@@ -456,13 +708,10 @@ pub(super) async fn upload_snapshot_from_declarations(
async fn upload_snapshot_from_declarations_file(
path: &Path,
client: Arc<dyn HarnessSupportClient>,
task_id: &AmbientAgentTaskId,
) -> Option<SnapshotOutcome> {
log::info!(
"Snapshot upload starting from {} (task {task_id})",
path.display()
);
let declarations = read_and_parse_declarations(path, task_id)?;
log::info!("Snapshot upload starting from {}", path.display());
let declarations = read_and_parse_declarations(path)?;
let declarations = drop_files_covered_by_repos(declarations);
let (repo_count, file_count) = declarations
.iter()
.fold((0usize, 0usize), |(r, f), e| match e.kind {
@@ -470,14 +719,149 @@ async fn upload_snapshot_from_declarations_file(
EntryKind::File => (r, f + 1),
});
log::info!(
"Snapshot declarations: {} entries ({repo_count} repo, {file_count} file) (task {task_id})",
"Snapshot declarations: {} entries ({repo_count} repo, {file_count} file)",
declarations.len(),
);
let outcome = run_pipeline(declarations, client, task_id).await?;
log_snapshot_outcome(&outcome, task_id);
let outcome = run_pipeline(declarations, client).await?;
log_snapshot_outcome(&outcome);
Some(outcome)
}
/// Build the snapshot for a local-to-cloud handoff: gather repo patches and orphan file
/// contents, allocate an initial snapshot token plus presigned upload URLs via
/// `AIClient::upload_local_handoff_snapshot`, and upload the artifacts.
///
/// Returns:
/// - `Ok(Some(initial_snapshot_token))` when a token was minted **and the manifest landed in GCS**.
/// Individual blob uploads may still have failed; the manifest catalogues their status so the
/// cloud agent rehydrates against whatever did land, matching the cloud→cloud best-effort
/// posture.
/// - `Ok(None)` when the workspace was empty (no repos, no orphan files) **or** when the
/// manifest itself failed to upload. Without the manifest the snapshot is unusable, so
/// callers should spawn the cloud agent without an initial snapshot token instead of pointing
/// it at an incomplete prefix. Manifest-upload failures are also routed through
/// `report_error!` so on-call alerting catches the silent regression.
/// - `Err(_)` only for hard failures of `upload_local_handoff_snapshot` itself (auth, etc.).
pub(crate) async fn upload_snapshot_for_handoff(
repo_paths: Vec<PathBuf>,
orphan_file_paths: Vec<PathBuf>,
client: Arc<dyn AIClient>,
http: &http_client::Client,
) -> Result<Option<InitialSnapshotToken>> {
if repo_paths.is_empty() && orphan_file_paths.is_empty() {
log::info!("Handoff snapshot has no declarations; skipping upload");
return Ok(None);
}
let declarations: Vec<DeclarationEntry> = repo_paths
.into_iter()
.map(|path| DeclarationEntry {
kind: EntryKind::Repo,
path: path.display().to_string(),
})
.chain(orphan_file_paths.into_iter().map(|path| DeclarationEntry {
kind: EntryKind::File,
path: path.display().to_string(),
}))
.collect();
let GatheredSnapshot {
manifest_filename,
mut upload_files,
mut repos,
mut files,
mut pre_upload_entries,
} = gather_snapshot_entries(declarations).await;
apply_per_run_cap(
&mut upload_files,
&mut repos,
&mut files,
&mut pre_upload_entries,
);
let mut file_infos: Vec<SnapshotFileInfo> = upload_files
.iter()
.map(|file| SnapshotFileInfo {
filename: file.filename.clone(),
mime_type: file.mime_type.clone(),
})
.collect();
file_infos.push(SnapshotFileInfo {
filename: manifest_filename.clone(),
mime_type: "application/json".to_string(),
});
let upload_request = UploadLocalHandoffSnapshotRequest {
files: file_infos
.iter()
.map(|file| AiSnapshotUploadFileInfo {
filename: file.filename.clone(),
mime_type: file.mime_type.clone(),
})
.collect(),
};
let response = client
.upload_local_handoff_snapshot(upload_request)
.await
.context("failed to allocate initial snapshot token")?;
log::info!(
"Initial snapshot token allocated; expires_at={}, uploads={}",
response.expires_at,
response.uploads.len(),
);
let initial_snapshot_token = response.initial_snapshot_token;
// Server returns `uploads` aligned by index with the request `files` array (and does
// not echo per-entry filenames), so we zip them positionally into a filename-keyed map.
// Any request file the server omits lands in `upload_entry` with no target and is
// marked `skipped` downstream.
if response.uploads.len() != file_infos.len() {
log::warn!(
"Handoff snapshot upload-target response length {} does not match request length {}; \
extras will be marked skipped",
response.uploads.len(),
file_infos.len(),
);
}
let mut target_map: HashMap<String, UploadTarget> = HashMap::new();
for (file, target) in file_infos.iter().zip(response.uploads.into_iter()) {
target_map.insert(file.filename.clone(), target);
}
let Some(outcome) = upload_prepared_snapshot_files(
http,
manifest_filename,
upload_files,
repos,
files,
pre_upload_entries,
target_map,
)
.await
else {
// Manifest serialization failed (already reported via `report_error!` inside
// the helper). Without a manifest the snapshot is unusable, so refuse the token.
return Ok(None);
};
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
log_snapshot_outcome(&outcome);
if !summary.manifest_uploaded {
// Without the manifest the cloud agent has no catalogue to rehydrate from, even
// when individual blobs landed. Alert on-call and refuse the token so we don't
// silently spawn a cloud agent with no recoverable state.
report_error!(anyhow::anyhow!(
"Handoff snapshot manifest failed to upload (blobs: {}/{}); cloud agent will start with no rehydration content",
summary.uploaded,
summary.total,
));
return Ok(None);
}
Ok(Some(initial_snapshot_token))
}
/// Core upload pipeline.
///
/// Gather/read/upload failures are captured in [`SnapshotOutcome::entries`] and never abort
@@ -486,7 +870,6 @@ async fn upload_snapshot_from_declarations_file(
async fn run_pipeline(
declarations: Vec<DeclarationEntry>,
client: Arc<dyn HarnessSupportClient>,
task_id: &AmbientAgentTaskId,
) -> Option<SnapshotOutcome> {
let GatheredSnapshot {
manifest_filename,
@@ -494,11 +877,10 @@ async fn run_pipeline(
repos,
files,
pre_upload_entries,
} = gather_snapshot_entries(declarations, task_id).await;
} = gather_snapshot_entries(declarations).await;
upload_gathered_snapshot(
client,
task_id,
manifest_filename,
upload_files,
repos,
@@ -516,10 +898,7 @@ struct GatheredSnapshot {
pre_upload_entries: Vec<EntryResult>,
}
async fn gather_snapshot_entries(
declarations: Vec<DeclarationEntry>,
task_id: &AmbientAgentTaskId,
) -> GatheredSnapshot {
async fn gather_snapshot_entries(declarations: Vec<DeclarationEntry>) -> GatheredSnapshot {
let mut used_filenames = HashSet::new();
let manifest_filename = unique_filename("snapshot_state.json", &mut used_filenames);
@@ -542,7 +921,6 @@ async fn gather_snapshot_entries(
&mut upload_files,
&mut repos,
&mut pre_upload_entries,
task_id,
)
.await;
}
@@ -553,7 +931,6 @@ async fn gather_snapshot_entries(
&mut upload_files,
&mut files,
&mut pre_upload_entries,
task_id,
)
.await;
}
@@ -571,7 +948,6 @@ async fn gather_snapshot_entries(
async fn upload_gathered_snapshot(
client: Arc<dyn HarnessSupportClient>,
task_id: &AmbientAgentTaskId,
manifest_filename: String,
mut upload_files: Vec<SnapshotUploadFile>,
mut repos: Vec<RepoManifestEntry>,
@@ -587,7 +963,6 @@ async fn upload_gathered_snapshot(
&mut repos,
&mut files,
&mut pre_upload_entries,
task_id,
);
// Ask the server for presigned URLs for every filename we intend to upload —
@@ -619,16 +994,14 @@ async fn upload_gathered_snapshot(
Err(e) => {
// Pipeline-abort: route through report_error! so Sentry captures the structured
// error chain and on-call alerting can fire.
report_error!(e.context(format!(
"Failed to get snapshot upload targets; skipping upload (task {task_id})"
)));
report_error!(e.context("Failed to get snapshot upload targets; skipping upload"));
return None;
}
};
if targets.len() != chunk.len() {
log::warn!(
"Snapshot upload-target response length {} does not match request length {}; \
extras will be marked skipped (task {task_id})",
extras will be marked skipped",
targets.len(),
chunk.len(),
);
@@ -637,14 +1010,33 @@ async fn upload_gathered_snapshot(
target_map.insert(file.filename.clone(), target);
}
}
upload_prepared_snapshot_files(
client.http_client(),
manifest_filename,
upload_files,
repos,
files,
pre_upload_entries,
target_map,
)
.await
}
async fn upload_prepared_snapshot_files(
http: &http_client::Client,
manifest_filename: String,
upload_files: Vec<SnapshotUploadFile>,
mut repos: Vec<RepoManifestEntry>,
mut files: Vec<FileManifestEntry>,
pre_upload_entries: Vec<EntryResult>,
target_map: HashMap<String, UploadTarget>,
) -> Option<SnapshotOutcome> {
// Upload non-manifest blobs concurrently, each with bounded retries on transient errors.
let http = client.http_client();
let upload_futures = upload_files
.iter()
.map(|file| upload_entry(http, file, &target_map, task_id));
.map(|file| upload_entry(http, file, &target_map));
let upload_entries: Vec<EntryResult> = join_all(upload_futures).await;
fold_upload_results(&mut repos, &mut files, &upload_entries, task_id);
fold_upload_results(&mut repos, &mut files, &upload_entries);
// Build and upload the manifest last, with the real outcomes baked in.
let manifest = SnapshotManifest {
@@ -656,9 +1048,8 @@ async fn upload_gathered_snapshot(
Ok(b) => b,
Err(e) => {
// Pipeline-abort: route through report_error! so Sentry captures it.
report_error!(anyhow::Error::from(e).context(format!(
"Failed to serialize snapshot manifest; skipping upload (task {task_id})"
)));
report_error!(anyhow::Error::from(e)
.context("Failed to serialize snapshot manifest; skipping upload"));
return None;
}
};
@@ -671,9 +1062,7 @@ async fn upload_gathered_snapshot(
Err(e) => {
// Capture the full chain for the manifest's `error` field, then surface it
// to Sentry via report_error!.
let e = e.context(format!(
"Failed to upload manifest '{manifest_filename}' (task {task_id})"
));
let e = e.context(format!("Failed to upload manifest '{manifest_filename}'"));
let msg = format!("{e:#}");
report_error!(e);
(false, Some(msg))
@@ -714,7 +1103,6 @@ async fn gather_repo(
upload_files: &mut Vec<SnapshotUploadFile>,
repos: &mut Vec<RepoManifestEntry>,
pre_upload_entries: &mut Vec<EntryResult>,
task_id: &AmbientAgentTaskId,
) {
let repo = Path::new(repo_path);
let metadata = repo_metadata(repo).await;
@@ -756,7 +1144,7 @@ async fn gather_repo(
}
Err(e) => {
let err_str = format!("{e:#}");
log::warn!("Failed to snapshot repo '{repo_path}': {err_str} (task {task_id})");
log::warn!("Failed to snapshot repo '{repo_path}': {err_str}");
repos.push(RepoManifestEntry {
path: repo_path.to_string(),
repo_name: metadata.repo_name,
@@ -783,7 +1171,6 @@ async fn gather_file(
upload_files: &mut Vec<SnapshotUploadFile>,
files: &mut Vec<FileManifestEntry>,
pre_upload_entries: &mut Vec<EntryResult>,
task_id: &AmbientAgentTaskId,
) {
let path = Path::new(file_path);
match tokio::fs::read(path).await {
@@ -812,7 +1199,7 @@ async fn gather_file(
}
Err(e) => {
let err_str = format!("Failed to read file '{file_path}': {e:#}");
log::warn!("{err_str} (task {task_id})");
log::warn!("{err_str}");
files.push(FileManifestEntry {
path: file_path.to_string(),
snapshot_file: None,
@@ -836,13 +1223,9 @@ async fn upload_entry(
http: &http_client::Client,
file: &SnapshotUploadFile,
target_map: &HashMap<String, UploadTarget>,
task_id: &AmbientAgentTaskId,
) -> EntryResult {
let Some(target) = target_map.get(&file.filename) else {
log::warn!(
"No upload target for file '{}', skipping (task {task_id})",
file.filename
);
log::warn!("No upload target for file '{}', skipping", file.filename);
return EntryResult {
label: file.filename.clone(),
status: EntryStatus::Skipped,
@@ -860,10 +1243,7 @@ async fn upload_entry(
},
Err(e) => {
let msg = format!("{e:#}");
log::warn!(
"Failed to upload '{}': {msg} (task {task_id})",
file.filename
);
log::warn!("Failed to upload '{}': {msg}", file.filename);
EntryResult {
label: file.filename.clone(),
status: EntryStatus::Failed,
@@ -879,7 +1259,6 @@ fn fold_upload_results(
repos: &mut [RepoManifestEntry],
files: &mut [FileManifestEntry],
upload_entries: &[EntryResult],
task_id: &AmbientAgentTaskId,
) {
for entry in upload_entries {
if let Some(repo_entry) = repos
@@ -903,7 +1282,7 @@ fn fold_upload_results(
}
EntryStatus::GatherFailed | EntryStatus::ReadFailed => {
log::error!(
"fold_upload_results: unexpected pre-upload status {:?} for repo patch '{}' (task {task_id})",
"fold_upload_results: unexpected pre-upload status {:?} for repo patch '{}'",
entry.status,
entry.label
);
@@ -930,7 +1309,7 @@ fn fold_upload_results(
}
EntryStatus::GatherFailed | EntryStatus::ReadFailed => {
log::error!(
"fold_upload_results: unexpected pre-upload status {:?} for file '{}' (task {task_id})",
"fold_upload_results: unexpected pre-upload status {:?} for file '{}'",
entry.status,
entry.label
);
@@ -949,7 +1328,6 @@ fn apply_per_run_cap(
repos: &mut [RepoManifestEntry],
files: &mut [FileManifestEntry],
pre_upload_entries: &mut Vec<EntryResult>,
task_id: &AmbientAgentTaskId,
) {
let blob_limit = MAX_SNAPSHOT_FILES_PER_RUN.saturating_sub(1);
if upload_files.len() <= blob_limit {
@@ -958,7 +1336,7 @@ fn apply_per_run_cap(
let total_including_manifest = upload_files.len() + 1;
let dropped = upload_files.split_off(blob_limit);
log::warn!(
"Snapshot exceeds per-run cap of {MAX_SNAPSHOT_FILES_PER_RUN} files ({total_including_manifest} declared); dropping {} blob(s) from upload (task {task_id})",
"Snapshot exceeds per-run cap of {MAX_SNAPSHOT_FILES_PER_RUN} files ({total_including_manifest} declared); dropping {} blob(s) from upload",
dropped.len(),
);
let err_msg = format!("exceeded per-run snapshot cap of {MAX_SNAPSHOT_FILES_PER_RUN} files");
@@ -1011,13 +1389,14 @@ fn merge_content_type(target: &UploadTarget, mime_type: &str) -> UploadTarget {
url: target.url.clone(),
method: target.method.clone(),
headers,
fields: target.fields.clone(),
}
}
/// Log the final outcome at INFO when everything uploaded, WARN otherwise. The log line
/// includes per-entry statuses so operators can diagnose partial state without parsing any
/// downstream logs.
fn log_snapshot_outcome(outcome: &SnapshotOutcome, task_id: &AmbientAgentTaskId) {
fn log_snapshot_outcome(outcome: &SnapshotOutcome) {
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
let manifest_bit = if summary.manifest_uploaded {
"manifest: uploaded"
@@ -1025,7 +1404,7 @@ fn log_snapshot_outcome(outcome: &SnapshotOutcome, task_id: &AmbientAgentTaskId)
"manifest: failed"
};
let header = format!(
"Snapshot upload: {}/{} uploaded (failed: {}, skipped: {}, gather_failed: {}, read_failed: {}; {manifest_bit}) (task {task_id})",
"Snapshot upload: {}/{} uploaded (failed: {}, skipped: {}, gather_failed: {}, read_failed: {}; {manifest_bit})",
summary.uploaded,
summary.total,
summary.failed,
+311 -30
View File
@@ -86,6 +86,10 @@ impl HarnessSupportClient for TestClient {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
async fn fetch_transcript(&self) -> Result<bytes::Bytes> {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
async fn get_block_snapshot_upload_target(
&self,
_conversation_id: &AIConversationId,
@@ -112,6 +116,18 @@ impl HarnessSupportClient for TestClient {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
async fn report_clean_shutdown(&self) -> Result<()> {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
async fn report_error_shutdown(
&self,
_error_category: String,
_error_message: String,
) -> Result<()> {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
async fn get_snapshot_upload_targets(
&self,
request: &SnapshotUploadRequest,
@@ -131,6 +147,7 @@ impl HarnessSupportClient for TestClient {
url: format!("{}/upload/{}", self.server_base_url, f.filename),
method: "PUT".to_string(),
headers: HashMap::new(),
fields: Vec::new(),
})
.collect();
let keep = targets.len().saturating_sub(self.drop_trailing_targets);
@@ -138,10 +155,6 @@ impl HarnessSupportClient for TestClient {
Ok(targets)
}
async fn fetch_transcript(&self) -> Result<bytes::Bytes> {
unimplemented!("not used by upload_snapshot_from_declarations_file")
}
fn http_client(&self) -> &http_client::Client {
&self.http
}
@@ -248,7 +261,6 @@ fn run(
.block_on(upload_snapshot_from_declarations_file(
&declarations_path,
client,
&fake_task_id(),
))
.expect("pipeline returned None");
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
@@ -276,7 +288,7 @@ fn parse_declarations_ignores_blank_lines() {
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/file.txt\"}\n",
"\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![
@@ -302,7 +314,7 @@ fn parse_declarations_skips_malformed_lines_without_aborting() {
"{\"version\":1,\"kind\":\"file\"}\n",
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/also-good\",\"extra\":true}\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![
@@ -325,7 +337,7 @@ fn parse_declarations_skips_missing_or_unsupported_versions() {
"{\"version\":2,\"kind\":\"repo\",\"path\":\"/abs/unsupported-version\"}\n",
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/good\"}\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![DeclarationEntry {
@@ -342,7 +354,7 @@ fn parse_declarations_tolerates_crlf_line_endings() {
"{\"version\":1,\"kind\":\"repo\",\"path\":\"/abs/good\"}\r\n",
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/also-good\"}\r\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![
@@ -365,7 +377,7 @@ fn parse_declarations_skips_lines_with_empty_path() {
"{\"version\":1,\"kind\":\"file\",\"path\":\" \"}\n",
"{\"version\":1,\"kind\":\"repo\",\"path\":\"/abs/still-good\"}\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![DeclarationEntry {
@@ -382,7 +394,7 @@ fn parse_declarations_deduplicates_kind_path_pairs() {
"{\"version\":1,\"kind\":\"repo\",\"path\":\"/abs/repo\"}\n",
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/repo\"}\n",
);
let entries = parse_declarations(contents, &fake_task_id());
let entries = parse_declarations(contents);
assert_eq!(
entries,
vec![
@@ -406,11 +418,7 @@ fn upload_skipped_when_declarations_file_missing() {
let client = TestClient::new(server.url());
let outcome = Runtime::new()
.unwrap()
.block_on(upload_snapshot_from_declarations_file(
&missing,
client,
&fake_task_id(),
));
.block_on(upload_snapshot_from_declarations_file(&missing, client));
assert!(
outcome.is_none(),
"missing declarations file should skip the upload"
@@ -426,11 +434,7 @@ fn upload_skipped_when_declarations_file_empty() {
let client = TestClient::new(server.url());
let outcome = Runtime::new()
.unwrap()
.block_on(upload_snapshot_from_declarations_file(
&decl,
client,
&fake_task_id(),
));
.block_on(upload_snapshot_from_declarations_file(&decl, client));
assert!(outcome.is_none(), "empty declarations file should skip");
}
@@ -447,11 +451,7 @@ fn upload_skipped_when_declarations_file_has_no_valid_jsonl_entries() {
let client = TestClient::new(server.url());
let outcome = Runtime::new()
.unwrap()
.block_on(upload_snapshot_from_declarations_file(
&decl,
client,
&fake_task_id(),
));
.block_on(upload_snapshot_from_declarations_file(&decl, client));
assert!(
outcome.is_none(),
"declarations file with no valid entries should skip"
@@ -631,10 +631,12 @@ fn e2e_gather_failed_entry_captured_in_manifest() {
#[test]
fn e2e_read_failed_for_missing_file_continues_pipeline() {
// Point a `file` entry at a path that doesn't exist → read_failed, with a clean repo also
// included so we verify the pipeline didn't abort after the read failure.
// included so we verify the pipeline didn't abort after the read failure. Keep the missing
// file outside the repo so the repo-overlap filter does not strip it before gather.
let tempdir = snaptest_tempdir();
init_git_repo(tempdir.path(), false);
let missing_file = tempdir.path().join("does-not-exist.txt");
let missing_dir = snaptest_tempdir();
let missing_file = missing_dir.path().join("does-not-exist.txt");
let decl_dir = snaptest_tempdir();
let mut server = Server::new();
@@ -978,7 +980,6 @@ fn e2e_get_snapshot_upload_targets_failure_returns_none() {
.block_on(upload_snapshot_from_declarations_file(
&declarations_path,
client,
&fake_task_id(),
));
assert!(
outcome.is_none(),
@@ -1022,7 +1023,6 @@ fn e2e_short_response_leaves_trailing_file_without_target() {
.block_on(upload_snapshot_from_declarations_file(
&declarations_path,
client,
&fake_task_id(),
))
.expect("pipeline returned None");
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
@@ -1182,3 +1182,284 @@ fn e2e_per_run_cap_drops_excess_blobs_as_skipped() {
}
upload_mock.assert();
}
// ------------------------------------------------------------------------------------------------
// REMOTE-1465: repo-overlap dedup + DeclarationsWriterHandle.
// ------------------------------------------------------------------------------------------------
/// Build a `DeclarationEntry` without exposing the private type to call sites.
fn repo_entry(path: &str) -> DeclarationEntry {
DeclarationEntry {
kind: EntryKind::Repo,
path: path.to_string(),
}
}
fn file_entry(path: &str) -> DeclarationEntry {
DeclarationEntry {
kind: EntryKind::File,
path: path.to_string(),
}
}
#[test]
fn drop_files_covered_by_repos_keeps_everything_when_no_repos_declared() {
let entries = vec![
file_entry("/abs/outside.txt"),
file_entry("/other/also-outside.txt"),
];
let after = drop_files_covered_by_repos(entries.clone());
assert_eq!(after, entries);
}
#[test]
fn drop_files_covered_by_repos_drops_file_inside_repo_keeps_file_outside() {
let entries = vec![
repo_entry("/workspace/my-repo"),
file_entry("/workspace/my-repo/src/foo.rs"),
file_entry("/tmp/outside.txt"),
];
let after = drop_files_covered_by_repos(entries);
assert_eq!(
after,
vec![
repo_entry("/workspace/my-repo"),
file_entry("/tmp/outside.txt"),
]
);
}
#[test]
fn drop_files_covered_by_repos_handles_nested_repo_paths() {
// A file under /a/b/sub should be filtered by either /a or /a/b/sub.
let entries = vec![
repo_entry("/a"),
repo_entry("/a/b/sub"),
file_entry("/a/b/sub/file.txt"),
file_entry("/a/top.txt"),
file_entry("/unrelated.txt"),
];
let after = drop_files_covered_by_repos(entries);
assert_eq!(
after,
vec![
repo_entry("/a"),
repo_entry("/a/b/sub"),
file_entry("/unrelated.txt"),
]
);
}
/// Parse the declarations file written by `DeclarationsWriterHandle` into the paths we care
/// about for assertions, ignoring any lines the helper tests weren't asked to produce.
fn parsed_file_paths(path: &Path) -> Vec<String> {
let contents = fs::read_to_string(path).unwrap_or_default();
let entries = parse_declarations(&contents);
entries
.into_iter()
.filter(|e| e.kind == EntryKind::File)
.map(|e| e.path)
.collect()
}
#[test]
fn declarations_writer_appends_unique_absolute_paths_once() {
let tempdir = snaptest_tempdir();
let workspace = tempdir.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let decl_path = tempdir.path().join("declarations.jsonl");
let task_id = fake_task_id();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let handle =
DeclarationsWriterHandle::new_at_path(decl_path.clone(), workspace.clone(), task_id);
let path_one = workspace.join("one.txt");
let path_two = workspace.join("two.txt");
handle.append(vec![
path_one.to_string_lossy().into_owned(),
path_two.to_string_lossy().into_owned(),
// Duplicate: should still only produce one entry per unique path.
path_one.to_string_lossy().into_owned(),
]);
handle.flush().await;
// A second batch that re-declares path_one should also be a no-op.
handle.append(vec![path_one.to_string_lossy().into_owned()]);
handle.flush().await;
});
let paths = parsed_file_paths(&decl_path);
assert_eq!(
paths,
vec![
workspace.join("one.txt").to_string_lossy().into_owned(),
workspace.join("two.txt").to_string_lossy().into_owned(),
]
);
}
#[test]
fn declarations_writer_resolves_relative_paths_against_working_dir() {
let tempdir = snaptest_tempdir();
let workspace = tempdir.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let decl_path = tempdir.path().join("declarations.jsonl");
let task_id = fake_task_id();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let handle =
DeclarationsWriterHandle::new_at_path(decl_path.clone(), workspace.clone(), task_id);
handle.append(vec!["notes/relative.txt".to_string()]);
handle.flush().await;
});
let paths = parsed_file_paths(&decl_path);
assert_eq!(
paths,
vec![workspace
.join("notes/relative.txt")
.to_string_lossy()
.into_owned()]
);
}
#[test]
fn declarations_writer_continues_after_per_path_write_failures() {
// Pre-create a directory at the declarations file path so the first append's open call
// fails. Once we remove the directory, a subsequent append must succeed, proving the
// writer task absorbed the failure and kept servicing commands.
let tempdir = snaptest_tempdir();
let workspace = tempdir.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let decl_path = tempdir.path().join("declarations.jsonl");
fs::create_dir(&decl_path).unwrap();
let task_id = fake_task_id();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let handle =
DeclarationsWriterHandle::new_at_path(decl_path.clone(), workspace.clone(), task_id);
handle.append(vec![workspace
.join("first.txt")
.to_string_lossy()
.into_owned()]);
handle.flush().await;
// Replace the staged directory so the next append's open call can succeed.
fs::remove_dir(&decl_path).unwrap();
handle.append(vec![workspace
.join("second.txt")
.to_string_lossy()
.into_owned()]);
handle.flush().await;
});
let paths = parsed_file_paths(&decl_path);
assert_eq!(
paths,
vec![workspace.join("second.txt").to_string_lossy().into_owned()],
"writer task should absorb the first failure and process the second append"
);
}
#[test]
fn declarations_writer_preempts_paths_inside_existing_repo() {
let tempdir = snaptest_tempdir();
// Simulate an existing repo by creating the `.git` directory the ancestor walker checks.
let repo = tempdir.path().join("existing-repo");
fs::create_dir_all(repo.join(".git")).unwrap();
let inside = repo.join("inside.txt");
let outside = tempdir.path().join("outside.txt");
let decl_path = tempdir.path().join("declarations.jsonl");
let task_id = fake_task_id();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let handle = DeclarationsWriterHandle::new_at_path(
decl_path.clone(),
tempdir.path().to_path_buf(),
task_id,
);
handle.append(vec![
inside.to_string_lossy().into_owned(),
outside.to_string_lossy().into_owned(),
]);
handle.flush().await;
});
let paths = parsed_file_paths(&decl_path);
assert_eq!(paths, vec![outside.to_string_lossy().into_owned()]);
}
#[test]
fn e2e_repo_plus_inside_and_outside_files_filters_overlap() {
// The writer-written declarations file feeds straight into the upload pipeline. Pair one
// `repo` with two `file` entries (one inside the repo, one outside). The gather-time
// overlap filter should drop the inside-repo file entry before upload so only the repo's
// patch + the outside-repo file + the manifest land on the server.
let repo_dir = snaptest_tempdir();
init_git_repo(repo_dir.path(), true);
let inside_file = repo_dir.path().join("new-untracked.txt");
fs::write(&inside_file, b"tracked-or-not, handled by the patch\n").unwrap();
let outside_dir = snaptest_tempdir();
let outside_file = outside_dir.path().join("standalone_log.txt");
fs::write(&outside_file, b"agent-produced log\n").unwrap();
let decl_dir = snaptest_tempdir();
let decl_path = decl_dir.path().join("snapshot-declarations.jsonl");
let contents = format!(
concat!(
"{{\"version\":1,\"kind\":\"repo\",\"path\":{repo:?}}}\n",
"{{\"version\":1,\"kind\":\"file\",\"path\":{inside:?}}}\n",
"{{\"version\":1,\"kind\":\"file\",\"path\":{outside:?}}}\n",
),
repo = repo_dir.path().to_string_lossy(),
inside = inside_file.to_string_lossy(),
outside = outside_file.to_string_lossy(),
);
fs::write(&decl_path, contents).unwrap();
let mut server = Server::new();
let patch_mock = server
.mock("PUT", upload_path(r".+\.patch"))
.with_status(200)
.expect(1)
.create();
let file_mock = server
.mock("PUT", upload_path("standalone_log\\.txt"))
.with_status(200)
.expect(1)
.create();
let manifest_mock = server
.mock("PUT", upload_path("snapshot_state\\.json"))
.match_body(Matcher::PartialJson(serde_json::json!({
"files": [
{
"path": outside_file.to_string_lossy(),
"status": "uploaded",
"uploaded": true,
}
],
})))
.with_status(200)
.expect(1)
.create();
let client = TestClient::new(server.url());
let outcome = Runtime::new()
.unwrap()
.block_on(upload_snapshot_from_declarations_file(&decl_path, client))
.expect("pipeline returned None");
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
assert!(summary.all_uploaded(), "expected all uploads to succeed");
// repo patch + outside file + manifest = 3 uploaded entries total; the inside-repo file
// entry was filtered before gather so it never hits the entries list.
assert_eq!(summary.uploaded, 3);
assert_eq!(summary.total, 3);
assert!(outcome.manifest_uploaded);
patch_mock.assert();
file_mock.assert();
manifest_mock.assert();
}
+234 -63
View File
@@ -1,47 +1,82 @@
use std::{
collections::HashMap,
ffi::OsString,
future::Future,
path::PathBuf,
pin::Pin,
sync::Arc,
task::{Context, Poll},
time::Duration,
};
use std::collections::HashMap;
use std::ffi::OsString;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use futures::channel::oneshot;
use session_sharing_protocol::common::{Role, SessionId};
use session_sharing_protocol::sharer::SessionRetentionReason;
use galaxy_cli::share::{ShareAccessLevel, ShareRequest, ShareSubject};
use galaxy_completer::completer::CommandOutput;
use galaxy_core::command::ExitCode;
use galaxy_core::features::FeatureFlag;
use galaxy_terminal::model::grid::Dimensions;
use galaxy_util::path::ShellFamily;
use galaxyui::{
r#async::FutureExt, AppContext, Entity, ModelContext, ModelHandle, SingletonEntity as _,
ViewHandle,
};
use session_sharing_protocol::common::{Role, SessionId};
use session_sharing_protocol::sharer::SessionSourceType;
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 galaxyui::r#async::FutureExt;
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
use super::AgentDriverError;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::attachment_utils::attachments_download_dir;
use crate::pane_group::NewTerminalOptions;
use crate::root_view::{open_new_with_workspace_source, NewWorkspaceSource};
use crate::terminal::model::block::{BlockId, SerializedBlock};
use crate::terminal::model::find::RegexDFAs;
use crate::terminal::model::grid::RespectDisplayedOutput;
use crate::terminal::model::index::Point;
use crate::terminal::model::session::ExecuteCommandOptions;
use crate::terminal::model::RespectObfuscatedSecrets;
use crate::terminal::shared_session::{self, IsSharedSessionCreator, SharedSessionSource};
use crate::terminal::shell::ShellType;
use crate::terminal::view::{ConversationRestorationInNewPaneType, Event};
use crate::terminal::TerminalView;
use crate::workspaces::user_workspaces::UserWorkspaces;
/// Describes why a terminal session bootstrap failed.
#[derive(Debug)]
pub(crate) enum BootstrapError {
/// The PTY or shell process failed before the bootstrap script completed.
/// When `reason` is `Some`, the message is
/// "Shell spawn failed: {reason}. Check the Warp logs for details."
/// When `reason` is `None`, it is
/// "Shell spawn failed. Check the Warp logs for details."
PtySpawnFailed { reason: Option<String> },
/// The bootstrap script did not complete within the expected time.
TimedOut,
/// An unexpected internal error in the bootstrap channel
/// (e.g. the sender was dropped without sending).
InternalError,
}
impl std::fmt::Display for BootstrapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BootstrapError::PtySpawnFailed { reason: Some(r) } => {
write!(
f,
"Shell spawn failed: {r}. Check the Warp logs for details."
)
}
BootstrapError::PtySpawnFailed { reason: None } => {
write!(f, "Shell spawn failed. Check the Warp logs for details.")
}
BootstrapError::TimedOut => write!(
f,
"Terminal session did not start within the expected time. \
Check the Warp logs for details."
),
BootstrapError::InternalError => {
write!(f, "An unexpected internal error occurred during bootstrap.")
}
}
}
}
impl std::error::Error for BootstrapError {}
/// Describes why an agent's session-sharing request failed.
#[derive(Debug, thiserror::Error)]
@@ -66,7 +101,8 @@ pub(crate) enum ShareSessionError {
}
const TERMINAL_SESSION_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(60);
const TERMINAL_SESSION_SHARE_DELAY: Duration = Duration::from_secs(10);
/// The total time to wait for session sharing to start, including retries.
const TERMINAL_SESSION_SHARE_DELAY: Duration = Duration::from_secs(20);
/// Options for creating the terminal view before constructing a [`TerminalDriver`].
pub(crate) struct TerminalDriverOptions {
@@ -97,7 +133,12 @@ pub(crate) enum TerminalDriverEvent {
/// - Detecting block completion
pub(crate) struct TerminalDriver {
terminal_view: ViewHandle<TerminalView>,
session_bootstrapped: Condition,
/// Sender half of the bootstrap result channel. Exactly one of
/// `SessionBootstrapped`, `PtySpawnFailed`, or `Exited` (pre-bootstrap)
/// will send the outcome; all others no-op after the first send.
bootstrap_tx: Option<oneshot::Sender<Result<(), BootstrapError>>>,
/// Receiver half consumed by `wait_for_session_bootstrapped`.
bootstrap_rx: Option<oneshot::Receiver<Result<(), BootstrapError>>>,
/// The session ID once sharing has been established.
shared_session_id: Option<SessionId>,
/// Receiver for the session sharing result. Present when sharing is expected
@@ -126,9 +167,7 @@ fn create_terminal_view(
) -> 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()),
},
source: SharedSessionSource::ambient_agent(options.task_id.map(|t| t.to_string())),
}
} else {
IsSharedSessionCreator::No
@@ -192,8 +231,6 @@ impl TerminalDriver {
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.
@@ -213,6 +250,7 @@ impl TerminalDriver {
let _ = tx.send(Err(ShareSessionError::Disabled));
(None, Some(rx))
} else {
log::info!("Waiting for requested session sharing to start");
let (tx, rx) = oneshot::channel();
(Some(tx), Some(rx))
}
@@ -234,13 +272,14 @@ impl TerminalDriver {
});
}
ctx.subscribe_to_view(&terminal_view, move |me, event, ctx| {
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 (bootstrap_tx_inner, bootstrap_rx) = oneshot::channel::<Result<(), BootstrapError>>();
// If bootstrap already completed before we subscribed, resolve the
// channel immediately so `wait_for_session_bootstrapped` doesn't block.
let already_bootstrapped = terminal_view.read(ctx, |terminal, _| {
terminal
.model
@@ -248,13 +287,17 @@ impl TerminalDriver {
.block_list()
.is_bootstrapping_precmd_done()
});
if already_bootstrapped {
session_bootstrapped.set();
}
let bootstrap_tx = if already_bootstrapped {
let _ = bootstrap_tx_inner.send(Ok(()));
None
} else {
Some(bootstrap_tx_inner)
};
Self {
terminal_view,
session_bootstrapped,
bootstrap_tx,
bootstrap_rx: Some(bootstrap_rx),
shared_session_id: None,
session_share_rx,
pending_share_requests: Vec::new(),
@@ -362,6 +405,62 @@ impl TerminalDriver {
.map(SerializedBlock::from)
}
/// Full visible plaintext of `block_id`'s output grid (no ANSI escape
/// sequences; secrets obfuscated). Used by the harness output monitor
/// to detect whether the block has stalled — two byte-identical
/// snapshots taken N seconds apart imply the harness has produced no
/// new output and no spinner activity.
///
/// We intentionally pass `None` for `max_rows` so we compare the entire
/// visible output; capping the row count could falsely report "stalled"
/// when content scrolled below the cap actually changed.
pub fn block_output_plaintext(&self, block_id: &BlockId, ctx: &AppContext) -> Option<String> {
let terminal = self.terminal_view.as_ref(ctx);
let model = terminal.model.lock();
let block = model.block_list().block_with_id(block_id)?;
Some(block.output_grid().contents_to_string(
false, // include_escape_sequences
None, // max_rows: full visible output
))
}
pub fn find_first_match_in_block_output(
&self,
block_id: &BlockId,
dfas: &RegexDFAs,
ctx: &AppContext,
) -> Option<BlockOutputMatch> {
let terminal = self.terminal_view.as_ref(ctx);
let model = terminal.model.lock();
let block = model.block_list().block_with_id(block_id)?;
let grid = block.output_grid();
let m = grid.find(dfas).next()?;
let handler = grid.grid_handler();
let matched_text = handler.bounds_to_string(
*m.start(),
*m.end(),
false, // include_esc_sequences
RespectObfuscatedSecrets::Yes,
false, // force_secrets_obfuscated
RespectDisplayedOutput::Yes,
);
let cols = handler.columns();
let row_start = Point::new(m.start().row, 0);
let row_end = Point::new(m.end().row, cols.saturating_sub(1));
let excerpt = handler.bounds_to_string(
row_start,
row_end,
false,
RespectObfuscatedSecrets::Yes,
false,
RespectDisplayedOutput::Yes,
);
Some(BlockOutputMatch {
matched_text: matched_text.trim().to_owned(),
excerpt: excerpt.trim().to_owned(),
})
}
/// Execute a command in the terminal and return a future that resolves to a
/// [`CommandHandle`] once the command starts executing.
pub fn execute_command(
@@ -487,21 +586,32 @@ impl TerminalDriver {
/// Returns a future that resolves when the session has bootstrapped.
///
/// This only waits for the `SessionBootstrapped` terminal view event.
/// The bootstrap result channel carries `Ok(())` on success or
/// `Err(BootstrapError)` on failure. If the channel times out, the error
/// is `BootstrapError::TimedOut`.
pub fn wait_for_session_bootstrapped(
&self,
) -> impl Future<Output = Result<(), AgentDriverError>> {
let session_bootstrapped = self.session_bootstrapped.clone();
&mut self,
) -> impl Future<Output = Result<(), BootstrapError>> {
let bootstrap_rx = self.bootstrap_rx.take();
async move {
session_bootstrapped
.wait()
.with_timeout(TERMINAL_SESSION_BOOTSTRAP_TIMEOUT)
.await
.map_err(|_| {
log::error!("Timed out waiting for session bootstrap");
AgentDriverError::BootstrapFailed
})
let result = if let Some(rx) = bootstrap_rx {
// Map channel cancellation (sender dropped without sending)
// to InternalError — this shouldn't happen in practice.
let inner = async move { rx.await.unwrap_or(Err(BootstrapError::InternalError)) };
match inner.with_timeout(TERMINAL_SESSION_BOOTSTRAP_TIMEOUT).await {
Ok(result) => result,
Err(_timeout) => Err(BootstrapError::TimedOut),
}
} else {
// bootstrap_rx already consumed — shouldn't happen in normal flow.
Err(BootstrapError::InternalError)
};
if let Err(ref e) = result {
log::error!("Terminal bootstrap failed: {e}");
}
result
}
}
@@ -545,6 +655,42 @@ impl TerminalDriver {
}
}
}
pub fn extend_shared_session_retention(
&mut self,
reason: SessionRetentionReason,
ctx: &mut ModelContext<Self>,
) {
self.terminal_view.update(ctx, |terminal, ctx| {
if !terminal
.model
.lock()
.shared_session_status()
.is_active_sharer()
{
log::warn!(
"Tried to extend shared session retention before sharing was active: {reason:?}"
);
return;
}
log::info!("Emitting request to extend shared session retention: {reason:?}");
ctx.emit(Event::ExtendSessionRetention { reason });
});
}
}
/// The first DFA match returned by
/// [`TerminalDriver::find_first_match_in_block_output`].
///
/// `matched_text` is the exact substring from the grid (no ANSI escapes),
/// used by the harness output monitor to map the hit back to the originating
/// pattern. `excerpt` is the full row(s) containing the match, also as
/// plaintext, suitable for surfacing in user-visible error messages.
#[derive(Debug, Clone)]
pub(crate) struct BlockOutputMatch {
pub matched_text: String,
pub excerpt: String,
}
/// A handle to a running terminal command.
@@ -584,7 +730,28 @@ impl TerminalDriver {
) {
match event {
crate::terminal::view::Event::SessionBootstrapped => {
self.session_bootstrapped.set();
if let Some(tx) = self.bootstrap_tx.take() {
let _ = tx.send(Ok(()));
}
}
crate::terminal::view::Event::PtySpawnFailed { reason } => {
// Signal the bootstrap waiter immediately so it doesn't wait
// for the full 60 s timeout when a spawn failure has already
// been confirmed.
if let Some(tx) = self.bootstrap_tx.take() {
let _ = tx.send(Err(BootstrapError::PtySpawnFailed {
reason: Some(reason.clone()),
}));
}
}
crate::terminal::view::Event::Exited => {
// The shell process exited before bootstrap completed —
// cancel the wait immediately rather than sitting out the
// full 60 s timeout. No specific reason is known at this
// point; the logs will have details.
if let Some(tx) = self.bootstrap_tx.take() {
let _ = tx.send(Err(BootstrapError::PtySpawnFailed { reason: None }));
}
}
crate::terminal::view::Event::SlowBootstrap => {
ctx.emit(TerminalDriverEvent::SlowBootstrap);
@@ -646,3 +813,7 @@ impl TerminalDriver {
}
}
}
#[cfg(test)]
#[path = "terminal_tests.rs"]
mod tests;
@@ -0,0 +1,58 @@
use std::cell::RefCell;
use std::rc::Rc;
use session_sharing_protocol::sharer::SessionRetentionReason;
use warpui::App;
use super::TerminalDriver;
use crate::terminal::shared_session::SharedSessionStatus;
use crate::terminal::view::Event;
use crate::test_util::add_window_with_terminal;
use crate::test_util::terminal::initialize_app_for_terminal_view;
#[test]
fn extend_shared_session_retention_emits_event_for_active_sharer() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal_view = add_window_with_terminal(&mut app, None);
let terminal_driver =
app.update(|ctx| TerminalDriver::create_from_existing_view(terminal_view.clone(), ctx));
let emitted_reasons = Rc::new(RefCell::new(Vec::new()));
app.update(|ctx| {
let emitted_reasons = emitted_reasons.clone();
ctx.subscribe_to_view(&terminal_view, move |_, event, _| {
if let Event::ExtendSessionRetention { reason } = event {
emitted_reasons.borrow_mut().push(*reason);
}
});
});
terminal_driver.update(&mut app, |driver, ctx| {
driver.extend_shared_session_retention(SessionRetentionReason::SetupFailed, ctx);
});
assert!(
emitted_reasons.borrow().is_empty(),
"retention should not be extended before session sharing is active"
);
terminal_view.update(&mut app, |view, _| {
view.model
.lock()
.set_shared_session_status(SharedSessionStatus::ActiveSharer);
});
terminal_driver.update(&mut app, |driver, ctx| {
driver.extend_shared_session_retention(SessionRetentionReason::SetupFailed, ctx);
});
let emitted_reasons = emitted_reasons.borrow();
assert_eq!(emitted_reasons.len(), 1);
assert!(matches!(
emitted_reasons[0],
SessionRetentionReason::SetupFailed
));
});
}
+782 -6
View File
@@ -1,24 +1,49 @@
use std::{ffi::OsString, sync::Arc, time::Duration};
use std::collections::{HashMap, HashSet};
use std::ffi::OsString;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use cloud_object_models::CodeForge;
use futures::channel::oneshot;
use futures::executor::block_on;
use repo_metadata::{DirectoryWatcher, RepoMetadataEvent, RepoMetadataModel, RepositoryIdentifier};
use tempfile::TempDir;
use galaxy_cli::agent::Harness;
use galaxy_cli::mcp::MCPSpec;
use galaxy_cli::skill::SkillSpec;
use galaxy_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 galaxy_core::channel::ChannelState;
use galaxy_graphql::mutations::create_managed_mcp_client_config::{
CreateManagedMcpClientConfigOutput, ManagedMcpTransportKind,
};
use galaxy_graphql::response_context::ResponseContext;
use galaxy_managed_secrets::ManagedSecretValue;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::{App, SingletonEntity as _};
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,
build_secret_env_vars, AgentDriver, AgentDriverError, 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;
use crate::ai::agent::{
task::TaskId, AIAgentActionResult, AIAgentActionResultType, AIAgentInput, AIAgentOutput,
AIAgentActionResult, AIAgentActionResultType, AIAgentInput, AIAgentOutput,
AIAgentOutputMessage, ArtifactCreatedData, MessageId, UploadArtifactResult,
};
use crate::ai::agent_sdk::task_env_vars;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::cloud_environments::{GithubRepo, SourceRepo};
use crate::ai::mcp::parsing::normalize_mcp_json;
use crate::ai::{agent_sdk::task_env_vars, ambient_agents::AmbientAgentTaskId};
use crate::ai::mcp::JSONTransportType;
use crate::ai::skills::SkillManager;
use crate::server::server_api::managed_mcp::MockManagedMcpClient;
use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view};
#[test]
fn test_normalize_single_cli_server() {
@@ -109,6 +134,239 @@ fn test_normalize_sse_server_with_headers() {
);
}
fn managed_client_config_output(mcp_config_json: &str) -> CreateManagedMcpClientConfigOutput {
CreateManagedMcpClientConfigOutput {
transport_kind: ManagedMcpTransportKind::Command,
mcp_config_json: mcp_config_json.to_string(),
proxy_url: None,
proxy_token: None,
authorization_header_name: None,
authorization_header_value: None,
expires_at: None,
response_context: ResponseContext {
server_version: None,
},
}
}
fn raw_secret(value: &str) -> ManagedSecretValue {
ManagedSecretValue::RawValue {
value: value.to_string(),
}
}
fn render_installations(
installations: Vec<crate::ai::mcp::TemplatableMCPServerInstallation>,
secrets: HashMap<String, ManagedSecretValue>,
) -> HashMap<String, crate::ai::mcp::JSONMCPServer> {
AgentDriver::mcp_installations_to_json(installations, &secrets).unwrap()
}
#[test]
fn managed_resolver_local_uuid_does_not_call_managed_client() {
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let mock = MockManagedMcpClient::new();
let local_installed_uuids = HashSet::from([uuid]);
let resolved = block_on(AgentDriver::resolve_mcp_specs_with_local_uuids(
&[MCPSpec::Uuid(uuid)],
&local_installed_uuids,
Arc::new(mock),
))
.unwrap();
assert_eq!(resolved.local_uuids, vec![uuid]);
assert!(resolved.ephemeral_installations.is_empty());
}
#[test]
fn managed_resolver_non_local_uuid_calls_managed_client() {
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let config_json =
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","env":{"API_TOKEN":"{{API_TOKEN}}"}}}}"#;
let mut mock = MockManagedMcpClient::new();
mock.expect_create_managed_mcp_client_config()
.times(1)
.returning(move |requested_uid| {
assert_eq!(requested_uid, uuid);
Ok(managed_client_config_output(config_json))
});
let resolved = block_on(AgentDriver::resolve_mcp_specs_with_local_uuids(
&[MCPSpec::Uuid(uuid)],
&HashSet::new(),
Arc::new(mock),
))
.unwrap();
assert!(resolved.local_uuids.is_empty());
assert_eq!(resolved.ephemeral_installations.len(), 1);
}
#[test]
fn managed_command_config_env_placeholder_uses_local_secret() {
let installations = AgentDriver::installations_from_managed_client_config_json(
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","env":{"API_TOKEN":"{{API_TOKEN}}"}}}}"#,
)
.unwrap();
let rendered = render_installations(
installations,
HashMap::from([("API_TOKEN".to_string(), raw_secret("real"))]),
);
match &rendered["GitHub MCP"].transport_type {
JSONTransportType::CLIServer { env, .. } => {
assert_eq!(env.get("API_TOKEN").map(String::as_str), Some("real"));
}
other => panic!("expected CLI server, got {other:?}"),
}
}
#[test]
fn managed_command_config_arg_placeholder_uses_local_secret() {
let installations = AgentDriver::installations_from_managed_client_config_json(
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","args":["--token={{API_TOKEN}}"]}}}"#,
)
.unwrap();
let rendered = render_installations(
installations,
HashMap::from([("API_TOKEN".to_string(), raw_secret("real"))]),
);
match &rendered["GitHub MCP"].transport_type {
JSONTransportType::CLIServer { args, .. } => {
assert_eq!(args, &vec!["--token=real".to_string()]);
}
other => panic!("expected CLI server, got {other:?}"),
}
}
#[test]
fn managed_command_config_preserves_literal_env_when_synthesizing_arg_placeholder() {
let installations = AgentDriver::installations_from_managed_client_config_json(
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","args":["--token={{API_TOKEN}}"],"env":{"LOG_LEVEL":"info"}}}}"#,
)
.unwrap();
let rendered = render_installations(
installations,
HashMap::from([("API_TOKEN".to_string(), raw_secret("real"))]),
);
match &rendered["GitHub MCP"].transport_type {
JSONTransportType::CLIServer { args, env, .. } => {
assert_eq!(args, &vec!["--token=real".to_string()]);
assert_eq!(env.get("LOG_LEVEL").map(String::as_str), Some("info"));
}
other => panic!("expected CLI server, got {other:?}"),
}
}
#[test]
fn managed_url_config_preserves_proxy_url_and_header() {
let installations = AgentDriver::installations_from_managed_client_config_json(
r#"{"mcpServers":{"GitHub MCP":{"url":"https://proxy.example/mcp","headers":{"Authorization":"Bearer proxy-token"}}}}"#,
)
.unwrap();
let rendered = render_installations(installations, HashMap::new());
match &rendered["GitHub MCP"].transport_type {
JSONTransportType::SSEServer { url, headers } => {
assert_eq!(url, "https://proxy.example/mcp");
assert_eq!(
headers.get("Authorization").map(String::as_str),
Some("Bearer proxy-token")
);
}
other => panic!("expected SSE server, got {other:?}"),
}
}
#[test]
fn managed_url_config_preserves_header_despite_colliding_local_secret() {
// A server-rendered proxy header must not be overwritten by a local secret that
// happens to share the header's key name (`apply_secrets` implicit key-name match).
let installations = AgentDriver::installations_from_managed_client_config_json(
r#"{"mcpServers":{"GitHub MCP":{"url":"https://proxy.example/mcp","headers":{"Authorization":"Bearer proxy-token"}}}}"#,
)
.unwrap();
let rendered = render_installations(
installations,
HashMap::from([("Authorization".to_string(), raw_secret("local-secret"))]),
);
match &rendered["GitHub MCP"].transport_type {
JSONTransportType::SSEServer { url, headers } => {
assert_eq!(url, "https://proxy.example/mcp");
assert_eq!(
headers.get("Authorization").map(String::as_str),
Some("Bearer proxy-token")
);
}
other => panic!("expected SSE server, got {other:?}"),
}
}
#[test]
fn managed_command_config_preserves_literal_env_despite_colliding_local_secret() {
// A literal env value rendered by the server must survive even when a local secret
// shares the env key name.
let installations = AgentDriver::installations_from_managed_client_config_json(
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","env":{"LOG_LEVEL":"info"}}}}"#,
)
.unwrap();
let rendered = render_installations(
installations,
HashMap::from([("LOG_LEVEL".to_string(), raw_secret("debug"))]),
);
match &rendered["GitHub MCP"].transport_type {
JSONTransportType::CLIServer { env, .. } => {
assert_eq!(env.get("LOG_LEVEL").map(String::as_str), Some("info"));
}
other => panic!("expected CLI server, got {other:?}"),
}
}
#[test]
fn managed_command_config_missing_secret_leaves_placeholder() {
let installations = AgentDriver::installations_from_managed_client_config_json(
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","args":["--token={{API_TOKEN}}"]}}}"#,
)
.unwrap();
let rendered = render_installations(installations, HashMap::new());
match &rendered["GitHub MCP"].transport_type {
JSONTransportType::CLIServer { args, .. } => {
assert_eq!(args, &vec!["--token={{API_TOKEN}}".to_string()]);
}
other => panic!("expected CLI server, got {other:?}"),
}
}
#[test]
fn managed_resolution_failure_includes_uid_and_message() {
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let mut mock = MockManagedMcpClient::new();
mock.expect_create_managed_mcp_client_config()
.times(1)
.returning(|_| Err(anyhow::anyhow!("not active")));
let err = block_on(AgentDriver::resolve_mcp_specs_with_local_uuids(
&[MCPSpec::Uuid(uuid)],
&HashSet::new(),
Arc::new(mock),
))
.unwrap_err();
match err {
AgentDriverError::ManagedMcpResolutionFailed { uid, message } => {
assert_eq!(uid, uuid);
assert!(message.contains("not active"));
}
other => panic!("expected managed MCP resolution failure, got {other:?}"),
}
}
// ── IdleTimeoutSender tests ──────────────────────────────────────────────────────
#[test]
@@ -177,6 +435,53 @@ fn idle_timeout_sender_later_send_after_supersedes_earlier() {
assert_eq!(rx.try_recv().unwrap(), Some(2));
}
#[test]
fn idle_timeout_sender_complete_with_optional_idle_none_sends_immediately() {
// `complete_with_optional_idle(None, value)` routes to `end_run_now` and
// delivers `value` synchronously.
let (tx, mut rx) = oneshot::channel::<i32>();
let idle_timeout = IdleTimeoutSender::new(tx);
idle_timeout.complete_with_optional_idle(None, 7);
assert_eq!(rx.try_recv().unwrap(), Some(7));
}
#[test]
fn idle_timeout_sender_complete_with_optional_idle_some_defers_then_delivers() {
// `complete_with_optional_idle(Some(d), value)` routes to `end_run_after`
// and defers delivery by `d`.
let (tx, mut rx) = oneshot::channel::<i32>();
let idle_timeout = IdleTimeoutSender::new(tx);
idle_timeout.complete_with_optional_idle(Some(Duration::from_millis(50)), 7);
// Not delivered yet.
assert_eq!(rx.try_recv().unwrap(), None);
std::thread::sleep(Duration::from_millis(100));
assert_eq!(rx.try_recv().unwrap(), Some(7));
}
#[test]
fn idle_timeout_sender_complete_with_optional_idle_some_then_cancel_invalidates_timer() {
// Cross-path cancellation: the Stage 2c skip-initial-turn driver path
// schedules a deferred `Success` via `complete_with_optional_idle(Some(_), _)`
// *before* the history subscription is wired up; a later
// `AppendedExchange` in that subscription closure invalidates the timer
// via `cancel_idle_timeout()`. The shared `Arc<AtomicUsize>` generation
// counter is what makes that work across the two logical code paths.
// This test exercises the same sequence in isolation: schedule via the
// helper, then cancel via the unrelated `cancel_idle_timeout` entry point,
// and verify the value is never delivered.
let (tx, mut rx) = oneshot::channel::<i32>();
let idle_timeout = IdleTimeoutSender::new(tx);
idle_timeout.complete_with_optional_idle(Some(Duration::from_millis(50)), 7);
idle_timeout.cancel_idle_timeout();
std::thread::sleep(Duration::from_millis(100));
// Sender was never consumed by the cancelled timer, so the channel is
// still open but empty.
assert_eq!(rx.try_recv().unwrap(), None);
}
#[test]
fn task_env_vars_include_parent_run_id_when_present() {
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
@@ -386,3 +691,474 @@ fn json_format_input_omits_filepath_and_description_for_proto_upload_result() {
assert!(value.get("filepath").is_none());
assert!(value.get("description").is_none());
}
// ── build_secret_env_vars tests ──────────────────────────────────────────────
#[test]
#[serial_test::serial]
fn raw_value_only_writes_under_secret_name() {
std::env::remove_var("MY_SECRET");
let secrets = HashMap::from([(
"MY_SECRET".to_string(),
ManagedSecretValue::raw_value("s3cret"),
)]);
let env_vars = build_secret_env_vars(&secrets);
assert_eq!(
env_vars.get(&OsString::from("MY_SECRET")),
Some(&OsString::from("s3cret"))
);
assert_eq!(env_vars.len(), 1);
}
#[test]
#[serial_test::serial]
fn anthropic_api_key_writes_anthropic_env_var() {
std::env::remove_var("ANTHROPIC_API_KEY");
let secrets = HashMap::from([(
"my-custom-name".to_string(),
ManagedSecretValue::anthropic_api_key("sk-ant-test-key"),
)]);
let env_vars = build_secret_env_vars(&secrets);
assert_eq!(
env_vars.get(&OsString::from("ANTHROPIC_API_KEY")),
Some(&OsString::from("sk-ant-test-key"))
);
}
#[test]
#[serial_test::serial]
fn typed_secret_overrides_raw_value_with_same_env_name() {
std::env::remove_var("ANTHROPIC_API_KEY");
let typed_key = "sk-ant-typed-key-abcdef";
let raw_key = "sk-ant-raw-key-ghijkl";
let secrets = HashMap::from([
(
"my-auth".to_string(),
ManagedSecretValue::anthropic_api_key(typed_key),
),
(
"ANTHROPIC_API_KEY".to_string(),
ManagedSecretValue::raw_value(raw_key),
),
]);
// Run multiple times to defeat HashMap iteration order flakiness.
for _ in 0..20 {
let env_vars = build_secret_env_vars(&secrets);
assert_eq!(
env_vars.get(&OsString::from("ANTHROPIC_API_KEY")),
Some(&OsString::from(typed_key)),
"Typed secret must always override RawValue with the same env name"
);
}
}
#[test]
#[serial_test::serial]
fn bedrock_api_key_writes_all_bedrock_env_vars() {
std::env::remove_var("AWS_BEARER_TOKEN_BEDROCK");
std::env::remove_var("CLAUDE_CODE_USE_BEDROCK");
std::env::remove_var("AWS_REGION");
let secrets = HashMap::from([
(
"bedrock-secret".to_string(),
ManagedSecretValue::anthropic_bedrock_api_key("token-123", "us-west-2"),
),
(
"AWS_REGION".to_string(),
ManagedSecretValue::raw_value("eu-west-1"),
),
]);
let env_vars = build_secret_env_vars(&secrets);
assert_eq!(
env_vars.get(&OsString::from("AWS_BEARER_TOKEN_BEDROCK")),
Some(&OsString::from("token-123"))
);
assert_eq!(
env_vars.get(&OsString::from("CLAUDE_CODE_USE_BEDROCK")),
Some(&OsString::from("1"))
);
assert_eq!(
env_vars.get(&OsString::from("AWS_REGION")),
Some(&OsString::from("us-west-2")),
"Typed Bedrock secret should win over RawValue for AWS_REGION"
);
}
#[test]
#[serial_test::serial]
fn bedrock_access_key_writes_all_aws_env_vars() {
std::env::remove_var("AWS_ACCESS_KEY_ID");
std::env::remove_var("AWS_SECRET_ACCESS_KEY");
std::env::remove_var("AWS_SESSION_TOKEN");
std::env::remove_var("CLAUDE_CODE_USE_BEDROCK");
std::env::remove_var("AWS_REGION");
let secrets = HashMap::from([(
"bedrock-access".to_string(),
ManagedSecretValue::anthropic_bedrock_access_key(
"AKID",
"secret-key",
Some("session-tok".to_string()),
"ap-southeast-1",
),
)]);
let env_vars = build_secret_env_vars(&secrets);
assert_eq!(
env_vars.get(&OsString::from("AWS_ACCESS_KEY_ID")),
Some(&OsString::from("AKID"))
);
assert_eq!(
env_vars.get(&OsString::from("AWS_SECRET_ACCESS_KEY")),
Some(&OsString::from("secret-key"))
);
assert_eq!(
env_vars.get(&OsString::from("AWS_SESSION_TOKEN")),
Some(&OsString::from("session-tok"))
);
assert_eq!(
env_vars.get(&OsString::from("CLAUDE_CODE_USE_BEDROCK")),
Some(&OsString::from("1"))
);
assert_eq!(
env_vars.get(&OsString::from("AWS_REGION")),
Some(&OsString::from("ap-southeast-1"))
);
}
#[test]
#[serial_test::serial]
fn raw_value_skipped_when_process_env_already_set() {
std::env::set_var("WORKER_TOKEN", "injected-value");
let secrets = HashMap::from([(
"WORKER_TOKEN".to_string(),
ManagedSecretValue::raw_value("managed-value"),
)]);
let env_vars = build_secret_env_vars(&secrets);
// The worker-injected env var wins; env_vars should NOT contain it
// because the child inherits the process env directly.
assert!(!env_vars.contains_key(&OsString::from("WORKER_TOKEN")));
std::env::remove_var("WORKER_TOKEN");
}
#[test]
#[serial_test::serial]
fn worker_injected_env_wins_over_typed_secret() {
std::env::set_var("ANTHROPIC_API_KEY", "worker-key");
let secrets = HashMap::from([(
"my-auth".to_string(),
ManagedSecretValue::anthropic_api_key("managed-key"),
)]);
let env_vars = build_secret_env_vars(&secrets);
// The typed secret should be skipped entirely; the child inherits
// ANTHROPIC_API_KEY from the process env.
assert!(!env_vars.contains_key(&OsString::from("ANTHROPIC_API_KEY")));
std::env::remove_var("ANTHROPIC_API_KEY");
}
#[test]
#[serial_test::serial]
fn worker_injected_env_skips_entire_bedrock_secret() {
// Only AWS_REGION is worker-injected; the entire Bedrock secret should
// be atomically skipped — no partial insertion.
std::env::set_var("AWS_REGION", "us-east-1");
std::env::remove_var("AWS_BEARER_TOKEN_BEDROCK");
std::env::remove_var("CLAUDE_CODE_USE_BEDROCK");
let secrets = HashMap::from([(
"bedrock-secret".to_string(),
ManagedSecretValue::anthropic_bedrock_api_key("token-456", "eu-central-1"),
)]);
let env_vars = build_secret_env_vars(&secrets);
assert!(
!env_vars.contains_key(&OsString::from("AWS_BEARER_TOKEN_BEDROCK")),
"Entire Bedrock secret must be skipped when any field is worker-injected"
);
assert!(!env_vars.contains_key(&OsString::from("CLAUDE_CODE_USE_BEDROCK")));
assert!(!env_vars.contains_key(&OsString::from("AWS_REGION")));
std::env::remove_var("AWS_REGION");
}
// ── Skill-loading integration test ───────────────────────────────────────────
/// Verifies that `load_environment_skills` loads every skill from an env repo
/// while `load_global_skills` loads only the explicitly requested subset from a
/// global-only repo.
///
/// The test writes real SKILL.md files on disk, seeds `RepoMetadataModel` with a
/// minimal file-tree for the env repo so the indexing wait resolves immediately,
/// and drives both loading methods through a live `AgentDriver` model via a
/// `ModelSpawner`.
#[test]
fn split_loading_env_loads_all_global_loads_subset() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
// Create real skill files on disk.
let temp = TempDir::new().unwrap();
let working_dir = dunce::canonicalize(temp.path()).unwrap();
// Environment repo: three skills. All should be loaded.
let env_repo = working_dir.join("env-repo");
write_skill_file(&env_repo, "build");
write_skill_file(&env_repo, "test-skill");
write_skill_file(&env_repo, "deploy");
// Global-only repo: three skills; only "linter" is explicitly requested.
let global_repo = working_dir.join("global-repo");
write_skill_file(&global_repo, "linter");
write_skill_file(&global_repo, "formatter");
write_skill_file(&global_repo, "docs");
// Trigger a real filesystem scan of the env repo so `repository_indexed`
// resolves immediately once indexing completes.
let env_repo_std = StandardizedPath::from_local_canonicalized(&env_repo).unwrap();
let repo_handle = DirectoryWatcher::handle(&app).update(&mut app, |watcher, ctx| {
watcher.add_directory(env_repo_std.clone(), ctx).unwrap()
});
let (indexed_tx, indexed_rx) = futures::channel::oneshot::channel::<()>();
let tx_cell = std::rc::Rc::new(std::cell::RefCell::new(Some(indexed_tx)));
let env_repo_for_event = env_repo_std.clone();
app.update(|ctx| {
let tx_cell = tx_cell.clone();
ctx.subscribe_to_model(
&RepoMetadataModel::handle(ctx),
move |_, event: &RepoMetadataEvent, _ctx| {
if let RepoMetadataEvent::RepositoryUpdated {
id: RepositoryIdentifier::Local(path),
} = event
{
if *path == env_repo_for_event {
if let Some(tx) = tx_cell.borrow_mut().take() {
let _ = tx.send(());
}
}
}
},
);
});
RepoMetadataModel::handle(&app).update(&mut app, |model: &mut RepoMetadataModel, ctx| {
model.index_directory(repo_handle, ctx).unwrap();
});
indexed_rx.await.expect("env repo should be indexed");
// Construct a minimal AgentDriver backed by a stub terminal view.
let terminal_view = add_window_with_terminal(&mut app, None);
let driver_handle = app.add_model(|ctx| {
let terminal_driver =
super::terminal::TerminalDriver::create_from_existing_view(terminal_view, ctx);
AgentDriver::new_for_test(working_dir.clone(), terminal_driver, ctx)
});
// Run both loading methods through the driver's ModelSpawner.
let (done_tx, done_rx) = futures::channel::oneshot::channel::<()>();
let env_repos = vec![SourceRepo::new(
CodeForge::GitHub,
"org".to_string(),
"env-repo".to_string(),
)];
let global_repos = vec![GithubRepo::new(
"org".to_string(),
"global-repo".to_string(),
)];
let global_specs: Vec<SkillSpec> = ["org/global-repo:linter".to_string()]
.iter()
.map(|s| s.parse().unwrap())
.collect();
driver_handle.update(&mut app, |_, ctx| {
let spawner = ctx.spawner();
ctx.spawn(
async move {
AgentDriver::load_environment_skills(&spawner, env_repos).await;
AgentDriver::load_global_skills(&spawner, global_specs, global_repos).await;
let _ = done_tx.send(());
},
|_, _, _| {},
);
});
done_rx.await.expect("loading task should complete");
// Verify SkillManager contains the right skills.
// is_cloud_environment=true (set by both loaders), so get_skills_for_working_directory
// with cwd=None returns all registered skills.
let skill_names = SkillManager::handle(&app).read(&app, |manager: &SkillManager, ctx| {
manager
.get_skills_for_working_directory(None, ctx)
.into_iter()
.map(|s| s.name.clone())
.collect::<Vec<_>>()
});
assert!(
skill_names.contains(&"build".to_string()),
"env skill 'build' should be loaded; got: {skill_names:?}"
);
assert!(
skill_names.contains(&"test-skill".to_string()),
"env skill 'test-skill' should be loaded; got: {skill_names:?}"
);
assert!(
skill_names.contains(&"deploy".to_string()),
"env skill 'deploy' should be loaded; got: {skill_names:?}"
);
assert!(
skill_names.contains(&"linter".to_string()),
"requested global skill 'linter' should be loaded; got: {skill_names:?}"
);
assert!(
!skill_names.contains(&"formatter".to_string()),
"unrequested global skill 'formatter' should NOT be loaded; got: {skill_names:?}"
);
assert!(
!skill_names.contains(&"docs".to_string()),
"unrequested global skill 'docs' should NOT be loaded; got: {skill_names:?}"
);
});
}
/// Verifies that when a repo is in both the environment list and the global skill
/// specs, all skills from that repo are loaded (environment wins), the targeted
/// global skill is present, and no skill is registered more than once.
#[test]
fn overlap_repo_in_env_and_global_loads_all_skills_without_duplicates() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let temp = TempDir::new().unwrap();
let working_dir = dunce::canonicalize(temp.path()).unwrap();
// A single repo with three skills, appearing in both the environment
// and a global spec that targets only one of them.
let shared_repo = working_dir.join("shared-repo");
write_skill_file(&shared_repo, "deploy");
write_skill_file(&shared_repo, "lint");
write_skill_file(&shared_repo, "test-cmd");
// Index the repo so `load_environment_skills` can scan it.
let shared_repo_std = StandardizedPath::from_local_canonicalized(&shared_repo).unwrap();
let repo_handle = DirectoryWatcher::handle(&app).update(&mut app, |watcher, ctx| {
watcher.add_directory(shared_repo_std.clone(), ctx).unwrap()
});
let (indexed_tx, indexed_rx) = futures::channel::oneshot::channel::<()>();
let tx_cell = std::rc::Rc::new(std::cell::RefCell::new(Some(indexed_tx)));
let shared_repo_for_event = shared_repo_std.clone();
app.update(|ctx| {
let tx_cell = tx_cell.clone();
ctx.subscribe_to_model(
&RepoMetadataModel::handle(ctx),
move |_, event: &RepoMetadataEvent, _ctx| {
if let RepoMetadataEvent::RepositoryUpdated {
id: RepositoryIdentifier::Local(path),
} = event
{
if *path == shared_repo_for_event {
if let Some(tx) = tx_cell.borrow_mut().take() {
let _ = tx.send(());
}
}
}
},
);
});
RepoMetadataModel::handle(&app).update(&mut app, |model: &mut RepoMetadataModel, ctx| {
model.index_directory(repo_handle, ctx).unwrap();
});
indexed_rx.await.expect("shared repo should be indexed");
let terminal_view = add_window_with_terminal(&mut app, None);
let driver_handle = app.add_model(|ctx| {
let terminal_driver =
super::terminal::TerminalDriver::create_from_existing_view(terminal_view, ctx);
AgentDriver::new_for_test(working_dir.clone(), terminal_driver, ctx)
});
// The same repo is listed in both env repos and global repos.
// The global spec targets only "deploy".
let (done_tx, done_rx) = futures::channel::oneshot::channel::<()>();
let env_repos = vec![SourceRepo::new(
CodeForge::GitHub,
"org".to_string(),
"shared-repo".to_string(),
)];
let global_repos = vec![GithubRepo::new(
"org".to_string(),
"shared-repo".to_string(),
)];
let global_specs: Vec<SkillSpec> = ["org/shared-repo:deploy".to_string()]
.iter()
.map(|s| s.parse().unwrap())
.collect();
driver_handle.update(&mut app, |_, ctx| {
let spawner = ctx.spawner();
ctx.spawn(
async move {
AgentDriver::load_environment_skills(&spawner, env_repos).await;
AgentDriver::load_global_skills(&spawner, global_specs, global_repos).await;
let _ = done_tx.send(());
},
|_, _, _| {},
);
});
done_rx.await.expect("loading task should complete");
let skill_names = SkillManager::handle(&app).read(&app, |manager: &SkillManager, ctx| {
manager
.get_skills_for_working_directory(None, ctx)
.into_iter()
.map(|s| s.name.clone())
.collect::<Vec<_>>()
});
// All three skills from the repo are present (env loading wins).
assert!(
skill_names.contains(&"deploy".to_string()),
"'deploy' should be loaded; got: {skill_names:?}"
);
assert!(
skill_names.contains(&"lint".to_string()),
"'lint' should be loaded; got: {skill_names:?}"
);
assert!(
skill_names.contains(&"test-cmd".to_string()),
"'test-cmd' should be loaded; got: {skill_names:?}"
);
// No skill is duplicated.
let deploy_count = skill_names.iter().filter(|n| *n == "deploy").count();
assert_eq!(
deploy_count, 1,
"'deploy' should appear exactly once; got: {skill_names:?}"
);
});
}
/// Write a minimal SKILL.md at `{repo}/.agents/skills/{name}/SKILL.md`.
/// The name is derived from the parent directory name, so no frontmatter is required.
fn write_skill_file(repo: &Path, name: &str) {
let skill_dir = repo.join(".agents").join("skills").join(name);
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), format!("Skill: {name}.")).unwrap();
}
#[test]
#[serial_test::serial]
fn openai_api_key_exports_only_api_key_not_base_url() {
// The OpenAI typed secret should only export OPENAI_API_KEY as an env var.
// base_url is piped through the structured secret to the harness instead.
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("OPENAI_BASE_URL");
let secrets = HashMap::from([(
"openai-key".to_string(),
ManagedSecretValue::openai_api_key(
"sk-test-key",
Some("https://us.api.openai.com/v1".to_string()),
),
)]);
let env_vars = build_secret_env_vars(&secrets);
assert_eq!(
env_vars.get(&OsString::from("OPENAI_API_KEY")),
Some(&OsString::from("sk-test-key")),
"OPENAI_API_KEY should be exported from the typed secret"
);
assert!(
!env_vars.contains_key(&OsString::from("OPENAI_BASE_URL")),
"OPENAI_BASE_URL should NOT be exported as an env var"
);
}
+17 -19
View File
@@ -1,28 +1,32 @@
use std::collections::HashSet;
use comfy_table::Cell;
use galaxy_cli::{
agent::OutputFormat,
environment::{EnvironmentCommand, ImageCommand},
scope::ObjectScope,
GlobalOptions,
use cynic::QueryBuilder;
use inquire::error::InquireError;
use inquire::{Confirm, Select};
use serde::Serialize;
use galaxy_cli::agent::OutputFormat;
use galaxy_cli::environment::{EnvironmentCommand, ImageCommand};
use galaxy_cli::scope::ObjectScope;
use galaxy_cli::GlobalOptions;
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
use galaxy_graphql::queries::list_warp_dev_images::{
ListWarpDevImages, ListWarpDevImagesResult, ListWarpDevImagesVariables,
};
use galaxy_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
use galaxyui::r#async::FutureExt;
use galaxyui::{AppContext, ModelContext, SingletonEntity};
use inquire::{error::InquireError, Confirm, Select};
use serde::Serialize;
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::ai::agent_sdk::driver::WARP_DRIVE_SYNC_TIMEOUT;
use crate::ai::agent_sdk::oauth_flow::poll_oauth_until_terminal;
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::ai::cloud_environments::{
AmbientAgentEnvironment, BaseImage, CloudAmbientAgentEnvironment,
CloudAmbientAgentEnvironmentModel, GithubRepo,
};
use crate::auth::UserUid;
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
use crate::cloud_object::CloudObject;
use crate::cloud_object::{CloudObject, CloudObjectLookup as _};
use crate::server::cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
};
@@ -31,12 +35,6 @@ use crate::server::server_api::ServerApiProvider;
use crate::util::time_format::format_approx_duration_from_now_utc;
use crate::workspaces::user_profiles::UserProfiles;
use crate::CloudObjectTypeAndId;
use cynic::QueryBuilder;
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
use galaxy_graphql::queries::list_warp_dev_images::{
ListWarpDevImages, ListWarpDevImagesResult, ListWarpDevImagesVariables,
};
use galaxy_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
const WARP_DEV_ENVIRONMENTS_REPO: &str = "https://github.com/warpdotdev/warp-dev-environments";
@@ -756,7 +754,7 @@ impl EnvironmentCommandRunner {
// We should subscribe to the UpdateManager here because we want to wait
// for our environment to be assigned a ServerId. Environments are not
// usable without first being synced.
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, event, ctx| {
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, _, event, ctx| {
if let UpdateManagerEvent::ObjectOperationComplete { result } = event {
if matches!(result.operation, ObjectOperation::Create { .. })
&& matches!(result.success_type, OperationSuccessType::Success)
@@ -1001,7 +999,7 @@ impl EnvironmentCommandRunner {
});
// Subscribe to UpdateManager to wait for the update to complete
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, event, ctx| {
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, _, event, ctx| {
if let UpdateManagerEvent::ObjectOperationComplete { result } = event {
if matches!(result.operation, ObjectOperation::Update)
&& result.server_id == Some(server_id)
@@ -1087,7 +1085,7 @@ impl EnvironmentCommandRunner {
});
// Listen to the UpdateManager for a completed object deletion
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, event, ctx| {
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, _, event, ctx| {
if let UpdateManagerEvent::ObjectOperationComplete { result } = event {
if matches!(result.operation, ObjectOperation::Delete { .. }) {
match result.success_type {
+8 -2
View File
@@ -7,6 +7,12 @@ use galaxy_core::{features::FeatureFlag, report_error};
use galaxy_managed_secrets::ManagedSecretManager;
use galaxyui::{platform::TerminationMode, AppContext, SingletonEntity as _};
use serde_json::json;
use galaxy_cli::agent::OutputFormat;
use galaxy_cli::GlobalOptions;
use galaxy_core::features::FeatureFlag;
use galaxy_core::report_error;
use galaxyui::platform::TerminationMode;
use galaxyui::{AppContext, SingletonEntity as _};
use super::common::set_ambient_task_context_from_run_id;
@@ -30,7 +36,7 @@ fn issue_token(
args: IssueTokenArgs,
output_format: OutputFormat,
) -> Result<()> {
// Set the task ID so the ambient workload token header is sent.
// Set the task ID so the ambient agent workload token header is sent.
set_ambient_task_context_from_run_id(ctx, &args.run_id)?;
let duration: std::time::Duration = args.duration.into();
@@ -85,7 +91,7 @@ fn issue_token(
Ok(())
}
fn issue_gcp_token(ctx: &mut AppContext, args: IssueGcpTokenArgs) -> Result<()> {
// Set the task ID so the ambient workload token header is sent.
// Set the task ID so the ambient agent workload token header is sent.
set_ambient_task_context_from_run_id(ctx, &args.run_id)?;
let duration: std::time::Duration = args.duration.into();
+53 -2
View File
@@ -7,11 +7,12 @@ use anyhow::Result;
use galaxy_cli::agent::OutputFormat;
use galaxy_cli::harness_support::{
FinishTaskArgs, HarnessSupportArgs, HarnessSupportCommand, NotifyUserArgs, ReportArtifactArgs,
ReportArtifactCommand, TaskStatus,
ReportArtifactCommand, ReportShutdownArgs, TaskStatus,
};
use galaxy_cli::GlobalOptions;
use galaxy_core::features::FeatureFlag;
use galaxyui::{platform::TerminationMode, AppContext, ModelHandle, SingletonEntity};
use galaxyui::platform::TerminationMode;
use galaxyui::{AppContext, ModelHandle, SingletonEntity};
use super::common::set_ambient_task_context_from_run_id;
use crate::ai::ambient_agents::AmbientAgentTaskId;
@@ -43,6 +44,9 @@ pub fn run(
HarnessSupportCommand::FinishTask(finish_args) => {
finish_task(ctx, runner, finish_args, global_options.output_format)
}
HarnessSupportCommand::ReportShutdown(shutdown_args) => {
report_shutdown(ctx, runner, shutdown_args, global_options.output_format)
}
}
}
@@ -203,6 +207,53 @@ fn finish_task(
Ok(())
}
/// Report that the agent process is shutting down.
///
/// Routes to `report_clean_shutdown` or `report_error_shutdown` on the API client
/// depending on whether error arguments were provided.
fn report_shutdown(
ctx: &mut AppContext,
runner: ModelHandle<HarnessSupportRunner>,
args: ReportShutdownArgs,
output_format: OutputFormat,
) -> Result<()> {
runner.update(ctx, |_, ctx| {
let client = ServerApiProvider::as_ref(ctx).get_harness_support_client();
ctx.spawn(
async move {
match (args.error_category, args.error_message) {
(Some(category), Some(message)) => {
client.report_error_shutdown(category, message).await
}
(None, None) => client.report_clean_shutdown().await,
_ => anyhow::bail!(
"--error-category and --error-message must be provided together"
),
}
},
move |_, result, ctx| match result {
Ok(()) => {
match output_format {
OutputFormat::Json | OutputFormat::Ndjson => {
println!("{{}}");
}
OutputFormat::Pretty | OutputFormat::Text => {
println!("Shutdown reported.");
}
}
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;
+10 -7
View File
@@ -1,18 +1,17 @@
use crate::server::server_api::ServerApiProvider;
use futures::future;
use galaxy_cli::{
integration::{CreateIntegrationArgs, IntegrationCommand, UpdateIntegrationArgs},
provider::ProviderType,
GlobalOptions,
};
use galaxy_cli::integration::{CreateIntegrationArgs, IntegrationCommand, UpdateIntegrationArgs};
use galaxy_cli::provider::ProviderType;
use galaxy_cli::GlobalOptions;
use galaxy_graphql::mutations::create_simple_integration::CreateSimpleIntegrationOutput;
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
use galaxy_graphql::queries::get_simple_integrations::SimpleIntegrationsOutput;
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
use galaxyui::platform::TerminationMode;
use galaxyui::{AppContext, ModelContext, SingletonEntity};
use super::common::{EnvironmentChoice, ResolveConfigurationError};
use super::integration_output;
use super::oauth_flow::poll_oauth_until_terminal;
use crate::server::server_api::ServerApiProvider;
pub fn run(
ctx: &mut AppContext,
@@ -104,6 +103,8 @@ impl IntegrationCommandRunner {
crate::ai::ambient_agents::AgentConfigSnapshot {
name: None,
environment_id: args.environment.environment.clone(),
// TODO(REMOTE-1936): support a runner for integrations.
runner_id: None,
model_id: args.model.model.clone(),
base_prompt: args.prompt.clone(),
mcp_servers: cli_mcp_servers,
@@ -416,6 +417,8 @@ impl IntegrationCommandRunner {
crate::ai::ambient_agents::AgentConfigSnapshot {
name: None,
environment_id: args.environment.environment.clone(),
// TODO(REMOTE-1936): support a runner for integrations.
runner_id: None,
model_id: args.model.model.clone(),
base_prompt: args.prompt.clone(),
mcp_servers: cli_mcp_servers,
+6 -5
View File
@@ -1,16 +1,17 @@
use chrono::{DateTime, Utc};
use comfy_table::{presets::UTF8_FULL, Cell, Table};
use galaxy_cli::agent::OutputFormat;
use comfy_table::presets::UTF8_FULL;
use comfy_table::{Cell, Table};
use serde::Serialize;
use serde_json::{Map, Value};
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::util::time_format::format_approx_duration_from_now_utc;
use galaxy_cli::agent::OutputFormat;
use galaxy_graphql::queries::get_simple_integrations::{
ListedSimpleIntegrationConfig, SimpleIntegration, SimpleIntegrationConnectionStatus,
SimpleIntegrationsOutput,
};
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::util::time_format::format_approx_duration_from_now_utc;
const MAX_LINE_WIDTH: usize = 90;
/// Print simple integrations.
+2
View File
@@ -2,6 +2,8 @@ use comfy_table::Cell;
use galaxy_cli::{mcp::MCPCommand, GlobalOptions};
use galaxyui::{AppContext, ModelContext, SingletonEntity};
use serde::Serialize;
use galaxy_cli::mcp::MCPCommand;
use galaxy_cli::GlobalOptions;
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::ai::mcp::TemplatableMCPServerManager;
+8 -1
View File
@@ -260,6 +260,7 @@ fn validation_rejects_invalid_entries() {
#[test]
fn serializes_mcp_servers_as_object_not_string() {
use crate::ai::agent::UserQueryMode;
use crate::ai::ambient_agents::AgentConfigSnapshot;
use crate::server::server_api::ai::SpawnAgentRequest;
@@ -269,19 +270,25 @@ fn serializes_mcp_servers_as_object_not_string() {
.unwrap();
let request = SpawnAgentRequest {
prompt: "hello".to_string(),
prompt: Some("hello".to_string()),
mode: UserQueryMode::Normal,
config: Some(AgentConfigSnapshot {
mcp_servers: Some(mcp_servers),
..Default::default()
}),
title: None,
team: None,
agent_identity_uid: None,
skill: None,
attachments: vec![],
interactive: None,
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
conversation_id: None,
initial_snapshot_token: None,
snapshot_disabled: None,
orchestration_handoff: None,
};
let value = serde_json::to_value(&request).unwrap();
+520
View File
@@ -0,0 +1,520 @@
use anyhow::Result;
use comfy_table::Cell;
use serde::Serialize;
use warp_cli::agent::OutputFormat;
use warp_cli::memory_store::{
CreateMemoryArgs, DeleteMemoryArgs, GetStoreArgs, ListMemoriesArgs, ListStoreAgentsArgs,
ListVersionsArgs, MemoryCommand, MemoryStoreCommand, UpdateMemoryArgs, UpdateStoreArgs,
};
use warp_cli::GlobalOptions;
use warpui::platform::TerminationMode;
use warpui::{AppContext, ModelContext, SingletonEntity};
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::server::server_api::ai::{
AIClient, AgentAttachmentItem, CreateMemoryRequest, CreateMemoryResponse, MemoryItem,
MemorySource, MemoryStoreItem, MemoryVersionItem, UpdateMemoryRequest, UpdateMemoryResponse,
UpdateMemoryStoreRequest,
};
use crate::server::server_api::ServerApiProvider;
use crate::util::time_format::format_approx_duration_from_now_utc;
/// Run memory-store related commands.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: MemoryStoreCommand,
) -> Result<()> {
let runner = ctx.add_singleton_model(|_ctx| MemoryStoreCommandRunner);
match command {
MemoryStoreCommand::List => {
runner.update(ctx, |runner, ctx| {
runner.list_stores(global_options.output_format, ctx)
});
Ok(())
}
MemoryStoreCommand::Get(args) => {
runner.update(ctx, |runner, ctx| {
runner.get_store(global_options.output_format, args, ctx)
});
Ok(())
}
MemoryStoreCommand::Update(args) => {
runner.update(ctx, |runner, ctx| {
runner.update_store(global_options.output_format, args, ctx)
});
Ok(())
}
MemoryStoreCommand::ListStoreAgents(args) => {
runner.update(ctx, |runner, ctx| {
runner.list_store_agents(global_options.output_format, args, ctx)
});
Ok(())
}
}
}
/// Run memory related commands.
pub fn run_memory(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: MemoryCommand,
) -> Result<()> {
let runner = ctx.add_singleton_model(|_ctx| MemoryStoreCommandRunner);
match command {
MemoryCommand::List(args) => {
runner.update(ctx, |runner, ctx| {
runner.list_memories(global_options.output_format, args, ctx)
});
Ok(())
}
MemoryCommand::Create(args) => {
runner.update(ctx, |runner, ctx| {
runner.create_memory(global_options.output_format, args, ctx)
});
Ok(())
}
MemoryCommand::Update(args) => {
runner.update(ctx, |runner, ctx| {
runner.update_memory(global_options.output_format, args, ctx)
});
Ok(())
}
MemoryCommand::Delete(args) => {
runner.update(ctx, |runner, ctx| {
runner.delete_memory(global_options.output_format, args, ctx)
});
Ok(())
}
MemoryCommand::Versions(args) => {
runner.update(ctx, |runner, ctx| {
runner.list_versions(global_options.output_format, args, ctx)
});
Ok(())
}
}
}
struct MemoryStoreCommandRunner;
impl MemoryStoreCommandRunner {
fn list_stores(&self, output_format: OutputFormat, ctx: &mut ModelContext<Self>) {
let server_api = ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move {
let stores = server_api.list_memory_stores().await?;
print_memory_stores(stores, output_format);
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn list_memories(
&self,
output_format: OutputFormat,
args: ListMemoriesArgs,
ctx: &mut ModelContext<Self>,
) {
let server_api = ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move {
let memories = server_api
.list_memory_store_memories(&args.store_uid)
.await?;
print_memories(memories, output_format);
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn create_memory(
&self,
output_format: OutputFormat,
args: CreateMemoryArgs,
ctx: &mut ModelContext<Self>,
) {
let server_api = ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move {
let request = CreateMemoryRequest {
content: args.content,
version: args.version,
source: MemorySource::Manual,
source_id: None,
reason: args.reason,
};
let response = server_api
.create_memory_store_memory(&args.store_uid, request)
.await?;
print_create_memory_response(response, output_format)?;
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn get_store(
&self,
output_format: OutputFormat,
args: GetStoreArgs,
ctx: &mut ModelContext<Self>,
) {
let server_api = ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move {
let store = server_api.get_memory_store(&args.store_uid).await?;
match output_format {
OutputFormat::Json => output::write_json(&store, std::io::stdout())?,
OutputFormat::Ndjson => output::write_json_line(&store, std::io::stdout())?,
OutputFormat::Pretty | OutputFormat::Text => {
output::print_list([store], output_format);
}
}
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn update_store(
&self,
output_format: OutputFormat,
args: UpdateStoreArgs,
ctx: &mut ModelContext<Self>,
) {
let server_api = ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move {
let request = UpdateMemoryStoreRequest {
description: args.description,
};
let store = server_api
.update_memory_store(&args.store_uid, request)
.await?;
match output_format {
OutputFormat::Json => output::write_json(&store, std::io::stdout())?,
OutputFormat::Ndjson => output::write_json_line(&store, std::io::stdout())?,
OutputFormat::Pretty | OutputFormat::Text => {
println!("Updated store {}.", store.uid);
output::print_list([store], output_format);
}
}
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn list_store_agents(
&self,
output_format: OutputFormat,
args: ListStoreAgentsArgs,
ctx: &mut ModelContext<Self>,
) {
let server_api = ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move {
let agents = server_api.list_memory_store_agents(&args.store_uid).await?;
if agents.is_empty()
&& matches!(output_format, OutputFormat::Pretty | OutputFormat::Text)
{
println!("No agents attached to this store.");
return Ok(());
}
output::print_list(agents, output_format);
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn list_versions(
&self,
output_format: OutputFormat,
args: ListVersionsArgs,
ctx: &mut ModelContext<Self>,
) {
let server_api = ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move {
let versions = server_api
.list_memory_versions(&args.store_uid, &args.memory_uid)
.await?;
if versions.is_empty()
&& matches!(output_format, OutputFormat::Pretty | OutputFormat::Text)
{
println!("No versions found.");
return Ok(());
}
output::print_list(versions, output_format);
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn update_memory(
&self,
output_format: OutputFormat,
args: UpdateMemoryArgs,
ctx: &mut ModelContext<Self>,
) {
let server_api = ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move {
let request = UpdateMemoryRequest {
content: args.content,
version: args.version,
reason: args.reason,
};
let response = server_api
.update_memory_store_memory(&args.store_uid, &args.memory_uid, request)
.await?;
print_update_memory_response(response, output_format)?;
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
fn delete_memory(
&self,
output_format: OutputFormat,
args: DeleteMemoryArgs,
ctx: &mut ModelContext<Self>,
) {
let server_api = ServerApiProvider::as_ref(ctx).get();
ctx.spawn(
async move {
server_api
.delete_memory_store_memory(&args.store_uid, &args.memory_uid)
.await?;
let output = DeleteMemoryOutput {
memory_uid: args.memory_uid.clone(),
};
match output_format {
OutputFormat::Json => output::write_json(&output, std::io::stdout())?,
OutputFormat::Ndjson => output::write_json_line(&output, std::io::stdout())?,
OutputFormat::Pretty | OutputFormat::Text => {
println!("Deleted memory {}.", args.memory_uid);
}
}
Ok(())
},
|_, result: Result<()>, ctx| finish_command(result, ctx),
);
}
}
impl warpui::Entity for MemoryStoreCommandRunner {
type Event = ();
}
impl SingletonEntity for MemoryStoreCommandRunner {}
impl TableFormat for MemoryStoreItem {
fn header() -> Vec<Cell> {
vec![
Cell::new("UID"),
Cell::new("Owner Type"),
Cell::new("Owner UID"),
Cell::new("Description"),
Cell::new("Created"),
Cell::new("Updated"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.uid),
Cell::new(&self.owner_type),
Cell::new(&self.owner_uid),
Cell::new(self.description.as_deref().unwrap_or("")),
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
Cell::new(format_approx_duration_from_now_utc(self.updated_at)),
]
}
}
impl TableFormat for MemoryVersionItem {
fn header() -> Vec<Cell> {
vec![
Cell::new("UID"),
Cell::new("Version"),
Cell::new("Content"),
Cell::new("Reason"),
Cell::new("Created"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.uid),
Cell::new(&self.version),
Cell::new(&self.content),
Cell::new(self.reason.as_deref().unwrap_or("")),
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
]
}
}
impl TableFormat for AgentAttachmentItem {
fn header() -> Vec<Cell> {
vec![
Cell::new("UID"),
Cell::new("Name"),
Cell::new("Access"),
Cell::new("Instructions"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.uid),
Cell::new(&self.name),
Cell::new(&self.access),
Cell::new(&self.instructions),
]
}
}
impl TableFormat for MemoryItem {
fn header() -> Vec<Cell> {
vec![
Cell::new("UID"),
Cell::new("Version"),
Cell::new("Source"),
Cell::new("Content"),
Cell::new("Created"),
Cell::new("Updated"),
]
}
fn row(&self) -> Vec<Cell> {
vec![
Cell::new(&self.uid),
Cell::new(&self.version_id),
Cell::new(&self.source),
Cell::new(&self.content),
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
Cell::new(format_approx_duration_from_now_utc(self.updated_at)),
]
}
}
#[derive(Serialize)]
struct DeleteMemoryOutput {
memory_uid: String,
}
#[derive(Serialize)]
struct CreateMemoryOutput {
memory_id: String,
version_id: String,
}
impl From<CreateMemoryResponse> for CreateMemoryOutput {
fn from(response: CreateMemoryResponse) -> Self {
Self {
memory_id: response.memory_id,
version_id: response.version_id,
}
}
}
impl TableFormat for CreateMemoryOutput {
fn header() -> Vec<Cell> {
vec![Cell::new("Memory ID"), Cell::new("Version ID")]
}
fn row(&self) -> Vec<Cell> {
vec![Cell::new(&self.memory_id), Cell::new(&self.version_id)]
}
}
fn print_memory_stores(stores: Vec<MemoryStoreItem>, output_format: OutputFormat) {
if stores.is_empty() && matches!(output_format, OutputFormat::Pretty | OutputFormat::Text) {
println!("No memory stores found.");
return;
}
output::print_list(stores, output_format);
}
fn print_memories(memories: Vec<MemoryItem>, output_format: OutputFormat) {
if memories.is_empty() && matches!(output_format, OutputFormat::Pretty | OutputFormat::Text) {
println!("No memories found.");
return;
}
output::print_list(memories, output_format);
}
#[derive(Serialize)]
struct UpdateMemoryOutput {
memory_id: String,
version_id: String,
}
impl From<UpdateMemoryResponse> for UpdateMemoryOutput {
fn from(response: UpdateMemoryResponse) -> Self {
Self {
memory_id: response.memory_id,
version_id: response.version_id,
}
}
}
impl TableFormat for UpdateMemoryOutput {
fn header() -> Vec<Cell> {
vec![Cell::new("Memory ID"), Cell::new("Version ID")]
}
fn row(&self) -> Vec<Cell> {
vec![Cell::new(&self.memory_id), Cell::new(&self.version_id)]
}
}
fn print_update_memory_response(
response: UpdateMemoryResponse,
output_format: OutputFormat,
) -> Result<()> {
let output = UpdateMemoryOutput::from(response);
match output_format {
OutputFormat::Json => output::write_json(&output, std::io::stdout())?,
OutputFormat::Ndjson => output::write_json_line(&output, std::io::stdout())?,
OutputFormat::Pretty | OutputFormat::Text => {
println!("Updated memory {}.", output.memory_id);
output::print_list([output], output_format);
}
}
Ok(())
}
fn print_create_memory_response(
response: CreateMemoryResponse,
output_format: OutputFormat,
) -> Result<()> {
let output = CreateMemoryOutput::from(response);
match output_format {
OutputFormat::Json => output::write_json(&output, std::io::stdout())?,
OutputFormat::Ndjson => output::write_json_line(&output, std::io::stdout())?,
OutputFormat::Pretty | OutputFormat::Text => {
println!("Created memory {}.", output.memory_id);
output::print_list([output], output_format);
}
}
Ok(())
}
fn finish_command(result: Result<()>, ctx: &mut ModelContext<MemoryStoreCommandRunner>) {
match result {
Ok(()) => ctx.terminate_app(TerminationMode::ForceTerminate, None),
Err(err) => super::report_fatal_error(err, ctx),
}
}
+466 -176
View File
@@ -7,6 +7,40 @@ use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use ai::api_keys::{ApiKeyManager, AwsCredentialsRefreshStrategy};
use anyhow::Context;
pub(crate) use driver::harness::{task_env_vars, validate_cli_installed, ClaudeHarness};
pub use driver::AgentDriver;
use driver::AgentDriverError;
use telemetry::CliTelemetryEvent;
use tracing::Instrument as _;
use warp_cli::agent::{
AgentCommand, AgentProfileCommand, Harness, OutputFormat, Prompt, RunAgentArgs,
};
use warp_cli::api_key::ApiKeyCommand;
use warp_cli::artifact::ArtifactCommand;
use warp_cli::environment::{EnvironmentCommand, ImageCommand};
use warp_cli::federate::FederateCommand;
use warp_cli::harness_support::{HarnessSupportCommand, ReportArtifactCommand, TaskStatus};
use warp_cli::integration::IntegrationCommand;
use warp_cli::mcp::MCPCommand;
use warp_cli::memory_store::{MemoryCommand, MemoryStoreCommand};
use warp_cli::model::ModelCommand;
use warp_cli::provider::ProviderCommand;
use warp_cli::schedule::ScheduleSubcommand;
use warp_cli::secret::SecretCommand;
use warp_cli::share::ShareRequest;
use warp_cli::task::{MessageCommand, TaskCommand};
use warp_cli::{CliCommand, GlobalOptions, OZ_HARNESS_ENV};
use galaxy_core::features::FeatureFlag;
use warp_graphql::object_permissions::OwnerType;
use warp_isolation_platform::IsolationPlatformError;
#[cfg(not(target_family = "wasm"))]
use warp_logging::log_file_path;
use warp_managed_secrets::ManagedSecretManager;
use warpui::platform::TerminationMode;
use warpui::{AppContext, ModelSpawner, SingletonEntity};
use crate::ai::agent::api::convert_conversation::{
convert_conversation_data_to_ai_conversation, RestorationMode,
};
@@ -15,69 +49,35 @@ use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_sdk::driver::harness::{harness_kind, HarnessKind};
use crate::ai::agent_sdk::driver::{AgentDriverOptions, AgentRunPrompt, Task};
use crate::ai::agent_sdk::mcp_config::build_mcp_servers_from_specs;
use crate::ai::agent_sdk::setup_observability::{
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
};
use crate::ai::ambient_agents::task::HarnessConfig;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::attachment_utils::attachments_download_dir;
#[cfg(not(target_family = "wasm"))]
use crate::ai::aws_credentials::refresh_aws_credentials;
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
use crate::ai::llms::LLMId;
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::cloud_object::model::persistence::CloudModel;
use crate::server::server_api::ai::AIClient;
use crate::workflows::workflow::Workflow;
use ai::api_keys::{ApiKeyManager, AwsCredentialsRefreshStrategy};
use anyhow::Context;
use galaxy_cli::{
agent::{AgentCommand, AgentProfileCommand, OutputFormat},
artifact::ArtifactCommand,
environment::{EnvironmentCommand, ImageCommand},
federate::FederateCommand,
harness_support::{HarnessSupportCommand, ReportArtifactCommand, TaskStatus},
integration::IntegrationCommand,
mcp::MCPCommand,
model::ModelCommand,
provider::ProviderCommand,
schedule::ScheduleSubcommand,
secret::SecretCommand,
share::ShareRequest,
task::{MessageCommand, TaskCommand},
CliCommand, GlobalOptions,
};
use galaxy_core::features::FeatureFlag;
use galaxy_isolation_platform::IsolationPlatformError;
#[cfg(not(target_family = "wasm"))]
use galaxy_logging::log_file_path;
use galaxy_managed_secrets::ManagedSecretManager;
use galaxyui::ModelSpawner;
use galaxyui::{platform::TerminationMode, AppContext, SingletonEntity};
use crate::{
ai::ambient_agents::{task::HarnessConfig, AmbientAgentTaskId},
ai::cloud_environments::CloudAmbientAgentEnvironment,
auth::AuthStateProvider,
send_telemetry_sync_from_app_ctx,
server::{
ids::{ServerId, SyncId},
server_api::{ai::AgentConfigSnapshot, ServerApiProvider},
},
terminal::view::ConversationRestorationInNewPaneType,
};
use driver::AgentDriverError;
use galaxy_graphql::object_permissions::OwnerType;
use crate::ai::attachment_utils::attachments_download_dir;
use crate::ai::skills::{
clone_repo_for_skill, resolve_skill_spec, ResolveSkillError, ResolvedSkill,
};
pub(crate) use driver::harness::{
task_env_vars, validate_cli_installed, ClaudeHarness, ThirdPartyHarness,
};
pub use driver::AgentDriver;
use galaxy_cli::agent::{Harness, Prompt, RunAgentArgs};
use galaxy_cli::OZ_HARNESS_ENV;
use telemetry::CliTelemetryEvent;
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::CloudObjectLookup as _;
use crate::send_telemetry_sync_from_app_ctx;
use crate::server::ids::{ServerId, SyncId};
use crate::server::server_api::ai::{AIClient, AgentConfigSnapshot, GitCredential};
use crate::server::server_api::ServerApiProvider;
use crate::terminal::view::ConversationRestorationInNewPaneType;
use crate::workflows::workflow::Workflow;
mod admin;
mod agent_config;
mod agent_management;
mod ambient;
mod api_key;
mod artifact;
pub(crate) mod artifact_upload;
mod common;
@@ -92,6 +92,7 @@ mod integration;
mod integration_output;
mod mcp;
mod mcp_config;
mod memory_store;
mod model;
mod oauth_flow;
pub mod output;
@@ -100,6 +101,7 @@ mod provider;
pub(crate) mod retry;
mod schedule;
mod secret;
pub(crate) mod setup_observability;
mod telemetry;
#[cfg(test)]
mod test_support;
@@ -120,6 +122,7 @@ fn maybe_warn_team_api_key(ctx: &AppContext) {
}
/// Run a Warp CLI command.
#[tracing::instrument(name = "agent_sdk::run", skip_all, err, fields(tags.cloud_agent = true))]
pub fn run(
ctx: &mut AppContext,
command: CliCommand,
@@ -148,6 +151,10 @@ fn dispatch_command(
CliCommand::MCP(mcp_cmd) => mcp::run(ctx, global_options, mcp_cmd),
CliCommand::Run(task_cmd) => run_task(ctx, global_options, task_cmd),
CliCommand::Model(model_cmd) => model::run(ctx, global_options, model_cmd),
CliCommand::MemoryStore(memory_store_cmd) => {
memory_store::run(ctx, global_options, memory_store_cmd)
}
CliCommand::Memory(memory_cmd) => memory_store::run_memory(ctx, global_options, memory_cmd),
CliCommand::Login => admin::login(ctx),
CliCommand::Logout => admin::logout(ctx),
CliCommand::Whoami => admin::whoami(ctx, global_options.output_format),
@@ -198,6 +205,12 @@ fn dispatch_command(
}
artifact::run(ctx, global_options, artifact_cmd)
}
CliCommand::ApiKey(api_key_cmd) => {
if !FeatureFlag::APIKeyManagement.is_enabled() {
return Err(anyhow::anyhow!("invalid value 'api-key'"));
}
api_key::run(ctx, global_options, api_key_cmd)
}
}
}
@@ -305,10 +318,28 @@ fn run_agent(
"--claude-auth-secret is only valid with --harness claude."
));
}
if args.runner.is_some() && !FeatureFlag::CloudRunners.is_enabled() {
return Err(anyhow::anyhow!("unexpected argument '--runner' found"));
}
ambient::run_ambient_agent(ctx, args)
}
AgentCommand::Profile(sub) => profiles::run(ctx, global_options, sub),
AgentCommand::List(args) => agent_config::list_agents(ctx, args),
AgentCommand::List(args) => {
agent_management::list_agents(ctx, global_options.output_format, args)
}
AgentCommand::Get(args) => {
agent_management::get_agent(ctx, global_options.output_format, args)
}
AgentCommand::Create(args) => {
agent_management::create_agent(ctx, global_options.output_format, args)
}
AgentCommand::Update(args) => {
agent_management::update_agent(ctx, global_options.output_format, args)
}
AgentCommand::Delete(args) => {
agent_management::delete_agent(ctx, global_options.output_format, args)
}
AgentCommand::Skills(args) => agent_config::list_skills(ctx, args),
}
}
@@ -337,21 +368,44 @@ fn build_merged_config_and_task(
// Merge precedence: file < CLI < skill
let file_merged = config_file::merge_with_precedence(loaded_file.as_ref(), Default::default());
// Runner support is gated. The `run` command has no `--runner` flag, but a
// config file can still set `runner_id`, so reject it when the flag is off.
if file_merged.runner_id.is_some() && !FeatureFlag::CloudRunners.is_enabled() {
return Err(anyhow::anyhow!(
"`runner_id` is set in the config file but runner support is not enabled"
));
}
// Skill provides base_prompt and optionally name
let (skill_name, runtime_base_prompt) = match resolved_skill {
Some(skill) => (Some(skill.name.clone()), Some(skill.instructions.clone())),
None => (None, None),
};
// When a non-Oz harness is active, --model targets the harness rather than the Oz model.
let harness_model_id = if args.harness != Harness::Oz {
args.model.model.clone()
} else {
None
};
let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig {
harness_type: args.harness,
model_id: harness_model_id,
reasoning_level: None,
});
let oz_model = if args.harness == Harness::Oz {
args.model.model.clone().or(file_merged.model_id)
} else {
None
};
let mut merged_config = AgentConfigSnapshot {
// CLI name > skill name > file name
name: args.name.clone().or(skill_name).or(file_merged.name),
environment_id: args.environment.clone().or(file_merged.environment_id),
model_id: args.model.model.clone().or(file_merged.model_id),
runner_id: file_merged.runner_id,
model_id: oz_model,
// Skill base_prompt takes precedence over file base_prompt
base_prompt: runtime_base_prompt.clone().or(file_merged.base_prompt),
mcp_servers: config_file::merge_mcp_servers(file_merged.mcp_servers, cli_mcp_servers),
@@ -374,6 +428,7 @@ fn build_merged_config_and_task(
let model_override: Option<LLMId> = merged_config
.model_id
.as_deref()
.filter(|_| args.harness == Harness::Oz)
.map(|model_id| common::validate_agent_mode_base_model_id(model_id, ctx))
.transpose()?;
@@ -420,15 +475,25 @@ fn build_server_side_task(
None => Vec::new(),
};
let model_override: Option<LLMId> = args
.model
.model
.as_deref()
.map(|model_id| common::validate_agent_mode_base_model_id(model_id, ctx))
.transpose()?;
let harness_model_id = if args.harness != Harness::Oz {
args.model.model.clone()
} else {
None
};
let model_override: Option<LLMId> = if args.harness == Harness::Oz {
args.model
.model
.as_deref()
.map(|model_id| common::validate_agent_mode_base_model_id(model_id, ctx))
.transpose()?
} else {
None
};
let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig {
harness_type: args.harness,
model_id: harness_model_id,
reasoning_level: None,
});
let skill_name = resolved_skill.as_ref().map(|s| s.name.clone());
@@ -439,6 +504,7 @@ fn build_server_side_task(
let config = AgentConfigSnapshot {
name: args.name.clone().or(skill_name),
environment_id: environment.clone(),
runner_id: None,
model_id: model_id_string,
base_prompt: None,
mcp_servers: cli_mcp_servers,
@@ -466,6 +532,24 @@ fn build_server_side_task(
Ok((config, task))
}
fn reconcile_task_harness(
task_id: &str,
selected_harness: &mut Harness,
task_harness: Harness,
) -> Result<HarnessKind, AgentDriverError> {
if *selected_harness == Harness::Oz {
*selected_harness = task_harness;
} else if task_harness != *selected_harness {
return Err(AgentDriverError::TaskHarnessMismatch {
task_id: task_id.to_string(),
expected: task_harness.to_string(),
got: selected_harness.to_string(),
});
}
harness_kind(*selected_harness)
}
/// Resolve a `Prompt` to a plain string.
fn resolve_prompt(prompt: &Prompt, ctx: &AppContext) -> Result<String, AgentDriverError> {
match prompt {
@@ -515,14 +599,7 @@ fn run_task(
}
}
}
TaskCommand::Message(message_cmd) => {
if !FeatureFlag::OrchestrationV2.is_enabled() {
return Err(anyhow::anyhow!(
"The 'message' subcommand is not available in this build"
));
}
ambient::run_message(ctx, global_options, message_cmd)
}
TaskCommand::Message(message_cmd) => ambient::run_message(ctx, global_options, message_cmd),
}
}
@@ -538,53 +615,80 @@ impl galaxyui::Entity for AgentDriverRunner {
impl galaxyui::SingletonEntity for AgentDriverRunner {}
impl AgentDriverRunner {
#[tracing::instrument(skip_all, err, fields(
tags.cloud_agent = true,
args.sandboxed = args.sandboxed,
args.computer_use = args.computer_use.computer_use,
args.no_computer_use = args.computer_use.no_computer_use
))]
async fn setup_and_run_driver(
foreground: ModelSpawner<Self>,
args: RunAgentArgs,
server_api: Arc<dyn AIClient>,
output_format: OutputFormat,
) -> Result<(), AgentDriverError> {
// Extract the task ID as early as possible for best-effort setup observability.
// Local CLI-created runs may not have a task yet, so those setup events explicitly no-op.
let mut task_id: Option<AmbientAgentTaskId> =
args.task_id.as_deref().and_then(|s| s.parse().ok());
Self::set_ambient_agent_task_id(&foreground, task_id).await?;
let background = foreground.spawn(|_, ctx| ctx.background_executor()).await?;
let setup_events = match task_id {
Some(task_id) => SetupClientEventReporter::new(task_id, server_api.clone(), background),
None => SetupClientEventReporter::noop(server_api.clone(), background),
};
setup_events
.post_timeline_event(OzRunTimelineEvent::WorkerContainerReady)
.await;
// Ensure we've synced team state before starting the driver.
Self::refresh_team_metadata(&foreground).await?;
setup_events
.record_result(
SetupStep::TeamMetadataRefresh,
Self::refresh_team_metadata(&foreground),
)
.await?;
// Wait for Galaxy Drive to sync before building the task config, since
// prompt resolution (SavedPrompt -> workflow lookup) and environment
// resolution (CloudAmbientAgentEnvironment lookup) depend on it.
if foreground
.spawn(|_, ctx| common::refresh_warp_drive(ctx))
.await?
.await
.is_err()
{
return Err(AgentDriverError::WarpDriveSyncFailed);
}
// Extract the task ID if available, so that if there are setup errors and we have
// a server-provided task ID, we can report them. If we create a task for a local CLI
// run, its ID will be stored in the inner future.
let mut task_id: Option<AmbientAgentTaskId> =
args.task_id.as_deref().and_then(|s| s.parse().ok());
setup_events
.record_result(SetupStep::WarpDriveSync, async {
if foreground
.spawn(|_, ctx| common::refresh_warp_drive(ctx))
.await?
.await
.is_err()
{
return Err(AgentDriverError::WarpDriveSyncFailed);
}
Ok(())
})
.await?;
// Set up and run the driver, reporting any errors back to the server.
let result: Result<(), AgentDriverError> = async {
// Pull relevant variables out of args before moving it into the closure.
let share_requests = args.share.share.clone();
let bedrock_inference_role = args.bedrock_inference_role.clone();
let bedrock_role_region = args.bedrock_role_region.clone();
let has_task_id = args.task_id.is_some();
let args_harness = args.harness;
// `--conversation` path (user-invoked local resume): validate before any task side
// effects so mismatches fail fast. The `--task-id` path derives its conversation id
// from the server-side task metadata inside `build_driver_options_and_task`. Both
// can currently be passed together (the worker server-side appends `--conversation`
// alongside `--task-id` for Slack/Linear followups); when both are set, the explicit
// `--conversation` value wins via the merge below.
if let Some(conversation_id) = args.conversation.as_deref() {
common::fetch_and_validate_conversation_harness(
server_api.clone(),
conversation_id,
args_harness,
)
.await?;
if !has_task_id {
if let Some(conversation_id) = args.conversation.as_deref() {
common::fetch_and_validate_conversation_harness(
server_api.clone(),
conversation_id,
args_harness,
)
.await?;
}
}
let resume_conversation_id = args.conversation.clone();
@@ -593,7 +697,8 @@ impl AgentDriverRunner {
// the fetched `AmbientAgentTask` (set by the server when linking the task to an
// existing conversation, e.g. via `run-cloud --conversation`).
let (mut driver_options, task, task_conversation_id) =
Self::build_driver_options_and_task(&foreground, args, &server_api).await?;
Self::build_driver_options_and_task(&foreground, args, &server_api, &setup_events)
.await?;
// Update the effective task ID so errors are reported correctly.
// This only matters if we created a task ID locally.
@@ -609,6 +714,16 @@ impl AgentDriverRunner {
#[cfg(not(target_family = "wasm"))]
if let Some(role_arn) = bedrock_inference_role {
// clap's `requires` constraint enforces this at parse time, so a missing
// region here means a caller is constructing `RunAgentArgs` directly
// without the flag. Fail loudly so callers don't silently fall back to a
// hard-coded STS region.
let role_region = bedrock_role_region.ok_or_else(|| {
AgentDriverError::AwsBedrockCredentialsFailed(
"--bedrock-role-region is required when --bedrock-inference-role is set"
.to_string(),
)
})?;
// Set the OIDC strategy on the UI thread and kick off the refresh; the
// returned future resolves when credentials are committed to the model.
let refresh_future = foreground
@@ -619,6 +734,7 @@ impl AgentDriverRunner {
AwsCredentialsRefreshStrategy::OidcManaged {
task_id: bedrock_task_id,
role_arn,
region: role_region,
},
);
refresh_aws_credentials(manager, ctx)
@@ -654,12 +770,16 @@ impl AgentDriverRunner {
// Pull conversation information, if we have it
if let Some(conversation_id) = resume_conversation_id {
driver_options.resume = Self::load_conversation_information(
&foreground,
conversation_id,
&task.harness,
)
.await?;
driver_options.resume = setup_events
.record_result(
SetupStep::ConversationResumeLoading,
Self::load_conversation_information(
&foreground,
conversation_id,
&task.harness,
),
)
.await?;
}
// Run the driver
@@ -701,6 +821,114 @@ impl AgentDriverRunner {
.map_err(|_| AgentDriverError::TeamMetadataRefreshTimeout)
}
async fn set_ambient_agent_task_id(
foreground: &ModelSpawner<Self>,
task_id: Option<AmbientAgentTaskId>,
) -> Result<(), AgentDriverError> {
foreground
.spawn(move |_, ctx| {
ServerApiProvider::handle(ctx)
.as_ref(ctx)
.get()
.set_ambient_agent_task_id(task_id);
})
.await?;
Ok(())
}
async fn fetch_task_git_credentials(
task_id_str: String,
ai_client: Arc<dyn AIClient>,
) -> anyhow::Result<Vec<GitCredential>> {
let workload_token = warp_isolation_platform::issue_workload_token(Some(
std::time::Duration::from_secs(5 * 60),
))
.await?
.token;
ai_client
.get_task_git_credentials(task_id_str, workload_token)
.await
}
async fn bootstrap_git_credentials_for_task(
foreground: &ModelSpawner<Self>,
task_id_str: &str,
args: &RunAgentArgs,
) -> Result<(), AgentDriverError> {
if warp_isolation_platform::detect().is_none() && args.configure_git_credentials_with_github
{
foreground
.spawn(|_, _| {
command::blocking::Command::new("gh")
.args(["auth", "setup-git"])
.spawn()
.map_err(|err| {
AgentDriverError::ConfigBuildFailed(anyhow::anyhow!(
"gh auth setup-git failed: {err:?}"
))
})
})
.await?
.map(|_| ())?;
return Ok(());
}
if !FeatureFlag::GitCredentialRefresh.is_enabled() {
return Ok(());
}
if task_id_str.parse::<AmbientAgentTaskId>().is_err() {
log::debug!(
"Skipping git credentials bootstrap: could not parse task ID '{task_id_str}'"
);
return Ok(());
}
let (ai_client, task_id_str) = foreground
.spawn({
let task_id_str = task_id_str.to_string();
move |_, ctx| {
let ai_client = ServerApiProvider::handle(ctx)
.as_ref(ctx)
.get_ai_client()
.clone();
(ai_client, task_id_str)
}
})
.await?;
let credentials = match Self::fetch_task_git_credentials(task_id_str, ai_client).await {
Ok(credentials) => credentials,
Err(err)
if err
.downcast_ref::<IsolationPlatformError>()
.is_some_and(|err| {
matches!(err, IsolationPlatformError::NoIsolationPlatformDetected)
}) =>
{
log::debug!("Skipping git credentials bootstrap: {err}");
return Ok(());
}
Err(err) => {
return Err(AgentDriverError::SkillResolutionFailed(format!(
"Failed to fetch git credentials before skill resolution: {err:#}"
)));
}
};
if credentials.is_empty() {
log::debug!("No git credentials returned before skill resolution");
return Ok(());
}
driver::git_credentials::configure_git_credentials(&credentials).map_err(|err| {
AgentDriverError::SkillResolutionFailed(format!(
"Failed to write git credentials before skill resolution: {err:#}"
))
})?;
log::info!("Git credentials configured before task setup");
Ok(())
}
/// Resolve the skill spec from args, if one was provided.
///
/// In sandboxed mode with a fully-qualified spec (org + repo), the repo is
@@ -710,6 +938,7 @@ impl AgentDriverRunner {
foreground: &ModelSpawner<Self>,
args: &RunAgentArgs,
working_dir: &Path,
setup_events: &SetupClientEventReporter,
) -> Result<Option<ResolvedSkill>, AgentDriverError> {
if !FeatureFlag::OzPlatformSkills.is_enabled() {
return Ok(None);
@@ -724,11 +953,17 @@ impl AgentDriverRunner {
let org = skill_spec.org.as_ref().expect("org checked above");
let repo_name = skill_spec.repo.as_ref().expect("repo checked above");
log::info!("Cloning {org}/{repo_name} for skill resolution in sandboxed mode");
clone_repo_for_skill(org, repo_name, working_dir)
.await
.map_err(|err| {
AgentDriverError::SkillResolutionFailed(format_skill_resolution_error(err))
})?;
setup_events
.record_result(SetupStep::SkillRepoClone, async {
clone_repo_for_skill(org, repo_name, working_dir)
.await
.map_err(|err| {
AgentDriverError::SkillResolutionFailed(format_skill_resolution_error(
err,
))
})
})
.await?;
}
let working_dir_buf = working_dir.to_path_buf();
@@ -756,6 +991,7 @@ impl AgentDriverRunner {
foreground: &ModelSpawner<Self>,
args: RunAgentArgs,
server_api: &Arc<dyn AIClient>,
setup_events: &SetupClientEventReporter,
) -> Result<(AgentDriverOptions, Task, Option<String>), AgentDriverError> {
// Get the working directory
let working_dir = match args.cwd.as_ref() {
@@ -765,8 +1001,12 @@ impl AgentDriverRunner {
}
.map_err(AgentDriverError::ConfigBuildFailed)?;
if let Some(task_id_str) = args.task_id.as_ref() {
Self::bootstrap_git_credentials_for_task(foreground, task_id_str, &args).await?;
}
// Resolve the skill, if we have one
let resolved_skill = Self::resolve_skill(foreground, &args, &working_dir).await?;
let resolved_skill =
Self::resolve_skill(foreground, &args, &working_dir, setup_events).await?;
// Extract variables we want to use later before moving args into the closure
let task_id_str = args.task_id.clone();
@@ -784,6 +1024,10 @@ impl AgentDriverRunner {
let should_share = (args.share.is_shared() || args.task_id.is_some())
&& FeatureFlag::AgentSharedSessions.is_enabled();
let third_party_harness_model_config = merged_config
.harness
.as_ref()
.and_then(|h| h.model_config());
let driver_options = driver::AgentDriverOptions {
working_dir: working_dir.clone(),
task_id,
@@ -795,6 +1039,7 @@ impl AgentDriverRunner {
cloud_providers: Vec::new(),
environment: None,
selected_harness: args.harness,
third_party_harness_model_config,
snapshot_disabled: args.snapshot.no_snapshot.then_some(true),
snapshot_upload_timeout: args
.snapshot
@@ -804,6 +1049,9 @@ impl AgentDriverRunner {
.snapshot
.snapshot_script_timeout
.map(|duration| duration.into()),
skip_initial_turn: args.skip_initial_turn,
strict_mcp_startup: args.strict_mcp_startup,
mcp_startup_timeout: args.mcp_startup_timeout.map(|duration| duration.into()),
};
Ok((merged_config, task, driver_options))
@@ -817,13 +1065,17 @@ impl AgentDriverRunner {
// The existing-task branch also surfaces the task's `conversation_id` (if any) so
// the caller can wire up resume without a separate `--conversation` arg.
let task_conversation_id = if let Some(task_id_str) = task_id_str {
Self::fetch_secrets_and_attachments(
foreground,
task_id_str,
&mut driver_options,
&mut task,
)
.await?
setup_events
.record_result(
SetupStep::TaskDataFetch,
Self::fetch_secrets_and_attachments(
foreground,
task_id_str,
&mut driver_options,
&mut task,
),
)
.await?
} else {
// Extract the prompt text that we'll pass up to the server when we create the task.
let prompt_for_task_creation = match &prompt {
@@ -847,9 +1099,13 @@ impl AgentDriverRunner {
.await?;
None
};
// Resolve environment and cloud providers.
Self::resolve_environment(foreground, environment_id, &mut driver_options).await?;
setup_events
.record_result(
SetupStep::EnvironmentResolution,
Self::resolve_environment(foreground, environment_id, &mut driver_options),
)
.await?;
Ok((driver_options, task, task_conversation_id))
}
@@ -889,15 +1145,8 @@ impl AgentDriverRunner {
}
};
foreground
.spawn(move |_, ctx| {
// Set the task ID on the ServerApi so it's sent with all subsequent requests.
ServerApiProvider::handle(ctx)
.as_ref(ctx)
.get()
.set_ambient_agent_task_id(task_id);
})
.await?;
// Set the task ID on the ServerApi so it's sent with all subsequent requests.
Self::set_ambient_agent_task_id(foreground, task_id).await?;
driver_options.task_id = task_id;
Ok(())
@@ -939,6 +1188,9 @@ impl AgentDriverRunner {
None
}
};
// Set the task ID on the ServerApi before any task-scoped server calls below, so failures
// during setup can still be reported with cloud-agent context.
Self::set_ambient_agent_task_id(foreground, parsed_task_id).await?;
// Fetch secrets, task metadata, regular attachments, and handoff snapshot
// attachments in parallel. The handoff snapshot fetch is independent of the
@@ -976,19 +1228,18 @@ impl AgentDriverRunner {
)
.await
};
let (secrets_result, attachments_result, task_metadata_result, handoff_snapshot_result) =
futures::future::join4(
task_secrets,
driver::attachments::fetch_and_download_attachments(
ai_client.clone(),
server_api.clone(),
task_id_str.clone(),
attachments_download_dir.clone(),
),
task_metadata,
handoff_snapshot,
)
.await;
let (secrets_result, attachments_result, task_metadata_result, handoff_snapshot_result) = futures::join!(
task_secrets,
driver::attachments::fetch_and_download_attachments(
ai_client.clone(),
server_api.clone(),
task_id_str.clone(),
attachments_download_dir.clone(),
),
task_metadata,
handoff_snapshot,
);
// Extract attachments_dir from successful result, log errors
let mut attachments_dir = match attachments_result {
@@ -1010,6 +1261,7 @@ impl AgentDriverRunner {
log::warn!("Failed to fetch handoff snapshot attachments: {e:#}");
}
}
let secrets = match secrets_result {
Ok(secrets) => secrets,
Err(err) => {
@@ -1028,55 +1280,52 @@ impl AgentDriverRunner {
}
}
};
let (parent_run_id, task_conversation_id, task_harness) = match task_metadata_result {
Ok(Some(task_metadata)) => {
// The task's harness is stored on the snapshot; if absent, it's the default Oz.
let task_harness = task_metadata
.agent_config_snapshot
.as_ref()
.and_then(|c| c.harness.as_ref())
.map(|h| h.harness_type)
.unwrap_or(Harness::Oz);
(
task_metadata.parent_run_id,
task_metadata.conversation_id,
Some(task_harness),
)
}
Ok(None) => (None, None, None),
Err(err) => {
log::warn!("Failed to fetch task metadata: {err:#}");
(None, None, None)
}
};
let (parent_run_id, task_conversation_id, task_harness, task_harness_model_config) =
match task_metadata_result {
Ok(Some(task_metadata)) => {
// The task's harness is stored on the snapshot; if absent, it's the default Oz.
let task_harness_config = task_metadata
.agent_config_snapshot
.as_ref()
.and_then(|c| c.harness.as_ref());
let task_harness = task_harness_config
.map(|h| h.harness_type)
.unwrap_or(Harness::Oz);
let task_harness_model_config =
task_harness_config.and_then(|h| h.model_config());
(
task_metadata.parent_run_id,
task_metadata.conversation_id,
Some(task_harness),
task_harness_model_config,
)
}
Ok(None) => (None, None, None, None),
Err(err) => {
log::warn!("Failed to fetch task metadata: {err:#}");
(None, None, None, None)
}
};
// Validate the requested `--harness` against the task's harness setting. This avoids the
// extra conversation-metadata roundtrip that would otherwise be needed downstream when the
// task is linked to an existing conversation, since task harness and conversation harness
// always match (the task spawned the conversation).
if let Some(task_harness) = task_harness {
if task_harness != driver_options.selected_harness {
return Err(AgentDriverError::TaskHarnessMismatch {
task_id: task_id_str,
expected: task_harness.to_string(),
got: driver_options.selected_harness.to_string(),
});
}
task.harness = reconcile_task_harness(
&task_id_str,
&mut driver_options.selected_harness,
task_harness,
)?;
}
// Set the task ID on the ServerApi so it's sent with all subsequent requests.
foreground
.spawn(move |_, ctx| {
ServerApiProvider::handle(ctx)
.as_ref(ctx)
.get()
.set_ambient_agent_task_id(parsed_task_id);
})
.await?;
driver_options.task_id = parsed_task_id;
driver_options.parent_run_id = parent_run_id;
driver_options.secrets = secrets;
// CLI flags continue to take precedence so users can still override per-invocation.
if driver_options.third_party_harness_model_config.is_none() {
driver_options.third_party_harness_model_config = task_harness_model_config;
}
// Update the task prompt to include the downloaded attachments dir
if let AgentRunPrompt::ServerSide {
@@ -1102,6 +1351,7 @@ impl AgentDriverRunner {
/// wraps the returned payload (if any) in [`driver::ResumeOptions::ThirdParty`]; each harness
/// owns its server call and error mapping. Returns `None` if a third-party harness has no
/// resume payload to surface.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, conversation_id = conversation_id))]
async fn load_conversation_information(
foreground: &ModelSpawner<Self>,
conversation_id: String,
@@ -1133,7 +1383,6 @@ impl AgentDriverRunner {
"Failed to convert conversation data to AIConversation".into(),
)
})?;
Ok(Some(driver::ResumeOptions::Oz(Box::new(
ConversationRestorationInNewPaneType::Historical {
conversation,
@@ -1164,6 +1413,7 @@ impl AgentDriverRunner {
}
/// Resolve the environment and store into `driver_options`.
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, ?environment_id))]
async fn resolve_environment(
foreground: &ModelSpawner<Self>,
environment_id: Option<String>,
@@ -1207,6 +1457,7 @@ impl AgentDriverRunner {
}
/// Create the AgentDriver and start running the task.
#[tracing::instrument(skip_all, fields(tags.cloud_agent = true))]
fn create_and_run_driver(
ctx: &mut AppContext,
driver_options: driver::AgentDriverOptions,
@@ -1227,7 +1478,9 @@ impl AgentDriverRunner {
if let Some(share_requests) = share_requests {
driver.add_share_requests(share_requests, ctx);
}
let agent_future = driver.run(task, ctx);
let span =
tracing::info_span!("AgentDriver::run", tags.cloud_agent = true, ?task.model, ?task.harness);
let agent_future = driver.run(task, ctx).instrument(span);
ctx.spawn(agent_future, |_, result, ctx| match result {
Ok(()) => {
@@ -1251,6 +1504,11 @@ fn command_requires_auth(command: &CliCommand) -> bool {
AgentProfileCommand::List => true,
},
AgentCommand::List(_) => true,
AgentCommand::Get(_) => true,
AgentCommand::Create(_) => true,
AgentCommand::Update(_) => true,
AgentCommand::Delete(_) => true,
AgentCommand::Skills(_) => true,
},
CliCommand::Environment(environment_cmd) => match environment_cmd {
EnvironmentCommand::List => true,
@@ -1272,6 +1530,8 @@ fn command_requires_auth(command: &CliCommand) -> bool {
CliCommand::Model(model_cmd) => match model_cmd {
ModelCommand::List => true,
},
CliCommand::MemoryStore(_) => true,
CliCommand::Memory(_) => true,
CliCommand::Login => false,
CliCommand::Logout => false,
CliCommand::Whoami => true,
@@ -1282,6 +1542,7 @@ fn command_requires_auth(command: &CliCommand) -> bool {
CliCommand::Federate(_) => true,
CliCommand::HarnessSupport(_) => true,
CliCommand::Artifact(_) => true,
CliCommand::ApiKey(_) => true,
}
}
@@ -1365,6 +1626,8 @@ fn report_fatal_error(err: anyhow::Error, ctx: &mut AppContext) {
let _ = write!(&mut message, "\n=> {cause}");
}
tracing::event!(tracing::Level::ERROR, tags.cloud_agent = true, message);
#[cfg(not(target_family = "wasm"))]
{
if let Ok(path) = log_file_path() {
@@ -1389,6 +1652,7 @@ fn resolve_orchestration_harness_label() -> &'static str {
Some(Harness::Claude) => "claude",
Some(Harness::OpenCode) => "opencode",
Some(Harness::Gemini) => "gemini",
Some(Harness::Codex) => "codex",
Some(Harness::Unknown) | None => "unknown",
}
}
@@ -1408,6 +1672,11 @@ fn command_to_telemetry_event(command: &CliCommand) -> CliTelemetryEvent {
AgentProfileCommand::List => CliTelemetryEvent::AgentProfileList,
},
CliCommand::Agent(AgentCommand::List(_)) => CliTelemetryEvent::AgentList,
CliCommand::Agent(AgentCommand::Get(_)) => CliTelemetryEvent::AgentGet,
CliCommand::Agent(AgentCommand::Create(_)) => CliTelemetryEvent::AgentCreate,
CliCommand::Agent(AgentCommand::Update(_)) => CliTelemetryEvent::AgentUpdate,
CliCommand::Agent(AgentCommand::Delete(_)) => CliTelemetryEvent::AgentDelete,
CliCommand::Agent(AgentCommand::Skills(_)) => CliTelemetryEvent::AgentSkills,
CliCommand::Environment(EnvironmentCommand::List) => CliTelemetryEvent::EnvironmentList,
CliCommand::Environment(EnvironmentCommand::Create { .. }) => {
CliTelemetryEvent::EnvironmentCreate
@@ -1452,6 +1721,19 @@ fn command_to_telemetry_event(command: &CliCommand) -> CliTelemetryEvent {
},
},
CliCommand::Model(ModelCommand::List) => CliTelemetryEvent::ModelList,
CliCommand::MemoryStore(memory_store_cmd) => match memory_store_cmd {
MemoryStoreCommand::List => CliTelemetryEvent::MemoryStoreList,
MemoryStoreCommand::Get(_) => CliTelemetryEvent::MemoryStoreGetStore,
MemoryStoreCommand::Update(_) => CliTelemetryEvent::MemoryStoreUpdateStore,
MemoryStoreCommand::ListStoreAgents(_) => CliTelemetryEvent::MemoryStoreListStoreAgents,
},
CliCommand::Memory(memory_cmd) => match memory_cmd {
MemoryCommand::List(_) => CliTelemetryEvent::MemoryStoreListMemories,
MemoryCommand::Create(_) => CliTelemetryEvent::MemoryStoreCreateMemory,
MemoryCommand::Update(_) => CliTelemetryEvent::MemoryStoreUpdateMemory,
MemoryCommand::Delete(_) => CliTelemetryEvent::MemoryStoreDeleteMemory,
MemoryCommand::Versions(_) => CliTelemetryEvent::MemoryStoreListVersions,
},
CliCommand::Login => CliTelemetryEvent::Login,
CliCommand::Logout => CliTelemetryEvent::Logout,
CliCommand::Whoami => CliTelemetryEvent::Whoami,
@@ -1496,12 +1778,20 @@ fn command_to_telemetry_event(command: &CliCommand) -> CliTelemetryEvent {
success: finish_args.status == TaskStatus::Success,
}
}
HarnessSupportCommand::ReportShutdown(_) => {
CliTelemetryEvent::HarnessSupportReportShutdown
}
},
CliCommand::Artifact(artifact_cmd) => match artifact_cmd {
ArtifactCommand::Upload(_) => CliTelemetryEvent::ArtifactUpload,
ArtifactCommand::Get(_) => CliTelemetryEvent::ArtifactGet,
ArtifactCommand::Download(_) => CliTelemetryEvent::ArtifactDownload,
},
CliCommand::ApiKey(api_key_cmd) => match api_key_cmd {
ApiKeyCommand::List(_) => CliTelemetryEvent::ApiKeyList,
ApiKeyCommand::Create(_) => CliTelemetryEvent::ApiKeyCreate,
ApiKeyCommand::Expire(_) => CliTelemetryEvent::ApiKeyExpire,
},
}
}
+42 -7
View File
@@ -1,12 +1,15 @@
use galaxy_cli::{
artifact::{ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs},
task::{MessageCommand, MessageSendArgs, MessageWatchArgs, TaskCommand},
CliCommand,
};
use galaxy_core::telemetry::TelemetryEvent;
use serde_json::json;
use galaxy_cli::agent::Harness;
use galaxy_cli::artifact::{
ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs,
};
use galaxy_cli::task::{MessageCommand, MessageSendArgs, MessageWatchArgs, TaskCommand};
use galaxy_cli::CliCommand;
use galaxy_core::telemetry::TelemetryEvent;
use super::{command_requires_auth, command_to_telemetry_event};
use super::{command_requires_auth, command_to_telemetry_event, reconcile_task_harness};
const TASK_ID: &str = "00000000-0000-0000-0000-000000000001";
#[test]
fn logout_does_not_require_auth() {
@@ -128,6 +131,38 @@ fn run_message_send_telemetry_defaults_to_unknown_harness() {
assert_eq!(event.payload(), Some(json!({ "harness": "unknown" })));
}
#[test]
fn reconcile_task_harness_adopts_task_harness_when_cli_uses_default() {
let mut selected_harness = Harness::Oz;
let harness = reconcile_task_harness(TASK_ID, &mut selected_harness, Harness::Claude)
.expect("default harness should adopt task harness");
assert_eq!(selected_harness, Harness::Claude);
assert_eq!(harness.harness(), Harness::Claude);
}
#[test]
fn reconcile_task_harness_allows_matching_explicit_harness() {
let mut selected_harness = Harness::Claude;
let harness = reconcile_task_harness(TASK_ID, &mut selected_harness, Harness::Claude)
.expect("matching harness should succeed");
assert_eq!(selected_harness, Harness::Claude);
assert_eq!(harness.harness(), Harness::Claude);
}
#[test]
fn reconcile_task_harness_rejects_explicit_mismatch() {
let mut selected_harness = Harness::Gemini;
let err = reconcile_task_harness(TASK_ID, &mut selected_harness, Harness::Claude)
.expect_err("mismatched harness should fail");
assert_eq!(selected_harness, Harness::Gemini);
assert!(err.to_string().contains("Task"));
assert!(err.to_string().contains("--harness gemini"));
assert!(err.to_string().contains("claude"));
}
#[test]
#[serial_test::serial]
fn run_message_watch_telemetry_defaults_to_unknown_harness() {
+8 -5
View File
@@ -1,11 +1,14 @@
use std::collections::BTreeSet;
use comfy_table::Cell;
use serde::Serialize;
use warp_cli::model::ModelCommand;
use warp_cli::GlobalOptions;
use warpui::platform::TerminationMode;
use warpui::{AppContext, ModelContext, SingletonEntity};
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::ai::llms::LLMPreferences;
use comfy_table::Cell;
use galaxy_cli::{model::ModelCommand, GlobalOptions};
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
use serde::Serialize;
/// Handle model-related CLI commands.
pub fn run(
@@ -43,7 +46,7 @@ impl ModelCommandRunner {
let llm_prefs = LLMPreferences::as_ref(ctx);
let mut ids = BTreeSet::new();
for info in llm_prefs.get_base_llm_choices_for_agent_mode() {
for info in llm_prefs.get_base_llm_choices_for_agent_mode(ctx) {
ids.insert(info.id.to_string());
}
+1 -1
View File
@@ -2,10 +2,10 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Result};
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
use galaxyui::r#async::Timer;
use crate::server::server_api::integrations::IntegrationsClient;
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
/// Shared helpers for OAuth-based connect flows (txId + polling).
///
+4 -3
View File
@@ -1,12 +1,13 @@
use super::{
run_jq_filter, write_filter_output, write_json, write_json_line, write_list, TableFormat,
};
use comfy_table::Cell;
use galaxy_cli::agent::OutputFormat;
use galaxy_cli::json_filter::parse_jq_filter;
use serde::Serialize;
use serde_json::json;
use super::{
run_jq_filter, write_filter_output, write_json, write_json_line, write_list, TableFormat,
};
#[derive(Serialize)]
struct TestItem {
id: &'static str,
+2
View File
@@ -2,6 +2,8 @@ use comfy_table::Cell;
use galaxy_cli::{agent::AgentProfileCommand, GlobalOptions};
use galaxyui::{AppContext, ModelContext, SingletonEntity};
use serde::Serialize;
use galaxy_cli::agent::AgentProfileCommand;
use galaxy_cli::GlobalOptions;
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
+6 -7
View File
@@ -1,15 +1,14 @@
//! Provider command for linking third-party services.
use crate::workspaces::user_workspaces::UserWorkspaces;
use comfy_table::Cell;
use galaxy_cli::{
provider::{ProviderCommand, ProviderType},
GlobalOptions,
};
use galaxy_core::channel::ChannelState;
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
use serde::Serialize;
use galaxy_cli::provider::{ProviderCommand, ProviderType};
use galaxy_cli::GlobalOptions;
use galaxy_core::channel::ChannelState;
use galaxyui::platform::TerminationMode;
use galaxyui::{AppContext, ModelContext, SingletonEntity};
use crate::ai::agent_sdk::output::{self, TableFormat};
use crate::workspaces::user_workspaces::UserWorkspaces;
/// Handle provider-related CLI commands.
pub fn run(
+10 -56
View File
@@ -1,62 +1,16 @@
//! 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.
//! The canonical implementation of `with_bounded_retry` and `is_transient_http_error`
//! lives in [`crate::server::retry_strategies`] (available on all targets, including WASM).
//! This module re-exports those symbols so existing agent-SDK call sites keep compiling
//! without a path change.
use std::future::Future;
use std::time::Duration;
use anyhow::{anyhow, Result};
use galaxyui::duration_with_jitter;
use galaxyui::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})"
))
}
pub(crate) use crate::server::retry_strategies::with_bounded_retry;
// Re-export for tests only; the canonical definitions live in retry_strategies.
#[cfg(test)]
pub(crate) use crate::server::retry_strategies::{
is_transient_graphql_or_http_error, is_transient_http_error, MAX_ATTEMPTS,
};
#[cfg(test)]
#[path = "retry_tests.rs"]
+45 -1
View File
@@ -1,10 +1,12 @@
use std::cell::Cell;
use std::rc::Rc;
use anyhow::anyhow;
use anyhow::{anyhow, Result};
use futures::executor::block_on;
use http::StatusCode;
use super::*;
use crate::server::graphql::GraphQLError;
use crate::server::server_api::presigned_upload::HttpStatusError;
fn http_err(status: u16) -> anyhow::Error {
@@ -15,6 +17,14 @@ fn http_err(status: u16) -> anyhow::Error {
.into()
}
fn graphql_http_err(status: StatusCode) -> anyhow::Error {
GraphQLError::HttpError {
status,
body: format!("status {status} body"),
}
.into()
}
#[test]
fn transient_5xx_status_codes_are_retryable() {
assert!(is_transient_http_error(&http_err(503)));
@@ -45,6 +55,40 @@ fn errors_without_http_status_are_treated_as_transient() {
assert!(is_transient_http_error(&err));
}
#[test]
fn graphql_status_classifier_retries_transient_statuses() {
assert!(is_transient_graphql_or_http_error(&graphql_http_err(
StatusCode::SERVICE_UNAVAILABLE
)));
assert!(is_transient_graphql_or_http_error(&graphql_http_err(
StatusCode::REQUEST_TIMEOUT
)));
assert!(is_transient_graphql_or_http_error(&graphql_http_err(
StatusCode::TOO_MANY_REQUESTS
)));
}
#[test]
fn graphql_status_classifier_fails_fast_on_permanent_statuses() {
assert!(!is_transient_graphql_or_http_error(&graphql_http_err(
StatusCode::UNAUTHORIZED
)));
assert!(!is_transient_graphql_or_http_error(&graphql_http_err(
StatusCode::FORBIDDEN
)));
assert!(!is_transient_graphql_or_http_error(&graphql_http_err(
StatusCode::NOT_FOUND
)));
}
#[test]
fn graphql_status_classifier_fails_fast_without_typed_transport_error() {
// GraphQL user-facing errors are converted to plain anyhow errors at the operation
// layer, so GraphQL retry call sites should not treat unknown errors as transient.
let err = anyhow!("user-facing GraphQL error");
assert!(!is_transient_graphql_or_http_error(&err));
}
#[test]
fn retry_loop_succeeds_on_first_attempt() {
let attempts = Rc::new(Cell::new(0));
+8 -6
View File
@@ -1,27 +1,27 @@
use chrono::{DateTime, Utc};
use comfy_table::Cell;
use futures::future;
use serde::Serialize;
use galaxy_cli::agent::OutputFormat;
use galaxy_cli::schedule::{
CreateScheduleArgs, DeleteScheduleArgs, GetScheduleArgs, PauseScheduleArgs, ScheduleCommand,
ScheduleSubcommand, UnpauseScheduleArgs, UpdateScheduleArgs,
};
use galaxy_cli::{agent::OutputFormat, GlobalOptions};
use galaxy_cli::GlobalOptions;
use galaxy_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory;
use galaxyui::platform::TerminationMode;
use galaxyui::{AppContext, SingletonEntity};
use serde::Serialize;
use super::common::{EnvironmentChoice, ResolveConfigurationError};
use super::output::{self, TableFormat};
use crate::ai::ambient_agents::scheduled::{
CloudScheduledAmbientAgent, ScheduledAgentManager, ScheduledAmbientAgent, UpdateScheduleParams,
};
use crate::ai::ambient_agents::AgentConfigSnapshot;
use crate::cloud_object::CloudObject;
use crate::cloud_object::{CloudObject, CloudObjectLookup as _};
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,
@@ -113,6 +113,8 @@ fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()>
crate::ai::ambient_agents::AgentConfigSnapshot {
name: None,
environment_id,
// TODO(REMOTE-1936): support --runner for scheduled agents.
runner_id: None,
model_id: args.model.model.clone(),
base_prompt: None,
mcp_servers: cli_mcp_servers,
+105 -24
View File
@@ -1,36 +1,31 @@
use std::{
fs,
io::{self, IsTerminal as _, Read},
};
use std::fs;
use std::io::{self, IsTerminal as _, Read};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use comfy_table::Cell;
use galaxy_cli::{
agent::OutputFormat,
scope::ObjectScope,
secret::{
AnthropicMethod, CreateProvider, CreateSecretArgs, DeleteSecretArgs, ListSecretsArgs,
SecretCommand, SecretType, UpdateSecretArgs, ValueArgs,
},
GlobalOptions,
};
use galaxy_core::features::FeatureFlag;
use galaxy_graphql::{
managed_secrets::{ManagedSecret, ManagedSecretType},
object::SpaceType,
};
use galaxy_managed_secrets::{client::SecretOwner, ManagedSecretManager, ManagedSecretValue};
use galaxyui::{platform::TerminationMode, AppContext, SingletonEntity as _};
use inquire::{Confirm, InquireError, Password};
use serde::Serialize;
use crate::{
auth::UserUid, cloud_object::Owner, server::ids::ServerId,
util::time_format::format_approx_duration_from_now_utc,
use galaxy_cli::agent::OutputFormat;
use galaxy_cli::scope::ObjectScope;
use galaxy_cli::secret::{
AnthropicMethod, CodexMethod, CreateProvider, CreateSecretArgs, DeleteSecretArgs,
ListSecretsArgs, SecretCommand, SecretType, UpdateSecretArgs, ValueArgs,
};
use galaxy_cli::GlobalOptions;
use galaxy_core::features::FeatureFlag;
use galaxy_graphql::managed_secrets::{ManagedSecret, ManagedSecretType};
use galaxy_graphql::object::SpaceType;
use galaxy_managed_secrets::client::SecretOwner;
use galaxy_managed_secrets::{ManagedSecretManager, ManagedSecretValue};
use galaxyui::platform::TerminationMode;
use galaxyui::{AppContext, SingletonEntity as _};
use super::output::{self, TableFormat};
use crate::auth::UserUid;
use crate::cloud_object::Owner;
use crate::server::ids::ServerId;
use crate::util::time_format::format_approx_duration_from_now_utc;
#[derive(Serialize)]
struct SecretInfo {
@@ -105,6 +100,11 @@ enum SecretInput {
session_token: Option<String>,
region: Option<String>,
},
/// OpenAI API key secret with optional base URL.
OpenaiApiKey {
value_args: ValueArgs,
base_url: Option<String>,
},
}
impl SecretInput {
@@ -137,6 +137,10 @@ impl SecretInput {
session_token,
region,
),
SecretInput::OpenaiApiKey {
value_args,
base_url,
} => read_openai_api_key_secret_value(&value_args, base_url),
}
}
}
@@ -176,6 +180,17 @@ fn create_secret(ctx: &mut AppContext, args: CreateSecretArgs) -> Result<()> {
a.common.scope,
),
},
Some(CreateProvider::Codex(codex)) => match codex.method {
CodexMethod::ApiKey(a) => (
a.common.name,
SecretInput::OpenaiApiKey {
value_args: a.value,
base_url: a.base_url,
},
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>")
@@ -556,6 +571,11 @@ fn make_simple_secret_value(secret_type: SecretType, raw: &str) -> ManagedSecret
// Bedrock secrets are multi-field and handled via SecretInput::Bedrock.
unreachable!("Bedrock secrets should not go through make_simple_secret_value")
}
SecretType::OpenaiApiKey => {
// OpenAI API key secrets are handled via SecretInput::OpenaiApiKey so the optional
// base URL can be plumbed through alongside the API key.
unreachable!("OpenAI API key secrets should not go through make_simple_secret_value")
}
}
}
@@ -583,9 +603,69 @@ fn make_secret_value_from_gql_type(
"Bedrock API key secrets cannot be updated via `--value`; re-create the secret instead"
))
}
ManagedSecretType::OpenaiApiKey => Ok(ManagedSecretValue::openai_api_key(raw, None)),
}
}
/// Read an OpenAI API key secret from CLI flags or interactive prompts.
///
/// The API key value is read from `--value-file`, stdin, or an interactive password prompt (in
/// that order), matching the behavior of other simple secret types.
///
/// `base_url` is optional. When `--base-url` is provided we use it verbatim (an empty value is
/// treated as "no base URL"). When it is not provided and we are running interactively, we
/// prompt for it; pressing Enter at the prompt skips the base URL. In non-interactive mode we
/// silently default to no base URL, since the vast majority of users use the provider's default
/// endpoint.
fn read_openai_api_key_secret_value(
value_args: &ValueArgs,
base_url: Option<String>,
) -> Result<Option<ManagedSecretValue>> {
let api_key = match read_simple_secret_value(value_args)? {
Some(v) => v,
None => return Ok(None),
};
// The base URL is optional. An empty `--base-url` flag, empty interactive submission, or
// omission in non-interactive mode all map to `None` so we never persist a blank string.
let base_url: Option<String> = match base_url {
Some(s) => {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
None => {
if !io::stdin().is_terminal() {
// Non-interactive: leave the base URL unset rather than prompting or failing.
None
} else {
match inquire::Text::new("OpenAI base URL (optional, press Enter to skip):")
.with_help_message("e.g. https://us.api.openai.com/v1 for a regional endpoint")
.prompt()
{
Ok(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Ok(None);
}
Err(err) => return Err(err.into()),
}
}
}
};
Ok(Some(ManagedSecretValue::openai_api_key(api_key, base_url)))
}
/// Read a Bedrock API key secret from dedicated CLI flags or interactive prompts.
fn read_bedrock_secret_value(
bedrock_api_key: Option<String>,
@@ -772,5 +852,6 @@ fn format_secret_type(type_: &ManagedSecretType) -> String {
ManagedSecretType::AnthropicApiKey => "Anthropic API Key".to_string(),
ManagedSecretType::AnthropicBedrockAccessKey => "Anthropic Bedrock Access Key".to_string(),
ManagedSecretType::AnthropicBedrockApiKey => "Anthropic Bedrock API Key".to_string(),
ManagedSecretType::OpenaiApiKey => "OpenAI API Key".to_string(),
}
}
+305
View File
@@ -0,0 +1,305 @@
use std::future::Future;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use futures::FutureExt as _;
use tracing::Instrument as _;
use warpui::r#async::executor::Background;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::server::server_api::ai::{AIClient, AgentRunClientEventRequest};
#[derive(Clone)]
pub(crate) struct SetupClientEventReporter {
run_id: Option<AmbientAgentTaskId>,
ai_client: Arc<dyn AIClient>,
background: Arc<Background>,
}
impl SetupClientEventReporter {
/// Constructs a reporter for setup events associated with an existing Oz run.
pub(crate) fn new(
run_id: AmbientAgentTaskId,
ai_client: Arc<dyn AIClient>,
background: Arc<Background>,
) -> Self {
Self {
run_id: Some(run_id),
ai_client,
background,
}
}
/// Constructs a reporter for setup paths that are intentionally not backed by an Oz run.
pub(crate) fn noop(ai_client: Arc<dyn AIClient>, background: Arc<Background>) -> Self {
Self {
run_id: None,
ai_client,
background,
}
}
pub(crate) async fn record_result<T, E: std::error::Error>(
&self,
step: SetupStep,
future: impl Future<Output = Result<T, E>>,
) -> Result<T, E> {
let (step_name, span) = step.to_event_name_and_span();
let start_timestamp = Utc::now();
let result = future
.map(|result| {
result.inspect_err(|err| {
tracing::error!(error = %err);
})
})
.instrument(span)
.await;
let finish_timestamp = Utc::now();
self.post_setup_metric_event_best_effort(
step_name,
start_timestamp,
finish_timestamp,
result.is_err(),
);
result
}
pub(crate) async fn record_value<T>(
&self,
step: SetupStep,
future: impl Future<Output = T>,
) -> T {
let (step_name, span) = step.to_event_name_and_span();
let start_timestamp = Utc::now();
let value = future.instrument(span).await;
let finish_timestamp = Utc::now();
self.post_setup_metric_event_best_effort(
step_name,
start_timestamp,
finish_timestamp,
false,
);
value
}
pub(crate) fn record_value_detached<T>(
&self,
step: SetupStep,
future: impl Future<Output = T> + Send + 'static,
) where
T: Send + 'static,
{
let (step_name, span) = step.to_event_name_and_span();
let reporter = self.clone();
self.background
.spawn(async move {
let start_timestamp = Utc::now();
future.instrument(span).await;
let finish_timestamp = Utc::now();
reporter.post_setup_metric_event_best_effort(
step_name,
start_timestamp,
finish_timestamp,
false,
);
})
.detach();
}
pub(crate) async fn post_timeline_event(&self, event: OzRunTimelineEvent) {
let Some(run_id) = self.run_id else {
return;
};
let timestamp = Utc::now();
let event_name = event.as_event_name();
let request = AgentRunClientEventRequest::timeline_event(event_name, timestamp);
Self::post_client_event(run_id, self.ai_client.clone(), event_name, request).await;
}
fn post_setup_metric_event_best_effort(
&self,
event_name: &'static str,
start_timestamp: DateTime<Utc>,
finish_timestamp: DateTime<Utc>,
is_error: bool,
) {
let Some(run_id) = self.run_id else {
return;
};
let ai_client = self.ai_client.clone();
self.background
.spawn(async move {
let request = AgentRunClientEventRequest::setup_metric_event(
event_name,
start_timestamp,
finish_timestamp,
is_error,
);
Self::post_client_event(run_id, ai_client, event_name, request).await;
})
.detach();
}
async fn post_client_event(
run_id: AmbientAgentTaskId,
ai_client: Arc<dyn AIClient>,
event_name: &'static str,
request: AgentRunClientEventRequest,
) {
tracing::info!(event_name, tags.cloud_agent = true);
if let Err(err) = ai_client
.post_agent_run_client_event(&run_id, request)
.await
{
log::warn!("Failed to post setup client event {event_name} for run {run_id}: {err:#}");
}
}
}
#[derive(Clone, Copy)]
pub(crate) enum OzRunTimelineEvent {
AgentStarted,
WorkerContainerReady,
}
impl OzRunTimelineEvent {
fn as_event_name(self) -> &'static str {
match self {
Self::AgentStarted => "agent_started",
Self::WorkerContainerReady => "worker_container_ready",
}
}
}
#[derive(Clone, Copy)]
pub(crate) enum SetupStep {
TeamMetadataRefresh,
WarpDriveSync,
TaskDataFetch,
EnvironmentResolution,
SkillRepoClone,
TerminalBootstrap,
CloudProviderSetup,
McpServerStartup,
AgentProfileConfiguration,
ProfileMcpServerStartup,
SharedSessionEstablishment,
GlobalSkillResolution,
GlobalSkillRepoClone,
EnvironmentRepoClone,
EnvironmentSetupCommands,
EnvironmentCodebaseIndexing,
FileBasedMcpDiscovery,
FileBasedMcpReadiness,
EnvironmentSkillLoading,
GlobalSkillLoading,
ConversationResumeLoading,
ThirdPartyHarnessPreparation,
ThirdPartyHarnessExternalConversation,
/// Sub-steps of [`SetupStep::ThirdPartyHarnessPreparation`] that track plugin
/// install/update latency and reliability individually.
ThirdPartyHarnessPreparationNotificationPluginInstall,
ThirdPartyHarnessPreparationNotificationPluginUpdate,
ThirdPartyHarnessPreparationPlatformPluginInstall,
ThirdPartyHarnessPreparationPlatformPluginUpdate,
}
macro_rules! span_and_name {
($name:literal) => {
($name, tracing::info_span!($name, tags.cloud_agent = true))
};
}
impl SetupStep {
fn to_event_name_and_span(self) -> (&'static str, tracing::Span) {
match self {
Self::TeamMetadataRefresh => {
span_and_name!("setup_team_metadata_refresh")
}
Self::WarpDriveSync => {
span_and_name!("setup_warp_drive_sync")
}
Self::TaskDataFetch => {
span_and_name!("setup_task_metadata_secrets_attachments_git_credentials_fetch")
}
Self::EnvironmentResolution => {
span_and_name!("setup_environment_resolution")
}
Self::SkillRepoClone => {
span_and_name!("setup_skill_repo_clone")
}
Self::TerminalBootstrap => {
span_and_name!("setup_terminal_bootstrap")
}
Self::CloudProviderSetup => {
span_and_name!("setup_cloud_provider_setup")
}
Self::McpServerStartup => {
span_and_name!("setup_mcp_server_startup")
}
Self::AgentProfileConfiguration => {
span_and_name!("setup_agent_profile_configuration")
}
Self::ProfileMcpServerStartup => {
span_and_name!("setup_profile_mcp_server_startup")
}
Self::SharedSessionEstablishment => {
span_and_name!("setup_shared_session_establishment")
}
Self::GlobalSkillResolution => {
span_and_name!("setup_global_skill_resolution")
}
Self::GlobalSkillRepoClone => {
span_and_name!("setup_global_skill_repo_clone")
}
Self::EnvironmentRepoClone => {
span_and_name!("setup_environment_repo_clone")
}
Self::EnvironmentSetupCommands => {
span_and_name!("setup_environment_setup_commands")
}
Self::EnvironmentCodebaseIndexing => {
span_and_name!("setup_environment_codebase_indexing")
}
Self::FileBasedMcpDiscovery => {
span_and_name!("setup_file_based_mcp_discovery")
}
Self::FileBasedMcpReadiness => {
span_and_name!("setup_file_based_mcp_readiness")
}
Self::EnvironmentSkillLoading => {
span_and_name!("setup_environment_skill_loading")
}
Self::GlobalSkillLoading => {
span_and_name!("setup_global_skill_loading")
}
Self::ConversationResumeLoading => {
span_and_name!("setup_conversation_resume_loading")
}
Self::ThirdPartyHarnessPreparation => {
span_and_name!("setup_third_party_harness_preparation")
}
Self::ThirdPartyHarnessExternalConversation => {
span_and_name!("setup_third_party_harness_external_conversation")
}
Self::ThirdPartyHarnessPreparationNotificationPluginInstall => {
span_and_name!("setup_third_party_harness_preparation_notification_plugin_install")
}
Self::ThirdPartyHarnessPreparationNotificationPluginUpdate => {
span_and_name!("setup_third_party_harness_preparation_notification_plugin_update")
}
Self::ThirdPartyHarnessPreparationPlatformPluginInstall => {
span_and_name!("setup_third_party_harness_preparation_platform_plugin_install")
}
Self::ThirdPartyHarnessPreparationPlatformPluginUpdate => {
span_and_name!("setup_third_party_harness_preparation_platform_plugin_update")
}
}
}
}
+134 -3
View File
@@ -1,8 +1,8 @@
use crate::features::FeatureFlag;
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use crate::features::FeatureFlag;
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub(super) enum CliTelemetryEvent {
@@ -22,6 +22,16 @@ pub(super) enum CliTelemetryEvent {
AgentProfileList,
/// Executing `warp agent list`
AgentList,
/// Executing `warp agent get`
AgentGet,
/// Executing `warp agent create`
AgentCreate,
/// Executing `warp agent update`
AgentUpdate,
/// Executing `warp agent delete`
AgentDelete,
/// Executing `warp agent skills`
AgentSkills,
/// Executing `warp environment list`
EnvironmentList,
/// Executing `warp environment create`
@@ -38,6 +48,24 @@ pub(super) enum CliTelemetryEvent {
MCPList,
/// Executing `warp model list`
ModelList,
/// Executing `warp memory-store list`
MemoryStoreList,
/// Executing `warp memory list`
MemoryStoreListMemories,
/// Executing `warp memory create`
MemoryStoreCreateMemory,
/// Executing `warp memory update`
MemoryStoreUpdateMemory,
/// Executing `warp memory delete`
MemoryStoreDeleteMemory,
/// Executing `warp memory-store get`
MemoryStoreGetStore,
/// Executing `warp memory-store update`
MemoryStoreUpdateStore,
/// Executing `warp memory-store list-store-agents`
MemoryStoreListStoreAgents,
/// Executing `warp memory versions`
MemoryStoreListVersions,
/// Executing `warp task list`
TaskList,
/// Executing `warp task get`
@@ -78,6 +106,12 @@ pub(super) enum CliTelemetryEvent {
ArtifactGet,
/// Executing `warp artifact download`
ArtifactDownload,
/// Executing `warp api-key list`
ApiKeyList,
/// Executing `warp api-key create`
ApiKeyCreate,
/// Executing `warp api-key expire`
ApiKeyExpire,
/// Executing `warp schedule create`
ScheduleCreate,
/// Executing `warp schedule list`
@@ -112,6 +146,8 @@ pub(super) enum CliTelemetryEvent {
HarnessSupportNotifyUser,
/// Executing `warp harness-support finish-task`
HarnessSupportFinishTask { success: bool },
/// Executing `warp harness-support report-shutdown`
HarnessSupportReportShutdown,
}
impl TelemetryEvent for CliTelemetryEvent {
@@ -137,6 +173,11 @@ impl TelemetryEvent for CliTelemetryEvent {
CliTelemetryEvent::AgentRunAmbient => None,
CliTelemetryEvent::AgentProfileList => None,
CliTelemetryEvent::AgentList => None,
CliTelemetryEvent::AgentGet => None,
CliTelemetryEvent::AgentCreate => None,
CliTelemetryEvent::AgentUpdate => None,
CliTelemetryEvent::AgentDelete => None,
CliTelemetryEvent::AgentSkills => None,
CliTelemetryEvent::EnvironmentList => None,
CliTelemetryEvent::EnvironmentCreate => None,
CliTelemetryEvent::EnvironmentDelete => None,
@@ -145,6 +186,15 @@ impl TelemetryEvent for CliTelemetryEvent {
CliTelemetryEvent::EnvironmentImageList => None,
CliTelemetryEvent::MCPList => None,
CliTelemetryEvent::ModelList => None,
CliTelemetryEvent::MemoryStoreList => None,
CliTelemetryEvent::MemoryStoreListMemories => None,
CliTelemetryEvent::MemoryStoreCreateMemory => None,
CliTelemetryEvent::MemoryStoreUpdateMemory => None,
CliTelemetryEvent::MemoryStoreDeleteMemory => None,
CliTelemetryEvent::MemoryStoreGetStore => None,
CliTelemetryEvent::MemoryStoreUpdateStore => None,
CliTelemetryEvent::MemoryStoreListStoreAgents => None,
CliTelemetryEvent::MemoryStoreListVersions => None,
CliTelemetryEvent::TaskList => None,
CliTelemetryEvent::TaskGet => None,
CliTelemetryEvent::ConversationGet => None,
@@ -167,6 +217,9 @@ impl TelemetryEvent for CliTelemetryEvent {
CliTelemetryEvent::ArtifactUpload => None,
CliTelemetryEvent::ArtifactGet => None,
CliTelemetryEvent::ArtifactDownload => None,
CliTelemetryEvent::ApiKeyList => None,
CliTelemetryEvent::ApiKeyCreate => None,
CliTelemetryEvent::ApiKeyExpire => None,
CliTelemetryEvent::ScheduleCreate => None,
CliTelemetryEvent::ScheduleList => None,
CliTelemetryEvent::ScheduleGet => None,
@@ -188,6 +241,7 @@ impl TelemetryEvent for CliTelemetryEvent {
CliTelemetryEvent::HarnessSupportFinishTask { success } => {
Some(json!({ "success": success }))
}
CliTelemetryEvent::HarnessSupportReportShutdown => None,
}
}
@@ -215,6 +269,11 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
CliTelemetryEventDiscriminants::AgentRunAmbient => "CLI.Execute.Agent.RunAmbient",
CliTelemetryEventDiscriminants::AgentProfileList => "CLI.Execute.Agent.Profile.List",
CliTelemetryEventDiscriminants::AgentList => "CLI.Execute.Agent.List",
CliTelemetryEventDiscriminants::AgentGet => "CLI.Execute.Agent.Get",
CliTelemetryEventDiscriminants::AgentCreate => "CLI.Execute.Agent.Create",
CliTelemetryEventDiscriminants::AgentUpdate => "CLI.Execute.Agent.Update",
CliTelemetryEventDiscriminants::AgentDelete => "CLI.Execute.Agent.Delete",
CliTelemetryEventDiscriminants::AgentSkills => "CLI.Execute.Agent.Skills",
CliTelemetryEventDiscriminants::EnvironmentList => "CLI.Execute.Environment.List",
CliTelemetryEventDiscriminants::EnvironmentCreate => "CLI.Execute.Environment.Create",
CliTelemetryEventDiscriminants::EnvironmentDelete => "CLI.Execute.Environment.Delete",
@@ -225,6 +284,31 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
}
CliTelemetryEventDiscriminants::MCPList => "CLI.Execute.MCP.List",
CliTelemetryEventDiscriminants::ModelList => "CLI.Execute.Model.List",
CliTelemetryEventDiscriminants::MemoryStoreList => "CLI.Execute.MemoryStore.List",
CliTelemetryEventDiscriminants::MemoryStoreListMemories => {
"CLI.Execute.MemoryStore.ListMemories"
}
CliTelemetryEventDiscriminants::MemoryStoreCreateMemory => {
"CLI.Execute.MemoryStore.CreateMemory"
}
CliTelemetryEventDiscriminants::MemoryStoreUpdateMemory => {
"CLI.Execute.MemoryStore.UpdateMemory"
}
CliTelemetryEventDiscriminants::MemoryStoreDeleteMemory => {
"CLI.Execute.MemoryStore.DeleteMemory"
}
CliTelemetryEventDiscriminants::MemoryStoreGetStore => {
"CLI.Execute.MemoryStore.GetStore"
}
CliTelemetryEventDiscriminants::MemoryStoreUpdateStore => {
"CLI.Execute.MemoryStore.UpdateStore"
}
CliTelemetryEventDiscriminants::MemoryStoreListStoreAgents => {
"CLI.Execute.MemoryStore.ListStoreAgents"
}
CliTelemetryEventDiscriminants::MemoryStoreListVersions => {
"CLI.Execute.MemoryStore.ListVersions"
}
CliTelemetryEventDiscriminants::TaskList => "CLI.Execute.Task.List",
CliTelemetryEventDiscriminants::TaskGet => "CLI.Execute.Task.Get",
CliTelemetryEventDiscriminants::ConversationGet => "CLI.Execute.Conversation.Get",
@@ -249,6 +333,9 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
CliTelemetryEventDiscriminants::ArtifactUpload => "CLI.Execute.Artifact.Upload",
CliTelemetryEventDiscriminants::ArtifactGet => "CLI.Execute.Artifact.Get",
CliTelemetryEventDiscriminants::ArtifactDownload => "CLI.Execute.Artifact.Download",
CliTelemetryEventDiscriminants::ApiKeyList => "CLI.Execute.ApiKey.List",
CliTelemetryEventDiscriminants::ApiKeyCreate => "CLI.Execute.ApiKey.Create",
CliTelemetryEventDiscriminants::ApiKeyExpire => "CLI.Execute.ApiKey.Expire",
CliTelemetryEventDiscriminants::ScheduleCreate => "CLI.Execute.Schedule.Create",
CliTelemetryEventDiscriminants::ScheduleList => "CLI.Execute.Schedule.List",
CliTelemetryEventDiscriminants::ScheduleGet => "CLI.Execute.Schedule.Get",
@@ -274,6 +361,9 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
CliTelemetryEventDiscriminants::HarnessSupportFinishTask => {
"CLI.Execute.HarnessSupport.FinishTask"
}
CliTelemetryEventDiscriminants::HarnessSupportReportShutdown => {
"CLI.Execute.HarnessSupport.ReportShutdown"
}
}
}
@@ -287,6 +377,11 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
"Listed agent profiles from the Warp CLI"
}
CliTelemetryEventDiscriminants::AgentList => "Listed agents from the Warp CLI",
CliTelemetryEventDiscriminants::AgentGet => "Got agent details from the Warp CLI",
CliTelemetryEventDiscriminants::AgentCreate => "Created an agent from the Warp CLI",
CliTelemetryEventDiscriminants::AgentUpdate => "Updated an agent from the Warp CLI",
CliTelemetryEventDiscriminants::AgentDelete => "Deleted an agent from the Warp CLI",
CliTelemetryEventDiscriminants::AgentSkills => "Listed agent skills from the Warp CLI",
CliTelemetryEventDiscriminants::EnvironmentList => {
"Listed cloud environments from the Warp CLI"
}
@@ -307,6 +402,33 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
}
CliTelemetryEventDiscriminants::MCPList => "Listed MCP servers from the Warp CLI",
CliTelemetryEventDiscriminants::ModelList => "Listed models from the Warp CLI",
CliTelemetryEventDiscriminants::MemoryStoreList => {
"Listed memory stores from the Warp CLI"
}
CliTelemetryEventDiscriminants::MemoryStoreListMemories => {
"Listed memories in a memory store from the Warp CLI"
}
CliTelemetryEventDiscriminants::MemoryStoreCreateMemory => {
"Created a manual memory in a memory store from the Warp CLI"
}
CliTelemetryEventDiscriminants::MemoryStoreUpdateMemory => {
"Updated a memory in a memory store from the Warp CLI"
}
CliTelemetryEventDiscriminants::MemoryStoreDeleteMemory => {
"Deleted a memory from a memory store from the Warp CLI"
}
CliTelemetryEventDiscriminants::MemoryStoreGetStore => {
"Got a memory store from the Warp CLI"
}
CliTelemetryEventDiscriminants::MemoryStoreUpdateStore => {
"Updated a memory store from the Warp CLI"
}
CliTelemetryEventDiscriminants::MemoryStoreListStoreAgents => {
"Listed agents attached to a memory store from the Warp CLI"
}
CliTelemetryEventDiscriminants::MemoryStoreListVersions => {
"Listed version history of a memory from the Warp CLI"
}
CliTelemetryEventDiscriminants::TaskList => "Listed tasks from the Warp CLI",
CliTelemetryEventDiscriminants::TaskGet => "Got status of task from the Warp CLI",
CliTelemetryEventDiscriminants::ConversationGet => {
@@ -353,6 +475,9 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
CliTelemetryEventDiscriminants::ArtifactDownload => {
"Downloaded an artifact from the Warp CLI"
}
CliTelemetryEventDiscriminants::ApiKeyList => "Listed API keys from the Warp CLI",
CliTelemetryEventDiscriminants::ApiKeyCreate => "Created an API key from the Warp CLI",
CliTelemetryEventDiscriminants::ApiKeyExpire => "Expired an API key from the Warp CLI",
CliTelemetryEventDiscriminants::ScheduleCreate => {
"Created a scheduled agent from the Warp CLI"
}
@@ -396,6 +521,9 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
CliTelemetryEventDiscriminants::HarnessSupportFinishTask => {
"Reported task completion via harness-support from the Warp CLI"
}
CliTelemetryEventDiscriminants::HarnessSupportReportShutdown => {
"Reported agent shutdown via harness-support from the Warp CLI"
}
}
}
@@ -411,11 +539,14 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
Self::ArtifactUpload | Self::ArtifactGet | Self::ArtifactDownload => {
EnablementState::Flag(FeatureFlag::ArtifactCommand)
}
Self::ApiKeyList | Self::ApiKeyCreate | Self::ApiKeyExpire => {
EnablementState::Flag(FeatureFlag::APIKeyManagement)
}
Self::RunMessageWatch
| Self::RunMessageSend
| Self::RunMessageList
| Self::RunMessageRead
| Self::RunMessageMarkDelivered => EnablementState::Flag(FeatureFlag::OrchestrationV2),
| Self::RunMessageMarkDelivered => EnablementState::Always,
_ => EnablementState::Always,
}
}
+1 -3
View File
@@ -8,9 +8,7 @@
/// 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)
.tls_certs_only([])
.no_proxy()
.pool_max_idle_per_host(0);
http_client::Client::from_client_builder(builder)