Skip to content

Commit 85fb83b

Browse files
mraadclaude
andcommitted
feat(buzz-acp): wire persona-pack MCP servers and skills into spawn
Persona packs resolved MCP servers and scoped skills, but nothing consumed them: `resolve_pack` was only reachable from `buzz pack validate/show`, and `ResolvedPersona.skills` was marked "reserved for future use, not yet wired". Every agent behind one harness host therefore saw the same tools. Add `--persona-pack <DIR>` / `--persona <NAME>` (mutually required) so the harness resolves a pack at startup and applies it: - MCP: the persona's merged servers (pack `.mcp.json` + frontmatter `mcp_servers:`) are passed to every `session/new` alongside the Buzz-managed server. The Buzz server keeps its name, so a colliding pack entry is dropped rather than shadowing the agent's relay credentials. - Skills: `pack::resolve_skills` scoping (claimed by a persona -> that persona only; unclaimed -> all) is materialized into `<workdir>/.agents/skills/<name>` and linked into the per-runtime discovery directories. An existing skill is never overwritten, but is still linked, so an operator-pinned version survives and stays reachable. Add `--workdir <DIR>`, applied before anything reads the working directory, so the ACP session cwd, the agent subprocess, and skill discovery agree. That is what makes one agent's capabilities unshared with another's. Pack failures are terminal: an unloadable pack, an unknown persona, or a declared-but-missing skill directory aborts startup rather than running an agent with silently fewer capabilities than its persona declares. Desktop spawn plumbing is untouched; it stays detached per the team migration in `migration/detach.rs`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017VkcJb4EkTx6pps1hPW9Xy Signed-off-by: mraad <mraad23@gmail.com>
1 parent e09f715 commit 85fb83b

8 files changed

Lines changed: 685 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/buzz-acp/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,4 @@ nix = { version = "0.31", default-features = false, features = ["signal"] }
7979
[dev-dependencies]
8080
tokio = { workspace = true, features = ["test-util"] }
8181
httparse = "1"
82+
tempfile = "3"

crates/buzz-acp/src/acp.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3490,6 +3490,50 @@ mod tests {
34903490
);
34913491
}
34923492

3493+
/// Persona MCP servers must reach the agent on the wire, not merely exist
3494+
/// in `Config` — this asserts the serialized `session/new` request the
3495+
/// adapter actually receives.
3496+
#[tokio::test]
3497+
async fn session_new_full_sends_mcp_servers_on_the_wire() {
3498+
let script = r#"
3499+
read -t 2 _init
3500+
echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}'
3501+
read -t 2 REQ
3502+
echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_test","_receivedRequest":'"$REQ"'}}'
3503+
sleep 1
3504+
"#;
3505+
let mut client = spawn_script(script).await;
3506+
client
3507+
.initialize()
3508+
.await
3509+
.expect("initialize should succeed");
3510+
3511+
let resp = client
3512+
.session_new_full(
3513+
"/tmp",
3514+
vec![McpServer {
3515+
name: "semgrep".into(),
3516+
command: "semgrep-mcp".into(),
3517+
args: vec!["--stdio".into()],
3518+
env: vec![EnvVar {
3519+
name: "TOKEN".into(),
3520+
value: "abc123".into(),
3521+
}],
3522+
}],
3523+
None,
3524+
None,
3525+
)
3526+
.await
3527+
.expect("session_new_full should succeed");
3528+
3529+
let server = &resp.raw["_receivedRequest"]["params"]["mcpServers"][0];
3530+
assert_eq!(server["name"], "semgrep");
3531+
assert_eq!(server["command"], "semgrep-mcp");
3532+
assert_eq!(server["args"][0], "--stdio");
3533+
assert_eq!(server["env"][0]["name"], "TOKEN");
3534+
assert_eq!(server["env"][0]["value"], "abc123");
3535+
}
3536+
34933537
#[tokio::test]
34943538
async fn goose_system_prompt_request_uses_set_contract() {
34953539
let script = r#"

crates/buzz-acp/src/config.rs

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,22 @@ pub struct CliArgs {
267267
#[arg(long, env = "BUZZ_ACP_MCP_COMMAND", default_value = "")]
268268
pub mcp_command: String,
269269

270+
/// Persona pack directory. Requires `--persona`: the named persona's MCP
271+
/// servers are added to every ACP session and its skills are materialized
272+
/// into the working directory.
273+
#[arg(long, env = "BUZZ_ACP_PERSONA_PACK", requires = "persona")]
274+
pub persona_pack: Option<PathBuf>,
275+
276+
/// Persona name within `--persona-pack`.
277+
#[arg(long, env = "BUZZ_ACP_PERSONA_NAME", requires = "persona_pack")]
278+
pub persona: Option<String>,
279+
280+
/// Working directory for this harness and its agent subprocess. Give each
281+
/// agent its own directory to keep skills and runtime config unshared.
282+
/// Created if missing. Defaults to the process working directory.
283+
#[arg(long, env = "BUZZ_ACP_WORKDIR")]
284+
pub workdir: Option<PathBuf>,
285+
270286
/// Idle timeout: max seconds of silence before killing a turn.
271287
/// Resets on any agent stdout activity.
272288
#[arg(long, env = "BUZZ_ACP_IDLE_TIMEOUT")]
@@ -598,6 +614,18 @@ pub struct Config {
598614
/// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL, BUZZ_AGENT_MODEL).
599615
/// Populated from persona pack resolution. Empty when no pack is configured.
600616
pub persona_env_vars: Vec<(String, String)>,
617+
/// MCP servers declared by the configured persona (pack `.mcp.json` merged
618+
/// with the persona's own `mcp_servers:`). Added to every ACP session
619+
/// alongside the Buzz-managed server. Empty when no pack is configured.
620+
pub persona_mcp_servers: Vec<buzz_persona::resolve::ResolvedMcpServer>,
621+
/// Absolute source directories of the skills scoped to the configured
622+
/// persona — the ones it claims plus the pack's unclaimed (shared) ones.
623+
/// Materialized into the working directory at startup. Empty when no pack
624+
/// is configured.
625+
pub persona_skill_dirs: Vec<PathBuf>,
626+
/// Working directory to switch into at startup. `None` keeps the process
627+
/// working directory as inherited.
628+
pub workdir: Option<PathBuf>,
601629
/// Whether `codex_network_env()` successfully injected a `CODEX_CONFIG` entry into
602630
/// `persona_env_vars`. When true, `AcpClient::spawn` merges all `CODEX_CONFIG` entries
603631
/// and forces `sandbox_workspace_write.network_access = true` via `build_codex_config_env`.
@@ -796,6 +824,57 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
796824
}
797825
}
798826

827+
/// Resolve `--persona-pack` / `--persona` into the persona's MCP servers and
828+
/// the source directories of the skills scoped to it.
829+
///
830+
/// Skill scoping is [`buzz_persona::pack::resolve_skills`]: a skill claimed by
831+
/// any persona goes only to that persona, an unclaimed one goes to all of them.
832+
///
833+
/// Every failure is terminal. A pack that does not load, a persona that is not
834+
/// in it, or a skill directory that does not exist would otherwise start an
835+
/// agent with quietly fewer capabilities than its persona declares.
836+
fn resolve_persona_pack(
837+
pack_dir: &std::path::Path,
838+
persona: &str,
839+
) -> Result<(Vec<buzz_persona::resolve::ResolvedMcpServer>, Vec<PathBuf>), ConfigError> {
840+
let pack_err = |e: buzz_persona::pack::PackError| {
841+
ConfigError::ConfigFile(format!("persona pack {}: {e}", pack_dir.display()))
842+
};
843+
844+
let loaded = buzz_persona::pack::load_pack(pack_dir).map_err(pack_err)?;
845+
// Scoping reads the loaded personas; resolution reads the whole pack.
846+
let mut skills_by_persona = buzz_persona::pack::resolve_skills(pack_dir, &loaded.personas);
847+
let resolved = buzz_persona::resolve::resolve_loaded_pack(&loaded).map_err(pack_err)?;
848+
849+
let available: Vec<&str> = resolved.personas.iter().map(|p| p.name.as_str()).collect();
850+
let selected = resolved
851+
.personas
852+
.iter()
853+
.find(|p| p.name == persona)
854+
.ok_or_else(|| {
855+
ConfigError::ConfigFile(format!(
856+
"persona '{persona}' not found in pack {} (available: {})",
857+
pack_dir.display(),
858+
available.join(", ")
859+
))
860+
})?;
861+
862+
let skills_root = pack_dir.join("skills");
863+
let mut skill_dirs = Vec::new();
864+
for name in skills_by_persona.remove(persona).unwrap_or_default() {
865+
let dir = skills_root.join(&name);
866+
if !dir.is_dir() {
867+
return Err(ConfigError::ConfigFile(format!(
868+
"persona '{persona}' declares skill '{name}' but {} is not a directory",
869+
dir.display()
870+
)));
871+
}
872+
skill_dirs.push(dir);
873+
}
874+
875+
Ok((selected.mcp_servers.clone(), skill_dirs))
876+
}
877+
799878
/// Per-runtime environment defaults applied when Buzz owns the agent process.
800879
///
801880
/// Mirrors [`default_agent_args`]: keyed on the normalized command identity,
@@ -1150,6 +1229,15 @@ impl Config {
11501229

11511230
validate_multiple_event_handling(args.multiple_event_handling, args.dedup)?;
11521231

1232+
// Persona pack: MCP servers and skill sources for the named persona.
1233+
// `--persona-pack` and `--persona` require each other (clap), so both
1234+
// are present or neither is.
1235+
let (persona_mcp_servers, persona_skill_dirs) =
1236+
match (args.persona_pack.as_deref(), args.persona.as_deref()) {
1237+
(Some(pack_dir), Some(persona)) => resolve_persona_pack(pack_dir, persona)?,
1238+
_ => (Vec::new(), Vec::new()),
1239+
};
1240+
11531241
let config = Config {
11541242
keys,
11551243
relay_url: args.relay_url,
@@ -1195,6 +1283,9 @@ impl Config {
11951283
respond_to_allowlist,
11961284
allowed_respond_to,
11971285
persona_env_vars,
1286+
persona_mcp_servers,
1287+
persona_skill_dirs,
1288+
workdir: args.workdir,
11981289
has_generated_codex_config,
11991290
relay_observer: args.relay_observer,
12001291
exit_after_inactivity_secs: args.exit_after_inactivity,
@@ -1571,6 +1662,9 @@ mod tests {
15711662
respond_to_allowlist: HashSet::new(),
15721663
allowed_respond_to: Vec::new(),
15731664
persona_env_vars: vec![],
1665+
persona_mcp_servers: vec![],
1666+
persona_skill_dirs: vec![],
1667+
workdir: None,
15741668
has_generated_codex_config: false,
15751669
relay_observer: false,
15761670
exit_after_inactivity_secs: 0,
@@ -3190,3 +3284,161 @@ channels = "ALL"
31903284
);
31913285
}
31923286
}
3287+
3288+
#[cfg(test)]
3289+
mod persona_pack_tests {
3290+
use super::*;
3291+
use std::path::Path;
3292+
3293+
/// Write a two-persona pack: `alpha` claims the `search` skill and declares
3294+
/// its own MCP server; `review` is unclaimed. `shared` belongs to nobody,
3295+
/// so it is scoped to both.
3296+
fn write_pack(root: &Path) {
3297+
std::fs::create_dir_all(root.join(".plugin")).unwrap();
3298+
std::fs::write(
3299+
root.join(".plugin/plugin.json"),
3300+
r#"{
3301+
"id": "test-pack",
3302+
"name": "Test Pack",
3303+
"version": "1.0.0",
3304+
"personas": ["personas/alpha.persona.md", "personas/beta.persona.md"]
3305+
}"#,
3306+
)
3307+
.unwrap();
3308+
3309+
std::fs::create_dir_all(root.join("personas")).unwrap();
3310+
std::fs::write(
3311+
root.join("personas/alpha.persona.md"),
3312+
r#"---
3313+
name: alpha
3314+
display_name: Alpha
3315+
description: Alpha persona.
3316+
skills:
3317+
- skills/search
3318+
mcp_servers:
3319+
- name: semgrep
3320+
command: semgrep-mcp
3321+
args: ['--stdio']
3322+
env:
3323+
TOKEN: abc123
3324+
---
3325+
You are Alpha.
3326+
"#,
3327+
)
3328+
.unwrap();
3329+
std::fs::write(
3330+
root.join("personas/beta.persona.md"),
3331+
"---\nname: beta\ndisplay_name: Beta\ndescription: Beta persona.\n---\nYou are Beta.\n",
3332+
)
3333+
.unwrap();
3334+
3335+
std::fs::write(
3336+
root.join(".mcp.json"),
3337+
r#"{"mcpServers": {"fetch": {"command": "fetch-mcp"}}}"#,
3338+
)
3339+
.unwrap();
3340+
3341+
for skill in ["search", "shared"] {
3342+
let dir = root.join("skills").join(skill);
3343+
std::fs::create_dir_all(&dir).unwrap();
3344+
std::fs::write(dir.join("SKILL.md"), format!("---\nname: {skill}\n---\n")).unwrap();
3345+
}
3346+
}
3347+
3348+
#[test]
3349+
fn resolves_persona_mcp_servers_merged_with_pack_shared() {
3350+
let tmp = tempfile::tempdir().unwrap();
3351+
write_pack(tmp.path());
3352+
3353+
let (servers, _) = resolve_persona_pack(tmp.path(), "alpha").unwrap();
3354+
3355+
let mut names: Vec<&str> = servers.iter().map(|s| s.name.as_str()).collect();
3356+
names.sort_unstable();
3357+
assert_eq!(names, vec!["fetch", "semgrep"]);
3358+
let semgrep = servers.iter().find(|s| s.name == "semgrep").unwrap();
3359+
assert_eq!(semgrep.command, "semgrep-mcp");
3360+
assert_eq!(semgrep.args, vec!["--stdio".to_string()]);
3361+
assert_eq!(
3362+
semgrep.env,
3363+
vec![("TOKEN".to_string(), "abc123".to_string())]
3364+
);
3365+
}
3366+
3367+
#[test]
3368+
fn claimed_skill_is_scoped_to_its_persona_only() {
3369+
let tmp = tempfile::tempdir().unwrap();
3370+
write_pack(tmp.path());
3371+
3372+
let (_, alpha_skills) = resolve_persona_pack(tmp.path(), "alpha").unwrap();
3373+
let (_, beta_skills) = resolve_persona_pack(tmp.path(), "beta").unwrap();
3374+
3375+
let names = |dirs: &[PathBuf]| -> Vec<String> {
3376+
let mut n: Vec<String> = dirs
3377+
.iter()
3378+
.map(|d| d.file_name().unwrap().to_string_lossy().into_owned())
3379+
.collect();
3380+
n.sort();
3381+
n
3382+
};
3383+
assert_eq!(names(&alpha_skills), vec!["search", "shared"]);
3384+
assert_eq!(
3385+
names(&beta_skills),
3386+
vec!["shared"],
3387+
"a skill claimed by alpha must not reach beta"
3388+
);
3389+
}
3390+
3391+
#[test]
3392+
fn unknown_persona_is_a_config_error_listing_the_available_names() {
3393+
let tmp = tempfile::tempdir().unwrap();
3394+
write_pack(tmp.path());
3395+
3396+
let err = resolve_persona_pack(tmp.path(), "gamma").unwrap_err();
3397+
let msg = err.to_string();
3398+
assert!(msg.contains("gamma"), "{msg}");
3399+
assert!(
3400+
msg.contains("alpha"),
3401+
"available names must be listed: {msg}"
3402+
);
3403+
}
3404+
3405+
#[test]
3406+
fn declared_but_missing_skill_directory_is_a_config_error() {
3407+
let tmp = tempfile::tempdir().unwrap();
3408+
write_pack(tmp.path());
3409+
std::fs::remove_dir_all(tmp.path().join("skills/search")).unwrap();
3410+
3411+
let err = resolve_persona_pack(tmp.path(), "alpha").unwrap_err();
3412+
assert!(
3413+
err.to_string().contains("search"),
3414+
"a persona must not start with a silently missing skill: {err}"
3415+
);
3416+
}
3417+
3418+
#[test]
3419+
fn pack_dir_without_a_manifest_is_a_config_error() {
3420+
let tmp = tempfile::tempdir().unwrap();
3421+
let err = resolve_persona_pack(tmp.path(), "alpha").unwrap_err();
3422+
assert!(err.to_string().contains("persona pack"), "{err}");
3423+
}
3424+
3425+
#[test]
3426+
fn persona_pack_and_persona_name_require_each_other() {
3427+
use clap::Parser;
3428+
let base = [
3429+
"buzz-acp",
3430+
"--private-key",
3431+
"nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
3432+
];
3433+
3434+
assert!(
3435+
CliArgs::try_parse_from(base.iter().copied().chain(["--persona-pack", "/tmp/pack"]))
3436+
.is_err(),
3437+
"--persona-pack alone must be rejected"
3438+
);
3439+
assert!(
3440+
CliArgs::try_parse_from(base.iter().copied().chain(["--persona", "alpha"])).is_err(),
3441+
"--persona alone must be rejected"
3442+
);
3443+
}
3444+
}

0 commit comments

Comments
 (0)