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:
7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz
2026-07-23 09:57:46 -07:00
parent 03f645ef7e
commit f0173c0b43
23 changed files with 427 additions and 85 deletions
+11 -4
View File
@@ -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({
+7
View File
@@ -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}");
}
+11 -22
View File
@@ -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();
+7 -17
View File
@@ -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,
+1
View File
@@ -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());
+15
View File
@@ -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);
}}
+5
View File
@@ -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.
*
+4
View File
@@ -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],
+40
View File
@@ -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,
}) => {
+1
View File
@@ -393,6 +393,7 @@ type MockBridgeOptions = {
model: string | null;
preferred_runtime?: string | null;
};
internalBuild?: boolean;
bakedBuildEnv?: Array<{
key: string;
masked: boolean;