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 |
| --- | --- |
| ![Editable agent access control before the
change](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4053/4053-before.png)
| ![Agent access locked to Only me in an internal
build](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4053/4053-after-v2.png)
|

## 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:
Tom Brow
2026-08-05 19:58:24 -06:00
committed by GitHub
co-authored by npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp Amp
parent 5677e4ca05
commit 16cc3de6d6
37 changed files with 1366 additions and 252 deletions
+17 -4
View File
@@ -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)
+24
View File
@@ -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);
}
}
+1 -1
View File
@@ -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"
);
}
+2
View File
@@ -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.
+1
View File
@@ -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 ─────────────────────────────────────────
+4 -1
View File
@@ -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
-1
View File
@@ -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);
});
+68 -16
View File
@@ -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");
+4
View File
@@ -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],
+75
View File
@@ -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,
}) => {
+1
View File
@@ -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,