diff --git a/Cargo.lock b/Cargo.lock index b7e7e325c85..c0d821dee54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,20 +1652,25 @@ version = "0.1.0" dependencies = [ "axum", "base64 0.22.1", + "buzz-backend-kubernetes", "buzz-core", "buzz-persona", "buzz-sdk", + "buzz-session-control", "buzz-ws-client", "bytes", "chrono", "clap", "diffy", "dirs", + "futures-util", "hex", "infer", "nostr 0.44.7", + "qrcode", "rand 0.10.1", "reqwest 0.13.4", + "rpassword", "rustls", "serde", "serde_json", @@ -1673,8 +1678,10 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "toml", "url", "uuid", + "zeroize", ] [[package]] @@ -8782,6 +8789,12 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quanta" version = "0.12.6" diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 59d1bb2cee6..04db22f42a9 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -86,9 +86,27 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" # Random number generation — full jitter for exponential backoff in with_retry rand = { workspace = true } -[dev-dependencies] -# Scratch files for channel-templates.json fixtures in tests +# `buzz onboard`: kind 24210 Sandbox session control (enroll, profile, the +# first agent-signed deploy), NIP-44 encrypted to the Sandbox manager. +buzz-session-control = { workspace = true } +# `buzz onboard`: secrets held in memory are wiped on drop. +zeroize = { workspace = true } +# `buzz onboard`: hidden terminal input for keys and tokens. +rpassword = "7.5" +# `buzz onboard`: the Paseo offer URL as a terminal QR code (no image backend). +qrcode = { version = "0.14", default-features = false } +# `buzz onboard`: the via profile check that profile.py makes. +toml = "1.0" +# `buzz onboard`: ssh-keygen writes the agent signing key into a private +# temporary directory, as the GPUI enroll form does. tempfile = "3" +# `buzz onboard`: the agents' first starts run concurrently. +futures-util = { workspace = true } + +[dev-dependencies] +# `buzz onboard` tests: the Sandbox manager's own deploy checks +# (`provider::parse`, `check_sandbox_request`) accept the first-start payload. +buzz-backend-kubernetes = { path = "../buzz-backend-kubernetes" } # Minimal HTTP test server for retry/policy integration tests axum = { workspace = true } # `test-util` enables paused-time control for deterministic timeout tests diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index f1fb50c414e..81bfec6d299 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -20,6 +20,33 @@ export BUZZ_PRIVATE_KEY="nsec1..." buzz channels list ``` +## Onboarding (`buzz onboard`) + +`buzz onboard` is an interactive wizard that sets up a developer for Remote +workspace agents without the Buzz app: a Buzz identity (generated, or imported +with `--import-key`), the community invite, GitHub, model and 1Password +credentials, agents from presets in a workspace channel, the encrypted profile +enrollment, and the Paseo pairing for desktop, web, and mobile. It sends the same events and Sandbox manager +requests as the Buzz app, so the result works in every Buzz app. + +```bash +buzz onboard # interactive +buzz onboard --yes --agents brokkr,bifrost # no prompts; secrets from BUZZ_ONBOARD_* env vars +buzz onboard --reenroll # change a stored credential +``` + +It does not need `BUZZ_PRIVATE_KEY` and ignores `BUZZ_RELAY_URL`: the relay is +`--relay` (default `wss://buzz.voltage.cloud`, `wss://` only). The owner key is +saved to `/buzz/identity.nsec` and progress to `onboard.json` (both +mode `0600`); a re-run resumes the setup. Credentials are never saved +or printed. Progress goes to stderr and a JSON summary without secrets to +stdout. A daemon gateway key is checked against your owner and agent keys. +Until the gateway SDK is included, the wizard prints the exact grant command +under **Action needed** and never reports a successful grant. The default and +maximum lifetime is 90 days (`--gateway-expires`); use `--skip-gateway-grant` +to defer authorization. The final step also prints your workflow command. +See [voltage/remote-agents.md](../../voltage/remote-agents.md#set-up-with-paseo-no-buzz-app). + ## Usage All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other, 5=write conflict. @@ -131,6 +158,7 @@ The table below mirrors that tree for readers who are not at a terminal. | Group | Subcommand | Description | |-------|-----------|-------------| +| `onboard` | | Set up identity, credentials and Remote workspace agents without the Buzz app | | `messages` | `send` | Send a message to a channel | | | `send-diff` | Send a code diff with metadata | | | `edit` | Edit a message you sent | diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 7c71016abd8..b83a10976da 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod mem; pub mod messages; pub mod moderation; pub mod notes; +pub mod onboard; pub mod pack; pub mod patches; pub mod pr; diff --git a/crates/buzz-cli/src/commands/onboard/agents.rs b/crates/buzz-cli/src/commands/onboard/agents.rs new file mode 100644 index 00000000000..c1074472409 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/agents.rs @@ -0,0 +1,349 @@ +//! Remote workspace agents as the Buzz app makes them: the presets offered, +//! the events that list an agent in every Buzz app, and the launch the +//! Sandbox manager registers at an agent's first start. Each shape is the +//! GPUI one, so an agent made here and one made in the app are the same to +//! every client and to the manager: +//! +//! - kind:0, signed by the agent: `{"name", "bot": true}` with the owner's +//! NIP-OA `auth` tag (the agent host's `profile_event`). +//! - kind 30177, signed by the owner: `{"parallelism": 1, "respond_to": +//! "owner-only", "name"}` with `d` = agent and `runtime` = +//! [`RUNTIME_TYPE`] (`agents2/listings.rs`). +//! - kind 9000, signed by the owner: the agent joins the channel as `bot`. +//! - the first agent-signed `deploy` (`remote_workspace/config.rs` +//! `Config::spec` through `sandbox-host`'s `payload`), which the manager +//! stores as the agent's launch registration. + +use std::collections::BTreeMap; + +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use super::state::AgentRecord; +use crate::error::CliError; + +/// The Remote workspace agent type, as the owner's record names it. +pub const RUNTIME_TYPE: &str = "voltage.remote-workspace/sandbox"; +/// The owner's agent records. +pub const LISTING_KIND: u16 = 30177; + +/// A harness the agent image runs. +pub struct Runtime { + /// `claude`, `codex` or `buzz-agent`. + pub id: &'static str, + /// What the developer sees. + pub label: &'static str, + command: &'static str, + default_env: &'static [(&'static str, &'static str)], +} + +/// GPUI's `RUNTIMES`. +pub const RUNTIMES: &[Runtime] = &[ + Runtime { + id: "claude", + label: "Claude Code", + command: "claude-agent-acp", + default_env: &[], + }, + Runtime { + id: "codex", + label: "Codex", + command: "codex-acp", + default_env: &[], + }, + Runtime { + id: "buzz-agent", + label: "Buzz agent", + command: "buzz-agent", + default_env: &[("MCP_HOOK_SERVERS", "*")], + }, +]; + +/// A starting point for an agent. +pub struct Preset { + /// What `--agents` takes. + pub id: &'static str, + /// The agent's display name. + pub name: &'static str, + /// One of [`RUNTIMES`]. + pub runtime: &'static str, + /// Empty uses the harness default. + pub model: &'static str, + /// An effort GPUI offers: empty, low, medium, high, xhigh or max. + pub effort: &'static str, + /// The model credential it needs, as a `models` key. + pub needs: &'static str, + /// The harness system prompt. + pub instructions: &'static str, +} + +/// The team voltage/remote-agents.md describes ("Coordinate a team"). +pub const PRESETS: &[Preset] = &[ + Preset { + id: "brokkr", + name: "Brokkr", + runtime: "claude", + model: "", + effort: "xhigh", + needs: "claude_oauth_token", + instructions: "You are Brokkr, the main builder and coordinator. Plan the work, \ + implement it, and run quality and security reviews with buzz-subagent review \ + before you open a pull request.", + }, + Preset { + id: "frigg", + name: "Frigg", + runtime: "claude", + model: "", + effort: "high", + needs: "claude_oauth_token", + instructions: "You are Frigg, the planner. Turn requests into concrete, ordered plans \ + with risks and validation steps. Do not implement unless asked.", + }, + Preset { + id: "bifrost", + name: "Bifrost", + runtime: "codex", + model: "", + effort: "high", + needs: "codex_access_token", + instructions: "You are Bifrost, an implementer. Make focused, tested changes and \ + report exactly what you changed and how you validated it.", + }, + Preset { + id: "forseti", + name: "Forseti", + runtime: "codex", + model: "", + effort: "xhigh", + needs: "codex_access_token", + instructions: "You are Forseti, a second opinion. Review designs and changes for \ + correctness and risk, and say plainly what you would block.", + }, + Preset { + id: "openrouter", + name: "Odin", + runtime: "buzz-agent", + model: "qwen/qwen3-coder", + effort: "", + needs: "openrouter_api_key", + instructions: "You are Odin, a general assistant running an OpenRouter model.", + }, +]; + +/// Whether a model credential that runs `needs` is among `available` +/// `models` keys: either one of each mutually exclusive pair serves its +/// harness, and the shared ChatGPT login serves Codex. +pub fn credential_available(needs: &str, available: &[String]) -> bool { + let serves: &[&str] = match needs { + "claude_oauth_token" => &["claude_oauth_token", "anthropic_api_key"], + "codex_access_token" => &["codex_access_token", "openai_api_key", "codex_login"], + _ => &[], + }; + available + .iter() + .any(|key| key == needs || serves.contains(&key.as_str())) +} + +/// The preset named `id`. +pub fn preset(id: &str) -> Option<&'static Preset> { + PRESETS + .iter() + .find(|preset| preset.id.eq_ignore_ascii_case(id.trim())) +} + +fn runtime(id: &str) -> &'static Runtime { + RUNTIMES + .iter() + .find(|runtime| runtime.id == id) + .unwrap_or(&RUNTIMES[0]) +} + +/// A new agent from `preset`, with a fresh key the owner attests. +pub fn create(preset: &Preset, owner: &Keys) -> Result { + let keys = Keys::generate(); + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(owner, &keys.public_key(), "") + .map_err(|error| CliError::Other(format!("could not attest the agent: {error}")))?; + let nsec = nostr::ToBech32::to_bech32(keys.secret_key()) + .map_err(|_| CliError::Other("could not encode the agent key".into()))?; + Ok(AgentRecord { + name: preset.name.into(), + preset: preset.id.into(), + runtime: preset.runtime.into(), + model: preset.model.into(), + effort: preset.effort.into(), + instructions: preset.instructions.into(), + pubkey: keys.public_key().to_hex(), + secret: super::state::Secret::new(nsec), + auth_tag, + ..AgentRecord::default() + }) +} + +/// The agent's keys. +pub fn keys(agent: &AgentRecord) -> Result { + Keys::parse(agent.secret.expose()) + .map_err(|_| CliError::Key(format!("the saved key of {} is invalid", agent.name))) +} + +fn auth(agent: &AgentRecord) -> Result { + buzz_sdk::nip_oa::parse_auth_tag(&agent.auth_tag).map_err(|_| { + CliError::Auth(format!( + "the saved attestation of {} is invalid", + agent.name + )) + }) +} + +/// The agent-signed kind:0 profile: `{"name", "bot": true}` and the owner's +/// attestation as its only tag. +pub fn profile_event(agent: &AgentRecord) -> Result { + let content = json!({"name": agent.name, "bot": true}).to_string(); + EventBuilder::new(Kind::Metadata, content) + .tags([auth(agent)?]) + .sign_with_keys(&keys(agent)?) + .map_err(|error| CliError::Other(format!("signing failed: {error}"))) +} + +/// The owner-signed kind 30177 record that lists the agent in every Buzz +/// app as a Remote workspace agent. +pub fn listing_event(agent: &AgentRecord, owner: &Keys) -> Result { + let content = json!({"parallelism": 1, "respond_to": "owner-only", "name": agent.name}); + let tags = [ + Tag::parse(["d", agent.pubkey.as_str()]), + Tag::parse(["runtime", RUNTIME_TYPE]), + ] + .into_iter() + .collect::, _>>() + .map_err(|error| CliError::Other(format!("tag error: {error}")))?; + EventBuilder::new(Kind::Custom(LISTING_KIND), content.to_string()) + .tags(tags) + .sign_with_keys(owner) + .map_err(|error| CliError::Other(format!("signing failed: {error}"))) +} + +/// The owner-signed kind 9000 that adds the agent to `channel` as `bot`. +pub fn member_event(agent: &AgentRecord, channel: Uuid, owner: &Keys) -> Result { + buzz_sdk::build_add_member(channel, &agent.pubkey, Some(buzz_sdk::MemberRole::Bot)) + .map_err(|error| CliError::Other(format!("build_add_member failed: {error}")))? + .sign_with_keys(owner) + .map_err(|error| CliError::Other(format!("signing failed: {error}"))) +} + +/// The owner's message that starts the agent's first thread: a mention, as +/// in the Buzz app. +pub fn kickoff_event(agent: &AgentRecord, channel: Uuid, owner: &Keys) -> Result { + let content = format!( + "@{} you were set up with `buzz onboard`. Reply in this thread to confirm that you are running.", + agent.name + ); + buzz_sdk::build_message(channel, &content, None, &[&agent.pubkey], false, &[], &[]) + .map_err(|error| CliError::Other(format!("build_message failed: {error}")))? + .sign_with_keys(owner) + .map_err(|error| CliError::Other(format!("signing failed: {error}"))) +} + +/// GPUI's `Config::spec` policy environment for `agent`. +pub fn policy_env(agent: &AgentRecord) -> BTreeMap { + let runtime = runtime(&agent.runtime); + let mut env: BTreeMap = runtime + .default_env + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + let mut set = |key: &str, value: String| env.insert(key.into(), value); + set("BUZZ_ACP_RELAY_OBSERVER", "true".into()); + set("BUZZ_ACP_LAZY_POOL", "true".into()); + set("BUZZ_ACP_AGENTS", "1".into()); + set("BUZZ_ACP_SESSION_POLICY", "thread".into()); + if !agent.instructions.trim().is_empty() { + set("BUZZ_ACP_SYSTEM_PROMPT", agent.instructions.clone()); + } + if !agent.model.trim().is_empty() { + let key = match runtime.id { + "claude" => "ANTHROPIC_MODEL", + "buzz-agent" => "BUZZ_AGENT_MODEL", + _ => "BUZZ_ACP_MODEL", + }; + set(key, agent.model.trim().into()); + } + if !agent.effort.is_empty() { + set("BUZZ_ACP_EFFORT_LEVEL", agent.effort.clone()); + } + let title: String = agent.name.chars().filter(|c| !c.is_control()).collect(); + if !title.trim().is_empty() { + set("BUZZ_ACP_SESSION_TITLE", title.trim().into()); + set("BUZZ_ACP_DISPLAY_NAME", title.trim().into()); + } + env +} + +/// The provider `deploy` request the Buzz app's host sends through the relay +/// provider for a mention in `thread_root`: the agent's key, attestation and +/// launch, scoped to that thread, replaying from `replay_floor`. +pub fn deploy_payload( + agent: &AgentRecord, + relay: &str, + owner: &str, + channel: &str, + thread_root: &str, + replay_floor: u64, +) -> Value { + let mut policy_env = policy_env(agent); + policy_env.insert("BUZZ_ACP_REPLAY_FLOOR".into(), replay_floor.to_string()); + json!({ + "op": "deploy", + "request_id": Uuid::new_v4().to_string(), + "agent": { + "name": agent.name, + "relay_url": relay, + "private_key_nsec": agent.secret.expose(), + "auth_tag": agent.auth_tag, + "env_vars": {}, + "launch": { + "command": runtime(&agent.runtime).command, + "args": [], + "env": {}, + "policy_env": policy_env, + "owner_pubkey": owner, + }, + }, + "provider_config": { + "sandbox": {"channel_id": channel, "thread_root": thread_root}, + "identity_policy": {"agent_pubkey": agent.pubkey, "owner_pubkey": owner}, + }, + }) +} + +/// The owner's Remote workspace agents, by pubkey and name, from their +/// kind 30177 records (newest per agent), as every Buzz app reads them. +pub fn remote_listings(events: &[Value], owner: &str) -> Vec<(String, String)> { + let mut heads: BTreeMap = BTreeMap::new(); + for event in events { + if event["kind"].as_u64() != Some(u64::from(LISTING_KIND)) + || event["pubkey"].as_str() != Some(owner) + { + continue; + } + let d = crate::client::extract_d_tag(event); + let at = event["created_at"].as_u64().unwrap_or(0); + if heads.get(&d).is_none_or(|(current, _)| at > *current) { + heads.insert(d, (at, event)); + } + } + heads + .into_iter() + .filter(|(_, (_, event))| { + crate::client::extract_tag_value(event, "runtime") == RUNTIME_TYPE + }) + .map(|(pubkey, (_, event))| { + let name = event["content"] + .as_str() + .and_then(|content| serde_json::from_str::(content).ok()) + .and_then(|content| content["name"].as_str().map(|name| name.trim().to_owned())) + .unwrap_or_default(); + (pubkey, name) + }) + .collect() +} diff --git a/crates/buzz-cli/src/commands/onboard/gateway.rs b/crates/buzz-cli/src/commands/onboard/gateway.rs new file mode 100644 index 00000000000..0ad84746c88 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/gateway.rs @@ -0,0 +1,52 @@ +//! The gateway grant boundary. The owner delegates the Paseo daemon's +//! gateway key with an owner-signed, expiring event, so the daemon can drive +//! the owner's own remote agents and nothing else. +//! +use nostr::PublicKey; + +/// The default grant lifetime. +pub const DEFAULT_LIFETIME: &str = "90d"; + +/// The longest grant `--gateway-expires` accepts. +pub const MAX_LIFETIME_SECS: u64 = 90 * DAY; + +/// Re-grant when the current relay grant expires within this long. +pub const RENEW_WITHIN_SECS: u64 = 7 * DAY; + +const HOUR: u64 = 60 * 60; +const DAY: u64 = 24 * HOUR; + +/// A grant lifetime: `h`, `d` or `w`, at least one hour and at most +/// [`MAX_LIFETIME_SECS`]. Returns the canonical text and the seconds. +pub fn lifetime(text: &str) -> Result<(String, u64), String> { + let text = text.trim().to_ascii_lowercase(); + let refuse = || { + format!( + "--gateway-expires must be hours, days or weeks such as {DEFAULT_LIFETIME}, 36h or 2w, at most 90d" + ) + }; + let (count, unit) = [("h", HOUR), ("d", DAY), ("w", 7 * DAY)] + .into_iter() + .find_map(|(suffix, unit)| text.strip_suffix(suffix).map(|count| (count, unit))) + .ok_or_else(refuse)?; + let count: u64 = count.parse().map_err(|_| refuse())?; + let secs = count.checked_mul(unit).ok_or_else(refuse)?; + if count == 0 || secs > MAX_LIFETIME_SECS { + return Err(refuse()); + } + Ok((text, secs)) +} + +/// The command that grants `gateway` for `lifetime` by hand. +pub fn command(gateway: &PublicKey, lifetime: &str) -> String { + format!( + "buzz gateway grant {} --expires {lifetime}", + gateway.to_hex() + ) +} + +/// Whether a grant that expires at `expires_at` must be renewed at `now` +/// for a run that grants `lifetime_secs`. +pub fn needs_renewal(expires_at: u64, now: u64, lifetime_secs: u64) -> bool { + expires_at <= now.saturating_add(RENEW_WITHIN_SECS.min(lifetime_secs / 2)) +} diff --git a/crates/buzz-cli/src/commands/onboard/io.rs b/crates/buzz-cli/src/commands/onboard/io.rs new file mode 100644 index 00000000000..07ccb166d58 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/io.rs @@ -0,0 +1,132 @@ +//! The terminal boundary: questions, hidden secret input, and progress. The +//! wizard talks only to [`Prompter`], so tests script the answers and read +//! back everything that would have been shown. + +use std::io::{BufRead, IsTerminal, Write}; + +use zeroize::Zeroizing; + +use crate::error::CliError; + +/// Asks the developer and tells them what happened. +pub trait Prompter { + /// Whether questions can be asked. When not, every question falls back + /// to its default or fails with its hint. + fn interactive(&self) -> bool; + + /// Shows a line of progress. + fn say(&mut self, text: &str); + + /// Asks a question. Without an answer it returns `default`; when not + /// interactive it fails with `hint` unless there is a default. + fn ask( + &mut self, + question: &str, + default: Option<&str>, + hint: &str, + ) -> Result; + + /// Asks for a secret without echoing it. When not interactive it fails + /// with `hint`. An empty answer is returned as empty. + fn secret(&mut self, question: &str, hint: &str) -> Result, CliError>; + + /// Asks a yes or no question; `default` when unanswered or not + /// interactive. + fn confirm(&mut self, question: &str, default: bool) -> Result { + if !self.interactive() { + return Ok(default); + } + let suffix = if default { "[Y/n]" } else { "[y/N]" }; + let answer = self.ask(&format!("{question} {suffix}"), Some(""), "")?; + Ok(match answer.trim().to_ascii_lowercase().as_str() { + "" => default, + "y" | "yes" => true, + _ => false, + }) + } +} + +/// The real terminal: questions and progress on stderr, answers on stdin, +/// secrets through the TTY without echo. +pub struct Terminal { + interactive: bool, +} + +impl Terminal { + /// Interactive only when stdin and stderr are terminals and `--yes` is + /// not set. + pub fn new(non_interactive: bool) -> Self { + Self { + interactive: !non_interactive + && std::io::stdin().is_terminal() + && std::io::stderr().is_terminal(), + } + } +} + +impl Prompter for Terminal { + fn interactive(&self) -> bool { + self.interactive + } + + fn say(&mut self, text: &str) { + eprintln!("{text}"); + } + + fn ask( + &mut self, + question: &str, + default: Option<&str>, + hint: &str, + ) -> Result { + if !self.interactive { + return default + .map(str::to_owned) + .ok_or_else(|| CliError::Usage(format!("{}: {hint}", question.trim()))); + } + match default.filter(|default| !default.is_empty()) { + Some(default) => eprint!("{question} [{default}]: "), + None => eprint!("{question}: "), + } + let _ = std::io::stderr().flush(); + let mut line = String::new(); + std::io::stdin() + .lock() + .read_line(&mut line) + .map_err(|_| CliError::Other("could not read the answer".into()))?; + let answer = line.trim(); + Ok(if answer.is_empty() { + default.unwrap_or_default().to_owned() + } else { + answer.to_owned() + }) + } + + fn secret(&mut self, question: &str, hint: &str) -> Result, CliError> { + if !self.interactive { + return Err(CliError::Usage(format!("{}: {hint}", question.trim()))); + } + rpassword::prompt_password(format!("{question} (hidden): ")) + .map(|text| Zeroizing::new(text.trim().to_owned())) + .map_err(|_| CliError::Other("could not read the hidden answer".into())) + } +} + +/// A secret's shape for display: its first few characters and its length, +/// never enough to use it. +pub fn redacted(secret: &str) -> String { + let prefix: String = secret + .chars() + .take_while(|c| *c != '-' && *c != '_') + .collect(); + let shown = if prefix.len() <= 4 && prefix.len() < secret.len() { + let end = secret + .char_indices() + .nth(prefix.chars().count() + 1) + .map_or(secret.len(), |(index, _)| index); + &secret[..end] + } else { + "" + }; + format!("{shown}… ({} characters)", secret.chars().count()) +} diff --git a/crates/buzz-cli/src/commands/onboard/manager.rs b/crates/buzz-cli/src/commands/onboard/manager.rs new file mode 100644 index 00000000000..9a4d7246457 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/manager.rs @@ -0,0 +1,207 @@ +//! The Sandbox manager boundary: kind 24210 session control +//! (`buzz-session-control`). Requests are NIP-44 encrypted to the manager +//! and replies are opened only when the manager signed them for this +//! request, exactly as the Buzz app's enroll form and its relay provider +//! (`buzz-backend-relay`) do. The transport only moves events; building +//! and opening them stays here, so tests exercise the real wire format. + +use std::time::Duration; + +use buzz_session_control::{build_request, open_reply, Op, Request, Status, KIND}; +use nostr::{Event, PublicKey, Timestamp}; +use serde_json::{json, Value}; + +use super::relay::Signer; +use crate::error::CliError; + +/// How long the manager has to answer `enroll` and `profile`, as in the app. +pub const OWNER_TIMEOUT: Duration = Duration::from_secs(60); +/// How long a first start may take: the relay provider's 600-second default +/// plus its 30 seconds to be accepted. +pub const DEPLOY_TIMEOUT: Duration = Duration::from_secs(630); + +/// Moves session control events between a signer and the manager. +pub trait ControlTransport { + /// Subscribes to kind 24210 events addressed to `signer`, publishes + /// `request`, and passes each event received to `open` until it returns + /// the terminal response or `timeout` passes. + async fn exchange( + &self, + signer: &Signer, + request: Event, + timeout: Duration, + open: F, + ) -> Result + where + F: FnMut(&Event) -> Option; +} + +/// Sends one `op` request with `payload` to `manager`, signed by `signer`, +/// and returns the manager's terminal response. Only a `done` reply that the +/// manager signed for this request event is read; anything else is skipped. +pub async fn ask( + transport: &T, + manager: &PublicKey, + signer: &Signer, + op: Op, + payload: Value, + timeout: Duration, +) -> Result { + let request_id = uuid::Uuid::new_v4().to_string(); + let envelope = Request::new(op, request_id.clone(), payload); + let event = build_request( + &signer.keys, + manager, + signer.auth_tag.as_deref(), + &envelope, + Timestamp::now(), + ) + .map_err(|error| { + CliError::Other(format!( + "could not build the {} request ({})", + op.as_str(), + error.label() + )) + })?; + drop(envelope); + let request_event = event.id; + transport + .exchange(signer, event, timeout, |reply| { + let reply = open_reply(reply, &signer.keys, manager, &request_event).ok()?; + (reply.request_id == request_id && reply.status == Status::Done) + .then(|| reply.response.unwrap_or(Value::Null)) + }) + .await +} + +/// The relay's WebSocket, authenticated as the signer (NIP-42, with the +/// owner attestation for an agent key). +pub struct WsTransport { + ws_url: String, +} + +impl WsTransport { + /// The transport for the `wss://` community `relay`. + pub fn new(relay: &str) -> Self { + Self { + ws_url: relay.trim_end_matches('/').to_owned(), + } + } +} + +const SUBSCRIPTION: &str = "onboard-session-control"; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); + +impl ControlTransport for WsTransport { + async fn exchange( + &self, + signer: &Signer, + request: Event, + timeout: Duration, + mut open: F, + ) -> Result + where + F: FnMut(&Event) -> Option, + { + use buzz_ws_client::{NostrWsConnection, RelayMessage, WsClientError}; + let unavailable = + || CliError::Other("could not connect, authenticate or subscribe to the relay".into()); + let tag = match &signer.auth_tag { + Some(json) => Some( + buzz_sdk::nip_oa::parse_auth_tag(json) + .map_err(|_| CliError::Auth("the agent attestation is malformed".into()))?, + ), + None => None, + }; + let connect = async { + let mut connection = + NostrWsConnection::connect_authenticated(&self.ws_url, &signer.keys, tag.as_ref()) + .await?; + let filter = json!({ + "kinds": [KIND], "#p": [signer.keys.public_key().to_hex()], "limit": 0, + }); + connection + .send_raw(&json!(["REQ", SUBSCRIPTION, filter])) + .await?; + // Replies are ephemeral: listen before sending. + loop { + match connection.next_event(CONNECT_TIMEOUT).await? { + RelayMessage::Eose { subscription_id } if subscription_id == SUBSCRIPTION => { + return Ok::<_, WsClientError>(connection); + } + RelayMessage::Closed { + subscription_id, .. + } if subscription_id == SUBSCRIPTION => { + return Err(WsClientError::ConnectionClosed); + } + _ => {} + } + } + }; + let mut connection = tokio::time::timeout(CONNECT_TIMEOUT, connect) + .await + .map_err(|_| unavailable())? + .map_err(|_| unavailable())?; + let lost = || { + CliError::Other("lost the relay connection before the Sandbox manager answered".into()) + }; + let ok = connection.send_event(request).await.map_err(|_| lost())?; + if !ok.accepted { + return Err(CliError::Other( + "the relay refused the session request".into(), + )); + } + let deadline = tokio::time::Instant::now() + timeout; + let result = loop { + let now = tokio::time::Instant::now(); + if now >= deadline { + break Err(CliError::Other(format!( + "the Sandbox manager did not answer within {} seconds", + timeout.as_secs() + ))); + } + match connection.next_event(deadline - now).await { + Ok(RelayMessage::Event { + subscription_id, + event, + }) if subscription_id == SUBSCRIPTION => { + if let Some(response) = open(&event) { + break Ok(response); + } + } + Ok(RelayMessage::Closed { + subscription_id, .. + }) if subscription_id == SUBSCRIPTION => break Err(lost()), + Ok(_) | Err(WsClientError::Timeout) => {} + Err(WsClientError::Json(_) | WsClientError::UnexpectedMessage(_)) => {} + Err(_) => break Err(lost()), + } + }; + let _ = tokio::time::timeout(Duration::from_secs(5), connection.disconnect()).await; + result + } +} + +/// The manager key the Buzz app's Remote workspace agents ask +/// (`MANAGER_PUBKEY` in GPUI's `remote_workspace/config.rs`). +pub const MANAGER_PUBKEY: &str = "1881c0c6186c81c00e607f9dcd9b6c655c49cd9a0227054e764a78e6829d31fd"; + +/// The manager's refusal of a first-start `deploy`, as `code: message`, +/// with what a `paseo_store_failed` refusal leaves behind. +pub fn refusal_text(response: &Value) -> String { + let code = response + .get("code") + .and_then(Value::as_str) + .unwrap_or("refused"); + let text = match response.get("error").and_then(Value::as_str) { + Some(message) => format!("{code}: {message}"), + None => code.to_owned(), + }; + if code == "paseo_store_failed" { + // The manager refuses after the Sandbox started and the launch + // registration was saved; repeating the deploy retries the update. + format!("{text} (it started, but your Paseo daemon's agent list was not updated)") + } else { + text + } +} diff --git a/crates/buzz-cli/src/commands/onboard/mod.rs b/crates/buzz-cli/src/commands/onboard/mod.rs new file mode 100644 index 00000000000..b8a6e36ee04 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/mod.rs @@ -0,0 +1,339 @@ +//! `buzz onboard`: set up a developer's Buzz identity, credentials and +//! Remote workspace agents from one terminal command, without the Buzz app. +//! +//! The wizard sends exactly the events and Sandbox manager requests the Buzz +//! app sends, so what it creates appears and works in every Buzz app, and +//! what the app created is recognised here: +//! +//! 1. [`Wizard::identity`]: the owner key, the community invite, the profile. +//! 2. [`Wizard::github`]: the GitHub token, Git email and agent signing key. +//! 3. [`Wizard::models`]: Claude, Codex and OpenRouter credentials. +//! 4. [`Wizard::op_token`]: the 1Password service account token. +//! 5. [`Wizard::agents`]: agents from presets, listed and added to a channel. +//! 6. [`Wizard::enroll`]: the encrypted `enroll`, the signing key on GitHub, +//! and the Paseo pairing; then [`Wizard::first_start`] registers each new +//! agent with the manager by its first agent-signed start in a channel +//! thread, which is also what lists it for the Paseo daemon. +//! 7. [`Wizard::gateway_grant`]: the owner's expiring grant of the Paseo +//! daemon's gateway key ([`gateway`]). +//! 8. [`Wizard::next_steps`]: Paseo pairing, workflows, the Buzz app, and +//! what still needs the developer. +//! +//! Each step is idempotent: progress is saved after every durable write +//! ([`state`]), and a re-run skips what is done. Credentials are held in +//! memory only and sent encrypted to the manager. + +mod agents; +mod gateway; +mod io; +mod manager; +mod paseo; +mod profile; +mod relay; +mod state; +mod steps_agents; +mod steps_credentials; +mod steps_enroll; +mod steps_gateway; +mod steps_identity; +mod tools; + +#[cfg(test)] +mod tests; + +use std::path::PathBuf; + +use nostr::PublicKey; +use serde_json::{json, Value}; +use zeroize::Zeroizing; + +use crate::error::CliError; +use io::Prompter; +use manager::ControlTransport; +use relay::Relay; +use state::{State, Store}; +use tools::Tools; + +/// The Voltage community. +pub const DEFAULT_RELAY: &str = "wss://buzz.voltage.cloud"; + +/// `buzz onboard` arguments. Secrets are read only from the environment or +/// hidden prompts, never from flags, so they stay out of process listings. +#[derive(clap::Args, Debug, Clone)] +pub struct OnboardArgs { + /// Community relay. Only wss:// is accepted. + #[arg(long, default_value = DEFAULT_RELAY)] + pub relay: String, + /// Never prompt: take every answer from flags and BUZZ_ONBOARD_* variables. + #[arg(long)] + pub yes: bool, + /// Import an existing key from BUZZ_PRIVATE_KEY (or a hidden prompt) + /// instead of generating one. + #[arg(long)] + pub import_key: bool, + /// Invite code or invite URL, when the key is not a member yet. + #[arg(long, env = "BUZZ_ONBOARD_INVITE", hide_env_values = true)] + pub invite: Option, + /// Accept the community's join policy without asking. + #[arg(long)] + pub accept_join_policy: bool, + /// Confirm the minimum age, when the join policy requires it. + #[arg(long)] + pub confirm_age: bool, + /// Your display name, when you have no profile yet. + #[arg(long)] + pub name: Option, + /// A verified email on your GitHub account [default: git config --global user.email]. + #[arg(long)] + pub git_email: Option, + /// A via profile TOML file [default: the shared Voltage via profile]. + #[arg(long)] + pub via_profile: Option, + /// Agents to create, by preset (brokkr, frigg, bifrost, forseti, openrouter). + #[arg(long, value_delimiter = ',')] + pub agents: Vec, + /// The workspace channel for the agents: a UUID or a name (created when missing). + #[arg(long)] + pub channel: Option, + /// Enroll again even when a profile is on file (keeps what you leave blank). + #[arg(long)] + pub reenroll: bool, + /// Create a new agent signing key when enrolling again. + #[arg(long)] + pub replace_signing_key: bool, + /// Do not start new agents once to register them with the Sandbox manager. + #[arg(long)] + pub skip_first_start: bool, + /// How long the Paseo daemon's gateway grant lasts: hours, days or weeks + /// (36h, 90d, 2w), at most 90d. + #[arg(long, default_value = gateway::DEFAULT_LIFETIME)] + pub gateway_expires: String, + /// Do not grant the Paseo daemon's gateway key. + #[arg(long)] + pub skip_gateway_grant: bool, + /// Where onboarding state and the key are kept [default: /buzz]. + #[arg(long, env = "BUZZ_ONBOARD_DIR")] + pub state_dir: Option, + /// The Sandbox manager's public key, hex. + #[arg(long, default_value = manager::MANAGER_PUBKEY, hide = true)] + pub manager_pubkey: String, +} + +/// The secret inputs that may come from the environment. +#[derive(Default)] +pub struct EnvSecrets { + /// `BUZZ_PRIVATE_KEY`, read only with `--import-key`. + pub private_key: Option>, + /// `BUZZ_ONBOARD_GITHUB_TOKEN`. + pub github_token: Option>, + /// `BUZZ_ONBOARD_OP_TOKEN`. + pub op_token: Option>, + /// `BUZZ_ONBOARD_CLAUDE_TOKEN`. + pub claude_token: Option>, + /// `BUZZ_ONBOARD_CODEX_TOKEN`. + pub codex_token: Option>, + /// `BUZZ_ONBOARD_OPENROUTER_KEY`. + pub openrouter_key: Option>, + /// `BUZZ_ONBOARD_OPENAI_API_KEY`. + pub openai_api_key: Option>, + /// `BUZZ_ONBOARD_ANTHROPIC_API_KEY`. + pub anthropic_api_key: Option>, +} + +impl EnvSecrets { + /// Reads the variables; empty ones count as unset. + pub fn from_env(import_key: bool) -> Self { + let read = |name: &str| { + std::env::var(name) + .ok() + .map(|value| Zeroizing::new(value.trim().to_owned())) + .filter(|value| !value.is_empty()) + }; + Self { + private_key: import_key.then(|| read("BUZZ_PRIVATE_KEY")).flatten(), + github_token: read("BUZZ_ONBOARD_GITHUB_TOKEN"), + op_token: read("BUZZ_ONBOARD_OP_TOKEN"), + claude_token: read("BUZZ_ONBOARD_CLAUDE_TOKEN"), + codex_token: read("BUZZ_ONBOARD_CODEX_TOKEN"), + openrouter_key: read("BUZZ_ONBOARD_OPENROUTER_KEY"), + openai_api_key: read("BUZZ_ONBOARD_OPENAI_API_KEY"), + anthropic_api_key: read("BUZZ_ONBOARD_ANTHROPIC_API_KEY"), + } + } +} + +/// The community as a canonical `wss://host[:port]` origin. Anything but +/// `wss://` is refused: the owner key and every credential travel over it. +pub fn canonical_relay(text: &str) -> Result { + let refuse = || { + CliError::Usage(format!( + "--relay must be a wss:// community origin such as {DEFAULT_RELAY}" + )) + }; + let url = url::Url::parse(text.trim()).map_err(|_| refuse())?; + if url.scheme() != "wss" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(refuse()); + } + Ok(url.as_str().trim_end_matches('/').to_owned()) +} + +/// The wizard: the saved state, the options, and its boundaries. +pub struct Wizard { + args: OnboardArgs, + env: EnvSecrets, + relay_url: String, + manager: PublicKey, + store: Store, + state: State, + prompter: P, + relay: R, + transport: T, + tools: X, + /// `--gateway-expires`: its canonical text and seconds. + gateway_lifetime: (String, u64), + /// Things the developer must still do; reported, never silent. + actions: Vec, + /// The `models` keys on file or about to be enrolled. + available_models: Vec, +} + +impl Wizard { + /// A wizard over `store` and the given boundaries. + #[allow(clippy::too_many_arguments)] + pub fn new( + args: OnboardArgs, + env: EnvSecrets, + store: Store, + prompter: P, + relay: R, + transport: T, + tools: X, + ) -> Result { + let relay_url = canonical_relay(&args.relay)?; + let gateway_lifetime = gateway::lifetime(&args.gateway_expires).map_err(CliError::Usage)?; + let manager = PublicKey::from_hex(&args.manager_pubkey) + .map_err(|_| CliError::Usage("the manager key must be 64 hex characters".into()))?; + let mut state = store.load().map_err(CliError::Other)?; + if state.relay.is_empty() { + state.relay = relay_url.clone(); + } else if state.relay != relay_url { + return Err(CliError::Usage(format!( + "{} holds onboarding for {}; pass --state-dir to onboard another community", + store.dir().display(), + state.relay + ))); + } + Ok(Self { + args, + env, + relay_url, + manager, + store, + state, + prompter, + relay, + transport, + tools, + gateway_lifetime, + actions: Vec::new(), + available_models: Vec::new(), + }) + } + + fn save(&self) -> Result<(), CliError> { + self.store.save(&self.state).map_err(CliError::Other) + } + + fn say(&mut self, text: &str) { + self.prompter.say(text); + } + + /// Runs every step in order and returns the summary. A step that fails + /// stops the run with its error; everything before it stays saved. A + /// failed first start or gateway grant still shows the next steps, then + /// fails the run. + pub async fn run(&mut self) -> Result { + self.say("Step 1 of 8: Buzz identity"); + let keys = self.identity().await?; + let on_file = self.on_file(&keys).await?; + let mut credentials = steps_credentials::Credentials::default(); + self.say("Step 2 of 8: GitHub"); + self.github(&on_file, &mut credentials).await?; + self.say("Step 3 of 8: Model credentials"); + self.models(&on_file, &mut credentials).await?; + self.say("Step 4 of 8: 1Password service account"); + self.op_token(&on_file, &mut credentials)?; + self.say("Step 5 of 8: Agents"); + let login = credentials.login.clone(); + self.agents(&keys, &login).await?; + self.say("Step 6 of 8: Enroll your profile"); + let pairing = self.enroll(&keys, &on_file, credentials).await?; + let started = self.first_start(&keys).await; + self.say("Step 7 of 8: Paseo gateway grant"); + let granted = self.gateway_grant(&keys, pairing.as_ref()).await; + self.say("Step 8 of 8: Next steps"); + self.next_steps(pairing.as_ref()); + let summary = self.summary(pairing.as_ref()); + started.and(granted).map(|()| summary) + } + + /// What was set up, without any secret. + fn summary(&self, pairing: Option<&paseo::Pairing>) -> Value { + json!({ + "relay": self.relay_url, + "owner_pubkey": self.state.owner_pubkey, + "state_dir": self.store.dir().display().to_string(), + "channel": self.state.channel.as_ref().map(|channel| json!({ + "id": channel.id, "name": channel.name, + })), + "agents": self.state.agents.iter().map(|agent| json!({ + "name": agent.name, + "pubkey": agent.pubkey, + "runtime": agent.runtime, + "registered": agent.registered, + })).collect::>(), + "enrolled_version": self.state.enrolled_version, + "paseo": pairing.map_or(json!({"provisioned": false}), |pairing| json!({ + "provisioned": true, + "server_id": pairing.server_id, + "offer_url": pairing.offer_url, + "gateway_pubkey": pairing.gateway.map(|key| key.to_hex()), + })), + "gateway_grant": self.state.gateway_grant.as_ref().map(|grant| json!({ + "gateway_pubkey": grant.gateway, "expires_at": grant.expires_at, + })), + "action_needed": self.actions, + }) + } +} + +/// `buzz onboard` with the real terminal, relay, manager and tools. +pub async fn run(args: OnboardArgs) -> Result<(), CliError> { + let dir = args + .state_dir + .clone() + .or_else(Store::default_dir) + .ok_or_else(|| CliError::Usage("no config directory: pass --state-dir".into()))?; + let relay_url = canonical_relay(&args.relay)?; + let env = EnvSecrets::from_env(args.import_key); + let prompter = io::Terminal::new(args.yes); + let mut wizard = Wizard::new( + args, + env, + Store::new(dir), + prompter, + relay::HttpRelay::new(&relay_url), + manager::WsTransport::new(&relay_url), + tools::SystemTools, + )?; + let summary = wizard.run().await?; + println!("{summary}"); + Ok(()) +} diff --git a/crates/buzz-cli/src/commands/onboard/paseo.rs b/crates/buzz-cli/src/commands/onboard/paseo.rs new file mode 100644 index 00000000000..db1835b8340 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/paseo.rs @@ -0,0 +1,75 @@ +//! The developer's Paseo pairing, from the Sandbox manager's `enroll` or +//! `profile` reply. A manager that provisions Paseo daemons adds +//! `paseo: {password, server_id, offer_url, gateway_pubkey?}` to both +//! replies (voltagecloud/buzz#150); an older manager adds nothing. + +use nostr::PublicKey; +use qrcode::render::unicode::Dense1x2; +use qrcode::QrCode; +use serde_json::Value; + +use super::state::Secret; + +/// The Paseo app's only offer origin. +pub const OFFER_PREFIX: &str = "https://app.paseo.sh/#offer="; + +/// How to pair a Paseo app with the developer's daemon. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pairing { + /// The daemon password. Shown once, never saved. + pub password: Secret, + /// The daemon's server ID (not secret). + pub server_id: String, + /// `https://app.paseo.sh/#offer=…`, which carries only public values. + pub offer_url: String, + /// The daemon's gateway key, when the manager gave it one. The owner + /// grants it ([`super::gateway`]) so the daemon can drive their agents. + pub gateway: Option, +} + +/// The pairing in a manager reply. `Ok(None)` when the reply has none; an +/// `Err` when it has one that is malformed, so a broken pairing is reported +/// rather than mistaken for "not provisioned". +pub fn pairing(response: &Value) -> Result, String> { + let Some(paseo) = response.get("paseo").filter(|paseo| !paseo.is_null()) else { + return Ok(None); + }; + let text = |name: &str| { + paseo + .get(name) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + }; + let malformed = || "The Sandbox manager's Paseo pairing was malformed.".to_owned(); + // Absent or null means no gateway key yet; anything else must be a key. + let gateway = match paseo.get("gateway_pubkey") { + None | Some(Value::Null) => None, + Some(Value::String(hex)) => Some(PublicKey::from_hex(hex).map_err(|_| malformed())?), + Some(_) => return Err(malformed()), + }; + match (text("password"), text("server_id"), text("offer_url")) { + (Some(password), Some(server_id), Some(offer_url)) + if offer_url.starts_with(OFFER_PREFIX) && offer_url.len() > OFFER_PREFIX.len() => + { + Ok(Some(Pairing { + password: Secret::new(password.to_owned()), + server_id: server_id.to_owned(), + offer_url: offer_url.to_owned(), + gateway, + })) + } + _ => Err(malformed()), + } +} + +/// `url` as a terminal QR code, two modules per character row. +pub fn qr(url: &str) -> Result { + let code = + QrCode::new(url.as_bytes()).map_err(|_| "The offer URL is too long for a QR code.")?; + Ok(code + .render::() + .dark_color(Dense1x2::Light) + .light_color(Dense1x2::Dark) + .quiet_zone(true) + .build()) +} diff --git a/crates/buzz-cli/src/commands/onboard/profile.rs b/crates/buzz-cli/src/commands/onboard/profile.rs new file mode 100644 index 00000000000..845887f1718 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/profile.rs @@ -0,0 +1,662 @@ +//! The developer profile `buzz onboard` enrolls, built and checked exactly as +//! the GPUI enroll form builds and checks it +//! (`apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll.rs`), +//! which mirrors the agent image's `profile.py`. Messages never name a value. + +use serde_json::{json, Map, Value}; +use zeroize::Zeroizing; + +/// A credential or setting the profile carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Field { + /// Fine-grained GitHub token for clone, push and PRs. + GithubToken, + /// A verified email on the GitHub account. + GitEmail, + /// The via profile TOML (only `op://` references). + ViaProfile, + /// 1Password service account token for the Buzz Agents Dev vault. + OpToken, + /// ChatGPT Business or Enterprise access token (`at-…`). + CodexAccessToken, + /// Claude subscription token from `claude setup-token`. + ClaudeOauthToken, + /// OpenAI API key. + OpenaiApiKey, + /// Anthropic API key. + AnthropicApiKey, + /// OpenRouter API key. + OpenrouterApiKey, +} + +impl Field { + /// Every field, in the GPUI form's order. + pub const ALL: [Field; 9] = [ + Field::GithubToken, + Field::GitEmail, + Field::ViaProfile, + Field::OpToken, + Field::CodexAccessToken, + Field::ClaudeOauthToken, + Field::OpenaiApiKey, + Field::AnthropicApiKey, + Field::OpenrouterApiKey, + ]; + + /// What the developer sees. + pub fn label(self) -> &'static str { + match self { + Field::GithubToken => "GitHub token", + Field::GitEmail => "Git email", + Field::ViaProfile => "via profile", + Field::OpToken => "1Password service account token", + Field::CodexAccessToken => "Codex access token", + Field::OpenaiApiKey => "OpenAI API key", + Field::AnthropicApiKey => "Anthropic API key", + Field::ClaudeOauthToken => "Claude subscription token", + Field::OpenrouterApiKey => "OpenRouter API key", + } + } + + /// The `models` entry it fills, for the model credentials. + pub fn model(self) -> Option<&'static str> { + match self { + Field::OpenaiApiKey => Some("openai_api_key"), + Field::CodexAccessToken => Some("codex_access_token"), + Field::AnthropicApiKey => Some("anthropic_api_key"), + Field::ClaudeOauthToken => Some("claude_oauth_token"), + Field::OpenrouterApiKey => Some("openrouter_api_key"), + _ => None, + } + } +} + +/// The collected values, by field. Wiped on drop. +#[derive(Default)] +pub struct Values([Zeroizing; Field::ALL.len()]); + +impl Values { + /// The text for `field`; empty when not collected. + pub fn get(&self, field: Field) -> &str { + &self.0[field as usize] + } + + /// Replaces the text for `field`. + pub fn set(&mut self, field: Field, text: String) { + self.0[field as usize] = Zeroizing::new(text); + } + + /// Whether `field` holds non-blank text. + pub fn filled(&self, field: Field) -> bool { + !self.get(field).trim().is_empty() + } +} + +/// Who a GitHub token belongs to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GitHubIdentity { + /// The account login. + pub login: String, + /// The profile name, or the login when the account has none. + pub name: String, +} + +/// What the manager holds for this developer: names and flags, never a +/// credential. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)] +pub struct Summary { + /// The stored profile version. + pub version_id: String, + /// When the profile was last stored (RFC 3339). + #[serde(default)] + pub updated_at: Option, + /// Git identity and signing key presence. + #[serde(default)] + pub git: GitSummary, + /// GitHub login and token presence. + #[serde(default)] + pub github: GitHubSummary, + /// via profile and 1Password token presence. + #[serde(default)] + pub via: ViaSummary, + /// The stored `models` entries, by key. + #[serde(default)] + pub models: Vec, + /// Whether a dedicated ChatGPT login is stored. + #[serde(default)] + pub codex_login: bool, +} + +/// The profile's Git section, without the key. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)] +pub struct GitSummary { + /// Git author name. + #[serde(default)] + pub name: String, + /// Git author email. + #[serde(default)] + pub email: String, + /// Whether an agent signing key is stored. + #[serde(default)] + pub signing_key: bool, +} + +/// The profile's GitHub section, without the token. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)] +pub struct GitHubSummary { + /// The token's login. + #[serde(default)] + pub login: String, + /// Whether a token is stored. + #[serde(default)] + pub token: bool, +} + +/// The profile's via section, without values. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)] +pub struct ViaSummary { + /// Whether a via profile is stored. + #[serde(default)] + pub profile: bool, + /// Whether a 1Password token is stored. + #[serde(default)] + pub op_token: bool, +} + +impl Summary { + /// Whether `field` has a stored value. + pub fn holds(&self, field: Field) -> bool { + match field { + Field::GithubToken => self.github.token, + Field::GitEmail => !self.git.email.is_empty(), + Field::ViaProfile => self.via.profile, + Field::OpToken => self.via.op_token, + _ => field + .model() + .is_some_and(|key| self.models.iter().any(|stored| stored == key)), + } + } + + /// One line naming what is on file, never a value. + pub fn text(&self) -> String { + let mut parts = Vec::new(); + if !self.github.login.is_empty() { + parts.push(format!("GitHub @{}", self.github.login)); + } + if !self.git.email.is_empty() { + parts.push(format!("Git email {}", self.git.email)); + } + for (held, name) in [ + (self.git.signing_key, "signing key"), + (self.via.profile, "via profile"), + (self.via.op_token, "1Password token"), + ] { + if held { + parts.push(name.to_owned()); + } + } + parts.extend( + Field::ALL + .into_iter() + .filter(|field| field.model().is_some() && self.holds(*field)) + .map(|field| field.label().to_owned()), + ); + if self.codex_login { + parts.push("ChatGPT login".into()); + } + format!("On file: {}.", parts.join(" · ")) + } +} + +/// What the `profile` request's `done` response says: the summary, `None` +/// when nothing is stored, or why not. +pub fn on_file_reply(response: &Value) -> Result, String> { + let malformed = || "The Sandbox manager's reply was malformed.".to_string(); + match response.get("ok").and_then(Value::as_bool) { + Some(true) => match response.get("profile") { + None | Some(Value::Null) => Ok(None), + Some(summary) => serde_json::from_value(summary.clone()) + .map(Some) + .map_err(|_| malformed()), + }, + Some(false) => Err(outcome(response)?.text()), + None => Err(malformed()), + } +} + +/// The complete profile for the owner `owner`, or why not. `github` is the +/// token's account and `signing_key` the agent-only key. The developer ID is +/// left for the Sandbox manager, which knows it from the owner key. +pub fn profile( + values: &Values, + owner: &str, + github: &GitHubIdentity, + signing_key: &str, +) -> Result { + let text = |field| values.get(field).trim(); + let models: Map = Field::ALL + .into_iter() + .filter_map(|field| Some((field.model()?, text(field)))) + .filter(|(_, value)| !value.is_empty()) + .map(|(key, value)| (key.to_owned(), value.into())) + .collect(); + let profile = json!({ + "version": 1, + "developer": "", + "owner_pubkey": owner, + "git": { + "name": github.name, + "email": text(Field::GitEmail), + "ssh_signing_key": signing_key, + }, + "github": {"login": github.login, "token": text(Field::GithubToken)}, + "via": {"profile": values.get(Field::ViaProfile), "op_token": text(Field::OpToken)}, + "models": models, + }); + validate(&profile)?; + Ok(profile) +} + +/// What an enrollment over a stored profile changes, beyond the values. +#[derive(Default)] +pub struct Changes<'a> { + /// The new GitHub token's account; `None` keeps the stored token. + pub github: Option<&'a GitHubIdentity>, + /// A new signing key; `None` keeps the stored one. + pub signing_key: Option<&'a str>, +} + +/// The partial profile the manager merges into the stored one: only the +/// fields filled in. Each field sent is checked as a full profile's is. +pub fn partial(values: &Values, owner: &str, changes: Changes) -> Result { + let text = |field| values.get(field).trim(); + let mut git = Map::new(); + if let Some(github) = changes.github { + git.insert("name".into(), github.name.clone().into()); + } + if !text(Field::GitEmail).is_empty() { + git.insert("email".into(), text(Field::GitEmail).into()); + } + if let Some(key) = changes.signing_key { + git.insert("ssh_signing_key".into(), key.into()); + } + let mut via = Map::new(); + if !text(Field::ViaProfile).is_empty() { + via.insert("profile".into(), values.get(Field::ViaProfile).into()); + } + if !text(Field::OpToken).is_empty() { + via.insert("op_token".into(), text(Field::OpToken).into()); + } + let mut models = Map::new(); + for field in Field::ALL { + if let Some(key) = field.model().filter(|_| !text(field).is_empty()) { + models.insert(key.to_owned(), text(field).into()); + } + } + let mut profile = json!({"version": 1, "developer": "", "owner_pubkey": owner}); + for (name, part) in [("git", git), ("via", via), ("models", models)] { + if !part.is_empty() { + profile[name] = Value::Object(part); + } + } + if let Some(github) = changes.github { + profile["github"] = json!({"login": github.login, "token": text(Field::GithubToken)}); + } + if profile + .as_object() + .is_some_and(|profile| profile.len() == 3) + { + return Err("Nothing to change: enter a credential to replace.".into()); + } + validate_partial(&profile)?; + Ok(profile) +} + +/// `profile.py`'s size limit, which Secrets Manager sets. +const MAX_PROFILE_BYTES: usize = 65_536; + +const PROFILE_FIELDS: [&str; 7] = [ + "version", + "developer", + "owner_pubkey", + "git", + "github", + "via", + "models", +]; + +const MODEL_FIELDS: [&str; 5] = [ + "openai_api_key", + "codex_access_token", + "anthropic_api_key", + "claude_oauth_token", + "openrouter_api_key", +]; + +/// `profile.py`'s `validate`, with its messages, which name no values. +pub fn validate(profile: &Value) -> Result<(), String> { + fields(profile, &PROFILE_FIELDS)?; + owner_fields(profile)?; + let git = &profile["git"]; + fields(git, &["name", "email", "ssh_signing_key"])?; + git_identity(&git["name"])?; + git_identity(&git["email"])?; + signing_key(&git["ssh_signing_key"])?; + let github = &profile["github"]; + fields(github, &["login", "token"])?; + github_account(github)?; + let via = &profile["via"]; + fields(via, &["profile", "op_token"])?; + token(&via["op_token"])?; + via_profile(&via["profile"])?; + models(&profile["models"])?; + size(profile) +} + +/// [`validate`] for a partial profile: each present field passes the same +/// rule as in a full one. +pub fn validate_partial(profile: &Value) -> Result<(), String> { + let only = |value: &Value, allowed: &[&str]| { + let ok = value + .as_object() + .is_some_and(|object| object.keys().all(|key| allowed.contains(&key.as_str()))); + require(ok, "Profile fields are incomplete or unsupported") + }; + only(profile, &PROFILE_FIELDS)?; + owner_fields(profile)?; + if let Some(git) = profile.get("git") { + only(git, &["name", "email", "ssh_signing_key"])?; + for name in ["name", "email"] { + git.get(name).map_or(Ok(()), git_identity)?; + } + git.get("ssh_signing_key").map_or(Ok(()), signing_key)?; + } + if let Some(github) = profile.get("github") { + fields(github, &["login", "token"])?; + github_account(github)?; + } + if let Some(via) = profile.get("via") { + only(via, &["profile", "op_token"])?; + via.get("op_token").map_or(Ok(()), token)?; + via.get("profile").map_or(Ok(()), via_profile)?; + } + if let Some(stored) = profile.get("models") { + models(stored)?; + } + size(profile) +} + +fn require(ok: bool, message: &str) -> Result<(), String> { + if ok { + Ok(()) + } else { + Err(message.to_owned()) + } +} + +fn lowercase_hex(text: &str, len: usize) -> bool { + text.len() == len && text.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +fn owner_fields(profile: &Value) -> Result<(), String> { + require( + profile["version"] == json!(1), + "Unsupported profile version", + )?; + let developer = profile.get("developer").unwrap_or(&Value::Null); + require( + developer.is_null() || developer.as_str().is_some_and(developer_id), + "Invalid developer ID", + )?; + require( + profile["owner_pubkey"] + .as_str() + .is_some_and(|owner| lowercase_hex(owner, 64)), + "Invalid Buzz owner", + ) +} + +/// `""`, or `[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?`. +fn developer_id(text: &str) -> bool { + let edge = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit(); + let bytes = text.as_bytes(); + text.is_empty() + || (bytes.len() <= 32 + && bytes.first().copied().is_some_and(edge) + && bytes.last().copied().is_some_and(edge) + && bytes.iter().all(|b| edge(*b) || *b == b'-')) +} + +fn git_identity(value: &Value) -> Result<(), String> { + let ok = value.as_str().is_some_and(|text| { + (1..=256).contains(&text.chars().count()) && !text.chars().any(|c| (c as u32) < 32) + }); + require(ok, "Invalid Git identity") +} + +/// `-----BEGIN OPENSSH PRIVATE KEY-----\n[A-Za-z0-9+/=\n]{1,4000}-----END +/// OPENSSH PRIVATE KEY-----\n?`. +fn signing_key(value: &Value) -> Result<(), String> { + const BEGIN: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\n"; + const END: &str = "-----END OPENSSH PRIVATE KEY-----"; + let ok = value.as_str().is_some_and(|text| { + let text = text.strip_suffix('\n').unwrap_or(text); + text.strip_prefix(BEGIN) + .and_then(|rest| rest.strip_suffix(END)) + .is_some_and(|body| { + (1..=4000).contains(&body.len()) + && body.bytes().all(|b| { + b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'\n') + }) + }) + }); + require(ok, "Invalid SSH signing key") +} + +/// `[a-zA-Z0-9][a-zA-Z0-9-]{0,38}`. +fn github_login(text: &str) -> bool { + let bytes = text.as_bytes(); + (1..=39).contains(&bytes.len()) + && bytes[0].is_ascii_alphanumeric() + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || *b == b'-') +} + +fn github_account(github: &Value) -> Result<(), String> { + require( + github["login"].as_str().is_some_and(github_login), + "Invalid GitHub login", + )?; + token(&github["token"]) +} + +fn via_profile(value: &Value) -> Result<(), String> { + let config = value.as_str().ok_or("Invalid via profile")?; + // The parser's error quotes the profile, so it is not shown. + let config: toml::Table = config.parse().map_err(|_| "Invalid via profile")?; + require( + truthy(config.get("services")) && truthy(config.get("providers")), + "via profile requires providers and services", + ) +} + +/// The `models` rules, including the two mutual exclusions. +pub fn models(models: &Value) -> Result<(), String> { + let models = models + .as_object() + .ok_or("Unsupported model credential type")?; + require( + models + .keys() + .all(|key| MODEL_FIELDS.contains(&key.as_str())), + "Unsupported model credential type", + )?; + // Codex reads the access token before its saved API-key login, so allow only one. + require( + !(models.contains_key("openai_api_key") && models.contains_key("codex_access_token")), + "Choose either an OpenAI API key or a Codex access token", + )?; + // Claude Code prefers an API key over the subscription token, so allow only one. + require( + !(models.contains_key("anthropic_api_key") && models.contains_key("claude_oauth_token")), + "Choose either a Claude API key or a Claude subscription token", + )?; + for value in models.values() { + token(value)?; + } + require( + models + .get("codex_access_token") + .is_none_or(|value| value.as_str().is_some_and(|value| value.starts_with("at-"))), + "Use a Codex access token (at-...)", + ) +} + +fn size(profile: &Value) -> Result<(), String> { + require( + profile.to_string().len() <= MAX_PROFILE_BYTES, + "Profile exceeds Secrets Manager size limit", + ) +} + +/// An object with exactly `expected`. +fn fields(value: &Value, expected: &[&str]) -> Result<(), String> { + let ok = value.as_object().is_some_and(|object| { + object.len() == expected.len() && expected.iter().all(|key| object.contains_key(*key)) + }); + require(ok, "Profile fields are incomplete or unsupported") +} + +/// 1 to 8192 characters, no whitespace. +fn token(value: &Value) -> Result<(), String> { + let ok = value.as_str().is_some_and(|text| { + (1..=8192).contains(&text.chars().count()) && !text.chars().any(char::is_whitespace) + }); + require(ok, "Invalid credential") +} + +/// Python's truthiness, for the via profile's tables. +fn truthy(value: Option<&toml::Value>) -> bool { + match value { + None => false, + Some(toml::Value::String(text)) => !text.is_empty(), + Some(toml::Value::Integer(n)) => *n != 0, + Some(toml::Value::Float(n)) => *n != 0.0, + Some(toml::Value::Boolean(b)) => *b, + Some(toml::Value::Datetime(_)) => true, + Some(toml::Value::Array(items)) => !items.is_empty(), + Some(toml::Value::Table(table)) => !table.is_empty(), + } +} + +/// How an enrollment ended. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + /// The manager stored a new profile version. + Enrolled { + /// The stored version. + version_id: String, + }, + /// The manager refused it with `code`. + Refused { + /// The manager's refusal code. + code: String, + }, +} + +/// The manager's refusal codes, and what each asks of the developer. +pub const REFUSALS: &[(&str, &str)] = &[ + ( + "unauthorized_owner", + "This Buzz identity is not a configured developer. Ask an admin to add your owner key \ + (see voltage/remote-agents.md, Onboard a developer), then run buzz onboard again.", + ), + ( + "profile_owner_mismatch", + "The profile's owner is not this Buzz identity.", + ), + ( + "profile_developer_mismatch", + "The developer ID is not the one configured for this identity.", + ), + ( + "invalid_profile", + "The Sandbox manager found the profile invalid.", + ), + ( + "github_login_mismatch", + "The GitHub token belongs to a different login.", + ), + ( + "profile_store_failed", + "The Sandbox manager could not store the profile. Run buzz onboard again.", + ), + ( + "paseo_store_failed", + "The Sandbox manager could not store your Paseo daemon's secret, so your profile is unchanged. \ + Run buzz onboard again (with --reenroll if you were changing a credential).", + ), + // Refusals of any session request (buzz-session-control's transport). + ( + "signer_mismatch", + "The request was not signed by your owner key. Run buzz onboard again.", + ), + ( + "stale_request", + "The Sandbox manager's clock and this computer's differ. Set your clock automatically, then run buzz onboard again.", + ), + ( + "replayed_request", + "The Sandbox manager already received this request. Run buzz onboard again.", + ), + ( + "request_id_reused", + "The request ID was already in use. Run buzz onboard again.", + ), + ( + "manager_busy", + "The Sandbox manager is busy. Wait a minute, then run buzz onboard again.", + ), + ( + "unsupported_op", + "The Sandbox manager does not support enrollment. Ask an admin to update it.", + ), + ( + "unsupported_version", + "The Sandbox manager does not support this request version. Update buzz, or ask an admin to update the manager.", + ), +]; + +impl Outcome { + /// What the developer sees. + pub fn text(&self) -> String { + match self { + Outcome::Enrolled { version_id } => format!("Enrolled. Profile version {version_id}."), + Outcome::Refused { code } => { + let reason = REFUSALS + .iter() + .find(|(known, _)| known == code) + .map_or("", |(_, reason)| reason); + format!("Refused ({code}). {reason}").trim_end().to_owned() + } + } + } +} + +/// What a `done` reply's response says. Only the code of a refusal is read: +/// its message is the manager's. +pub fn outcome(response: &Value) -> Result { + let text = |name: &str| { + response + .get(name) + .and_then(Value::as_str) + .map(str::to_owned) + }; + match response.get("ok").and_then(Value::as_bool) { + Some(true) => text("version_id").map(|version_id| Outcome::Enrolled { version_id }), + Some(false) => text("code").map(|code| Outcome::Refused { code }), + None => None, + } + .ok_or_else(|| "The Sandbox manager's reply was malformed.".into()) +} diff --git a/crates/buzz-cli/src/commands/onboard/relay.rs b/crates/buzz-cli/src/commands/onboard/relay.rs new file mode 100644 index 00000000000..df7585b6d95 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/relay.rs @@ -0,0 +1,255 @@ +//! The community boundary: the relay's HTTP bridge (`POST /query`, +//! `POST /events`) and its invite endpoints, through the same NIP-98 +//! client every other `buzz` command uses. + +use nostr::{Event, Keys, Tag}; +use serde_json::{json, Value}; + +use crate::client::BuzzClient; +use crate::error::CliError; + +/// Who signs a request: a key, and for an agent its owner's attestation. +#[derive(Clone)] +pub struct Signer { + /// The signing key. + pub keys: Keys, + /// The NIP-OA `auth` tag JSON, for an agent key. + pub auth_tag: Option, +} + +impl Signer { + /// The owner, who needs no attestation. + pub fn owner(keys: &Keys) -> Self { + Self { + keys: keys.clone(), + auth_tag: None, + } + } +} + +/// The community's join policy, when it has one. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct JoinPolicy { + /// The policy revision to accept. + pub version: String, + /// Whether joining requires a minimum-age assertion. + pub age_attestation_required: bool, +} + +/// How a claim ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Claimed { + /// The key joined now. + Joined, + /// The key was already a member. + AlreadyMember, +} + +/// What `buzz onboard` asks of the community. +pub trait Relay { + /// Whether `keys` can read the community: a member's query succeeds, + /// anyone else's is refused with 401 or 403. + async fn is_member(&self, keys: &Keys) -> Result; + + /// The join policy, `None` when there is none. + async fn join_policy(&self) -> Result, CliError>; + + /// Accepts the join policy for `code`, returning the relay's receipt. + async fn accept_policy( + &self, + keys: &Keys, + code: &str, + policy: &JoinPolicy, + age_confirmed: bool, + ) -> Result; + + /// Claims the invite `code` as `keys`. + async fn claim( + &self, + keys: &Keys, + code: &str, + receipt: Option<&str>, + ) -> Result; + + /// Events matching `filter`, read as `keys`. + async fn query(&self, keys: &Keys, filter: Value) -> Result, CliError>; + + /// Submits `event`, signed by `signer`; fails unless the relay accepts it. + async fn publish(&self, signer: &Signer, event: Event) -> Result<(), CliError>; +} + +/// The relay's HTTP bridge at `base` (`https://host`). +pub struct HttpRelay { + base: String, +} + +impl HttpRelay { + /// The bridge of the `wss://` community `relay`. + pub fn new(relay: &str) -> Self { + Self { + base: crate::client::normalize_relay_url(relay), + } + } + + fn client(&self, signer: &Signer) -> Result { + let (tag, json) = match &signer.auth_tag { + Some(json) => { + let tag: Tag = buzz_sdk::nip_oa::parse_auth_tag(json) + .map_err(|_| CliError::Auth("the agent attestation is malformed".into()))?; + (Some(tag), Some(json.clone())) + } + None => (None, None), + }; + BuzzClient::new(self.base.clone(), signer.keys.clone(), tag, json) + } +} + +fn denied(error: &CliError) -> bool { + matches!( + error, + CliError::Relay { + status: 401 | 403, + .. + } | CliError::Auth(_) + ) +} + +impl Relay for HttpRelay { + async fn is_member(&self, keys: &Keys) -> Result { + let client = self.client(&Signer::owner(keys))?; + let filter = json!({"kinds": [0], "authors": [keys.public_key().to_hex()], "limit": 1}); + match client.query(&filter).await { + Ok(_) => Ok(true), + Err(error) if denied(&error) => Ok(false), + Err(error) => Err(error), + } + } + + async fn join_policy(&self) -> Result, CliError> { + let client = self.client(&Signer::owner(&Keys::generate()))?; + let body = match client.get_public("/api/join-policy").await { + Ok(body) => body, + Err(CliError::Relay { status: 404, .. }) => return Ok(None), + Err(error) => return Err(error), + }; + parse_join_policy(&body) + } + + async fn accept_policy( + &self, + keys: &Keys, + code: &str, + policy: &JoinPolicy, + age_confirmed: bool, + ) -> Result { + let client = self.client(&Signer::owner(keys))?; + let body = json!({ + "code": code, + "policy_version": policy.version, + "age_confirmed": age_confirmed, + }); + let reply = client + .post_json_authed("/api/invites/accept-policy", &body) + .await?; + serde_json::from_str::(&reply) + .ok() + .and_then(|value| value.get("receipt")?.as_str().map(str::to_owned)) + .filter(|receipt| !receipt.is_empty()) + .ok_or_else(|| CliError::Other("The relay did not accept the join policy.".into())) + } + + async fn claim( + &self, + keys: &Keys, + code: &str, + receipt: Option<&str>, + ) -> Result { + let client = self.client(&Signer::owner(keys))?; + let mut body = json!({"code": code}); + if let Some(receipt) = receipt { + body["policy_receipt"] = json!(receipt); + } + let reply = client + .post_json_authed("/api/invites/claim", &body) + .await + .map_err(claim_error)?; + parse_claim(&reply) + } + + async fn query(&self, keys: &Keys, mut filter: Value) -> Result, CliError> { + // Provisioning decisions must see prior writes, including after a + // lost acknowledgement or local-state write failure. + filter + .as_object_mut() + .ok_or_else(|| CliError::Other("Provisioning filter must be an object.".into()))? + .insert("consistency".into(), json!("strong")); + self.client(&Signer::owner(keys))? + .query_all_bounded(filter, 500) + .await + } + + async fn publish(&self, signer: &Signer, event: Event) -> Result<(), CliError> { + let reply = self.client(signer)?.submit_event(event).await?; + accepted(&reply) + } +} + +/// `{"policy": {...}}`, or `{}` for none. +pub fn parse_join_policy(body: &str) -> Result, CliError> { + let malformed = || CliError::Other("The relay returned an invalid join policy.".into()); + let value: Value = serde_json::from_str(body).map_err(|_| malformed())?; + let Some(policy) = value.get("policy").filter(|policy| !policy.is_null()) else { + return Ok(None); + }; + Ok(Some(JoinPolicy { + version: policy["version"].as_str().ok_or_else(malformed)?.to_owned(), + age_attestation_required: policy["age_attestation_required"] + .as_bool() + .ok_or_else(malformed)?, + })) +} + +/// `{"status":"joined"|"already_member", …}`; nothing else counts. +pub fn parse_claim(body: &str) -> Result { + let value: Value = serde_json::from_str(body) + .map_err(|_| CliError::Other("The relay returned an invalid invite response.".into()))?; + match value.get("status").and_then(Value::as_str) { + Some("joined") => Ok(Claimed::Joined), + Some("already_member") => Ok(Claimed::AlreadyMember), + _ => Err(CliError::Other( + "The relay returned an invalid invite response.".into(), + )), + } +} + +/// The relay's invite refusals, worded as the Buzz app words them. +fn claim_error(error: CliError) -> CliError { + let CliError::Relay { status, body } = &error else { + return error; + }; + let text = match (status, body.as_str()) { + (429, _) => "Too many invite attempts. Wait a minute and run buzz onboard again.", + (_, "invite_expired") => "This invite code has expired. Ask for a new one.", + (_, "invite_exhausted") => "This invite has reached its use limit. Ask for a new invite.", + (_, "invite_invalid") => "This invite code is not valid for this community.", + (_, "join_policy_required") => "This community requires current policy acceptance.", + _ => return error, + }; + CliError::Auth(text.into()) +} + +/// The relay's write receipt must say `accepted: true`. +pub fn accepted(reply: &str) -> Result<(), CliError> { + let value: Value = serde_json::from_str(reply).unwrap_or(Value::Null); + if value.get("accepted").and_then(Value::as_bool) == Some(true) { + Ok(()) + } else { + let message = value + .get("message") + .and_then(Value::as_str) + .unwrap_or("no receipt"); + Err(CliError::Other(format!( + "the relay did not accept the event: {message}" + ))) + } +} diff --git a/crates/buzz-cli/src/commands/onboard/state.rs b/crates/buzz-cli/src/commands/onboard/state.rs new file mode 100644 index 00000000000..731a78f8c77 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/state.rs @@ -0,0 +1,267 @@ +//! What `buzz onboard` keeps between runs, so a partial onboarding resumes +//! where it stopped. Everything lives in one private directory (mode +//! `0700`): the owner key in `identity.nsec` and the progress in +//! `onboard.json`, both mode `0600`, each written atomically. Model, GitHub +//! and 1Password credentials are never written here: they go only to the +//! Sandbox manager, encrypted, as the Buzz app sends them. + +use std::fmt; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use zeroize::Zeroizing; + +/// The state file's format version. +pub const VERSION: u32 = 1; + +/// A secret string: wiped on drop, redacted in `Debug`. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct Secret(Zeroizing); + +impl Secret { + /// Wraps `text`. + pub fn new(text: String) -> Self { + Self(Zeroizing::new(text)) + } + + /// The secret text. Never print it. + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("") + } +} + +impl Serialize for Secret { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Secret { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer).map(Secret::new) + } +} + +/// The workspace channel the agents were added to. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChannelRef { + /// The channel UUID. + pub id: String, + /// Its name when chosen. + pub name: String, +} + +/// One agent `buzz onboard` created, and how far its setup got. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentRecord { + /// Display name. + pub name: String, + /// The preset it was made from. + pub preset: String, + /// The harness: `claude`, `codex` or `buzz-agent`. + pub runtime: String, + /// Empty uses the harness default. + pub model: String, + /// Empty uses the model default. + pub effort: String, + /// The harness system prompt. + pub instructions: String, + /// The agent's public key, hex. + pub pubkey: String, + /// The agent's secret key, `nsec`. The Sandbox manager receives it + /// encrypted in the first start, as from the Buzz app's host. + pub secret: Secret, + /// The owner's NIP-OA attestation JSON for this agent. + pub auth_tag: String, + /// The agent-signed kind:0 profile was accepted. + #[serde(default)] + pub profile_published: bool, + /// The owner's kind 30177 record was accepted. + #[serde(default)] + pub listed: bool, + /// The agent is a member of the workspace channel. + #[serde(default)] + pub member: bool, + /// The manager stored its launch registration (first start succeeded). + #[serde(default)] + pub registered: bool, + /// The signed first-start message, saved before any publication or deploy. + #[serde(default)] + pub kickoff: Option, + /// The relay acknowledged the kickoff before a deploy could be attempted. + #[serde(default)] + pub kickoff_published: bool, +} + +/// A signing key the manager stored but GitHub may not have yet. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingKey { + /// The GitHub login it belongs on. + pub login: String, + /// The key's title on GitHub. + pub title: String, + /// The public OpenSSH line (not secret). + pub public: String, +} + +/// A gateway grant the relay accepted. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct GatewayGrant { + /// The granted gateway key, hex. + pub gateway: String, + /// When the grant expires, unix seconds. + pub expires_at: u64, +} + +/// Onboarding progress. Holds no model, GitHub or 1Password credential. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct State { + /// Always [`VERSION`]. + pub version: u32, + /// The community, as a canonical `wss://` origin. + #[serde(default)] + pub relay: String, + /// The owner public key, hex. + #[serde(default)] + pub owner_pubkey: String, + /// The owner is a member of the community. + #[serde(default)] + pub joined: bool, + /// The owner has a kind:0 profile. + #[serde(default)] + pub owner_profile: bool, + /// The workspace channel. + #[serde(default)] + pub channel: Option, + /// The agents created here. + #[serde(default)] + pub agents: Vec, + /// The last profile version the manager stored. + #[serde(default)] + pub enrolled_version: Option, + /// A stored signing key still to add on GitHub. + #[serde(default)] + pub pending_key: Option, + /// The Paseo daemon's last accepted gateway grant. + #[serde(default)] + pub gateway_grant: Option, +} + +/// The private directory and its two files. +pub struct Store { + dir: PathBuf, +} + +const STATE_FILE: &str = "onboard.json"; +const IDENTITY_FILE: &str = "identity.nsec"; + +impl Store { + /// The store at `dir`, created on first write. + pub fn new(dir: PathBuf) -> Self { + Self { dir } + } + + /// `$XDG_CONFIG_HOME/buzz` (or the platform's config directory). + pub fn default_dir() -> Option { + dirs::config_dir().map(|dir| dir.join("buzz")) + } + + /// The directory. + pub fn dir(&self) -> &Path { + &self.dir + } + + /// Where the owner key lives. + pub fn identity_path(&self) -> PathBuf { + self.dir.join(IDENTITY_FILE) + } + + /// The saved progress, or a fresh state. + pub fn load(&self) -> Result { + let path = self.dir.join(STATE_FILE); + match std::fs::read(&path) { + Ok(bytes) => { + let state: State = serde_json::from_slice(&bytes) + .map_err(|_| format!("{} is not valid onboarding state", path.display()))?; + if state.version != VERSION { + return Err(format!( + "{} has unsupported version {}", + path.display(), + state.version + )); + } + Ok(state) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(State { + version: VERSION, + ..State::default() + }), + Err(_) => Err(format!("could not read {}", path.display())), + } + } + + /// Saves `state` atomically, mode `0600`. + pub fn save(&self, state: &State) -> Result<(), String> { + let bytes = Zeroizing::new( + serde_json::to_vec_pretty(state).map_err(|_| "could not encode onboarding state")?, + ); + self.write_private(STATE_FILE, &bytes) + } + + /// The saved owner key, if any. + pub fn load_identity(&self) -> Result, String> { + let path = self.identity_path(); + match std::fs::read_to_string(&path) { + Ok(text) => Ok(Some(Secret::new(text.trim().to_owned()))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(format!("could not read {}", path.display())), + } + } + + /// Saves the owner key (`nsec`), mode `0600`. + pub fn save_identity(&self, nsec: &Secret) -> Result<(), String> { + let text = Zeroizing::new(format!("{}\n", nsec.expose())); + self.write_private(IDENTITY_FILE, text.as_bytes()) + } + + /// Writes `name` in the private directory through a `0600` temporary + /// file in the same directory, then renames it into place. + fn write_private(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + create_private_dir(&self.dir)?; + let target = self.dir.join(name); + let failed = || format!("could not write {}", target.display()); + let mut file = tempfile::Builder::new() + .prefix(".onboard-") + .tempfile_in(&self.dir) + .map_err(|_| failed())?; + restrict(file.path(), 0o600).map_err(|_| failed())?; + file.write_all(bytes).map_err(|_| failed())?; + file.as_file().sync_all().map_err(|_| failed())?; + file.persist(&target).map_err(|_| failed())?; + restrict(&target, 0o600).map_err(|_| failed()) + } +} + +fn create_private_dir(dir: &Path) -> Result<(), String> { + std::fs::create_dir_all(dir).map_err(|_| format!("could not create {}", dir.display()))?; + restrict(dir, 0o700).map_err(|_| format!("could not restrict {}", dir.display())) +} + +#[cfg(unix)] +fn restrict(path: &Path, mode: u32) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) +} + +#[cfg(not(unix))] +fn restrict(_path: &Path, _mode: u32) -> std::io::Result<()> { + // Windows ACLs inherit the user profile's private configuration directory. + Ok(()) +} diff --git a/crates/buzz-cli/src/commands/onboard/steps_agents.rs b/crates/buzz-cli/src/commands/onboard/steps_agents.rs new file mode 100644 index 00000000000..839a824b38a --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/steps_agents.rs @@ -0,0 +1,437 @@ +//! Step 5 (agents and their channel) and the first start that registers +//! each new agent with the Sandbox manager. + +use std::collections::HashSet; + +use buzz_session_control::Op; +use nostr::Keys; +use serde_json::{json, Value}; +use uuid::Uuid; + +use super::agents::{self, LISTING_KIND, PRESETS}; +use super::io::Prompter; +use super::manager::{self, ControlTransport}; +use super::relay::{Relay, Signer}; +use super::state::ChannelRef; +use super::tools::Tools; +use super::Wizard; +use crate::error::CliError; + +impl Wizard { + /// Step 5. Creates agents from presets, each with its own key the owner + /// attests, and publishes what lists it in every Buzz app: its kind:0 + /// profile, the owner's kind 30177 record, and its `bot` membership in + /// the workspace channel. Agents the Buzz app already lists are shown and + /// never duplicated. Each write is saved as it lands, so a re-run + /// finishes only what is missing. + pub async fn agents(&mut self, keys: &Keys, login: &str) -> Result<(), CliError> { + let owner = keys.public_key().to_hex(); + let filter = json!({"kinds": [LISTING_KIND], "authors": [owner], "limit": 500}); + let events = self.relay.query(keys, filter).await?; + let ours: HashSet = self.state.agents.iter().map(|a| a.pubkey.clone()).collect(); + let others: Vec<(String, String)> = agents::remote_listings(&events, &owner) + .into_iter() + .filter(|(pubkey, _)| !ours.contains(pubkey)) + .collect(); + if !others.is_empty() { + let names: Vec<_> = others.iter().map(|(_, name)| name.as_str()).collect(); + self.say(&format!( + " Already in your Buzz app: {}. They keep running from the app.", + names.join(", ") + )); + } + for preset in self.wanted_presets(!others.is_empty())? { + if self + .state + .agents + .iter() + .any(|agent| agent.preset == preset.id) + { + continue; + } + if others + .iter() + .any(|(_, name)| name.eq_ignore_ascii_case(preset.name)) + { + self.say(&format!( + " Skipped {}: an agent with that name is already listed.", + preset.name + )); + continue; + } + if !agents::credential_available(preset.needs, &self.available_models) { + self.actions.push(format!( + "{} runs {} and needs {}: add it with buzz onboard --reenroll.", + preset.name, preset.runtime, preset.needs + )); + } + // Saved before anything is published, so its key is never lost. + self.state.agents.push(agents::create(preset, keys)?); + self.save()?; + } + if self.state.agents.is_empty() { + self.say(" No agents to set up."); + return Ok(()); + } + let channel = self.channel(keys, login).await?; + let channel_id = Uuid::parse_str(&channel.id) + .map_err(|_| CliError::Other("the saved channel ID is invalid".into()))?; + for index in 0..self.state.agents.len() { + let agent = self.state.agents[index].clone(); + if !agent.profile_published { + let signer = Signer { + keys: agents::keys(&agent)?, + auth_tag: Some(agent.auth_tag.clone()), + }; + self.relay + .publish(&signer, agents::profile_event(&agent)?) + .await?; + self.state.agents[index].profile_published = true; + self.save()?; + } + if !agent.listed { + let event = agents::listing_event(&agent, keys)?; + self.relay.publish(&Signer::owner(keys), event).await?; + self.state.agents[index].listed = true; + self.save()?; + } + if !agent.member { + let event = agents::member_event(&agent, channel_id, keys)?; + self.relay.publish(&Signer::owner(keys), event).await?; + self.state.agents[index].member = true; + self.save()?; + } + self.say(&format!( + " {} ({}): listed, and in #{}.", + agent.name, + agents::RUNTIMES + .iter() + .find(|runtime| runtime.id == agent.runtime) + .map_or(agent.runtime.as_str(), |runtime| runtime.label), + channel.name + )); + } + Ok(()) + } + + /// The presets to create: `--agents`, nothing more on a re-run, or the + /// developer's choice (Brokkr unless the Buzz app already lists agents). + fn wanted_presets( + &mut self, + has_others: bool, + ) -> Result, CliError> { + let answer = if !self.args.agents.is_empty() { + self.args.agents.join(",") + } else if !self.state.agents.is_empty() { + return Ok(Vec::new()); + } else { + let default = if has_others { "none" } else { "brokkr" }; + if self.prompter.interactive() { + for preset in PRESETS { + let line = format!( + " {:<10} {:<8} {:<11} effort {:<6} {}", + preset.id, + preset.name, + preset.runtime, + if preset.effort.is_empty() { + "-" + } else { + preset.effort + }, + if preset.model.is_empty() { + "(harness default model)" + } else { + preset.model + }, + ); + self.say(&line); + } + } + self.prompter.ask( + " Agents to create (comma-separated presets, or none)", + Some(default), + "pass --agents", + )? + }; + let mut presets = Vec::new(); + for id in answer.split(',').map(str::trim).filter(|id| !id.is_empty()) { + if id.eq_ignore_ascii_case("none") { + continue; + } + let preset = agents::preset(id).ok_or_else(|| { + let ids: Vec<_> = PRESETS.iter().map(|preset| preset.id).collect(); + CliError::Usage(format!( + "Unknown agent preset {id:?}; choose from {}.", + ids.join(", ") + )) + })?; + if !presets + .iter() + .any(|known: &&agents::Preset| known.id == preset.id) + { + presets.push(preset); + } + } + Ok(presets) + } + + /// The workspace channel: the saved one, `--channel` (a UUID, or the + /// name of one of the owner's channels), or a new private stream channel. + async fn channel(&mut self, keys: &Keys, login: &str) -> Result { + if let Some(channel) = &self.state.channel { + return Ok(channel.clone()); + } + let default = if login.is_empty() { + format!("agents-{}", &self.state.owner_pubkey[..8]) + } else { + format!("{}-agents", login.to_ascii_lowercase()) + }; + let wanted = match self.args.channel.clone() { + Some(channel) => channel, + None => self.prompter.ask( + " Workspace channel for your agents (a name or UUID)", + Some(&default), + "pass --channel", + )?, + }; + let wanted = buzz_core::channel::canonical_channel_name(&wanted).to_owned(); + let channel = match Uuid::parse_str(&wanted) { + Ok(id) => { + let filter = json!({"kinds": [39000], "#d": [id.to_string()], "limit": 1}); + let found = self.relay.query(keys, filter).await?; + let event = found.first().ok_or_else(|| { + CliError::NotFound(format!("No channel {id} is visible to you.")) + })?; + ChannelRef { + id: id.to_string(), + name: crate::client::extract_tag_value(event, "name"), + } + } + Err(_) => match self.own_channel(keys, &wanted).await? { + Some(channel) => channel, + None => self.create_channel(keys, &wanted).await?, + }, + }; + self.say(&format!( + " Workspace channel #{} ({}).", + channel.name, channel.id + )); + self.state.channel = Some(channel.clone()); + self.save()?; + Ok(channel) + } + + /// One of the owner's channels named `name`. + async fn own_channel(&self, keys: &Keys, name: &str) -> Result, CliError> { + let filter = json!({"kinds": [39002], "#p": [keys.public_key().to_hex()], "limit": 500}); + let ids: Vec = self + .relay + .query(keys, filter) + .await? + .iter() + .map(crate::client::extract_d_tag) + .filter(|id| !id.is_empty()) + .collect(); + if ids.is_empty() { + return Ok(None); + } + let filter = json!({"kinds": [39000], "#d": ids, "limit": 500}); + Ok(self + .relay + .query(keys, filter) + .await? + .iter() + .find(|event| { + crate::client::extract_tag_value(event, "name").eq_ignore_ascii_case(name) + }) + .map(|event| ChannelRef { + id: crate::client::extract_d_tag(event), + name: crate::client::extract_tag_value(event, "name"), + })) + } + + async fn create_channel(&self, keys: &Keys, name: &str) -> Result { + let id = Uuid::new_v4(); + let event = buzz_sdk::build_create_channel( + id, + name, + Some(buzz_sdk::Visibility::Private), + Some(buzz_sdk::ChannelKind::Stream), + Some("Remote workspace agents set up with buzz onboard"), + None, + false, + ) + .map_err(|error| CliError::Usage(format!("That channel name is not valid: {error}")))? + .sign_with_keys(keys) + .map_err(|error| CliError::Other(format!("signing failed: {error}")))?; + self.relay.publish(&Signer::owner(keys), event).await?; + Ok(ChannelRef { + id: id.to_string(), + name: name.to_owned(), + }) + } + + /// Starts each agent the manager has not registered yet, once, as the + /// Buzz app's host starts an agent for a mention: the owner mentions it + /// in a new thread of the workspace channel, and the agent signs a + /// `deploy` for that thread through the relay. On success the manager + /// stores the agent's launch registration, so later mentions from any + /// Buzz app or Paseo start it. Every failure is reported and stays + /// pending for the next run. + pub async fn first_start(&mut self, keys: &Keys) -> Result<(), CliError> { + let pending: Vec = (0..self.state.agents.len()) + .filter(|index| !self.state.agents[*index].registered) + .collect(); + if pending.is_empty() { + return Ok(()); + } + if self.args.skip_first_start { + for index in &pending { + let name = self.state.agents[*index].name.clone(); + self.actions.push(format!( + "{name} is not registered with the Sandbox manager yet, so mentions do not start it and your Paseo daemon does not list it: run buzz onboard without --skip-first-start." + )); + } + return Ok(()); + } + if self.state.enrolled_version.is_none() { + return Err(CliError::Other( + "Enroll your profile before your agents can start.".into(), + )); + } + let channel = self + .state + .channel + .clone() + .ok_or_else(|| CliError::Other("No workspace channel is saved.".into()))?; + let channel_id = Uuid::parse_str(&channel.id) + .map_err(|_| CliError::Other("the saved channel ID is invalid".into()))?; + self.say(&format!( + " Starting {} agent(s) once in #{} to register them. The first start can take a few minutes.", + pending.len(), + channel.name + )); + let owner = keys.public_key().to_hex(); + let mut jobs = Vec::new(); + for index in pending { + let agent = self.state.agents[index].clone(); + let kickoff = self.first_start_kickoff(index, channel_id, keys).await?; + let payload = agents::deploy_payload( + &agent, + &self.relay_url, + &owner, + &channel.id, + &kickoff.id.to_hex(), + kickoff.created_at.as_secs(), + ); + let signer = Signer { + keys: agents::keys(&agent)?, + auth_tag: Some(agent.auth_tag.clone()), + }; + jobs.push((index, signer, payload)); + } + let transport = &self.transport; + let manager_key = self.manager; + let results = futures_util::future::join_all(jobs.iter().map(|(_, signer, payload)| { + manager::ask( + transport, + &manager_key, + signer, + Op::Deploy, + payload.clone(), + manager::DEPLOY_TIMEOUT, + ) + })) + .await; + let mut failures = Vec::new(); + for ((index, _, _), result) in jobs.iter().zip(results) { + let name = self.state.agents[*index].name.clone(); + match result { + Ok(response) if response.get("ok").and_then(Value::as_bool) == Some(true) => { + self.state.agents[*index].registered = true; + self.save()?; + self.say(&format!(" {name} started and is registered.")); + } + Ok(response) => { + failures.push(format!("{name}: {}", manager::refusal_text(&response))) + } + Err(error) => failures.push(format!("{name}: {error}")), + } + } + if failures.is_empty() { + return Ok(()); + } + for failure in &failures { + self.say(&format!(" Did not start {failure}")); + } + Err(CliError::Other(format!( + "{} agent(s) did not start: {}. Run buzz onboard again to retry.", + failures.len(), + failures.join("; ") + ))) + } +} + +impl Wizard { + /// Resume one durable first-start identity across publication and deploy + /// failures. A manager refusal can follow an already committed launch. + async fn first_start_kickoff( + &mut self, + index: usize, + channel: Uuid, + keys: &Keys, + ) -> Result { + if let Some(event) = self.state.agents[index].kickoff.clone() { + let found = self.relay.query(keys, json!({ + "kinds": [9], "ids": [event.id.to_hex()], "#h": [channel.to_string()], "limit": 1, + })).await?; + let mut seen = false; + for value in found { + let stored: nostr::Event = serde_json::from_value(value) + .map_err(|_| CliError::Other("Invalid first-start lookup response.".into()))?; + if stored.id != event.id || stored.verify().is_err() { + return Err(CliError::Other( + "Unrelated first-start lookup response.".into(), + )); + } + seen = true; + } + if seen { + if !self.state.agents[index].kickoff_published { + self.state.agents[index].kickoff_published = true; + self.save()?; + } + return Ok(event); + } + if self.state.agents[index].kickoff_published { + return Err(CliError::Other(format!( + "The saved first-start thread {} is unavailable. Check that thread before retrying; a Sandbox may already be running.", event.id.to_hex() + ))); + } + // No deploy was attempted before the durable publication flag. + // An unposted expired message can therefore be replaced safely. + if nostr::Timestamp::now() + .as_secs() + .abs_diff(event.created_at.as_secs()) + <= 840 + { + self.relay + .publish(&Signer::owner(keys), event.clone()) + .await?; + self.state.agents[index].kickoff_published = true; + self.save()?; + return Ok(event); + } + } + let event = agents::kickoff_event(&self.state.agents[index], channel, keys)?; + self.state.agents[index].kickoff = Some(event.clone()); + self.state.agents[index].kickoff_published = false; + self.save()?; + self.relay + .publish(&Signer::owner(keys), event.clone()) + .await?; + self.state.agents[index].kickoff_published = true; + self.save()?; + Ok(event) + } +} diff --git a/crates/buzz-cli/src/commands/onboard/steps_credentials.rs b/crates/buzz-cli/src/commands/onboard/steps_credentials.rs new file mode 100644 index 00000000000..2ff716a0bc4 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/steps_credentials.rs @@ -0,0 +1,284 @@ +//! Steps 2 to 4: the credentials the profile carries, collected in memory +//! only. With a profile on file, each step keeps what is stored unless a +//! new value is given, as the Buzz app's enroll form does. + +use serde_json::{Map, Value}; + +use super::io::{redacted, Prompter}; +use super::manager::ControlTransport; +use super::profile::{self, Field, GitHubIdentity, Values}; +use super::relay::Relay; +use super::steps_identity::OnFile; +use super::tools::{SigningKey, Tools}; +use super::Wizard; +use crate::error::CliError; + +/// The shared Voltage via profile (voltage/remote-agents.md, "Shared via +/// profile"): only `op://` references, no secret values. +pub const SHARED_VIA_PROFILE: &str = include_str!("via-profile.toml"); + +/// What steps 2 to 4 collected. Wiped on drop. +#[derive(Default)] +pub struct Credentials { + /// The values to send, by field. + pub values: Values, + /// A new GitHub token's account; `None` keeps the stored token. + pub github: Option, + /// A new agent signing key; `None` keeps the stored one. + pub signing_key: Option, + /// The GitHub login the signing key belongs on. + pub login: String, +} + +impl Credentials { + /// Whether anything would change the stored profile. + pub fn changes(&self) -> bool { + self.github.is_some() + || self.signing_key.is_some() + || Field::ALL + .into_iter() + .any(|field| self.values.filled(field)) + } +} + +impl Wizard { + /// Step 2. The GitHub token (from `BUZZ_ONBOARD_GITHUB_TOKEN`, `gh auth + /// token`, or a hidden prompt) and its account, the Git email, and a new + /// agent-only Ed25519 signing key from `ssh-keygen` when none is stored. + /// The key's public half goes to GitHub only after the manager stores + /// the profile ([`Wizard::enroll`]), as in the Buzz app. + pub async fn github( + &mut self, + on_file: &OnFile, + credentials: &mut Credentials, + ) -> Result<(), CliError> { + let summary = on_file.summary.as_ref(); + let token_on_file = summary.is_some_and(|summary| summary.github.token); + if token_on_file && !self.args.reenroll && self.env.github_token.is_none() { + credentials.login = summary.map(|s| s.github.login.clone()).unwrap_or_default(); + let login = credentials.login.clone(); + self.say(&format!(" GitHub token on file for @{login}.")); + } else { + let token = self.github_token(token_on_file).await?; + if token.is_empty() { + credentials.login = summary.map(|s| s.github.login.clone()).unwrap_or_default(); + } else { + let identity = self + .tools + .github_identity(&token) + .await + .map_err(CliError::Auth)?; + self.say(&format!(" GitHub token for @{}.", identity.login)); + credentials.login = identity.login.clone(); + credentials + .values + .set(Field::GithubToken, token.to_string()); + credentials.github = Some(identity); + } + } + let email_on_file = summary.is_some_and(|summary| !summary.git.email.is_empty()); + if self.args.git_email.is_some() || !email_on_file { + let email = match self.args.git_email.clone() { + Some(email) => email, + None => { + let local = self.tools.git_email().await; + let default = Some(local.as_str()).filter(|email| !email.is_empty()); + self.prompter.ask( + " Git email (a verified email on your GitHub account)", + default, + "pass --git-email", + )? + } + }; + if email.trim().is_empty() { + return Err(CliError::Usage("A Git email is required.".into())); + } + credentials + .values + .set(Field::GitEmail, email.trim().to_owned()); + } + let key_on_file = summary.is_some_and(|summary| summary.git.signing_key); + if !key_on_file || self.args.replace_signing_key { + if credentials.login.is_empty() { + return Err(CliError::Usage("A GitHub token is required.".into())); + } + let comment = format!("buzz agents {}", credentials.login); + let key = self + .tools + .ssh_keygen(&comment) + .await + .map_err(CliError::Other)?; + self.say( + " Created an agent-only Ed25519 signing key. It goes to GitHub after you enroll.", + ); + credentials.signing_key = Some(key); + } + Ok(()) + } + + async fn github_token( + &mut self, + on_file: bool, + ) -> Result, CliError> { + if let Some(token) = self.env.github_token.take() { + return Ok(token); + } + if !on_file { + if let Some(token) = self.tools.gh_auth_token().await { + let question = format!( + " Use the GitHub CLI's token ({})? A fine-grained token scoped to voltagecloud is safer", + redacted(&token) + ); + if self.prompter.confirm(&question, true)? { + return Ok(token); + } + } + } + let question = if on_file { + " GitHub token (blank keeps the one on file)" + } else { + " GitHub fine-grained token" + }; + let token = self.prompter.secret( + question, + "set BUZZ_ONBOARD_GITHUB_TOKEN or sign in with gh auth login", + )?; + if token.is_empty() && !on_file { + return Err(CliError::Usage("A GitHub token is required.".into())); + } + Ok(token) + } + + /// Step 3. Claude subscription token (`claude setup-token`), Codex access + /// token and OpenRouter key from `BUZZ_ONBOARD_*` or hidden prompts; + /// OpenAI and Anthropic API keys from the environment only. Checked with + /// the profile's rules, including that each pair is mutually exclusive. + pub async fn models( + &mut self, + on_file: &OnFile, + credentials: &mut Credentials, + ) -> Result<(), CliError> { + let summary = on_file.summary.clone().unwrap_or_default(); + let from_env = [ + (Field::ClaudeOauthToken, self.env.claude_token.take()), + (Field::CodexAccessToken, self.env.codex_token.take()), + (Field::OpenrouterApiKey, self.env.openrouter_key.take()), + (Field::OpenaiApiKey, self.env.openai_api_key.take()), + (Field::AnthropicApiKey, self.env.anthropic_api_key.take()), + ]; + for (field, value) in from_env { + if let Some(value) = value { + credentials.values.set(field, value.to_string()); + } + } + if self.prompter.interactive() { + let asked = [ + (Field::ClaudeOauthToken, "sk-ant-oat01-…"), + (Field::CodexAccessToken, "at-…"), + (Field::OpenrouterApiKey, "sk-or-…"), + ]; + for (field, shape) in asked { + if credentials.values.filled(field) { + continue; + } + let held = summary.holds(field); + if field == Field::ClaudeOauthToken + && !held + && self.prompter.confirm( + " Run claude setup-token now to create a Claude subscription token?", + false, + )? + && !self.tools.claude_setup_token().await + { + self.say( + " claude setup-token did not finish. Paste a token, or leave it blank.", + ); + } + let keep = if held { + "blank keeps the one on file" + } else { + "blank to skip" + }; + let question = format!(" {} ({shape}, {keep})", field.label()); + let value = self.prompter.secret(&question, "")?; + credentials.values.set(field, value.to_string()); + } + } + let models: Map = Field::ALL + .into_iter() + .filter(|field| credentials.values.filled(*field)) + .filter_map(|field| { + Some(( + field.model()?.to_owned(), + credentials.values.get(field).trim().into(), + )) + }) + .collect(); + profile::models(&Value::Object(models.clone())).map_err(CliError::Usage)?; + let entered: Vec<_> = Field::ALL + .into_iter() + .filter(|field| field.model().is_some() && credentials.values.filled(*field)) + .map(Field::label) + .collect(); + if !entered.is_empty() { + self.say(&format!(" Will send: {}.", entered.join(", "))); + } + self.available_models = summary.models.clone(); + self.available_models.extend(models.keys().cloned()); + if summary.codex_login { + self.available_models.push("codex_login".into()); + } + if models.is_empty() && summary.models.is_empty() && !summary.codex_login { + self.actions.push( + "Add a model credential (BUZZ_ONBOARD_CLAUDE_TOKEN, BUZZ_ONBOARD_CODEX_TOKEN or BUZZ_ONBOARD_OPENROUTER_KEY) and run buzz onboard --reenroll: your agents cannot run a model yet.".into(), + ); + } + Ok(()) + } + + /// Step 4. The 1Password service account token (`ops_…`) that reads the + /// Buzz Agents Dev vault, and the via profile (the shared one unless + /// `--via-profile`). Both are required for a first enrollment. + pub fn op_token( + &mut self, + on_file: &OnFile, + credentials: &mut Credentials, + ) -> Result<(), CliError> { + let summary = on_file.summary.clone().unwrap_or_default(); + let held = summary.holds(Field::OpToken); + let token = match self.env.op_token.take() { + Some(token) => token, + None if held && !self.args.reenroll => { + self.say(" 1Password token on file."); + Default::default() + } + None => { + let question = if held { + " 1Password service account token (ops_…, blank keeps the one on file)" + } else { + " 1Password service account token (ops_…)" + }; + self.prompter + .secret(question, "set BUZZ_ONBOARD_OP_TOKEN")? + } + }; + if token.is_empty() && !held { + return Err(CliError::Usage( + "A 1Password service account token is required (see voltage/remote-agents.md, 1Password service account).".into(), + )); + } + credentials.values.set(Field::OpToken, token.to_string()); + let via = match &self.args.via_profile { + Some(path) => Some( + std::fs::read_to_string(path) + .map_err(|_| CliError::Usage(format!("could not read {}", path.display())))?, + ), + None if !summary.holds(Field::ViaProfile) => Some(SHARED_VIA_PROFILE.to_owned()), + None => None, + }; + if let Some(via) = via { + credentials.values.set(Field::ViaProfile, via); + } + Ok(()) + } +} diff --git a/crates/buzz-cli/src/commands/onboard/steps_enroll.rs b/crates/buzz-cli/src/commands/onboard/steps_enroll.rs new file mode 100644 index 00000000000..78144734f5f --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/steps_enroll.rs @@ -0,0 +1,218 @@ +//! Step 6 (enroll, the signing key on GitHub, the Paseo pairing) and +//! step 8 (next steps). + +use buzz_session_control::Op; +use nostr::Keys; +use serde_json::json; + +use super::io::Prompter; +use super::manager::{self, ControlTransport}; +use super::paseo::{self, Pairing}; +use super::profile::{self, Changes, Outcome}; +use super::relay::{Relay, Signer}; +use super::state::PendingKey; +use super::steps_credentials::Credentials; +use super::steps_identity::OnFile; +use super::tools::Tools; +use super::Wizard; +use crate::error::CliError; + +impl Wizard { + /// Step 6. Sends the owner-signed, NIP-44 encrypted kind 24210 `enroll` + /// the Buzz app sends: the complete profile when nothing is on file, or + /// only what changes. Once the manager stores a new signing key, adds its + /// public half to GitHub with `gh` (or hands back the manual steps and + /// retries on the next run). Returns the Paseo pairing, when provisioned. + pub async fn enroll( + &mut self, + keys: &Keys, + on_file: &OnFile, + credentials: Credentials, + ) -> Result, CliError> { + let mut pairing = on_file.pairing.clone(); + if on_file.summary.is_some() + && !credentials.changes() + && !self.args.reenroll + && !(on_file.paseo_enabled && pairing.is_none()) + { + self.say(" Your profile is on file; nothing new to send."); + self.register_signing_key().await; + return Ok(pairing); + } + let owner = keys.public_key().to_hex(); + let body = match &on_file.summary { + None => { + let github = credentials + .github + .as_ref() + .ok_or_else(|| CliError::Usage("A GitHub token is required.".into()))?; + let key = credentials + .signing_key + .as_ref() + .ok_or_else(|| CliError::Other("No signing key was created.".into()))?; + profile::profile(&credentials.values, &owner, github, &key.private) + } + Some(summary) => { + // Re-enrollment also provisions or rotates the narrow daemon + // Secret. Preserve credentials by sending the existing email. + let mut unchanged = profile::Values::default(); + let values = if credentials.changes() { + &credentials.values + } else { + unchanged.set(profile::Field::GitEmail, summary.git.email.clone()); + &unchanged + }; + profile::partial( + values, + &owner, + Changes { + github: credentials.github.as_ref(), + signing_key: credentials + .signing_key + .as_ref() + .map(|key| key.private.as_str()), + }, + ) + } + } + .map_err(CliError::Usage)?; + let response = manager::ask( + &self.transport, + &self.manager, + &Signer::owner(keys), + Op::Enroll, + json!({ "profile": body }), + manager::OWNER_TIMEOUT, + ) + .await?; + let outcome = profile::outcome(&response).map_err(CliError::Other)?; + let version_id = match &outcome { + Outcome::Refused { code } if code == "unauthorized_owner" => { + return Err(CliError::Auth(format!( + "{} Your owner key is {owner}.", + outcome.text() + ))); + } + Outcome::Refused { .. } => return Err(CliError::Other(outcome.text())), + Outcome::Enrolled { version_id } => version_id.clone(), + }; + self.say(&format!(" {}", outcome.text())); + self.state.enrolled_version = Some(version_id); + match paseo::pairing(&response) { + Ok(Some(found)) => pairing = Some(found), + Ok(None) => {} + Err(error) if self.actions.contains(&error) => {} + Err(error) => self.actions.push(error), + } + if let Some(key) = &credentials.signing_key { + self.state.pending_key = Some(PendingKey { + login: credentials.login.clone(), + title: format!( + "Buzz agents ({})", + chrono::Utc::now().format("%Y-%m-%d %H:%M UTC") + ), + public: key.public.clone(), + }); + } + if let Err(error) = self.save() { + // The manager stored the key; without the saved record, say + // where its public half must go before failing. + if let Some(key) = &self.state.pending_key { + self.say(&format!( + " Add this signing key to GitHub @{} as a Signing Key: {}", + key.login, key.public + )); + } + return Err(error); + } + drop(credentials); + self.register_signing_key().await; + Ok(pairing) + } + + /// Adds the stored signing key's public half to GitHub with `gh`. A key + /// `gh` cannot add stays pending, with the manual steps reported. + async fn register_signing_key(&mut self) { + let Some(key) = self.state.pending_key.clone() else { + return; + }; + let added = self + .tools + .gh_add_signing_key(&key.login, &key.title, &key.public) + .await; + match added { + Ok(()) | Err("this key is already on a GitHub account") => { + self.say(&format!( + " Your agents' signing key is on GitHub @{}.", + key.login + )); + self.state.pending_key = None; + if let Err(error) = self.save() { + self.actions + .push(format!("Could not save onboarding state: {error}")); + } + } + Err(reason) => { + self.say(&format!( + " Could not add your agents' signing key to GitHub: {reason}." + )); + self.actions.push(format!( + "Add your agents' signing key to GitHub @{}: Settings, SSH and GPG keys, New SSH key, Key type Signing Key, paste: {} (or fix gh and run buzz onboard again).", + key.login, key.public + )); + } + } + } + + /// Step 8. How to pair Paseo, how to use workflows and the Buzz app, + /// and everything still to do. + pub fn next_steps(&mut self, pairing: Option<&Pairing>) { + match pairing { + Some(pairing) => { + self.say(" Paseo: your daemon is provisioned. Pair a Paseo app with it:"); + self.say(&format!(" Password: {}", pairing.password.expose())); + self.say(&format!(" Server ID: {}", pairing.server_id)); + self.say(&format!(" Offer: {}", pairing.offer_url)); + match paseo::qr(&pairing.offer_url) { + Ok(code) => self.say(&code), + Err(error) => self.say(&format!(" {error}")), + } + self.say(" Paseo desktop: open the offer link on that computer, then enter the password."); + self.say(" Paseo web: open the offer link in a browser (app.paseo.sh), then enter the password."); + self.say(" Paseo mobile: scan the QR code with your phone's camera or the Paseo app, then enter the password."); + self.say(" The password is not saved. Run buzz onboard again to show it."); + self.say(" Your daemon lists an agent after its first start in a channel thread; agents added later appear after the daemon restarts."); + } + None => self.say( + " Paseo isn't provisioned for you yet. Run buzz onboard again after an admin enables Paseo daemons.", + ), + } + let channel = self + .state + .channel + .as_ref() + .map_or_else(String::new, |channel| format!(" in #{}", channel.name)); + let channel_id = self + .state + .channel + .as_ref() + .map_or("", |channel| channel.id.as_str()); + self.say(&format!( + " Workflows: BUZZ_PRIVATE_KEY=\"$(cat {})\" BUZZ_RELAY_URL={} buzz workflows list --channel {channel_id} (see buzz workflows --help).", + self.store.identity_path().display(), + self.relay_url + )); + self.say(&format!( + " Buzz app instead: install the Voltage build (voltage/remote-agents.md), sign in with the key in {}, and join {}. Your agents appear under Agents; mention one{channel} to start a session.", + self.store.identity_path().display(), + self.relay_url + )); + if !self.actions.is_empty() { + self.say(" Action needed:"); + let actions = self.actions.clone(); + for action in actions { + self.say(&format!(" - {action}")); + } + } + } +} diff --git a/crates/buzz-cli/src/commands/onboard/steps_gateway.rs b/crates/buzz-cli/src/commands/onboard/steps_gateway.rs new file mode 100644 index 00000000000..d8aad484414 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/steps_gateway.rs @@ -0,0 +1,165 @@ +//! Step 7: grant the Paseo daemon's gateway key. + +use buzz_sdk::gateway::{Delegation, MAX_FUTURE_SKEW_SECS}; +use nostr::{Event, Keys}; +use serde_json::json; + +use super::gateway; +use super::io::Prompter; +use super::manager::ControlTransport; +use super::paseo::Pairing; +use super::relay::{Relay, Signer}; +use super::state::GatewayGrant; +use super::tools::Tools; +use super::Wizard; +use crate::error::CliError; + +impl Wizard { + /// Reconcile the current relay grant before reporting authority. Local + /// onboarding state is a summary, never proof of an unrevoked delegation. + pub async fn gateway_grant( + &mut self, + keys: &Keys, + pairing: Option<&Pairing>, + ) -> Result<(), CliError> { + if self.state.gateway_grant.take().is_some() { + self.save()?; + } + let Some(pairing) = pairing else { + self.say(" No Paseo daemon yet, so there is no gateway key to grant."); + return Ok(()); + }; + let Some(gateway) = pairing.gateway else { + self.say(" Your Paseo daemon has no gateway key yet; nothing to grant."); + return Ok(()); + }; + let hex = gateway.to_hex(); + if hex == self.state.owner_pubkey || self.state.agents.iter().any(|a| a.pubkey == hex) { + self.actions.push(format!( + "Your Paseo daemon's gateway key {hex} is your own or an agent's key; it was not granted. Ask an admin to check the Sandbox manager." + )); + return Ok(()); + } + let (lifetime, lifetime_secs) = self.gateway_lifetime.clone(); + let now = u64::try_from(chrono::Utc::now().timestamp()).unwrap_or_default(); + let command = gateway::command(&gateway, &lifetime); + if self.args.skip_gateway_grant { + self.say(&format!(" Skipped. To grant it later, run: {command}")); + return Ok(()); + } + self.actions.push(format!( + "Gateway membership is separate: ask a relay administrator to admit {hex} with buzz-admin add-member --pubkey {hex} --role member, then add it to the workspace with buzz channels add-member --channel {} --pubkey {hex}.", + self.state.channel.as_ref().map_or("", |channel| channel.id.as_str()) + )); + let profiles = self + .relay + .query(keys, json!({"kinds":[0], "authors":[hex], "limit":1})) + .await?; + if profiles.iter().any(|profile| { + profile["tags"] + .as_array() + .is_some_and(|tags| tags.iter().any(|tag| tag[0] == "auth")) + }) { + return Err(CliError::Auth("the daemon gateway key belongs to an agent; ask an administrator to repair its Secret".into())); + } + let coordinate = json!({ + "kinds":[buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION], + "authors":[keys.public_key()], "#d":[hex], "limit":1 + }); + let values = self.relay.query(keys, coordinate.clone()).await?; + let heads: Vec = values + .into_iter() + .map(serde_json::from_value) + .collect::>() + .map_err(|_| CliError::Other("relay returned an invalid gateway grant".into()))?; + if heads.len() > 1 { + return Err(CliError::Other( + "relay returned multiple gateway heads".into(), + )); + } + let mut created_at = now; + if let Some(head) = heads.first() { + let (delegation, _) = Delegation::parse(head).map_err(|error| { + CliError::Other(format!("invalid current gateway head: {error}")) + })?; + if head.pubkey != keys.public_key() || delegation.gateway != gateway { + return Err(CliError::Other( + "relay returned an unrelated gateway head".into(), + )); + } + if let Some(live) = buzz_sdk::gateway::live(head, keys.public_key(), gateway, now) { + if !gateway::needs_renewal(live.expires_at(), now, lifetime_secs) { + self.state.gateway_grant = Some(GatewayGrant { + gateway: hex, + expires_at: live.expires_at(), + }); + self.save()?; + self.say(&format!( + " Your Paseo daemon's gateway key is granted until {}.", + date(live.expires_at()) + )); + return Ok(()); + } + } + created_at = now.max(head.created_at.as_secs().saturating_add(1)); + if created_at > now.saturating_add(MAX_FUTURE_SKEW_SECS) { + return Err(CliError::Conflict( + "the current gateway head is too far in the future; check the owner's clock" + .into(), + )); + } + } + let expires_at = now.saturating_add(lifetime_secs); + let builder = Delegation::grant(gateway, expires_at, created_at) + .map_err(|error| CliError::Other(format!("could not build gateway grant: {error}")))?; + let event = builder + .sign_with_keys(keys) + .map_err(|error| CliError::Other(format!("could not sign gateway grant: {error}")))?; + let question = format!( + " Let your Paseo daemon (gateway key {}…) drive your remote agents for {lifetime}?", + hex.get(..12).unwrap_or(&hex) + ); + if !self.prompter.confirm(&question, true)? { + self.say(&format!(" Skipped. To grant it later, run: {command}")); + return Ok(()); + } + let submitted_id = event.id; + self.relay.publish(&Signer::owner(keys), event).await?; + // A dominated NIP-33 write can receive an accepted duplicate receipt. + // Confirmation may have paused while another device revoked the grant. + let current: Vec = self + .relay + .query(keys, coordinate) + .await? + .into_iter() + .map(serde_json::from_value) + .collect::>() + .map_err(|_| { + CliError::Other("relay returned an invalid gateway head after publication".into()) + })?; + let confirmed = matches!(current.as_slice(), [head] + if head.id == submitted_id && buzz_sdk::gateway::live(head, keys.public_key(), gateway, + u64::try_from(chrono::Utc::now().timestamp()).unwrap_or_default()).is_some()); + if !confirmed { + return Err(CliError::Conflict("the gateway grant changed during confirmation; no live grant was recorded. Check the current grant before retrying".into())); + } + self.state.gateway_grant = Some(GatewayGrant { + gateway: hex, + expires_at, + }); + self.save()?; + self.say(&format!( + " Granted your Paseo daemon's gateway key until {}.", + date(expires_at) + )); + Ok(()) + } +} + +/// `secs` as a UTC date. +fn date(secs: u64) -> String { + i64::try_from(secs) + .ok() + .and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) + .map_or_else(|| secs.to_string(), |at| at.format("%Y-%m-%d").to_string()) +} diff --git a/crates/buzz-cli/src/commands/onboard/steps_identity.rs b/crates/buzz-cli/src/commands/onboard/steps_identity.rs new file mode 100644 index 00000000000..353def39acb --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/steps_identity.rs @@ -0,0 +1,274 @@ +//! Step 1: the owner's Buzz identity, community membership and profile, +//! and the manager's view of what is already enrolled. + +use buzz_session_control::Op; +use nostr::{EventBuilder, Keys, Kind, ToBech32}; +use serde_json::{json, Value}; + +use super::io::Prompter; +use super::manager::{self, ControlTransport}; +use super::paseo::{self, Pairing}; +use super::profile::{self, Summary}; +use super::relay::{Claimed, Relay, Signer}; +use super::state::Secret; +use super::tools::Tools; +use super::Wizard; +use crate::error::CliError; + +/// What the manager holds for this owner before anything is sent. +#[derive(Default)] +pub struct OnFile { + /// The stored profile, `None` when nothing is enrolled. + pub summary: Option, + /// The Paseo pairing the manager returned with it. + pub pairing: Option, + /// The manager can provision Paseo, even if its Secret is absent. + pub paseo_enabled: bool, +} + +/// The code in an invite code or an invite URL (`…/invite/`), checked +/// as the Buzz app checks it: `^[a-zA-Z0-9._-]{1,256}$`. +pub fn invite_code(text: &str) -> Result { + let text = text.trim(); + let code = match url::Url::parse(text) { + Ok(url) => url + .path_segments() + .and_then(|mut segments| { + segments.find(|segment| *segment == "invite")?; + segments.next().map(str::to_owned) + }) + .unwrap_or_default(), + Err(_) => text.to_owned(), + }; + let valid = (1..=256).contains(&code.len()) + && code + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')); + if valid { + Ok(code) + } else { + Err(CliError::Usage("That invite code is not valid.".into())) + } +} + +impl Wizard { + /// Step 1. Loads, imports or generates the owner key (saved mode + /// `0600`, never printed), joins the community with an invite when the + /// key is not a member, and publishes a profile when there is none. + pub async fn identity(&mut self) -> Result { + let keys = self.owner_keys()?; + let npub = keys + .public_key() + .to_bech32() + .map_err(|_| CliError::Key("could not encode the public key".into()))?; + self.say(&format!( + " Identity {npub} ({} hex), key in {}", + keys.public_key().to_hex(), + self.store.identity_path().display() + )); + if !self.state.joined { + self.join(&keys).await?; + } + if !self.state.owner_profile { + self.owner_profile(&keys).await?; + } + Ok(keys) + } + + fn owner_keys(&mut self) -> Result { + if let Some(saved) = self.store.load_identity().map_err(CliError::Other)? { + let keys = Keys::parse(saved.expose()).map_err(|_| { + CliError::Key(format!( + "{} does not hold a valid key", + self.store.identity_path().display() + )) + })?; + let hex = keys.public_key().to_hex(); + if !self.state.owner_pubkey.is_empty() && self.state.owner_pubkey != hex { + return Err(CliError::Key( + "the saved key does not match the saved onboarding state".into(), + )); + } + if self.state.owner_pubkey.is_empty() { + self.state.owner_pubkey = hex; + self.save()?; + } + return Ok(keys); + } + let import = self.args.import_key + || (self.prompter.interactive() + && self + .prompter + .ask( + " Generate a new Buzz key, or import the one you use in the Buzz app? (generate/import)", + Some("generate"), + "", + )? + .trim() + .eq_ignore_ascii_case("import")); + let keys = if import { + let text = match self.env.private_key.take() { + Some(text) => text, + None => self.prompter.secret( + " Your Buzz key (nsec or hex)", + "set BUZZ_PRIVATE_KEY with --import-key", + )?, + }; + Keys::parse(text.trim()) + .map_err(|_| CliError::Key("that Buzz key is not valid".into()))? + } else { + Keys::generate() + }; + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|_| CliError::Key("could not encode the key".into()))?; + self.store + .save_identity(&Secret::new(nsec)) + .map_err(CliError::Other)?; + self.state.owner_pubkey = keys.public_key().to_hex(); + self.save()?; + self.say(if import { + " Imported your key." + } else { + " Generated a new key. To be added as a developer, give your admin the hex key on the next line." + }); + Ok(keys) + } + + async fn join(&mut self, keys: &Keys) -> Result<(), CliError> { + if self.relay.is_member(keys).await? { + self.state.joined = true; + return self.save(); + } + let answer = match self.args.invite.clone() { + Some(invite) => invite, + None => self.prompter.ask( + " You are not a member yet. Invite code or link", + None, + "pass --invite or set BUZZ_ONBOARD_INVITE", + )?, + }; + let code = invite_code(&answer)?; + let receipt = match self.relay.join_policy().await? { + None => None, + Some(policy) => { + let base = crate::client::normalize_relay_url(&self.relay_url); + self.say(&format!( + " This community has terms ({base}/api/join-policy/terms) and a privacy policy ({base}/api/join-policy/privacy)." + )); + let accepted = self.args.accept_join_policy + || self.prompter.confirm(" Accept them?", false)?; + if !accepted { + return Err(CliError::Usage( + "Joining requires accepting the join policy (--accept-join-policy).".into(), + )); + } + let age = !policy.age_attestation_required + || self.args.confirm_age + || self + .prompter + .confirm(" Do you meet the community's minimum age?", false)?; + if !age { + return Err(CliError::Usage( + "Joining requires confirming the minimum age (--confirm-age).".into(), + )); + } + Some( + self.relay + .accept_policy(keys, &code, &policy, policy.age_attestation_required) + .await?, + ) + } + }; + let claimed = self.relay.claim(keys, &code, receipt.as_deref()).await?; + self.say(match claimed { + Claimed::Joined => " Joined the community.", + Claimed::AlreadyMember => " Already a member of the community.", + }); + self.state.joined = true; + self.save() + } + + async fn owner_profile(&mut self, keys: &Keys) -> Result<(), CliError> { + let filter = json!({"kinds": [0], "authors": [keys.public_key().to_hex()], "limit": 1}); + if self.relay.query(keys, filter).await?.is_empty() { + let name = match self.args.name.clone() { + Some(name) => name, + None => self + .prompter + .ask(" Your display name", None, "pass --name")?, + }; + let name = name.trim().to_owned(); + if !(1..=100).contains(&name.chars().count()) { + return Err(CliError::Usage( + "The display name must be 1 to 100 characters.".into(), + )); + } + let content = json!({"name": name, "display_name": name}).to_string(); + let event = EventBuilder::new(Kind::Metadata, content) + .sign_with_keys(keys) + .map_err(|error| CliError::Other(format!("signing failed: {error}")))?; + self.relay.publish(&Signer::owner(keys), event).await?; + self.say(&format!(" Published your profile as {name}.")); + } + self.state.owner_profile = true; + self.save() + } + + /// What the manager holds for this owner (the `profile` request), and + /// its Paseo pairing. A refusal stops the run: an owner the manager does + /// not know cannot enroll, and an unknown state cannot pick between a + /// complete and a partial profile. + pub async fn on_file(&mut self, keys: &Keys) -> Result { + let response = manager::ask( + &self.transport, + &self.manager, + &Signer::owner(keys), + Op::Profile, + json!({}), + manager::OWNER_TIMEOUT, + ) + .await + .map_err(|error| { + CliError::Other(format!( + "Could not check what is enrolled: {error}. Run buzz onboard again." + )) + })?; + let summary = profile::on_file_reply(&response).map_err(|text| { + if response.get("code").and_then(Value::as_str) == Some("unauthorized_owner") { + CliError::Auth(format!( + "{text} Your owner key is {}.", + keys.public_key().to_hex() + )) + } else { + CliError::Other(text) + } + })?; + let pairing = match paseo::pairing(&response) { + Ok(pairing) => pairing, + Err(error) => { + self.actions.push(error); + None + } + }; + match &summary { + Some(summary) => { + self.state.enrolled_version = Some(summary.version_id.clone()); + self.save()?; + let text = summary.text(); + self.say(&format!(" {text}")); + } + None => self.say(" Nothing enrolled yet."), + } + let paseo_enabled = response + .get("paseo_enabled") + .and_then(Value::as_bool) + .unwrap_or(pairing.is_some()); + Ok(OnFile { + summary, + pairing, + paseo_enabled, + }) + } +} diff --git a/crates/buzz-cli/src/commands/onboard/tests.rs b/crates/buzz-cli/src/commands/onboard/tests.rs new file mode 100644 index 00000000000..6be51273e7e --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/tests.rs @@ -0,0 +1,1239 @@ +//! `buzz onboard` journeys through the production wizard, with only the +//! terminal, relay, manager transport and local tools faked. + +#[path = "tests_fakes.rs"] +mod fakes; + +use std::path::Path; + +use buzz_session_control::Op; +use clap::Parser; +use nostr::{Keys, ToBech32}; +use serde_json::{json, Value}; +use zeroize::Zeroizing; + +use super::gateway; +use super::profile::{self, GitHubIdentity}; +use super::state::{Secret, Store}; +use super::steps_credentials::SHARED_VIA_PROFILE; +use super::{agents, canonical_relay, paseo, steps_identity, EnvSecrets, OnboardArgs, Wizard}; +use crate::error::CliError; +use fakes::{strings, Answer, FakeManager, FakeRelay, FakeTools, ScriptPrompter}; + +const GITHUB: &str = "ghp_SECRETGITHUBTOKEN"; +const GH_CLI: &str = "gho_SECRETGHCLITOKEN"; +const OP: &str = "ops_SECRETOPTOKEN"; +const CLAUDE: &str = "sk-ant-oat01-SECRETCLAUDE"; +const CODEX: &str = "at-SECRETCODEX"; +const PASSWORD: &str = "PASEO-SECRET-PASSWORD"; +const INVITE: &str = "v2.invitecode"; + +#[derive(Parser)] +struct Harness { + #[command(flatten)] + args: OnboardArgs, +} + +fn args(dir: &Path, manager: &FakeManager, extra: &[&str]) -> OnboardArgs { + let mut argv = vec![ + "onboard".to_owned(), + "--state-dir".into(), + dir.display().to_string(), + "--manager-pubkey".into(), + manager.keys.public_key().to_hex(), + ]; + argv.extend(extra.iter().map(|arg| arg.to_string())); + Harness::parse_from(argv).args +} + +fn secret(text: &str) -> Option> { + Some(Zeroizing::new(text.to_owned())) +} + +fn env(owner: &Keys) -> EnvSecrets { + EnvSecrets { + private_key: secret(&owner.secret_key().to_bech32().unwrap()), + github_token: secret(GITHUB), + op_token: secret(OP), + claude_token: secret(CLAUDE), + codex_token: secret(CODEX), + ..EnvSecrets::default() + } +} + +fn tools() -> FakeTools { + let octo = GitHubIdentity { + login: "octo".into(), + name: "Octo Cat".into(), + }; + FakeTools { + identities: [(GITHUB.to_owned(), octo.clone()), (GH_CLI.to_owned(), octo)] + .into_iter() + .collect(), + git_email: "octo@example.com".into(), + ..FakeTools::default() + } +} + +/// A pairing as the manager returns it; `gateway` empty leaves the key out. +fn paseo(gateway: &str) -> Value { + let mut pairing = json!({"password": PASSWORD, "server_id": "srv-1", + "offer_url": "https://app.paseo.sh/#offer=eyJ2IjoyfQ"}); + if !gateway.is_empty() { + pairing["gateway_pubkey"] = json!(gateway); + } + pairing +} + +async fn onboard( + args: OnboardArgs, + env: EnvSecrets, + prompter: &mut ScriptPrompter, + relay: &FakeRelay, + manager: &FakeManager, + tools: &FakeTools, +) -> Result { + let store = Store::new(args.state_dir.clone().unwrap()); + let mut wizard = Wizard::new(args, env, store, prompter, relay, manager, tools)?; + wizard.run().await +} + +fn now() -> u64 { + u64::try_from(chrono::Utc::now().timestamp()).unwrap() +} + +fn tag<'a>(event: &'a Value, name: &str) -> Vec<&'a str> { + event["tags"] + .as_array() + .unwrap() + .iter() + .filter(|tag| tag[0] == name) + .map(|tag| tag[1].as_str().unwrap()) + .collect() +} + +#[cfg(unix)] +fn mode(path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +/// The secrets a run must never show, summarise, or save. +fn assert_no_secrets(haystack: &str, extra: &[String]) { + let mut secrets: Vec = [GITHUB, GH_CLI, OP, CLAUDE, CODEX, "PRIVATEKEYMATERIAL"] + .iter() + .map(|s| s.to_string()) + .collect(); + secrets.extend(extra.iter().cloned()); + for secret in secrets { + assert!( + !haystack.contains(&secret), + "a secret leaked: {}…", + &secret[..6] + ); + } +} + +/// A non-interactive first run sets up everything the Buzz app would, and a +/// re-run changes nothing. +#[tokio::test] +async fn first_run_sets_up_like_the_app_and_a_rerun_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + let state_dir = dir.path().join("buzz"); + let owner = Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let relay = FakeRelay::with_invite(INVITE); + relay.state().policy = Some(super::relay::JoinPolicy { + version: "2026-01".into(), + age_attestation_required: false, + }); + let manager = FakeManager::for_developer(&owner_hex); + let gateway = Keys::generate().public_key().to_hex(); + manager.state().paseo = Some(paseo(&gateway)); + let tools = tools(); + let extra = [ + "--yes", + "--import-key", + "--invite", + "https://buzz.voltage.cloud/invite/v2.invitecode", + "--accept-join-policy", + "--name", + "Octo", + "--agents", + "brokkr,bifrost", + ]; + let mut prompter = ScriptPrompter::quiet(); + let summary = onboard( + args(&state_dir, &manager, &extra), + env(&owner), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + + // Identity: joined through the policy, profile published. + assert!(relay.state().accepted_policy); + assert!(relay.state().members.contains(&owner_hex)); + let profiles = relay.of_kind(0); + assert!(profiles.iter().any(|p| p["pubkey"] == owner_hex)); + + // Agents: the exact app shapes. + assert_eq!(summary["agents"].as_array().unwrap().len(), 2); + let channel = relay.of_kind(9007); + assert_eq!(channel.len(), 1); + assert_eq!(tag(&channel[0], "name"), ["octo-agents"]); + assert_eq!(tag(&channel[0], "visibility"), ["private"]); + let channel_id = tag(&channel[0], "h")[0].to_owned(); + for agent in summary["agents"].as_array().unwrap() { + let pubkey = agent["pubkey"].as_str().unwrap(); + assert_eq!(agent["registered"], true); + let profile = profiles.iter().find(|p| p["pubkey"] == pubkey).unwrap(); + let content: Value = serde_json::from_str(profile["content"].as_str().unwrap()).unwrap(); + assert_eq!(content, json!({"name": agent["name"], "bot": true})); + let auth = &profile["tags"][0]; + assert_eq!(profile["tags"].as_array().unwrap().len(), 1); + assert_eq!(auth[0], "auth"); + assert_eq!(auth[1], owner_hex); + assert_eq!(auth[2], ""); + let listing = relay + .of_kind(30177) + .into_iter() + .find(|l| tag(l, "d") == [pubkey]) + .unwrap(); + assert_eq!(listing["pubkey"], owner_hex); + assert_eq!(tag(&listing, "runtime"), [agents::RUNTIME_TYPE]); + let content: Value = serde_json::from_str(listing["content"].as_str().unwrap()).unwrap(); + assert_eq!( + content, + json!({"parallelism": 1, "respond_to": "owner-only", "name": agent["name"]}) + ); + let member = relay + .of_kind(9000) + .into_iter() + .find(|m| tag(m, "p") == [pubkey]) + .unwrap(); + assert_eq!(tag(&member, "h"), [channel_id.as_str()]); + assert_eq!(tag(&member, "role"), ["bot"]); + // First start: the kickoff mention, then the registered launch. + let kickoff = relay + .of_kind(9) + .into_iter() + .find(|m| tag(m, "p") == [pubkey]) + .unwrap(); + let launch = manager.state().registrations[pubkey].clone(); + assert_eq!(launch["relay_url"], "wss://buzz.voltage.cloud"); + assert_eq!(launch["launch"]["owner_pubkey"], owner_hex); + let policy = &launch["launch"]["policy_env"]; + assert_eq!(policy["BUZZ_ACP_SESSION_POLICY"], "thread"); + assert_eq!( + policy["BUZZ_ACP_REPLAY_FLOOR"], + kickoff["created_at"].to_string() + ); + assert_eq!(policy["BUZZ_ACP_DISPLAY_NAME"], agent["name"]); + } + let first = summary["agents"][0]["pubkey"].as_str().unwrap().to_owned(); + let brokkr = manager.state().registrations[&first].clone(); + assert_eq!(brokkr["launch"]["command"], "claude-agent-acp"); + assert_eq!( + brokkr["launch"]["policy_env"]["BUZZ_ACP_EFFORT_LEVEL"], + "xhigh" + ); + + // Enrollment: one complete profile, then the key on GitHub. + let stored = manager.state().profile.clone().unwrap(); + assert_eq!(stored["github"], json!({"login": "octo", "token": GITHUB})); + assert_eq!(stored["git"]["name"], "Octo Cat"); + assert_eq!(stored["git"]["email"], "octo@example.com"); + assert_eq!(stored["via"]["profile"], SHARED_VIA_PROFILE); + assert_eq!( + stored["models"], + json!({"claude_oauth_token": CLAUDE, "codex_access_token": CODEX}) + ); + assert_eq!( + manager.state().ops, + [Op::Profile, Op::Enroll, Op::Deploy, Op::Deploy] + ); + let added = tools.gh_added.lock().unwrap().clone(); + assert_eq!(added.len(), 1); + assert_eq!(added[0].0, "octo"); + assert!(added[0].1.starts_with("ssh-ed25519 ")); + + // Paseo: the password shown once, the offer as a QR code. + let transcript = prompter.transcript.join("\n"); + assert_eq!(transcript.matches(PASSWORD).count(), 1); + assert!(transcript.contains("https://app.paseo.sh/#offer=")); + assert!(transcript.contains('█') || transcript.contains('▀')); + assert!(transcript.contains("app.paseo.sh")); + assert_eq!(summary["paseo"]["provisioned"], true); + assert_eq!(summary["paseo"]["gateway_pubkey"], gateway); + + // Gateway: one owner-signed grant of the daemon's key for 90 days. + let grants = relay.of_kind(30181); + assert_eq!(grants.len(), 1); + assert_eq!(grants[0]["pubkey"], owner_hex); + assert_eq!(tag(&grants[0], "d"), [gateway.as_str()]); + assert_eq!(tag(&grants[0], "p"), [gateway.as_str()]); + let expires: u64 = tag(&grants[0], "expiration")[0].parse().unwrap(); + assert!(expires.abs_diff(now() + 90 * 86_400) < 120, "{expires}"); + assert_eq!(summary["gateway_grant"]["expires_at"], expires); + assert!(transcript.contains("Granted your Paseo daemon's gateway key until")); + + // Secrets: never shown, summarised, or saved beyond the key files. + let saved = std::fs::read_to_string(state_dir.join("onboard.json")).unwrap(); + let state: Value = serde_json::from_str(&saved).unwrap(); + let mut keys: Vec = state["agents"] + .as_array() + .unwrap() + .iter() + .map(|agent| agent["secret"].as_str().unwrap().to_owned()) + .collect(); + let owner_nsec = owner.secret_key().to_bech32().unwrap(); + keys.push(owner_nsec.clone()); + assert_no_secrets(&transcript, &keys); + keys.push(PASSWORD.into()); + assert_no_secrets(&summary.to_string(), &keys); + assert_no_secrets(&saved, &[owner_nsec, PASSWORD.into()]); + #[cfg(unix)] + { + assert_eq!(mode(&state_dir), 0o700); + assert_eq!(mode(&state_dir.join("onboard.json")), 0o600); + assert_eq!(mode(&state_dir.join("identity.nsec")), 0o600); + } + + // Re-run with no secrets and no flags: nothing is sent again. + let published = relay.state().published; + let mut prompter = ScriptPrompter::quiet(); + let again = onboard( + args(&state_dir, &manager, &["--yes"]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + assert_eq!(relay.state().published, published); + assert_eq!(manager.state().ops.len(), 5); + assert_eq!(manager.state().ops[4], Op::Profile); + assert_eq!(*tools.keys_made.lock().unwrap(), 1); + assert_eq!(tools.gh_added.lock().unwrap().len(), 1); + assert_eq!(again["agents"], summary["agents"]); + assert_eq!(again["channel"], summary["channel"]); + assert!(prompter.transcript.join("\n").contains("is granted until")); + + // A grant near its expiry is renewed, once. + let current = + buzz_sdk::gateway::Delegation::grant(gateway.parse().unwrap(), now() + 86_400, now()) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + relay.state().events.retain(|event| event["kind"] != 30181); + relay + .state() + .events + .push(serde_json::to_value(current).unwrap()); + for _ in 0..2 { + let mut prompter = ScriptPrompter::quiet(); + onboard( + args(&state_dir, &manager, &["--yes", "--gateway-expires", "2w"]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + } + let grants = relay.of_kind(30181); + assert_eq!(grants.len(), 2); + let renewed: u64 = tag(&grants[1], "expiration")[0].parse().unwrap(); + assert!(renewed.abs_diff(now() + 14 * 86_400) < 120, "{renewed}"); + + // A remotely revoked grant overrides the long-lived local summary. A + // refused renewal clears that summary; a retry publishes a newer head, + // including when revoke and grant occur within the same wall-clock second. + let revoked_at = grants[1]["created_at"].as_u64().unwrap() + 1; + let revoked = buzz_sdk::gateway::Delegation::revoke(gateway.parse().unwrap(), revoked_at) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + relay + .state() + .events + .push(serde_json::to_value(revoked).unwrap()); + relay.state().fail_kind = Some(30181); + let mut prompter = ScriptPrompter::quiet(); + assert!(onboard( + args(&state_dir, &manager, &["--yes"]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools + ) + .await + .is_err()); + assert!(Store::new(state_dir.clone()) + .load() + .unwrap() + .gateway_grant + .is_none()); + let mut prompter = ScriptPrompter::quiet(); + onboard( + args(&state_dir, &manager, &["--yes"]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + let current: nostr::Event = + serde_json::from_value(relay.of_kind(30181).pop().unwrap()).unwrap(); + assert!(current.created_at.as_secs() > revoked_at); + assert!(buzz_sdk::gateway::live( + ¤t, + owner.public_key(), + gateway.parse().unwrap(), + now() + ) + .is_some()); +} + +/// The interactive path: a generated key, gh's token, prompts for every +/// secret, a signing key gh cannot add yet, then a resumed run that adds it. +#[tokio::test] +async fn interactive_run_redacts_secrets_and_resumes_the_signing_key() { + let dir = tempfile::tempdir().unwrap(); + let state_dir = dir.path().join("buzz"); + let relay = FakeRelay::with_invite(INVITE); + let manager = FakeManager::for_developer(""); + manager.state().any_developer = true; + let mut tools = tools(); + tools.gh_token = Some(GH_CLI.into()); + tools + .gh_results + .lock() + .unwrap() + .push_back(Err("gh needs the admin:ssh_signing_key scope")); + let mut prompter = ScriptPrompter::scripted(vec![ + Answer::Text(""), // generate a key + Answer::Text(INVITE), // invite + Answer::Text("Dev"), // display name + Answer::Text(""), // use gh's token + Answer::Text(""), // the default Git email + Answer::Text("n"), // do not run claude setup-token + Answer::Secret(CLAUDE.into()), + Answer::Secret(String::new()), // no Codex token + Answer::Secret(String::new()), // no OpenRouter key + Answer::Secret(OP.into()), + Answer::Text("brokkr"), // agents + Answer::Text(""), // the default channel + ]); + let summary = onboard( + args(&state_dir, &manager, &[]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + assert!( + prompter.answers.is_empty(), + "every scripted answer was asked" + ); + let transcript = prompter.transcript.join("\n"); + assert!( + transcript.contains("gho_"), + "gh's token is named by its prefix" + ); + assert_no_secrets(&transcript, &[]); + assert_eq!( + manager.state().profile.as_ref().unwrap()["github"]["token"], + GH_CLI + ); + let actions = summary["action_needed"].as_array().unwrap(); + assert!(actions + .iter() + .any(|a| a.as_str().unwrap().contains("ssh-ed25519 AAAAPUBLIC1"))); + assert!(transcript.contains("isn't provisioned")); + assert!(Store::new(state_dir.clone()) + .load() + .unwrap() + .pending_key + .is_some()); + + // The resumed run retries only the GitHub step. + let mut prompter = ScriptPrompter::quiet(); + let again = onboard( + args(&state_dir, &manager, &["--yes"]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + assert_eq!(tools.gh_added.lock().unwrap().len(), 1); + assert!(Store::new(state_dir.clone()) + .load() + .unwrap() + .pending_key + .is_none()); + assert!(again["action_needed"].as_array().unwrap().is_empty()); + assert_eq!(manager.state().versions, 1, "nothing was enrolled twice"); + + // Enabling Paseo later, and rotating its Secret later still, both work + // without collecting credentials again or replacing agent identities. + let before = manager.state().profile.clone(); + manager.state().paseo_enabled = true; + manager.state().provisioned_pairing = Some(paseo("")); + for version in [2, 3] { + manager.state().paseo = None; + let mut prompter = ScriptPrompter::quiet(); + let summary = onboard( + args(&state_dir, &manager, &["--yes"]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + assert_eq!(summary["paseo"]["provisioned"], true); + assert_eq!(manager.state().versions, version); + assert_eq!(manager.state().profile, before); + assert_eq!(tools.gh_added.lock().unwrap().len(), 1); + } +} + +/// A failed publish and a failed first start each fail the run, are +/// reported, and finish on later runs without repeating what landed. +#[tokio::test] +async fn failures_are_reported_and_resume_without_duplicates() { + let dir = tempfile::tempdir().unwrap(); + let state_dir = dir.path().join("buzz"); + let owner = Keys::generate(); + let relay = FakeRelay::with_invite(INVITE); + relay.state().fail_kind = Some(30177); + let manager = FakeManager::for_developer(&owner.public_key().to_hex()); + let tools = tools(); + let extra = [ + "--yes", + "--import-key", + "--invite", + INVITE, + "--name", + "O", + "--agents", + "brokkr", + ]; + let mut prompter = ScriptPrompter::quiet(); + let error = onboard( + args(&state_dir, &manager, &extra), + env(&owner), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap_err(); + assert!(matches!(error, CliError::Relay { status: 503, .. })); + let state = Store::new(state_dir.clone()).load().unwrap(); + assert!(state.agents[0].profile_published && !state.agents[0].listed); + + // Second run: the listing lands, enrollment happens, the start fails. + // Third run: the manager starts it but cannot update the Paseo + // daemon's agent list, so the start stays pending. + let agent = state.agents[0].pubkey.clone(); + for (index, code) in ["manager_timeout", "paseo_store_failed"].iter().enumerate() { + manager + .state() + .failing_deploys + .insert(agent.clone(), (*code).to_owned()); + let mut prompter = ScriptPrompter::quiet(); + let error = onboard( + args(&state_dir, &manager, &["--yes"]), + if index == 0 { + env(&owner) + } else { + EnvSecrets::default() + }, + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("did not start"), "{error}"); + let transcript = prompter.transcript.join("\n"); + assert!(transcript.contains(code), "{transcript}"); + assert_eq!( + transcript.contains("agent list was not updated"), + *code == "paseo_store_failed" + ); + assert!(!Store::new(state_dir.clone()).load().unwrap().agents[0].registered); + } + + // Fourth run: only the start is retried. + // A completed deploy whose reply is lost also resumes the same root. + manager.state().lost_deploy_reply = true; + let mut prompter = ScriptPrompter::quiet(); + let error = onboard( + args(&state_dir, &manager, &["--yes"]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap_err(); + assert!(error + .to_string() + .contains("manager reply lost after deploy")); + assert!(manager.state().registrations.contains_key(&agent)); + let mut prompter = ScriptPrompter::quiet(); + onboard( + args(&state_dir, &manager, &["--yes"]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + assert!(manager.state().registrations.contains_key(&agent)); + let profiles = relay + .of_kind(0) + .into_iter() + .filter(|p| p["pubkey"] == agent) + .count(); + assert_eq!(profiles, 1); + assert_eq!(relay.of_kind(30177).len(), 1); + assert_eq!(relay.of_kind(9000).len(), 1); + assert_eq!( + relay.of_kind(9).len(), + 1, + "retries reuse the durable kickoff" + ); + let roots = manager.state().deploy_roots.clone(); + let kickoff = relay.of_kind(9)[0]["id"].as_str().unwrap().to_owned(); + assert!(roots.iter().all(|root| root == &kickoff)); + assert_eq!(manager.state().versions, 1); +} + +/// An owner the manager does not know stops before any credential is asked +/// for, and is told which key to give the admin. +#[tokio::test] +async fn an_unconfigured_owner_stops_before_credentials() { + let dir = tempfile::tempdir().unwrap(); + let state_dir = dir.path().join("buzz"); + let owner = Keys::generate(); + let relay = FakeRelay::with_invite(INVITE); + let manager = FakeManager::for_developer("someone-else"); + let tools = tools(); + let extra = ["--yes", "--import-key", "--invite", INVITE, "--name", "O"]; + let mut prompter = ScriptPrompter::quiet(); + let error = onboard( + args(&state_dir, &manager, &extra), + env(&owner), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap_err(); + let hex = owner.public_key().to_hex(); + assert!( + matches!(&error, CliError::Auth(text) if text.contains(&hex)), + "{error}" + ); + assert_eq!(manager.state().ops, [Op::Profile]); + assert_eq!(*tools.keys_made.lock().unwrap(), 0); + assert!(state_dir.join("identity.nsec").exists()); +} + +/// Agents the Buzz app already lists are recognised and never duplicated; +/// mutually exclusive credentials are refused before anything is sent. +#[tokio::test] +async fn app_agents_are_kept_and_exclusive_credentials_refused() { + let dir = tempfile::tempdir().unwrap(); + let state_dir = dir.path().join("buzz"); + let owner = Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let relay = FakeRelay::with_invite(INVITE); + let app_agent = Keys::generate().public_key().to_hex(); + relay.state().events.push(json!({ + "kind": 30177, "pubkey": owner_hex, "created_at": 1, + "tags": [["d", app_agent], ["runtime", agents::RUNTIME_TYPE]], + "content": json!({"name": "Brokkr", "parallelism": 1, "respond_to": "owner-only"}).to_string(), + })); + let manager = FakeManager::for_developer(&owner_hex); + manager.state().paseo = + Some(json!({"password": "x", "server_id": "s", "offer_url": "https://example.com"})); + let malformed = "The Sandbox manager's Paseo pairing was malformed."; + let tools = tools(); + let mut secrets = env(&owner); + secrets.openai_api_key = secret("sk-openai"); + let extra = [ + "--yes", + "--import-key", + "--invite", + INVITE, + "--name", + "O", + "--agents", + "brokkr,bifrost", + ]; + let mut prompter = ScriptPrompter::quiet(); + let error = onboard( + args(&state_dir, &manager, &extra), + secrets, + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + "Choose either an OpenAI API key or a Codex access token" + ); + assert_eq!(manager.state().ops, [Op::Profile]); + + let mut prompter = ScriptPrompter::quiet(); + let summary = onboard( + args(&state_dir, &manager, &extra), + env(&owner), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + let names: Vec<_> = summary["agents"] + .as_array() + .unwrap() + .iter() + .map(|a| a["name"].clone()) + .collect(); + assert_eq!(names, [json!("Bifrost")]); + assert!(prompter + .transcript + .join("\n") + .contains("Already in your Buzz app: Brokkr")); + let actions = summary["action_needed"].as_array().unwrap(); + assert_eq!(actions.iter().filter(|a| *a == malformed).count(), 1); + assert_eq!(summary["paseo"]["provisioned"], false); +} + +/// Real SDK grants never authorize the owner's key or an agent key, and +/// declining the interactive confirmation leaves no grant on the relay. +#[tokio::test] +async fn gateway_grants_require_a_separate_key_and_explicit_confirmation() { + let dir = tempfile::tempdir().unwrap(); + let state_dir = dir.path().join("buzz"); + let owner = Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let relay = FakeRelay::with_invite(INVITE); + let manager = FakeManager::for_developer(&owner_hex); + let gateway = Keys::generate().public_key().to_hex(); + let tools = tools(); + let mut prompter = ScriptPrompter::quiet(); + onboard( + args( + &state_dir, + &manager, + &[ + "--yes", + "--import-key", + "--invite", + INVITE, + "--name", + "O", + "--agents", + "brokkr", + ], + ), + env(&owner), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + + // A daemon key that is the owner's own is refused, even with the SDK. + manager.state().paseo = Some(paseo(&owner_hex)); + let mut prompter = ScriptPrompter::quiet(); + let summary = onboard( + args(&state_dir, &manager, &["--yes"]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + assert!(relay.of_kind(30181).is_empty()); + assert!(summary["action_needed"][0] + .as_str() + .unwrap() + .contains("is your own or an agent's key")); + + // Declining in the terminal grants nothing and says how to grant later. + manager.state().paseo = Some(paseo(&gateway)); + let mut prompter = ScriptPrompter::scripted(vec![ + Answer::Secret(String::new()), // keep the Claude token on file + Answer::Secret(String::new()), // no Codex token + Answer::Secret(String::new()), // no OpenRouter key + Answer::Text("n"), // do not grant the gateway key + ]); + onboard( + args(&state_dir, &manager, &[]), + EnvSecrets::default(), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + assert!(prompter.answers.is_empty()); + assert!(relay.of_kind(30181).is_empty()); + assert!(prompter.transcript.join("\n").contains(&format!( + "Skipped. To grant it later, run: buzz gateway grant {gateway} --expires 90d" + ))); +} + +/// Every enroll refusal, `paseo_store_failed` included, fails the run with +/// its code and guidance, adds no signing key to GitHub (the app adds it +/// only after the manager stores it), saves nothing as enrolled, and a +/// plain re-run then enrolls once. +#[tokio::test] +async fn every_enroll_refusal_is_reported_and_resumes() { + let codes = profile::REFUSALS + .iter() + .map(|(code, _)| *code) + .chain(["something_new"]); + for code in codes { + let dir = tempfile::tempdir().unwrap(); + let state_dir = dir.path().join("buzz"); + let owner = Keys::generate(); + let relay = FakeRelay::with_invite(INVITE); + let manager = FakeManager::for_developer(&owner.public_key().to_hex()); + manager.state().paseo = Some(paseo("")); + manager.state().enroll_refusal = Some(code.to_owned()); + let tools = tools(); + let extra = [ + "--yes", + "--import-key", + "--invite", + INVITE, + "--name", + "O", + "--agents", + "brokkr", + ]; + let mut prompter = ScriptPrompter::quiet(); + let error = onboard( + args(&state_dir, &manager, &extra), + env(&owner), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap_err(); + let text = error.to_string(); + assert!( + text.contains(&format!("Refused ({code})")), + "{code}: {text}" + ); + let reason = profile::REFUSALS.iter().find(|(known, _)| *known == code); + if let Some((_, reason)) = reason { + assert!(text.contains(reason), "{code}: {text}"); + } + assert_eq!( + matches!(error, CliError::Auth(_)), + code == "unauthorized_owner", + "{code}" + ); + assert!(tools.gh_added.lock().unwrap().is_empty(), "{code}"); + let state = Store::new(state_dir.clone()).load().unwrap(); + assert!( + state.enrolled_version.is_none() && state.pending_key.is_none(), + "{code}" + ); + assert!(!prompter.transcript.join("\n").contains(PASSWORD), "{code}"); + + let mut prompter = ScriptPrompter::quiet(); + let summary = onboard( + args(&state_dir, &manager, &["--yes"]), + env(&owner), + &mut prompter, + &relay, + &manager, + &tools, + ) + .await + .unwrap(); + assert_eq!(manager.state().versions, 1, "{code}"); + assert_eq!(tools.gh_added.lock().unwrap().len(), 1, "{code}"); + assert_eq!(summary["paseo"]["provisioned"], true, "{code}"); + assert_eq!(summary["agents"][0]["registered"], true, "{code}"); + } +} + +/// The pairing and grant-lifetime contracts, row by row. +#[test] +fn paseo_pairings_and_grant_lifetimes_are_checked() { + let key = Keys::generate().public_key(); + let hex = key.to_hex(); + let with = |field: &str, value: Value| { + let mut pairing = paseo(""); + pairing[field] = value; + json!({"ok": true, "paseo": pairing}) + }; + let gateway = |reply: Value| paseo::pairing(&reply).map(|p| p.map(|p| p.gateway)); + let malformed = Err("The Sandbox manager's Paseo pairing was malformed.".to_owned()); + for (reply, expected) in [ + (json!({"ok": true}), Ok(None)), + (json!({"ok": true, "paseo": null}), Ok(None)), + (json!({"ok": true, "paseo": paseo("")}), Ok(Some(None))), + (with("gateway_pubkey", Value::Null), Ok(Some(None))), + (with("gateway_pubkey", json!(hex)), Ok(Some(Some(key)))), + ( + with("gateway_pubkey", json!(key.to_bech32().unwrap())), + malformed.clone(), + ), + (with("gateway_pubkey", json!(&hex[1..])), malformed.clone()), + ( + with("gateway_pubkey", json!("z".repeat(64))), + malformed.clone(), + ), + (with("gateway_pubkey", json!(7)), malformed.clone()), + ( + with("offer_url", json!("https://example.com/#offer=x")), + malformed.clone(), + ), + (with("password", json!("")), malformed.clone()), + ] { + assert_eq!(gateway(reply.clone()), expected, "{reply}"); + } + for (input, expected) in [ + ("90d", Some(("90d", 90 * 86_400))), + (" 36H ", Some(("36h", 36 * 3_600))), + ("2w", Some(("2w", 14 * 86_400))), + ("91d", None), + ("365d", None), + ("0d", None), + ("90", None), + ("d", None), + ("-1d", None), + ("9é", None), + ("", None), + ] { + let got = gateway::lifetime(input).ok(); + assert_eq!( + got.as_ref().map(|(text, secs)| (text.as_str(), *secs)), + expected, + "{input:?}" + ); + } + assert!(gateway::needs_renewal(100, 100, 90 * 86_400)); + assert!(gateway::needs_renewal(100 + 7 * 86_400, 100, 90 * 86_400)); + assert!(!gateway::needs_renewal(101 + 7 * 86_400, 100, 90 * 86_400)); + assert!(!gateway::needs_renewal(100 + 3 * 3_600, 100, 4 * 3_600)); +} + +/// Only wss:// community origins are accepted, before anything is written. +#[test] +fn only_wss_origins_are_accepted() { + for (input, expected) in [ + ("wss://buzz.voltage.cloud", Some("wss://buzz.voltage.cloud")), + ( + "wss://buzz.voltage.cloud/", + Some("wss://buzz.voltage.cloud"), + ), + ("wss://relay.example:8443", Some("wss://relay.example:8443")), + ("ws://buzz.voltage.cloud", None), + ("https://buzz.voltage.cloud", None), + ("http://localhost:3000", None), + ("wss://buzz.voltage.cloud/path", None), + ("wss://user@buzz.voltage.cloud", None), + ("wss://buzz.voltage.cloud?x=1", None), + ("buzz.voltage.cloud", None), + ] { + assert_eq!(canonical_relay(input).ok().as_deref(), expected, "{input}"); + } + let dir = tempfile::tempdir().unwrap(); + let state_dir = dir.path().join("buzz"); + let manager = FakeManager::for_developer(""); + let relay = FakeRelay::default(); + let tools = tools(); + let mut prompter = ScriptPrompter::quiet(); + let refused = Wizard::new( + args( + &state_dir, + &manager, + &["--relay", "ws://buzz.voltage.cloud"], + ), + EnvSecrets::default(), + Store::new(state_dir.clone()), + &mut prompter, + &relay, + &manager, + &tools, + ); + assert!(matches!(refused, Err(CliError::Usage(_)))); + let refused = Wizard::new( + args(&state_dir, &manager, &["--gateway-expires", "0d"]), + EnvSecrets::default(), + Store::new(state_dir.clone()), + &mut prompter, + &relay, + &manager, + &tools, + ); + assert!(matches!(refused, Err(CliError::Usage(_)))); + assert!(!state_dir.exists()); +} + +/// The model credential rules, including both mutual exclusions. +#[test] +fn model_credentials_follow_the_profile_rules() { + let cases: [(Value, Result<(), &str>); 7] = [ + ( + json!({"claude_oauth_token": CLAUDE, "codex_access_token": CODEX}), + Ok(()), + ), + (json!({"openrouter_api_key": "sk-or-1"}), Ok(())), + ( + json!({"codex_access_token": "sk-not-at"}), + Err("Use a Codex access token (at-...)"), + ), + ( + json!({"openai_api_key": "k", "codex_access_token": CODEX}), + Err("Choose either an OpenAI API key or a Codex access token"), + ), + ( + json!({"anthropic_api_key": "k", "claude_oauth_token": CLAUDE}), + Err("Choose either a Claude API key or a Claude subscription token"), + ), + ( + json!({"claude_oauth_token": "has space"}), + Err("Invalid credential"), + ), + ( + json!({"gemini": "k"}), + Err("Unsupported model credential type"), + ), + ]; + for (models, expected) in cases { + assert_eq!( + profile::models(&models), + expected.map_err(str::to_owned), + "{models}" + ); + } +} + +/// The embedded via profile is the documented shared one, and valid. +#[test] +fn the_shared_via_profile_matches_the_docs() { + let doc = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../voltage/remote-agents.md"), + ) + .unwrap(); + let block = doc + .split("### Shared via profile") + .nth(1) + .and_then(|rest| rest.split("```toml\n").nth(1)) + .and_then(|rest| rest.split("```").next()) + .unwrap(); + assert_eq!(block, SHARED_VIA_PROFILE); + let partial = json!({"version": 1, "developer": "", "owner_pubkey": "a".repeat(64), + "via": {"profile": SHARED_VIA_PROFILE}}); + assert_eq!(profile::validate_partial(&partial), Ok(())); +} + +/// Invite input, secret debug output and GPUI launch parity. +#[test] +fn small_contracts_hold() { + for (input, expected) in [ + ("v2.abc", Some("v2.abc")), + ("https://buzz.voltage.cloud/invite/v2.abc", Some("v2.abc")), + (" code_1-x ", Some("code_1-x")), + ("https://buzz.voltage.cloud/", None), + ("bad code", None), + ] { + assert_eq!( + steps_identity::invite_code(input).ok().as_deref(), + expected, + "{input}" + ); + } + assert_eq!( + format!("{:?}", Secret::new("nsec1secret".into())), + "" + ); + let owner = Keys::generate(); + let mut agent = agents::create(agents::preset("openrouter").unwrap(), &owner).unwrap(); + let env = agents::policy_env(&agent); + assert_eq!(env["BUZZ_AGENT_MODEL"], "qwen/qwen3-coder"); + assert_eq!(env["MCP_HOOK_SERVERS"], "*"); + assert!(!env.contains_key("BUZZ_ACP_EFFORT_LEVEL")); + agent.runtime = "claude".into(); + agent.model = "claude-opus-5-5".into(); + assert_eq!( + agents::policy_env(&agent)["ANTHROPIC_MODEL"], + "claude-opus-5-5" + ); + for preset in agents::PRESETS { + assert!(["", "low", "medium", "high", "xhigh", "max"].contains(&preset.effort)); + assert!(agents::RUNTIMES.iter().any(|r| r.id == preset.runtime)); + } + let mut all = Vec::new(); + strings(&json!({"a": ["b"]}), &mut all); + assert_eq!(all, ["b"]); +} + +/// The production HTTP boundary requests writer reads for every provisioning +/// lookup. A simulated lagging replica otherwise hides the existing agent. +#[tokio::test] +async fn provisioning_reads_see_writer_state() { + use super::relay::{HttpRelay, Relay}; + use axum::{routing::post, Json, Router}; + let owner = Keys::generate(); + let agent = nostr::EventBuilder::new(nostr::Kind::Custom(30177), "existing agent") + .tags([nostr::Tag::parse(["d", &Keys::generate().public_key().to_hex()]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let expected = serde_json::to_value(agent).unwrap(); + let writer = expected.clone(); + let app = Router::new().route( + "/query", + post(move |Json(filters): Json| { + let writer = writer.clone(); + async move { + let strong = filters + .as_array() + .unwrap() + .iter() + .all(|f| f["consistency"] == "strong"); + Json(if strong { json!([writer]) } else { json!([]) }) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let relay = HttpRelay::new(&format!("http://{address}")); + let events = Relay::query(&relay, &owner, json!({"kinds": [30177], "limit": 1})).await; + server.abort(); + assert_eq!(events.unwrap(), vec![expected]); +} + +/// The actual HTTP publisher accepts a dominated NIP-33 receipt. The default +/// wizard must still read the writer and refuse to claim a concurrent revoke +/// was replaced by its older grant. +#[tokio::test] +async fn gateway_confirmation_requires_the_submitted_live_head_after_duplicate_receipt() { + use super::relay::HttpRelay; + use axum::{routing::post, Json, Router}; + use std::sync::{Arc, Mutex}; + let dir = tempfile::tempdir().unwrap(); + let owner = Keys::generate(); + let gateway = Keys::generate().public_key(); + let current = Arc::new(Mutex::new(None::)); + let reads = current.clone(); + let writes = current.clone(); + let signing_owner = owner.clone(); + let app = Router::new() + .route("/query", post(move |Json(filters): Json| { + let current = reads.clone(); + async move { + assert!(filters.as_array().unwrap().iter().all(|filter| filter["consistency"] == "strong")); + Json(if filters[0]["kinds"][0] == 30181 { + current.lock().unwrap().as_ref().map_or(json!([]), |event| json!([event])) + } else { json!([]) }) + } + })) + .route("/events", post(move |Json(event): Json| { + let current = writes.clone(); + let owner = signing_owner.clone(); + async move { + event.verify().unwrap(); + let revoked = buzz_sdk::gateway::Delegation::revoke(gateway, event.created_at.as_secs() + 1) + .unwrap().sign_with_keys(&owner).unwrap(); + *current.lock().unwrap() = Some(revoked); + Json(json!({"event_id":event.id,"accepted":true,"message":"duplicate: replaced by a newer event"})) + } + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let relay = HttpRelay::new(&format!("http://{address}")); + let manager = FakeManager::for_developer(&owner.public_key().to_hex()); + let mut prompter = ScriptPrompter::quiet(); + let tools = tools(); + let mut wizard = Wizard::new( + args(dir.path(), &manager, &["--yes"]), + EnvSecrets::default(), + Store::new(dir.path().to_owned()), + &mut prompter, + relay, + &manager, + &tools, + ) + .unwrap(); + wizard.state.owner_pubkey = owner.public_key().to_hex(); + let pairing = paseo::pairing(&json!({"paseo":paseo(&gateway.to_hex())})) + .unwrap() + .unwrap(); + let result = wizard.gateway_grant(&owner, Some(&pairing)).await; + assert!(matches!(result, Err(CliError::Conflict(_)))); + assert!(wizard.state.gateway_grant.is_none()); + drop(wizard); + assert!(!prompter + .transcript + .join("\n") + .contains("Granted your Paseo")); + assert!(Store::new(dir.path().to_owned()) + .load() + .unwrap() + .gateway_grant + .is_none()); + assert!( + !buzz_sdk::gateway::Delegation::parse(current.lock().unwrap().as_ref().unwrap()) + .unwrap() + .0 + .enabled + ); + server.abort(); +} diff --git a/crates/buzz-cli/src/commands/onboard/tests_fakes.rs b/crates/buzz-cli/src/commands/onboard/tests_fakes.rs new file mode 100644 index 00000000000..8c645964dac --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/tests_fakes.rs @@ -0,0 +1,602 @@ +//! Fakes for the four external boundaries: the terminal, the relay, the +//! Sandbox manager's transport, and local tools. The manager fake opens the +//! real encrypted requests with the manager key and answers with real +//! signed replies, so the wire path under test is the production one. + +use std::collections::{BTreeMap, HashSet, VecDeque}; +use std::sync::Mutex; +use std::time::Duration; + +use buzz_session_control::{build_reply, open_request, Op, Reply}; +use nostr::{Event, Keys, Timestamp}; +use serde_json::{json, Map, Value}; +use zeroize::Zeroizing; + +use super::super::io::Prompter; +use super::super::manager::ControlTransport; +use super::super::profile::{self, GitHubIdentity}; +use super::super::relay::{Claimed, JoinPolicy, Relay, Signer}; +use super::super::tools::{SigningKey, Tools}; +use crate::error::CliError; + +/// One scripted answer. +pub enum Answer { + Text(&'static str), + Secret(String), +} + +/// Scripted terminal; records everything it would have shown or asked. +pub struct ScriptPrompter { + pub interactive: bool, + pub answers: VecDeque, + pub transcript: Vec, +} + +impl ScriptPrompter { + pub fn quiet() -> Self { + Self { + interactive: false, + answers: VecDeque::new(), + transcript: Vec::new(), + } + } + + pub fn scripted(answers: Vec) -> Self { + Self { + interactive: true, + answers: answers.into(), + transcript: Vec::new(), + } + } +} + +impl Prompter for &mut ScriptPrompter { + fn interactive(&self) -> bool { + self.interactive + } + + fn say(&mut self, text: &str) { + self.transcript.push(text.to_owned()); + } + + fn ask( + &mut self, + question: &str, + default: Option<&str>, + hint: &str, + ) -> Result { + self.transcript.push(format!("? {question}")); + if !self.interactive { + return default + .map(str::to_owned) + .ok_or_else(|| CliError::Usage(format!("{}: {hint}", question.trim()))); + } + match self.answers.pop_front() { + Some(Answer::Text("")) => Ok(default.unwrap_or_default().to_owned()), + Some(Answer::Text(text)) => Ok(text.to_owned()), + Some(Answer::Secret(_)) => panic!("asked {question:?} but the script has a secret"), + None => panic!("no scripted answer for {question:?}"), + } + } + + fn secret(&mut self, question: &str, hint: &str) -> Result, CliError> { + self.transcript.push(format!("? {question}")); + if !self.interactive { + return Err(CliError::Usage(format!("{}: {hint}", question.trim()))); + } + match self.answers.pop_front() { + Some(Answer::Secret(text)) => Ok(Zeroizing::new(text)), + Some(Answer::Text(_)) => panic!("asked secret {question:?} but the script has text"), + None => panic!("no scripted secret for {question:?}"), + } + } +} + +/// An in-memory community. +#[derive(Default)] +pub struct FakeRelay { + pub inner: Mutex, +} + +#[derive(Default)] +pub struct RelayState { + pub members: HashSet, + pub invites: HashSet, + pub policy: Option, + pub accepted_policy: bool, + pub events: Vec, + pub published: usize, + /// The next publish of this kind fails once. + pub fail_kind: Option, +} + +fn tag_values<'a>(event: &'a Value, name: &str) -> Vec<&'a str> { + event["tags"] + .as_array() + .into_iter() + .flatten() + .filter(|tag| tag[0] == name) + .filter_map(|tag| tag[1].as_str()) + .collect() +} + +fn matches(filter: &Value, event: &Value) -> bool { + let within = |key: &str, value: &Value| { + filter + .get(key) + .and_then(Value::as_array) + .is_none_or(|allowed| allowed.contains(value)) + }; + let tagged = |key: &str, tag: &str| { + filter + .get(key) + .and_then(Value::as_array) + .is_none_or(|allowed| { + tag_values(event, tag) + .iter() + .any(|value| allowed.iter().any(|allowed| allowed == value)) + }) + }; + within("kinds", &event["kind"]) + && within("ids", &event["id"]) + && within("authors", &event["pubkey"]) + && tagged("#h", "h") + && tagged("#d", "d") + && tagged("#p", "p") +} + +impl FakeRelay { + pub fn with_invite(code: &str) -> Self { + let relay = Self::default(); + relay.state().invites.insert(code.to_owned()); + relay + } + + pub fn state(&self) -> std::sync::MutexGuard<'_, RelayState> { + self.inner.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub fn of_kind(&self, kind: u64) -> Vec { + self.state() + .events + .iter() + .filter(|event| event["kind"] == kind) + .cloned() + .collect() + } +} + +impl Relay for &FakeRelay { + async fn is_member(&self, keys: &Keys) -> Result { + Ok(self.state().members.contains(&keys.public_key().to_hex())) + } + + async fn join_policy(&self) -> Result, CliError> { + Ok(self.state().policy.clone()) + } + + async fn accept_policy( + &self, + _keys: &Keys, + code: &str, + policy: &JoinPolicy, + _age: bool, + ) -> Result { + let mut state = self.state(); + assert_eq!(state.policy.as_ref(), Some(policy)); + state.accepted_policy = true; + Ok(format!("receipt-{code}")) + } + + async fn claim( + &self, + keys: &Keys, + code: &str, + receipt: Option<&str>, + ) -> Result { + let mut state = self.state(); + if state.policy.is_some() && receipt != Some(&format!("receipt-{code}")) { + return Err(CliError::Auth("join_policy_required".into())); + } + if !state.invites.contains(code) { + return Err(CliError::Auth( + "This invite code is not valid for this community.".into(), + )); + } + Ok(if state.members.insert(keys.public_key().to_hex()) { + Claimed::Joined + } else { + Claimed::AlreadyMember + }) + } + + async fn query(&self, keys: &Keys, filter: Value) -> Result, CliError> { + let state = self.state(); + if !state.members.contains(&keys.public_key().to_hex()) { + return Err(CliError::Relay { + status: 403, + body: "restricted".into(), + }); + } + let mut events: Vec<_> = state + .events + .iter() + .filter(|event| matches(&filter, event)) + .cloned() + .collect(); + events.sort_by(|a, b| { + b["created_at"] + .as_u64() + .cmp(&a["created_at"].as_u64()) + .then_with(|| a["id"].as_str().cmp(&b["id"].as_str())) + }); + if let Some(limit) = filter["limit"].as_u64() { + events.truncate(limit as usize); + } + Ok(events) + } + + async fn publish(&self, signer: &Signer, event: Event) -> Result<(), CliError> { + assert!(event.verify().is_ok(), "the relay only takes valid events"); + assert_eq!( + event.pubkey, + signer.keys.public_key(), + "NIP-98 signer is the author" + ); + let mut state = self.state(); + let author = event.pubkey.to_hex(); + // Admission: a member, or an agent its member owner attests. + let admitted = state.members.contains(&author) + || signer.auth_tag.as_deref().is_some_and(|tag| { + buzz_sdk::nip_oa::verify_auth_tag(tag, &event.pubkey).is_ok() + && state + .members + .contains(&serde_json::from_str::>(tag).unwrap()[1]) + }); + if !admitted { + return Err(CliError::Relay { + status: 403, + body: "not a member".into(), + }); + } + let value = serde_json::to_value(&event).unwrap(); + let kind = value["kind"].as_u64().unwrap(); + if state.fail_kind == Some(kind) { + state.fail_kind = None; + return Err(CliError::Relay { + status: 503, + body: "unavailable".into(), + }); + } + state.published += 1; + if kind == 9007 { + let id = tag_values(&value, "h")[0].to_owned(); + let name = tag_values(&value, "name")[0].to_owned(); + state + .events + .push(json!({"kind": 39000, "pubkey": "relay", "created_at": 0, + "tags": [["d", id], ["name", name]], "content": ""})); + state + .events + .push(json!({"kind": 39002, "pubkey": "relay", "created_at": 0, + "tags": [["d", id], ["p", author]], "content": ""})); + } + state.events.push(value); + Ok(()) + } +} + +/// The Sandbox manager, reached through a fake transport. +pub struct FakeManager { + pub keys: Keys, + pub inner: Mutex, +} + +#[derive(Default)] +pub struct ManagerState { + /// Accept every owner as a configured developer. + pub any_developer: bool, + pub developers: HashSet, + pub profile: Option, + pub versions: u32, + pub paseo: Option, + pub ops: Vec, + /// Agents whose next deploy is refused, with the refusal code. + pub failing_deploys: BTreeMap, + /// The code the next enroll is refused with, after the owner check. + pub enroll_refusal: Option, + /// Stored launch registrations, by agent pubkey. + pub registrations: BTreeMap, + /// Recorded scopes prove retries converge on the same Sandbox. + pub deploy_roots: Vec, + /// Commit a deploy, then lose its response once. + pub lost_deploy_reply: bool, + pub paseo_enabled: bool, + pub provisioned_pairing: Option, +} + +impl FakeManager { + pub fn for_developer(owner: &str) -> Self { + let manager = Self { + keys: Keys::generate(), + inner: Mutex::default(), + }; + manager.state().developers.insert(owner.to_owned()); + manager + } + + pub fn state(&self) -> std::sync::MutexGuard<'_, ManagerState> { + self.inner.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn summary(profile: &Value) -> Value { + json!({ + "version_id": "v", + "git": {"name": profile["git"]["name"], "email": profile["git"]["email"], + "signing_key": profile["git"]["ssh_signing_key"].is_string()}, + "github": {"login": profile["github"]["login"], "token": profile["github"]["token"].is_string()}, + "via": {"profile": profile["via"]["profile"].is_string(), "op_token": profile["via"]["op_token"].is_string()}, + "models": profile["models"].as_object().map(|m| m.keys().cloned().collect::>()).unwrap_or_default(), + }) + } + + fn answer(&self, signer: &str, op: Op, payload: &Value, auth: Option<&str>) -> Value { + let mut state = self.state(); + state.ops.push(op); + let owner = match (op, auth) { + (Op::Enroll | Op::Profile, None) => signer.to_owned(), + (Op::Deploy, Some(tag)) => { + let agent = nostr::PublicKey::from_hex(signer).unwrap(); + match buzz_sdk::nip_oa::verify_auth_tag(tag, &agent) { + Ok(owner) => owner.to_hex(), + Err(_) => return json!({"ok": false, "code": "unauthorized_owner"}), + } + } + _ => return json!({"ok": false, "code": "signer_mismatch"}), + }; + if !state.any_developer && !state.developers.contains(&owner) { + return json!({"ok": false, "code": "unauthorized_owner"}); + } + let with_paseo = |mut reply: Value, paseo: &Option| { + if let Some(paseo) = paseo { + reply["paseo"] = paseo.clone(); + } + reply + }; + match op { + Op::Profile => { + let summary = state.profile.as_ref().map(Self::summary); + with_paseo( + json!({"ok": true, "profile": summary, "paseo_enabled": state.paseo_enabled || state.paseo.is_some()}), + &state.paseo, + ) + } + Op::Enroll => { + if let Some(code) = state.enroll_refusal.take() { + return json!({"ok": false, "code": code, "error": "refused by the fake"}); + } + let profile = payload["profile"].clone(); + let checked = match &state.profile { + None => profile::validate(&profile), + Some(_) => profile::validate_partial(&profile), + }; + if checked.is_err() || profile["owner_pubkey"] != owner { + return json!({"ok": false, "code": "invalid_profile"}); + } + let merged = match state.profile.take() { + None => profile, + Some(mut stored) => { + merge(&mut stored, &profile); + stored + } + }; + state.profile = Some(merged); + state.versions += 1; + if state.paseo_enabled && state.paseo.is_none() { + state.paseo = state.provisioned_pairing.clone(); + } + let version = format!("v{}", state.versions); + with_paseo(json!({"ok": true, "version_id": version}), &state.paseo) + } + Op::Deploy => { + // The real manager's checks on the provider request. + let request = buzz_backend_kubernetes::provider::parse(payload.clone()) + .unwrap_or_else(|_| panic!("the manager could not parse the deploy")); + let buzz_backend_kubernetes::wire::Request::Deploy(deploy) = &request else { + panic!("not a deploy"); + }; + buzz_backend_kubernetes::provider::check_sandbox_request(deploy) + .unwrap_or_else(|error| panic!("the manager refused the deploy: {error:?}")); + assert!(state.profile.is_some(), "deploys follow enrollment"); + state.deploy_roots.push( + payload["provider_config"]["sandbox"]["thread_root"] + .as_str() + .unwrap_or_default() + .to_owned(), + ); + if let Some(code) = state.failing_deploys.remove(signer) { + // The real manager refuses `paseo_store_failed` after it + // saved the registration; the other codes, before. + if code == "paseo_store_failed" { + state + .registrations + .insert(signer.to_owned(), payload["agent"].clone()); + } + return json!({"ok": false, "code": code, "error": "refused by the fake"}); + } + state + .registrations + .insert(signer.to_owned(), payload["agent"].clone()); + json!({"ok": true, "agent_id": "sandbox-1"}) + } + _ => json!({"ok": false, "code": "unsupported"}), + } + } +} + +fn merge(stored: &mut Value, partial: &Value) { + for (key, value) in partial.as_object().into_iter().flatten() { + match (stored.get_mut(key), value) { + (Some(Value::Object(into)), Value::Object(from)) => { + for (field, value) in from { + into.insert(field.clone(), value.clone()); + } + } + _ => { + stored[key] = value.clone(); + } + } + } +} + +impl ControlTransport for &FakeManager { + async fn exchange( + &self, + signer: &Signer, + request: Event, + _timeout: Duration, + mut open: F, + ) -> Result + where + F: FnMut(&Event) -> Option, + { + let opened = open_request(&request, &self.keys).expect("encrypted to the manager"); + assert!(!request.content.contains("token"), "content is ciphertext"); + assert_eq!(opened.signer, signer.keys.public_key()); + let response = self.answer( + &opened.signer.to_hex(), + opened.request.op, + &opened.request.payload, + opened.auth_tag.as_deref(), + ); + if opened.request.op == Op::Deploy && self.state().lost_deploy_reply { + self.state().lost_deploy_reply = false; + return Err(CliError::DeliveryUnknown( + "manager reply lost after deploy".into(), + )); + } + let id = opened.request.request_id.clone(); + let requester = signer.keys.public_key(); + let now = Timestamp::now(); + let stranger = Keys::generate(); + let candidates = [ + // Not terminal. + build_reply( + &self.keys, + &requester, + &request.id, + &Reply::accepted(id.clone()), + now, + ), + // Forged: not the manager's key. + build_reply( + &stranger, + &requester, + &request.id, + &Reply::done(id.clone(), json!({"ok": true, "version_id": "forged"})), + now, + ), + // Another request's reply. + build_reply( + &self.keys, + &requester, + &nostr::EventId::from_slice(&[7u8; 32]).expect("an id"), + &Reply::done("other", json!({"ok": false})), + now, + ), + build_reply( + &self.keys, + &requester, + &request.id, + &Reply::done(id, response), + now, + ), + ]; + for candidate in candidates { + if let Some(response) = open(&candidate.expect("reply builds")) { + return Ok(response); + } + } + Err(CliError::Other("no reply opened".into())) + } +} + +/// Local tools with scripted results. +#[derive(Default)] +pub struct FakeTools { + pub gh_token: Option, + pub identities: BTreeMap, + pub keys_made: Mutex, + /// What the next `gh` signing key additions return, in order; `Ok` after. + pub gh_results: Mutex>>, + pub gh_added: Mutex>, + pub git_email: String, +} + +impl Tools for &FakeTools { + async fn gh_auth_token(&self) -> Option> { + self.gh_token.clone().map(Zeroizing::new) + } + + async fn github_identity(&self, token: &str) -> Result { + self.identities + .get(token) + .cloned() + .ok_or_else(|| "GitHub did not accept the token.".into()) + } + + async fn ssh_keygen(&self, comment: &str) -> Result { + let mut made = self.keys_made.lock().unwrap(); + *made += 1; + Ok(SigningKey { + private: Zeroizing::new(format!( + "-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATEKEYMATERIAL{made}\n-----END OPENSSH PRIVATE KEY-----\n" + )), + public: format!("ssh-ed25519 AAAAPUBLIC{made} {comment}"), + }) + } + + async fn gh_add_signing_key( + &self, + login: &str, + _title: &str, + public: &str, + ) -> Result<(), &'static str> { + let result = self + .gh_results + .lock() + .unwrap() + .pop_front() + .unwrap_or(Ok(())); + if result.is_ok() { + self.gh_added + .lock() + .unwrap() + .push((login.to_owned(), public.to_owned())); + } + result + } + + async fn git_email(&self) -> String { + self.git_email.clone() + } + + async fn claude_setup_token(&self) -> bool { + true + } +} + +/// Every string value in `value`, for secret scans. +pub fn strings(value: &Value, out: &mut Vec) { + match value { + Value::String(text) => out.push(text.clone()), + Value::Array(items) => items.iter().for_each(|item| strings(item, out)), + Value::Object(fields) => fields.values().for_each(|item| strings(item, out)), + _ => {} + } +} + +#[allow(dead_code)] +pub fn object(value: Value) -> Map { + value.as_object().cloned().unwrap_or_default() +} diff --git a/crates/buzz-cli/src/commands/onboard/tools.rs b/crates/buzz-cli/src/commands/onboard/tools.rs new file mode 100644 index 00000000000..2c9120ac061 --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/tools.rs @@ -0,0 +1,228 @@ +//! The local tool boundary: the GitHub CLI (`gh`), `ssh-keygen`, `git`, +//! `claude`, and GitHub's `GET /user`. Each call mirrors the GPUI enroll +//! form (`remote_workspace/signing.rs` and `enroll_form.rs`): the same +//! commands, arguments, timeouts and failure wording. No token or private +//! key is ever logged or put on a command line. + +use std::process::Stdio; +use std::time::Duration; + +use zeroize::Zeroizing; + +use super::profile::GitHubIdentity; + +/// A generated key pair: the OpenSSH private key and its public line. +pub struct SigningKey { + /// The OpenSSH private key. Never print it. + pub private: Zeroizing, + /// The `ssh-ed25519 …` public line. + pub public: String, +} + +/// What `buzz onboard` runs on the developer's machine. +pub trait Tools { + /// `gh auth token`, when `gh` is installed and signed in. + async fn gh_auth_token(&self) -> Option>; + + /// Who `token` belongs to, from GitHub's `GET /user`. + async fn github_identity(&self, token: &str) -> Result; + + /// A fresh Ed25519 key without a passphrase, from `ssh-keygen`. + async fn ssh_keygen(&self, comment: &str) -> Result; + + /// Adds `public` to `login`'s account as a signing key titled `title` + /// with `gh`, which must be signed in as `login`; or why not. + async fn gh_add_signing_key( + &self, + login: &str, + title: &str, + public: &str, + ) -> Result<(), &'static str>; + + /// `git config --global user.email`, or empty. + async fn git_email(&self) -> String; + + /// Runs `claude setup-token` attached to the terminal, so the developer + /// signs in and sees the token; false when `claude` is not installed. + async fn claude_setup_token(&self) -> bool; +} + +/// The real tools. +pub struct SystemTools; + +const API: &str = "https://api.github.com"; +/// How long one `gh` call may take. +const GH_TIMEOUT: Duration = Duration::from_secs(30); + +async fn gh(args: &[&str]) -> Result { + let child = tokio::process::Command::new("gh") + .args(args) + .env("GH_HOST", "github.com") + .env("GH_PROMPT_DISABLED", "1") + .stdin(Stdio::null()) + .kill_on_drop(true) + .output(); + match tokio::time::timeout(GH_TIMEOUT, child).await { + Ok(Ok(output)) => Ok(output), + Ok(Err(_)) => Err("the GitHub CLI (gh) is not installed"), + Err(_) => Err("the GitHub CLI (gh) did not answer"), + } +} + +/// Why a failed `gh` call could not add the key, from its error output. +pub fn gh_failure(stderr: &str) -> &'static str { + if stderr.contains("admin:ssh_signing_key") { + "gh needs the admin:ssh_signing_key scope: run gh auth refresh -h github.com -s admin:ssh_signing_key, then buzz onboard again" + } else if stderr.contains("gh auth login") || stderr.contains("not logged") { + "gh is not signed in: run gh auth login" + } else if stderr.contains("key is already in use") { + "this key is already on a GitHub account" + } else { + "gh could not add the key" + } +} + +/// `GET /user`'s login and name. +pub fn parse_identity(body: &[u8]) -> Result { + let user: serde_json::Value = + serde_json::from_slice(body).map_err(|_| "Could not read GitHub's reply.".to_string())?; + let login = user["login"] + .as_str() + .filter(|login| !login.is_empty()) + .ok_or("Could not read GitHub's reply.")? + .to_owned(); + let name = user["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty() && !name.chars().any(char::is_control)) + .unwrap_or(&login) + .to_owned(); + Ok(GitHubIdentity { login, name }) +} + +impl Tools for SystemTools { + async fn gh_auth_token(&self) -> Option> { + let output = gh(&["auth", "token", "--hostname", "github.com"]) + .await + .ok()?; + let token = Zeroizing::new(String::from_utf8(output.stdout).ok()?.trim().to_owned()); + (output.status.success() && !token.is_empty()).then_some(token) + } + + async fn github_identity(&self, token: &str) -> Result { + let unreachable = || "Could not reach GitHub.".to_string(); + let client = reqwest::Client::builder() + .https_only(true) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|_| unreachable())?; + let response = client + .get(format!("{API}/user")) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "buzz-cli") + .send() + .await + .map_err(|_| unreachable())?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("GitHub did not accept the token.".into()); + } + if !response.status().is_success() { + return Err(format!( + "GitHub refused the token ({}).", + response.status().as_u16() + )); + } + let body = response + .bytes() + .await + .map_err(|_| "Could not read GitHub's reply.".to_string())?; + parse_identity(&body) + } + + async fn ssh_keygen(&self, comment: &str) -> Result { + let directory = tempfile::tempdir().map_err(|_| "Could not create the signing key.")?; + let path = directory.path().join("agent-signing-key"); + let status = tokio::process::Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-C", comment, "-f"]) + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status() + .await + .map_err(|_| "Could not run ssh-keygen. Install OpenSSH.")?; + if !status.success() { + return Err("ssh-keygen could not create the signing key.".into()); + } + let private = Zeroizing::new( + std::fs::read_to_string(&path).map_err(|_| "Could not read the signing key.")?, + ); + let public = std::fs::read_to_string(path.with_extension("pub")) + .map_err(|_| "Could not read the signing key.")? + .trim() + .to_owned(); + Ok(SigningKey { private, public }) + } + + async fn gh_add_signing_key( + &self, + login: &str, + title: &str, + public: &str, + ) -> Result<(), &'static str> { + let user = gh(&["api", "user", "--jq", ".login"]).await?; + if !user.status.success() { + return Err(gh_failure(&String::from_utf8_lossy(&user.stderr))); + } + if !String::from_utf8_lossy(&user.stdout) + .trim() + .eq_ignore_ascii_case(login) + { + return Err("gh is signed in to a different GitHub account than the token"); + } + let title = format!("title={title}"); + let key = format!("key={public}"); + let added = gh(&[ + "api", + "--method", + "POST", + "/user/ssh_signing_keys", + "-f", + &title, + "-f", + &key, + ]) + .await?; + if added.status.success() { + Ok(()) + } else { + Err(gh_failure(&String::from_utf8_lossy(&added.stderr))) + } + } + + async fn git_email(&self) -> String { + tokio::process::Command::new("git") + .args(["config", "--global", "user.email"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .await + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|email| email.trim().to_owned()) + .unwrap_or_default() + } + + async fn claude_setup_token(&self) -> bool { + tokio::process::Command::new("claude") + .arg("setup-token") + .status() + .await + .is_ok_and(|status| status.success()) + } +} diff --git a/crates/buzz-cli/src/commands/onboard/via-profile.toml b/crates/buzz-cli/src/commands/onboard/via-profile.toml new file mode 100644 index 00000000000..a68fa343f4c --- /dev/null +++ b/crates/buzz-cli/src/commands/onboard/via-profile.toml @@ -0,0 +1,70 @@ +version = 1 + +[providers.onepassword] +type = "1password" +account = "teamvoltage.1password.com" + +[services.grafana-staging] +description = "Staging Grafana HTTP API access through a service account token" +hint = "via grafana-staging api /api/org" +provider = "onepassword" + +[services.grafana-staging.secrets] +token = "op://Buzz Agents Dev/Via Grafana Staging/via-token" + +[services.grafana-staging.commands.api] +description = "Call the staging Grafana HTTP API with a service account token." +mode = "rest" +base_url = "http://grafana.monitoring.svc.cluster.local" +method_default = "GET" + +[services.grafana-staging.commands.api.auth] +type = "bearer" +secret = "token" + +[services.grafana-staging.commands.api.headers] +Accept = "application/json" + +[services.linear] +description = "Linear app-actor API access through OAuth" +hint = "via linear api POST /graphql --json '{\"query\":\"{ viewer { id name } }\"}'" +provider = "onepassword" + +[services.linear.secrets] +oauth = "op://Buzz Agents Dev/Via Linear App Voltage/text" + +[services.linear.commands.api] +description = "Call configured Linear API endpoints with an app-actor OAuth bearer token." +mode = "rest" +base_url = "https://api.linear.app" +method_default = "GET" + +[services.linear.commands.api.auth] +type = "oauth" +credential = "oauth" + +[services.depot] +description = "Depot build cache (sccache, Turborepo, Go) shared by every developer's agents" +provider = "onepassword" + +[services.depot.secrets] +organization = "op://Buzz Agents Dev/Depot/organization" +token = "op://Buzz Agents Dev/Depot/credential" + +[services.argocd-staging] +description = "Staging Argo CD API: every app's status, resource tree and logs; sync and restart in application projects" +hint = "via argocd-staging api /api/v1/applications --query fields=items.metadata.name,items.spec.project,items.status.sync.status,items.status.health.status" +provider = "onepassword" + +[services.argocd-staging.secrets] +token = "op://Buzz Agents Dev/Argo CD Staging/credential" + +[services.argocd-staging.commands.api] +description = "Call the staging Argo CD API with the buzz-agents account token." +mode = "rest" +base_url = "http://argocd-server.argo.svc.cluster.local" +method_default = "GET" + +[services.argocd-staging.commands.api.auth] +type = "bearer" +secret = "token" diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 77ddbc168f3..5b7b4278945 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -260,6 +260,8 @@ pub enum OutputFormat { #[derive(Subcommand)] enum Cmd { + /// Set up your Buzz identity, credentials and Remote workspace agents (interactive) + Onboard(commands::onboard::OnboardArgs), /// Draft owner-reviewed agent creation and updates #[command(subcommand)] Agents(AgentsCmd), @@ -2300,6 +2302,11 @@ async fn run(cli: Cli) -> Result<(), CliError> { }; } + // Onboarding creates or imports the identity, so it needs no key yet. + if let Cmd::Onboard(ref args) = cli.command { + return commands::onboard::run(args.clone()).await; + } + // 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(|| { @@ -2362,6 +2369,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await, Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await, Cmd::Pack(_) => unreachable!("handled above"), + Cmd::Onboard(_) => unreachable!("handled above"), } } @@ -2544,6 +2552,7 @@ mod tests { "messages", "moderation", "notes", + "onboard", "pack", "patches", "pr", diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index 85689772f69..6bdc7d6e194 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -6,7 +6,7 @@ Your agents use your GitHub account, your model subscriptions, and a shared set Every commit an agent makes is signed with a key that is on your GitHub account, so GitHub shows it as **Verified**. - **Admins:** start at [Onboard a developer](#onboard-a-developer). -- **Developers:** start at [Set up your agents](#set-up-your-agents). +- **Developers:** start at [Set up your agents](#set-up-your-agents) to use the Buzz app, or at [Set up with Paseo (no Buzz app)](#set-up-with-paseo-no-buzz-app) to use one terminal command. A Paseo daemon uses its own gateway key. Follow [Gateway keys](../docs/agent-gateway-key.md) to admit that key and grant access @@ -114,7 +114,7 @@ sequenceDiagram | Credential | You enter it | Stored in | Used by | |---|---|---|---| -| Buzz identity | Buzz app sign-in | Your machine | The app, to sign requests | +| Buzz identity | Buzz app sign-in, or `buzz onboard` | Your machine (`buzz onboard`: `~/.config/buzz/identity.nsec`, mode `0600`) | The app or `buzz`, to sign requests | | GitHub token | Enroll form | Profile (Secrets Manager) | The Sandbox: clone, push, PRs | | Agent signing key | Created by the app | Profile; public half on GitHub | The Sandbox: sign commits | | 1Password service account token | Enroll form | Profile | The Sandbox: read the Buzz Agents Dev vault | @@ -152,6 +152,111 @@ Do these steps once for each new developer. You need the staging VPN for step 2. The developer's Buzz identity must be a member of the `buzz.voltage.cloud` community. +## Set up with Paseo (no Buzz app) + +Use this path if you use [Paseo](https://paseo.sh) as your interface. You need Paseo and one terminal command, `buzz onboard`. You do not need the Buzz app. +The command sends the same events and Sandbox manager requests as the Buzz app. Agents, profiles, and channels that you create with it appear in the Buzz apps, and agents that you create in a Buzz app appear to `buzz onboard`. You can change to the Buzz app at any time. + +Before you start: + +1. An admin must do [Onboard a developer](#onboard-a-developer) for your Buzz owner key. If you do not have a key yet, run `buzz onboard` once. It creates a key and shows the hex public key to give to your admin. +2. Get an invite to `buzz.voltage.cloud`, if your key is not a member yet. +3. Get your credentials: see [Get your credentials](#2-get-your-credentials). Install `gh` and run `gh auth login`, so that the command can add your agents' signing key to GitHub. + +Install the CLI and run the wizard: + +```sh +cargo install --locked --git https://github.com/voltagecloud/buzz buzz-cli +buzz onboard +``` + +The wizard asks one question at a time, and hides each secret as you type it. It does these steps: + +| Step | What it does | Same as in the Buzz app | +|---|---|---| +| 1. Identity | Creates a key, or imports your Buzz app key with `--import-key`. Joins the community with your invite. Publishes your profile. | Sign in, join a community | +| 2. GitHub | Reads your token from `gh auth token`, or asks for one. Reads your Git email. Creates the agent-only Ed25519 signing key with `ssh-keygen`. | Enroll form: GitHub token, Git email | +| 3. Models | Can run `claude setup-token`. Asks for a Claude subscription token, a Codex access token, and an OpenRouter API key. Checks them with the enroll form's rules. | Enroll form: model credentials | +| 4. 1Password | Asks for the service account token. Uses the [shared via profile](#shared-via-profile). | Enroll form: 1Password token, via profile | +| 5. Agents | Creates agents from presets (Brokkr, Frigg, Bifrost, Forseti, or an OpenRouter agent), lists them, and adds them to a private workspace channel. | **Create agent → Remote workspace** | +| 6. Enroll | Sends the encrypted enroll request. Adds the signing key to GitHub with `gh`. Starts each new agent once, so that the Sandbox manager registers it. Shows your Paseo pairing. | **Enroll profile**, then the first mention | +| 7. Gateway | Checks the daemon’s public gateway key and prints the grant command until the gateway SDK is available. | Owner delegation | +| 8. Next steps | Shows pairing for desktop, web, and mobile, workflow commands, and the Buzz app path. | | + +Each step is saved when it completes. If a step fails, the wizard tells you why. Run `buzz onboard` again: it does only the steps that are not complete. +The command never saves or shows your credentials. It sends them only in the encrypted enroll request. It keeps your key and its progress in `~/.config/buzz` (mode `0700`; files mode `0600`). Each agent's key is in `onboard.json`, as the Buzz app keeps agent keys on its device. + +### Pair Paseo + +If an admin enabled Paseo daemons, the final step shows your daemon's password, an offer link (`https://app.paseo.sh/#offer=…`), and the same link as a QR code. If not, it says that Paseo is not provisioned yet. Run `buzz onboard` again after an admin enables it. + +- **Paseo desktop:** open the offer link on that computer, then enter the password. +- **Paseo web:** open the offer link in a browser at `app.paseo.sh`, then enter the password. +- **Paseo mobile:** scan the QR code, then enter the password. + +The command does not save the password. To show it again, run `buzz onboard`. +An unchanged developer profile still reconciles a missing or rotated daemon Secret. +The manager reports whether Paseo is enabled, so enabling it after initial enrollment +does not require changing credentials. + +### Run without prompts + +Use `--yes` to never prompt. Give secrets in environment variables, never in flags: + +```sh +BUZZ_ONBOARD_INVITE='https://buzz.voltage.cloud/invite/v2.…' \ +BUZZ_ONBOARD_GITHUB_TOKEN='github_pat_…' \ +BUZZ_ONBOARD_OP_TOKEN='ops_…' \ +BUZZ_ONBOARD_CLAUDE_TOKEN='sk-ant-oat01-…' \ +buzz onboard --yes --name 'Your Name' --git-email you@example.com --agents brokkr,bifrost +``` + +The other secret variables are `BUZZ_ONBOARD_CODEX_TOKEN`, `BUZZ_ONBOARD_OPENROUTER_KEY`, `BUZZ_ONBOARD_OPENAI_API_KEY`, and `BUZZ_ONBOARD_ANTHROPIC_API_KEY`. Run `buzz onboard --help` for every flag. + +### Use the Buzz app with the same agents + +- Sign in to the Buzz app with the key in `~/.config/buzz/identity.nsec`. Your agents appear under **Agents**. Mention one in your workspace channel to start a session. +- The app shows agents from `buzz onboard` as running on another device, because their keys are not in the app. The Sandbox manager starts them for your mentions from any Buzz app. +- `buzz onboard` lists agents that you created in the app, and does not create a second agent with the same name. +- To change one credential later, run `buzz onboard --reenroll` and fill in only what changes. Or use the enroll form in the app. + +### Gateway authorization and workflows + +The manager returns the daemon's public gateway key with its pairing. The owner's private key stays on this computer. +The wizard defaults to a 90-day grant lifetime; `--gateway-expires 2w` selects a shorter lifetime. The maximum is 90 days. +The wizard asks before publishing an owner-signed grant through the gateway SDK. +Each run checks the current relay head, including revocation, before reporting a +grant as live. After publication, a second writer read must confirm that the +submitted head is current and live; a duplicate receipt alone does not suffice. +A grant near expiry is renewed after confirmation; local state +does not override the relay. `--yes` accepts the displayed grant step. +Use `--skip-gateway-grant` to defer this step. The command prints the exact +`buzz gateway grant --expires 90d` command for later use. + +The gateway is a separate community member. Before granting, ask an administrator to run: + +```sh +buzz-admin add-member --pubkey --role member +``` + +As the channel owner, add that public key to your workspace channel: + +```sh +buzz channels add-member --channel --pubkey +``` + +A delegation grants authority over your remote agents; it does not admit a key to the community or a channel. + +Each new agent starts once in a channel thread. The wizard saves the signed kickoff +before publishing and reuses its thread after a lost reply or a manager failure. +Provisioning reads use the relay writer to avoid creating duplicates from stale reads. +This registers the launch with the manager and adds it to the daemon's agent list. +With `--skip-first-start`, mentions cannot start an unregistered agent, and the daemon cannot list it. Rerun without that flag to finish registration. +Agents added later appear after the daemon restarts. + +Use `buzz workflows --help` to create and manage channel workflows. The final step prints the command for your workspace channel. +Ask the Paseo assistant to use those commands for automations. Workflows run on Buzz, with their history on the relay. + ## Set up your agents ### 1. Install the Buzz app (Voltage build) @@ -490,6 +595,8 @@ CAUTION: An agent runs with full tool access in its Sandbox, and it reads untrus | What you see | What to do | |---|---| +| `buzz onboard` says *Could not check what is enrolled* | Check that you can reach `wss://buzz.voltage.cloud`, then run `buzz onboard` again. | +| `buzz onboard` says *N agent(s) did not start* | Run `buzz onboard` again. It retries only the agents that did not start. | | *This Buzz identity is not a configured developer.* | Ask an admin to do [Onboard a developer](#onboard-a-developer), including the restart in step 3. | | *Join the community at the relay to enroll through it.* | Join `buzz.voltage.cloud` in the app, then enroll again. | | *GitHub did not accept the token.* | Make a new fine-grained token, then enroll again. |