Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH
- **[VISION.md](VISION.md)** · **[VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)** · **[VISION_PROJECTS.md](VISION_PROJECTS.md)** · **[VISION_AGENT.md](VISION_AGENT.md)** — the four vision docs
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — system design, kind ranges, subsystem boundaries
- **[TESTING.md](TESTING.md)** — multi-agent E2E test suite
- **[Agent import deep link](docs/agent-import-deep-link.md)** — local v1 handoff for opening a snapshot preview in Buzz Desktop
- **[CONTRIBUTING.md](CONTRIBUTING.md)** · **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** · **[SECURITY.md](SECURITY.md)** · **[GOVERNANCE.md](GOVERNANCE.md)**

<details>
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ buzz-persona = { path = "../buzz-persona" }
# channel-templates.json store for `channels create --template`
dirs = "6"

# Open Buzz Desktop through its registered buzz:// deep-link handler.
webbrowser = "1"

# WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay)
buzz-ws-client = { path = "../buzz-ws-client" }

Expand Down
10 changes: 10 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ buzz mem set <slug> "my-value"
buzz mem patch <slug> --base-hash <hex> < diff.patch # or --no-base-hash
buzz mem rm <slug>

# Local agent snapshot import
buzz agents import --file ./my-agent.agent.json

# Repository protection
buzz repos protect list --id my-repo
buzz repos protect set --id my-repo --ref refs/heads/main --push admin --no-force-push --no-delete
Expand All @@ -95,6 +98,12 @@ buzz channels list | jq '.[].name'
constraint omitted from the command is removed. `protect list` reports malformed
stored rules in `validation_error` so an owner can remove and repair them.

`agents import` is local-only and does not require relay credentials. It opens
Buzz Desktop's existing import preview; the user must confirm **Import** before
the agent is created. Native apps may also use the documented
[`buzz://agent-import` v1 deep link](../../docs/agent-import-deep-link.md)
directly.

## Commands

| Group | Subcommand | Description |
Expand Down Expand Up @@ -154,6 +163,7 @@ stored rules in `validation_error` so an owner can remove and repair them.
| | `protect set` | Create or replace a protection rule |
| | `protect remove` | Remove a protection rule |
| `upload` | `file` | Upload a file to the Blossom store |
| `agents` | `import` | Open a local snapshot in Buzz Desktop's import preview |
| `pack` | `validate` | Validate a persona pack (local, no relay) |
| | `inspect` | Inspect a persona pack (local, no relay) |
| `mem` | `ls` | List non-tombstoned memories |
Expand Down
131 changes: 131 additions & 0 deletions crates/buzz-cli/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use buzz_core::kind::KIND_IA_ARCHIVED_LIST;
use buzz_sdk::builders::{build_archive_identity_request, build_unarchive_identity_request};
use nostr::PublicKey;
use serde_json::json;
use std::path::{Path, PathBuf};
use url::Url;

use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft};
use crate::client::BuzzClient;
Expand All @@ -11,6 +13,9 @@ use crate::{AgentsCmd, RespondToArg};

pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> {
match command {
AgentsCmd::Import { .. } => Err(CliError::Other(
"agent import was not handled as a local command".into(),
)),
AgentsCmd::DraftCreate {
channel,
display_name,
Expand Down Expand Up @@ -151,6 +156,76 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli
}
}

const MAX_AGENT_JSON_BYTES: u64 = 5 * 1024 * 1024;
const MAX_AGENT_PNG_BYTES: u64 = 10 * 1024 * 1024;
const AGENT_IMPORT_DEEP_LINK_VERSION: &str = "1";

fn snapshot_size_cap(path: &Path) -> Result<u64, CliError> {
let name = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| CliError::Usage("snapshot filename is not valid UTF-8".into()))?
.to_ascii_lowercase();
if name.ends_with(".agent.json") {
Ok(MAX_AGENT_JSON_BYTES)
} else if name.ends_with(".agent.png") {
Ok(MAX_AGENT_PNG_BYTES)
} else {
Err(CliError::Usage(
"snapshot filename must end with .agent.json or .agent.png".into(),
))
}
}

pub(crate) fn build_agent_import_deep_link(file: &Path) -> Result<(PathBuf, Url), CliError> {
let canonical = file
.canonicalize()
.map_err(|error| CliError::Usage(format!("cannot read snapshot file: {error}")))?;
let metadata = canonical
.metadata()
.map_err(|error| CliError::Usage(format!("cannot inspect snapshot file: {error}")))?;
if !metadata.is_file() {
return Err(CliError::Usage(
"snapshot path must point to a regular file".into(),
));
}
let cap = snapshot_size_cap(&canonical)?;
if metadata.len() > cap {
return Err(CliError::Usage(format!(
"snapshot file is too large (maximum {} MiB)",
cap / (1024 * 1024)
)));
}

let file_url = Url::from_file_path(&canonical)
.map_err(|()| CliError::Usage("could not convert snapshot path to a file URL".into()))?;
let mut deep_link = Url::parse("buzz://agent-import")
.map_err(|error| CliError::Other(format!("invalid import deep-link base: {error}")))?;
deep_link
.query_pairs_mut()
.append_pair("v", AGENT_IMPORT_DEEP_LINK_VERSION)
.append_pair("file", file_url.as_str());
Ok((canonical, deep_link))
}

pub fn cmd_import(file: &Path) -> Result<(), CliError> {
let (canonical, deep_link) = build_agent_import_deep_link(file)?;
webbrowser::open(deep_link.as_str())
.map_err(|error| CliError::Other(format!("failed to open Buzz Desktop: {error}")))?;
println!(
"{}",
json!({
"ok": true,
"action": "agent-import",
"opened": true,
"confirmed": false,
"file": canonical,
"message": "Snapshot sent to Buzz Desktop. Review the preview and confirm Import in the app."
})
);
Ok(())
}

/// Require `BUZZ_AUTH_TAG` and parse the owner pubkey from it. Used only by
/// the `draft-create` and `draft-update` paths.
fn require_owner(client: &BuzzClient) -> Result<PublicKey, CliError> {
Expand Down Expand Up @@ -386,6 +461,7 @@ mod tests {
use buzz_core::kind::KIND_IA_ARCHIVED_LIST;
use nostr::{EventBuilder, Keys, Kind, Tag};
use serde_json::json;
use std::io::Write;

fn hex64(c: char) -> String {
std::iter::repeat_n(c, 64).collect()
Expand All @@ -395,6 +471,61 @@ mod tests {
std::iter::repeat_n(c, 128).collect()
}

#[test]
fn agent_import_deep_link_contains_canonical_file_url() {
let directory = tempfile::tempdir().expect("temp directory");
let snapshot_path = directory.path().join("name with spaces.agent.json");
std::fs::write(&snapshot_path, b"{}").expect("write snapshot");

let (canonical, deep_link) =
build_agent_import_deep_link(&snapshot_path).expect("valid deep link");
assert_eq!(
canonical,
snapshot_path.canonicalize().expect("canonical path")
);
assert_eq!(deep_link.scheme(), "buzz");
assert_eq!(deep_link.host_str(), Some("agent-import"));
assert_eq!(
deep_link
.query_pairs()
.find(|(key, _)| key == "v")
.map(|(_, value)| value.into_owned())
.as_deref(),
Some(AGENT_IMPORT_DEEP_LINK_VERSION)
);
let file = deep_link
.query_pairs()
.find(|(key, _)| key == "file")
.map(|(_, value)| value.into_owned())
.expect("file query parameter");
assert!(file.starts_with("file://"));
assert!(file.contains("name%20with%20spaces.agent.json"));
}

#[test]
fn agent_import_rejects_unknown_extension() {
let mut snapshot = tempfile::NamedTempFile::new().expect("temp file");
snapshot.write_all(b"{}").expect("write snapshot");

let error =
build_agent_import_deep_link(snapshot.path()).expect_err("extension must be rejected");
assert!(error.to_string().contains(".agent.json or .agent.png"));
}

#[test]
fn agent_import_rejects_oversize_json_before_opening_desktop() {
let directory = tempfile::tempdir().expect("temp directory");
let snapshot_path = directory.path().join("large.agent.json");
let snapshot = std::fs::File::create(&snapshot_path).expect("create snapshot");
snapshot
.set_len(MAX_AGENT_JSON_BYTES + 1)
.expect("extend snapshot");

let error =
build_agent_import_deep_link(&snapshot_path).expect_err("size must be rejected");
assert!(error.to_string().contains("maximum 5 MiB"));
}

// --- (b) auth-selection matrix: extract_owner_auth_tag ---

#[test]
Expand Down
17 changes: 15 additions & 2 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Configuration (flags override env vars):
BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required]
BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional]

The 'pack' subcommand runs locally and does not require a relay connection.
The 'pack' subcommand and 'agents import' run locally and do not require a relay connection.

Exit codes: 0=ok 1=bad input 2=relay/network error 3=auth error 4=other 5=write conflict
Errors are JSON on stderr: {\"error\": \"<category>\", \"message\": \"<detail>\"}"
Expand Down Expand Up @@ -258,6 +258,12 @@ impl RespondToArg {

#[derive(Subcommand)]
pub enum AgentsCmd {
/// Open a local snapshot in Buzz Desktop's import preview
Import {
/// Path to an .agent.json or .agent.png snapshot
#[arg(long)]
file: std::path::PathBuf,
},
/// Open a prefilled create-agent form in the owner's Buzz Desktop
DraftCreate {
/// Current channel UUID; the new agent is added here after save
Expand Down Expand Up @@ -1738,6 +1744,12 @@ async fn run(cli: Cli) -> Result<(), CliError> {
};
}

// Agent snapshot import only opens Buzz's review surface. It never talks
// to the relay and must remain usable without a Nostr private key.
if let Cmd::Agents(AgentsCmd::Import { ref file }) = cli.command {
return commands::agents::cmd_import(file);
}

// Auth: private key is required for all relay operations.
// The keypair IS the identity — no tokens, no other auth.
let private_key_str = cli.private_key.ok_or_else(|| {
Expand Down Expand Up @@ -1872,6 +1884,7 @@ mod tests {
"archived",
"draft-create",
"draft-update",
"import",
"unarchive"
]
);
Expand Down Expand Up @@ -1992,7 +2005,7 @@ mod tests {
#[test]
fn subcommand_counts_are_stable() {
let expected: Vec<(&str, usize)> = vec![
("agents", 5),
("agents", 6),
("canvas", 2),
("channels", 16),
("dms", 4),
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export default defineConfig({
"**/channel-sort.spec.ts",
"**/identity-lost.spec.ts",
"**/deep-link-invite.spec.ts",
"**/agent-import-deep-link.spec.ts",
"**/invite-qr-download.spec.ts",
"**/global-agent-config-screenshots.spec.ts",
"**/doctor-states.spec.ts",
Expand Down
Loading