diff --git a/Cargo.lock b/Cargo.lock index 0232383..18aa483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,7 @@ dependencies = [ "sha2", "tempfile", "ts-rs", + "url", ] [[package]] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index aa2f404..16171f6 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -40,13 +40,13 @@ use codeischeap_core::{ process_gateway_event, }; use codeischeap_desktop_api::{ - BetaMetricsPreview, BetaMetricsSnapshot, CaptureMode, CapturedRequest, CertificateAuthority, - CertificateAuthorityState, CertificatePrivateMaterial, CertificateTrust, DesktopApiError, - DiagnosticEvent, ExportPreview, ExportProfile, ExportReceipt, SupportBundlePreview, - UpdateStatus, WorkspaceBootstrap, WorkspaceSource, build_batch_export_preview, - build_beta_metrics_preview, build_export_preview, build_support_bundle_preview, - diagnose_capture_compatibility, load_request, load_workspace, recovery_read_only_compatibility, - search_requests, + BetaMetricsPreview, BetaMetricsSnapshot, CaptureMode, CaptureProfile, CapturedRequest, + CertificateAuthority, CertificateAuthorityState, CertificatePrivateMaterial, CertificateTrust, + DesktopApiError, DiagnosticEvent, ExportPreview, ExportProfile, ExportReceipt, + SupportBundlePreview, UpdateStatus, WorkspaceBootstrap, WorkspaceSource, + build_batch_export_preview, build_beta_metrics_preview, build_export_preview, + build_support_bundle_preview, diagnose_capture_compatibility, load_request, load_workspace, + recovery_read_only_compatibility, search_requests, }; use codeischeap_gateway::{Gateway, GatewayCapture, GatewayCaptureEvent}; use codeischeap_process_attribution::resolve_loopback_client_pid; @@ -87,7 +87,7 @@ const LEGACY_DEMO_CAPTURE_ID: &str = "demo_openai_parser"; const KEY_SERVICE: &str = "com.codeischeap.desktop"; const KEY_ACCOUNT: &str = "capture-database-v1"; const DEFAULT_GATEWAY_ADDRESS: &str = "127.0.0.1:8787"; -const DEFAULT_OPENAI_UPSTREAM: &str = "https://api.openai.com"; +const CAPTURE_PROFILE_SETTING_KEY: &str = "capture.profile"; const CAPTURE_UPDATED_EVENT: &str = "capture-updated"; const CAPTURE_RUNTIME_ERROR_EVENT: &str = "capture-runtime-error"; const UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "update-download-progress"; @@ -118,6 +118,7 @@ struct DesktopState { beta_metrics_error: Mutex>, beta_metrics_initialized: AtomicBool, beta_first_capture_eligible: bool, + capture_profile: Mutex, gateway: AsyncMutex>, proxy: AsyncMutex>, mode: AsyncMutex, @@ -280,6 +281,7 @@ impl PlatformProxySession { struct RuntimeSnapshot { mode: CaptureMode, active: bool, + capture_profile: CaptureProfile, proxy_available: bool, gateway_endpoint: Option, proxy_endpoint: Option, @@ -431,6 +433,39 @@ async fn set_capture_mode( load_runtime_workspace(&app, &state).await } +#[tauri::command] +async fn update_capture_profile( + profile: CaptureProfile, + app: AppHandle, + state: State<'_, DesktopState>, +) -> Result { + initialize_store(&app, &state)?; + ensure_store_writable(&state.store)?; + let profile = profile.validated().map_err(|error| error.to_string())?; + if state.capture_active.load(Ordering::Acquire) { + return Err("pause capture before changing the active Profile".to_owned()); + } + if *state.mode.lock().await != CaptureMode::Gateway { + return Err("return capture to Gateway mode before changing the active Profile".to_owned()); + } + let previous = active_capture_profile(&state)?; + if previous == profile { + return load_runtime_workspace(&app, &state).await; + } + + stop_gateway(&state).await?; + replace_capture_profile(&state, profile.clone())?; + if let Err(error) = ensure_gateway(&app, &state).await { + let rollback = restore_capture_profile(&app, &state, previous).await; + return Err(combine_profile_update_error(error, rollback)); + } + if let Err(error) = persist_capture_profile(&state.store, &profile) { + let rollback = restore_capture_profile(&app, &state, previous).await; + return Err(combine_profile_update_error(error, rollback)); + } + load_runtime_workspace(&app, &state).await +} + #[tauri::command] async fn install_certificate_authority_trust( app: AppHandle, @@ -760,6 +795,7 @@ pub fn run() { beta_metrics_error: Mutex::new(beta_metrics_error.clone()), beta_metrics_initialized: AtomicBool::new(false), beta_first_capture_eligible, + capture_profile: Mutex::new(CaptureProfile::default()), gateway: AsyncMutex::new(None), proxy: AsyncMutex::new(None), mode: AsyncMutex::new(CaptureMode::Gateway), @@ -821,6 +857,7 @@ pub fn run() { search_workspace, set_capture_active, set_capture_mode, + update_capture_profile, uninstall_certificate_authority_trust, write_batch_capture_export, write_beta_metrics, @@ -1079,6 +1116,14 @@ fn initialize_store(app: &AppHandle, state: &DesktopState) -> Result<(), String> recovery } }; + let capture_profile = match load_capture_profile(&initialized) { + Ok(profile) => profile, + Err(error) => { + emit_runtime_error(app, "capture_profile_invalid", error); + CaptureProfile::default() + } + }; + replace_capture_profile(state, capture_profile)?; *store = Some(initialized); } if state.beta_metrics_initialized.load(Ordering::Acquire) { @@ -1100,6 +1145,43 @@ fn initialize_store(app: &AppHandle, state: &DesktopState) -> Result<(), String> Ok(()) } +fn load_capture_profile(store: &EncryptedStore) -> Result { + match store + .get_setting_json(CAPTURE_PROFILE_SETTING_KEY) + .map_err(|error| error.to_string())? + { + Some(encoded) => CaptureProfile::from_json(&encoded).map_err(|error| error.to_string()), + None => Ok(CaptureProfile::default()), + } +} + +fn persist_capture_profile(store: &SharedStore, profile: &CaptureProfile) -> Result<(), String> { + let encoded = serde_json::to_string(profile).map_err(|error| error.to_string())?; + store + .lock() + .map_err(|_| "encrypted workspace is temporarily unavailable".to_owned())? + .as_mut() + .ok_or_else(|| "encrypted workspace has not initialized".to_owned())? + .set_setting_json(CAPTURE_PROFILE_SETTING_KEY, &encoded, current_unix_ms()?) + .map_err(|error| error.to_string()) +} + +fn active_capture_profile(state: &DesktopState) -> Result { + state + .capture_profile + .lock() + .map_err(|_| "capture Profile is temporarily unavailable".to_owned()) + .map(|profile| profile.clone()) +} + +fn replace_capture_profile(state: &DesktopState, profile: CaptureProfile) -> Result<(), String> { + *state + .capture_profile + .lock() + .map_err(|_| "capture Profile is temporarily unavailable".to_owned())? = profile; + Ok(()) +} + fn store_is_read_only(store: &SharedStore) -> Result { let store = store .lock() @@ -1588,6 +1670,7 @@ async fn runtime_snapshot(app: &AppHandle, state: &DesktopState) -> RuntimeSnaps RuntimeSnapshot { mode, active: state.capture_active.load(Ordering::Acquire), + capture_profile: active_capture_profile(state).unwrap_or_default(), proxy_available: state.sidecar_bundle.is_some(), gateway_endpoint, proxy_endpoint, @@ -1599,6 +1682,7 @@ async fn runtime_snapshot(app: &AppHandle, state: &DesktopState) -> RuntimeSnaps } fn apply_runtime_state(workspace: &mut WorkspaceBootstrap, snapshot: RuntimeSnapshot) { + workspace.capture_profile = snapshot.capture_profile.clone(); if snapshot.read_only_recovery { workspace.source = WorkspaceSource::RecoveryBackup; workspace.capture.active = false; @@ -1615,14 +1699,14 @@ fn apply_runtime_state(workspace: &mut WorkspaceBootstrap, snapshot: RuntimeSnap } let (profile, endpoint) = match snapshot.mode { CaptureMode::Gateway => ( - "OpenAI-compatible local gateway", + format!("{} · Local Gateway", snapshot.capture_profile.name), snapshot.gateway_endpoint.as_deref(), ), CaptureMode::Proxy => ( if snapshot.system_proxy_active { - "System-managed explicit TLS proxy" + format!("{} · System-managed proxy", snapshot.capture_profile.name) } else { - "Manual explicit TLS proxy" + format!("{} · Manual proxy", snapshot.capture_profile.name) }, snapshot.proxy_endpoint.as_deref(), ), @@ -1631,7 +1715,7 @@ fn apply_runtime_state(workspace: &mut WorkspaceBootstrap, snapshot: RuntimeSnap workspace.capture.can_control = endpoint.is_some(); workspace.capture.proxy_available = snapshot.proxy_available; workspace.capture.mode = snapshot.mode; - workspace.capture.profile = profile.to_owned(); + workspace.capture.profile = profile; workspace.capture.endpoint = endpoint.unwrap_or("Not connected").to_owned(); workspace.capture.certificate_authority = snapshot.certificate_authority; workspace.compatibility = @@ -1741,8 +1825,11 @@ async fn ensure_gateway(app: &AppHandle, state: &DesktopState) -> Result<(), Str let address = listener .local_addr() .map_err(|error| format!("local AI gateway address is unavailable: {error}"))?; - let upstream = Url::parse(DEFAULT_OPENAI_UPSTREAM) - .map_err(|error| format!("default OpenAI upstream is invalid: {error}"))?; + let profile = active_capture_profile(state)?; + let upstream = profile.gateway_url().map_err(|error| error.to_string())?; + let policy = profile + .capture_policy() + .map_err(|error| error.to_string())?; let (capture, receiver, _) = GatewayCapture::defaults(); capture.set_enabled(capture_enabled); let gateway = Gateway::new(upstream).map_err(|error| error.to_string())?; @@ -1765,6 +1852,7 @@ async fn ensure_gateway(app: &AppHandle, state: &DesktopState) -> Result<(), Str capture.clone(), state.capture_active.clone(), address, + policy, receiver, )); @@ -1776,6 +1864,52 @@ async fn ensure_gateway(app: &AppHandle, state: &DesktopState) -> Result<(), Str Ok(()) } +async fn stop_gateway(state: &DesktopState) -> Result<(), String> { + let runtime = state.gateway.lock().await.take(); + if runtime.is_none() { + return Ok(()); + } + drop(runtime); + for _ in 0..80 { + match TcpListener::bind(DEFAULT_GATEWAY_ADDRESS).await { + Ok(listener) => { + drop(listener); + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => { + tokio::time::sleep(Duration::from_millis(25)).await; + } + Err(error) => { + return Err(format!( + "local AI gateway release check failed for {DEFAULT_GATEWAY_ADDRESS}: {error}" + )); + } + } + } + Err(format!( + "local AI gateway did not release {DEFAULT_GATEWAY_ADDRESS} after shutdown" + )) +} + +async fn restore_capture_profile( + app: &AppHandle, + state: &DesktopState, + profile: CaptureProfile, +) -> Result<(), String> { + let _ = stop_gateway(state).await; + replace_capture_profile(state, profile)?; + ensure_gateway(app, state).await +} + +fn combine_profile_update_error(error: String, rollback: Result<(), String>) -> String { + match rollback { + Ok(()) => format!("capture Profile was not changed: {error}"), + Err(rollback_error) => format!( + "capture Profile update failed: {error}; previous Profile could not restart: {rollback_error}" + ), + } +} + async fn switch_capture_mode( app: &AppHandle, state: &DesktopState, @@ -1822,7 +1956,9 @@ async fn ensure_proxy(app: &AppHandle, state: &DesktopState) -> Result<(), Strin .sidecar_bundle .clone() .ok_or_else(|| "verified explicit proxy bundle is unavailable".to_owned())?; - let policy = CapturePolicy::load_default().map_err(|error| error.to_string())?; + let policy = active_capture_profile(state)? + .capture_policy() + .map_err(|error| error.to_string())?; let target_hosts = policy .targets .iter() @@ -2428,15 +2564,9 @@ async fn process_capture_events( capture: GatewayCapture, capture_active: Arc, gateway_address: SocketAddr, + policy: CapturePolicy, mut receiver: mpsc::Receiver, ) { - let policy = match CapturePolicy::load_default() { - Ok(policy) => policy, - Err(error) => { - emit_runtime_error(&app, "capture_policy_invalid", error.to_string()); - return; - } - }; let adapters = AdapterRegistry::default(); let mut pending = HashMap::::new(); let mut captures_since_retention = 0_usize; @@ -2714,6 +2844,40 @@ mod tests { } } + #[test] + fn capture_profiles_round_trip_through_encrypted_workspace_settings() { + const PROFILE_CANARY: &str = "profile-runtime-canary.example.test"; + let directory = tempdir().expect("temp directory must be created"); + let store = Arc::new(Mutex::new(Some( + EncryptedStore::open( + directory.path().join("captures.db"), + DatabaseKey::from_bytes([0x73; 32]), + ) + .expect("encrypted store must open"), + ))); + let profile = CaptureProfile { + version: codeischeap_desktop_api::CAPTURE_PROFILE_VERSION.to_owned(), + name: "Private lab".to_owned(), + gateway_upstream: format!("https://{PROFILE_CANARY}"), + additional_hosts: vec!["proxy.profile.test".to_owned()], + } + .validated() + .expect("profile must validate"); + + persist_capture_profile(&store, &profile).expect("profile must persist"); + let loaded = load_capture_profile( + store + .lock() + .expect("store lock") + .as_ref() + .expect("store must exist"), + ) + .expect("profile must load"); + + assert_eq!(loaded, profile); + assert!(!files_contain(directory.path(), PROFILE_CANARY.as_bytes())); + } + #[test] fn updater_configuration_rejects_missing_public_keys() { assert_eq!(updater_public_key(None), None); @@ -2862,6 +3026,7 @@ mod tests { RuntimeSnapshot { mode: CaptureMode::Gateway, active: false, + capture_profile: CaptureProfile::default(), proxy_available: true, gateway_endpoint: Some("http://127.0.0.1:8787".to_owned()), proxy_endpoint: None, @@ -2877,7 +3042,7 @@ mod tests { assert!(workspace.capture.proxy_available); assert_eq!(workspace.capture.mode, CaptureMode::Gateway); assert_eq!(workspace.capture.endpoint, "http://127.0.0.1:8787"); - assert_eq!(workspace.capture.profile, "OpenAI-compatible local gateway"); + assert_eq!(workspace.capture.profile, "OpenAI default · Local Gateway"); assert_eq!( workspace.compatibility.code, codeischeap_desktop_api::CaptureCompatibilityCode::CapturePaused @@ -2901,6 +3066,7 @@ mod tests { RuntimeSnapshot { mode: CaptureMode::Proxy, active: true, + capture_profile: CaptureProfile::default(), proxy_available: true, gateway_endpoint: Some("http://127.0.0.1:8787".to_owned()), proxy_endpoint: Some("http://127.0.0.1:43125".to_owned()), @@ -2955,6 +3121,7 @@ mod tests { RuntimeSnapshot { mode: CaptureMode::Proxy, active: true, + capture_profile: CaptureProfile::default(), proxy_available: true, gateway_endpoint: Some("http://127.0.0.1:8787".to_owned()), proxy_endpoint: Some("http://127.0.0.1:43125".to_owned()), @@ -2972,7 +3139,7 @@ mod tests { assert_eq!(workspace.capture.endpoint, "http://127.0.0.1:43125"); assert_eq!( workspace.capture.profile, - "System-managed explicit TLS proxy" + "OpenAI default · System-managed proxy" ); assert_eq!( workspace.capture.certificate_authority, @@ -2988,6 +3155,7 @@ mod tests { RuntimeSnapshot { mode: CaptureMode::Proxy, active: true, + capture_profile: CaptureProfile::default(), proxy_available: true, gateway_endpoint: Some("http://127.0.0.1:8787".to_owned()), proxy_endpoint: Some("http://127.0.0.1:43125".to_owned()), @@ -3533,6 +3701,7 @@ mod tests { beta_metrics_error: Mutex::new(None), beta_metrics_initialized: AtomicBool::new(true), beta_first_capture_eligible: false, + capture_profile: Mutex::new(CaptureProfile::default()), gateway: AsyncMutex::new(Some(GatewayRuntime { capture: capture.clone(), endpoint: "http://127.0.0.1:8787".to_owned(), diff --git a/apps/desktop/src/data/workspace.json b/apps/desktop/src/data/workspace.json index 0551c1e..7f7562f 100644 --- a/apps/desktop/src/data/workspace.json +++ b/apps/desktop/src/data/workspace.json @@ -1,6 +1,12 @@ { "apiVersion": "0.1", "source": "synthetic_fixture", + "captureProfile": { + "version": "0.1", + "name": "OpenAI default", + "gatewayUpstream": "https://api.openai.com", + "additionalHosts": [] + }, "capture": { "active": true, "canControl": false, diff --git a/apps/desktop/src/generated/desktop-api/CaptureProfile.ts b/apps/desktop/src/generated/desktop-api/CaptureProfile.ts new file mode 100644 index 0000000..bc6a974 --- /dev/null +++ b/apps/desktop/src/generated/desktop-api/CaptureProfile.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CaptureProfile = { version: string, name: string, gatewayUpstream: string, additionalHosts: Array, }; diff --git a/apps/desktop/src/generated/desktop-api/WorkspaceBootstrap.ts b/apps/desktop/src/generated/desktop-api/WorkspaceBootstrap.ts index 7844285..cd339b8 100644 --- a/apps/desktop/src/generated/desktop-api/WorkspaceBootstrap.ts +++ b/apps/desktop/src/generated/desktop-api/WorkspaceBootstrap.ts @@ -1,7 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CaptureCompatibility } from "./CaptureCompatibility"; +import type { CaptureProfile } from "./CaptureProfile"; import type { CaptureState } from "./CaptureState"; import type { CapturedRequest } from "./CapturedRequest"; import type { WorkspaceSource } from "./WorkspaceSource"; -export type WorkspaceBootstrap = { apiVersion: string, source: WorkspaceSource, capture: CaptureState, compatibility: CaptureCompatibility, requests: Array, }; +export type WorkspaceBootstrap = { apiVersion: string, source: WorkspaceSource, captureProfile: CaptureProfile, capture: CaptureState, compatibility: CaptureCompatibility, requests: Array, }; diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 0f3bc93..01bf209 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -9,6 +9,7 @@ export type { CaptureCompatibility } from "./generated/desktop-api/CaptureCompat export type { CaptureCompatibilityCode } from "./generated/desktop-api/CaptureCompatibilityCode"; export type { CaptureCompatibilityStatus } from "./generated/desktop-api/CaptureCompatibilityStatus"; export type { CaptureMode } from "./generated/desktop-api/CaptureMode"; +export type { CaptureProfile } from "./generated/desktop-api/CaptureProfile"; export type { CaptureState } from "./generated/desktop-api/CaptureState"; export type { CaptureStatus } from "./generated/desktop-api/CaptureStatus"; export type { CertificateAuthority } from "./generated/desktop-api/CertificateAuthority"; diff --git a/crates/desktop-api/Cargo.toml b/crates/desktop-api/Cargo.toml index 50c8295..83dcca2 100644 --- a/crates/desktop-api/Cargo.toml +++ b/crates/desktop-api/Cargo.toml @@ -7,6 +7,7 @@ description = "Versioned desktop command DTOs for CodeIsCheap" [dependencies] codeischeap-capture-ipc = { path = "../capture-ipc" } +codeischeap-capture-policy = { path = "../capture-policy" } codeischeap-prompt-ir = { path = "../prompt-ir" } codeischeap-storage = { path = "../storage" } regex.workspace = true @@ -15,7 +16,7 @@ serde.workspace = true serde_json.workspace = true sha2.workspace = true ts-rs.workspace = true +url.workspace = true [dev-dependencies] -codeischeap-capture-policy = { path = "../capture-policy" } tempfile.workspace = true diff --git a/crates/desktop-api/src/bin/export-desktop-contract.rs b/crates/desktop-api/src/bin/export-desktop-contract.rs index 5c28fa0..d8ff24c 100644 --- a/crates/desktop-api/src/bin/export-desktop-contract.rs +++ b/crates/desktop-api/src/bin/export-desktop-contract.rs @@ -2,8 +2,8 @@ use std::fs; use std::path::PathBuf; use codeischeap_desktop_api::{ - BetaMetricsPreview, BetaMetricsSnapshot, ExportPreview, ExportProfile, ExportReceipt, - ExportRedaction, SupportBundlePreview, UpdateStatus, WorkspaceBootstrap, + BetaMetricsPreview, BetaMetricsSnapshot, CaptureProfile, ExportPreview, ExportProfile, + ExportReceipt, ExportRedaction, SupportBundlePreview, UpdateStatus, WorkspaceBootstrap, }; use ts_rs::{Config, TS}; @@ -23,6 +23,7 @@ fn main() -> Result<(), Box> { .with_out_dir(bindings_path) .with_large_int("number"); WorkspaceBootstrap::export_all(&config)?; + CaptureProfile::export_all(&config)?; ExportProfile::export_all(&config)?; ExportRedaction::export_all(&config)?; ExportPreview::export_all(&config)?; diff --git a/crates/desktop-api/src/export.rs b/crates/desktop-api/src/export.rs index 169ab8b..09f6b14 100644 --- a/crates/desktop-api/src/export.rs +++ b/crates/desktop-api/src/export.rs @@ -661,6 +661,7 @@ mod tests { let workspace = WorkspaceBootstrap { api_version: DESKTOP_API_VERSION.to_owned(), source: WorkspaceSource::EncryptedLocal, + capture_profile: crate::CaptureProfile::default(), compatibility: diagnose_capture_compatibility(&capture, 0), capture, requests: vec![request()], diff --git a/crates/desktop-api/src/lib.rs b/crates/desktop-api/src/lib.rs index 4ed01a7..1ed04c0 100644 --- a/crates/desktop-api/src/lib.rs +++ b/crates/desktop-api/src/lib.rs @@ -3,6 +3,7 @@ mod beta; mod compatibility; mod export; +mod profile; mod update; pub use beta::{ @@ -14,6 +15,10 @@ pub use compatibility::{ CompatibilityAction, CompatibilityConfidence, CompatibilityStep, CompatibilityStepStatus, diagnose_capture_compatibility, recovery_read_only_compatibility, }; +pub use profile::{ + CAPTURE_PROFILE_VERSION, CaptureProfile, CaptureProfileError, DEFAULT_CAPTURE_PROFILE_NAME, + DEFAULT_GATEWAY_UPSTREAM, MAX_CAPTURE_PROFILE_NAME_BYTES, +}; pub use export::{ DiagnosticEvent, EXPORT_FORMAT_VERSION, EXPORT_POLICY_VERSION, ExportPreview, ExportProfile, @@ -45,6 +50,7 @@ pub const DESKTOP_API_VERSION: &str = "0.1"; pub struct WorkspaceBootstrap { pub api_version: String, pub source: WorkspaceSource, + pub capture_profile: CaptureProfile, pub capture: CaptureState, pub compatibility: CaptureCompatibility, pub requests: Vec, @@ -314,6 +320,7 @@ pub fn load_workspace(store: &EncryptedStore) -> Result, +} + +impl Default for CaptureProfile { + fn default() -> Self { + Self { + version: CAPTURE_PROFILE_VERSION.to_owned(), + name: DEFAULT_CAPTURE_PROFILE_NAME.to_owned(), + gateway_upstream: DEFAULT_GATEWAY_UPSTREAM.to_owned(), + additional_hosts: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CaptureProfileError { + InvalidJson, + UnsupportedVersion(String), + InvalidName, + InvalidGatewayUpstream, + GatewayCredentialsForbidden, + GatewayOriginRequired, + BuiltInAdditionalHost(String), + Policy(PolicyError), +} + +impl fmt::Display for CaptureProfileError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidJson => write!(formatter, "capture profile JSON is invalid"), + Self::UnsupportedVersion(version) => { + write!( + formatter, + "capture profile version {version} is unsupported" + ) + } + Self::InvalidName => write!( + formatter, + "capture profile name must be trimmed text of at most {MAX_CAPTURE_PROFILE_NAME_BYTES} bytes" + ), + Self::InvalidGatewayUpstream => write!( + formatter, + "Gateway upstream must be an absolute HTTP or HTTPS URL" + ), + Self::GatewayCredentialsForbidden => write!( + formatter, + "Gateway upstream must not contain embedded credentials" + ), + Self::GatewayOriginRequired => write!( + formatter, + "Gateway upstream must be an origin without a path, query, or fragment" + ), + Self::BuiltInAdditionalHost(host) => { + write!( + formatter, + "additional capture host {host} is already built in" + ) + } + Self::Policy(error) => write!(formatter, "{error}"), + } + } +} + +impl std::error::Error for CaptureProfileError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Policy(error) => Some(error), + _ => None, + } + } +} + +impl From for CaptureProfileError { + fn from(error: PolicyError) -> Self { + Self::Policy(error) + } +} + +impl CaptureProfile { + pub fn from_json(encoded: &str) -> Result { + let profile: Self = + serde_json::from_str(encoded).map_err(|_| CaptureProfileError::InvalidJson)?; + profile.validated() + } + + pub fn validated(mut self) -> Result { + if self.version != CAPTURE_PROFILE_VERSION { + return Err(CaptureProfileError::UnsupportedVersion(self.version)); + } + let name = self.name.trim(); + if name.is_empty() + || name.len() > MAX_CAPTURE_PROFILE_NAME_BYTES + || name.chars().any(char::is_control) + { + return Err(CaptureProfileError::InvalidName); + } + self.name = name.to_owned(); + + let upstream = validate_gateway_upstream(&self.gateway_upstream)?; + self.gateway_upstream = canonical_origin(&upstream); + self.additional_hosts = normalize_additional_hosts(&self.additional_hosts)?; + let built_in_hosts = built_in_hosts()?; + if let Some(host) = self + .additional_hosts + .iter() + .find(|host| built_in_hosts.contains(host.as_str())) + { + return Err(CaptureProfileError::BuiltInAdditionalHost(host.clone())); + } + self.capture_policy()?; + Ok(self) + } + + pub fn gateway_url(&self) -> Result { + validate_gateway_upstream(&self.gateway_upstream) + } + + pub fn capture_policy(&self) -> Result { + let mut scope_hosts = self.additional_hosts.clone(); + let upstream = self.gateway_url()?; + let upstream_host = upstream + .host_str() + .ok_or(CaptureProfileError::InvalidGatewayUpstream)? + .to_ascii_lowercase(); + let default_policy = CapturePolicy::load_default()?; + let upstream_has_openai_scope = default_policy + .matching_target(&openai_scope_probe(&upstream_host)) + .is_some(); + if !upstream_has_openai_scope && !scope_hosts.contains(&upstream_host) { + scope_hosts.push(upstream_host); + } + Ok(default_policy.with_additional_hosts(&scope_hosts)?) + } +} + +fn validate_gateway_upstream(value: &str) -> Result { + let upstream = Url::parse(value).map_err(|_| CaptureProfileError::InvalidGatewayUpstream)?; + if !matches!(upstream.scheme(), "http" | "https") || upstream.host_str().is_none() { + return Err(CaptureProfileError::InvalidGatewayUpstream); + } + if !upstream.username().is_empty() || upstream.password().is_some() { + return Err(CaptureProfileError::GatewayCredentialsForbidden); + } + if upstream.path() != "/" || upstream.query().is_some() || upstream.fragment().is_some() { + return Err(CaptureProfileError::GatewayOriginRequired); + } + Ok(upstream) +} + +fn canonical_origin(upstream: &Url) -> String { + let mut origin = upstream.origin().ascii_serialization(); + if origin.ends_with('/') { + origin.pop(); + } + origin +} + +fn built_in_hosts() -> Result, CaptureProfileError> { + Ok(CapturePolicy::load_default()? + .targets + .into_iter() + .flat_map(|target| target.hosts) + .collect()) +} + +fn openai_scope_probe(host: &str) -> CapturedRequest { + CapturedRequest { + method: "POST".to_owned(), + scheme: "https".to_owned(), + host: host.to_owned(), + port: 443, + path: "/v1/chat/completions".to_owned(), + query: Vec::new(), + headers: Vec::new(), + body: CapturedBody { + state: CapturedBodyState::Empty, + content: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_profile_uses_the_built_in_openai_scope() { + let profile = CaptureProfile::default() + .validated() + .expect("default profile"); + assert_eq!(profile.gateway_upstream, DEFAULT_GATEWAY_UPSTREAM); + assert_eq!(profile.capture_policy().expect("policy").targets.len(), 5); + } + + #[test] + fn custom_origins_and_hosts_are_canonical_and_bounded_by_existing_paths() { + let profile = CaptureProfile { + version: CAPTURE_PROFILE_VERSION.to_owned(), + name: " Private lab ".to_owned(), + gateway_upstream: "https://LOCALHOST:8443/".to_owned(), + additional_hosts: vec![" Proxy.EXAMPLE.test. ".to_owned()], + } + .validated() + .expect("custom profile"); + assert_eq!(profile.name, "Private lab"); + assert_eq!(profile.gateway_upstream, "https://localhost:8443"); + assert_eq!(profile.additional_hosts, ["proxy.example.test"]); + + let policy = profile.capture_policy().expect("custom policy"); + assert!( + policy + .matching_target(&openai_scope_probe("localhost")) + .is_some() + ); + let mut denied = openai_scope_probe("proxy.example.test"); + denied.path = "/admin".to_owned(); + assert!(policy.matching_target(&denied).is_none()); + } + + #[test] + fn profiles_reject_credentials_non_origins_and_built_in_scope_expansion() { + for upstream in [ + "ftp://example.test", + "https://user:secret@example.test", + "https://example.test/v1", + "https://example.test?key=value", + ] { + assert!( + CaptureProfile { + gateway_upstream: upstream.to_owned(), + ..CaptureProfile::default() + } + .validated() + .is_err() + ); + } + assert_eq!( + CaptureProfile { + additional_hosts: vec!["api.openai.com".to_owned()], + ..CaptureProfile::default() + } + .validated(), + Err(CaptureProfileError::BuiltInAdditionalHost( + "api.openai.com".to_owned() + )) + ); + } +} diff --git a/schemas/desktop-api/v0.1.schema.json b/schemas/desktop-api/v0.1.schema.json index 89af214..bf58918 100644 --- a/schemas/desktop-api/v0.1.schema.json +++ b/schemas/desktop-api/v0.1.schema.json @@ -9,6 +9,9 @@ "capture": { "$ref": "#/$defs/CaptureState" }, + "captureProfile": { + "$ref": "#/$defs/CaptureProfile" + }, "compatibility": { "$ref": "#/$defs/CaptureCompatibility" }, @@ -25,6 +28,7 @@ "required": [ "apiVersion", "source", + "captureProfile", "capture", "compatibility", "requests" @@ -188,6 +192,33 @@ "proxy" ] }, + "CaptureProfile": { + "type": "object", + "additionalProperties": false, + "properties": { + "additionalHosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "gatewayUpstream": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "version", + "name", + "gatewayUpstream", + "additionalHosts" + ] + }, "CaptureState": { "type": "object", "properties": {