mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): lock internal agents to owner access
Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
This commit is contained in:
parent
03f645ef7e
commit
f0173c0b43
@@ -80,7 +80,9 @@ const overrides = new Map([
|
||||
// ratcheting 1443 -> 1295. Queued to split further in the A2 fold.
|
||||
// global-agent-config: resolve_deploy_model_provider + visibility exports
|
||||
// add ~40 lines on top of the 1A.1 ratchet. Queued to split.
|
||||
["src-tauri/src/commands/agents.rs", 1340],
|
||||
// internal-owner-only: create-time policy normalization adds a small
|
||||
// security boundary while keeping OSS respond-to behavior unchanged.
|
||||
["src-tauri/src/commands/agents.rs", 1349],
|
||||
// agent-lifecycle-fixes: cascade-delete in delete_persona restructured into
|
||||
// 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for
|
||||
// retry-safety. Load-bearing reviewer-required change; queued to split.
|
||||
@@ -118,7 +120,8 @@ const overrides = new Map([
|
||||
// receipts (write_agent_runtime_receipt atomic JSON + remove/read_all
|
||||
// helpers) replace the pubkey-keyed PID file, plus the hashed pair-scoped
|
||||
// runtime log path. Load-bearing crash-recovery surface; queued to split.
|
||||
["src-tauri/src/managed_agents/storage.rs", 1383],
|
||||
// internal-owner-only: persistence choke point normalizes local agent access.
|
||||
["src-tauri/src/managed_agents/storage.rs", 1386],
|
||||
// harness-persona-sync: persona-runtime resolution threaded into the spawn
|
||||
// path here. Load-bearing feature growth; queued to split in the resolver
|
||||
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
|
||||
@@ -131,7 +134,9 @@ const overrides = new Map([
|
||||
// record_provider param + applies persona_field_with_record_fallback. +5 lines.
|
||||
// global-agent-config: spawn_agent_child loads global config and merges as
|
||||
// lowest env layer (+8 lines). Queued to split.
|
||||
["src-tauri/src/managed_agents/runtime.rs", 2216],
|
||||
// internal-owner-only: runtime authorization normalization protects stale
|
||||
// or hand-edited records before spawning an internal managed agent.
|
||||
["src-tauri/src/managed_agents/runtime.rs", 2228],
|
||||
// config-bridge setup-payload env-boundary fix adds readiness wiring in
|
||||
// spawn_agent_child; load-bearing security fix, queued to split.
|
||||
["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016],
|
||||
@@ -541,7 +546,9 @@ const overrides = new Map([
|
||||
// isRuntimeAutoSeededRef tracking for edit-mode seeding (Fizz shows models);
|
||||
// runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix);
|
||||
// hideProviderIds computation for Databricks v1 gate. Queued to split.
|
||||
["src/features/agents/ui/AgentDefinitionDialog.tsx", 1035],
|
||||
// internal-owner-only: queries the backend policy so the internal build can
|
||||
// hide definition-level access controls while OSS keeps them configurable.
|
||||
["src/features/agents/ui/AgentDefinitionDialog.tsx", 1043],
|
||||
]);
|
||||
|
||||
await runFileSizeCheck({
|
||||
|
||||
@@ -15,8 +15,15 @@ fn main() {
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_INTERNAL");
|
||||
println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)");
|
||||
|
||||
// Explicit distribution identity. Internal packaging sets this presence-only
|
||||
// marker; OSS/custom builds remain public regardless of baked defaults.
|
||||
if std::env::var("BUZZ_BUILD_INTERNAL").is_ok() {
|
||||
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_INTERNAL=1");
|
||||
}
|
||||
|
||||
if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") {
|
||||
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}");
|
||||
}
|
||||
|
||||
@@ -767,6 +767,12 @@ async fn discover_databricks_models(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Return whether this build enforces owner-only managed-agent access.
|
||||
#[tauri::command]
|
||||
pub fn agent_access_owner_only() -> bool {
|
||||
crate::managed_agents::internal_build()
|
||||
}
|
||||
|
||||
/// Update mutable fields on an existing managed agent record.
|
||||
///
|
||||
/// Does NOT auto-restart the agent. Runtime config changes (system prompt,
|
||||
@@ -874,28 +880,11 @@ pub async fn update_managed_agent(
|
||||
record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref });
|
||||
}
|
||||
|
||||
// Inbound author gate: merge patch onto current values, then validate
|
||||
// the merged state. This lets a single update switch to Allowlist AND
|
||||
// supply pubkeys atomically.
|
||||
let prospective_mode = input.respond_to.unwrap_or(record.respond_to);
|
||||
let prospective_allowlist = match input.respond_to_allowlist.as_ref() {
|
||||
Some(list) => crate::managed_agents::validate_respond_to_allowlist(list)?,
|
||||
None => record.respond_to_allowlist.clone(),
|
||||
};
|
||||
if prospective_mode == crate::managed_agents::RespondTo::Allowlist
|
||||
&& prospective_allowlist.is_empty()
|
||||
{
|
||||
return Err(
|
||||
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
record.respond_to = prospective_mode;
|
||||
// Preserve the persisted allowlist across mode toggles — only replace
|
||||
// when the caller explicitly supplied a new list.
|
||||
if input.respond_to_allowlist.is_some() {
|
||||
record.respond_to_allowlist = prospective_allowlist;
|
||||
}
|
||||
crate::managed_agents::apply_update_access(
|
||||
record,
|
||||
input.respond_to,
|
||||
input.respond_to_allowlist.as_deref(),
|
||||
)?;
|
||||
|
||||
record.updated_at = now_iso();
|
||||
|
||||
|
||||
@@ -578,22 +578,12 @@ pub async fn create_managed_agent(
|
||||
}
|
||||
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
|
||||
|
||||
// Validate & normalize the respond-to allowlist BEFORE any side effects.
|
||||
// The harness has its own validator (buzz-acp/src/config.rs) but we want
|
||||
// to catch malformed input at the boundary so the agent never tries to
|
||||
// start with a list that will crash it on launch. The mode/allowlist
|
||||
// pairing (and the definition-default fallback) is resolved later at the
|
||||
// mint site via `resolve_mint_behavioral_defaults`, where the linked
|
||||
// definition is in hand.
|
||||
let respond_to_allowlist =
|
||||
crate::managed_agents::validate_respond_to_allowlist(&input.respond_to_allowlist)?;
|
||||
if input.respond_to == Some(crate::managed_agents::RespondTo::Allowlist)
|
||||
&& respond_to_allowlist.is_empty()
|
||||
{
|
||||
return Err(
|
||||
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(),
|
||||
);
|
||||
}
|
||||
let (requested_respond_to, respond_to_allowlist) =
|
||||
crate::managed_agents::resolve_create_access(
|
||||
&input.backend,
|
||||
input.respond_to,
|
||||
&input.respond_to_allowlist,
|
||||
)?;
|
||||
|
||||
// Snapshot the workspace owner pubkey for the legacy-record auth_tag
|
||||
// fallback. Computed outside the records lock to keep lock ordering simple.
|
||||
@@ -817,7 +807,7 @@ pub async fn create_managed_agent(
|
||||
// point for definition behavioral strings — fails loudly on a bad
|
||||
// mode/range instead of minting an agent the author didn't describe.
|
||||
let minted = crate::managed_agents::resolve_mint_behavioral_defaults(
|
||||
input.respond_to,
|
||||
requested_respond_to,
|
||||
respond_to_allowlist.clone(),
|
||||
input.parallelism,
|
||||
linked_persona.as_ref(),
|
||||
|
||||
@@ -98,6 +98,7 @@ pub async fn create_persona(
|
||||
updated_at: now,
|
||||
};
|
||||
apply_persona_behavior(&mut persona, input.behavior)?;
|
||||
crate::managed_agents::normalize_definition_access(&mut persona);
|
||||
personas.push(persona.clone());
|
||||
save_personas(&app, &personas)?;
|
||||
retain_persona_pending(&app, &state, &persona);
|
||||
@@ -197,6 +198,7 @@ pub async fn update_persona(
|
||||
persona.env_vars = env_vars;
|
||||
}
|
||||
apply_persona_behavior(persona, input.behavior)?;
|
||||
crate::managed_agents::normalize_definition_access(persona);
|
||||
persona.updated_at = now_iso();
|
||||
|
||||
let result = persona.clone();
|
||||
@@ -815,9 +817,14 @@ fn apply_inbound_persona(personas: &mut Vec<AgentDefinition>, inbound: AgentDefi
|
||||
local.respond_to = inbound.respond_to;
|
||||
local.respond_to_allowlist = inbound.respond_to_allowlist;
|
||||
local.parallelism = inbound.parallelism;
|
||||
crate::managed_agents::normalize_definition_access(local);
|
||||
local.updated_at = inbound.updated_at;
|
||||
}
|
||||
None => personas.push(inbound),
|
||||
None => {
|
||||
let mut inbound = inbound;
|
||||
crate::managed_agents::normalize_definition_access(&mut inbound);
|
||||
personas.push(inbound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,6 +866,7 @@ fn apply_inbound_managed_agent(
|
||||
local.parallelism = inbound.parallelism;
|
||||
local.respond_to = inbound.respond_to;
|
||||
local.respond_to_allowlist = inbound.respond_to_allowlist;
|
||||
crate::managed_agents::normalize_managed_agent_access(local);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ pub async fn confirm_agent_snapshot_import(
|
||||
let persona_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// Build persona from snapshot definition.
|
||||
let persona = AgentDefinition {
|
||||
let mut persona = AgentDefinition {
|
||||
id: persona_id.clone(),
|
||||
display_name: display_name.clone(),
|
||||
avatar_url: effective_avatar.clone(),
|
||||
@@ -417,6 +417,7 @@ pub async fn confirm_agent_snapshot_import(
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
};
|
||||
crate::managed_agents::normalize_definition_access(&mut persona);
|
||||
|
||||
personas.push(persona.clone());
|
||||
save_personas(&app, &personas)?;
|
||||
@@ -426,7 +427,7 @@ pub async fn confirm_agent_snapshot_import(
|
||||
|
||||
// Build the managed agent record — no machine-local commands, no
|
||||
// secrets, no lineage from the snapshot.
|
||||
let record = ManagedAgentRecord {
|
||||
let mut record = ManagedAgentRecord {
|
||||
pubkey: pubkey.clone(),
|
||||
name: display_name.clone(),
|
||||
display_name: None,
|
||||
@@ -485,6 +486,7 @@ pub async fn confirm_agent_snapshot_import(
|
||||
runtime: snapshot.definition.runtime.clone(),
|
||||
name_pool: snapshot.definition.name_pool.clone(),
|
||||
};
|
||||
crate::managed_agents::normalize_managed_agent_access(&mut record);
|
||||
|
||||
records.push(record.clone());
|
||||
save_managed_agents(&app, &records)?;
|
||||
|
||||
@@ -118,7 +118,7 @@ fn definition_from_snapshot(
|
||||
let respond_to = (behavior.respond_to != crate::managed_agents::RespondTo::default())
|
||||
.then(|| behavior.respond_to.as_str().to_string());
|
||||
|
||||
Ok(AgentDefinition {
|
||||
let mut definition = AgentDefinition {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
display_name: member.profile.display_name.trim().to_string(),
|
||||
avatar_url: effective_avatar(member),
|
||||
@@ -137,7 +137,9 @@ fn definition_from_snapshot(
|
||||
parallelism: behavior.parallelism,
|
||||
created_at: now.to_string(),
|
||||
updated_at: now.to_string(),
|
||||
})
|
||||
};
|
||||
crate::managed_agents::normalize_definition_access(&mut definition);
|
||||
Ok(definition)
|
||||
}
|
||||
|
||||
pub(crate) fn build_import_definitions(
|
||||
@@ -547,7 +549,7 @@ pub async fn confirm_team_snapshot_import(
|
||||
};
|
||||
|
||||
// Build the ManagedAgentRecord for this member.
|
||||
let record = ManagedAgentRecord {
|
||||
let mut record = ManagedAgentRecord {
|
||||
pubkey: pubkey.clone(),
|
||||
name: display_name.clone(),
|
||||
display_name: None,
|
||||
@@ -608,6 +610,7 @@ pub async fn confirm_team_snapshot_import(
|
||||
runtime: member.definition.runtime.clone(),
|
||||
name_pool: member.definition.name_pool.clone(),
|
||||
};
|
||||
crate::managed_agents::normalize_managed_agent_access(&mut record);
|
||||
|
||||
minted.push(MintedMember {
|
||||
definition,
|
||||
|
||||
@@ -787,6 +787,7 @@ pub fn run() {
|
||||
get_managed_agent_log,
|
||||
get_agent_models,
|
||||
discover_agent_models,
|
||||
agent_access_owner_only,
|
||||
get_agent_config_surface,
|
||||
get_runtime_file_config,
|
||||
get_baked_build_env_keys,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Distribution policy for managed-agent inbound author access.
|
||||
|
||||
use super::{
|
||||
validate_respond_to_allowlist, AgentDefinition, BackendKind, ManagedAgentRecord, RespondTo,
|
||||
};
|
||||
|
||||
/// Internal packaging sets `BUZZ_BUILD_INTERNAL`; OSS/custom builds do not.
|
||||
pub(crate) fn internal_build() -> bool {
|
||||
option_env!("BUZZ_DESKTOP_BUILD_INTERNAL").is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn owner_only_for_backend(backend: &BackendKind) -> bool {
|
||||
owner_only_for_backend_with_policy(backend, internal_build())
|
||||
}
|
||||
|
||||
pub(crate) fn owner_only_for_backend_with_policy(backend: &BackendKind, internal: bool) -> bool {
|
||||
internal && *backend == BackendKind::Local
|
||||
}
|
||||
|
||||
/// Normalize a persisted/projected instance. Provider instances remain
|
||||
/// configurable because the internal policy applies only to local execution.
|
||||
pub(crate) fn normalize_managed_agent_access(record: &mut ManagedAgentRecord) -> bool {
|
||||
normalize_managed_agent_access_with_policy(record, internal_build())
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_managed_agent_access_with_policy(
|
||||
record: &mut ManagedAgentRecord,
|
||||
internal: bool,
|
||||
) -> bool {
|
||||
if !owner_only_for_backend_with_policy(&record.backend, internal) {
|
||||
return false;
|
||||
}
|
||||
let changed =
|
||||
record.respond_to != RespondTo::OwnerOnly || !record.respond_to_allowlist.is_empty();
|
||||
record.respond_to = RespondTo::OwnerOnly;
|
||||
record.respond_to_allowlist.clear();
|
||||
changed
|
||||
}
|
||||
|
||||
/// Definitions are backend-neutral defaults. Internal builds store owner-only
|
||||
/// defaults so every later local mint starts safe; provider instance policy is
|
||||
/// still decided from its own backend at create/update/deploy time.
|
||||
pub(crate) fn normalize_definition_access(record: &mut AgentDefinition) -> bool {
|
||||
normalize_definition_access_with_policy(record, internal_build())
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_definition_access_with_policy(
|
||||
record: &mut AgentDefinition,
|
||||
internal: bool,
|
||||
) -> bool {
|
||||
if !internal {
|
||||
return false;
|
||||
}
|
||||
let changed = record.respond_to.is_some() || !record.respond_to_allowlist.is_empty();
|
||||
record.respond_to = None;
|
||||
record.respond_to_allowlist.clear();
|
||||
changed
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_create_access(
|
||||
backend: &BackendKind,
|
||||
requested_mode: Option<RespondTo>,
|
||||
requested_allowlist: &[String],
|
||||
) -> Result<(Option<RespondTo>, Vec<String>), String> {
|
||||
resolve_create_access_with_policy(
|
||||
backend,
|
||||
requested_mode,
|
||||
requested_allowlist,
|
||||
internal_build(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_create_access_with_policy(
|
||||
backend: &BackendKind,
|
||||
requested_mode: Option<RespondTo>,
|
||||
requested_allowlist: &[String],
|
||||
internal: bool,
|
||||
) -> Result<(Option<RespondTo>, Vec<String>), String> {
|
||||
if owner_only_for_backend_with_policy(backend, internal) {
|
||||
return Ok((Some(RespondTo::OwnerOnly), Vec::new()));
|
||||
}
|
||||
let allowlist = validate_respond_to_allowlist(requested_allowlist)?;
|
||||
if requested_mode == Some(RespondTo::Allowlist) && allowlist.is_empty() {
|
||||
return Err(
|
||||
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist".into(),
|
||||
);
|
||||
}
|
||||
Ok((requested_mode, allowlist))
|
||||
}
|
||||
|
||||
pub(crate) fn apply_update_access(
|
||||
record: &mut ManagedAgentRecord,
|
||||
requested_mode: Option<RespondTo>,
|
||||
requested_allowlist: Option<&[String]>,
|
||||
) -> Result<(), String> {
|
||||
apply_update_access_with_policy(
|
||||
record,
|
||||
requested_mode,
|
||||
requested_allowlist,
|
||||
internal_build(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_update_access_with_policy(
|
||||
record: &mut ManagedAgentRecord,
|
||||
requested_mode: Option<RespondTo>,
|
||||
requested_allowlist: Option<&[String]>,
|
||||
internal: bool,
|
||||
) -> Result<(), String> {
|
||||
if owner_only_for_backend_with_policy(&record.backend, internal) {
|
||||
record.respond_to = RespondTo::OwnerOnly;
|
||||
record.respond_to_allowlist.clear();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mode = requested_mode.unwrap_or(record.respond_to);
|
||||
let allowlist = match requested_allowlist {
|
||||
Some(list) => validate_respond_to_allowlist(list)?,
|
||||
None => record.respond_to_allowlist.clone(),
|
||||
};
|
||||
if mode == RespondTo::Allowlist && allowlist.is_empty() {
|
||||
return Err(
|
||||
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist".into(),
|
||||
);
|
||||
}
|
||||
record.respond_to = mode;
|
||||
if requested_allowlist.is_some() {
|
||||
record.respond_to_allowlist = allowlist;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn record(backend: BackendKind) -> ManagedAgentRecord {
|
||||
let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({
|
||||
"pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "",
|
||||
"agent_command": "", "agent_args": [], "mcp_command": "",
|
||||
"turn_timeout_seconds": 0, "system_prompt": null, "created_at": "",
|
||||
"updated_at": "", "last_started_at": null, "last_stopped_at": null,
|
||||
"last_exit_code": null, "last_error": null
|
||||
}))
|
||||
.unwrap();
|
||||
record.backend = backend;
|
||||
record.respond_to = RespondTo::Anyone;
|
||||
record.respond_to_allowlist = vec!["stale".into()];
|
||||
record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_create_clamps_local_but_not_provider() {
|
||||
let local = resolve_create_access_with_policy(
|
||||
&BackendKind::Local,
|
||||
Some(RespondTo::Anyone),
|
||||
&["bad".into()],
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(local, (Some(RespondTo::OwnerOnly), Vec::new()));
|
||||
|
||||
let provider = BackendKind::Provider {
|
||||
id: "p".into(),
|
||||
config: serde_json::json!({}),
|
||||
};
|
||||
let result =
|
||||
resolve_create_access_with_policy(&provider, Some(RespondTo::Anyone), &[], true)
|
||||
.unwrap();
|
||||
assert_eq!(result, (Some(RespondTo::Anyone), Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_update_clamps_local_but_not_provider() {
|
||||
let mut local = record(BackendKind::Local);
|
||||
apply_update_access_with_policy(&mut local, None, None, true).unwrap();
|
||||
assert_eq!(local.respond_to, RespondTo::OwnerOnly);
|
||||
assert!(local.respond_to_allowlist.is_empty());
|
||||
|
||||
let mut provider = record(BackendKind::Provider {
|
||||
id: "p".into(),
|
||||
config: serde_json::json!({}),
|
||||
});
|
||||
apply_update_access_with_policy(&mut provider, None, None, true).unwrap();
|
||||
assert_eq!(provider.respond_to, RespondTo::Anyone);
|
||||
assert_eq!(provider.respond_to_allowlist, vec!["stale"]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
pub(crate) mod access_policy;
|
||||
mod agent_env;
|
||||
pub(crate) mod agent_events;
|
||||
pub(crate) mod agent_snapshot;
|
||||
pub(crate) mod team_snapshot;
|
||||
pub(crate) use access_policy::{
|
||||
apply_update_access, internal_build, normalize_definition_access,
|
||||
normalize_managed_agent_access, owner_only_for_backend, resolve_create_access,
|
||||
};
|
||||
pub(crate) use agent_env::{
|
||||
baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor,
|
||||
};
|
||||
|
||||
@@ -366,6 +366,9 @@ pub(crate) fn load_personas_from_path(
|
||||
|
||||
pub fn save_personas(app: &AppHandle, records: &[AgentDefinition]) -> Result<(), String> {
|
||||
let mut sorted = records.to_vec();
|
||||
for record in &mut sorted {
|
||||
crate::managed_agents::normalize_definition_access(record);
|
||||
}
|
||||
sort_personas(&mut sorted);
|
||||
|
||||
// Post-fold: persona saves write key-less definition records into the
|
||||
|
||||
@@ -1519,8 +1519,16 @@ pub fn build_managed_agent_summary(
|
||||
start_on_app_launch: record.start_on_app_launch,
|
||||
auto_restart_on_config_change: record.auto_restart_on_config_change,
|
||||
log_path,
|
||||
respond_to: record.respond_to,
|
||||
respond_to_allowlist: record.respond_to_allowlist.clone(),
|
||||
respond_to: if super::owner_only_for_backend(&record.backend) {
|
||||
super::types::RespondTo::OwnerOnly
|
||||
} else {
|
||||
record.respond_to
|
||||
},
|
||||
respond_to_allowlist: if super::owner_only_for_backend(&record.backend) {
|
||||
Vec::new()
|
||||
} else {
|
||||
record.respond_to_allowlist.clone()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1551,9 +1559,31 @@ pub(crate) fn build_respond_to_env(
|
||||
record: &ManagedAgentRecord,
|
||||
owner_hex: Option<&str>,
|
||||
) -> Result<RespondToEnv, String> {
|
||||
build_respond_to_env_with_policy(
|
||||
record,
|
||||
owner_hex,
|
||||
super::owner_only_for_backend(&record.backend),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_respond_to_env_with_policy(
|
||||
record: &ManagedAgentRecord,
|
||||
owner_hex: Option<&str>,
|
||||
enforced_owner_only: bool,
|
||||
) -> Result<RespondToEnv, String> {
|
||||
let respond_to = if enforced_owner_only {
|
||||
super::types::RespondTo::OwnerOnly
|
||||
} else {
|
||||
record.respond_to
|
||||
};
|
||||
|
||||
// Defensive re-validation: an on-disk record could have been hand-edited.
|
||||
let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?;
|
||||
if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() {
|
||||
let normalized = if enforced_owner_only {
|
||||
Vec::new()
|
||||
} else {
|
||||
super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?
|
||||
};
|
||||
if respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() {
|
||||
return Err(
|
||||
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(),
|
||||
);
|
||||
@@ -1562,12 +1592,9 @@ pub(crate) fn build_respond_to_env(
|
||||
let mut set: Vec<(&'static str, String)> = Vec::new();
|
||||
let mut remove: Vec<&'static str> = Vec::new();
|
||||
|
||||
set.push((
|
||||
"BUZZ_ACP_RESPOND_TO",
|
||||
record.respond_to.as_str().to_string(),
|
||||
));
|
||||
set.push(("BUZZ_ACP_RESPOND_TO", respond_to.as_str().to_string()));
|
||||
|
||||
if record.respond_to == super::types::RespondTo::Allowlist {
|
||||
if respond_to == super::types::RespondTo::Allowlist {
|
||||
set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(",")));
|
||||
} else {
|
||||
remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST");
|
||||
|
||||
@@ -112,7 +112,7 @@ fn unknown_command_returns_none() {
|
||||
|
||||
// ── build_respond_to_env tests ───────────────────────────────────────
|
||||
|
||||
use super::build_respond_to_env;
|
||||
use super::{build_respond_to_env, build_respond_to_env_with_policy};
|
||||
use crate::managed_agents::types::{ManagedAgentRecord, RespondTo};
|
||||
|
||||
/// Construct a minimal record fixture for env-building tests. Only the
|
||||
@@ -230,6 +230,24 @@ fn build_env_anyone_omits_allowlist_var() {
|
||||
assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_policy_overrides_stale_anyone_record_at_runtime() {
|
||||
let rec = fixture(
|
||||
RespondTo::Anyone,
|
||||
vec!["malformed stale allowlist".into()],
|
||||
Some("tag".into()),
|
||||
);
|
||||
let (set, remove) = build_respond_to_env_with_policy(&rec, Some("owner"), true).unwrap();
|
||||
let set_map: std::collections::HashMap<_, _> = set.into_iter().collect();
|
||||
|
||||
assert_eq!(
|
||||
set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str),
|
||||
Some("owner-only")
|
||||
);
|
||||
assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
||||
assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_env_legacy_record_without_auth_tag_emits_agent_owner() {
|
||||
let rec = fixture(RespondTo::OwnerOnly, vec![], None);
|
||||
|
||||
@@ -300,6 +300,9 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord])
|
||||
pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> {
|
||||
let definitions = load_agent_definitions(app).unwrap_or_default();
|
||||
let mut sorted = records.to_vec();
|
||||
for record in &mut sorted {
|
||||
super::normalize_managed_agent_access(record);
|
||||
}
|
||||
// A caller-supplied key-less record would collide with the definition
|
||||
// half re-read below; instances always carry a pubkey.
|
||||
sorted.retain(|record| !record.pubkey.is_empty());
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
discoverGitBashPrerequisite,
|
||||
discoverManagedAgentPrereqs,
|
||||
getAgentConfigSurface,
|
||||
getAgentAccessOwnerOnly,
|
||||
getBakedBuildEnv,
|
||||
getBakedBuildEnvKeys,
|
||||
getChannelMembers,
|
||||
@@ -909,6 +910,20 @@ export function useRuntimeFileConfigQuery(
|
||||
|
||||
export const bakedBuildEnvKeysQueryKey = ["baked-build-env-keys"] as const;
|
||||
export const bakedBuildEnvQueryKey = ["baked-build-env"] as const;
|
||||
export const agentAccessOwnerOnlyQueryKey = [
|
||||
"agent-access-owner-only",
|
||||
] as const;
|
||||
|
||||
export function useAgentAccessOwnerOnlyQuery(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: agentAccessOwnerOnlyQueryKey,
|
||||
queryFn: () => getAgentAccessOwnerOnly(),
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: Infinity,
|
||||
refetchInterval: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query safely displayable baked build env entries. The backend masks secrets,
|
||||
|
||||
@@ -67,7 +67,11 @@ import {
|
||||
MODEL_DISCOVERY_LOADING_VALUE,
|
||||
usePersonaModelDiscovery,
|
||||
} from "./usePersonaModelDiscovery";
|
||||
import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks";
|
||||
import {
|
||||
useAgentAccessOwnerOnlyQuery,
|
||||
useBakedBuildEnvKeysQuery,
|
||||
useRuntimeFileConfigQuery,
|
||||
} from "../hooks";
|
||||
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
|
||||
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
|
||||
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
|
||||
@@ -361,6 +365,9 @@ export function AgentDefinitionDialog({
|
||||
}
|
||||
const { data: bakedEnvKeys, isLoading: bakedLoading } =
|
||||
useBakedBuildEnvKeysQuery({ enabled: open });
|
||||
const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({
|
||||
enabled: open,
|
||||
});
|
||||
const credentialSettled = !fileConfigLoading && !bakedLoading;
|
||||
const localModeGate = React.useMemo(
|
||||
() =>
|
||||
@@ -1008,6 +1015,7 @@ export function AgentDefinitionDialog({
|
||||
hiddenEnvKeys={
|
||||
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
|
||||
}
|
||||
hideAgentAccess={agentAccessOwnerOnly === true}
|
||||
inheritedEnvVars={inheritedEnvVarsForAdvanced}
|
||||
model={model}
|
||||
modelTuningRuntimeId={runtime}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
useAcpRuntimesQuery,
|
||||
useAgentAccessOwnerOnlyQuery,
|
||||
useAgentConfigSurface,
|
||||
useBakedBuildEnvKeysQuery,
|
||||
usePersonasQuery,
|
||||
@@ -389,6 +390,9 @@ export function AgentInstanceEditDialog({
|
||||
});
|
||||
|
||||
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
|
||||
const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
// Merge global env as the base layer so credential keys satisfied via global
|
||||
// config (e.g. ANTHROPIC_API_KEY) are available to model discovery. Use
|
||||
@@ -920,15 +924,16 @@ export function AgentInstanceEditDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Who can talk to this agent */}
|
||||
<CreateAgentRespondToField
|
||||
allowlist={respondToAllowlist}
|
||||
disabled={updateMutation.isPending}
|
||||
mode={respondTo}
|
||||
onAllowlistChange={setRespondToAllowlist}
|
||||
onModeChange={setRespondTo}
|
||||
variant="persona"
|
||||
/>
|
||||
{!agentAccessOwnerOnly || agent.backend.type !== "local" ? (
|
||||
<CreateAgentRespondToField
|
||||
allowlist={respondToAllowlist}
|
||||
disabled={updateMutation.isPending}
|
||||
mode={respondTo}
|
||||
onAllowlistChange={setRespondToAllowlist}
|
||||
onModeChange={setRespondTo}
|
||||
variant="persona"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Provider (runtime) */}
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -26,6 +26,7 @@ export function PersonaAdvancedFields({
|
||||
requiredEnvKeys = [],
|
||||
fileSatisfiedEnvKeys = [],
|
||||
hiddenEnvKeys = [],
|
||||
hideAgentAccess = false,
|
||||
}: {
|
||||
behaviorDraft: PersonaBehaviorDraft;
|
||||
disabled: boolean;
|
||||
@@ -46,24 +47,27 @@ export function PersonaAdvancedFields({
|
||||
requiredEnvKeys?: readonly string[];
|
||||
fileSatisfiedEnvKeys?: readonly string[];
|
||||
hiddenEnvKeys?: readonly string[];
|
||||
hideAgentAccess?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5 pt-2">
|
||||
<CreateAgentRespondToField
|
||||
allowlist={behaviorDraft.respondToAllowlist}
|
||||
disabled={disabled}
|
||||
mode={behaviorDraft.respondTo ?? "owner-only"}
|
||||
onAllowlistChange={(allowlist) =>
|
||||
onBehaviorDraftChange({
|
||||
...behaviorDraft,
|
||||
respondToAllowlist: allowlist,
|
||||
})
|
||||
}
|
||||
onModeChange={(mode) =>
|
||||
onBehaviorDraftChange({ ...behaviorDraft, respondTo: mode })
|
||||
}
|
||||
variant="persona"
|
||||
/>
|
||||
{!hideAgentAccess ? (
|
||||
<CreateAgentRespondToField
|
||||
allowlist={behaviorDraft.respondToAllowlist}
|
||||
disabled={disabled}
|
||||
mode={behaviorDraft.respondTo ?? "owner-only"}
|
||||
onAllowlistChange={(allowlist) =>
|
||||
onBehaviorDraftChange({
|
||||
...behaviorDraft,
|
||||
respondToAllowlist: allowlist,
|
||||
})
|
||||
}
|
||||
onModeChange={(mode) =>
|
||||
onBehaviorDraftChange({ ...behaviorDraft, respondTo: mode })
|
||||
}
|
||||
variant="persona"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "@/shared/ui/modalSearchStyles";
|
||||
import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard";
|
||||
import { useManagedAgentRuntimesQuery } from "@/features/agents/managedAgentRuntimeHooks";
|
||||
import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/hooks";
|
||||
import {
|
||||
findManagedAgentRuntime,
|
||||
managedAgentPairAction,
|
||||
@@ -144,6 +145,9 @@ export function MembersSidebar({
|
||||
const managedAgentRuntimesQuery = useManagedAgentRuntimesQuery({
|
||||
enabled: open,
|
||||
});
|
||||
const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({
|
||||
enabled: open,
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
@@ -640,7 +644,12 @@ export function MembersSidebar({
|
||||
onChangeRole={(m, role) => {
|
||||
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
|
||||
}}
|
||||
onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined}
|
||||
onEditRespondTo={
|
||||
memberIsBot &&
|
||||
(!agentAccessOwnerOnly || managedAgent?.backend.type !== "local")
|
||||
? setEditRespondToAgent
|
||||
: undefined
|
||||
}
|
||||
onManagedAgentAction={(agent) => {
|
||||
void handleAgentLifecycleAction(agent, managedAgentRuntime);
|
||||
}}
|
||||
|
||||
@@ -1014,6 +1014,11 @@ export async function getBakedBuildEnvKeys(): Promise<string[]> {
|
||||
return invokeTauri<string[]>("get_baked_build_env_keys");
|
||||
}
|
||||
|
||||
/** Return whether this build forces managed-agent access to owner-only. */
|
||||
export async function getAgentAccessOwnerOnly(): Promise<boolean> {
|
||||
return invokeTauri<boolean>("agent_access_owner_only");
|
||||
}
|
||||
|
||||
/**
|
||||
* A single baked build env entry.
|
||||
*
|
||||
|
||||
@@ -351,6 +351,8 @@ type E2eConfig = {
|
||||
model: string | null;
|
||||
preferred_runtime?: string | null;
|
||||
};
|
||||
/** Explicit internal-distribution marker; independent of baked defaults. */
|
||||
internalBuild?: boolean;
|
||||
/** Baked build env returned by the display and key-name Tauri commands. */
|
||||
bakedBuildEnv?: Array<{
|
||||
key: string;
|
||||
@@ -10285,6 +10287,8 @@ export function maybeInstallE2eTauriMocks() {
|
||||
}
|
||||
case "get_baked_build_env_keys":
|
||||
return (config?.mock?.bakedBuildEnv ?? []).map((entry) => entry.key);
|
||||
case "agent_access_owner_only":
|
||||
return config?.mock?.internalBuild ?? false;
|
||||
case "update_managed_agent":
|
||||
return handleUpdateManagedAgent(
|
||||
payload as Parameters<typeof handleUpdateManagedAgent>[0],
|
||||
|
||||
@@ -79,6 +79,46 @@ async function pickDropdownOption(
|
||||
}
|
||||
|
||||
test.describe("edit agent dialog", () => {
|
||||
test("internal build hides the managed-agent access control", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
internalBuild: true,
|
||||
bakedBuildEnv: BAKED_DEFAULTS,
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: AGENT_PUBKEY,
|
||||
name: AGENT_NAME,
|
||||
status: "stopped",
|
||||
channelNames: ["agents"],
|
||||
respondTo: "anyone",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await openEditDialog(page);
|
||||
|
||||
await expect(page.getByTestId("agent-respond-to")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("OSS build keeps the managed-agent access control", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
bakedBuildEnv: BAKED_DEFAULTS,
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: AGENT_PUBKEY,
|
||||
name: AGENT_NAME,
|
||||
status: "stopped",
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await openEditDialog(page);
|
||||
|
||||
await expect(page.getByTestId("agent-respond-to")).toBeVisible();
|
||||
});
|
||||
|
||||
test("edits the agent name and persists it across a dialog reopen", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -393,6 +393,7 @@ type MockBridgeOptions = {
|
||||
model: string | null;
|
||||
preferred_runtime?: string | null;
|
||||
};
|
||||
internalBuild?: boolean;
|
||||
bakedBuildEnv?: Array<{
|
||||
key: string;
|
||||
masked: boolean;
|
||||
|
||||
Reference in New Issue
Block a user