diff --git a/crates/buzz-swarm/README.md b/crates/buzz-swarm/README.md index 48308dbfd2..653eaca7fb 100644 --- a/crates/buzz-swarm/README.md +++ b/crates/buzz-swarm/README.md @@ -53,7 +53,8 @@ Missing agent keys are generated once and saved as `keys/.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/` 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 diff --git a/crates/buzz-swarm/src/cli.rs b/crates/buzz-swarm/src/cli.rs index 69f55546be..dfad1e502a 100644 --- a/crates/buzz-swarm/src/cli.rs +++ b/crates/buzz-swarm/src/cli.rs @@ -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 { 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 { 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 { diff --git a/crates/buzz-swarm/src/config.rs b/crates/buzz-swarm/src/config.rs index aff46660d5..2aa6c47bb5 100644 --- a/crates/buzz-swarm/src/config.rs +++ b/crates/buzz-swarm/src/config.rs @@ -147,6 +147,10 @@ impl SecretRef { pub struct Secret(Zeroizing); 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 { + 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> { - 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()), diff --git a/crates/buzz-swarm/src/config/tests.rs b/crates/buzz-swarm/src/config/tests.rs index 9e19daae3b..d923e4dd72 100644 --- a/crates/buzz-swarm/src/config/tests.rs +++ b/crates/buzz-swarm/src/config/tests.rs @@ -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") diff --git a/crates/buzz-swarm/src/identity.rs b/crates/buzz-swarm/src/identity.rs index 7c4b657c5e..2273550847 100644 --- a/crates/buzz-swarm/src/identity.rs +++ b/crates/buzz-swarm/src/identity.rs @@ -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, diff --git a/crates/buzz-swarm/src/plan.rs b/crates/buzz-swarm/src/plan.rs index 82bc5db8fc..2313ea4c72 100644 --- a/crates/buzz-swarm/src/plan.rs +++ b/crates/buzz-swarm/src/plan.rs @@ -45,6 +45,8 @@ pub struct Plan { pub owner_pubkey: String, pub agents: Vec, pub generated_keys: Vec, + /// Default workdirs that do not exist yet; created on start. + pub new_workdirs: Vec, } 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 { .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. diff --git a/crates/buzz-swarm/src/plan/tests.rs b/crates/buzz-swarm/src/plan/tests.rs index 6f243653f0..e28b6a43f1 100644 --- a/crates/buzz-swarm/src/plan/tests.rs +++ b/crates/buzz-swarm/src/plan/tests.rs @@ -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()" diff --git a/crates/buzz-swarm/tests/cli.rs b/crates/buzz-swarm/tests/cli.rs index adcf2a7cb5..92f6a4d97e 100644 --- a/crates/buzz-swarm/tests/cli.rs +++ b/crates/buzz-swarm/tests/cli.rs @@ -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()); } diff --git a/crates/buzz-swarm/tests/shutdown.rs b/crates/buzz-swarm/tests/shutdown.rs index 524ef87d0f..1640ac19b9 100644 --- a/crates/buzz-swarm/tests/shutdown.rs +++ b/crates/buzz-swarm/tests/shutdown.rs @@ -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 {