fix(swarm): route declared env sources and default private workdirs

A {env: VAR} source reached every harness, not only the agents that
declare it: sibling processes inherited a per-agent credential from the
parent environment. Remove every declared source (agent env, auth_tag,
and raw defaults, including disabled agents) from the inherited
environment; declarers still receive the value under the declared name.

Agents defaulted to the config directory as workdir, which is where
generated keys/ live, so each shell-capable agent started beside its
siblings' private keys. Default to workspaces/<name> beside the config,
created 0700 on start and reported by validate. The default now lives
only in the plan.

README: state exactly what shutdown cleans up. Swarm kills the harness's
own process group; buzz-acp moves agents into their own groups and stops
them on SIGTERM, so a forced shutdown or harness crash relies on host
containment.

Also build Secret directly and format executable().

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
This commit is contained in:
Eva
2026-09-23 13:54:27 -04:00
parent 9ccd650409
commit fed802fa7d
9 changed files with 166 additions and 43 deletions

View File

@@ -53,7 +53,8 @@ Missing agent keys are generated once and saved as `keys/<name>.nsec` beside
the config, with mode `0600`. Subsequent runs reuse them. Mount that directory
on persistent storage in ephemeral containers, or supply keys from your secret
manager. `validate` checks swarm wiring, identities, and the harness executable;
it writes no keys and starts nothing. Generated preview identities are temporary.
it writes no keys or directories and starts nothing. Generated preview
identities are temporary.
The runtime validates its own model and ACP settings when started.
The owner must have access to every target relay, and each relay must support
@@ -71,7 +72,7 @@ See [buzz-swarm.example.yaml](buzz-swarm.example.yaml). Agent fields replace
| `owner.nsec`, `owner.conditions` | Owner key and optional NIP-OA conditions |
| `name`, `nsec`, `auth_tag`, `enabled` | Per-agent identity and selection |
| `relays` | Non-empty relay list, shared or per agent |
| `workdir` | Working directory, defaulting to the config directory |
| `workdir` | Existing working directory; default `workspaces/<name>` beside the config, created `0700` on start |
| `harness` | Executable path or name on PATH, default `buzz-acp` |
| `restart`, `max_restarts` | `on-failure` (default), `always`, or `never`; default budget 10 |
| `env` | Existing ACP/agent options and credentials |
@@ -100,9 +101,11 @@ Swarm defaults `BUZZ_ACP_AGENT_COMMAND` to `buzz-agent` and
ACP runtime; an empty MCP command disables it. See `buzz-acp --help` and
[buzz-agent](../buzz-agent/README.md) for runtime options. Ambient `BUZZ_ACP_*`
settings are cleared; use YAML `env` to configure them explicitly. Model/provider
environment variables are inherited. Swarm owns relay and identity variables;
YAML `env` cannot override them. Owner and sibling agent key source variables
are removed before launching each harness. Processes sharing an OS user still
environment variables are inherited unless the file names them as a source.
Swarm owns relay and identity variables; YAML `env` cannot override them. Every
`{env: VARIABLE}` source in the file, including in `defaults` and disabled
agents, is removed from the inherited environment; only agents that declare it
receive its value, under the declared name. Processes sharing an OS user still
share that user's filesystem access.
An existing agent key and pre-signed `auth_tag` can be supplied without `owner`,
@@ -119,11 +122,13 @@ second backoff; the restart budget resets after 60 seconds of uptime. Clean
exits stay ended under the default policy. Final failures produce exit code 1.
Ctrl-C or SIGTERM stops harness process groups with 15 seconds of grace
(`start --grace SECONDS`). A second signal forces shutdown. Remaining group
members are killed before their leader is reaped, including on natural exit.
Custom runtimes must manage subprocesses they move into other process groups;
container/service containment is the backstop. Workdirs are ordinary directories,
not sandboxes.
(`start --grace SECONDS`). A second signal forces shutdown. Remaining members of
the harness's own group are killed before its leader is reaped, including on
natural exit. Swarm does not track processes a harness moves into other groups.
The default `buzz-acp` does this for each agent and stops those groups itself on
SIGTERM; after a forced shutdown or a harness crash they can outlive Swarm. Run
Swarm under container or service containment when that matters. Workdirs are
ordinary directories, not sandboxes.
```sh
cargo test -p buzz-swarm

View File

@@ -1,8 +1,9 @@
use std::os::unix::fs::DirBuilderExt;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use anyhow::Result;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use crate::config::{expand_tilde, SwarmFile, DEFAULT_CONFIG_FILE};
@@ -71,6 +72,9 @@ async fn run(cli: Cli) -> Result<bool> {
key.path.display()
);
}
for workdir in &plan.new_workdirs {
println!("{}: created on start", workdir.display());
}
Ok(false)
}
Command::Start { grace } => {
@@ -78,6 +82,13 @@ async fn run(cli: Cli) -> Result<bool> {
for key in &plan.generated_keys {
key.persist()?;
}
for workdir in &plan.new_workdirs {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(workdir)
.with_context(|| format!("creating workdir {}", workdir.display()))?;
}
let summary = supervisor::run(
plan,
Options {

View File

@@ -147,6 +147,10 @@ impl SecretRef {
pub struct Secret(Zeroizing<String>);
impl Secret {
pub fn new(value: String) -> Self {
Self(Zeroizing::new(value))
}
pub fn expose(&self) -> &str {
&self.0
}
@@ -238,9 +242,6 @@ impl SwarmFile {
if let Some(path) = &mut spec.workdir {
*path = self.directory.join(expand_tilde(path, env));
}
if spec.workdir.is_none() && !self.directory.as_os_str().is_empty() {
spec.workdir = Some(self.directory.clone());
}
if let Some(command) = &mut spec.harness {
if command.contains('/') || command.starts_with('~') {
*command = self
@@ -254,11 +255,15 @@ impl SwarmFile {
Ok(specs)
}
/// `defaults:` on its own, including values every agent overrides.
pub fn defaults(&self) -> Result<Spec> {
serde_yaml::from_value(Value::Mapping(self.defaults.clone())).context("parsing `defaults:`")
}
/// Type-check `defaults:` on its own, then produce one fully merged `Spec`
/// per agent entry (agent keys win; `env:` maps merge key-wise).
pub fn specs(&self) -> Result<Vec<Spec>> {
let defaults: Spec = serde_yaml::from_value(Value::Mapping(self.defaults.clone()))
.context("parsing `defaults:`")?;
let defaults = self.defaults()?;
for (field, present) in [
("name", defaults.name.is_some()),
("nsec", defaults.nsec.is_some()),

View File

@@ -132,7 +132,10 @@ agents:
matches!(file.owner(&env).unwrap().nsec, SecretRef::File(path) if path == directory.path().join("owner.nsec"))
);
let specs = file.resolved_specs(&env).unwrap();
assert_eq!(specs[0].workdir.as_deref(), Some(directory.path()));
assert_eq!(
specs[0].workdir, None,
"the plan owns the per-agent default"
);
assert_eq!(
Path::new(specs[0].harness.as_deref().unwrap()),
directory.path().join("bin/harness")

View File

@@ -77,7 +77,7 @@ impl Identity {
)
.context("`auth_tag` does not authorize this agent key")?;
Ok(Self {
secret: SecretRef::Literal(keys.secret_key().to_bech32()?).resolve(env)?,
secret: Secret::new(keys.secret_key().to_bech32()?),
pubkey: keys.public_key().to_hex(),
owner: delegated_owner.to_hex(),
auth_tag,

View File

@@ -45,6 +45,8 @@ pub struct Plan {
pub owner_pubkey: String,
pub agents: Vec<AgentPlan>,
pub generated_keys: Vec<GeneratedKey>,
/// Default workdirs that do not exist yet; created on start.
pub new_workdirs: Vec<PathBuf>,
}
impl Plan {
@@ -70,14 +72,26 @@ impl Plan {
.filter_map(|source| source.env_key())
.map(str::to_owned)
.collect();
// A declared source reaches only the agents that declare it, under the
// name they declare; no child inherits it ambiently. Raw defaults count
// even when every agent overrides them.
let defaults = file.defaults()?;
let declared_sources = specs
.iter()
.chain([&defaults])
.flat_map(|spec| spec.auth_tag.iter().chain(spec.env.values()))
.filter_map(|source| source.env_key())
.map(str::to_owned);
let env_remove: Vec<_> = env
.keys()
.filter(|key| key.starts_with("BUZZ_ACP_") || RESERVED.contains(key))
.map(str::to_owned)
.chain(private_sources.iter().cloned())
.chain(declared_sources)
.collect();
let mut agents = Vec::new();
let mut generated_keys = Vec::new();
let mut new_workdirs = Vec::new();
let mut seen_names = BTreeSet::new();
let mut seen_connections = BTreeSet::new();
let mut selected = BTreeSet::new();
@@ -111,15 +125,22 @@ impl Plan {
None => owner_pubkey = Some(identity.owner.clone()),
}
let program = executable(spec.harness.as_deref().unwrap_or("buzz-acp"), env)?;
let workdir = spec
.workdir
.clone()
.unwrap_or_else(|| file.directory.clone());
// Default to a private directory per agent, away from `keys/`.
// Only that default may be created; an explicit workdir must exist.
let workdir = spec.workdir.clone().unwrap_or_else(|| {
file.directory
.join("workspaces")
.join(name.to_ascii_lowercase())
});
let create = spec.workdir.is_none() && !workdir.try_exists()?;
ensure!(
workdir.is_dir(),
create || workdir.is_dir(),
"agent `{name}`: workdir {} is not a directory",
workdir.display()
);
if create {
new_workdirs.push(workdir.clone());
}
let mut child_env = BTreeMap::from([
("BUZZ_ACP_AGENT_COMMAND".into(), "buzz-agent".into()),
("BUZZ_ACP_MCP_COMMAND".into(), "buzz-dev-mcp".into()),
@@ -201,6 +222,7 @@ impl Plan {
owner_pubkey: owner_pubkey.context("no agent owner")?,
agents,
generated_keys,
new_workdirs,
})
}
}
@@ -216,10 +238,18 @@ fn executable(name: &str, env: &Env) -> Result<PathBuf> {
.map(|directory| directory.join(name))
.collect()
};
candidates.into_iter().find(|path| std::fs::metadata(path)
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0))
.map(std::path::absolute).transpose()?
.with_context(|| format!("cannot find executable `{name}`; install buzz-acp or Sprig's personality symlinks on PATH"))
let is_executable = |path: &PathBuf| {
std::fs::metadata(path)
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
};
candidates
.into_iter()
.find(is_executable)
.map(std::path::absolute)
.transpose()?
.with_context(|| {
format!("cannot find executable `{name}`; install buzz-acp or Sprig's personality symlinks on PATH")
})
}
/// Normalize equivalent relay addresses before checking duplicate identities.

View File

@@ -89,6 +89,33 @@ fn inherited_identity_and_acp_policy_are_removed_but_provider_env_is_inherited()
);
}
#[test]
fn declared_sources_reach_only_their_declarers() {
let fixture = Fixture::new();
let plan = fixture
.plan(
" env:\n SHARED: {env: DEFAULT_SOURCE}\n ANTHROPIC_API_KEY: {env: ANTHROPIC_API_KEY}",
" - name: alpha\n env:\n SHARED: alpha\n MY_KEY: {env: ALPHA_SOURCE}\n - name: beta\n env:\n SHARED: beta",
&Env::from_iter([
("DEFAULT_SOURCE", "overridden-everywhere"),
("ALPHA_SOURCE", "alpha-only"),
("ANTHROPIC_API_KEY", "shared-provider-key"),
]),
)
.expect("plan");
let [alpha, beta] = &plan.agents[..] else {
panic!("two connections");
};
assert_eq!(alpha.env["MY_KEY"], "alpha-only");
assert!(!beta.env.contains_key("MY_KEY"));
for agent in [alpha, beta] {
assert_eq!(agent.env["ANTHROPIC_API_KEY"], "shared-provider-key");
for source in ["ALPHA_SOURCE", "DEFAULT_SOURCE", "ANTHROPIC_API_KEY"] {
assert!(agent.env_remove.iter().any(|key| key == source), "{source}");
}
}
}
#[test]
fn env_cannot_override_identity_sources_or_use_invalid_names() {
let fixture = Fixture::new();
@@ -158,7 +185,18 @@ fn file_credentials_resolve_relative_to_the_config_directory() {
)
.expect("plan");
assert_eq!(plan.agents[0].env["API_CREDENTIAL"], "test-value");
assert_eq!(plan.agents[0].workdir, fixture.root.path());
assert_eq!(
plan.agents[0].workdir,
fixture.root.path().join("workspaces/helper")
);
assert_eq!(plan.new_workdirs, [plan.agents[0].workdir.clone()]);
// Another agent's pending default does not make an explicit workdir exist.
for agents in [
" - name: helper\n - name: other\n workdir: workspaces/helper",
" - name: other\n workdir: workspaces/helper\n - name: helper",
] {
assert!(fixture.plan("", agents, &Env::default()).is_err());
}
assert_eq!(
format!("{:?}", SecretRef::Literal("hidden".into())),
"SecretRef(<redacted>)"

View File

@@ -3,6 +3,7 @@
use std::fs;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::{Command, Output};
use nostr::{Keys, ToBech32};
@@ -23,11 +24,12 @@ impl Fixture {
set -eu
test -z "${OWNER_SOURCE+x}"
test -z "${OTHER_AGENT_SOURCE+x}"
test -z "${SIBLING_SOURCE+x}"
test -z "${TAG_SOURCE+x}"
test -z "${BUZZ_ACP_HEARTBEAT_PROMPT+x}"
test "$BUZZ_ACP_SYSTEM_PROMPT_FILE" = "persona.txt"
test -f "$BUZZ_ACP_SYSTEM_PROMPT_FILE"
test -z "$BUZZ_ACP_MCP_COMMAND"
printf '%s\n%s\n%s\n' "$BUZZ_PRIVATE_KEY" "$BUZZ_AUTH_TAG" "$BUZZ_RELAY_URL" > "launch-${BUZZ_RELAY_URL##*/}"
printf '%s\n%s\n%s\n%s\n' "$BUZZ_PRIVATE_KEY" "$BUZZ_AUTH_TAG" "$BUZZ_RELAY_URL" "$(pwd -P)" > "launch-${BUZZ_RELAY_URL##*/}"
"#).expect("harness");
fs::set_permissions(script, fs::Permissions::from_mode(0o755)).expect("chmod");
fs::write(root.path().join("persona.txt"), "Test agent").expect("persona");
@@ -53,6 +55,11 @@ defaults:
BUZZ_ACP_SYSTEM_PROMPT_FILE: persona.txt
agents:
{agents}
- name: sibling
enabled: false
auth_tag: {{env: TAG_SOURCE}}
env:
SIBLING_CREDENTIAL: {{env: SIBLING_SOURCE}}
"#
),
)
@@ -70,6 +77,8 @@ agents:
self.owner.secret_key().to_bech32().expect("nsec"),
)
.env("OTHER_AGENT_SOURCE", "must-not-reach-the-child")
.env("SIBLING_SOURCE", "declared-by-a-sibling-only")
.env("TAG_SOURCE", "declared-by-a-disabled-sibling")
.env(
"BUZZ_ACP_HEARTBEAT_PROMPT",
std::ffi::OsString::from_vec(vec![0xff]),
@@ -81,6 +90,14 @@ agents:
command
}
/// Launch records land in the default per-agent workdir.
fn launch(&self, host: &str) -> std::path::PathBuf {
self.root
.path()
.join("workspaces/helper")
.join(format!("launch-{host}"))
}
fn run(&self, args: &[&str]) -> Output {
let output = self.command().args(args).output().expect("run swarm");
assert!(
@@ -98,12 +115,14 @@ fn starts_from_another_directory_and_reuses_one_identity_across_relays_and_resta
fixture.write_config(" - name: helper\n - name: disabled\n enabled: false\n nsec: {env: OTHER_AGENT_SOURCE}");
let preview = fixture.run(&["validate"]);
assert!(String::from_utf8_lossy(&preview.stdout).contains("created on start"));
for written in ["keys", "workspaces"] {
assert!(
!fixture.root.path().join(written).exists(),
"validation wrote {written}"
);
}
assert!(
!fixture.root.path().join("keys").exists(),
"validation wrote keys"
);
assert!(
!fixture.root.path().join("launch-one.example").exists(),
!fixture.launch("one.example").exists(),
"validation launched a harness"
);
@@ -119,10 +138,8 @@ fn starts_from_another_directory_and_reuses_one_identity_across_relays_and_resta
0o600
);
let keys = Keys::parse(persisted.trim()).expect("agent key");
let first =
fs::read_to_string(fixture.root.path().join("launch-one.example")).expect("first launch");
let second =
fs::read_to_string(fixture.root.path().join("launch-two.example")).expect("second launch");
let first = fs::read_to_string(fixture.launch("one.example")).expect("first launch");
let second = fs::read_to_string(fixture.launch("two.example")).expect("second launch");
let first: Vec<_> = first.lines().collect();
let second: Vec<_> = second.lines().collect();
assert!(
@@ -131,6 +148,20 @@ fn starts_from_another_directory_and_reuses_one_identity_across_relays_and_resta
);
assert_eq!(first[1], second[1], "relays received different auth tags");
assert_ne!(first[2], second[2]);
// Agents run in a private workdir, not beside `keys/`.
let workdir = fixture.root.path().join("workspaces/helper");
assert_eq!(
Path::new(first[3]),
workdir.canonicalize().expect("workdir")
);
assert_eq!(
fs::metadata(&workdir)
.expect("workdir")
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
buzz_sdk::nip_oa::verify_auth_tag(first[1], &keys.public_key()).expect("tag"),
fixture.owner.public_key()
@@ -190,7 +221,7 @@ fn invalid_launches_start_nothing_and_persist_no_keys() {
let output = fixture.command().output().expect("run");
assert!(!output.status.success(), "invalid configuration succeeded");
assert!(!fixture.root.path().join("keys").exists());
assert!(!fixture.root.path().join("launch-one.example").exists());
assert!(!fixture.root.path().join("workspaces").exists());
}
}
@@ -220,7 +251,7 @@ fn unknown_selection_and_mismatched_delegation_fail_closed() {
.expect("key");
fs::write(fixture.root.path().join("auth.json"), tag).expect("tag file");
assert!(!fixture.command().output().expect("run").status.success());
assert!(!fixture.root.path().join("launch-one.example").exists());
assert!(!fixture.launch("one.example").exists());
}
#[test]
@@ -234,5 +265,5 @@ fn an_expired_delegation_fails_before_launch() {
fs::write(path, config).expect("write");
assert!(!fixture.command().output().expect("run").status.success());
assert!(!fixture.root.path().join("keys").exists());
assert!(!fixture.root.path().join("launch-one.example").exists());
assert!(!fixture.launch("one.example").exists());
}

View File

@@ -78,7 +78,7 @@ fn blocked_stdout_does_not_prevent_shutdown_or_orphan_the_harness() {
.expect("start swarm");
let mut running = Running {
swarm,
pid_file: directory.path().join("harness.pid"),
pid_file: directory.path().join("workspaces/noisy/harness.pid"),
};
let startup_deadline = Instant::now() + Duration::from_secs(5);
let harness_pid = loop {