Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,11 +473,14 @@ async fn restart_setup_mode_agents_after_install(
.find(|(key, _)| key.pubkey == record.pubkey)
.map(|(_, p)| p.setup_mode)
.unwrap_or(false);
// Readiness probe with no single relay context — community
// env is relay-scoped, so resolve without it (base layers only).
let effective = resolve_effective_agent_env(
record,
&personas,
known_acp_runtime(&effective_cmd),
&global,
None,
);
let now_ready = matches!(agent_readiness(&effective), AgentReadiness::Ready);
let pid_alive = runtimes.iter().any(|(key, runtime)| {
Expand Down Expand Up @@ -607,7 +610,7 @@ async fn restart_single_agent_after_install(
}

let runtime_meta = known_acp_runtime(&effective_cmd);
let effective = resolve_effective_agent_env(record, &personas, runtime_meta, &global);
let effective = resolve_effective_agent_env(record, &personas, runtime_meta, &global, None);
if !matches!(agent_readiness(&effective), AgentReadiness::Ready) {
return Err(format!(
"agent {pubkey_owned} readiness is still NotReady after install — not bouncing"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ pub(super) fn agent_model_discovery_config(
personas: &[crate::managed_agents::AgentDefinition],
global: &crate::managed_agents::GlobalAgentConfig,
) -> Result<AgentModelDiscoveryConfig, String> {
let descriptor =
crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global)?;
// Model discovery is relay-agnostic — resolve without the community layer.
let descriptor = crate::managed_agents::resolve_effective_harness_descriptor(
record, personas, global, None,
)?;
let (model, provider) =
crate::managed_agents::resolve_effective_model_provider(record, personas, global);
let provider_env_var =
Expand Down
52 changes: 39 additions & 13 deletions desktop/src-tauri/src/commands/global_agent_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,19 +191,32 @@ fn collect_restart_candidates(
if record.backend != BackendKind::Local {
return false;
}
let has_live_runtime = runtimes.iter_mut().any(|(key, runtime)| {
key.pubkey.eq_ignore_ascii_case(&record.pubkey)
&& runtime.child.try_wait().ok().flatten().is_none()
// Capture the live pair's relay so the old/new env diff includes
// the community layer the running process was spawned with.
let live_relay = runtimes.iter_mut().find_map(|(key, runtime)| {
(key.pubkey.eq_ignore_ascii_case(&record.pubkey)
&& runtime.child.try_wait().ok().flatten().is_none())
.then(|| key.relay_url.clone())
});
if !has_live_runtime {
let Some(live_relay) = live_relay else {
return false;
}
};
let effective_cmd = record_agent_command(record, &all_personas);
let runtime_meta = known_acp_runtime(&effective_cmd);
let old_effective =
resolve_effective_agent_env(record, &all_personas, runtime_meta, old_global);
let new_effective =
resolve_effective_agent_env(record, &all_personas, runtime_meta, new_global);
let old_effective = resolve_effective_agent_env(
record,
&all_personas,
runtime_meta,
old_global,
Some(&live_relay),
);
let new_effective = resolve_effective_agent_env(
record,
&all_personas,
runtime_meta,
new_global,
Some(&live_relay),
);
let old_ready = matches!(agent_readiness(&old_effective), AgentReadiness::Ready);
let new_ready = matches!(agent_readiness(&new_effective), AgentReadiness::Ready);
// For a Ready+running agent: the process must be alive now and the
Expand Down Expand Up @@ -308,10 +321,23 @@ async fn restart_local_agent_on_config_change(
// per agent when the save-command personas haven't changed.
let effective_cmd = record_agent_command(record, &personas_owned);
let runtime_meta = known_acp_runtime(&effective_cmd);
let old_effective =
resolve_effective_agent_env(record, &personas_owned, runtime_meta, &old_global_clone);
let new_effective =
resolve_effective_agent_env(record, &personas_owned, runtime_meta, &new_global_clone);
// Use the first live pair's relay for the community layer — the same
// relay the pre-filter diffed against.
let pair_relay = runtime_keys.first().map(|key| key.relay_url.as_str());
let old_effective = resolve_effective_agent_env(
record,
&personas_owned,
runtime_meta,
&old_global_clone,
pair_relay,
);
let new_effective = resolve_effective_agent_env(
record,
&personas_owned,
runtime_meta,
&new_global_clone,
pair_relay,
);
let old_ready = matches!(agent_readiness(&old_effective), AgentReadiness::Ready);
let new_ready = matches!(agent_readiness(&new_effective), AgentReadiness::Ready);
// Under lock, the alive check was already done above via process_is_running.
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1630,13 +1630,13 @@ fn deleted_harness_summary_display_and_spawn_sentence_agree() {
let record = record_with(Some("doomed"), None, None);
let global = GlobalAgentConfig::default();
assert!(
resolve_effective_harness_descriptor(&record, &[], &global).is_ok(),
resolve_effective_harness_descriptor(&record, &[], &global, None).is_ok(),
"must resolve before delete"
);

// Delete it — the shared resolver used by BOTH spawn and summary errors.
delete_and_warm(dir.path(), "doomed").unwrap();
let err = resolve_effective_harness_descriptor(&record, &[], &global).unwrap_err();
let err = resolve_effective_harness_descriptor(&record, &[], &global, None).unwrap_err();

// Summary half: renders the missing id, never the default command.
let id = super::dangling_harness_id(&err).expect("resolver must emit the typed sentinel");
Expand Down
83 changes: 83 additions & 0 deletions desktop/src-tauri/src/managed_agents/global_config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ pub struct GlobalAgentConfig {
#[serde(default)]
pub env_vars: BTreeMap<String, String>,

/// Community-scoped env vars, keyed by relay URL.
///
/// Under agents-everywhere (#2122) any agent record may spawn into any
/// community, so per-record `env_vars` cannot safely carry
/// community-specific credentials — they follow the agent into every
/// community it spawns on. This map is the location-scoped complement:
/// entries under a relay key are injected only into agents spawned for
/// that community's relay.
///
/// Precedence: `global env_vars < community < persona < per-agent`
/// (a community entry is a more specific global default; identity-scoped
/// layers still win on collision).
///
/// Relay keys are matched against the *effective* spawn relay after
/// trimming whitespace and trailing `/` on both sides. Reserved and
/// derived keys are rejected at save time and stripped at spawn time,
/// same as every other env tier.
#[serde(default)]
pub community_env_vars: BTreeMap<String, BTreeMap<String, String>>,

/// Global fallback provider (e.g. `"databricks_v2"`, `"anthropic"`).
///
/// Used only when neither the agent record nor the linked persona specifies
Expand Down Expand Up @@ -118,6 +138,35 @@ pub fn validate_global_config(config: &GlobalAgentConfig) -> Result<(), String>
));
}

// Community env maps: same key rules as the global map, per community.
for (relay, community_env) in &config.community_env_vars {
if relay.trim().is_empty() {
return Err("community env vars must be keyed by a non-empty relay URL".to_string());
}
let non_empty: BTreeMap<String, String> = community_env
.iter()
.filter(|(_, v)| !v.is_empty())
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
validate_user_env_keys(&non_empty).map_err(|e| format!("community `{relay}`: {e}"))?;
let derived: Vec<&str> = non_empty
.keys()
.filter(|k| {
DERIVED_PROVIDER_MODEL_ENV_KEYS
.iter()
.any(|d| d.eq_ignore_ascii_case(k.as_str()))
})
.map(String::as_str)
.collect();
if !derived.is_empty() {
return Err(format!(
"community `{relay}`: the following keys must be set via the structured \
provider/model fields, not as env vars: {}",
derived.join(", ")
));
}
}

// Validate the structured provider and model fields.
for (field, value) in [("provider", &config.provider), ("model", &config.model)] {
if let Some(v) = value {
Expand Down Expand Up @@ -150,6 +199,40 @@ pub fn validate_global_config(config: &GlobalAgentConfig) -> Result<(), String>
/// caller that clears a row cannot accidentally shadow global.
pub fn strip_empty_env_vars(config: &mut GlobalAgentConfig) {
config.env_vars.retain(|_, v| !v.is_empty());
for community_env in config.community_env_vars.values_mut() {
community_env.retain(|_, v| !v.is_empty());
}
config
.community_env_vars
.retain(|relay, community_env| !relay.trim().is_empty() && !community_env.is_empty());
}

/// Normalize a relay URL for community-env matching: trim whitespace and
/// trailing `/`. Applied to both the stored key and the effective spawn relay
/// so `wss://relay.example.com` and `wss://relay.example.com/` match.
fn normalize_community_relay(relay: &str) -> &str {
relay.trim().trim_end_matches('/')
}

/// Look up the community env map for an effective spawn relay, if any.
///
/// `None` when the config has no entry for the relay (or `relay_url` is
/// blank). Matching is exact after [`normalize_community_relay`] on both
/// sides — no host/scheme heuristics, so a key can never accidentally match
/// a different community.
pub fn community_env_for<'a>(
config: &'a GlobalAgentConfig,
relay_url: &str,
) -> Option<&'a BTreeMap<String, String>> {
let target = normalize_community_relay(relay_url);
if target.is_empty() {
return None;
}
config
.community_env_vars
.iter()
.find(|(key, _)| normalize_community_relay(key) == target)
.map(|(_, env)| env)
}

/// Normalize `provider` and `model` to `None` when blank or whitespace-only.
Expand Down
Loading