mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
587 lines
22 KiB
Rust
587 lines
22 KiB
Rust
use serde::Serialize;
|
|
use tauri::{AppHandle, Manager, State};
|
|
|
|
#[path = "agent_config_readiness.rs"]
|
|
mod readiness_preview;
|
|
pub use readiness_preview::{AgentReadinessDraft, AgentReadinessEvaluation};
|
|
|
|
use crate::{
|
|
app_state::AppState,
|
|
managed_agents::{
|
|
config_bridge::{
|
|
read_goose_file_config,
|
|
reader::read_config_surface,
|
|
types::{
|
|
AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, InheritedConfigTiers,
|
|
RuntimeConfigSurface, SessionConfigCache,
|
|
},
|
|
},
|
|
current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key,
|
|
known_acp_runtime, load_managed_agents, load_personas, resolve_effective_agent_env,
|
|
save_managed_agents, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig,
|
|
KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES,
|
|
},
|
|
};
|
|
|
|
/// Subset of the goose file config exposed to the frontend for gate evaluation.
|
|
///
|
|
/// Only the fields the dialog gate needs. This tracks which requirements are already satisfied in the
|
|
/// harness config file, so it can show "Set in goose config" rather than
|
|
/// surfacing a false missing-key marker.
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RuntimeFileConfigSubset {
|
|
/// Provider set in the harness config file, if any.
|
|
pub provider: Option<String>,
|
|
/// Model set in the harness config file, if any.
|
|
pub model: Option<String>,
|
|
/// Flat credential env keys in the harness config file's `extra` map (e.g. `DATABRICKS_HOST`); only non-empty values included.
|
|
pub satisfied_env_keys: Vec<String>,
|
|
}
|
|
|
|
/// Sanitize a raw env map from an inherited tier (persona or global) with the
|
|
/// same rules `merged_user_env` applies at spawn time: reserved keys, malformed
|
|
/// keys, NUL-byte values, and oversize values are stripped silently.
|
|
fn sanitize_inherited_env(
|
|
raw: &std::collections::BTreeMap<String, String>,
|
|
) -> std::collections::BTreeMap<String, String> {
|
|
raw.iter()
|
|
.filter(|(k, v)| {
|
|
!is_reserved_env_key(k)
|
|
&& is_well_formed_env_key(k)
|
|
&& !v.contains('\0')
|
|
&& v.len() <= MAX_ENV_VALUE_BYTES
|
|
})
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect()
|
|
}
|
|
|
|
/// Normalize a structured field value: blank/whitespace-only collapses to
|
|
/// `None`, matching `effective_config`'s `non_blank` helper.
|
|
fn non_blank(v: Option<&str>) -> Option<String> {
|
|
v.filter(|s| !s.trim().is_empty()).map(str::to_owned)
|
|
}
|
|
|
|
/// Build a sanitized `InheritedConfigTiers` snapshot at the command boundary.
|
|
///
|
|
/// Persona env, global env, and harness definition env are sanitized with
|
|
/// spawn-equivalent rules. Structured fields are normalized (blank → None).
|
|
/// A missing persona (orphaned link) yields empty persona tiers — the panel
|
|
/// still renders from record/global while spawn independently refuses.
|
|
fn build_inherited_tiers(
|
|
record_persona_id: Option<&str>,
|
|
record_runtime: Option<&str>,
|
|
personas: &[AgentDefinition],
|
|
global: &GlobalAgentConfig,
|
|
) -> InheritedConfigTiers {
|
|
let persona = record_persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid));
|
|
|
|
let persona_env = persona
|
|
.map(|p| sanitize_inherited_env(&p.env_vars))
|
|
.unwrap_or_default();
|
|
let global_env = sanitize_inherited_env(&global.env_vars);
|
|
|
|
// Definition env: same resolution as spawn (record.runtime → persona.runtime → "").
|
|
// Reserved keys stripped; no malformed-key / NUL / oversize check needed because
|
|
// harness definitions are local admin-authored JSON, not user-provided data — but
|
|
// we apply `sanitize_inherited_env` for defense-in-depth (same rules as the other tiers).
|
|
let definition_env = {
|
|
let runtime_id = record_runtime
|
|
.or_else(|| persona.and_then(|p| p.runtime.as_deref()))
|
|
.unwrap_or("");
|
|
crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id)
|
|
.map(|def| sanitize_inherited_env(&def.env))
|
|
.unwrap_or_default()
|
|
};
|
|
|
|
let persona_model = persona.and_then(|p| non_blank(p.model.as_deref()));
|
|
let persona_provider = persona.and_then(|p| non_blank(p.provider.as_deref()));
|
|
let persona_prompt = persona.and_then(|p| non_blank(Some(&p.system_prompt)));
|
|
let global_model = non_blank(global.model.as_deref());
|
|
let global_provider = non_blank(global.provider.as_deref());
|
|
|
|
InheritedConfigTiers {
|
|
persona_env,
|
|
global_env,
|
|
definition_env,
|
|
persona_model,
|
|
persona_provider,
|
|
persona_prompt,
|
|
global_model,
|
|
global_provider,
|
|
}
|
|
}
|
|
|
|
/// Resolve the config surface with inherited persona and global tiers applied.
|
|
///
|
|
/// Persona-linked instances have their system_prompt/model/provider cleared
|
|
/// first (definition-authoritative): stale materialized snapshots can never
|
|
/// shadow live persona values. The reader then resolves each field through its
|
|
/// full candidate list (record env > ACP > persona env > global env > structured
|
|
/// persona/global > config file) via `resolve_with_override`.
|
|
fn resolve_config_surface(
|
|
mut record: ManagedAgentRecord,
|
|
personas: &[AgentDefinition],
|
|
runtime_meta: Option<&KnownAcpRuntime>,
|
|
session_cache: Option<&SessionConfigCache>,
|
|
global: &GlobalAgentConfig,
|
|
claude_config_dir: Option<&std::path::Path>,
|
|
) -> RuntimeConfigSurface {
|
|
// Linked instances are definition-authoritative: clear stale materialized
|
|
// model/provider/prompt so they can never masquerade as BuzzExplicit and
|
|
// shadow definition values. Env var overrides are untouched.
|
|
if record.persona_id.is_some() {
|
|
record.system_prompt = None;
|
|
record.model = None;
|
|
record.provider = None;
|
|
}
|
|
|
|
let tiers = build_inherited_tiers(
|
|
record.persona_id.as_deref(),
|
|
record.runtime.as_deref(),
|
|
personas,
|
|
global,
|
|
);
|
|
|
|
read_config_surface(
|
|
&record,
|
|
runtime_meta,
|
|
session_cache,
|
|
&tiers,
|
|
claude_config_dir,
|
|
)
|
|
}
|
|
|
|
/// Get the file-layer config for a runtime — used by the Create/Edit/Persona
|
|
/// dialogs to know which requirements are already satisfied in the harness
|
|
/// config file (e.g. `~/.config/goose/config.yaml`), so they can show
|
|
/// "Set in goose config" instead of surfacing a false required-field marker.
|
|
///
|
|
/// Returns `null` when the runtime has no config file or it cannot be parsed.
|
|
/// Currently only "goose" is supported; other runtimes return `null`.
|
|
#[tauri::command]
|
|
pub async fn get_runtime_file_config(
|
|
runtime_id: String,
|
|
) -> Result<Option<RuntimeFileConfigSubset>, String> {
|
|
tokio::task::spawn_blocking(move || match runtime_id.as_str() {
|
|
"goose" => {
|
|
let cfg = read_goose_file_config()?;
|
|
let satisfied_env_keys = cfg
|
|
.extra
|
|
.into_iter()
|
|
.filter(|(_, v)| !v.is_empty())
|
|
.map(|(k, _)| k)
|
|
.collect();
|
|
Some(RuntimeFileConfigSubset {
|
|
provider: cfg.provider,
|
|
model: cfg.model,
|
|
satisfied_env_keys,
|
|
})
|
|
}
|
|
_ => None,
|
|
})
|
|
.await
|
|
.map_err(|e| format!("spawn_blocking failed: {e}"))
|
|
}
|
|
|
|
/// Return the key names of all non-empty baked build env vars.
|
|
///
|
|
/// Internal (Block) builds bake provider credentials and other env pairs into
|
|
/// the binary at compile time via `BUZZ_BUILD_AGENT_ENV`. The backend readiness
|
|
/// gate already treats these keys as satisfying their requirements (Layer 1 of
|
|
/// `resolve_effective_agent_env`). This command exposes the *key names only* —
|
|
/// never the values — so the frontend dialogs can apply the same logic and avoid
|
|
/// surfacing a spurious "Required" badge for keys that are covered by the baked
|
|
/// env.
|
|
///
|
|
/// OSS builds have no baked env, so this returns an empty list — OSS behavior
|
|
/// is unchanged.
|
|
#[tauri::command]
|
|
pub fn get_baked_build_env_keys() -> Vec<String> {
|
|
crate::managed_agents::baked_build_env()
|
|
.into_iter()
|
|
.filter(|(_, v)| !v.is_empty())
|
|
.map(|(k, _)| k)
|
|
.collect()
|
|
}
|
|
|
|
/// A single baked build env entry returned to the frontend.
|
|
///
|
|
/// Values are masked in Rust so unmasked secret values never cross the
|
|
/// Tauri IPC boundary. The `masked` flag lets the frontend style masked
|
|
/// rows distinctly.
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct BakedEnvEntry {
|
|
pub key: String,
|
|
/// The display value — real value for non-secret keys, `••••••` for
|
|
/// secret keys whose names match the secret heuristic.
|
|
pub value: String,
|
|
/// `true` when the value was replaced by the mask placeholder.
|
|
pub masked: bool,
|
|
}
|
|
|
|
/// Expose the baked build env to the frontend with values shown, but any
|
|
/// key not in the safe-to-reveal allowlist has its value replaced by `••••••`.
|
|
///
|
|
/// Provider and model arrive as `BUZZ_AGENT_PROVIDER` / `BUZZ_AGENT_MODEL`
|
|
/// keys in `baked_build_env()` and are included in the returned list like any
|
|
/// other key. Empty-value keys are filtered out (same as
|
|
/// `get_baked_build_env_keys`).
|
|
///
|
|
/// OSS builds return an empty list — the baked-env section is hidden entirely
|
|
/// in OSS installations.
|
|
#[tauri::command]
|
|
pub fn get_baked_build_env() -> Vec<BakedEnvEntry> {
|
|
crate::managed_agents::baked_build_env()
|
|
.into_iter()
|
|
.filter(|(_, v)| !v.is_empty())
|
|
.map(|(key, value)| {
|
|
let masked = !is_safe_to_reveal(&key);
|
|
let display_value = if masked {
|
|
"\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}".to_string()
|
|
} else {
|
|
value
|
|
};
|
|
BakedEnvEntry {
|
|
key,
|
|
value: display_value,
|
|
masked,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Evaluate a proposed managed-agent configuration without persisting it.
|
|
///
|
|
/// The request explicitly distinguishes a complete new-agent proposal from a
|
|
/// patch to one exact saved agent. Projection reuses the production create,
|
|
/// update, effective-config, harness, and readiness owners.
|
|
#[tauri::command]
|
|
pub async fn evaluate_agent_readiness_draft(
|
|
draft: AgentReadinessDraft,
|
|
app: AppHandle,
|
|
_state: State<'_, AppState>,
|
|
) -> Result<AgentReadinessEvaluation, String> {
|
|
tokio::task::spawn_blocking(move || {
|
|
// Snapshot persisted inputs while holding the store lock; readiness may
|
|
// probe an external CLI and therefore runs only after this scope exits.
|
|
let (records, definitions, global) = {
|
|
let state = app.state::<AppState>();
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
(
|
|
load_managed_agents(&app)?,
|
|
load_personas(&app)?,
|
|
crate::managed_agents::load_global_agent_config(&app)?,
|
|
)
|
|
};
|
|
readiness_preview::evaluate_draft(draft, &records, &definitions, &global)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
|
}
|
|
|
|
/// Get the full config surface for a managed agent.
|
|
///
|
|
/// Returns normalized + advanced config from all available tiers.
|
|
/// Pre-spawn agents show config file values with ACP tiers marked as pending.
|
|
/// Persona-sourced values are resolved by `resolve_config_surface`.
|
|
#[tauri::command]
|
|
pub async fn get_agent_config_surface(
|
|
pubkey: String,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<RuntimeConfigSurface, String> {
|
|
let record = {
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
let mut records = load_managed_agents(&app)?;
|
|
let mut runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|e| e.to_string())?;
|
|
let (sync_changed, exited_pubkeys) =
|
|
sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app));
|
|
if sync_changed {
|
|
save_managed_agents(&app, &records)?;
|
|
}
|
|
for pubkey in &exited_pubkeys {
|
|
state.clear_agent_session_caches(pubkey);
|
|
}
|
|
records
|
|
.into_iter()
|
|
.find(|r| r.pubkey == pubkey)
|
|
.ok_or_else(|| format!("agent {pubkey} not found"))?
|
|
};
|
|
|
|
let personas = load_personas(&app).unwrap_or_default();
|
|
let effective_cmd = crate::managed_agents::record_agent_command(&record, &personas);
|
|
let runtime_meta = known_acp_runtime(&effective_cmd);
|
|
let runtime_key = ManagedAgentRuntimeKey::new(
|
|
pubkey.clone(),
|
|
&crate::relay::effective_agent_relay_url(
|
|
&record.relay_url,
|
|
&crate::relay::relay_ws_url_with_override(&state),
|
|
),
|
|
)?;
|
|
let session_cache = state.get_session_cache(&runtime_key);
|
|
let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default();
|
|
|
|
// #3493: for claude agents, resolve the settings.json and .claude.json paths
|
|
// from the agent's effective CLAUDE_CONFIG_DIR env var (if set), falling
|
|
// back to ~/.claude/ and ~/.claude.json. We never provision this dir
|
|
// ourselves — we only respect what the user configured.
|
|
//
|
|
// Use resolve_effective_agent_env so the lookup covers all tiers (baked
|
|
// floor → definition → global → persona → record) and cannot diverge from
|
|
// what the spawned process actually sees.
|
|
let claude_config_dir: Option<std::path::PathBuf> = if runtime_meta
|
|
.is_some_and(|m| m.id == "claude")
|
|
{
|
|
let effective_env = resolve_effective_agent_env(&record, &personas, runtime_meta, &global);
|
|
// Treat empty or blank CLAUDE_CONFIG_DIR as unset, matching Claude's
|
|
// `CLAUDE_CONFIG_DIR || homedir()` resolver semantics.
|
|
effective_env
|
|
.env
|
|
.get("CLAUDE_CONFIG_DIR")
|
|
.filter(|v| !v.trim().is_empty())
|
|
.map(std::path::PathBuf::from)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(resolve_config_surface(
|
|
record,
|
|
&personas,
|
|
runtime_meta,
|
|
session_cache.as_ref(),
|
|
&global,
|
|
claude_config_dir.as_deref(),
|
|
))
|
|
}
|
|
|
|
/// Store a `session_config_captured` observer event payload into the session cache.
|
|
///
|
|
/// Called by the TypeScript observer relay when it decrypts a `session_config_captured`
|
|
/// event from a running agent. The payload contains raw ACP session/new fields.
|
|
#[tauri::command]
|
|
pub fn put_agent_session_config(
|
|
pubkey: String,
|
|
payload: serde_json::Value,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) {
|
|
let record_relay_url = {
|
|
let _guard = match state.managed_agents_store_lock.lock() {
|
|
Ok(g) => g,
|
|
Err(_) => return,
|
|
};
|
|
match load_managed_agents(&app) {
|
|
Ok(records) => match records.into_iter().find(|r| r.pubkey == pubkey) {
|
|
Some(record) => record.relay_url,
|
|
None => return,
|
|
},
|
|
_ => return,
|
|
}
|
|
};
|
|
|
|
// Pair identity: prefer the relay URL the harness attached to the payload
|
|
// (same pattern as lifecycle frames). Older harnesses don't attach one;
|
|
// fall back to the record's effective relay — with no attached URL the
|
|
// frame can only have arrived over the active workspace relay, which is
|
|
// exactly what effective_agent_relay_url resolves to absent a pin.
|
|
let relay_url = payload
|
|
.get("relayUrl")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| {
|
|
crate::relay::effective_agent_relay_url(
|
|
&record_relay_url,
|
|
&crate::relay::relay_ws_url_with_override(&state),
|
|
)
|
|
});
|
|
|
|
let config_options = parse_config_options(payload.get("configOptions"));
|
|
let available_modes = parse_modes(&config_options, payload.get("modes"));
|
|
let (available_models, current_model) = parse_models(payload.get("models"));
|
|
let model_overridden = payload
|
|
.get("modelOverridden")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
let cache = SessionConfigCache {
|
|
config_options,
|
|
available_modes,
|
|
available_models,
|
|
current_model,
|
|
model_overridden,
|
|
goose_native_config: None,
|
|
captured_at: crate::util::now_iso(),
|
|
};
|
|
|
|
let Ok(runtime_key) = ManagedAgentRuntimeKey::new(pubkey, &relay_url) else {
|
|
return;
|
|
};
|
|
state.put_session_cache(runtime_key, cache);
|
|
}
|
|
|
|
fn parse_config_options(raw: Option<&serde_json::Value>) -> Vec<AcpConfigOptionEntry> {
|
|
let arr = match raw.and_then(|v| v.as_array()) {
|
|
Some(a) => a,
|
|
None => return Vec::new(),
|
|
};
|
|
arr.iter()
|
|
.filter_map(|opt| {
|
|
let config_id = opt
|
|
.get("id")
|
|
.or_else(|| opt.get("configId"))?
|
|
.as_str()?
|
|
.to_string();
|
|
Some(AcpConfigOptionEntry {
|
|
config_id,
|
|
category: opt
|
|
.get("category")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string),
|
|
display_name: opt
|
|
.get("displayName")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string),
|
|
current_value: opt
|
|
.get("value")
|
|
.or_else(|| opt.get("currentValue"))
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string),
|
|
options: parse_option_values(opt.get("options")),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn parse_option_values(raw: Option<&serde_json::Value>) -> Vec<AcpConfigOptionValue> {
|
|
let arr = match raw.and_then(|v| v.as_array()) {
|
|
Some(a) => a,
|
|
None => return Vec::new(),
|
|
};
|
|
arr.iter()
|
|
.filter_map(|o| {
|
|
let value = o.get("value").and_then(|v| v.as_str())?.to_string();
|
|
Some(AcpConfigOptionValue {
|
|
value,
|
|
display_name: o
|
|
.get("displayName")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn parse_modes(
|
|
config_options: &[AcpConfigOptionEntry],
|
|
raw: Option<&serde_json::Value>,
|
|
) -> Vec<String> {
|
|
if let Some(arr) = raw.and_then(|v| v.as_array()) {
|
|
return arr
|
|
.iter()
|
|
.filter_map(|m| m.as_str().map(str::to_string))
|
|
.collect();
|
|
}
|
|
// Fall back: extract mode options from configOptions with category "mode".
|
|
config_options
|
|
.iter()
|
|
.filter(|o| o.category.as_deref() == Some("mode"))
|
|
.flat_map(|o| o.options.iter().map(|v| v.value.clone()))
|
|
.collect()
|
|
}
|
|
|
|
fn parse_models(raw: Option<&serde_json::Value>) -> (Vec<AcpModelEntry>, Option<String>) {
|
|
let raw = match raw {
|
|
Some(v) => v,
|
|
None => return (Vec::new(), None),
|
|
};
|
|
|
|
// Object shape: { currentModelId, availableModels: [...] }
|
|
if let Some(obj) = raw.as_object() {
|
|
let current_model = obj
|
|
.get("currentModelId")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string);
|
|
let models = obj
|
|
.get("availableModels")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|m| {
|
|
let model_id = m
|
|
.get("modelId")
|
|
.or_else(|| m.get("id"))
|
|
.and_then(|v| v.as_str())?
|
|
.to_string();
|
|
Some(AcpModelEntry {
|
|
model_id,
|
|
name: m.get("name").and_then(|v| v.as_str()).map(str::to_string),
|
|
description: m
|
|
.get("description")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string),
|
|
})
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
return (models, current_model);
|
|
}
|
|
|
|
// Array shape: [{ modelId, isCurrent, ... }]
|
|
let arr = match raw.as_array() {
|
|
Some(a) => a,
|
|
None => return (Vec::new(), None),
|
|
};
|
|
let mut current_model = None;
|
|
let models = arr
|
|
.iter()
|
|
.filter_map(|m| {
|
|
let model_id = m
|
|
.get("modelId")
|
|
.or_else(|| m.get("id"))
|
|
.and_then(|v| v.as_str())?
|
|
.to_string();
|
|
if m.get("isCurrent")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false)
|
|
{
|
|
current_model = Some(model_id.clone());
|
|
}
|
|
Some(AcpModelEntry {
|
|
model_id,
|
|
name: m.get("name").and_then(|v| v.as_str()).map(str::to_string),
|
|
description: m
|
|
.get("description")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string),
|
|
})
|
|
})
|
|
.collect();
|
|
(models, current_model)
|
|
}
|
|
|
|
/// Atomically set the record's canonical effort column and strip every stale
|
|
/// record-scope effort env alias. Split from the Tauri command so the invariant
|
|
/// — no leftover alias can outrank the just-set column — is directly testable.
|
|
pub(crate) fn apply_picker_effort_level(
|
|
record: &mut ManagedAgentRecord,
|
|
effort_level: Option<String>,
|
|
) {
|
|
record.effort_level = effort_level;
|
|
crate::managed_agents::remove_record_effort_aliases(&mut record.env_vars);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "agent_config_tests.rs"]
|
|
mod tests;
|