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 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. 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, 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( diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index f8e0300277..f616c3567b 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -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, @@ -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(); } @@ -1905,15 +1909,44 @@ 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(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::*; @@ -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] 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. 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 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 | 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, + }, + } + : {}), }; } 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,