mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
acfbb1bb6a
commit
bb3bd33069
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ impl Shim {
|
||||
"buzz",
|
||||
"git-credential-nostr",
|
||||
"git-sign-nostr",
|
||||
"buzz-provision-agent",
|
||||
] {
|
||||
symlink(&self_exe, &dir.path().join(name))?;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user