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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,13 @@ RELAY_URL=ws://localhost:3000
# Root directory for ephemeral Git workspaces and the disposable pack cache.
# Default: ./repos (relative to CWD).
# BUZZ_GIT_REPO_PATH=./repos
# BUZZ_GIT_MAX_PACK_BYTES=524288000
# BUZZ_GIT_MAX_PACK_BYTES=524288000 # default 500 MiB on self-hosted relays
# BUZZ_GIT_MAX_REPO_BYTES=1048576000
# Git clients: pushes >1 MiB use buffered bodies by default. Set globally:
# git config --global http.postBuffer 536870912
# Without that, chunked Transfer-Encoding breaks NIP-98 auth (instant 401).
# Over-limit packs should receive HTTP 413 naming BUZZ_GIT_MAX_PACK_BYTES
# (not a TLS abort / broken pipe mid-upload).
# Process-local immutable pack/index cache. Zero disables retention.
# BUZZ_GIT_PACK_CACHE_PATH=./repos/.pack-cache
# BUZZ_GIT_PACK_CACHE_MAX_BYTES=5368709120
Expand Down
13 changes: 13 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ BUZZ_RELAY_PRIVATE_KEY=<relay signing key> \

> **Running multiple agents?** Mint a separate keypair for each. Every agent needs its own identity.


## Remote-only agents (avoid duplicate replies)

If an agent runs on a VPS or another machine, **do not** also start it from Buzz Desktop
on a laptop with the same agent key. Both harnesses subscribe to the same mentions and
will both reply (#3832).

- On machines that should not host the agent: keep it inactive in Desktop, or avoid
message-triggered starts / mention wake for that identity.
- When a second harness connects, the relay emits a NOTICE warning about duplicate sessions
(also logged by buzz-acp at warn).


## Channels

The harness discovers channels by querying the relay with the agent's authenticated identity.
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,9 @@ async fn tokio_main() -> Result<()> {
let mut heartbeat_in_flight = false;

let mut presence_heartbeat = if config.presence_enabled {
// Renew presence every 60s. Relay TTL is buzz_pubsub::PRESENCE_TTL_SECS
// (180s = 3× this interval). Desktop uses the same 60s cadence — see
// desktop/src/features/presence/lib/presence.ts and ARCHITECTURE.md.
let interval = Duration::from_secs(60);
Some(tokio::time::interval_at(
tokio::time::Instant::now() + interval,
Expand Down
111 changes: 107 additions & 4 deletions crates/buzz-cli/src/agent_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub struct UpdateAgentDraft {
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub respond_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub respond_to_allowlist: Option<Vec<String>>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -84,6 +86,42 @@ fn optional(value: Option<String>, label: &str) -> Result<Option<String>, CliErr
value.map(|value| required(value, label, 300)).transpose()
}

/// Normalize allowlist entries to lowercase 64-char hex pubkeys.
pub fn normalize_allowlist_pubkeys(
entries: Option<Vec<String>>,
) -> Result<Option<Vec<String>>, CliError> {
let Some(entries) = entries else {
return Ok(None);
};
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for entry in entries {
let trimmed = entry.trim();
if trimmed.is_empty() {
continue;
}
let hex = if trimmed.starts_with("npub1") {
PublicKey::parse(trimmed)
.map_err(|_| {
CliError::Usage(format!(
"invalid npub in respond-to-allowlist: {trimmed}"
))
})?
.to_hex()
} else if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
trimmed.to_ascii_lowercase()
} else {
return Err(CliError::Usage(format!(
"invalid pubkey in respond-to-allowlist: '{trimmed}' (64-char hex or npub)"
)));
};
if seen.insert(hex.clone()) {
out.push(hex);
}
}
Ok(Some(out))
}

fn build<T: Serialize>(
keys: &Keys,
owner: &PublicKey,
Expand Down Expand Up @@ -150,12 +188,24 @@ pub fn build_update(
uuid::Uuid::parse_str(&channel_id)
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel_id}")))?;
let respond_to = optional(draft.respond_to, "respond-to")?;
if respond_to
.as_deref()
.is_some_and(|value| value != "owner-only" && value != "anyone")
if respond_to.as_deref().is_some_and(|value| {
value != "owner-only" && value != "allowlist" && value != "anyone"
}) {
return Err(CliError::Usage(
"respond-to must be owner-only, allowlist, or anyone".into(),
));
}
let respond_to_allowlist = normalize_allowlist_pubkeys(draft.respond_to_allowlist)?;
if respond_to.as_deref() == Some("allowlist")
&& respond_to_allowlist.as_ref().is_none_or(|v| v.is_empty())
{
return Err(CliError::Usage(
"respond-to must be owner-only or anyone".into(),
"respond-to allowlist requires at least one --respond-to-allowlist pubkey".into(),
));
}
if respond_to.is_none() && respond_to_allowlist.is_some() {
return Err(CliError::Usage(
"respond-to-allowlist requires --respond-to allowlist".into(),
));
}
let request = UpdateAgentDraft {
Expand All @@ -170,13 +220,15 @@ pub fn build_update(
provider: optional(draft.provider, "provider")?,
model: optional(draft.model, "model")?,
respond_to,
respond_to_allowlist,
};
if request.display_name.is_none()
&& request.system_prompt.is_none()
&& request.runtime.is_none()
&& request.provider.is_none()
&& request.model.is_none()
&& request.respond_to.is_none()
&& request.respond_to_allowlist.is_none()
{
return Err(CliError::Usage(
"include at least one field to update".into(),
Expand Down Expand Up @@ -254,12 +306,63 @@ mod tests {
provider: None,
model: None,
respond_to: None,
respond_to_allowlist: None,
},
)
.unwrap_err();
assert!(error.to_string().contains("at least one field"));
}

#[test]
fn update_allowlist_round_trips_pubkeys() {
let agent = Keys::generate();
let owner = Keys::generate();
let allowed = Keys::generate().public_key().to_hex();
let built = build_update(
&agent,
&owner.public_key(),
UpdateAgentDraft {
channel_id: CHANNEL.into(),
agent_name: "Scout".into(),
display_name: None,
system_prompt: None,
runtime: None,
provider: None,
model: None,
respond_to: Some("allowlist".into()),
respond_to_allowlist: Some(vec![allowed.clone(), allowed.clone()]),
},
)
.unwrap();
let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap();
assert_eq!(payload["payload"]["request"]["respondTo"], "allowlist");
assert_eq!(
payload["payload"]["request"]["respondToAllowlist"],
serde_json::json!([allowed])
);
}

#[test]
fn update_allowlist_requires_pubkeys() {
let error = build_update(
&Keys::generate(),
&Keys::generate().public_key(),
UpdateAgentDraft {
channel_id: CHANNEL.into(),
agent_name: "Scout".into(),
display_name: None,
system_prompt: None,
runtime: None,
provider: None,
model: None,
respond_to: Some("allowlist".into()),
respond_to_allowlist: None,
},
)
.unwrap_err();
assert!(error.to_string().contains("respond-to-allowlist"));
}

#[test]
fn create_rejects_invalid_channel() {
let error = build_create(
Expand Down
2 changes: 2 additions & 0 deletions crates/buzz-cli/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli
provider,
model,
respond_to,
respond_to_allowlist,
} => {
let owner = require_owner(client)?;
let built = build_update(
Expand All @@ -66,6 +67,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli
provider,
model,
respond_to: respond_to.map(RespondToArg::to_wire),
respond_to_allowlist,
},
)?;
let response = client.publish_ephemeral_event(built.event).await?;
Expand Down
6 changes: 6 additions & 0 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ enum Cmd {
pub enum RespondToArg {
#[value(name = "owner-only")]
OwnerOnly,
#[value(name = "allowlist")]
Allowlist,
#[value(name = "anyone")]
Anyone,
}
Expand All @@ -250,6 +252,7 @@ impl RespondToArg {
fn to_wire(self) -> String {
match self {
Self::OwnerOnly => "owner-only",
Self::Allowlist => "allowlist",
Self::Anyone => "anyone",
}
.to_string()
Expand Down Expand Up @@ -291,6 +294,9 @@ pub enum AgentsCmd {
model: Option<String>,
#[arg(long, value_enum)]
respond_to: Option<RespondToArg>,
/// Pubkeys (64-char hex or npub) for --respond-to allowlist
#[arg(long = "respond-to-allowlist", value_delimiter = ',')]
respond_to_allowlist: Option<Vec<String>>,
},
/// Submit a NIP-IA archive request for an identity (kind 9035)
#[command(
Expand Down
44 changes: 42 additions & 2 deletions crates/buzz-relay/src/api/git/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use axum::{
body::Body,
extract::{Path as AxumPath, Query, State},
http::{header, StatusCode},
middleware::{from_fn, Next},
response::{IntoResponse, Response},
routing::{get, post},
Router,
Expand Down Expand Up @@ -343,10 +344,13 @@ fn acquire_git_permit(
/// via `Ok(None)` from [`hydrate_for_read`] and never reaches this fn.
fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Response {
error!(error = %err, owner = %owner, repo = %repo, "hydrate failed");
if matches!(err, HydrateError::ResourceLimit(_)) {
if let HydrateError::ResourceLimit(detail) = &err {
return (
StatusCode::PAYLOAD_TOO_LARGE,
"repository exceeds relay resource limits",
format!(
"repository exceeds relay resource limits ({detail}; \
self-hosted relays: raise BUZZ_GIT_MAX_PACK_BYTES / BUZZ_GIT_MAX_REPO_BYTES)"
),
)
.into_response();
}
Expand Down Expand Up @@ -1905,15 +1909,44 @@ async fn finalize_push(state: &Arc<AppState>, ctx: PushContext) -> Response {
/// Mounted at `/git/{owner}/{repo}/...` with a configurable max pack size.
pub fn git_router(state: Arc<AppState>) -> Router {
let body_limit = state.config.git_max_pack_bytes as usize;
let max_bytes = state.config.git_max_pack_bytes;

Router::new()
.route("/git/{owner}/{repo}/info/refs", get(info_refs))
.route("/git/{owner}/{repo}/git-upload-pack", post(upload_pack))
.route("/git/{owner}/{repo}/git-receive-pack", post(receive_pack))
.layer(from_fn(move |req, next| {
rewrite_git_body_limit_413(req, next, max_bytes)
}))
.layer(RequestBodyLimitLayer::new(body_limit))
.with_state(state)
}

/// `RequestBodyLimitLayer` returns a bare 413; rewrite the body so operators
/// see which env var to raise (and so proxies don't look like TLS aborts).
async fn rewrite_git_body_limit_413(
req: axum::extract::Request,
next: Next,
max_bytes: u64,
) -> Response {
let response = next.run(req).await;
if response.status() == StatusCode::PAYLOAD_TOO_LARGE {
return (
StatusCode::PAYLOAD_TOO_LARGE,
git_body_limit_message(max_bytes),
)
.into_response();
}
response
}

fn git_body_limit_message(max_bytes: u64) -> String {
format!(
"git pack body exceeds relay limit ({max_bytes} bytes; \
self-hosted relays: BUZZ_GIT_MAX_PACK_BYTES)"
)
}

#[cfg(test)]
mod track_c_tests {
use super::*;
Expand Down Expand Up @@ -1952,6 +1985,13 @@ mod track_c_tests {
assert_eq!(bytes.as_ref(), plaintext);
}

#[test]
fn git_body_limit_message_names_env_var() {
let msg = git_body_limit_message(5_242_880);
assert!(msg.contains("5242880"));
assert!(msg.contains("BUZZ_GIT_MAX_PACK_BYTES"));
}

/// Without a gzip `Content-Encoding`, the body is passed through byte
/// for byte (the common small-clone / already-inflated case).
#[tokio::test]
Expand Down
12 changes: 11 additions & 1 deletion crates/buzz-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -916,7 +916,17 @@ impl Config {
dir.display()
)));
}
tracing::info!("BUZZ_WEB_DIR={} — serving web UI from relay", dir.display());
if serve_git_web_gui {
tracing::info!(
"BUZZ_WEB_DIR={} — serving web UI at / and /repos (BUZZ_SERVE_GIT_WEB_GUI=true)",
dir.display()
);
} else {
tracing::info!(
"BUZZ_WEB_DIR={} — bundle validated; /invite/* works. Set BUZZ_SERVE_GIT_WEB_GUI=true to serve / and /repos as HTML (otherwise NIP-11 JSON).",
dir.display()
);
}
}

// Reject explicitly-configured secrets that are too short.
Expand Down
26 changes: 26 additions & 0 deletions crates/buzz-relay/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,32 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
}

info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful");

// Detect concurrent sessions for the same identity (e.g. the same
// agent key running on a laptop and a VPS). Both harnesses would
// otherwise subscribe to the same mentions and reply twice.
let other_sessions = state
.conn_manager
.connection_ids_for_pubkey_in_community(
conn.tenant.community(),
pubkey.to_bytes().as_slice(),
)
.iter()
.filter(|id| **id != conn_id)
.count();
if other_sessions > 0 {
warn!(
conn_id = %conn_id,
pubkey = %pubkey.to_hex(),
other_sessions,
"duplicate authenticated session for identity — concurrent agent harnesses may both reply to mentions"
);
conn.send(RelayMessage::notice(
"Another session is already connected as this identity. \
If this is an agent, running the same key in two places can cause duplicate replies.",
));
}

*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
state
.conn_manager
Expand Down
3 changes: 3 additions & 0 deletions crates/git-credential-nostr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,6 @@ git ──stdin──▶ git-credential-nostr ──stdout──▶ git
| `useHttpPath` | `credential.useHttpPath` is not set | `git config --global credential.useHttpPath true` |
| Empty output / no auth | git version is older than 2.46 | Upgrade git |
| `clock skew` / auth rejected | System clock is off by more than 60 s | Sync your system clock (`ntpdate`, `timedatectl`) |
| Instant `401` on large push | Git switched to chunked encoding; NIP-98 auth can't replay | `git config --global http.postBuffer 536870912` |
| `413` / pack limit message | Pack exceeds relay `BUZZ_GIT_MAX_PACK_BYTES` | Split history, raise the limit on a self-hosted relay, or ask the hosted operator |
| TLS abort / broken pipe mid-upload | Often a proxy + abrupt body-limit enforcement | Apply the postBuffer fix above; current relays should return a clear `413` instead |
Loading