Add local agent provisioning personality

Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e
2026-07-22 16:49:47 -07:00
parent acfbb1bb6a
commit bb3bd33069
7 changed files with 181 additions and 4 deletions
Generated
+2
View File
@@ -965,10 +965,12 @@ dependencies = [
"base64",
"buzz-cli",
"buzz-core",
"buzz-sdk",
"git-credential-nostr",
"git-sign-nostr",
"ignore",
"image",
"keyring",
"nix 0.31.3",
"nostr",
"reqwest 0.13.4",
+4
View File
@@ -15,6 +15,7 @@ path = "src/main.rs"
[dependencies]
buzz-cli = { path = "../buzz-cli" }
buzz-sdk = { workspace = true }
git-credential-nostr = { path = "../git-credential-nostr" }
git-sign-nostr = { path = "../git-sign-nostr" }
nostr = { workspace = true }
@@ -43,6 +44,9 @@ buzz-core = { workspace = true }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", default-features = false, features = ["signal", "process"] }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"] }
# Windows Job Object APIs for the shell tool's timeout kill path: terminating a
# job kills the bash child AND every MSYS grandchild it forked, the Windows
# analogue of the Unix killpg above. windows-sys 0.61 is already workspace-
+7
View File
@@ -11,6 +11,7 @@ use std::path::Path;
use std::sync::Arc;
mod paths;
mod provision_agent;
mod read_file;
mod rg;
mod shell;
@@ -150,6 +151,12 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
"tree" => std::process::exit(tree::run(std::env::args().skip(1).collect())),
"git-credential-nostr" => std::process::exit(git_credential_nostr::run()),
"git-sign-nostr" => std::process::exit(git_sign_nostr::run()),
"buzz-provision-agent" => {
std::process::exit(provision_agent::run(std::env::args().skip(1)))
}
"buzz-dev-mcp" if std::env::args().nth(1).as_deref() == Some("provision-agent") => {
std::process::exit(provision_agent::run(std::env::args().skip(2)))
}
_ => {}
}
+162
View File
@@ -0,0 +1,162 @@
//! Local-only Buzz agent identity and NIP-OA provisioning.
//!
//! The owner key comes from Buzz Desktop's macOS Keychain item. It is never
//! accepted through argv or the environment. Only the generated agent key and
//! signed auth tag are printed.
use std::io::{BufRead, Write};
use nostr::{Keys, ToBech32};
#[cfg(any(target_os = "macos", test))]
use serde::Deserialize;
use zeroize::Zeroize;
#[cfg(target_os = "macos")]
const KEYCHAIN_SERVICE: &str = "buzz-desktop";
#[cfg(target_os = "macos")]
const KEYCHAIN_ACCOUNT: &str = "secrets";
#[cfg(any(target_os = "macos", test))]
#[derive(Deserialize)]
struct KeychainBlob {
identity: String,
}
pub fn run<I>(args: I) -> i32
where
I: IntoIterator<Item = String>,
{
match run_inner(args, std::io::stdin().lock(), &mut std::io::stdout()) {
Ok(()) => 0,
Err(error) => {
eprintln!("{}", serde_json::json!({"error": error}));
1
}
}
}
fn run_inner<I, R, W>(args: I, mut input: R, output: &mut W) -> Result<(), String>
where
I: IntoIterator<Item = String>,
R: BufRead,
W: Write,
{
let mut agent_from_stdin = false;
for arg in args {
match arg.as_str() {
"--agent-private-key-stdin" if !agent_from_stdin => agent_from_stdin = true,
_ => return Err(format!("unknown or duplicate argument: {arg}")),
}
}
let owner = load_owner_keys()?;
let agent = if agent_from_stdin {
let mut value = String::new();
input
.read_line(&mut value)
.map_err(|error| format!("failed to read agent key from stdin: {error}"))?;
let agent = Keys::parse(value.trim())
.map_err(|error| format!("invalid agent key from stdin: {error}"));
value.zeroize();
agent?
} else {
Keys::generate()
};
write_provisioned_identity(&owner, &agent, output)
}
#[cfg(target_os = "macos")]
fn load_owner_keys() -> Result<Keys, String> {
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
.map_err(|error| format!("failed to open Buzz Desktop Keychain entry: {error}"))?;
let mut raw = entry
.get_password()
.map_err(|error| format!("failed to read Buzz Desktop Keychain entry: {error}"))?;
let blob: Result<KeychainBlob, _> = serde_json::from_str(&raw);
raw.zeroize();
let mut blob = blob.map_err(|error| format!("invalid Buzz Desktop Keychain blob: {error}"))?;
let owner = Keys::parse(blob.identity.trim())
.map_err(|error| format!("invalid owner key in Buzz Desktop Keychain: {error}"));
blob.identity.zeroize();
owner
}
#[cfg(not(target_os = "macos"))]
fn load_owner_keys() -> Result<Keys, String> {
Err("Buzz Desktop Keychain provisioning is supported only on macOS".to_string())
}
fn write_provisioned_identity<W: Write>(
owner: &Keys,
agent: &Keys,
output: &mut W,
) -> Result<(), String> {
let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(owner, &agent.public_key(), "")
.map_err(|error| format!("failed to compute owner auth tag: {error}"))?;
let agent_nsec = agent
.secret_key()
.to_bech32()
.map_err(|error| format!("failed to encode agent private key: {error}"))?;
serde_json::to_writer(
&mut *output,
&serde_json::json!({
"agent_private_key_nsec": agent_nsec,
"agent_pubkey": agent.public_key().to_hex(),
"owner_pubkey": owner.public_key().to_hex(),
"auth_tag": auth_tag,
}),
)
.map_err(|error| format!("failed to serialize provisioned identity: {error}"))?;
output
.write_all(b"\n")
.map_err(|error| format!("failed to write provisioned identity: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn owner() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000001").unwrap()
}
#[test]
fn provisioned_identity_has_verifiable_owner_tag() {
let owner = owner();
let agent = Keys::generate();
let owner_secret = owner.secret_key().to_secret_hex();
let mut output = Vec::new();
write_provisioned_identity(&owner, &agent, &mut output).unwrap();
let value: serde_json::Value = serde_json::from_slice(&output).unwrap();
let agent_nsec = value["agent_private_key_nsec"].as_str().unwrap();
let parsed_agent = Keys::parse(agent_nsec).unwrap();
let auth_tag = value["auth_tag"].as_str().unwrap();
let verified_owner =
buzz_sdk::nip_oa::verify_auth_tag(auth_tag, &parsed_agent.public_key())
.expect("generated auth tag must verify");
assert_eq!(parsed_agent.public_key(), agent.public_key());
assert_eq!(value["owner_pubkey"], owner.public_key().to_hex());
assert_eq!(verified_owner, owner.public_key());
assert!(!String::from_utf8(output).unwrap().contains(&owner_secret));
}
#[test]
fn existing_agent_stdin_parser_rejects_unknown_arguments_before_keychain_read() {
let mut output = Vec::new();
let error = run_inner(["--wrong".to_string()], "".as_bytes(), &mut output).unwrap_err();
assert_eq!(error, "unknown or duplicate argument: --wrong");
assert!(output.is_empty());
}
#[test]
fn keychain_blob_ignores_other_secret_entries() {
let raw = r#"{"identity":"owner","agent:abc":"must-not-be-retained"}"#;
let blob: KeychainBlob = serde_json::from_str(raw).unwrap();
assert_eq!(blob.identity, "owner");
}
}
+1
View File
@@ -35,6 +35,7 @@ impl Shim {
"buzz",
"git-credential-nostr",
"git-sign-nostr",
"buzz-provision-agent",
] {
symlink(&self_exe, &dir.path().join(name))?;
}
+2 -2
View File
@@ -37,7 +37,7 @@ fn dispatch() -> Result<(), String> {
}
},
// buzz-dev-mcp also handles its own multicall names: rg, tree,
// buzz, git-credential-nostr, and git-sign-nostr.
// buzz, git-credential-nostr, git-sign-nostr, and buzz-provision-agent.
_ => buzz_dev_mcp::run().map_err(|e| e.to_string()),
}
}
@@ -47,7 +47,7 @@ fn print_usage() {
"Sprig — all-in-one Buzz ACP harness, agent, and developer MCP\n\n\
Sprig is a multicall binary. Invoke it through one of the personality names:\n\n\
buzz-acp ACP harness\n buzz-agent ACP-compliant agent\n buzz-dev-mcp Developer MCP server\n\n\
Developer MCP helper names are also supported: rg, tree, buzz, git-credential-nostr, git-sign-nostr.\n\n\
Developer MCP helper names are also supported: rg, tree, buzz, git-credential-nostr, git-sign-nostr, buzz-provision-agent.\n\n\
Installers can create links with:\n ln -s sprig buzz-acp\n ln -s sprig buzz-agent\n ln -s sprig buzz-dev-mcp"
);
}
+3 -2
View File
@@ -6,7 +6,8 @@
# buzz-acp link to sprig (ACP harness)
# buzz-agent link to sprig (ACP-compliant agent)
# buzz-dev-mcp link to sprig (developer MCP server; also dispatches
# rg/tree/buzz/git-credential-nostr/git-sign-nostr)
# rg/tree/buzz/git-credential-nostr/git-sign-nostr/
# buzz-provision-agent)
#
# Usage:
# ./scripts/build-sprig.sh [version] [target]
@@ -145,7 +146,7 @@ Commands:
- `buzz-agent` — ACP-compliant agent (spawns MCP servers, calls LLMs).
- `buzz-dev-mcp` — Developer MCP server (shell, str_replace, todo) and
multicall entrypoint for `rg`, `tree`, `buzz`, `git-credential-nostr`,
`git-sign-nostr`.
`git-sign-nostr`, and `buzz-provision-agent`.
See `sprig.json` for SHA-256s, sizes, target, and source git SHA.