From 4fb05d4ef3940e5a1a82dd2f28139ff45d65dbd6 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 18 Jul 2026 13:19:37 +0800 Subject: [PATCH] feat: add proxy compatibility diagnostics --- README.md | 4 +- apps/desktop/src-tauri/src/lib.rs | 107 +++-- apps/desktop/src/App.test.tsx | 84 +++- apps/desktop/src/App.tsx | 9 +- apps/desktop/src/SettingsDialog.tsx | 43 +- apps/desktop/src/data/workspace.json | 14 + .../desktop-api/CaptureCompatibility.ts | 9 + .../desktop-api/CaptureCompatibilityCode.ts | 3 + .../desktop-api/CaptureCompatibilityStatus.ts | 3 + .../desktop-api/CompatibilityAction.ts | 3 + .../desktop-api/CompatibilityConfidence.ts | 3 + .../desktop-api/CompatibilityStep.ts | 4 + .../desktop-api/CompatibilityStepStatus.ts | 3 + .../desktop-api/WorkspaceBootstrap.ts | 3 +- apps/desktop/src/styles.css | 22 + apps/desktop/src/types.ts | 7 + apps/desktop/src/workspace.ts | 25 +- crates/desktop-api/src/compatibility.rs | 403 ++++++++++++++++++ crates/desktop-api/src/export.rs | 31 +- crates/desktop-api/src/lib.rs | 156 ++++++- docs/experience.html | 2 +- docs/progress.html | 15 +- docs/security.html | 2 +- schemas/desktop-api/v0.1.schema.json | 117 +++++ 24 files changed, 997 insertions(+), 75 deletions(-) create mode 100644 apps/desktop/src/generated/desktop-api/CaptureCompatibility.ts create mode 100644 apps/desktop/src/generated/desktop-api/CaptureCompatibilityCode.ts create mode 100644 apps/desktop/src/generated/desktop-api/CaptureCompatibilityStatus.ts create mode 100644 apps/desktop/src/generated/desktop-api/CompatibilityAction.ts create mode 100644 apps/desktop/src/generated/desktop-api/CompatibilityConfidence.ts create mode 100644 apps/desktop/src/generated/desktop-api/CompatibilityStep.ts create mode 100644 apps/desktop/src/generated/desktop-api/CompatibilityStepStatus.ts create mode 100644 crates/desktop-api/src/compatibility.rs diff --git a/README.md b/README.md index 9f66343..d152099 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Rust crate 位于 `crates/prompt-ir`,公开 JSON Schema 位于 `schemas/prompt 加密存储位于 `crates/storage`:使用 SQLCipher、WAL、版本化迁移与 FTS5,数据库 key 由 Windows Credential Manager 或 macOS Keychain 持有,并覆盖错误密钥拒绝、加密备份恢复与落盘明文 canary 测试。 -桌面应用位于 `apps/desktop`。Tauri 通过 OS 凭据库打开 SQLCipher,并从加密数据库加载三栏工作台;首次启动会幂等写入一条经过共享 Capture Policy 清洗的演示请求。浏览器开发模式继续使用无凭据 fixture,实时 Gateway 捕获将在后续接入。 +桌面应用位于 `apps/desktop`。Tauri 通过 OS 凭据库打开 SQLCipher,并从加密数据库加载三栏工作台;桌面运行时接入实时 Gateway/Proxy 捕获,浏览器开发模式使用无凭据 fixture。 桌面 command DTO 定义在 `crates/desktop-api`,公开 Schema 位于 `schemas/desktop-api/v0.1.schema.json`,React 使用的 TypeScript 类型由 Rust 生成。契约变更后执行: @@ -55,6 +55,8 @@ sidecar IPC 协议为 `0.2`:仅监听 loopback,使用每次 Proxy 会话重 Gateway 和 Proxy 捕获会按显式客户端标签、User-Agent 规则或捕获模式回退生成带置信度的应用归因。Gateway 客户端可设置 `x-codeischeap-client: ` 提供高置信度标签;该内部请求头会在持久化和转发上游前删除。当前归因不声明进程 PID,未知客户端会明确显示为低置信度的 `Gateway client` 或 `Proxy client`。 +Connection 设置页会按 Proxy bundle、loopback 端点、本地 CA、系统信任和当前会话捕获事件生成兼容诊断。Proxy 全部就绪但仍无事件时,只提示低置信度的代理绕过/证书固定可能性,并提供 Gateway 回退;产品不会尝试绕过证书固定。 + 系统代理事务与独立恢复 watchdog 位于 `crates/proxy-recovery`;Windows WinINet 与 macOS networksetup backend 均已通过临时 CI runner 的真实强杀恢复实验。 启动 Gateway Spike: diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index aaa6115..0ba658a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -24,8 +24,8 @@ use codeischeap_desktop_api::{ CaptureMode, CapturedRequest, CertificateAuthority, CertificateAuthorityState, CertificatePrivateMaterial, CertificateTrust, DesktopApiError, DiagnosticEvent, ExportPreview, ExportProfile, ExportReceipt, SupportBundlePreview, WorkspaceBootstrap, - build_batch_export_preview, build_export_preview, build_support_bundle_preview, load_request, - load_workspace, search_requests, + build_batch_export_preview, build_export_preview, build_support_bundle_preview, + diagnose_capture_compatibility, load_request, load_workspace, search_requests, }; use codeischeap_gateway::{Gateway, GatewayCapture, GatewayCaptureEvent}; use codeischeap_proxy_recovery::recover_from_journal; @@ -77,6 +77,7 @@ struct DesktopState { proxy: AsyncMutex>, mode: AsyncMutex, capture_active: Arc, + proxy_session_event_count: Arc, next_proxy_generation: AtomicU64, proxy_recovery_checked: AtomicBool, sidecar_bundle: Option>, @@ -105,6 +106,16 @@ struct ProxyRuntime { shutdown: Option>, } +struct ProxyCaptureContext { + app: AppHandle, + store: SharedStore, + capture_active: Arc, + session_event_count: Arc, + listener: TcpListener, + token: String, + policy: CapturePolicy, +} + impl ProxyRuntime { fn shutdown(mut self) -> Result<(), String> { if let Some(shutdown) = self.shutdown.take() { @@ -147,6 +158,7 @@ struct RuntimeSnapshot { gateway_endpoint: Option, proxy_endpoint: Option, system_proxy_active: bool, + proxy_session_event_count: u64, certificate_authority: CertificateAuthority, } @@ -232,7 +244,11 @@ async fn search_workspace( } #[tauri::command] -async fn set_capture_active(active: bool, state: State<'_, DesktopState>) -> Result { +async fn set_capture_active( + active: bool, + app: AppHandle, + state: State<'_, DesktopState>, +) -> Result { let mode = *state.mode.lock().await; match mode { CaptureMode::Gateway => { @@ -249,7 +265,7 @@ async fn set_capture_active(active: bool, state: State<'_, DesktopState>) -> Res } } state.capture_active.store(active, Ordering::Release); - Ok(active) + load_runtime_workspace(&app, &state).await } #[tauri::command] @@ -443,6 +459,7 @@ pub fn run() { proxy: AsyncMutex::new(None), mode: AsyncMutex::new(CaptureMode::Gateway), capture_active: Arc::new(AtomicBool::new(true)), + proxy_session_event_count: Arc::new(AtomicU64::new(0)), next_proxy_generation: AtomicU64::new(1), proxy_recovery_checked: AtomicBool::new(false), sidecar_bundle, @@ -873,6 +890,7 @@ async fn runtime_snapshot(app: &AppHandle, state: &DesktopState) -> RuntimeSnaps gateway_endpoint, proxy_endpoint, system_proxy_active, + proxy_session_event_count: state.proxy_session_event_count.load(Ordering::Acquire), certificate_authority: application_certificate_authority(app), } } @@ -899,6 +917,8 @@ fn apply_runtime_state(workspace: &mut WorkspaceBootstrap, snapshot: RuntimeSnap workspace.capture.profile = profile.to_owned(); workspace.capture.endpoint = endpoint.unwrap_or("Not connected").to_owned(); workspace.capture.certificate_authority = snapshot.certificate_authority; + workspace.compatibility = + diagnose_capture_compatibility(&workspace.capture, snapshot.proxy_session_event_count); } fn application_certificate_authority(app: &AppHandle) -> CertificateAuthority { @@ -1101,6 +1121,7 @@ async fn ensure_proxy(app: &AppHandle, state: &DesktopState) -> Result<(), Strin .map_err(|error| error.to_string())?; let endpoint = format!("http://{}", process.endpoint()); let generation = state.next_proxy_generation.fetch_add(1, Ordering::Relaxed); + state.proxy_session_event_count.store(0, Ordering::Release); let mut next_runtime = ProxyRuntime { generation, system_proxy: None, @@ -1111,12 +1132,15 @@ async fn ensure_proxy(app: &AppHandle, state: &DesktopState) -> Result<(), Strin activate_system_proxy(app, &mut next_runtime).await?; let (shutdown, shutdown_rx) = oneshot::channel(); tauri::async_runtime::spawn(process_proxy_events( - app.clone(), - state.store.clone(), - state.capture_active.clone(), - listener, - token, - policy, + ProxyCaptureContext { + app: app.clone(), + store: state.store.clone(), + capture_active: state.capture_active.clone(), + session_event_count: state.proxy_session_event_count.clone(), + listener, + token, + policy, + }, shutdown_rx, )); next_runtime.shutdown = Some(shutdown); @@ -1287,36 +1311,30 @@ fn generate_ipc_token() -> Result { Ok(token) } -async fn process_proxy_events( - app: AppHandle, - store: SharedStore, - capture_active: Arc, - listener: TcpListener, - token: String, - policy: CapturePolicy, - mut shutdown: oneshot::Receiver<()>, -) { +async fn process_proxy_events(context: ProxyCaptureContext, mut shutdown: oneshot::Receiver<()>) { let adapters = AdapterRegistry::default(); let mut captures_since_retention = 0_usize; loop { let result = tokio::select! { _ = &mut shutdown => break, result = receive_and_persist_proxy_capture( - &listener, - &token, - &policy, + &context.listener, + &context.token, + &context.policy, &adapters, - &store, - &capture_active, + &context.store, + &context.capture_active, ) => result, }; match result { Ok(Some(capture_id)) => { + context.session_event_count.fetch_add(1, Ordering::Relaxed); captures_since_retention += 1; - emit_capture_updated(&app, capture_id); + emit_capture_updated(&context.app, capture_id); if captures_since_retention >= CAPTURES_PER_RETENTION_RUN { captures_since_retention = 0; - let maintenance = store + let maintenance = context + .store .lock() .map_err(|_| ProxyCaptureError::StoreUnavailable) .and_then(|mut store| { @@ -1328,13 +1346,13 @@ async fn process_proxy_events( .map_err(ProxyCaptureError::Storage) }); if let Err(error) = maintenance { - apply_proxy_error_policy(&capture_active, &app, &error); + apply_proxy_error_policy(&context.capture_active, &context.app, &error); } } } Ok(None) => {} Err(error) => { - apply_proxy_error_policy(&capture_active, &app, &error); + apply_proxy_error_policy(&context.capture_active, &context.app, &error); if matches!(error, ProxyCaptureError::Ingest(_)) { tokio::time::sleep(Duration::from_millis(100)).await; } @@ -1679,6 +1697,7 @@ mod tests { gateway_endpoint: Some("http://127.0.0.1:8787".to_owned()), proxy_endpoint: None, system_proxy_active: false, + proxy_session_event_count: 0, certificate_authority: CertificateAuthority::missing(), }, ); @@ -1689,6 +1708,10 @@ mod tests { 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.compatibility.code, + codeischeap_desktop_api::CaptureCompatibilityCode::CapturePaused + ); } #[test] @@ -1707,8 +1730,8 @@ mod tests { subject: Some("mitmproxy".to_owned()), valid_from_unix_ms: Some(1_577_836_800_000), valid_until_unix_ms: Some(4_070_908_800_000), - private_material: CertificatePrivateMaterial::Unchecked, - trust: CertificateTrust::Unchecked, + private_material: CertificatePrivateMaterial::Restricted, + trust: CertificateTrust::Trusted, detail: None, }; @@ -1721,6 +1744,7 @@ mod tests { gateway_endpoint: Some("http://127.0.0.1:8787".to_owned()), proxy_endpoint: Some("http://127.0.0.1:43125".to_owned()), system_proxy_active: true, + proxy_session_event_count: 0, certificate_authority: certificate_authority.clone(), }, ); @@ -1738,6 +1762,28 @@ mod tests { workspace.capture.certificate_authority, certificate_authority ); + assert_eq!( + workspace.compatibility.code, + codeischeap_desktop_api::CaptureCompatibilityCode::ProxyCaptureUnobserved + ); + + apply_runtime_state( + &mut workspace, + RuntimeSnapshot { + mode: CaptureMode::Proxy, + active: true, + 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()), + system_proxy_active: true, + proxy_session_event_count: 1, + certificate_authority, + }, + ); + assert_eq!( + workspace.compatibility.code, + codeischeap_desktop_api::CaptureCompatibilityCode::ProxyCaptureObserved + ); } #[cfg(windows)] @@ -1943,6 +1989,7 @@ mod tests { })), mode: AsyncMutex::new(CaptureMode::Proxy), capture_active: Arc::new(AtomicBool::new(true)), + proxy_session_event_count: Arc::new(AtomicU64::new(0)), next_proxy_generation: AtomicU64::new(8), proxy_recovery_checked: AtomicBool::new(true), sidecar_bundle: None, diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 477c79e..e9772c9 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -355,7 +355,18 @@ describe("request workbench", () => { }); vi.mocked(invoke).mockImplementation(async (command, args) => { if (command === "bootstrap_workspace") return structuredClone(workspace); - if (command === "set_capture_active") return Boolean(args?.active); + if (command === "set_capture_active") { + const next = structuredClone(workspace); + next.capture.active = Boolean(args?.active); + next.compatibility = { + ...next.compatibility, + code: "capture_paused", + status: "attention", + title: "Gateway capture paused", + action: "resume_capture", + }; + return next; + } throw new Error(`Unexpected command: ${command}`); }); @@ -441,6 +452,77 @@ describe("request workbench", () => { expect(screen.getByText("Healthy").parentElement).toHaveTextContent("Proxy"); }); + it("diagnoses an unobserved proxy session without claiming certificate pinning", async () => { + const user = userEvent.setup(); + window.__TAURI_INTERNALS__ = {}; + const proxy = structuredClone(fixture) as unknown as WorkspaceBootstrap; + proxy.capture = { + ...proxy.capture, + active: true, + canControl: true, + proxyAvailable: true, + mode: "proxy", + profile: "System-managed explicit TLS proxy", + endpoint: "http://127.0.0.1:43125", + certificateAuthority: { + state: "ready", + canManageTrust: true, + fingerprintSha256: "AA:BB:CC:DD", + subject: "mitmproxy", + validFromUnixMs: 1_577_836_800_000, + validUntilUnixMs: 4_070_908_800_000, + privateMaterial: "restricted", + trust: "trusted", + detail: null, + }, + }; + proxy.compatibility = { + code: "proxy_capture_unobserved", + status: "attention", + confidence: "low", + title: "No Proxy capture observed yet", + summary: "Send one request from the target application. If it succeeds there but remains absent here, the application may bypass the proxy or pin certificates; use Gateway capture instead.", + recommendedMode: "gateway", + action: "use_gateway", + steps: [ + { id: "proxy_bundle", status: "pass", label: "Verified Proxy bundle", detail: "Available" }, + { id: "proxy_runtime", status: "pass", label: "Proxy runtime", detail: "http://127.0.0.1:43125" }, + { id: "local_ca", status: "pass", label: "Local certificate authority", detail: "Ready · restricted private material" }, + { id: "system_trust", status: "pass", label: "System trust", detail: "trusted" }, + { id: "session_capture", status: "pending", label: "Current Proxy session", detail: "No capture event observed yet" }, + ], + }; + const gateway = structuredClone(fixture) as unknown as WorkspaceBootstrap; + gateway.capture = { + ...gateway.capture, + active: true, + canControl: true, + proxyAvailable: true, + mode: "gateway", + profile: "OpenAI-compatible local gateway", + endpoint: "http://127.0.0.1:8787", + }; + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "bootstrap_workspace") return structuredClone(proxy); + if (command === "set_capture_mode" && args?.mode === "gateway") { + return structuredClone(gateway); + } + throw new Error(`Unexpected command: ${command}`); + }); + + render(); + await user.click(await screen.findByRole("button", { name: "Settings" })); + const dialog = screen.getByRole("dialog", { name: "Settings & diagnostics" }); + expect(within(dialog).getByText("No Proxy capture observed yet")).toBeInTheDocument(); + expect(within(dialog).getByText("low confidence")).toBeInTheDocument(); + expect(within(dialog).getByText(/may bypass the proxy or pin certificates/)).toBeInTheDocument(); + expect(within(dialog).getByText("No capture event observed yet")).toBeInTheDocument(); + + await user.click(within(dialog).getByRole("button", { name: "Use Gateway" })); + expect(invoke).toHaveBeenCalledWith("set_capture_mode", { mode: "gateway" }); + expect(await screen.findByText("OpenAI-compatible local gateway")).toBeInTheDocument(); + }); + it("keeps residual certificate details visible when the proxy bundle is unavailable", async () => { window.__TAURI_INTERNALS__ = {}; const workspace = structuredClone(fixture) as unknown as WorkspaceBootstrap; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 787e97f..a23a64e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -253,13 +253,10 @@ export function App() { const toggleCapture = () => { const next = !captureActive; persistCaptureActive(next) - .then((active) => { - setCaptureActive(active); + .then((nextWorkspace) => { + setCaptureActive(nextWorkspace.capture.active); setCaptureError(""); - setWorkspace((current) => current && { - ...current, - capture: { ...current.capture, active }, - }); + setWorkspace(nextWorkspace); }) .catch((error: unknown) => { setCaptureError(error instanceof Error ? error.message : "Capture state could not change."); diff --git a/apps/desktop/src/SettingsDialog.tsx b/apps/desktop/src/SettingsDialog.tsx index a708057..d9f9f90 100644 --- a/apps/desktop/src/SettingsDialog.tsx +++ b/apps/desktop/src/SettingsDialog.tsx @@ -44,6 +44,7 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr const [supportSavedPath, setSupportSavedPath] = useState(""); const closeRef = useRef(null); const certificate = workspace.capture.certificateAuthority; + const compatibility = workspace.compatibility; const canTrust = certificate.canManageTrust && certificate.state === "ready" && certificate.trust === "not_trusted"; @@ -133,7 +134,30 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr
Local certificate authority{certificateLabel(certificate)}{certificateError || certificate.detail || certificate.fingerprintSha256 || "No local CA material"}
- {(canTrust || canRemoveTrust) && } + {(canTrust || canRemoveTrust) && compatibility.action !== "trust_certificate" && } +
+
+
+
+ Compatibility + {compatibility.title} +
+ {compatibility.confidence} confidence +
+

{compatibility.summary}

+
    + {compatibility.steps.map((step) =>
  • + {step.status === "pass" ? : } +
    {step.label}{step.detail}
    +
  • )} +
+ {compatibility.action !== "none" && onCertificateTrustChange(true)} + onGateway={() => onModeChange("gateway")} + />}
Safe recoveryReturn traffic to the local GatewayStops the explicit proxy runtime and restores managed system proxy settings.
@@ -151,6 +175,7 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr + @@ -162,6 +187,22 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr ); } +function CompatibilityCommand({ action, disabled, onResume, onTrust, onGateway }: { + action: WorkspaceBootstrap["compatibility"]["action"]; + disabled: boolean; + onResume: () => void; + onTrust: () => void; + onGateway: () => void; +}) { + if (action === "resume_capture") { + return ; + } + if (action === "trust_certificate") { + return ; + } + return ; +} + function DiagnosticRow({ label, healthy, detail }: { label: string; healthy: boolean; detail: string }) { return {healthy ? : }{label}{healthy ? "Ready" : "Attention"}{detail}; } diff --git a/apps/desktop/src/data/workspace.json b/apps/desktop/src/data/workspace.json index 726840c..0551c1e 100644 --- a/apps/desktop/src/data/workspace.json +++ b/apps/desktop/src/data/workspace.json @@ -22,6 +22,20 @@ "detail": null } }, + "compatibility": { + "code": "gateway_ready", + "status": "ready", + "confidence": "high", + "title": "Gateway capture ready", + "summary": "Route the target client to the local Gateway endpoint. Local certificate trust is not required.", + "recommendedMode": "gateway", + "action": "none", + "steps": [ + { "id": "gateway_runtime", "status": "pass", "label": "Local Gateway", "detail": "127.0.0.1:3210" }, + { "id": "certificate_interception", "status": "pass", "label": "Certificate interception", "detail": "Not required in Gateway mode" }, + { "id": "recording", "status": "pass", "label": "Recording", "detail": "Active" } + ] + }, "requests": [ { "id": "req_anthropic_tools", diff --git a/apps/desktop/src/generated/desktop-api/CaptureCompatibility.ts b/apps/desktop/src/generated/desktop-api/CaptureCompatibility.ts new file mode 100644 index 0000000..edad99a --- /dev/null +++ b/apps/desktop/src/generated/desktop-api/CaptureCompatibility.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CaptureCompatibilityCode } from "./CaptureCompatibilityCode"; +import type { CaptureCompatibilityStatus } from "./CaptureCompatibilityStatus"; +import type { CaptureMode } from "./CaptureMode"; +import type { CompatibilityAction } from "./CompatibilityAction"; +import type { CompatibilityConfidence } from "./CompatibilityConfidence"; +import type { CompatibilityStep } from "./CompatibilityStep"; + +export type CaptureCompatibility = { code: CaptureCompatibilityCode, status: CaptureCompatibilityStatus, confidence: CompatibilityConfidence, title: string, summary: string, recommendedMode: CaptureMode, action: CompatibilityAction, steps: Array, }; diff --git a/apps/desktop/src/generated/desktop-api/CaptureCompatibilityCode.ts b/apps/desktop/src/generated/desktop-api/CaptureCompatibilityCode.ts new file mode 100644 index 0000000..e4b5a7b --- /dev/null +++ b/apps/desktop/src/generated/desktop-api/CaptureCompatibilityCode.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 CaptureCompatibilityCode = "gateway_ready" | "gateway_unavailable" | "proxy_bundle_unavailable" | "proxy_unavailable" | "certificate_missing" | "certificate_invalid" | "certificate_trust_required" | "capture_paused" | "proxy_capture_unobserved" | "proxy_capture_observed"; diff --git a/apps/desktop/src/generated/desktop-api/CaptureCompatibilityStatus.ts b/apps/desktop/src/generated/desktop-api/CaptureCompatibilityStatus.ts new file mode 100644 index 0000000..b37b439 --- /dev/null +++ b/apps/desktop/src/generated/desktop-api/CaptureCompatibilityStatus.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 CaptureCompatibilityStatus = "ready" | "attention" | "blocked"; diff --git a/apps/desktop/src/generated/desktop-api/CompatibilityAction.ts b/apps/desktop/src/generated/desktop-api/CompatibilityAction.ts new file mode 100644 index 0000000..4042d85 --- /dev/null +++ b/apps/desktop/src/generated/desktop-api/CompatibilityAction.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 CompatibilityAction = "none" | "resume_capture" | "trust_certificate" | "use_gateway"; diff --git a/apps/desktop/src/generated/desktop-api/CompatibilityConfidence.ts b/apps/desktop/src/generated/desktop-api/CompatibilityConfidence.ts new file mode 100644 index 0000000..15a9765 --- /dev/null +++ b/apps/desktop/src/generated/desktop-api/CompatibilityConfidence.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 CompatibilityConfidence = "high" | "low"; diff --git a/apps/desktop/src/generated/desktop-api/CompatibilityStep.ts b/apps/desktop/src/generated/desktop-api/CompatibilityStep.ts new file mode 100644 index 0000000..bdf179b --- /dev/null +++ b/apps/desktop/src/generated/desktop-api/CompatibilityStep.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CompatibilityStepStatus } from "./CompatibilityStepStatus"; + +export type CompatibilityStep = { id: string, status: CompatibilityStepStatus, label: string, detail: string, }; diff --git a/apps/desktop/src/generated/desktop-api/CompatibilityStepStatus.ts b/apps/desktop/src/generated/desktop-api/CompatibilityStepStatus.ts new file mode 100644 index 0000000..ba87f1b --- /dev/null +++ b/apps/desktop/src/generated/desktop-api/CompatibilityStepStatus.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 CompatibilityStepStatus = "pass" | "attention" | "blocked" | "pending"; diff --git a/apps/desktop/src/generated/desktop-api/WorkspaceBootstrap.ts b/apps/desktop/src/generated/desktop-api/WorkspaceBootstrap.ts index fb24bd6..7844285 100644 --- a/apps/desktop/src/generated/desktop-api/WorkspaceBootstrap.ts +++ b/apps/desktop/src/generated/desktop-api/WorkspaceBootstrap.ts @@ -1,6 +1,7 @@ // 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 { CaptureState } from "./CaptureState"; import type { CapturedRequest } from "./CapturedRequest"; import type { WorkspaceSource } from "./WorkspaceSource"; -export type WorkspaceBootstrap = { apiVersion: string, source: WorkspaceSource, capture: CaptureState, requests: Array, }; +export type WorkspaceBootstrap = { apiVersion: string, source: WorkspaceSource, capture: CaptureState, compatibility: CaptureCompatibility, requests: Array, }; diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 3a55195..ece1e9e 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -297,6 +297,24 @@ kbd { min-width: 18px; padding: 1px 5px; border: 1px solid var(--border); border .settings-band > div:first-child > span { color: var(--text-tertiary); font-size: 9px; font-weight: 700; text-transform: uppercase; } .settings-band > div:first-child > strong { font-size: 13px; } .settings-band > div:first-child > small { max-width: 480px; overflow-wrap: anywhere; color: var(--text-secondary); font-size: 10px; line-height: 1.45; } +.compatibility-diagnostic { padding: 18px 22px; border-bottom: 1px solid var(--border); box-shadow: inset 3px 0 var(--amber); } +.compatibility-diagnostic.compatibility-ready { box-shadow: inset 3px 0 var(--accent); } +.compatibility-diagnostic.compatibility-blocked { box-shadow: inset 3px 0 var(--danger); } +.compatibility-diagnostic > header { display: flex; align-items: start; justify-content: space-between; gap: 16px; } +.compatibility-diagnostic > header > div { display: grid; gap: 4px; } +.compatibility-diagnostic > header > div > span { color: var(--text-tertiary); font-size: 9px; font-weight: 700; text-transform: uppercase; } +.compatibility-diagnostic > header strong { font-size: 13px; } +.compatibility-confidence { flex: 0 0 auto; color: var(--text-tertiary); font: 9px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; } +.compatibility-diagnostic > p { max-width: 650px; margin: 8px 0 14px; color: var(--text-secondary); font-size: 10px; line-height: 1.55; } +.compatibility-diagnostic > ul { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px 20px; margin: 0; padding: 0; list-style: none; } +.compatibility-diagnostic li { min-width: 0; display: grid; grid-template-columns: 15px minmax(0, 1fr); align-items: start; gap: 7px; } +.compatibility-diagnostic li svg { margin-top: 1px; color: var(--amber); } +.compatibility-diagnostic li.compatibility-step-pass svg { color: var(--accent); } +.compatibility-diagnostic li.compatibility-step-blocked svg { color: var(--danger); } +.compatibility-diagnostic li > div { min-width: 0; display: grid; gap: 2px; } +.compatibility-diagnostic li strong { color: var(--text-secondary); font-size: 10px; } +.compatibility-diagnostic li span { overflow-wrap: anywhere; color: var(--text-tertiary); font: 9px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; } +.compatibility-command { margin-top: 15px; } .settings-mode { width: 220px; } .settings-command { min-width: 112px; height: 31px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 11px; border: 1px solid var(--border-strong); border-radius: 5px; background: var(--surface); color: var(--text-secondary); cursor: pointer; } .settings-command:hover { color: var(--text); background: var(--surface-subtle); } @@ -328,6 +346,10 @@ kbd { min-width: 18px; padding: 1px 5px; border: 1px solid var(--border); border .settings-dialog { width: calc(100vw - 24px); height: calc(100vh - 24px); } } +@media (max-width: 620px) { + .compatibility-diagnostic > ul { grid-template-columns: minmax(0, 1fr); } +} + @media (prefers-reduced-motion: no-preference) { .request-row, .icon-button, .segmented-control button { transition: background-color 120ms ease, color 120ms ease; } } diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 798bc34..db36fc1 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -3,6 +3,9 @@ export type { AnatomySection } from "./generated/desktop-api/AnatomySection"; export type { ApplicationAttributionSource } from "./generated/desktop-api/ApplicationAttributionSource"; export type { ApplicationConfidence } from "./generated/desktop-api/ApplicationConfidence"; export type { CapturedRequest } from "./generated/desktop-api/CapturedRequest"; +export type { CaptureCompatibility } from "./generated/desktop-api/CaptureCompatibility"; +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 { CaptureState } from "./generated/desktop-api/CaptureState"; export type { CaptureStatus } from "./generated/desktop-api/CaptureStatus"; @@ -10,6 +13,10 @@ export type { CertificateAuthority } from "./generated/desktop-api/CertificateAu export type { CertificateAuthorityState } from "./generated/desktop-api/CertificateAuthorityState"; export type { CertificatePrivateMaterial } from "./generated/desktop-api/CertificatePrivateMaterial"; export type { CertificateTrust } from "./generated/desktop-api/CertificateTrust"; +export type { CompatibilityAction } from "./generated/desktop-api/CompatibilityAction"; +export type { CompatibilityConfidence } from "./generated/desktop-api/CompatibilityConfidence"; +export type { CompatibilityStep } from "./generated/desktop-api/CompatibilityStep"; +export type { CompatibilityStepStatus } from "./generated/desktop-api/CompatibilityStepStatus"; export type { EvidenceLevel } from "./generated/desktop-api/EvidenceLevel"; export type { EvidenceLocator } from "./generated/desktop-api/EvidenceLocator"; export type { ExportPreview } from "./generated/desktop-api/ExportPreview"; diff --git a/apps/desktop/src/workspace.ts b/apps/desktop/src/workspace.ts index 01a5c1d..e92a705 100644 --- a/apps/desktop/src/workspace.ts +++ b/apps/desktop/src/workspace.ts @@ -42,11 +42,29 @@ export async function searchWorkspace(query: string): Promise .filter((request) => requestSearchText(request).includes(normalized)); } -export async function setCaptureActive(active: boolean): Promise { +export async function setCaptureActive(active: boolean): Promise { if (window.__TAURI_INTERNALS__) { - return invoke("set_capture_active", { active }); + return invoke("set_capture_active", { active }); } - return active; + const workspace = structuredClone(fixture) as unknown as WorkspaceBootstrap; + workspace.capture.active = active; + workspace.compatibility = active + ? structuredClone((fixture as unknown as WorkspaceBootstrap).compatibility) + : { + code: "capture_paused", + status: "attention", + confidence: "high", + title: "Gateway capture paused", + summary: "Traffic can still be forwarded, but new requests are not being recorded.", + recommendedMode: "gateway", + action: "resume_capture", + steps: [ + { id: "gateway_runtime", status: "pass", label: "Local Gateway", detail: workspace.capture.endpoint }, + { id: "certificate_interception", status: "pass", label: "Certificate interception", detail: "Not required in Gateway mode" }, + { id: "recording", status: "attention", label: "Recording", detail: "Paused" }, + ], + }; + return workspace; } export async function setCaptureMode(mode: CaptureMode): Promise { @@ -303,6 +321,7 @@ async function fixtureSupportBundlePreview( endpointConnected: workspace.capture.endpoint !== "Not connected", proxyBundle: workspace.capture.proxyAvailable, }, + compatibility: workspace.compatibility, runtimeIssue: scannedIssue.value, diagnosticEvents: [], }, diff --git a/crates/desktop-api/src/compatibility.rs b/crates/desktop-api/src/compatibility.rs new file mode 100644 index 0000000..8c33367 --- /dev/null +++ b/crates/desktop-api/src/compatibility.rs @@ -0,0 +1,403 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{ + CaptureMode, CaptureState, CertificateAuthorityState, CertificatePrivateMaterial, + CertificateTrust, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CaptureCompatibility { + pub code: CaptureCompatibilityCode, + pub status: CaptureCompatibilityStatus, + pub confidence: CompatibilityConfidence, + pub title: String, + pub summary: String, + pub recommended_mode: CaptureMode, + pub action: CompatibilityAction, + pub steps: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum CaptureCompatibilityCode { + GatewayReady, + GatewayUnavailable, + ProxyBundleUnavailable, + ProxyUnavailable, + CertificateMissing, + CertificateInvalid, + CertificateTrustRequired, + CapturePaused, + ProxyCaptureUnobserved, + ProxyCaptureObserved, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum CaptureCompatibilityStatus { + Ready, + Attention, + Blocked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum CompatibilityConfidence { + High, + Low, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum CompatibilityAction { + None, + ResumeCapture, + TrustCertificate, + UseGateway, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CompatibilityStep { + pub id: String, + pub status: CompatibilityStepStatus, + pub label: String, + pub detail: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum CompatibilityStepStatus { + Pass, + Attention, + Blocked, + Pending, +} + +#[must_use] +pub fn diagnose_capture_compatibility( + capture: &CaptureState, + proxy_session_event_count: u64, +) -> CaptureCompatibility { + let steps = compatibility_steps(capture, proxy_session_event_count); + let summary = diagnostic_summary(capture, proxy_session_event_count); + CaptureCompatibility { + code: summary.code, + status: summary.status, + confidence: summary.confidence, + title: summary.title.to_owned(), + summary: summary.detail.to_owned(), + recommended_mode: match summary.action { + CompatibilityAction::UseGateway => CaptureMode::Gateway, + _ => capture.mode, + }, + action: summary.action, + steps, + } +} + +struct DiagnosticSummary { + code: CaptureCompatibilityCode, + status: CaptureCompatibilityStatus, + confidence: CompatibilityConfidence, + title: &'static str, + detail: &'static str, + action: CompatibilityAction, +} + +fn diagnostic_summary(capture: &CaptureState, proxy_session_event_count: u64) -> DiagnosticSummary { + if capture.mode == CaptureMode::Gateway { + return gateway_summary(capture); + } + if !capture.proxy_available { + return summary( + CaptureCompatibilityCode::ProxyBundleUnavailable, + CaptureCompatibilityStatus::Blocked, + CompatibilityConfidence::High, + "Explicit proxy unavailable", + "The verified Proxy bundle is missing or incompatible. Continue with Gateway capture.", + CompatibilityAction::UseGateway, + ); + } + if capture.endpoint == "Not connected" { + return summary( + CaptureCompatibilityCode::ProxyUnavailable, + CaptureCompatibilityStatus::Blocked, + CompatibilityConfidence::High, + "Explicit proxy unavailable", + "The Proxy runtime did not expose a usable loopback endpoint. Return to Gateway capture.", + CompatibilityAction::UseGateway, + ); + } + if capture.certificate_authority.state == CertificateAuthorityState::Invalid + || capture.certificate_authority.private_material == CertificatePrivateMaterial::Insecure + { + return summary( + CaptureCompatibilityCode::CertificateInvalid, + CaptureCompatibilityStatus::Blocked, + CompatibilityConfidence::High, + "Local CA is invalid", + "The local certificate authority cannot be used safely. Return to Gateway until it is repaired.", + CompatibilityAction::UseGateway, + ); + } + if capture.certificate_authority.state == CertificateAuthorityState::Missing { + return summary( + CaptureCompatibilityCode::CertificateMissing, + CaptureCompatibilityStatus::Attention, + CompatibilityConfidence::High, + "Local CA is not ready", + "The Proxy runtime has not produced a usable local certificate authority. Gateway capture remains available.", + CompatibilityAction::UseGateway, + ); + } + if capture.certificate_authority.trust != CertificateTrust::Trusted { + return summary( + CaptureCompatibilityCode::CertificateTrustRequired, + CaptureCompatibilityStatus::Attention, + CompatibilityConfidence::High, + "System trust is required", + "Trust the verified local CA before expecting HTTPS applications to use Proxy capture.", + if capture.certificate_authority.can_manage_trust { + CompatibilityAction::TrustCertificate + } else { + CompatibilityAction::UseGateway + }, + ); + } + if !capture.active { + return summary( + CaptureCompatibilityCode::CapturePaused, + CaptureCompatibilityStatus::Attention, + CompatibilityConfidence::High, + "Proxy capture paused", + "The Proxy runtime is available, but new requests are not being recorded.", + CompatibilityAction::ResumeCapture, + ); + } + if proxy_session_event_count == 0 { + return summary( + CaptureCompatibilityCode::ProxyCaptureUnobserved, + CaptureCompatibilityStatus::Attention, + CompatibilityConfidence::Low, + "No Proxy capture observed yet", + "Send one request from the target application. If it succeeds there but remains absent here, the application may bypass the proxy or pin certificates; use Gateway capture instead.", + CompatibilityAction::UseGateway, + ); + } + summary( + CaptureCompatibilityCode::ProxyCaptureObserved, + CaptureCompatibilityStatus::Ready, + CompatibilityConfidence::High, + "Proxy capture observed", + "This Proxy session has delivered a sanitized capture event to the encrypted workspace.", + CompatibilityAction::None, + ) +} + +fn gateway_summary(capture: &CaptureState) -> DiagnosticSummary { + if capture.endpoint == "Not connected" { + return summary( + CaptureCompatibilityCode::GatewayUnavailable, + CaptureCompatibilityStatus::Blocked, + CompatibilityConfidence::High, + "Gateway unavailable", + "The local Gateway endpoint is not available. Capture cannot start until the runtime is restored.", + CompatibilityAction::None, + ); + } + if !capture.active { + return summary( + CaptureCompatibilityCode::CapturePaused, + CaptureCompatibilityStatus::Attention, + CompatibilityConfidence::High, + "Gateway capture paused", + "Traffic can still be forwarded, but new requests are not being recorded.", + CompatibilityAction::ResumeCapture, + ); + } + summary( + CaptureCompatibilityCode::GatewayReady, + CaptureCompatibilityStatus::Ready, + CompatibilityConfidence::High, + "Gateway capture ready", + "Route the target client to the local Gateway endpoint. Local certificate trust is not required.", + CompatibilityAction::None, + ) +} + +const fn summary( + code: CaptureCompatibilityCode, + status: CaptureCompatibilityStatus, + confidence: CompatibilityConfidence, + title: &'static str, + detail: &'static str, + action: CompatibilityAction, +) -> DiagnosticSummary { + DiagnosticSummary { + code, + status, + confidence, + title, + detail, + action, + } +} + +fn compatibility_steps( + capture: &CaptureState, + proxy_session_event_count: u64, +) -> Vec { + let runtime_ready = capture.endpoint != "Not connected"; + if capture.mode == CaptureMode::Gateway { + return vec![ + compatibility_step( + "gateway_runtime", + if runtime_ready { + CompatibilityStepStatus::Pass + } else { + CompatibilityStepStatus::Blocked + }, + "Local Gateway", + if runtime_ready { + capture.endpoint.clone() + } else { + "No loopback endpoint".to_owned() + }, + ), + compatibility_step( + "certificate_interception", + CompatibilityStepStatus::Pass, + "Certificate interception", + "Not required in Gateway mode", + ), + compatibility_step( + "recording", + if capture.active { + CompatibilityStepStatus::Pass + } else { + CompatibilityStepStatus::Attention + }, + "Recording", + if capture.active { "Active" } else { "Paused" }, + ), + ]; + } + + vec![ + compatibility_step( + "proxy_bundle", + if capture.proxy_available { + CompatibilityStepStatus::Pass + } else { + CompatibilityStepStatus::Blocked + }, + "Verified Proxy bundle", + if capture.proxy_available { + "Available" + } else { + "Unavailable" + }, + ), + compatibility_step( + "proxy_runtime", + if runtime_ready { + CompatibilityStepStatus::Pass + } else { + CompatibilityStepStatus::Blocked + }, + "Proxy runtime", + if runtime_ready { + capture.endpoint.clone() + } else { + "No loopback endpoint".to_owned() + }, + ), + compatibility_step( + "local_ca", + certificate_step_status(capture), + "Local certificate authority", + match capture.certificate_authority.state { + CertificateAuthorityState::Missing => "Not generated".to_owned(), + CertificateAuthorityState::Invalid => "Invalid".to_owned(), + CertificateAuthorityState::Ready => format!( + "Ready · {} private material", + private_material_label(capture.certificate_authority.private_material) + ), + }, + ), + compatibility_step( + "system_trust", + if capture.certificate_authority.trust == CertificateTrust::Trusted { + CompatibilityStepStatus::Pass + } else { + CompatibilityStepStatus::Attention + }, + "System trust", + certificate_trust_label(capture.certificate_authority.trust), + ), + compatibility_step( + "session_capture", + if proxy_session_event_count > 0 { + CompatibilityStepStatus::Pass + } else { + CompatibilityStepStatus::Pending + }, + "Current Proxy session", + if proxy_session_event_count > 0 { + format!("{proxy_session_event_count} sanitized capture events observed") + } else { + "No capture event observed yet".to_owned() + }, + ), + ] +} + +fn certificate_step_status(capture: &CaptureState) -> CompatibilityStepStatus { + match capture.certificate_authority.state { + CertificateAuthorityState::Ready + if capture.certificate_authority.private_material + != CertificatePrivateMaterial::Insecure => + { + CompatibilityStepStatus::Pass + } + CertificateAuthorityState::Invalid => CompatibilityStepStatus::Blocked, + CertificateAuthorityState::Missing => CompatibilityStepStatus::Attention, + CertificateAuthorityState::Ready => CompatibilityStepStatus::Blocked, + } +} + +fn compatibility_step( + id: &str, + status: CompatibilityStepStatus, + label: &str, + detail: impl Into, +) -> CompatibilityStep { + CompatibilityStep { + id: id.to_owned(), + status, + label: label.to_owned(), + detail: detail.into(), + } +} + +const fn private_material_label(material: CertificatePrivateMaterial) -> &'static str { + match material { + CertificatePrivateMaterial::Missing => "missing", + CertificatePrivateMaterial::Restricted => "restricted", + CertificatePrivateMaterial::Unchecked => "unchecked", + CertificatePrivateMaterial::Insecure => "insecure", + } +} + +const fn certificate_trust_label(trust: CertificateTrust) -> &'static str { + match trust { + CertificateTrust::Unchecked => "unchecked", + CertificateTrust::Trusted => "trusted", + CertificateTrust::NotTrusted => "not trusted", + CertificateTrust::Unsupported => "unsupported", + } +} diff --git a/crates/desktop-api/src/export.rs b/crates/desktop-api/src/export.rs index 8e5b9da..df0daf9 100644 --- a/crates/desktop-api/src/export.rs +++ b/crates/desktop-api/src/export.rs @@ -183,6 +183,7 @@ pub fn build_support_bundle_preview( "endpointConnected": workspace.capture.endpoint != "Not connected", "proxyBundle": workspace.capture.proxy_available, }, + "compatibility": &workspace.compatibility, "runtimeIssue": runtime_issue, "diagnosticEvents": diagnostic_events, }, @@ -460,7 +461,7 @@ mod tests { use crate::{ AnatomyItem, AnatomySection, ApplicationAttributionSource, ApplicationConfidence, CaptureMode, CaptureState, CaptureStatus, CertificateAuthority, EvidenceLevel, - RequestDetail, TimelineEvent, WorkspaceBootstrap, + RequestDetail, TimelineEvent, WorkspaceBootstrap, diagnose_capture_compatibility, }; fn request() -> CapturedRequest { @@ -644,20 +645,22 @@ mod tests { #[test] fn support_bundles_exclude_requests_and_scan_runtime_issues() { + let capture = CaptureState { + active: true, + can_control: true, + proxy_available: false, + mode: CaptureMode::Gateway, + profile: "Local gateway".to_owned(), + endpoint: "127.0.0.1:8787".to_owned(), + storage: "SQLCipher 4 / WAL".to_owned(), + request_count: 1, + certificate_authority: CertificateAuthority::missing(), + }; let workspace = WorkspaceBootstrap { api_version: DESKTOP_API_VERSION.to_owned(), source: WorkspaceSource::EncryptedLocal, - capture: CaptureState { - active: true, - can_control: true, - proxy_available: false, - mode: CaptureMode::Gateway, - profile: "Local gateway".to_owned(), - endpoint: "127.0.0.1:8787".to_owned(), - storage: "SQLCipher 4 / WAL".to_owned(), - request_count: 1, - certificate_authority: CertificateAuthority::missing(), - }, + compatibility: diagnose_capture_compatibility(&capture, 0), + capture, requests: vec![request()], }; @@ -678,6 +681,10 @@ mod tests { assert_eq!(document["diagnostics"]["capture"]["requestCount"], 1); assert_eq!(document["privacy"]["logsIncluded"], true); assert_eq!(document["privacy"]["logDetailsIncluded"], false); + assert_eq!( + document["diagnostics"]["compatibility"]["code"], + "gateway_ready" + ); assert_eq!( document["diagnostics"]["diagnosticEvents"][0]["code"], "gateway_serve_failed" diff --git a/crates/desktop-api/src/lib.rs b/crates/desktop-api/src/lib.rs index a6ed115..0fdb4bc 100644 --- a/crates/desktop-api/src/lib.rs +++ b/crates/desktop-api/src/lib.rs @@ -1,7 +1,14 @@ //! Versioned DTOs shared by the Tauri command layer and React workbench. +mod compatibility; mod export; +pub use compatibility::{ + CaptureCompatibility, CaptureCompatibilityCode, CaptureCompatibilityStatus, + CompatibilityAction, CompatibilityConfidence, CompatibilityStep, CompatibilityStepStatus, + diagnose_capture_compatibility, +}; + pub use export::{ DiagnosticEvent, EXPORT_FORMAT_VERSION, EXPORT_POLICY_VERSION, ExportPreview, ExportProfile, ExportReceipt, ExportRedaction, SUPPORT_BUNDLE_FORMAT_VERSION, SupportBundlePreview, @@ -32,6 +39,7 @@ pub struct WorkspaceBootstrap { pub api_version: String, pub source: WorkspaceSource, pub capture: CaptureState, + pub compatibility: CaptureCompatibility, pub requests: Vec, } @@ -283,20 +291,23 @@ pub fn load_workspace(store: &EncryptedStore) -> Result请求详情

关键流程

-
  1. 开始捕获:选择 Gateway 或代理,自动完成连通性检查。
  2. 检查请求:实时列表选中请求,Anatomy 默认展开关键内容。
  3. 比较:选择两个请求,先看结构差异,再定位 Raw 证据。
  4. 导出:选择安全等级,扫描敏感内容,预览后写出文件。
+
  1. 开始捕获:选择 Gateway 或代理,检查 bundle、端点、CA 与信任状态。
  2. 确认兼容:Proxy 会话出现首个捕获事件后标记 Ready;零事件只低置信度提示绕过代理或证书固定,并可切回 Gateway。
  3. 检查请求:实时列表选中请求,Anatomy 默认展开关键内容。
  4. 比较:选择两个请求,先看结构差异,再定位 Raw 证据。
  5. 导出:选择安全等级,扫描敏感内容,预览后写出文件。

设计原则

  • 捕获状态、范围和本地存储状态始终可见。
  • 推断、截断、未知和未采集不能只靠颜色区分。
  • 采用稳定三栏与可拖动面板,流式内容不改变布局。
  • 界面保持密集、克制,少用卡片和装饰。
  • 键盘可完成选择、比较、定位和导出。
diff --git a/docs/progress.html b/docs/progress.html index 0dc8bb9..faf62ac 100644 --- a/docs/progress.html +++ b/docs/progress.html @@ -58,11 +58,11 @@

3. 里程碑

4. 工作流进度

- - + + - +
工作流任务范围进度状态Owner本周重点
架构与工程基础ARC-001~004100%DoneCodex / TBD保持 schema、事件与 command 契约同步
Desktop / FrontendAPP-001~00692%In progressCodex / TBD采集首次捕获耗时并完成真实运行验收
Capture / NetworkCAP-001~00778%In progressCodex / TBD补齐进程 PID/pinning 兼容诊断与 macOS 特权代理 helper;生产签名随发布凭据补齐
Desktop / FrontendAPP-001~00694%In progressCodex / TBD采集首次捕获耗时并完成真实运行验收
Capture / NetworkCAP-001~00782%In progressCodex / TBD补齐真实进程 PID 归因与 macOS 特权代理 helper;生产签名随发布凭据补齐
Prompt / AdaptersPAR-001~007100%DoneCodex / TBD保持价格目录与 provider usage 映射可追溯
Data / SecurityDAT-001~002、SEC-001~00475%In progressCodex / TBD继续 OS 级 IPC ACL、WASI 权限、供应链控制与独立安全评审
Test / ReleaseTST-001~004、REL-001~00345%In progressCodex / TBD继续 TST-002 Proxy 取消/背压与支持处理流程
Test / ReleaseTST-001~004、REL-001~00348%In progressCodex / TBD继续 TST-002 Proxy 取消/背压与支持处理流程

5. 当前迭代:S5 / S6

@@ -77,14 +77,14 @@

5. 当前迭代:S5 / S6

APP-003实时 Capture 工作台Done100%Codex / TBD2026-07-15本地 Gateway、暂停/恢复、增量刷新及响应状态完成;1,000 条虚拟列表、筛选、键盘滚动与焦点保持通过验收 APP-004Request InspectorDone100%Codex / TBD2026-07-15Anatomy、Timeline、Raw 与元数据完成;JSON Pointer 和 SSE UTF-8 字节范围均可定位并高亮原始证据 APP-005Compare 与搜索Done100%Codex / TBD2026-07-17SQLCipher FTS 接入桌面搜索;双请求基准选择、消息/工具/参数结构 diff、文本 diff、交换、筛选与键盘取消均通过回归 - APP-006设置、诊断与首次连接In progress85%Codex / TBD2026-08-25Connection/Diagnostics、空工作区引导、Gateway/Proxy/录制/CA 控制、安全恢复及精确支持包预览/保存已完成;首次捕获 P50 待真实测量 + APP-006设置、诊断与首次连接In progress90%Codex / TBD2026-08-25Connection/Diagnostics、空工作区引导、Gateway/Proxy/录制/CA 控制、安全恢复、兼容诊断树及精确支持包预览/保存已完成;首次捕获 P50 待真实测量 CAP-001实现 Local AI GatewayDone100%Codex / TBD2026-07-15OpenAI-compatible 本地端点保持双向流式、取消传播、头清理与稳定 502 CAP-002实现请求/响应 tee 与背压Done100%Codex / TBD2026-07-15有界前缀、非阻塞队列、截断/取消/降级指标和暂停控制通过集成测试 CAP-003打包 mitmproxy sidecar 与最小 addonIn progress95%Codex / Release Owner TBD2026-08-25三平台 bundle、manifest、SBOM、原子安装、Tauri 资源、运行时校验、最小环境与 Windows Job Object/Unix 进程组清理完成;正式 Authenticode/Developer ID 制品待发布凭据 CAP-004显式代理与 TLS flowDone100%Codex / TBD2026-07-15桌面 sidecar、认证 IPC、模式与暂停控制、进程树清理、非目标 TLS 直通、gzip/brotli、SSE/NDJSON/JSON-seq 脱敏完成;真实 TLS+ALPN HTTP/2 客户端/服务端证明代理请求响应与直连基线一致 CAP-005证书生命周期In progress85%Codex / TBD2026-08-25跨平台 CA 元数据、私钥权限与系统信任可核验;Windows CurrentUser ROOT 与 macOS User domain 均支持严格校验后的幂等安装/卸载,卸载前自动退出 Proxy;Admin/System 只读,真实交互验收待完成 CAP-006系统代理快照与 watchdogIn progress80%Codex / TBD2026-08-25Windows 桌面 Proxy 模式已接入 CurrentUser WinINet 事务、私有 journal、同一签名可执行文件 watchdog、活 owner 防误恢复和启动修复;sidecar 独立退出会按运行时代际安全回退 Gateway 并恢复代理,macOS 特权 helper 待完成 - CAP-007进程/应用归因与兼容诊断In progress65%Codex / TBD2026-08-25显式 Gateway label、User-Agent 规则与捕获模式回退已形成版本化策略,桌面列表/Inspector/Raw/导出展示来源和置信度;真实 PID 归因与证书固定诊断树待完成 + CAP-007进程/应用归因与兼容诊断In progress85%Codex / TBD2026-08-25应用标签/User-Agent/捕获模式归因及版本化兼容诊断树已接入;Proxy 会话事件证明、低置信度 pinning/绕过提示、Gateway 回退和支持包证据完成,真实 PID 归因待完成 DAT-001SQLCipher、迁移、WAL 与 OS 凭据库Done100%Codex / TBD2026-07-15schema v2 可从 v0/v1 升级,响应类型、状态码和耗时可查询;备份恢复、DB/WAL canary 与 OS 凭据库均通过 DAT-002写入、查询、保留与磁盘压力Done100%Codex / TBD2026-07-15批量写入原子提交;默认保留 30 天/50,000 条并以 500 条事务清理;低于 256 MiB 或 SQLITE_FULL 时暂停记录但保持 Gateway 转发 SEC-001范围策略和凭据 scrubberDone100%Codex / Security Reviewer TBD2026-07-18请求与响应 header/JSON body 双层脱敏;canary 在 DB、WAL、诊断日志、临时文件和支持包中零命中 @@ -99,7 +99,7 @@

5. 当前迭代:S5 / S6

PAR-007token、成本和语义指纹Done100%Codex / TBD2026-07-17四厂商 reported usage 归一化、显式 estimated 估算、版本化价格匹配、未知价格留空与 BLAKE3-256 语义指纹完成 TST-001协议 fixture 与 golden testsDone100%Codex / TBD2026-07-17版本化能力矩阵覆盖 OpenAI、Anthropic、Gemini 与 Ollama 的请求、响应、流式、工具、多模态、错误和 Raw fallback;声明均由 fixture 与 golden 验证 TST-002Gateway/Proxy 集成测试In progress65%Codex / TBD2026-08-25认证 IPC、暂停丢弃、真实模式切换与进程树清理、压缩、流式脱敏、非目标 TLS、HTTP/2 基线一致性及 Windows CA 精确增删通过;真实 ROOT 往返需交互式 Windows 会话,Proxy 取消、背压与完整协议一致性待扩展 - REL-002诊断与支持包In progress65%Codex / Support Owner TBD2026-09-22版本化 JSON 支持包可预览、复制和保存;256 KiB code-only journal 与最近 100 条事件接入,排除 Prompt、Raw、请求标识和日志详情;支持处理流程待完成 + REL-002诊断与支持包In progress75%Codex / Support Owner TBD2026-09-22版本化 JSON 支持包可预览、复制和保存,包含不带请求标识的兼容诊断树;256 KiB code-only journal 与最近 100 条事件接入,排除 Prompt、Raw 和日志详情;支持处理流程待完成 SPIKE-001Gateway 流式透明转发验证Done100%Codex / TBD2026-07-14双向流式、取消传递、头清理与稳定 502 集成测试通过 SPIKE-002mitmproxy sidecar IPC/打包验证Done100%Codex / TBD2026-07-14凭据清理、IPC、打包与真实转发通过 SPIKE-003Windows/macOS 代理恢复验证Done100%Codex / TBD2026-07-14Windows / macOS 真实强杀恢复通过 @@ -122,7 +122,7 @@

6. 后续迭代承诺

S2CAP-001/002、PAR-002/003、APP-003DoneGateway、OpenAI 解析与千条实时工作台全部通过验收 S3PAR-004、APP-004、TST-001、DAT-002Done双厂商 Inspector、能力矩阵与数据生命周期全部通过验收 S4CAP-003~005、TST-002In progresssidecar bundle、桌面运行时、协议矩阵、跨平台 CA 状态及两平台用户级信任生命周期已实现;签名、Proxy 取消/背压与交互式验收待推进 - S5CAP-006/007、SEC-002/003、APP-006、TST-003In progressSEC-002 完成,IPC 抗阻塞与协议绑定、应用标签/User-Agent/低置信度回退已接入;继续 macOS helper、OS socket ACL、PID/pinning 诊断与其余故障注入 + S5CAP-006/007、SEC-002/003、APP-006、TST-003In progressSEC-002 完成,IPC 抗阻塞、应用归因和兼容诊断树已接入;继续 macOS helper、OS socket ACL、真实 PID 归因与其余故障注入 S6PAR-005~007、APP-005Done四厂商适配器、token/成本/指纹、全文搜索和结构/文本 Compare 全部完成 S7TST-004、性能、可访问性、诊断与保留Not started功能冻结 S8SEC-004、REL-001/002、安全评审In progresscode-only 诊断与支持包已接入;签名更新、安装回滚、支持流程与安全评审待推进 @@ -157,6 +157,7 @@

9. 质量指标

Timeline 原始证据定位SSE sequence 与 UTF-8 frame 字节范围一致流事件可回到精确原文Gateway 集成测试与浏览器验收 数据生命周期批量原子写入、30 天/50,000 条保留、256 MiB 磁盘保护通过压力下不损坏 DB 且网络转发不中断SQLCipher 集成测试与 Tauri 运行时测试 Sidecar bundle三平台制品、原子安装、Tauri 嵌入和最小环境子进程测试通过运行时复核全部 hash/contract;发布时强制有效平台签名CI bundle artifact、Rust runtime 与 release signature gate + Proxy 兼容诊断bundle/端点/CA/信任/会话事件可解释;零事件仅低置信度提示 pinning/绕过不误报已证实 pinning;可一键回退 GatewayDesktop API、Tauri、React 测试与 620 px 浏览器布局验收 关键 crash-free sessionN/A> 99.5%选择开启的产品遥测 diff --git a/docs/security.html b/docs/security.html index 01376e7..e3be0f4 100644 --- a/docs/security.html +++ b/docs/security.html @@ -27,7 +27,7 @@

关键控制

证书与代理边界

-
  • 仅在用户选择 HTTPS 代理时生成本地 CA。
  • UI 显示证书指纹、信任状态和卸载入口。
  • 应用使用证书固定时停止 MITM,并建议 Gateway 或浏览器适配器。
  • 停止、崩溃、升级和卸载都必须恢复原系统代理。
+
  • 仅在用户选择 HTTPS 代理时生成本地 CA。
  • UI 显示证书指纹、信任状态和卸载入口。
  • 诊断树只在 bundle、端点、CA 与信任均正常但当前会话无捕获时,低置信度提示代理绕过或证书固定;不尝试绕过 pinning。
  • 不兼容应用明确回退 Gateway;停止、崩溃、升级和卸载都必须恢复原系统代理。

导出等级

diff --git a/schemas/desktop-api/v0.1.schema.json b/schemas/desktop-api/v0.1.schema.json index 704491e..f70f7e8 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" }, + "compatibility": { + "$ref": "#/$defs/CaptureCompatibility" + }, "requests": { "type": "array", "items": { @@ -23,6 +26,7 @@ "apiVersion", "source", "capture", + "compatibility", "requests" ], "$defs": { @@ -111,6 +115,71 @@ "low" ] }, + "CaptureCompatibility": { + "type": "object", + "properties": { + "action": { + "$ref": "#/$defs/CompatibilityAction" + }, + "code": { + "$ref": "#/$defs/CaptureCompatibilityCode" + }, + "confidence": { + "$ref": "#/$defs/CompatibilityConfidence" + }, + "recommendedMode": { + "$ref": "#/$defs/CaptureMode" + }, + "status": { + "$ref": "#/$defs/CaptureCompatibilityStatus" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/CompatibilityStep" + } + }, + "summary": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "code", + "status", + "confidence", + "title", + "summary", + "recommendedMode", + "action", + "steps" + ] + }, + "CaptureCompatibilityCode": { + "type": "string", + "enum": [ + "gateway_ready", + "gateway_unavailable", + "proxy_bundle_unavailable", + "proxy_unavailable", + "certificate_missing", + "certificate_invalid", + "certificate_trust_required", + "capture_paused", + "proxy_capture_unobserved", + "proxy_capture_observed" + ] + }, + "CaptureCompatibilityStatus": { + "type": "string", + "enum": [ + "ready", + "attention", + "blocked" + ] + }, "CaptureMode": { "type": "string", "enum": [ @@ -372,6 +441,54 @@ "unsupported" ] }, + "CompatibilityAction": { + "type": "string", + "enum": [ + "none", + "resume_capture", + "trust_certificate", + "use_gateway" + ] + }, + "CompatibilityConfidence": { + "type": "string", + "enum": [ + "high", + "low" + ] + }, + "CompatibilityStep": { + "type": "object", + "properties": { + "detail": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/CompatibilityStepStatus" + } + }, + "required": [ + "id", + "status", + "label", + "detail" + ] + }, + "CompatibilityStepStatus": { + "type": "string", + "enum": [ + "pass", + "attention", + "blocked", + "pending" + ] + }, "EvidenceLevel": { "type": "string", "enum": [
等级内容用途