notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 5b8b01f91e44189515f7c1162e23795bac625735
parent 53c97b7ea841fbf827ca0c5c2d03399e75c28114
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 24 Feb 2026 11:22:56 -0800

dave: truncate oversized permission request events for relay sync

Large Edit/Write tool calls could produce permission request events
(kind-1988) that exceed relay max event size limits. The relay would
silently drop the event while the session state event (kind-31988)
still synced with status "needs_input", leaving remote sessions stuck
with no permission request to respond to.

Truncate large string fields in tool_input before serializing into the
nostr event content (40KB budget, well within 64KB after PNS wrapping).
The local UI keeps the full PermissionRequest -- only the relay-synced
event is truncated. Uses floor_char_boundary for clean UTF-8 cuts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/session_events.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 135 insertions(+), 2 deletions(-)

diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -553,6 +553,82 @@ pub fn build_live_event( /// requests and respond. Tags include `perm-id` (UUID), `tool-name`, and /// `t: ai-permission` for filtering. /// +/// Maximum serialized size for tool_input in permission request events. +/// Keeps the final PNS-wrapped event well under typical relay limits +/// (~64KB). Budget: 40KB content + ~500B inner event overhead + ~500B +/// PNS outer overhead, with 1.33x base64 expansion ≈ 54KB total. +const MAX_TOOL_INPUT_BYTES: usize = 40_000; + +/// Truncate large string values in a tool_input JSON object so that the +/// serialized result fits within `max_bytes`. +/// +/// Only modifies top-level string fields in an Object value. Fields are +/// truncated proportionally based on how much they exceed their share of +/// the budget. A `"_truncated": true` flag is added when any field is +/// shortened. +fn truncate_tool_input(tool_input: &serde_json::Value, max_bytes: usize) -> serde_json::Value { + use serde_json::Value; + + let serialized = serde_json::to_string(tool_input).unwrap_or_default(); + if serialized.len() <= max_bytes { + return tool_input.clone(); + } + + let obj = match tool_input.as_object() { + Some(o) => o, + None => return tool_input.clone(), + }; + + // Collect string field names sorted largest-first so we know what to trim + let mut string_fields: Vec<(&str, usize)> = obj + .iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.as_str(), s.len()))) + .collect(); + string_fields.sort_by(|a, b| b.1.cmp(&a.1)); + + if string_fields.is_empty() { + return tool_input.clone(); + } + + let excess = serialized.len() - max_bytes; + let suffix = "\n... (truncated)"; + let suffix_len = suffix.len(); + // Safety margin for JSON escaping differences and the added _truncated field + let trim_target = excess + suffix_len * string_fields.len() + 64; + + // Distribute the trim proportionally among string fields + let total_string_bytes: usize = string_fields.iter().map(|(_, len)| len).sum(); + let mut trim_amounts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for (key, len) in &string_fields { + let share = (*len as f64 / total_string_bytes as f64 * trim_target as f64).ceil() as usize; + trim_amounts.insert(key, share.min(*len)); + } + + let mut result = serde_json::Map::new(); + let mut did_truncate = false; + for (key, val) in obj { + if let Some(s) = val.as_str() { + let trim = trim_amounts.get(key.as_str()).copied().unwrap_or(0); + if trim > 0 && s.len() > suffix_len + trim { + let keep = s.len() - trim; + let cut = notedeck::abbrev::floor_char_boundary(s, keep); + let truncated = format!("{}{}", &s[..cut], suffix); + result.insert(key.clone(), Value::String(truncated)); + did_truncate = true; + } else { + result.insert(key.clone(), val.clone()); + } + } else { + result.insert(key.clone(), val.clone()); + } + } + + if did_truncate { + result.insert("_truncated".to_string(), Value::Bool(true)); + } + Value::Object(result) +} + /// Does NOT participate in threading — permission events are ancillary. pub fn build_permission_request_event( perm_id: &uuid::Uuid, @@ -561,10 +637,13 @@ pub fn build_permission_request_event( session_id: &str, secret_key: &[u8; 32], ) -> Result<BuiltEvent, EventBuildError> { - // Content is a JSON summary for display on remote clients + // Truncate large string values so the event fits within relay size + // limits after PNS wrapping. The local UI keeps the full tool_input. + let tool_input_for_event = truncate_tool_input(tool_input, MAX_TOOL_INPUT_BYTES); + let content = serde_json::json!({ "tool_name": tool_name, - "tool_input": tool_input, + "tool_input": tool_input_for_event, }) .to_string(); @@ -1065,6 +1144,60 @@ mod tests { } #[test] + fn test_truncate_tool_input_small() { + // Small input should pass through unchanged + let input = serde_json::json!({"command": "ls -la"}); + let result = truncate_tool_input(&input, 1000); + assert_eq!(input, result); + assert!(result.get("_truncated").is_none()); + } + + #[test] + fn test_truncate_tool_input_large_edit() { + // Large edit should be truncated + let big_old = "x".repeat(30_000); + let big_new = "y".repeat(30_000); + let input = serde_json::json!({ + "file_path": "/some/file.rs", + "old_string": big_old, + "new_string": big_new, + }); + let result = truncate_tool_input(&input, 40_000); + + // Should fit within budget + let serialized = serde_json::to_string(&result).unwrap(); + assert!(serialized.len() <= 40_000, "got {} bytes", serialized.len()); + + // Should be marked as truncated + assert_eq!( + result.get("_truncated").and_then(|v| v.as_bool()), + Some(true) + ); + + // file_path should be preserved + assert_eq!( + result.get("file_path").and_then(|v| v.as_str()), + Some("/some/file.rs") + ); + + // Truncated fields should end with the suffix + let old = result.get("old_string").and_then(|v| v.as_str()).unwrap(); + assert!(old.ends_with("... (truncated)")); + } + + #[test] + fn test_truncate_tool_input_utf8_boundary() { + // Multibyte chars should not be split + let big = "\u{1F600}".repeat(10_000); // 4-byte emoji repeated + let input = serde_json::json!({"content": big}); + let result = truncate_tool_input(&input, 1000); + + let content = result.get("content").and_then(|v| v.as_str()).unwrap(); + // Should be valid UTF-8 (this would panic if not) + assert!(content.ends_with("... (truncated)")); + } + + #[test] fn test_build_permission_response_event() { let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); let request_note_id = [42u8; 32];