From ccf04600ec9d3666eb621f0d0cc8db74e8c37126 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 3 Sep 2026 03:35:18 -0500 Subject: [PATCH] feat(native): familiar contract and analytics reads Add the two native commands for the familiar detail reads Cave promoted into Client v1 (OpenCoven/coven-cave#5288) and the SDK now serves (OpenCoven/sdk#104): cave_get_familiar_contract GET /api/client/v1/familiars/:id/contract cave_get_familiar_analytics GET /api/client/v1/familiars/:id/analytics Both go through the same bounded path every canonical read uses: one CaveReadPath variant with validated parameters, one command taking `handle` and `operation`, one capability permission, and one entry in each reviewed command table. A familiar id is held to the slug allow-list Cave itself enforces, which is narrower than a conversation id: `.` and `~` are legal there and not here, because Cave's own allow-list does not carry them. An id this host would have to percent-encode is one Cave could never name, so it is refused before the wire rather than sent to be refused. The analytics narrowing is bounded the same way. Cave refuses an unknown window or an out-of-range recent count rather than correcting it, so the same values are refused here, and the query carries only what the caller asked for -- an implied default would become a refusal at the other end. The webview binding for these commands lands with the vendored SDK bump; the host is complete and exercised by its own tests in the meantime. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KacEJmkX5GhkViPUhx9Mie --- src-tauri/build.rs | 2 + src-tauri/capabilities/default.json | 4 +- src-tauri/gen/schemas/desktop-schema.json | 24 +++ src-tauri/src/commands.rs | 47 ++++++ src-tauri/src/lib.rs | 15 +- src-tauri/src/transport.rs | 193 +++++++++++++++++++++- src/specification-guards.test.ts | 8 + 7 files changed, 286 insertions(+), 7 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 4d093438..6f781aa8 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -19,6 +19,8 @@ const NATIVE_COMMANDS: &[&str] = &[ "cave_list_conversations", "cave_get_conversation", "cave_list_conversation_messages", + "cave_get_familiar_contract", + "cave_get_familiar_analytics", ]; fn tauri_config_value<'a>(config: &'a serde_json::Value, key: &str) -> &'a str { diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b8eb9a5d..b908a854 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -21,6 +21,8 @@ "allow-cave-list-projects", "allow-cave-list-conversations", "allow-cave-get-conversation", - "allow-cave-list-conversation-messages" + "allow-cave-list-conversation-messages", + "allow-cave-get-familiar-contract", + "allow-cave-get-familiar-analytics" ] } diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index 8850d16f..7c3a9a0f 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -212,6 +212,18 @@ "const": "allow-cave-get-conversation", "markdownDescription": "Enables the cave_get_conversation command without any pre-configured scope." }, + { + "description": "Enables the cave_get_familiar_analytics command without any pre-configured scope.", + "type": "string", + "const": "allow-cave-get-familiar-analytics", + "markdownDescription": "Enables the cave_get_familiar_analytics command without any pre-configured scope." + }, + { + "description": "Enables the cave_get_familiar_contract command without any pre-configured scope.", + "type": "string", + "const": "allow-cave-get-familiar-contract", + "markdownDescription": "Enables the cave_get_familiar_contract command without any pre-configured scope." + }, { "description": "Enables the cave_health command without any pre-configured scope.", "type": "string", @@ -320,6 +332,18 @@ "const": "deny-cave-get-conversation", "markdownDescription": "Denies the cave_get_conversation command without any pre-configured scope." }, + { + "description": "Denies the cave_get_familiar_analytics command without any pre-configured scope.", + "type": "string", + "const": "deny-cave-get-familiar-analytics", + "markdownDescription": "Denies the cave_get_familiar_analytics command without any pre-configured scope." + }, + { + "description": "Denies the cave_get_familiar_contract command without any pre-configured scope.", + "type": "string", + "const": "deny-cave-get-familiar-contract", + "markdownDescription": "Denies the cave_get_familiar_contract command without any pre-configured scope." + }, { "description": "Denies the cave_health command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cdf718bf..34494c58 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -28,6 +28,8 @@ pub const REGISTERED_COMMANDS: &[&str] = &[ "cave_list_conversations", "cave_get_conversation", "cave_list_conversation_messages", + "cave_get_familiar_contract", + "cave_get_familiar_analytics", ]; #[tauri::command] @@ -292,3 +294,48 @@ pub async fn cave_list_conversation_messages( pub fn registered_command_names() -> &'static [&'static str] { REGISTERED_COMMANDS } + +#[tauri::command] +pub async fn cave_get_familiar_contract( + handle: String, + familiar_id: String, + operation: NativeOperationInput, + state: State<'_, NativeConnectionState>, +) -> Result { + let runner = state.inner().clone(); + let operation_state = runner.clone(); + runner + .run_operation(operation, async move { + operation_state + .cave_read(handle, CaveReadPath::FamiliarContract { familiar_id }) + .await + }) + .await +} + +#[tauri::command] +pub async fn cave_get_familiar_analytics( + handle: String, + familiar_id: String, + window: Option, + recent_limit: Option, + operation: NativeOperationInput, + state: State<'_, NativeConnectionState>, +) -> Result { + let runner = state.inner().clone(); + let operation_state = runner.clone(); + runner + .run_operation(operation, async move { + operation_state + .cave_read( + handle, + CaveReadPath::FamiliarAnalytics { + familiar_id, + window, + recent_limit, + }, + ) + .await + }) + .await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62d08ca7..d68a873f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,10 +29,11 @@ use transport::{ConstrainedTransport, NativeCaveTransport}; pub use commands::{ app_identity, app_installation_id, cave_cancel_operation, cave_credential_status, - cave_forget_credential, cave_get_conversation, cave_health, cave_launch, - cave_list_conversation_messages, cave_list_conversations, cave_list_familiars, - cave_list_projects, cave_pairing_create, cave_pairing_exchange, cave_pairing_poll, - cave_read_discovery, cave_reset_pairing, coven_health, registered_command_names, + cave_forget_credential, cave_get_conversation, cave_get_familiar_analytics, + cave_get_familiar_contract, cave_health, cave_launch, cave_list_conversation_messages, + cave_list_conversations, cave_list_familiars, cave_list_projects, cave_pairing_create, + cave_pairing_exchange, cave_pairing_poll, cave_read_discovery, cave_reset_pairing, + coven_health, registered_command_names, }; pub use coven::CovenHealthResult; pub use metadata::{AppIdentity, APP_IDENTIFIER, APP_NAME, APP_PHASE}; @@ -267,7 +268,9 @@ fn builder() -> tauri::Builder { cave_list_projects, cave_list_conversations, cave_get_conversation, - cave_list_conversation_messages + cave_list_conversation_messages, + cave_get_familiar_contract, + cave_get_familiar_analytics ]) } @@ -462,6 +465,8 @@ mod smoke_tests { "cave_list_conversations", "cave_get_conversation", "cave_list_conversation_messages", + "cave_get_familiar_contract", + "cave_get_familiar_analytics", ] ); } diff --git a/src-tauri/src/transport.rs b/src-tauri/src/transport.rs index cd9d843e..52e8864b 100644 --- a/src-tauri/src/transport.rs +++ b/src-tauri/src/transport.rs @@ -92,6 +92,14 @@ pub(crate) enum CaveReadPath { conversation_id: String, page: NativePage, }, + FamiliarContract { + familiar_id: String, + }, + FamiliarAnalytics { + familiar_id: String, + window: Option, + recent_limit: Option, + }, } #[derive(Clone, Deserialize)] @@ -175,10 +183,32 @@ impl CaveReadPath { validate_canonical_conversation_id(conversation_id)?; page.validate() } + Self::FamiliarContract { familiar_id } => validate_canonical_familiar_id(familiar_id), + Self::FamiliarAnalytics { + familiar_id, + window, + recent_limit, + } => { + validate_canonical_familiar_id(familiar_id)?; + // Cave refuses an unknown window or an out-of-range recent + // count rather than correcting it, so the same values are + // refused here instead of being sent to be rejected there. + if window + .as_deref() + .is_some_and(|window| !CANONICAL_ANALYTICS_WINDOWS.contains(&window)) + || recent_limit.is_some_and(|recent| recent > 100) + { + return Err(NativeDiagnostic::new("invalid_native_input", false)); + } + Ok(()) + } } } } +/// The windows Cave aggregates over. A client may narrow to exactly one. +const CANONICAL_ANALYTICS_WINDOWS: [&str; 4] = ["7d", "14d", "8w", "all"]; + fn is_canonical_cursor(value: &str) -> bool { if value.is_empty() || value.len() > 512 @@ -202,6 +232,26 @@ fn is_canonical_cursor(value: &str) -> bool { trailing != usize::MAX && trailing % (if remainder == 2 { 16 } else { 4 }) == 0 } +/// A familiar id, held to the slug allow-list Cave itself enforces. +/// +/// Narrower than a conversation id on purpose: Cave's `isValidFamiliarId` +/// accepts `[a-z0-9][a-z0-9_-]{0,63}` case-insensitively and nothing else, so +/// an id this host would have to percent-encode is one Cave could never carry. +/// Refusing it here means a traversal-shaped id never reaches the wire at all. +fn validate_canonical_familiar_id(value: &str) -> NativeResult<()> { + let bytes = value.as_bytes(); + let leads = bytes.first().is_some_and(u8::is_ascii_alphanumeric); + if !leads + || bytes.len() > 64 + || !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(NativeDiagnostic::new("invalid_native_input", false)); + } + Ok(()) +} + fn validate_canonical_conversation_id(value: &str) -> NativeResult<()> { if value.trim().is_empty() || matches!(value, "." | "..") @@ -414,6 +464,28 @@ impl NativeCaveTransport for ConstrainedTransport { }, Some(page), ), + CaveReadPath::FamiliarContract { familiar_id } => ( + format!( + "api/client/v1/familiars/{}/contract", + encoded_cave_path_segment(&familiar_id) + ), + None, + ), + CaveReadPath::FamiliarAnalytics { + familiar_id, + window, + recent_limit, + } => ( + with_analytics_query( + format!( + "api/client/v1/familiars/{}/analytics", + encoded_cave_path_segment(&familiar_id) + ), + window.as_deref(), + recent_limit, + ), + None, + ), }; let path = with_page(path, page)?; Self::request(authority, Method::GET, &path, Some(bearer), None, None).await @@ -480,6 +552,31 @@ pub(crate) fn encoded_cave_path_segment(value: &str) -> String { .collect() } +/// `window` before `recent`, and only what the caller set. +/// +/// Both values are already proven by `CaveReadPath::validate`, and Cave +/// refuses a parameter it does not serve rather than ignoring it, so the query +/// carries exactly the narrowing that was asked for and nothing implied. +fn with_analytics_query( + mut path: String, + window: Option<&str>, + recent_limit: Option, +) -> String { + if window.is_none() && recent_limit.is_none() { + return path; + } + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + if let Some(window) = window { + serializer.append_pair("window", window); + } + if let Some(recent) = recent_limit { + serializer.append_pair("recent", &recent.to_string()); + } + path.push('?'); + path.push_str(&serializer.finish()); + path +} + fn with_page(mut path: String, page: Option) -> NativeResult { let Some(page) = page else { return Ok(path); @@ -625,7 +722,8 @@ mod tests { use super::{ encoded_cave_path_segment, managed_pairing_created, response_data, response_diagnostic, - CaveReadPath, ConstrainedTransport, NativeCaveTransport, NativeHttpResponse, NativePage, + with_analytics_query, CaveReadPath, ConstrainedTransport, NativeCaveTransport, + NativeHttpResponse, NativePage, }; use crate::cave::{ pin_owner_discovery_record, OwnerDiscoveryRecord, OwnerDiscoveryRecordMetadata, @@ -793,6 +891,99 @@ mod tests { } } + #[test] + fn canonical_familiar_ids_are_bounded_to_caves_slug_allow_list() { + for familiar_id in ["scribe", "cody", "a", "a-b_c9", &"a".repeat(64)] { + assert!( + CaveReadPath::FamiliarContract { + familiar_id: familiar_id.to_owned(), + } + .validate() + .is_ok(), + "{familiar_id} is a Cave familiar slug" + ); + } + // Narrower than a conversation id: `.` and `~` are legal there and not + // here, because Cave's own allow-list does not carry them. Anything + // this host would have to percent-encode is refused before the wire. + for familiar_id in [ + "", + ".", + "..", + "a/b", + "space id", + "雪", + "percent%", + "-leading", + "_leading", + "dot.name", + "tilde~name", + &"a".repeat(65), + ] { + assert!( + CaveReadPath::FamiliarContract { + familiar_id: familiar_id.to_owned(), + } + .validate() + .is_err(), + "{familiar_id} is not a Cave familiar slug" + ); + } + } + + #[test] + fn familiar_analytics_narrowing_is_refused_before_it_reaches_cave() { + let analytics = + |window: Option<&str>, recent: Option| CaveReadPath::FamiliarAnalytics { + familiar_id: "scribe".to_owned(), + window: window.map(str::to_owned), + recent_limit: recent, + }; + for window in ["7d", "14d", "8w", "all"] { + assert!(analytics(Some(window), None).validate().is_ok(), "{window}"); + } + assert!(analytics(None, None).validate().is_ok()); + assert!(analytics(None, Some(0)).validate().is_ok()); + assert!(analytics(None, Some(100)).validate().is_ok()); + // Cave refuses these rather than correcting them, so they never leave. + for window in ["3d", "7D", "", "all "] { + assert!( + analytics(Some(window), None).validate().is_err(), + "{window}" + ); + } + assert!(analytics(None, Some(101)).validate().is_err()); + assert!(analytics(Some("7d"), Some(101)).validate().is_err()); + } + + #[test] + fn familiar_routes_carry_only_the_narrowing_that_was_asked_for() { + assert_eq!( + with_analytics_query( + "api/client/v1/familiars/scribe/analytics".to_owned(), + None, + None + ), + "api/client/v1/familiars/scribe/analytics" + ); + assert_eq!( + with_analytics_query( + "api/client/v1/familiars/scribe/analytics".to_owned(), + Some("7d"), + Some(5), + ), + "api/client/v1/familiars/scribe/analytics?window=7d&recent=5" + ); + assert_eq!( + with_analytics_query( + "api/client/v1/familiars/scribe/analytics".to_owned(), + None, + Some(0), + ), + "api/client/v1/familiars/scribe/analytics?recent=0" + ); + } + #[test] fn endpoint_takeover_receives_ciphertext_only_and_cannot_forge_a_response() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/src/specification-guards.test.ts b/src/specification-guards.test.ts index ad659378..88129e2a 100644 --- a/src/specification-guards.test.ts +++ b/src/specification-guards.test.ts @@ -227,6 +227,8 @@ describe('Phase 1 specification guards', () => { 'allow-cave-list-conversations', 'allow-cave-get-conversation', 'allow-cave-list-conversation-messages', + 'allow-cave-get-familiar-contract', + 'allow-cave-get-familiar-analytics', ]); for (const permission of capability.permissions) { @@ -680,6 +682,8 @@ describe('Phase 1 specification guards', () => { 'cave_list_conversations', 'cave_get_conversation', 'cave_list_conversation_messages', + 'cave_get_familiar_contract', + 'cave_get_familiar_analytics', ]) { expect(buildScript).toContain(`"${command}"`); } @@ -706,6 +710,8 @@ describe('Phase 1 specification guards', () => { 'cave_list_conversations', 'cave_get_conversation', 'cave_list_conversation_messages', + 'cave_get_familiar_contract', + 'cave_get_familiar_analytics', ]; const schemaCommands = [...schema.matchAll(/"const": "(?:allow|deny)-([^"]+)"/g)] .map((match) => match[1]?.replaceAll('-', '_') ?? '') @@ -743,6 +749,8 @@ describe('Phase 1 specification guards', () => { 'cave_list_conversations', 'cave_get_conversation', 'cave_list_conversation_messages', + 'cave_get_familiar_contract', + 'cave_get_familiar_analytics', ]; expect(registeredCommandNames(commands)).toEqual(expected);