mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): enforce owner-only access in internal builds (#4053)
## Problem Managed agents in internal Buzz builds should answer only their owner. Previously, an agent could keep a broader access setting and respond to other people, which did not match the access policy for internal builds. This PR makes owner-only access effective for every managed agent in internal builds and makes that restriction clear in the Desktop UI. Open source builds remain configurable. ## Changes - Enforce owner-only access when any managed agent starts or is deployed from an internal build. - Show the agent access control as locked to **Only me** in Desktop, with an explanation of why it cannot be changed. - Keep Welcome teammates working under the same rule without triggering unnecessary restarts. - Leave open source build behavior unchanged. This changes effective runtime access without rewriting stored or relay-advertised settings. The companion [#4064](https://github.com/block/buzz/pull/4064) explains the restriction in-thread when someone without access mentions an agent. The enforcement will remain inactive in shipped builds until [squareup/buzz-releases#74](https://github.com/squareup/buzz-releases/pull/74) marks internal releases during the build. ## Screenshots | Before | After | | --- | --- | |  |  | ## Tests Added coverage for: - Runtime enforcement for [locally run agents](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/runtime/tests.rs#L196) and [deployed agents](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/commands/agents_tests.rs#L510). - The [current-build deployment path](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/commands/agents_tests.rs#L455), [invalid stored access](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/access_policy.rs#L98), and the [local startup guard](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/env_vars/tests.rs#L149). - Consistent enforcement across [both agent backends](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src-tauri/src/managed_agents/access_policy.rs#L112). - Welcome teammates created as [locally run](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeGuide.test.mjs#L384) or [deployed](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeGuide.test.mjs#L393) agents, including [access-only](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeKickoff.test.mjs#L202) and [runtime-related](https://github.com/block/buzz/blob/e0165f52b52741a74184c9899e2b51eeec40c939/desktop/src/features/onboarding/welcomeKickoff.test.mjs#L225) restart behavior. The full Desktop Rust and JavaScript suites, type checks, formatting, clippy, and file-size checks passed. Playwright E2E was not run. --- Originated from Buzz channel [buzz-agent-control](buzz://channel?id=cf5dada7-e26a-4887-ae41-b3bd5f42d3b2). Supersedes #2537. --------- Signed-off-by: Tom Brow <tomb@block.xyz> Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
co-authored by
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
Amp
parent
5677e4ca05
commit
16cc3de6d6
@@ -212,9 +212,10 @@ desktop-tauri-test: _ensure-sidecar-stubs
|
||||
desktop-terminal-performance-test:
|
||||
cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture
|
||||
|
||||
# Verify compiled-flag behavior under both compile states (clean + internal).
|
||||
# Runs the auto-connect compiled-flag test twice with independently supplied
|
||||
# expected values; build.rs rerun-if-env-changed triggers recompilation.
|
||||
# Verify compiled-flag behavior under both compile states (clean + capability set).
|
||||
# Runs the auto-connect and owner-only access focused tests twice with
|
||||
# independently supplied expected values; build.rs rerun-if-env-changed
|
||||
# triggers recompilation.
|
||||
desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -223,10 +224,22 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs
|
||||
env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \
|
||||
BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \
|
||||
cargo test compiled_flag_matches_expected -- --ignored --nocapture
|
||||
echo "=== Internal build (flag set) → expect true ==="
|
||||
env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \
|
||||
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \
|
||||
cargo test --lib
|
||||
env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \
|
||||
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \
|
||||
cargo test compiled_policy_matches_expected -- --ignored --nocapture
|
||||
echo "=== Internal build (flags set) → expect true ==="
|
||||
BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \
|
||||
BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \
|
||||
cargo test compiled_flag_matches_expected -- --ignored --nocapture
|
||||
BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \
|
||||
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \
|
||||
cargo test --lib
|
||||
BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \
|
||||
BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \
|
||||
cargo test compiled_policy_matches_expected -- --ignored --nocapture
|
||||
echo "Both compiled states verified."
|
||||
|
||||
# Build the full desktop Tauri app locally (unsigned, for testing)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Shared schema, included from the same source the runtime command parses with,
|
||||
// so the build-time validation below and the runtime parse cannot drift.
|
||||
include!("src/commands/reconnect_hook_config.rs");
|
||||
// Same source of truth the runtime filters with, so a baked build env cannot
|
||||
// carry a reserved key the runtime believes it already rejected.
|
||||
include!("src/managed_agents/reserved_env_keys.rs");
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
@@ -13,9 +16,16 @@ fn main() {
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY");
|
||||
println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)");
|
||||
|
||||
// Explicit owner-only agent-access capability. Release packaging sets this
|
||||
// presence-only marker; OSS/custom builds leave agent access configurable.
|
||||
if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() {
|
||||
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY=1");
|
||||
}
|
||||
|
||||
if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") {
|
||||
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}");
|
||||
}
|
||||
@@ -59,6 +69,20 @@ fn main() {
|
||||
line
|
||||
);
|
||||
}
|
||||
// The baked env is written into every spawned agent's environment
|
||||
// LAST (see `managed_agents/runtime.rs`), after Buzz sets the
|
||||
// access gates and identity vars. A baked reserved key would
|
||||
// therefore silently override the gate the UI promises, so reject
|
||||
// it at build time instead of shipping a binary that bypasses its
|
||||
// own enforcement.
|
||||
if is_reserved_env_key(key) {
|
||||
panic!(
|
||||
"BUZZ_BUILD_AGENT_ENV line {}: `{}` is reserved by Buzz and cannot be baked \
|
||||
into a build (it would override Buzz's own identity/access env)",
|
||||
line_no + 1,
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
|
||||
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ENV={encoded}");
|
||||
|
||||
@@ -330,7 +330,10 @@ fn child_shell_is_the_resolved_shell_not_the_inherited_one() {
|
||||
/// grows a new secret, this points at the file to update.
|
||||
#[test]
|
||||
fn reserved_keys_are_covered() {
|
||||
let source = include_str!("../../../src/managed_agents/env_vars.rs");
|
||||
// The list lives in its own file because `build.rs` `include!`s the same
|
||||
// source (see `managed_agents/reserved_env_keys.rs`); read it there rather
|
||||
// than through the module that includes it.
|
||||
let source = include_str!("../../../src/managed_agents/reserved_env_keys.rs");
|
||||
let declared: Vec<&str> = source
|
||||
.lines()
|
||||
.skip_while(|line| !line.contains("RESERVED_ENV_KEYS"))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/// Return whether this build enforces owner-only managed-agent access.
|
||||
#[tauri::command]
|
||||
pub fn agent_access_owner_only() -> bool {
|
||||
crate::managed_agents::owner_only_access_build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
#[ignore = "requires BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"]
|
||||
fn compiled_policy_matches_expected() {
|
||||
let expected = std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY")
|
||||
.expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set")
|
||||
.parse::<bool>()
|
||||
.expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false");
|
||||
assert_eq!(super::agent_access_owner_only(), expected);
|
||||
}
|
||||
}
|
||||
@@ -1355,9 +1355,9 @@ pub async fn delete_managed_agent(
|
||||
// 2. Harness sees it, exits gracefully, sets presence to "offline"
|
||||
// 3. Desktop's existing presence polling sees "offline" — UI updates automatically
|
||||
// No backend Tauri command needed. Presence IS the status.
|
||||
|
||||
#[path = "agents_deploy.rs"]
|
||||
mod deploy;
|
||||
pub(super) mod provider_access;
|
||||
use deploy::build_deploy_payload;
|
||||
#[cfg(test)]
|
||||
use deploy::{deploy_payload_json, DeployProjections};
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Upgrade reconciliation for provider-backed managed-agent access.
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
find_managed_agent_mut, load_managed_agents, save_managed_agents, BackendKind,
|
||||
ManagedAgentRecord,
|
||||
},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
pub(super) fn needs_reconciliation_with_policy(
|
||||
record: &ManagedAgentRecord,
|
||||
owner_only_access: bool,
|
||||
) -> bool {
|
||||
owner_only_access && record.backend != BackendKind::Local && record.backend_agent_id.is_some()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProviderAccessTarget {
|
||||
pubkey: String,
|
||||
provider_id: String,
|
||||
config: serde_json::Value,
|
||||
cached_binary_path: Option<String>,
|
||||
agent_json: Result<serde_json::Value, String>,
|
||||
}
|
||||
|
||||
fn collect_targets_with(
|
||||
records: Vec<ManagedAgentRecord>,
|
||||
owner_only_access: bool,
|
||||
mut build_payload: impl FnMut(&ManagedAgentRecord) -> Result<serde_json::Value, String>,
|
||||
) -> Vec<ProviderAccessTarget> {
|
||||
records
|
||||
.into_iter()
|
||||
.filter(|record| needs_reconciliation_with_policy(record, owner_only_access))
|
||||
.map(|record| match record.backend.clone() {
|
||||
BackendKind::Provider { id, config } => ProviderAccessTarget {
|
||||
agent_json: build_payload(&record),
|
||||
pubkey: record.pubkey,
|
||||
provider_id: id,
|
||||
config,
|
||||
cached_binary_path: record.provider_binary_path,
|
||||
},
|
||||
BackendKind::Local => {
|
||||
unreachable!("provider access reconciliation selected a local agent")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Redeploy every existing provider agent in an owner-only access build.
|
||||
///
|
||||
/// The saved `backend_agent_id` only proves that some provider deployment
|
||||
/// exists. A marked build sends the current owner-only payload before each
|
||||
/// community UI load. Workspace apply fails closed if any provider rejects it.
|
||||
pub(crate) async fn reconcile_on_workspace_apply(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
) -> Result<(), String> {
|
||||
if !crate::managed_agents::owner_only_access_build() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let targets = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
collect_targets_with(load_managed_agents(app)?, true, |record| {
|
||||
super::build_deploy_payload(app, state, record)
|
||||
})
|
||||
};
|
||||
|
||||
for target in targets {
|
||||
let ProviderAccessTarget {
|
||||
pubkey,
|
||||
provider_id,
|
||||
config,
|
||||
cached_binary_path,
|
||||
agent_json,
|
||||
} = target;
|
||||
let agent_json = match agent_json {
|
||||
Ok(agent_json) => agent_json,
|
||||
Err(error) => {
|
||||
persist_failure(app, state, &pubkey, &error)?;
|
||||
return Err(format!(
|
||||
"provider access reconciliation failed for agent {pubkey}: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) = super::deploy_to_provider(
|
||||
app,
|
||||
state,
|
||||
&pubkey,
|
||||
&provider_id,
|
||||
&config,
|
||||
agent_json,
|
||||
cached_binary_path.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(format!(
|
||||
"provider access reconciliation failed for agent {pubkey}: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_failure(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
error: &str,
|
||||
) -> Result<(), String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|lock_error| lock_error.to_string())?;
|
||||
let mut records = load_managed_agents(app)?;
|
||||
let record = find_managed_agent_mut(&mut records, pubkey)?;
|
||||
record.last_error = Some(error.to_string());
|
||||
record.updated_at = now_iso();
|
||||
save_managed_agents(app, &records)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn record(backend: BackendKind, backend_agent_id: Option<&str>) -> 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.backend_agent_id = backend_agent_id.map(str::to_string);
|
||||
record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_collects_existing_provider_and_builds_projected_payload() {
|
||||
let records = vec![
|
||||
record(
|
||||
BackendKind::Provider {
|
||||
id: "provider".into(),
|
||||
config: serde_json::json!({"region": "test"}),
|
||||
},
|
||||
Some("existing"),
|
||||
),
|
||||
record(
|
||||
BackendKind::Provider {
|
||||
id: "not-deployed".into(),
|
||||
config: serde_json::json!({}),
|
||||
},
|
||||
None,
|
||||
),
|
||||
record(BackendKind::Local, Some("stale")),
|
||||
];
|
||||
|
||||
let targets = collect_targets_with(records, true, |_| {
|
||||
Ok(serde_json::json!({"respond_to": "owner-only"}))
|
||||
});
|
||||
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].pubkey, "agent");
|
||||
assert_eq!(targets[0].provider_id, "provider");
|
||||
assert_eq!(targets[0].config["region"], "test");
|
||||
assert_eq!(
|
||||
targets[0].agent_json.as_ref().unwrap()["respond_to"],
|
||||
"owner-only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmarked_build_collects_no_upgrade_targets() {
|
||||
let records = vec![record(
|
||||
BackendKind::Provider {
|
||||
id: "provider".into(),
|
||||
config: serde_json::json!({}),
|
||||
},
|
||||
Some("existing"),
|
||||
)];
|
||||
|
||||
assert!(
|
||||
collect_targets_with(records, false, |_| { Ok(serde_json::Value::Null) }).is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ pub(super) struct DeployProjections {
|
||||
/// Effective parallelism derived from the same resolved `descriptor.command`
|
||||
/// as `launch.policy_env["BUZZ_ACP_AGENTS"]`.
|
||||
pub effective_parallelism: u32,
|
||||
/// Access fields projected from the same build policy that gates local starts.
|
||||
pub owner_only_access: bool,
|
||||
}
|
||||
|
||||
/// Resolve the deploy-specific structured model/provider for a managed agent.
|
||||
@@ -170,6 +172,7 @@ pub(super) fn build_deploy_payload(
|
||||
effective_provider: effective.provider.value,
|
||||
effective_prompt: effective.system_prompt.value,
|
||||
effective_parallelism,
|
||||
owner_only_access: crate::managed_agents::owner_only_access_build(),
|
||||
},
|
||||
merged_user_env,
|
||||
launch,
|
||||
@@ -179,8 +182,8 @@ pub(super) fn build_deploy_payload(
|
||||
/// Pure serialization half of [`build_deploy_payload`]. Legacy top-level fields
|
||||
/// remain for display/bookkeeping; providers execute the resolved `launch` block.
|
||||
/// `projections.effective_parallelism` is pre-computed from the same resolved
|
||||
/// descriptor as `launch.policy_env["BUZZ_ACP_AGENTS"]` — the two fields are
|
||||
/// always consistent regardless of stale `record.agent_command` pins.
|
||||
/// descriptor as `launch.policy_env["BUZZ_ACP_AGENTS"]`. Access is projected from
|
||||
/// the same compiled policy that gates local starts.
|
||||
pub(super) fn deploy_payload_json(
|
||||
record: &ManagedAgentRecord,
|
||||
relay_url: String,
|
||||
@@ -188,6 +191,8 @@ pub(super) fn deploy_payload_json(
|
||||
merged_env: BTreeMap<String, String>,
|
||||
launch: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
let (respond_to, respond_to_allowlist) =
|
||||
crate::managed_agents::projected_access_with_policy(record, projections.owner_only_access);
|
||||
serde_json::json!({
|
||||
"name": &record.name,
|
||||
"relay_url": relay_url,
|
||||
@@ -204,8 +209,8 @@ pub(super) fn deploy_payload_json(
|
||||
// Legacy top-level field: projected from the same resolved descriptor as
|
||||
// launch.policy_env["BUZZ_ACP_AGENTS"] — the two are always consistent.
|
||||
"parallelism": projections.effective_parallelism,
|
||||
"respond_to": record.respond_to,
|
||||
"respond_to_allowlist": &record.respond_to_allowlist,
|
||||
"respond_to": respond_to,
|
||||
"respond_to_allowlist": respond_to_allowlist,
|
||||
"env_vars": merged_env,
|
||||
"launch": launch,
|
||||
})
|
||||
@@ -363,6 +368,7 @@ mod tests {
|
||||
effective_provider: None,
|
||||
effective_prompt: None,
|
||||
effective_parallelism,
|
||||
owner_only_access: false,
|
||||
},
|
||||
BTreeMap::new(),
|
||||
launch.clone(),
|
||||
@@ -407,6 +413,7 @@ mod tests {
|
||||
effective_provider: None,
|
||||
effective_prompt: None,
|
||||
effective_parallelism,
|
||||
owner_only_access: false,
|
||||
},
|
||||
BTreeMap::new(),
|
||||
launch.clone(),
|
||||
@@ -452,6 +459,7 @@ mod tests {
|
||||
effective_provider: None,
|
||||
effective_prompt: None,
|
||||
effective_parallelism,
|
||||
owner_only_access: false,
|
||||
},
|
||||
BTreeMap::new(),
|
||||
launch.clone(),
|
||||
|
||||
@@ -411,6 +411,27 @@ fn legacy_avatar_empty_when_nothing_resolves() {
|
||||
|
||||
// ── Provider deploy payload completeness ─────────────────────────────────────
|
||||
|
||||
fn deploy_payload_for_policy(
|
||||
record: &ManagedAgentRecord,
|
||||
owner_only_access: bool,
|
||||
) -> serde_json::Value {
|
||||
deploy_payload_json(
|
||||
record,
|
||||
"wss://relay.example".to_string(),
|
||||
DeployProjections {
|
||||
effective_model: Some("gpt-x".to_string()),
|
||||
effective_provider: Some("openai".to_string()),
|
||||
effective_prompt: None,
|
||||
effective_parallelism: record.parallelism,
|
||||
owner_only_access,
|
||||
},
|
||||
std::collections::BTreeMap::new(),
|
||||
// Access projection is the subject here; the launch block is exercised
|
||||
// by the shared provider fixture test below.
|
||||
serde_json::Value::Null,
|
||||
)
|
||||
}
|
||||
|
||||
/// The shared provider fixture is the contract arbiter: it must be the exact
|
||||
/// richest deploy request produced by the real desktop serializers.
|
||||
#[test]
|
||||
@@ -473,6 +494,8 @@ fn deploy_payload_matches_the_shared_full_launch_fixture() {
|
||||
&descriptor.command,
|
||||
record.parallelism,
|
||||
),
|
||||
// Fixture asserts the record's own access fields survive.
|
||||
owner_only_access: false,
|
||||
},
|
||||
std::collections::BTreeMap::from([("USER_KEY".into(), "user-value".into())]),
|
||||
launch,
|
||||
@@ -507,3 +530,129 @@ fn tauri_platform_configs_bundle_kubernetes_only_on_supported_hosts() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_build_deploy_payload_forwards_compiled_policy() {
|
||||
use crate::managed_agents::{BackendKind, RespondTo};
|
||||
|
||||
let expected_owner_only = match std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY") {
|
||||
Ok(value) => value
|
||||
.parse::<bool>()
|
||||
.expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false"),
|
||||
Err(std::env::VarError::NotPresent)
|
||||
if !crate::managed_agents::owner_only_access_build() =>
|
||||
{
|
||||
false
|
||||
}
|
||||
Err(std::env::VarError::NotPresent) => {
|
||||
panic!(
|
||||
"BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set for owner-only-access-build tests"
|
||||
)
|
||||
}
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
panic!("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be valid UTF-8")
|
||||
}
|
||||
};
|
||||
let mut record = bare_agent_record(None, None, None);
|
||||
record.backend = BackendKind::Provider {
|
||||
id: "provider".to_string(),
|
||||
config: serde_json::json!({}),
|
||||
};
|
||||
record.respond_to = RespondTo::Anyone;
|
||||
record.respond_to_allowlist = vec!["a".repeat(64)];
|
||||
|
||||
let payload = deploy_payload_json(
|
||||
&record,
|
||||
"wss://relay.example".to_string(),
|
||||
DeployProjections {
|
||||
effective_model: None,
|
||||
effective_provider: None,
|
||||
effective_prompt: None,
|
||||
effective_parallelism: record.parallelism,
|
||||
owner_only_access: crate::managed_agents::owner_only_access_build(),
|
||||
},
|
||||
std::collections::BTreeMap::new(),
|
||||
// The compiled access policy is the subject here; the launch block is
|
||||
// exercised by the shared provider fixture test above.
|
||||
serde_json::Value::Null,
|
||||
);
|
||||
let expected_mode = if expected_owner_only {
|
||||
"owner-only"
|
||||
} else {
|
||||
"anyone"
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
payload["respond_to"], expected_mode,
|
||||
"current-build deploy payload did not forward the compiled policy",
|
||||
);
|
||||
let expected_allowlist = if expected_owner_only {
|
||||
serde_json::json!([])
|
||||
} else {
|
||||
serde_json::json!(["a".repeat(64)])
|
||||
};
|
||||
assert_eq!(
|
||||
payload["respond_to_allowlist"], expected_allowlist,
|
||||
"current-build deploy payload did not apply the compiled policy to the stale allowlist",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_upgrade_reconciliation_targets_existing_deployments_only_in_marked_builds() {
|
||||
use crate::managed_agents::BackendKind;
|
||||
|
||||
let mut record = bare_agent_record(None, None, None);
|
||||
record.backend = BackendKind::Provider {
|
||||
id: "provider".to_string(),
|
||||
config: serde_json::json!({}),
|
||||
};
|
||||
record.backend_agent_id = Some("existing-provider-agent".to_string());
|
||||
record.respond_to = crate::managed_agents::RespondTo::Anyone;
|
||||
record.respond_to_allowlist = vec!["a".repeat(64)];
|
||||
|
||||
assert!(provider_access::needs_reconciliation_with_policy(
|
||||
&record, true
|
||||
));
|
||||
let payload = deploy_payload_for_policy(&record, true);
|
||||
assert_eq!(payload["respond_to"], "owner-only");
|
||||
assert_eq!(payload["respond_to_allowlist"], serde_json::json!([]));
|
||||
assert!(!provider_access::needs_reconciliation_with_policy(
|
||||
&record, false
|
||||
));
|
||||
|
||||
record.backend_agent_id = None;
|
||||
assert!(!provider_access::needs_reconciliation_with_policy(
|
||||
&record, true
|
||||
));
|
||||
|
||||
record.backend = BackendKind::Local;
|
||||
record.backend_agent_id = Some("stale-provider-id".to_string());
|
||||
assert!(!provider_access::needs_reconciliation_with_policy(
|
||||
&record, true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_only_access_deploy_payload_clamps_stale_access() {
|
||||
use crate::managed_agents::{BackendKind, RespondTo};
|
||||
|
||||
let mut record = bare_agent_record(None, None, None);
|
||||
record.backend = BackendKind::Provider {
|
||||
id: "provider".to_string(),
|
||||
config: serde_json::json!({}),
|
||||
};
|
||||
record.respond_to = RespondTo::Anyone;
|
||||
record.respond_to_allowlist = vec!["a".repeat(64)];
|
||||
|
||||
let payload = deploy_payload_for_policy(&record, true);
|
||||
|
||||
assert_eq!(
|
||||
payload["respond_to"], "owner-only",
|
||||
"owner-only-access deploy payload widened stale access"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["respond_to_allowlist"],
|
||||
serde_json::json!([]),
|
||||
"owner-only-access deploy payload retained a stale allowlist"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod agent_access;
|
||||
mod agent_auth;
|
||||
mod agent_config;
|
||||
mod agent_discovery;
|
||||
@@ -63,6 +64,7 @@ mod window_vibrancy;
|
||||
mod workflows;
|
||||
mod workspace;
|
||||
|
||||
pub use agent_access::*;
|
||||
pub use agent_auth::*;
|
||||
pub use agent_config::*;
|
||||
pub use agent_discovery::*;
|
||||
|
||||
@@ -212,6 +212,8 @@ pub async fn apply_workspace(
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
|
||||
|
||||
let state = restore_app.state::<AppState>();
|
||||
super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?;
|
||||
|
||||
// Backfill this exact relay+owner scope only after the workspace has been
|
||||
// applied. Running at process boot would target the fallback relay and
|
||||
// collapse every community into one pending-event store.
|
||||
|
||||
@@ -774,6 +774,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,190 @@
|
||||
//! Distribution policy at managed-agent enforcement boundaries.
|
||||
//!
|
||||
//! ## What this build capability guarantees, and what it does not
|
||||
//!
|
||||
//! `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY` marks a build whose managed agents may
|
||||
//! answer only their owner. Enforcement is applied at the two boundaries where
|
||||
//! Desktop hands access to something that runs the agent, and nowhere else. The
|
||||
//! stored record and its relay-advertised access fields are left untouched, so
|
||||
//! the same profile keeps its user-chosen access when it is opened in an OSS
|
||||
//! build.
|
||||
//!
|
||||
//! Enforced:
|
||||
//!
|
||||
//! - **Local spawn.** [`build_respond_to_env_with_policy`] clamps
|
||||
//! `BUZZ_ACP_RESPOND_TO` to `owner-only` and pins the independent
|
||||
//! `BUZZ_ACP_ALLOWED_RESPOND_TO=owner-only` guard on every start, whatever
|
||||
//! the record says.
|
||||
//! - **Provider deployment, including upgrades.**
|
||||
//! [`projected_access_with_policy`] projects owner-only into every payload.
|
||||
//! Workspace apply redeploys each existing provider agent before the marked
|
||||
//! build renders community UI. A failed redeploy fails the apply, so Desktop
|
||||
//! does not present the locked owner-only control as applied while the remote
|
||||
//! deployment may still use a wider policy.
|
||||
//!
|
||||
//! ## "owner-only" is owner plus verified same-owner sibling agents
|
||||
//!
|
||||
//! The harness gate this projection targets admits the human owner *and* every
|
||||
//! cryptographically NIP-OA-verified agent that shares that owner (see
|
||||
//! `crates/buzz-acp/src/lib.rs`). That is the intended boundary, not an
|
||||
//! oversight: an owner's own agents are inside their trust boundary, and Buzz's
|
||||
//! built-in Welcome team relies on it, because the lead instructs its teammates
|
||||
//! while every teammate is created owner-only (see
|
||||
//! `welcomeTeammateHasExpectedAccess` in
|
||||
//! `desktop/src/features/onboarding/welcomeGuide.ts`). Read every use of
|
||||
//! "owner-only" in this module as `owner ∪ verified same-owner agents`. The
|
||||
//! setting's own copy says so: the line under Only me reads "Only you and your
|
||||
//! agents can send instructions." (`RespondToField.tsx`). The dropdown label
|
||||
//! stays "Only me", which is the audience the user picks.
|
||||
|
||||
use super::{validate_respond_to_allowlist, ManagedAgentRecord, RespondTo};
|
||||
|
||||
pub(crate) type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>);
|
||||
|
||||
/// Release packaging sets `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY`; OSS/custom
|
||||
/// builds do not.
|
||||
pub(crate) fn owner_only_access_build() -> bool {
|
||||
option_env!("BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY").is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn owner_only() -> bool {
|
||||
owner_only_with_policy(owner_only_access_build())
|
||||
}
|
||||
|
||||
pub(crate) fn owner_only_with_policy(owner_only_access: bool) -> bool {
|
||||
owner_only_access
|
||||
}
|
||||
|
||||
/// Project effective access at a behavioral boundary without changing the
|
||||
/// stored or relay-advertised access fields.
|
||||
pub(crate) fn projected_access_with_policy(
|
||||
record: &ManagedAgentRecord,
|
||||
owner_only_access: bool,
|
||||
) -> (RespondTo, Vec<String>) {
|
||||
if owner_only_with_policy(owner_only_access) {
|
||||
(RespondTo::OwnerOnly, Vec::new())
|
||||
} else {
|
||||
(record.respond_to, record.respond_to_allowlist.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the inbound-author access environment for a launched agent. The
|
||||
/// explicit policy input keeps owner-only access enforcement testable without
|
||||
/// weakening the production caller's compile-time decision.
|
||||
pub(crate) fn build_respond_to_env_with_policy(
|
||||
record: &ManagedAgentRecord,
|
||||
owner_hex: Option<&str>,
|
||||
enforced_owner_only: bool,
|
||||
) -> Result<RespondToEnv, String> {
|
||||
let (respond_to, _) = projected_access_with_policy(record, enforced_owner_only);
|
||||
let normalized = validate_respond_to_allowlist(&record.respond_to_allowlist)?;
|
||||
if respond_to == RespondTo::Allowlist && normalized.is_empty() {
|
||||
return Err(
|
||||
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut set = vec![("BUZZ_ACP_RESPOND_TO", respond_to.as_str().to_string())];
|
||||
let mut remove = Vec::new();
|
||||
if enforced_owner_only {
|
||||
set.push((
|
||||
"BUZZ_ACP_ALLOWED_RESPOND_TO",
|
||||
RespondTo::OwnerOnly.as_str().to_string(),
|
||||
));
|
||||
} else {
|
||||
remove.push("BUZZ_ACP_ALLOWED_RESPOND_TO");
|
||||
}
|
||||
if respond_to == RespondTo::Allowlist {
|
||||
set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(",")));
|
||||
} else {
|
||||
remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST");
|
||||
}
|
||||
|
||||
if record.auth_tag.is_none() {
|
||||
if let Some(owner) = owner_hex {
|
||||
set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string()));
|
||||
} else {
|
||||
remove.push("BUZZ_ACP_AGENT_OWNER");
|
||||
}
|
||||
} else {
|
||||
remove.push("BUZZ_ACP_AGENT_OWNER");
|
||||
}
|
||||
Ok((set, remove))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::managed_agents::BackendKind;
|
||||
|
||||
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!["a".repeat(64)];
|
||||
record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_only_access_policy_rejects_malformed_stored_allowlist_before_clamping() {
|
||||
let mut record = record(BackendKind::Local);
|
||||
record.respond_to_allowlist = vec!["malformed stale allowlist".into()];
|
||||
|
||||
let error = build_respond_to_env_with_policy(&record, Some("owner"), true)
|
||||
.expect_err("owner-only access policy accepted a malformed stored allowlist");
|
||||
|
||||
assert!(
|
||||
error.contains("invalid pubkey in respond-to allowlist"),
|
||||
"owner-only access policy returned the wrong malformed-allowlist error: {error}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_only_access_enforcement_clamps_local_and_provider() {
|
||||
for (label, backend) in [
|
||||
("local", BackendKind::Local),
|
||||
(
|
||||
"provider",
|
||||
BackendKind::Provider {
|
||||
id: "p".into(),
|
||||
config: serde_json::json!({}),
|
||||
},
|
||||
),
|
||||
] {
|
||||
let record = record(backend);
|
||||
let (gate_set, _) =
|
||||
build_respond_to_env_with_policy(&record, Some("owner"), true).unwrap();
|
||||
let gate_set: std::collections::HashMap<_, _> = gate_set.into_iter().collect();
|
||||
assert_eq!(
|
||||
gate_set.get("BUZZ_ACP_RESPOND_TO").map(String::as_str),
|
||||
Some("owner-only"),
|
||||
"owner-only runtime env did not clamp {label} agent",
|
||||
);
|
||||
assert_eq!(
|
||||
gate_set
|
||||
.get("BUZZ_ACP_ALLOWED_RESPOND_TO")
|
||||
.map(String::as_str),
|
||||
Some("owner-only"),
|
||||
"owner-only runtime env omitted the {label} agent guard",
|
||||
);
|
||||
|
||||
let (respond_to, allowlist) = projected_access_with_policy(&record, true);
|
||||
assert_eq!(
|
||||
respond_to,
|
||||
RespondTo::OwnerOnly,
|
||||
"owner-only provider payload did not clamp {label} agent",
|
||||
);
|
||||
assert!(
|
||||
allowlist.is_empty(),
|
||||
"owner-only provider payload retained {label} agent allowlist",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,19 @@ fn build_env_map(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Defense in depth. `build.rs` already refuses to bake a reserved key, so
|
||||
// reaching this filter means the binary was produced by a build that
|
||||
// skipped that check. Drop the key rather than let it override the access
|
||||
// gate: the baked map is written into the spawned agent's environment last
|
||||
// (see `managed_agents/runtime.rs`), so a baked `BUZZ_ACP_RESPOND_TO` would
|
||||
// otherwise win over the gate Desktop just set.
|
||||
map.retain(|key, _| {
|
||||
if super::env_vars::is_reserved_env_key(key) {
|
||||
eprintln!("buzz-desktop: ignoring reserved env var `{key}` from the baked build env");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
map
|
||||
}
|
||||
|
||||
@@ -356,4 +369,66 @@ mod tests {
|
||||
"unrelated merged_env keys must pass through unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
// ── baked reserved-key filtering ──────────────────────────────────────
|
||||
//
|
||||
// The baked map is written into a spawned agent's environment LAST (see
|
||||
// `managed_agents/runtime.rs`), after Buzz sets the access gates. If a
|
||||
// baked reserved key survived here, an internal build packaged with
|
||||
// `BUZZ_ACP_RESPOND_TO=anyone` would answer anyone while the UI shows
|
||||
// "Only me". `build.rs` rejects such a key at build time; these tests pin
|
||||
// the runtime backstop for a binary built without that check.
|
||||
|
||||
#[test]
|
||||
fn build_env_map_drops_baked_access_gate_keys() {
|
||||
use base64::Engine as _;
|
||||
let raw = "BUZZ_ACP_RESPOND_TO=anyone\nBUZZ_ACP_ALLOWED_RESPOND_TO=anyone\nBUZZ_ACP_RESPOND_TO_ALLOWLIST=deadbeef\nDATABRICKS_MODEL=goose-claude-opus-4-8";
|
||||
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
|
||||
let map = build_env_map(None, None, Some(&blob));
|
||||
for key in [
|
||||
"BUZZ_ACP_RESPOND_TO",
|
||||
"BUZZ_ACP_ALLOWED_RESPOND_TO",
|
||||
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
|
||||
] {
|
||||
assert!(
|
||||
!map.contains_key(key),
|
||||
"baked `{key}` must not reach the spawned agent env"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
map.get("DATABRICKS_MODEL").map(String::as_str),
|
||||
Some("goose-claude-opus-4-8"),
|
||||
"non-reserved baked keys must still pass through"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_env_map_drops_baked_reserved_keys_case_insensitively() {
|
||||
use base64::Engine as _;
|
||||
// `is_reserved_env_key` compares case-insensitively, and so must the
|
||||
// baked filter: env lookup is case-sensitive on Unix, but a lowercase
|
||||
// spelling would still be a reserved key smuggled past a case-sensitive
|
||||
// check on Windows.
|
||||
let raw = "buzz_acp_respond_to=anyone\nBuzz_Private_Key=nsec1fake";
|
||||
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
|
||||
let map = build_env_map(None, None, Some(&blob));
|
||||
assert!(
|
||||
map.is_empty(),
|
||||
"reserved keys in any casing must be dropped from the baked env: {map:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_env_map_drops_every_reserved_key() {
|
||||
use base64::Engine as _;
|
||||
for key in super::super::env_vars::RESERVED_ENV_KEYS {
|
||||
let raw = format!("{key}=baked-value");
|
||||
let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
|
||||
let map = build_env_map(None, None, Some(&blob));
|
||||
assert!(
|
||||
map.is_empty(),
|
||||
"baked reserved key `{key}` must be dropped, got {map:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,13 @@
|
||||
//! Precedence: desktop parent env < persona env < agent env (last wins on
|
||||
//! key collision). See `runtime::spawn_agent_child`.
|
||||
//!
|
||||
//! A small set of *reserved* keys — Buzz's identity and secrets, and
|
||||
//! control-plane values set by the Desktop — are rejected at save time and
|
||||
//! stripped at runtime so a typo or malicious value can't swap the agent's
|
||||
//! nsec or bypass a harness-specific execution cap. Behavior knobs
|
||||
//! A small set of *reserved* keys includes Buzz's identity, secrets, security
|
||||
//! gates, and control-plane values. Save-time validation rejects those keys.
|
||||
//! Runtime filtering strips old persisted overrides. Behavior knobs
|
||||
//! (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely
|
||||
//! overridable — those have dedicated UI fields, but power users may want
|
||||
//! to bypass them.
|
||||
//!
|
||||
//! `BUZZ_ACP_AGENTS` is reserved because the Desktop resolves the effective
|
||||
//! parallelism (applying per-harness caps such as OpenClaw's cap of 5) and
|
||||
//! writes the result into `launch.policy_env`. A user-supplied
|
||||
//! `BUZZ_ACP_AGENTS` would bypass the cap and cause OpenClaw agents to spawn
|
||||
//! uncapped workers against their single shared Gateway daemon.
|
||||
//! overridable. Power users can still bypass their dedicated UI fields.
|
||||
//! `BUZZ_ACP_AGENTS` is reserved because Desktop applies harness-specific caps
|
||||
//! before it writes the provider launch policy.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -47,77 +41,9 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool {
|
||||
.any(|k| k.eq_ignore_ascii_case(key))
|
||||
}
|
||||
|
||||
/// Env var keys that Buzz sets itself and users must not override from
|
||||
/// the persona/agent env_vars UI. Four categories:
|
||||
///
|
||||
/// 1. **Identity / secrets** — overriding would swap the agent's nsec or
|
||||
/// leak credentials.
|
||||
/// 2. **Code-execution surface** — overriding the binary/args lets the
|
||||
/// user run arbitrary code as the agent process.
|
||||
/// 3. **Security gates** — overriding the respond-to mode/allowlist or
|
||||
/// relay URL would silently break the saved security settings (the UI
|
||||
/// shows owner-only while the running agent answers anyone, for
|
||||
/// example), or redirect the agent to an attacker-controlled relay.
|
||||
/// 4. **Control-plane execution policy** — the Desktop owns the effective
|
||||
/// value, derived from structured record fields after applying per-harness
|
||||
/// caps. A user-supplied override would bypass the cap and produce a
|
||||
/// worker pool size that neither the record nor the UI represents.
|
||||
///
|
||||
/// This list is deliberately narrow — it only covers keys with security or
|
||||
/// correctness implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL,
|
||||
/// BUZZ_ACP_SYSTEM_PROMPT, …) remain freely overridable; those have
|
||||
/// dedicated UI fields but power users may want to bypass them.
|
||||
pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[
|
||||
// Identity / secrets.
|
||||
"BUZZ_PRIVATE_KEY",
|
||||
"NOSTR_PRIVATE_KEY",
|
||||
"BUZZ_AUTH_TAG",
|
||||
"BUZZ_API_TOKEN",
|
||||
"BUZZ_ACP_PRIVATE_KEY",
|
||||
"BUZZ_ACP_API_TOKEN",
|
||||
// Relay URL: overriding would let a malicious config redirect the
|
||||
// agent to an attacker-controlled relay.
|
||||
"BUZZ_RELAY_URL",
|
||||
// Code-execution surface: overriding would let the user run arbitrary
|
||||
// binaries/args as the agent process.
|
||||
"BUZZ_ACP_AGENT_COMMAND",
|
||||
"BUZZ_ACP_AGENT_ARGS",
|
||||
"BUZZ_ACP_MCP_COMMAND",
|
||||
// Control-plane parallelism: the Desktop resolves the effective
|
||||
// worker-pool size (applying any per-harness cap) and writes it into
|
||||
// launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the
|
||||
// harness cap and cause OpenClaw agents to spawn uncapped workers.
|
||||
"BUZZ_ACP_AGENTS",
|
||||
// Security gates: respond-to mode + allowlist + legacy owner-only
|
||||
// fallback. Overriding would make the running agent's gate diverge
|
||||
// from the saved/UI-visible settings.
|
||||
"BUZZ_ACP_RESPOND_TO",
|
||||
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
|
||||
"BUZZ_ACP_AGENT_OWNER",
|
||||
// Stable agent identity used for git attribution and private-conversation
|
||||
// provenance must come from the managed-agent record, not user overrides.
|
||||
"BUZZ_ACP_DISPLAY_NAME",
|
||||
// Remote lifetime/presence policy: user env must not disable the
|
||||
// desktop/provider-owned bounds while the saved record still promises them.
|
||||
"BUZZ_ACP_EXIT_AFTER_INACTIVITY",
|
||||
"BUZZ_ACP_NO_PRESENCE",
|
||||
// Readiness handoff: desktop is the ONLY readiness source. A saved or
|
||||
// ambient env var must not be able to forge setup mode (NotReady) on a
|
||||
// Ready agent or suppress it (empty/stale payload) on a NotReady one.
|
||||
"BUZZ_ACP_SETUP_PAYLOAD",
|
||||
// Desktop ownership markers: these brand every spawned harness with the
|
||||
// launching Desktop instance. A user-supplied override would let a
|
||||
// definition masquerade as a different instance or fake the nonce used
|
||||
// for same-session sweep decisions.
|
||||
"BUZZ_MANAGED_AGENT",
|
||||
"BUZZ_MANAGED_AGENT_START_NONCE",
|
||||
];
|
||||
|
||||
pub(crate) fn is_reserved_env_key(key: &str) -> bool {
|
||||
RESERVED_ENV_KEYS
|
||||
.iter()
|
||||
.any(|reserved| reserved.eq_ignore_ascii_case(key))
|
||||
}
|
||||
// Canonical reserved-key list + predicate, shared verbatim with `build.rs`.
|
||||
// See `reserved_env_keys.rs` for why this is `include!`d rather than a module.
|
||||
include!("reserved_env_keys.rs");
|
||||
|
||||
/// Returns true if `key` is a well-formed POSIX-shaped env var name:
|
||||
/// `[A-Za-z_][A-Za-z0-9_]*`. This is a hard requirement, not a stylistic
|
||||
|
||||
@@ -150,7 +150,11 @@ fn reserved_keys_include_respond_to_gate() {
|
||||
// Respond-to mode + allowlist control who the agent answers.
|
||||
// Overriding via env_vars would let the running agent answer
|
||||
// anyone even when the UI/record says owner-only.
|
||||
for key in ["BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST"] {
|
||||
for key in [
|
||||
"BUZZ_ACP_RESPOND_TO",
|
||||
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
|
||||
"BUZZ_ACP_ALLOWED_RESPOND_TO",
|
||||
] {
|
||||
assert!(is_reserved_env_key(key), "{key} should be reserved");
|
||||
let agent = map(&[(key, "anyone")]);
|
||||
let merged = merged_user_env(&BTreeMap::new(), &agent);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
pub(crate) mod access_policy;
|
||||
mod agent_env;
|
||||
pub(crate) mod agent_events;
|
||||
pub(crate) mod agent_snapshot;
|
||||
pub(crate) mod agent_snapshot_envelope;
|
||||
pub(crate) mod team_snapshot;
|
||||
pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_access_with_policy};
|
||||
pub(crate) use agent_env::{
|
||||
baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Canonical reserved-env-key list, `include!`d into BOTH `build.rs`
|
||||
// (compile-time rejection of baked `BUZZ_BUILD_AGENT_ENV` collisions) and
|
||||
// `managed_agents/env_vars.rs` (save-time validation and spawn-time
|
||||
// filtering). Build scripts cannot import from the crate, so sharing the
|
||||
// source via `include!` is what guarantees the build-time check and the
|
||||
// runtime filter use one identical list — zero drift surface. See
|
||||
// `commands/reconnect_hook_config.rs` for the same pattern.
|
||||
//
|
||||
// Keep this file dependency-free: no crate-internal imports, no external
|
||||
// crates. Both consumers compile it as-is.
|
||||
|
||||
/// Env var keys that Buzz sets itself and users must not override from
|
||||
/// the persona/agent env_vars UI. Three categories:
|
||||
///
|
||||
/// 1. **Identity / secrets** — overriding would swap the agent's nsec or
|
||||
/// leak credentials.
|
||||
/// 2. **Code-execution surface** — overriding the binary/args lets the
|
||||
/// user run arbitrary code as the agent process.
|
||||
/// 3. **Security gates** — overriding the respond-to mode/allowlist or
|
||||
/// relay URL would silently break the saved security settings (the UI
|
||||
/// shows owner-only while the running agent answers anyone, for
|
||||
/// example), or redirect the agent to an attacker-controlled relay.
|
||||
///
|
||||
/// This list is deliberately narrow — it only covers keys with security
|
||||
/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely
|
||||
/// overridable; those have dedicated UI fields but power users may want
|
||||
/// to bypass them.
|
||||
pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[
|
||||
// Identity / secrets.
|
||||
"BUZZ_PRIVATE_KEY",
|
||||
"NOSTR_PRIVATE_KEY",
|
||||
"BUZZ_AUTH_TAG",
|
||||
"BUZZ_API_TOKEN",
|
||||
"BUZZ_ACP_PRIVATE_KEY",
|
||||
"BUZZ_ACP_API_TOKEN",
|
||||
// Relay URL: overriding would let a malicious config redirect the
|
||||
// agent to an attacker-controlled relay.
|
||||
"BUZZ_RELAY_URL",
|
||||
// Code-execution surface: overriding would let the user run arbitrary
|
||||
// binaries/args as the agent process.
|
||||
"BUZZ_ACP_AGENT_COMMAND",
|
||||
"BUZZ_ACP_AGENT_ARGS",
|
||||
"BUZZ_ACP_MCP_COMMAND",
|
||||
// Control-plane parallelism: the Desktop resolves the effective
|
||||
// worker-pool size (applying any per-harness cap) and writes it into
|
||||
// launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the
|
||||
// harness cap and cause OpenClaw agents to spawn uncapped workers.
|
||||
"BUZZ_ACP_AGENTS",
|
||||
// Security gates: respond-to mode + allowlist + deployment allowlist +
|
||||
// legacy owner-only fallback. Overriding would make the running agent's
|
||||
// gate diverge from the saved/UI-visible settings.
|
||||
"BUZZ_ACP_RESPOND_TO",
|
||||
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
|
||||
"BUZZ_ACP_ALLOWED_RESPOND_TO",
|
||||
"BUZZ_ACP_AGENT_OWNER",
|
||||
// Stable agent identity used for git attribution and private-conversation
|
||||
// provenance must come from the managed-agent record, not user overrides.
|
||||
"BUZZ_ACP_DISPLAY_NAME",
|
||||
// Remote lifetime/presence policy: user env must not disable the
|
||||
// desktop/provider-owned bounds while the saved record still promises them.
|
||||
"BUZZ_ACP_EXIT_AFTER_INACTIVITY",
|
||||
"BUZZ_ACP_NO_PRESENCE",
|
||||
// Readiness handoff: desktop is the ONLY readiness source. A saved or
|
||||
// ambient env var must not be able to forge setup mode (NotReady) on a
|
||||
// Ready agent or suppress it (empty/stale payload) on a NotReady one.
|
||||
"BUZZ_ACP_SETUP_PAYLOAD",
|
||||
// Desktop ownership markers: these brand every spawned harness with the
|
||||
// launching Desktop instance. A user-supplied override would let a
|
||||
// definition masquerade as a different instance or fake the nonce used
|
||||
// for same-session sweep decisions.
|
||||
"BUZZ_MANAGED_AGENT",
|
||||
"BUZZ_MANAGED_AGENT_START_NONCE",
|
||||
];
|
||||
|
||||
pub(crate) fn is_reserved_env_key(key: &str) -> bool {
|
||||
RESERVED_ENV_KEYS
|
||||
.iter()
|
||||
.any(|reserved| reserved.eq_ignore_ascii_case(key))
|
||||
}
|
||||
@@ -16,9 +16,9 @@ use crate::{
|
||||
|
||||
mod path;
|
||||
pub(in crate::managed_agents) use path::build_augmented_path;
|
||||
pub(crate) use path::compose_path_entries;
|
||||
pub(crate) use path::should_skip_claude_executable;
|
||||
pub(crate) use path::should_use_inherited;
|
||||
pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited};
|
||||
|
||||
pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondToEnv};
|
||||
|
||||
mod metadata;
|
||||
pub(crate) use metadata::{
|
||||
@@ -33,8 +33,6 @@ pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair};
|
||||
mod sweep;
|
||||
pub(crate) use sweep::sweep_untracked_bundle_harnesses;
|
||||
|
||||
type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>);
|
||||
|
||||
mod process;
|
||||
#[cfg(test)]
|
||||
use process::{
|
||||
@@ -370,44 +368,7 @@ pub(crate) fn build_respond_to_env(
|
||||
record: &ManagedAgentRecord,
|
||||
owner_hex: Option<&str>,
|
||||
) -> Result<RespondToEnv, String> {
|
||||
// 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() {
|
||||
return Err(
|
||||
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
));
|
||||
|
||||
if record.respond_to == super::types::RespondTo::Allowlist {
|
||||
set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(",")));
|
||||
} else {
|
||||
remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST");
|
||||
}
|
||||
|
||||
// Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without
|
||||
// it the harness can't resolve the owner, and owner-dependent gate modes
|
||||
// would drop every event. Forwarding the workspace owner pubkey via
|
||||
// BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records
|
||||
// (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before.
|
||||
if record.auth_tag.is_none() {
|
||||
if let Some(owner) = owner_hex {
|
||||
set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string()));
|
||||
} else {
|
||||
remove.push("BUZZ_ACP_AGENT_OWNER");
|
||||
}
|
||||
} else {
|
||||
remove.push("BUZZ_ACP_AGENT_OWNER");
|
||||
}
|
||||
|
||||
Ok((set, remove))
|
||||
build_respond_to_env_with_policy(record, owner_hex, super::owner_only())
|
||||
}
|
||||
|
||||
pub(crate) fn configure_runtime_cli(
|
||||
@@ -1015,5 +976,8 @@ pub fn start_managed_agent_process(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_fixtures;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
use crate::managed_agents::types::{ManagedAgentRecord, RespondTo};
|
||||
|
||||
pub(super) const EXPECTED_ACCESS_ENV: &str = "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY";
|
||||
|
||||
pub(super) fn expected_owner_only() -> bool {
|
||||
match std::env::var(EXPECTED_ACCESS_ENV) {
|
||||
Ok(value) => value
|
||||
.parse::<bool>()
|
||||
.unwrap_or_else(|_| panic!("{EXPECTED_ACCESS_ENV} must be true or false")),
|
||||
Err(std::env::VarError::NotPresent)
|
||||
if !crate::managed_agents::owner_only_access_build() =>
|
||||
{
|
||||
false
|
||||
}
|
||||
Err(std::env::VarError::NotPresent) => {
|
||||
panic!("{EXPECTED_ACCESS_ENV} must be set for owner-only-access-build tests")
|
||||
}
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
panic!("{EXPECTED_ACCESS_ENV} must be valid UTF-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn expected_mode(oss_mode: &'static str) -> &'static str {
|
||||
if expected_owner_only() {
|
||||
"owner-only"
|
||||
} else {
|
||||
oss_mode
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a minimal record fixture for runtime tests.
|
||||
pub(super) fn fixture(
|
||||
respond_to: RespondTo,
|
||||
allowlist: Vec<String>,
|
||||
auth_tag: Option<String>,
|
||||
) -> ManagedAgentRecord {
|
||||
ManagedAgentRecord {
|
||||
pubkey: "p".into(),
|
||||
name: "n".into(),
|
||||
persona_id: None,
|
||||
private_key_nsec: "nsec1fake".into(),
|
||||
auth_tag,
|
||||
relay_url: "ws://localhost:3000".into(),
|
||||
avatar_url: None,
|
||||
acp_command: "buzz-acp".into(),
|
||||
agent_command: "goose".into(),
|
||||
agent_command_override: None,
|
||||
agent_args: vec![],
|
||||
mcp_command: String::new(),
|
||||
turn_timeout_seconds: 320,
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
parallelism: 1,
|
||||
system_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
persona_source_version: None,
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
start_on_app_launch: false,
|
||||
auto_restart_on_config_change: true,
|
||||
runtime_pid: None,
|
||||
backend: Default::default(),
|
||||
backend_agent_id: None,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
created_at: "now".into(),
|
||||
updated_at: "now".into(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
last_error_code: None,
|
||||
respond_to,
|
||||
respond_to_allowlist: allowlist,
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
shared: false,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
catalog_source: None,
|
||||
definition_respond_to: None,
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
@@ -117,73 +117,10 @@ fn unknown_command_returns_none() {
|
||||
|
||||
// ── build_respond_to_env tests ───────────────────────────────────────
|
||||
|
||||
use super::build_respond_to_env;
|
||||
use super::test_fixtures::{expected_mode, expected_owner_only, fixture};
|
||||
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
|
||||
/// fields read by `build_respond_to_env` matter here.
|
||||
fn fixture(
|
||||
respond_to: RespondTo,
|
||||
allowlist: Vec<String>,
|
||||
auth_tag: Option<String>,
|
||||
) -> ManagedAgentRecord {
|
||||
ManagedAgentRecord {
|
||||
pubkey: "p".into(),
|
||||
name: "n".into(),
|
||||
persona_id: None,
|
||||
private_key_nsec: "nsec1fake".into(),
|
||||
auth_tag,
|
||||
relay_url: "ws://localhost:3000".into(),
|
||||
avatar_url: None,
|
||||
acp_command: "buzz-acp".into(),
|
||||
agent_command: "goose".into(),
|
||||
agent_command_override: None,
|
||||
agent_args: vec![],
|
||||
mcp_command: String::new(),
|
||||
turn_timeout_seconds: 320,
|
||||
idle_timeout_seconds: None,
|
||||
max_turn_duration_seconds: None,
|
||||
parallelism: 1,
|
||||
system_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
persona_source_version: None,
|
||||
env_vars: std::collections::BTreeMap::new(),
|
||||
start_on_app_launch: false,
|
||||
auto_restart_on_config_change: true,
|
||||
runtime_pid: None,
|
||||
backend: Default::default(),
|
||||
backend_agent_id: None,
|
||||
provider_binary_path: None,
|
||||
team_id: None,
|
||||
persona_team_dir: None,
|
||||
persona_name_in_team: None,
|
||||
created_at: "now".into(),
|
||||
updated_at: "now".into(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
last_error_code: None,
|
||||
respond_to,
|
||||
respond_to_allowlist: allowlist,
|
||||
display_name: None,
|
||||
slug: None,
|
||||
runtime: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
shared: false,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
catalog_source: None,
|
||||
definition_respond_to: None,
|
||||
definition_respond_to_allowlist: Vec::new(),
|
||||
definition_parallelism: None,
|
||||
relay_mesh: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_env_owner_only_sets_mode_and_removes_others() {
|
||||
let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
|
||||
@@ -195,6 +132,18 @@ fn build_env_owner_only_sets_mode_and_removes_others() {
|
||||
);
|
||||
assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
||||
assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
||||
if expected_owner_only() {
|
||||
assert_eq!(
|
||||
set_map
|
||||
.get("BUZZ_ACP_ALLOWED_RESPOND_TO")
|
||||
.map(String::as_str),
|
||||
Some("owner-only")
|
||||
);
|
||||
assert!(!remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO"));
|
||||
} else {
|
||||
assert!(!set_map.contains_key("BUZZ_ACP_ALLOWED_RESPOND_TO"));
|
||||
assert!(remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO"));
|
||||
}
|
||||
// auth_tag is present → no AGENT_OWNER fallback fires.
|
||||
assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER"));
|
||||
}
|
||||
@@ -214,14 +163,19 @@ fn build_env_allowlist_sets_both_envs_and_joins() {
|
||||
let set_map: std::collections::HashMap<_, _> = set.into_iter().collect();
|
||||
assert_eq!(
|
||||
set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str),
|
||||
Some("allowlist")
|
||||
);
|
||||
assert_eq!(
|
||||
set_map
|
||||
.get("BUZZ_ACP_RESPOND_TO_ALLOWLIST")
|
||||
.map(String::as_str),
|
||||
Some(format!("{a},{b}").as_str()),
|
||||
Some(expected_mode("allowlist")),
|
||||
"runtime wrapper did not apply the declared build policy",
|
||||
);
|
||||
if expected_owner_only() {
|
||||
assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
||||
} else {
|
||||
assert_eq!(
|
||||
set_map
|
||||
.get("BUZZ_ACP_RESPOND_TO_ALLOWLIST")
|
||||
.map(String::as_str),
|
||||
Some(format!("{a},{b}").as_str()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -231,7 +185,30 @@ fn build_env_anyone_omits_allowlist_var() {
|
||||
let set_map: std::collections::HashMap<_, _> = set.into_iter().collect();
|
||||
assert_eq!(
|
||||
set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str),
|
||||
Some("anyone")
|
||||
Some(expected_mode("anyone")),
|
||||
"runtime wrapper did not apply the declared build policy",
|
||||
);
|
||||
assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
||||
assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_only_access_policy_overrides_stale_anyone_record_at_runtime() {
|
||||
let rec = fixture(RespondTo::Anyone, vec!["a".repeat(64)], 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"),
|
||||
"owner-only-access runtime env widened stale access",
|
||||
);
|
||||
assert_eq!(
|
||||
set_map
|
||||
.get("BUZZ_ACP_ALLOWED_RESPOND_TO")
|
||||
.map(String::as_str),
|
||||
Some("owner-only"),
|
||||
"owner-only-access runtime env omitted the owner-only guard",
|
||||
);
|
||||
assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
||||
assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
||||
@@ -271,8 +248,17 @@ fn build_env_rejects_corrupted_allowlist() {
|
||||
#[test]
|
||||
fn build_env_rejects_empty_allowlist_in_allowlist_mode() {
|
||||
let rec = fixture(RespondTo::Allowlist, vec![], Some("tag".into()));
|
||||
let err = build_respond_to_env(&rec, Some("owner")).unwrap_err();
|
||||
assert!(err.contains("at least one pubkey"));
|
||||
if expected_owner_only() {
|
||||
let (set, _) = build_respond_to_env(&rec, Some("owner")).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")
|
||||
);
|
||||
} else {
|
||||
let err = build_respond_to_env(&rec, Some("owner")).unwrap_err();
|
||||
assert!(err.contains("at least one pubkey"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── persona fixture helpers ─────────────────────────────────────────
|
||||
|
||||
@@ -164,7 +164,10 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
shown; when it *is* remote they picked that host from the selector
|
||||
themselves. Never synthesize a run location a surface doesn't have. Don't
|
||||
expose `respond-to`, `allowlist`, Nostr, or harness jargon in primary UI
|
||||
copy.
|
||||
copy. **The owner-only-access build capability is backend-independent.** When
|
||||
`getAgentAccessOwnerOnly()` is true, every managed agent's access control is
|
||||
locked to owner-only, including provider-backed agents. A provider backend
|
||||
does not prove remote execution and must never create a policy carve-out.
|
||||
|
||||
## The tests that enforce this
|
||||
|
||||
|
||||
@@ -946,7 +946,6 @@ export function useRuntimeFileConfigQuery(
|
||||
|
||||
export const bakedBuildEnvKeysQueryKey = ["baked-build-env-keys"] as const;
|
||||
export const bakedBuildEnvQueryKey = ["baked-build-env"] as const;
|
||||
|
||||
/**
|
||||
* Query safely displayable baked build env entries. The backend masks secrets,
|
||||
* so this is only used for inherited provider/model/effort labels.
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useStartManagedAgentMutation,
|
||||
useUpdateManagedAgentMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly";
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import type {
|
||||
ManagedAgent,
|
||||
@@ -63,9 +64,9 @@ import {
|
||||
type RuntimeModelProviderSelection,
|
||||
} from "./runtimeModelProviderSelection";
|
||||
import { AgentCreationPreview } from "./AgentCreationPreview";
|
||||
import { OwnerOnlyAccessField } from "./OwnerOnlyAccessField";
|
||||
import type { EnvVarsValue } from "./EnvVarsEditor";
|
||||
import { useRequiredCredentialState } from "./useRequiredCredentialState";
|
||||
import { CreateAgentRespondToField } from "./RespondToField";
|
||||
import { RunOnSummarySection } from "./RunOnSummarySection";
|
||||
import { PersonaDropdownField } from "./PersonaDropdownField";
|
||||
import {
|
||||
@@ -392,6 +393,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
|
||||
@@ -905,7 +909,6 @@ export function AgentInstanceEditDialog({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{/* Agent name */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
@@ -933,17 +936,14 @@ export function AgentInstanceEditDialog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Who can send instructions */}
|
||||
<CreateAgentRespondToField
|
||||
<OwnerOnlyAccessField
|
||||
accessLocked={agentAccessOwnerOnly === true}
|
||||
allowlist={respondToAllowlist}
|
||||
disabled={updateMutation.isPending}
|
||||
mode={respondTo}
|
||||
onAllowlistChange={setRespondToAllowlist}
|
||||
onModeChange={setRespondTo}
|
||||
variant="persona"
|
||||
/>
|
||||
|
||||
<RunOnSummarySection backend={agent.backend} />
|
||||
|
||||
{/* Provider (runtime) */}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { RespondToMode } from "@/shared/api/types";
|
||||
import {
|
||||
CreateAgentRespondToField,
|
||||
OWNER_ONLY_ACCESS_DISABLED_REASON,
|
||||
} from "./RespondToField";
|
||||
|
||||
export function OwnerOnlyAccessField({
|
||||
accessLocked,
|
||||
allowlist,
|
||||
disabled,
|
||||
mode,
|
||||
onAllowlistChange,
|
||||
onModeChange,
|
||||
}: {
|
||||
accessLocked: boolean;
|
||||
allowlist: string[];
|
||||
disabled: boolean;
|
||||
mode: RespondToMode;
|
||||
onAllowlistChange: (allowlist: string[]) => void;
|
||||
onModeChange: (mode: RespondToMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<CreateAgentRespondToField
|
||||
allowlist={accessLocked ? [] : allowlist}
|
||||
disabled={disabled || accessLocked}
|
||||
disabledReason={
|
||||
accessLocked ? OWNER_ONLY_ACCESS_DISABLED_REASON : undefined
|
||||
}
|
||||
mode={accessLocked ? "owner-only" : mode}
|
||||
onAllowlistChange={onAllowlistChange}
|
||||
onModeChange={onModeChange}
|
||||
variant="persona"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import * as React from "react";
|
||||
import { useAgentAccessOwnerOnlyQuery } from "../useAgentAccessOwnerOnly";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
|
||||
import { CreateAgentRespondToField } from "./RespondToField";
|
||||
import {
|
||||
CreateAgentRespondToField,
|
||||
OWNER_ONLY_ACCESS_DISABLED_REASON,
|
||||
} from "./RespondToField";
|
||||
import type { PersonaBehaviorDraft } from "./personaBehaviorDraft";
|
||||
import {
|
||||
isBuzzAgentRuntime,
|
||||
@@ -83,6 +87,11 @@ export function PersonaAdvancedFields({
|
||||
*/
|
||||
selectedRuntime?: AcpRuntimeCatalogEntry;
|
||||
}) {
|
||||
const { data: agentAccessOwnerOnly = false } = useAgentAccessOwnerOnlyQuery();
|
||||
const respondToMode = agentAccessOwnerOnly
|
||||
? "owner-only"
|
||||
: (behaviorDraft.respondTo ?? "owner-only");
|
||||
|
||||
// Numeric tuning descriptors — gate on catalog status so that loading/error
|
||||
// never collapses to "no controls": keys stay visible as generic rows.
|
||||
const numericDescriptors = React.useMemo(
|
||||
@@ -126,9 +135,12 @@ export function PersonaAdvancedFields({
|
||||
return (
|
||||
<div className="space-y-5 pt-2">
|
||||
<CreateAgentRespondToField
|
||||
allowlist={behaviorDraft.respondToAllowlist}
|
||||
disabled={disabled}
|
||||
mode={behaviorDraft.respondTo ?? "owner-only"}
|
||||
allowlist={agentAccessOwnerOnly ? [] : behaviorDraft.respondToAllowlist}
|
||||
disabled={disabled || agentAccessOwnerOnly}
|
||||
disabledReason={
|
||||
agentAccessOwnerOnly ? OWNER_ONLY_ACCESS_DISABLED_REASON : undefined
|
||||
}
|
||||
mode={respondToMode}
|
||||
onAllowlistChange={(allowlist) =>
|
||||
onBehaviorDraftChange({
|
||||
...behaviorDraft,
|
||||
|
||||
@@ -39,6 +39,12 @@ import type { PersonaDropdownOption } from "./agentConfigOptions";
|
||||
* than an explanation, and stays one sentence — Only me already owns the line
|
||||
* below the control.
|
||||
*
|
||||
* The line below Only me says "Only you and your agents", because the harness
|
||||
* gate admits the owner and every verified same-owner agent, not the owner
|
||||
* alone (see `managed_agents/access_policy.rs`). The dropdown label stays
|
||||
* "Only me": it is the audience the user picks, and it has meant this since
|
||||
* before agents could instruct each other.
|
||||
*
|
||||
* Which machine and stakes it names follow the optional `runLocation` prop, and
|
||||
* an unknown location falls back to the local wording rather than hedging with
|
||||
* "computer or server" — see `lib/agentAccessWarning.ts` for the copy and the
|
||||
@@ -72,6 +78,9 @@ const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [
|
||||
{ label: "Selected people", value: "allowlist" },
|
||||
];
|
||||
|
||||
export const OWNER_ONLY_ACCESS_DISABLED_REASON =
|
||||
"This build disallows changing this setting.";
|
||||
|
||||
export function CreateAgentRespondToField({
|
||||
mode,
|
||||
allowlist,
|
||||
@@ -79,6 +88,7 @@ export function CreateAgentRespondToField({
|
||||
onAllowlistChange,
|
||||
ownerPubkey,
|
||||
disabled,
|
||||
disabledReason,
|
||||
variant,
|
||||
runLocation,
|
||||
}: {
|
||||
@@ -93,6 +103,8 @@ export function CreateAgentRespondToField({
|
||||
*/
|
||||
ownerPubkey?: string | null;
|
||||
disabled?: boolean;
|
||||
/** Explanation shown when this access control is unavailable. */
|
||||
disabledReason?: string;
|
||||
/** When "persona", uses PersonaDropdownField styling to match the persona dialog. */
|
||||
variant?: "default" | "persona";
|
||||
/**
|
||||
@@ -219,10 +231,18 @@ export function CreateAgentRespondToField({
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{disabledReason ? (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="agent-respond-to-disabled-reason"
|
||||
>
|
||||
{disabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
{mode === "anyone" ? accessWarning : null}
|
||||
{mode === "owner-only" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only you can send instructions.
|
||||
Only you and your agents can send instructions.
|
||||
</p>
|
||||
) : null}
|
||||
{mode === "allowlist" ? (
|
||||
|
||||
@@ -55,6 +55,16 @@ test("the warning copy comes from the shared helper, not inline text", () => {
|
||||
assert.match(collapsedSource, /<p aria-live="polite"[^>]*> \{warningText\}/);
|
||||
});
|
||||
|
||||
test("the Only me line names the owner's agents, not the owner alone", () => {
|
||||
// The harness gate admits the owner and every verified same-owner agent
|
||||
// (`managed_agents/access_policy.rs`), and the built-in Welcome team depends
|
||||
// on that, so a line promising the owner alone would overstate the boundary.
|
||||
assert.match(
|
||||
collapsedSource,
|
||||
/mode === "owner-only" \? \( <p[^>]*> Only you and your agents can send instructions\./,
|
||||
);
|
||||
});
|
||||
|
||||
test("primary respond-to copy does not expose implementation jargon", () => {
|
||||
const primaryFieldSource = respondToFieldSource.slice(
|
||||
respondToFieldSource.indexOf('data-testid="agent-respond-to"'),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* React hook: report whether this build forces owner-only agent access.
|
||||
*
|
||||
* The value is baked at build time, so it cannot change while the app runs.
|
||||
* The query key is stable and the result never goes stale, which keeps one
|
||||
* fetch per QueryClient lifetime and gives every caller the same answer.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getAgentAccessOwnerOnly } from "@/shared/api/tauriAgentAccess";
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useUpdateManagedAgentMutation } from "@/features/agents/hooks";
|
||||
import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly";
|
||||
import { runLocationForBackend } from "@/features/agents/lib/agentAccessWarning";
|
||||
import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField";
|
||||
import {
|
||||
CreateAgentRespondToField,
|
||||
OWNER_ONLY_ACCESS_DISABLED_REASON,
|
||||
} from "@/features/agents/ui/RespondToField";
|
||||
import type { ManagedAgent, RespondToMode } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
@@ -25,6 +29,10 @@ export function EditRespondToDialog({
|
||||
open: boolean;
|
||||
}) {
|
||||
const updateMutation = useUpdateManagedAgentMutation();
|
||||
const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({
|
||||
enabled: open,
|
||||
});
|
||||
const accessLocked = agentAccessOwnerOnly === true;
|
||||
const [respondTo, setRespondTo] = React.useState<RespondToMode>("owner-only");
|
||||
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
|
||||
[],
|
||||
@@ -61,9 +69,12 @@ export function EditRespondToDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CreateAgentRespondToField
|
||||
allowlist={respondToAllowlist}
|
||||
disabled={updateMutation.isPending}
|
||||
mode={respondTo}
|
||||
allowlist={accessLocked ? [] : respondToAllowlist}
|
||||
disabled={updateMutation.isPending || accessLocked}
|
||||
disabledReason={
|
||||
accessLocked ? OWNER_ONLY_ACCESS_DISABLED_REASON : undefined
|
||||
}
|
||||
mode={accessLocked ? "owner-only" : respondTo}
|
||||
onAllowlistChange={setRespondToAllowlist}
|
||||
onModeChange={setRespondTo}
|
||||
ownerPubkey={currentPubkey}
|
||||
@@ -84,7 +95,9 @@ export function EditRespondToDialog({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!respondToValid || updateMutation.isPending}
|
||||
disabled={
|
||||
!respondToValid || updateMutation.isPending || accessLocked
|
||||
}
|
||||
onClick={() => void handleSave()}
|
||||
size="sm"
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
pickWelcomeGuideAgentForRelay,
|
||||
pickWelcomeTeamStarterAgentForRelay,
|
||||
welcomeStarterRuntimeUpdate,
|
||||
welcomeTeammateAccessUpdate,
|
||||
welcomeTeammateHasExpectedAccess,
|
||||
WELCOME_GUIDE_AGENT_NAME,
|
||||
WELCOME_GUIDE_PERSONA_ID,
|
||||
WELCOME_TEAM_ID,
|
||||
@@ -379,3 +381,74 @@ test("starter matching prefers running, then deployed instances", () => {
|
||||
deployed,
|
||||
);
|
||||
});
|
||||
|
||||
test("owner-only-access policy accepts local Welcome teammates", () => {
|
||||
const teammate = makeAgent({
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
});
|
||||
assert.equal(welcomeTeammateHasExpectedAccess(teammate, PUB_B, true), true);
|
||||
assert.equal(welcomeTeammateHasExpectedAccess(teammate, PUB_B, false), false);
|
||||
});
|
||||
|
||||
test("access remediation converges for an upgraded owner-only install", () => {
|
||||
// Pre-existing installs allowlisted the lead. An owner-only build must move
|
||||
// them to owner-only, and the write it makes must satisfy the predicate, so
|
||||
// the next provisioning pass makes no further write.
|
||||
const allowlisted = makeAgent({
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [PUB_B],
|
||||
});
|
||||
const update = welcomeTeammateAccessUpdate(allowlisted, PUB_B, true);
|
||||
assert.deepEqual(update, {
|
||||
pubkey: PUB_A,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
});
|
||||
const remediated = makeAgent({
|
||||
respondTo: update.respondTo,
|
||||
respondToAllowlist: update.respondToAllowlist,
|
||||
});
|
||||
assert.equal(welcomeTeammateHasExpectedAccess(remediated, PUB_B, true), true);
|
||||
assert.equal(welcomeTeammateAccessUpdate(remediated, PUB_B, true), null);
|
||||
});
|
||||
|
||||
test("access remediation allowlists the lead when the build is not owner-only", () => {
|
||||
const ownerOnly = makeAgent({
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
});
|
||||
const update = welcomeTeammateAccessUpdate(ownerOnly, PUB_B, false);
|
||||
assert.deepEqual(update, {
|
||||
pubkey: PUB_A,
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [PUB_B],
|
||||
});
|
||||
const remediated = makeAgent({
|
||||
respondTo: update.respondTo,
|
||||
respondToAllowlist: update.respondToAllowlist,
|
||||
});
|
||||
assert.equal(
|
||||
welcomeTeammateHasExpectedAccess(remediated, PUB_B, false),
|
||||
true,
|
||||
);
|
||||
assert.equal(welcomeTeammateAccessUpdate(remediated, PUB_B, false), null);
|
||||
});
|
||||
|
||||
test("access remediation skips a teammate that already allows the lead", () => {
|
||||
const allowlisted = makeAgent({
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [PUB_B, PUB_C],
|
||||
});
|
||||
assert.equal(welcomeTeammateAccessUpdate(allowlisted, PUB_B, false), null);
|
||||
});
|
||||
|
||||
test("owner-only-access policy accepts provider Welcome teammates", () => {
|
||||
const teammate = makeAgent({
|
||||
backend: { type: "provider", id: "remote", config: {} },
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
});
|
||||
assert.equal(welcomeTeammateHasExpectedAccess(teammate, PUB_B, true), true);
|
||||
assert.equal(welcomeTeammateHasExpectedAccess(teammate, PUB_B, false), false);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
listManagedAgents,
|
||||
updateManagedAgent,
|
||||
} from "@/shared/api/tauri";
|
||||
import { getAgentAccessOwnerOnly } from "@/shared/api/tauriAgentAccess";
|
||||
import { getGlobalAgentConfig } from "@/shared/api/tauriGlobalAgentConfig";
|
||||
import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas";
|
||||
import type {
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
AgentPersona,
|
||||
CreateManagedAgentInput,
|
||||
ManagedAgent,
|
||||
UpdateManagedAgentInput,
|
||||
} from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
@@ -260,6 +262,58 @@ export function welcomeStarterRuntimeUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
export function welcomeTeammateHasExpectedAccess(
|
||||
teammate: ManagedAgent,
|
||||
leadPubkey: string,
|
||||
agentAccessOwnerOnly: boolean,
|
||||
) {
|
||||
if (agentAccessOwnerOnly) {
|
||||
// Welcome teammates are created owner-only, and the lead remains authorized
|
||||
// as a NIP-OA-verified sibling because every Welcome agent shares one owner.
|
||||
return (
|
||||
teammate.respondTo === "owner-only" &&
|
||||
teammate.respondToAllowlist.length === 0
|
||||
);
|
||||
}
|
||||
return (
|
||||
teammate.respondTo === "allowlist" &&
|
||||
teammate.respondToAllowlist.some(
|
||||
(pubkey) => normalizePubkey(pubkey) === normalizePubkey(leadPubkey),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The access write that moves a Welcome teammate to the state this build
|
||||
* expects, or null when it is already there. The remediation target must track
|
||||
* {@link welcomeTeammateHasExpectedAccess}: writing `allowlist:[lead]` in an
|
||||
* owner-only build would fail the predicate again on the next provisioning
|
||||
* pass, so an upgraded install with pre-existing allowlisted teammates would
|
||||
* rewrite the same rejected state forever and keep restarting them.
|
||||
*/
|
||||
export function welcomeTeammateAccessUpdate(
|
||||
teammate: ManagedAgent,
|
||||
leadPubkey: string,
|
||||
agentAccessOwnerOnly: boolean,
|
||||
): UpdateManagedAgentInput | null {
|
||||
if (
|
||||
welcomeTeammateHasExpectedAccess(teammate, leadPubkey, agentAccessOwnerOnly)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return agentAccessOwnerOnly
|
||||
? {
|
||||
pubkey: teammate.pubkey,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
}
|
||||
: {
|
||||
pubkey: teammate.pubkey,
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [leadPubkey],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the complete built-in Welcome Team is ready for kickoff.
|
||||
* The team itself is Rust-seeded; this only activates personas, creates any
|
||||
@@ -271,11 +325,13 @@ async function provisionWelcomeTeam(
|
||||
): Promise<WelcomeTeamAgents> {
|
||||
const existingAgents = await listManagedAgents();
|
||||
await ensureWelcomeTeamPersonasActive();
|
||||
const [personas, runtimeCatalog, globalConfig] = await Promise.all([
|
||||
listPersonas(),
|
||||
discoverAcpRuntimes(),
|
||||
getGlobalAgentConfig(),
|
||||
]);
|
||||
const [personas, runtimeCatalog, globalConfig, agentAccessOwnerOnly] =
|
||||
await Promise.all([
|
||||
listPersonas(),
|
||||
discoverAcpRuntimes(),
|
||||
getGlobalAgentConfig(),
|
||||
getAgentAccessOwnerOnly(),
|
||||
]);
|
||||
const personasById = new Map(
|
||||
personas.map((persona) => [persona.id, persona]),
|
||||
);
|
||||
@@ -322,17 +378,13 @@ async function provisionWelcomeTeam(
|
||||
const leadPubkey = lead.pubkey;
|
||||
for (const index of [1, 2] as const) {
|
||||
const teammate = welcomeAgents[index];
|
||||
const alreadyAllowsLead =
|
||||
teammate.respondTo === "allowlist" &&
|
||||
teammate.respondToAllowlist.some(
|
||||
(pubkey) => normalizePubkey(pubkey) === normalizePubkey(leadPubkey),
|
||||
);
|
||||
if (!alreadyAllowsLead) {
|
||||
const updated = await updateManagedAgent({
|
||||
pubkey: teammate.pubkey,
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [leadPubkey],
|
||||
});
|
||||
const accessUpdate = welcomeTeammateAccessUpdate(
|
||||
teammate,
|
||||
leadPubkey,
|
||||
agentAccessOwnerOnly,
|
||||
);
|
||||
if (accessUpdate) {
|
||||
const updated = await updateManagedAgent(accessUpdate);
|
||||
welcomeAgents[index] = updated.agent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,47 @@ test("running teammates restart when their allowlist does not include the lead",
|
||||
);
|
||||
});
|
||||
|
||||
test("owner-only-access policy does not restart running local and provider teammates", () => {
|
||||
for (const backend of [
|
||||
{ type: "local" },
|
||||
{ type: "provider", id: "remote", config: {} },
|
||||
]) {
|
||||
assert.equal(
|
||||
welcomeTeammateNeedsRestart(
|
||||
{
|
||||
...honey,
|
||||
backend,
|
||||
status: "running",
|
||||
needsRestart: false,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
},
|
||||
fizz.pubkey,
|
||||
true,
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("owner-only-access policy still restarts running teammates for runtime changes", () => {
|
||||
assert.equal(
|
||||
welcomeTeammateNeedsRestart(
|
||||
{
|
||||
...honey,
|
||||
backend: { type: "local" },
|
||||
status: "running",
|
||||
needsRestart: true,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
},
|
||||
fizz.pubkey,
|
||||
true,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("opener keeps partial-readiness warm and mentions only online teammates", () => {
|
||||
const agentSet = { lead: fizz, teammates: [honey, bumble] };
|
||||
const introTeammates = selectWelcomeKickoffIntroTeammates(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useAcpRuntimesQuery,
|
||||
useManagedAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly";
|
||||
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
|
||||
import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
pickWelcomeTeamStarterAgentForRelay,
|
||||
WELCOME_TEAM_STARTERS,
|
||||
type WelcomeTeamStarterDefinition,
|
||||
welcomeTeammateHasExpectedAccess,
|
||||
} from "@/features/onboarding/welcomeGuide";
|
||||
import { isWelcomeChannel } from "@/features/onboarding/welcome";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
@@ -358,13 +360,15 @@ async function markerExists(channelId: string, marker: string) {
|
||||
export function welcomeTeammateNeedsRestart(
|
||||
agent: ManagedAgent,
|
||||
leadPubkey: string,
|
||||
agentAccessOwnerOnly = false,
|
||||
) {
|
||||
return (
|
||||
agent.status === "running" &&
|
||||
(agent.needsRestart ||
|
||||
agent.respondTo !== "allowlist" ||
|
||||
!agent.respondToAllowlist.some(
|
||||
(pubkey) => normalizePubkey(pubkey) === normalizePubkey(leadPubkey),
|
||||
!welcomeTeammateHasExpectedAccess(
|
||||
agent,
|
||||
leadPubkey,
|
||||
agentAccessOwnerOnly,
|
||||
))
|
||||
);
|
||||
}
|
||||
@@ -496,6 +500,8 @@ export function useWelcomeKickoff(
|
||||
const { activeCommunity } = useCommunities();
|
||||
const runtimesQuery = useAcpRuntimesQuery();
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const agentAccessOwnerOnlyQuery = useAgentAccessOwnerOnlyQuery();
|
||||
const agentAccessOwnerOnly = agentAccessOwnerOnlyQuery.data;
|
||||
const { globalConfig, isLoading: configLoading } = useGlobalAgentConfig();
|
||||
const channelId = activeChannel?.id ?? null;
|
||||
const isActiveWelcome = isWelcomeChannel(activeChannel);
|
||||
@@ -556,7 +562,8 @@ export function useWelcomeKickoff(
|
||||
!channelId ||
|
||||
!isActiveWelcome ||
|
||||
configLoading ||
|
||||
runtimesQuery.isPending
|
||||
runtimesQuery.isPending ||
|
||||
agentAccessOwnerOnly === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -610,7 +617,11 @@ export function useWelcomeKickoff(
|
||||
);
|
||||
if (
|
||||
isTeammate &&
|
||||
welcomeTeammateNeedsRestart(agent, resolvedAgentSet.lead.pubkey)
|
||||
welcomeTeammateNeedsRestart(
|
||||
agent,
|
||||
resolvedAgentSet.lead.pubkey,
|
||||
agentAccessOwnerOnly,
|
||||
)
|
||||
) {
|
||||
return restartWelcomeTeammate(agent, {
|
||||
onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey),
|
||||
@@ -687,6 +698,7 @@ export function useWelcomeKickoff(
|
||||
})();
|
||||
}, [
|
||||
activeCommunity?.relayUrl,
|
||||
agentAccessOwnerOnly,
|
||||
channelId,
|
||||
configLoading,
|
||||
isActiveWelcome,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
|
||||
export const getAgentAccessOwnerOnly = () =>
|
||||
invokeTauri<boolean>("agent_access_owner_only");
|
||||
@@ -444,6 +444,8 @@ type E2eConfig = {
|
||||
model: string | null;
|
||||
preferred_runtime?: string | null;
|
||||
};
|
||||
/** Explicit owner-only agent-access capability; independent of baked defaults. */
|
||||
ownerOnlyAccessBuild?: boolean;
|
||||
/** File-layer config returned by runtime id. */
|
||||
runtimeFileConfigs?: Record<string, RuntimeFileConfigSubset | null>;
|
||||
/** Baked build env returned by the display and key-name Tauri commands. */
|
||||
@@ -12340,6 +12342,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?.ownerOnlyAccessBuild ?? false;
|
||||
case "update_managed_agent":
|
||||
return handleUpdateManagedAgent(
|
||||
payload as Parameters<typeof handleUpdateManagedAgent>[0],
|
||||
|
||||
@@ -78,7 +78,82 @@ async function pickDropdownOption(
|
||||
await page.getByRole("menuitemradio", { name: optionName }).click();
|
||||
}
|
||||
|
||||
test.describe("agent definition dialog", () => {
|
||||
test("owner-only-access build shows disabled agent access with an explanation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
ownerOnlyAccessBuild: true,
|
||||
bakedBuildEnv: BAKED_DEFAULTS,
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByTestId("new-agent-card").click();
|
||||
await page.getByRole("menuitem", { name: "Create agent" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Advanced", exact: true }).click();
|
||||
|
||||
await expect(dialog.getByTestId("agent-respond-to")).toBeVisible();
|
||||
await expect(dialog.locator("#agent-respond-to")).toBeDisabled();
|
||||
await expect(dialog.locator("#agent-respond-to")).toContainText(
|
||||
"Only me (default)",
|
||||
);
|
||||
await expect(
|
||||
dialog.getByTestId("agent-respond-to-disabled-reason"),
|
||||
).toHaveText("This build disallows changing this setting.");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("edit agent dialog", () => {
|
||||
test("owner-only-access build shows a disabled owner-only access control with an explanation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
ownerOnlyAccessBuild: true,
|
||||
bakedBuildEnv: BAKED_DEFAULTS,
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: AGENT_PUBKEY,
|
||||
name: AGENT_NAME,
|
||||
status: "stopped",
|
||||
channelNames: ["agents"],
|
||||
respondTo: "anyone",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await openEditDialog(page);
|
||||
|
||||
const accessControl = page.getByTestId("agent-respond-to");
|
||||
await expect(accessControl).toBeVisible();
|
||||
await expect(page.locator("#agent-respond-to")).toBeDisabled();
|
||||
await expect(page.locator("#agent-respond-to")).toContainText(
|
||||
"Only me (default)",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("agent-respond-to-disabled-reason"),
|
||||
).toHaveText("This build disallows changing this setting.");
|
||||
});
|
||||
|
||||
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,
|
||||
}) => {
|
||||
|
||||
@@ -430,6 +430,7 @@ type MockBridgeOptions = {
|
||||
model: string | null;
|
||||
preferred_runtime?: string | null;
|
||||
};
|
||||
ownerOnlyAccessBuild?: boolean;
|
||||
/** File-layer config returned by runtime id. */
|
||||
runtimeFileConfigs?: Record<
|
||||
string,
|
||||
|
||||
Reference in New Issue
Block a user