diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce351..256946fd26 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -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)| { @@ -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" diff --git a/desktop/src-tauri/src/commands/agent_models_discovery_config.rs b/desktop/src-tauri/src/commands/agent_models_discovery_config.rs index e43f09495b..3d9686647b 100644 --- a/desktop/src-tauri/src/commands/agent_models_discovery_config.rs +++ b/desktop/src-tauri/src/commands/agent_models_discovery_config.rs @@ -60,8 +60,10 @@ pub(super) fn agent_model_discovery_config( personas: &[crate::managed_agents::AgentDefinition], global: &crate::managed_agents::GlobalAgentConfig, ) -> Result { - 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 = diff --git a/desktop/src-tauri/src/commands/global_agent_config.rs b/desktop/src-tauri/src/commands/global_agent_config.rs index 91219bafb9..5089d92d6f 100644 --- a/desktop/src-tauri/src/commands/global_agent_config.rs +++ b/desktop/src-tauri/src/commands/global_agent_config.rs @@ -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 @@ -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. diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521..d5d5728d23 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -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"); diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 162f447981..54596ebb43 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -53,6 +53,26 @@ pub struct GlobalAgentConfig { #[serde(default)] pub env_vars: BTreeMap, + /// 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>, + /// Global fallback provider (e.g. `"databricks_v2"`, `"anthropic"`). /// /// Used only when neither the agent record nor the linked persona specifies @@ -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 = 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 { @@ -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> { + 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. diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e226..6e3f2595d2 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -264,6 +264,10 @@ fn default_config_is_all_none_empty() { fn roundtrip_serialization() { let config = GlobalAgentConfig { env_vars: BTreeMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())]), + community_env_vars: BTreeMap::from([( + "wss://work.example.com".to_string(), + BTreeMap::from([("MEMORY_API_KEY".to_string(), "work-key".to_string())]), + )]), provider: Some("anthropic".to_string()), model: Some("claude-opus-4".to_string()), preferred_runtime: Some("claude".to_string()), @@ -495,6 +499,7 @@ fn inherited_shared_compute_translates_to_supported_agent_transport() { &personas, Some(runtime), &global, + None, ); assert_eq!( @@ -589,6 +594,14 @@ fn populated_global_config_round_trips() { env_vars: [("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())] .into_iter() .collect(), + community_env_vars: [( + "wss://work.example.com".to_string(), + [("MEMORY_API_KEY".to_string(), "work-key".to_string())] + .into_iter() + .collect(), + )] + .into_iter() + .collect(), provider: Some("anthropic".to_string()), model: Some("claude-opus-4-5".to_string()), preferred_runtime: None, @@ -649,3 +662,205 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { "record runtime must override persona runtime in command resolution" ); } + +// ── community_env_vars ──────────────────────────────────────────────────────── + +fn config_with_community_env(relay: &str, pairs: &[(&str, &str)]) -> GlobalAgentConfig { + let mut community_env_vars: BTreeMap> = BTreeMap::new(); + community_env_vars.insert( + relay.to_string(), + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ); + GlobalAgentConfig { + community_env_vars, + ..Default::default() + } +} + +#[test] +fn validate_accepts_valid_community_env_vars() { + let config = config_with_community_env( + "wss://work.example.com", + &[ + ("MEMORY_API_KEY", "work-key"), + ("CLAUDE_CONFIG_DIR", "/home/u/.claude-work"), + ], + ); + assert!(validate_global_config(&config).is_ok()); +} + +#[test] +fn validate_rejects_reserved_key_in_community_env() { + let config = + config_with_community_env("wss://work.example.com", &[("BUZZ_PRIVATE_KEY", "nope")]); + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("wss://work.example.com"), + "error must name the community: {err}" + ); +} + +#[test] +fn validate_rejects_derived_key_in_community_env() { + let config = + config_with_community_env("wss://work.example.com", &[("GOOSE_PROVIDER", "openai")]); + let err = validate_global_config(&config).unwrap_err(); + assert!( + err.contains("structured provider/model"), + "derived keys must be rejected per community: {err}" + ); +} + +#[test] +fn validate_rejects_blank_community_relay_key() { + let config = config_with_community_env(" ", &[("SOME_KEY", "v")]); + let err = validate_global_config(&config).unwrap_err(); + assert!(err.contains("non-empty relay"), "{err}"); +} + +#[test] +fn strip_empty_removes_empty_community_values_and_empty_communities() { + let mut config = + config_with_community_env("wss://work.example.com", &[("KEEP", "v"), ("DROP", "")]); + config + .community_env_vars + .insert("wss://all-empty.example.com".to_string(), { + let mut m = BTreeMap::new(); + m.insert("ONLY".to_string(), String::new()); + m + }); + strip_empty_env_vars(&mut config); + let work = config + .community_env_vars + .get("wss://work.example.com") + .expect("community with a real value survives"); + assert_eq!(work.get("KEEP").map(String::as_str), Some("v")); + assert!(!work.contains_key("DROP"), "empty value must be stripped"); + assert!( + !config + .community_env_vars + .contains_key("wss://all-empty.example.com"), + "community with no surviving values must be dropped" + ); +} + +#[test] +fn community_env_for_matches_with_trailing_slash_normalization() { + let config = config_with_community_env("wss://work.example.com/", &[("K", "v")]); + assert!( + super::community_env_for(&config, "wss://work.example.com").is_some(), + "stored trailing slash must not defeat the match" + ); + assert!( + super::community_env_for(&config, "wss://work.example.com/").is_some(), + "queried trailing slash must not defeat the match" + ); + assert!( + super::community_env_for(&config, "wss://other.example.com").is_none(), + "different community must not match" + ); + assert!( + super::community_env_for(&config, "").is_none(), + "blank relay must never match" + ); +} + +/// Community layer sits between global and persona/agent: it overrides a +/// colliding global value, and a per-agent value still overrides it. +#[test] +fn community_env_layers_between_global_and_agent() { + let mut record = bare_record(); + record + .env_vars + .insert("AGENT_WINS".to_string(), "agent-value".to_string()); + + let mut config = config_with_community_env( + "wss://work.example.com", + &[ + ("COMMUNITY_ONLY", "community-value"), + ("GLOBAL_COLLIDES", "community-value"), + ("AGENT_WINS", "community-value"), + ], + ); + config + .env_vars + .insert("GLOBAL_COLLIDES".to_string(), "global-value".to_string()); + config + .env_vars + .insert("GLOBAL_ONLY".to_string(), "global-value".to_string()); + + let effective = super::super::readiness::resolve_effective_agent_env( + &record, + &[], + None, + &config, + Some("wss://work.example.com"), + ); + assert_eq!( + effective.env.get("COMMUNITY_ONLY").map(String::as_str), + Some("community-value") + ); + assert_eq!( + effective.env.get("GLOBAL_COLLIDES").map(String::as_str), + Some("community-value"), + "community must override global on collision" + ); + assert_eq!( + effective.env.get("GLOBAL_ONLY").map(String::as_str), + Some("global-value") + ); + assert_eq!( + effective.env.get("AGENT_WINS").map(String::as_str), + Some("agent-value"), + "per-agent env must override community on collision" + ); + + // A different relay gets none of the community layer. + let other = super::super::readiness::resolve_effective_agent_env( + &record, + &[], + None, + &config, + Some("wss://other.example.com"), + ); + assert!(!other.env.contains_key("COMMUNITY_ONLY")); + assert_eq!( + other.env.get("GLOBAL_COLLIDES").map(String::as_str), + Some("global-value"), + "without a community match the global value must stand" + ); + + // No relay context: community layer absent entirely. + let no_relay = + super::super::readiness::resolve_effective_agent_env(&record, &[], None, &config, None); + assert!(!no_relay.env.contains_key("COMMUNITY_ONLY")); +} + +/// Reserved keys inside a community map must be stripped at resolve time even +/// if a config file was hand-edited past save-time validation. +#[test] +fn community_env_strips_reserved_keys_at_resolve_time() { + let record = bare_record(); + let config = config_with_community_env( + "wss://work.example.com", + &[("BUZZ_PRIVATE_KEY", "evil"), ("SAFE_KEY", "ok")], + ); + let effective = super::super::readiness::resolve_effective_agent_env( + &record, + &[], + None, + &config, + Some("wss://work.example.com"), + ); + assert!( + !effective.env.contains_key("BUZZ_PRIVATE_KEY"), + "reserved key must never pass through the community layer" + ); + assert_eq!( + effective.env.get("SAFE_KEY").map(String::as_str), + Some("ok") + ); +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1..b84b8c10fb 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -99,7 +99,7 @@ pub(crate) struct EffectiveHarnessDescriptor { /// the harness definition's args apply. pub args: Vec, /// The full layered process env: baked floor → runtime metadata → definition - /// env → global → persona → agent. + /// env → global → community (relay-scoped) → persona → agent. pub env: BTreeMap, } @@ -122,10 +122,15 @@ pub(crate) struct EffectiveHarnessDescriptor { /// * `record` — the managed agent record /// * `personas` — all current personas (for command/env resolution) /// * `global` — global agent config defaults +/// * `relay_url` — the *effective* relay this resolution is for, when the +/// caller has one (spawn, hash, per-pair status/summary). Enables the +/// community env layer; `None` resolves without it (e.g. relay-agnostic +/// model probes). pub(crate) fn resolve_effective_harness_descriptor( record: &ManagedAgentRecord, personas: &[crate::managed_agents::types::AgentDefinition], global: &crate::managed_agents::GlobalAgentConfig, + relay_url: Option<&str>, ) -> Result { let effective_command = crate::managed_agents::try_record_agent_command(record, personas)?; let runtime_meta = known_acp_runtime(&effective_command); @@ -163,8 +168,14 @@ pub(crate) fn resolve_effective_harness_descriptor( // Env: full layered resolution (same as resolve_effective_agent_env). // Pass harness_def directly to avoid a second lookup. - let effective_env = - resolve_effective_agent_env_with_def(record, personas, runtime_meta, global, harness_def); + let effective_env = resolve_effective_agent_env_with_def( + record, + personas, + runtime_meta, + global, + harness_def, + relay_url, + ); Ok(EffectiveHarnessDescriptor { command: effective_command, @@ -183,11 +194,14 @@ pub(crate) fn resolve_effective_harness_descriptor( /// * `runtime` — the `KnownAcpRuntime` for the effective command, if any /// * `global` — global agent config defaults (lowest user layer; pass /// `&GlobalAgentConfig::default()` in tests that don't need global config) +/// * `relay_url` — the effective relay for the community env layer, when the +/// caller has one (`None` skips the community layer) pub(crate) fn resolve_effective_agent_env( record: &ManagedAgentRecord, personas: &[AgentDefinition], runtime: Option<&KnownAcpRuntime>, global: &GlobalAgentConfig, + relay_url: Option<&str>, ) -> EffectiveAgentEnv { // Look up the harness definition for definition-level env (preset/custom). // Same resolution logic as spawn_agent_child: record runtime id first, then @@ -208,7 +222,7 @@ pub(crate) fn resolve_effective_agent_env( crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) }; - resolve_effective_agent_env_with_def(record, personas, runtime, global, harness_def) + resolve_effective_agent_env_with_def(record, personas, runtime, global, harness_def, relay_url) } /// Inner implementation that accepts a pre-fetched `harness_def` to avoid a @@ -220,6 +234,7 @@ fn resolve_effective_agent_env_with_def( runtime: Option<&KnownAcpRuntime>, global: &GlobalAgentConfig, harness_def: Option>, + relay_url: Option<&str>, ) -> EffectiveAgentEnv { let effective_command = crate::managed_agents::record_agent_command(record, personas); @@ -259,6 +274,18 @@ fn resolve_effective_agent_env_with_def( let global_env = merged_user_env(&BTreeMap::new(), &global.env_vars); env.extend(global_env); + // Layer 3a': community env vars — location-scoped defaults for the + // effective relay this resolution is for. Injected after global and + // before persona/agent so identity-scoped values win on collision. + // `merged_user_env` with an empty lower map applies reserved/malformed-key + // filtering, same as the global layer above. + if let Some(relay) = relay_url { + if let Some(community_env) = super::global_config::community_env_for(global, relay) { + let community_env = merged_user_env(&BTreeMap::new(), community_env); + env.extend(community_env); + } + } + // Layer 3b: merged user env — live persona env under the record's own // overrides (last-wins), after reserved/malformed-key filtering. Reading // the persona live is what makes persona credential edits refresh on the @@ -1535,7 +1562,8 @@ mod tests { }; let runtime = known_acp_runtime_exact("buzz-agent"); - let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default()); + let effective = + resolve_effective_agent_env(&record, &[], runtime, &Default::default(), None); // User env_vars must be present in the output (last-write-wins). assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 37927961ed..2918f9f3ca 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -276,6 +276,7 @@ pub fn build_managed_agent_summary( record, personas, global_config, + pair_key.as_ref().map(|key| key.relay_url.as_str()), ) .unwrap_or_else(|e| { // Dangling harness — surface the missing id so the UI tells the same @@ -495,15 +496,19 @@ pub fn spawn_agent_child( // model probes all consume this descriptor rather than assembling values inline. // Like the orphan refusal above, this runs before any side effect so a refused // spawn leaves no trace. - let descriptor = - crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) - .map_err(|e| { - format!( - "cannot spawn agent {}: {}", - record.pubkey, - crate::managed_agents::user_facing_harness_error(&e) - ) - })?; + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + record, + &personas, + &global, + Some(&runtime_key.relay_url), + ) + .map_err(|e| { + format!( + "cannot spawn agent {}: {}", + record.pubkey, + crate::managed_agents::user_facing_harness_error(&e) + ) + })?; let effective_command = &descriptor.command; let agent_args = &descriptor.args; diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..898e26dff8 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -55,7 +55,8 @@ fn status_for_with( let StatusInputs { personas, global } = inputs; let command = record_agent_command(record, personas); let metadata = super::known_acp_runtime(&command); - let effective = resolve_effective_agent_env(record, personas, metadata, global); + let effective = + resolve_effective_agent_env(record, personas, metadata, global, Some(&key.relay_url)); let local_setup = matches!(agent_readiness(&effective), AgentReadiness::Ready); ManagedAgentRuntimeStatus { pubkey: key.pubkey.clone(), @@ -433,7 +434,8 @@ fn unkeyable_failed_status( ) -> ManagedAgentRuntimeStatus { let command = record_agent_command(record, personas); let metadata = super::known_acp_runtime(&command); - let effective = resolve_effective_agent_env(record, personas, metadata, global); + let effective = + resolve_effective_agent_env(record, personas, metadata, global, Some(&requested)); ManagedAgentRuntimeStatus { pubkey: record.pubkey.clone(), relay_url: requested.clone(), diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 648cc62bbe..80a56d9182 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -75,17 +75,25 @@ pub(crate) fn spawn_config_hash( // as spawn_agent_child. Dangling harness id falls back to the infallible // record_agent_command (no-op: a dangling harness can't be spawned, so the // hash never matters for that agent). - let descriptor = - crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global) - .unwrap_or_else(|_| { - let cmd = crate::managed_agents::record_agent_command(record, personas); - let args = normalize_agent_args(&cmd, record.agent_args.clone()); - crate::managed_agents::readiness::EffectiveHarnessDescriptor { - command: cmd, - args, - env: Default::default(), - } - }); + // Same relay resolution as the spawn path, so the hash covers the exact + // community env layer a restart would inject. + let effective_relay = + crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay); + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + record, + personas, + global, + Some(&effective_relay), + ) + .unwrap_or_else(|_| { + let cmd = crate::managed_agents::record_agent_command(record, personas); + let args = normalize_agent_args(&cmd, record.agent_args.clone()); + crate::managed_agents::readiness::EffectiveHarnessDescriptor { + command: cmd, + args, + env: Default::default(), + } + }); let runtime_meta = known_acp_runtime(&descriptor.command); let mut hasher = DefaultHasher::new(); diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af8976..0f4f34723a 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -58,6 +58,7 @@ import { getGlobalAgentCredentialState } from "./globalAgentCredentialState"; export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { env_vars: {}, + community_env_vars: {}, provider: null, model: null, preferred_runtime: null, diff --git a/desktop/src/features/agents/useGlobalAgentConfig.ts b/desktop/src/features/agents/useGlobalAgentConfig.ts index 4b90beb43d..52e671444f 100644 --- a/desktop/src/features/agents/useGlobalAgentConfig.ts +++ b/desktop/src/features/agents/useGlobalAgentConfig.ts @@ -16,6 +16,7 @@ import type { GlobalAgentConfig } from "@/shared/api/types"; const EMPTY_CONFIG: GlobalAgentConfig = { env_vars: {}, + community_env_vars: {}, provider: null, model: null, preferred_runtime: null, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 3f07e9ad9a..e88fb19576 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -1008,6 +1008,13 @@ export type ChannelMessagesPageResponse = { export type GlobalAgentConfig = { /** Global env vars injected into all agents unconditionally. */ env_vars: Record; + /** + * Community-scoped env vars keyed by relay URL — injected only into agents + * spawned for that community's relay. Precedence: global < community < + * persona < per-agent. Mirrors the Rust field; carried through saves even + * when the UI has no editor for it yet. + */ + community_env_vars: Record>; /** Global fallback provider (e.g. "anthropic", "databricks_v2"). Null = no global default. */ provider: string | null; /** Global fallback model identifier. Null = no global default. */