From 398caee320dd65e46dae9f41b3c8ef7b564cef91 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:47:24 +0300 Subject: [PATCH 01/12] fix(mobile): Enter inserts newline in channel composer Match the pulse composer: multiline keyboard with newline action, and keep send on the visible send button (#3725). Co-authored-by: Cursor Signed-off-by: Taksh --- mobile/lib/features/channels/compose_bar/layout.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index 31b930e36d..6d1f06de2b 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -102,13 +102,13 @@ class _ComposeBarLayout extends StatelessWidget { TextField( controller: controller, focusNode: focusNode, - textInputAction: TextInputAction.send, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, contextMenuBuilder: contextMenuBuilder, contentInsertionConfiguration: ContentInsertionConfiguration( allowedMimeTypes: _pastedImageMimeTypes, onContentInserted: onContentInserted, ), - onSubmitted: (_) => onSend(), minLines: 1, maxLines: 5, style: context.textTheme.bodyLarge, From 46cbaba9d568b65870083f009913fb54ebefa43c Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:48:32 +0300 Subject: [PATCH 02/12] feat(cli): support allowlist in agent draft-update Owner-reviewed drafts can grant Selected people access via --respond-to allowlist and --respond-to-allowlist (#3792). Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-cli/src/agent_management.rs | 111 +++++++++++++++++++++++- crates/buzz-cli/src/commands/agents.rs | 2 + crates/buzz-cli/src/lib.rs | 6 ++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/crates/buzz-cli/src/agent_management.rs b/crates/buzz-cli/src/agent_management.rs index ce4059f821..fa4428353f 100644 --- a/crates/buzz-cli/src/agent_management.rs +++ b/crates/buzz-cli/src/agent_management.rs @@ -35,6 +35,8 @@ pub struct UpdateAgentDraft { pub model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub respond_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub respond_to_allowlist: Option>, } #[derive(Debug, Serialize)] @@ -84,6 +86,42 @@ fn optional(value: Option, label: &str) -> Result, 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>, +) -> Result>, 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( keys: &Keys, owner: &PublicKey, @@ -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 { @@ -170,6 +220,7 @@ 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() @@ -177,6 +228,7 @@ pub fn build_update( && 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(), @@ -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( diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 58564a45c2..6b362b8a89 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -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( @@ -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?; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0726406d29..f748d731ff 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -242,6 +242,8 @@ enum Cmd { pub enum RespondToArg { #[value(name = "owner-only")] OwnerOnly, + #[value(name = "allowlist")] + Allowlist, #[value(name = "anyone")] Anyone, } @@ -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() @@ -291,6 +294,9 @@ pub enum AgentsCmd { model: Option, #[arg(long, value_enum)] respond_to: Option, + /// Pubkeys (64-char hex or npub) for --respond-to allowlist + #[arg(long = "respond-to-allowlist", value_delimiter = ',')] + respond_to_allowlist: Option>, }, /// Submit a NIP-IA archive request for an identity (kind 9035) #[command( From 9cffbe8bf97fa992c4bb5ba3223e43bb88b01412 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:49:12 +0300 Subject: [PATCH 03/12] fix(desktop): round-trip allowlist in owner-reviewed agent drafts Parse respondToAllowlist from draft updates and stop clearing the stored Selected people list when applying respond-to changes (#3792). Co-authored-by: Cursor Signed-off-by: Taksh --- .../features/agents/agentManagement.test.mjs | 38 +++++++++++++++++++ .../src/features/agents/agentManagement.ts | 34 ++++++++++++++++- .../src/features/agents/useAgentManagement.ts | 17 ++++++++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/agentManagement.test.mjs b/desktop/src/features/agents/agentManagement.test.mjs index 0fa9176c74..f434b52a24 100644 --- a/desktop/src/features/agents/agentManagement.test.mjs +++ b/desktop/src/features/agents/agentManagement.test.mjs @@ -104,3 +104,41 @@ test("allows agents to update only personal, editable profiles", () => { false, ); }); + +test("parses allowlist access updates with pubkey list", () => { + const pubkey = "a".repeat(64); + const payload = { + type: AGENT_MANAGEMENT_REQUEST, + action: "update", + requestId: "request-allowlist", + request: { + channelId: CHANNEL_ID, + agentName: "Review helper", + respondTo: "allowlist", + respondToAllowlist: [pubkey], + }, + }; + + assert.deepEqual(parseAgentManagementRequest(payload), { + ...payload, + request: { + ...payload.request, + respondToAllowlist: [pubkey], + }, + }); +}); + +test("rejects allowlist mode without pubkeys", () => { + const payload = { + type: AGENT_MANAGEMENT_REQUEST, + action: "update", + requestId: "request-allowlist-empty", + request: { + channelId: CHANNEL_ID, + agentName: "Review helper", + respondTo: "allowlist", + }, + }; + + assert.equal(parseAgentManagementRequest(payload), null); +}); diff --git a/desktop/src/features/agents/agentManagement.ts b/desktop/src/features/agents/agentManagement.ts index 5b5e18d872..608d2f5916 100644 --- a/desktop/src/features/agents/agentManagement.ts +++ b/desktop/src/features/agents/agentManagement.ts @@ -30,6 +30,7 @@ export type AgentManagementUpdateRequest = { provider?: string; model?: string; respondTo?: RespondToMode; + respondToAllowlist?: string[]; }; }; @@ -42,7 +43,23 @@ function isText(value: unknown): value is string { } function isRespondTo(value: unknown): value is RespondToMode | undefined { - return value === undefined || value === "owner-only" || value === "anyone"; + return ( + value === undefined || + value === "owner-only" || + value === "allowlist" || + value === "anyone" + ); +} + +function isAllowlistPubkeys(value: unknown): value is string[] | undefined { + if (value === undefined) return true; + if (!Array.isArray(value) || value.length === 0) return false; + return value.every( + (entry) => + typeof entry === "string" && + entry.length === 64 && + /^[0-9a-f]+$/i.test(entry), + ); } function hasOnlyKeys( @@ -94,6 +111,7 @@ export function parseAgentManagementRequest( if ( !isRespondTo(request.respondTo) || + !isAllowlistPubkeys(request.respondToAllowlist) || !hasOnlyKeys(request, [ "channelId", "agentName", @@ -103,6 +121,7 @@ export function parseAgentManagementRequest( "provider", "model", "respondTo", + "respondToAllowlist", ]) || !isText(request.channelId) || !isText(request.agentName) @@ -120,7 +139,20 @@ export function parseAgentManagementRequest( ...(isText(request.provider) ? { provider: request.provider } : {}), ...(isText(request.model) ? { model: request.model } : {}), ...(request.respondTo ? { respondTo: request.respondTo } : {}), + ...(Array.isArray(request.respondToAllowlist) + ? { + respondToAllowlist: request.respondToAllowlist.map((entry) => + entry.toLowerCase(), + ), + } + : {}), }; + if ( + changes.respondTo === "allowlist" && + (!changes.respondToAllowlist || changes.respondToAllowlist.length === 0) + ) { + return null; + } if (Object.keys(changes).length === 0) return null; return { type: AGENT_MANAGEMENT_REQUEST, diff --git a/desktop/src/features/agents/useAgentManagement.ts b/desktop/src/features/agents/useAgentManagement.ts index f4cdb895ef..da71b39d04 100644 --- a/desktop/src/features/agents/useAgentManagement.ts +++ b/desktop/src/features/agents/useAgentManagement.ts @@ -49,11 +49,24 @@ function updateInputFromRequest( ? { behavior: { respondTo: changes.respondTo, - respondToAllowlist: [], + respondToAllowlist: + changes.respondTo === "allowlist" + ? (changes.respondToAllowlist ?? + current.behavior?.respondToAllowlist ?? + []) + : [], parallelism: current.behavior?.parallelism, }, } - : {}), + : changes.respondToAllowlist + ? { + behavior: { + respondTo: current.behavior?.respondTo ?? "allowlist", + respondToAllowlist: changes.respondToAllowlist, + parallelism: current.behavior?.parallelism, + }, + } + : {}), }; } From 205053d73f1927ee96c89371a7088bd1ffd07b61 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:49:40 +0300 Subject: [PATCH 04/12] fix(relay): warn when a second session auths as the same pubkey Emit a NOTICE (and warn log) on NIP-42 auth when another live connection already holds the identity, so duplicate agent harnesses are visible (#3832). Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-relay/src/handlers/auth.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e..166f8b46b8 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -275,6 +275,32 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, 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 From 381cb85ec6e2160db0abd6efe445f7fd2a126003 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:49:40 +0300 Subject: [PATCH 05/12] docs(acp): document remote-only agents and duplicate-key hazard Explain why the same agent key must not run on a laptop and a VPS at once, and point at the relay NOTICE (#3832). Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-acp/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..82a7dc1e0f 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -44,6 +44,19 @@ BUZZ_RELAY_PRIVATE_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. From 00f01e7902c959764442f752822dabfd0ce467f8 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:50:24 +0300 Subject: [PATCH 06/12] fix(relay): return HTTP 413 when git pack body exceeds limit Map RequestBodyLimitLayer failures to a clear 413 that names BUZZ_GIT_MAX_PACK_BYTES, and surface hydrate ResourceLimit details (#3802). Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-relay/src/api/git/transport.rs | 38 ++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index f8e0300277..93ea49ad8a 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -15,16 +15,19 @@ use std::time::{Duration, Instant}; use axum::{ body::Body, + error_handling::HandleErrorLayer, extract::{Path as AxumPath, Query, State}, http::{header, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, + BoxError, Router, }; use base64::Engine; use hex; use serde::Deserialize; use tokio::process::Command; +use tower::ServiceBuilder; use tower_http::limit::RequestBodyLimitLayer; use tracing::{error, info, warn}; @@ -343,10 +346,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(); } @@ -1905,15 +1911,41 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { /// Mounted at `/git/{owner}/{repo}/...` with a configurable max pack size. pub fn git_router(state: Arc) -> 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(RequestBodyLimitLayer::new(body_limit)) + .layer( + ServiceBuilder::new() + .layer(HandleErrorLayer::new(move |err: BoxError| async move { + git_body_limit_response(err, max_bytes) + })) + .layer(RequestBodyLimitLayer::new(body_limit)), + ) .with_state(state) } +fn git_body_limit_response(err: BoxError, max_bytes: u64) -> Response { + let msg = err.to_string(); + if msg.contains("length limit") || msg.contains("LengthLimitError") { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + format!( + "git pack body exceeds relay limit ({max_bytes} bytes; \ + self-hosted relays: BUZZ_GIT_MAX_PACK_BYTES)" + ), + ) + .into_response(); + } + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to read git request body", + ) + .into_response() +} + #[cfg(test)] mod track_c_tests { use super::*; From 8f1467219857c1dbff5463a7c1b8aa3203dc9ac1 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:50:44 +0300 Subject: [PATCH 07/12] docs(git): document postBuffer and pack-limit troubleshooting Clarify BUZZ_GIT_MAX_PACK_BYTES, http.postBuffer for NIP-98, and opaque large-push failure modes (#3802). Co-authored-by: Cursor Signed-off-by: Taksh --- .env.example | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index b9bfcada0e..54102bf809 100644 --- a/.env.example +++ b/.env.example @@ -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 From 1aae46be5efad2821f0195a8b149435e387c7eb0 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:50:44 +0300 Subject: [PATCH 08/12] fix(relay): clarify BUZZ_WEB_DIR startup log when web GUI is disabled Distinguish invite-bundle-only mode from full SPA serving so operators know they still need BUZZ_SERVE_GIT_WEB_GUI (#3815). Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-relay/src/config.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 85a0ca2efe..6796685968 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -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. From f6fc9a1e535c6b28f021b5287affabc49f506734 Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:50:44 +0300 Subject: [PATCH 09/12] docs(acp): cross-reference presence TTL at harness heartbeat Document the 60s heartbeat / 180s TTL invariant next to the interval so agent presence docs stay aligned with the relay (#3795). Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-acp/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322..ecd71f0fe0 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -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, From d79866f658f19182793049066643414ac383ac6a Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:51:04 +0300 Subject: [PATCH 10/12] test(relay): cover git pack body-limit 413 mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit-test the LengthLimitError → HTTP 413 helper used by git_router. Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-relay/src/api/git/transport.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 93ea49ad8a..5c8be32d61 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -1984,6 +1984,15 @@ mod track_c_tests { assert_eq!(bytes.as_ref(), plaintext); } + #[test] + fn git_body_limit_response_maps_length_errors_to_413() { + let response = git_body_limit_response( + Box::new(std::io::Error::other("LengthLimitError")), + 5_242_880, + ); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + /// Without a gzip `Content-Encoding`, the body is passed through byte /// for byte (the common small-clone / already-inflated case). #[tokio::test] From 0cbd50db044251993f1c90733297542d6602202e Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:51:04 +0300 Subject: [PATCH 11/12] docs(git-credential-nostr): add large-push troubleshooting rows Document postBuffer 401s, pack-limit 413s, and mid-upload TLS aborts. Co-authored-by: Cursor Signed-off-by: Taksh --- crates/git-credential-nostr/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/git-credential-nostr/README.md b/crates/git-credential-nostr/README.md index 827f8d55a3..4d7d6961dc 100644 --- a/crates/git-credential-nostr/README.md +++ b/crates/git-credential-nostr/README.md @@ -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 | From 482b69beafedd45d4c7bbd3ce6d5ef8ce420d02d Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 14:55:23 +0300 Subject: [PATCH 12/12] fix(relay): rewrite bare git 413 bodies with pack-limit guidance Use axum middleware to replace RequestBodyLimitLayer's empty 413 with a message that names BUZZ_GIT_MAX_PACK_BYTES. Co-authored-by: Cursor Signed-off-by: Taksh --- crates/buzz-relay/src/api/git/transport.rs | 53 +++++++++++----------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 5c8be32d61..f616c3567b 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -15,19 +15,17 @@ use std::time::{Duration, Instant}; use axum::{ body::Body, - error_handling::HandleErrorLayer, extract::{Path as AxumPath, Query, State}, http::{header, StatusCode}, + middleware::{from_fn, Next}, response::{IntoResponse, Response}, routing::{get, post}, - BoxError, Router, }; use base64::Engine; use hex; use serde::Deserialize; use tokio::process::Command; -use tower::ServiceBuilder; use tower_http::limit::RequestBodyLimitLayer; use tracing::{error, info, warn}; @@ -1917,33 +1915,36 @@ pub fn git_router(state: Arc) -> Router { .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( - ServiceBuilder::new() - .layer(HandleErrorLayer::new(move |err: BoxError| async move { - git_body_limit_response(err, max_bytes) - })) - .layer(RequestBodyLimitLayer::new(body_limit)), - ) + .layer(from_fn(move |req, next| { + rewrite_git_body_limit_413(req, next, max_bytes) + })) + .layer(RequestBodyLimitLayer::new(body_limit)) .with_state(state) } -fn git_body_limit_response(err: BoxError, max_bytes: u64) -> Response { - let msg = err.to_string(); - if msg.contains("length limit") || msg.contains("LengthLimitError") { +/// `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, - format!( - "git pack body exceeds relay limit ({max_bytes} bytes; \ - self-hosted relays: BUZZ_GIT_MAX_PACK_BYTES)" - ), + git_body_limit_message(max_bytes), ) .into_response(); } - ( - StatusCode::INTERNAL_SERVER_ERROR, - "failed to read git request body", + 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)" ) - .into_response() } #[cfg(test)] @@ -1985,12 +1986,10 @@ mod track_c_tests { } #[test] - fn git_body_limit_response_maps_length_errors_to_413() { - let response = git_body_limit_response( - Box::new(std::io::Error::other("LengthLimitError")), - 5_242_880, - ); - assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + 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