From 7f94904a2e979b795d814aabc6779bf18f8118ee Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 18 Jul 2026 00:17:54 +0800 Subject: [PATCH 1/2] feat: export filtered requests as a secure batch --- apps/desktop/src-tauri/src/lib.rs | 204 ++++++++++++++++++++++++++++-- apps/desktop/src/App.test.tsx | 79 ++++++++++++ apps/desktop/src/App.tsx | 40 ++++-- apps/desktop/src/workspace.ts | 127 +++++++++++++++++-- crates/desktop-api/src/export.rs | 91 +++++++++++-- crates/desktop-api/src/lib.rs | 24 ++-- docs/progress.html | 11 +- docs/threat-model.html | 2 +- 8 files changed, 519 insertions(+), 59 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2c2b29b..b948fc8 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -22,8 +22,9 @@ use codeischeap_core::{ }; use codeischeap_desktop_api::{ CaptureMode, CapturedRequest, CertificateAuthority, CertificateAuthorityState, - CertificatePrivateMaterial, CertificateTrust, ExportPreview, ExportProfile, ExportReceipt, - WorkspaceBootstrap, build_export_preview, load_workspace, search_requests, + CertificatePrivateMaterial, CertificateTrust, DesktopApiError, ExportPreview, ExportProfile, + ExportReceipt, WorkspaceBootstrap, build_batch_export_preview, build_export_preview, + load_request, load_workspace, search_requests, }; use codeischeap_gateway::{Gateway, GatewayCapture, GatewayCaptureEvent}; use codeischeap_proxy_recovery::recover_from_journal; @@ -59,6 +60,7 @@ const CAPTURE_UPDATED_EVENT: &str = "capture-updated"; const CAPTURE_RUNTIME_ERROR_EVENT: &str = "capture-runtime-error"; const MAX_PENDING_CAPTURE_OUTCOMES: usize = 256; const CAPTURES_PER_RETENTION_RUN: usize = 100; +const MAX_BATCH_EXPORT_REQUESTS: usize = 200; const SIDECAR_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); const SIDECAR_MONITOR_INTERVAL: Duration = Duration::from_millis(250); const PROXY_RECOVERY_JOURNAL_FILENAME: &str = "proxy-recovery.v0.1.json"; @@ -311,6 +313,22 @@ async fn preview_capture_export( .map_err(|error| format!("capture export preview task failed: {error}"))? } +#[tauri::command] +async fn preview_batch_capture_export( + capture_ids: Vec, + profile: ExportProfile, + app: AppHandle, + state: State<'_, DesktopState>, +) -> Result { + initialize_store(&app, &state.store)?; + let store = state.store.clone(); + tauri::async_runtime::spawn_blocking(move || { + build_batch_capture_export(&store, &capture_ids, profile, current_unix_ms()?) + }) + .await + .map_err(|error| format!("batch capture export preview task failed: {error}"))? +} + #[tauri::command] async fn write_capture_export( capture_id: String, @@ -339,6 +357,32 @@ async fn write_capture_export( .map_err(|error| format!("capture export write task failed: {error}"))? } +#[tauri::command] +async fn write_batch_capture_export( + capture_ids: Vec, + profile: ExportProfile, + exported_at_unix_ms: u64, + expected_sha256: String, + path: PathBuf, + app: AppHandle, + state: State<'_, DesktopState>, +) -> Result { + initialize_store(&app, &state.store)?; + let store = state.store.clone(); + tauri::async_runtime::spawn_blocking(move || { + write_batch_capture_export_file( + &store, + &capture_ids, + profile, + exported_at_unix_ms, + &expected_sha256, + &path, + ) + }) + .await + .map_err(|error| format!("batch capture export write task failed: {error}"))? +} + pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) @@ -392,11 +436,13 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ bootstrap_workspace, install_certificate_authority_trust, + preview_batch_capture_export, preview_capture_export, search_workspace, set_capture_active, set_capture_mode, uninstall_certificate_authority_trust, + write_batch_capture_export, write_capture_export ]) .run(tauri::generate_context!()) @@ -499,21 +545,87 @@ fn build_capture_export( .map_err(|error| format!("capture export could not be encoded: {error}")) } +fn build_batch_capture_export( + store: &SharedStore, + capture_ids: &[String], + profile: ExportProfile, + exported_at_unix_ms: u64, +) -> Result { + validate_batch_capture_ids(capture_ids)?; + let requests = export_requests(store, capture_ids)?; + build_batch_export_preview(&requests, profile, exported_at_unix_ms) + .map_err(|error| format!("batch capture export could not be encoded: {error}")) +} + fn export_request(store: &SharedStore, capture_id: &str) -> Result { + export_requests(store, &[capture_id.to_owned()]).map(|mut requests| requests.remove(0)) +} + +fn export_requests( + store: &SharedStore, + capture_ids: &[String], +) -> Result, String> { let store = store .lock() .map_err(|_| "encrypted workspace is temporarily unavailable".to_owned())?; - let workspace = load_workspace( - store - .as_ref() - .ok_or_else(|| "encrypted workspace has not initialized".to_owned())?, - ) - .map_err(|error| error.to_string())?; - workspace - .requests - .into_iter() - .find(|request| request.id == capture_id) - .ok_or_else(|| format!("capture {capture_id} is unavailable for export")) + let store = store + .as_ref() + .ok_or_else(|| "encrypted workspace has not initialized".to_owned())?; + capture_ids + .iter() + .map(|capture_id| { + load_request(store, capture_id).map_err(|error| match error { + DesktopApiError::MissingCapture(_) => { + format!("capture {capture_id} is unavailable for export") + } + other => other.to_string(), + }) + }) + .collect() +} + +fn validate_batch_capture_ids(capture_ids: &[String]) -> Result<(), String> { + if capture_ids.is_empty() { + return Err("batch export requires at least one capture".to_owned()); + } + if capture_ids.len() > MAX_BATCH_EXPORT_REQUESTS { + return Err(format!( + "batch export supports at most {MAX_BATCH_EXPORT_REQUESTS} captures" + )); + } + let mut unique = BTreeSet::new(); + for capture_id in capture_ids { + if capture_id.is_empty() { + return Err("batch export capture IDs cannot be empty".to_owned()); + } + if !unique.insert(capture_id) { + return Err(format!( + "capture {capture_id} appears more than once in the batch export" + )); + } + } + Ok(()) +} + +fn write_batch_capture_export_file( + store: &SharedStore, + capture_ids: &[String], + profile: ExportProfile, + exported_at_unix_ms: u64, + expected_sha256: &str, + path: &Path, +) -> Result { + let preview = + build_batch_capture_export(store, capture_ids, profile, exported_at_unix_ms)?; + if preview.content_sha256 != expected_sha256 { + return Err("a capture changed after preview; review the refreshed export".to_owned()); + } + write_export_file(path, preview.content.as_bytes())?; + Ok(ExportReceipt { + path: path.to_string_lossy().into_owned(), + byte_count: preview.byte_count, + redaction_count: u64::try_from(preview.redactions.len()).unwrap_or(u64::MAX), + }) } fn current_unix_ms() -> Result { @@ -1652,6 +1764,72 @@ mod tests { assert!(write_export_file(&directory.path().join("capture.txt"), b"{}").is_err()); } + #[test] + fn batch_export_rejects_invalid_capture_lists() { + assert!(validate_batch_capture_ids(&[]).is_err()); + assert!(validate_batch_capture_ids(&[String::new()]).is_err()); + assert!( + validate_batch_capture_ids(&["capture-1".to_owned(), "capture-1".to_owned()]) + .is_err() + ); + assert!( + validate_batch_capture_ids( + &(0..=MAX_BATCH_EXPORT_REQUESTS) + .map(|index| format!("capture-{index}")) + .collect::>() + ) + .is_err() + ); + } + + #[test] + fn batch_export_rejects_missing_captures() { + 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([0x66; 32]), + ) + .expect("encrypted store must open"), + ))); + + let error = build_batch_capture_export( + &store, + &["missing-capture".to_owned()], + ExportProfile::Minimal, + 10, + ) + .expect_err("missing capture must reject the entire batch"); + + assert!(error.contains("missing-capture")); + } + + #[test] + fn batch_export_hash_mismatch_does_not_create_a_file() { + let directory = tempdir().expect("temp directory must be created"); + let mut encrypted = EncryptedStore::open( + directory.path().join("captures.db"), + DatabaseKey::from_bytes([0x67; 32]), + ) + .expect("encrypted store must open"); + seed_demo_capture(&mut encrypted).expect("demo capture must seed"); + let store = Arc::new(Mutex::new(Some(encrypted))); + let path = directory.path().join("batch.json"); + + let error = write_batch_capture_export_file( + &store, + &[LEGACY_DEMO_CAPTURE_ID.to_owned()], + ExportProfile::Forensic, + 10, + &"0".repeat(64), + &path, + ) + .expect_err("stale preview hash must reject the write"); + + assert!(error.contains("changed after preview")); + assert!(!path.exists()); + } + #[test] fn disk_pressure_pauses_capture_without_stopping_the_gateway() { let (capture, receiver, _) = GatewayCapture::defaults(); diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index ac71c43..0b6f055 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -644,6 +644,85 @@ describe("request workbench", () => { expect(await within(dialog).findByText("Saved")).toBeInTheDocument(); }); + it("exports the current visible request set in stable order", async () => { + const user = userEvent.setup(); + window.__TAURI_INTERNALS__ = {}; + const workspace = structuredClone(fixture) as unknown as WorkspaceBootstrap; + const captureIds = workspace.requests + .filter((request) => request.provider === "OpenAI") + .map((request) => request.id); + const preview = (profile: ExportProfile): ExportPreview => ({ + profile, + suggestedFilename: `codeischeap-batch-${captureIds.length}-${profile}.json`, + content: `{"profile":"${profile}","requestCount":${captureIds.length}}\n`, + byteCount: 48, + contentSha256: "b".repeat(64), + exportedAtUnixMs: 1_700_000_000_100, + redactions: [], + policyVersion: "0.1", + }); + vi.mocked(save).mockResolvedValue("D:\\exports\\visible-requests.json"); + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "bootstrap_workspace") return structuredClone(workspace); + if (command === "preview_batch_capture_export") { + return preview((args?.profile as ExportProfile) ?? "minimal"); + } + if (command === "write_batch_capture_export") { + return { path: args?.path, byteCount: 48, redactionCount: 0 }; + } + throw new Error(`Unexpected command: ${command}`); + }); + + render(); + await user.selectOptions( + await screen.findByRole("combobox", { name: "Provider filter" }), + "OpenAI", + ); + expect(await screen.findByText(`${captureIds.length} visible`)).toBeInTheDocument(); + await user.click(await screen.findByRole("button", { name: "Export visible requests" })); + const dialog = await screen.findByRole("dialog", { name: "Export visible requests" }); + expect( + await within(dialog).findByText(new RegExp(`${captureIds.length} requests$`)), + ).toBeInTheDocument(); + expect(invoke).toHaveBeenCalledWith("preview_batch_capture_export", { + captureIds, + profile: "minimal", + }); + + await user.click(within(dialog).getByRole("button", { name: "reproducible" })); + expect(await within(dialog).findByText(/"profile":"reproducible"/)).toBeInTheDocument(); + await user.click(within(dialog).getByRole("button", { name: "Save JSON" })); + + expect(invoke).toHaveBeenCalledWith("write_batch_capture_export", { + captureIds, + profile: "reproducible", + exportedAtUnixMs: 1_700_000_000_100, + expectedSha256: "b".repeat(64), + path: "D:\\exports\\visible-requests.json", + }); + expect(await within(dialog).findByText("Saved")).toBeInTheDocument(); + }); + + it("keeps batch export preview failures visible", async () => { + const user = userEvent.setup(); + window.__TAURI_INTERNALS__ = {}; + const workspace = structuredClone(fixture) as unknown as WorkspaceBootstrap; + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "bootstrap_workspace") return structuredClone(workspace); + if (command === "preview_batch_capture_export") { + throw new Error("A request disappeared before export."); + } + throw new Error(`Unexpected command: ${command}`); + }); + + render(); + await user.click(await screen.findByRole("button", { name: "Export visible requests" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "A request disappeared before export.", + ); + }); + it("virtualizes one thousand requests and keeps filtered selection coherent", async () => { const user = userEvent.setup(); window.__TAURI_INTERNALS__ = {}; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 66dcaf7..15593af 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -46,7 +46,9 @@ import type { import { installCertificateAuthorityTrust, loadWorkspace, + previewBatchCaptureExport, previewCaptureExport, + saveBatchCaptureExport, saveCaptureExport, searchWorkspace, setCaptureActive as persistCaptureActive, @@ -88,7 +90,10 @@ export function App() { const [modeChanging, setModeChanging] = useState(false); const [certificateChanging, setCertificateChanging] = useState(false); const [certificateError, setCertificateError] = useState(""); - const [exportRequestId, setExportRequestId] = useState(null); + const [exportSelection, setExportSelection] = useState<{ + requests: CapturedRequest[]; + batch: boolean; + } | null>(null); const [settingsOpen, setSettingsOpen] = useState(false); const [compareBase, setCompareBase] = useState(null); const [compareMode, setCompareMode] = useState("structure"); @@ -213,7 +218,6 @@ export function App() { const selectedRequest = requests.find((request) => request.id === effectiveSelectedId) ?? selected; const knownRequests = [...(workspace?.requests ?? []), ...(searchedRequests ?? [])]; const compareTarget = compareBase && selectedRequest?.id !== compareBase.id ? selectedRequest : null; - const exportRequest = knownRequests.find((request) => request.id === exportRequestId) ?? null; useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -356,6 +360,7 @@ export function App() { onApplication={setApplication} onSelect={(id) => { setSelectedId(id); setTab("anatomy"); }} onCancelCompare={() => setCompareBase(null)} + onExportVisible={() => setExportSelection({ requests, batch: true })} /> setListWidth((width) => clamp(width + delta, 320, 560))} /> {compareBase && compareTarget ? setExportRequestId(selectedRequest.id)} + onExport={() => setExportSelection({ requests: [selectedRequest], batch: false })} onCompare={() => { setCompareBase(selectedRequest); setCompareMode("structure"); @@ -380,7 +385,11 @@ export function App() { comparing={compareBase?.id === selectedRequest.id} /> : } - {exportRequest && setExportRequestId(null)} />} + {exportSelection && setExportSelection(null)} + />} {settingsOpen && void; onSelect: (id: string) => void; onCancelCompare: () => void; + onExportVisible: () => void; }) { const listRef = useRef(null); const providers = ["All providers", ...new Set(allRequests.map((request) => request.provider))]; @@ -525,7 +535,7 @@ function RequestPane({ requests, allRequests, selectedId, query, provider, appli return (
-

Requests

{requests.length} visible
+

Requests

{requests.length} visible
{searchError && {searchError}} @@ -601,8 +611,10 @@ function Inspector({ request, tab, comparing, onTab, onExport, onCompare }: { re ); } -function ExportDialog({ request, onClose }: { request: CapturedRequest; onClose: () => void }) { +function ExportDialog({ requests, batch, onClose }: { requests: CapturedRequest[]; batch: boolean; onClose: () => void }) { const closeRef = useRef(null); + const captureIds = useMemo(() => requests.map((request) => request.id), [requests]); + const request = requests[0]; const [profile, setProfile] = useState("minimal"); const [preview, setPreview] = useState(null); const [error, setError] = useState(""); @@ -621,13 +633,16 @@ function ExportDialog({ request, onClose }: { request: CapturedRequest; onClose: setPreview(null); setError(""); setSavedPath(""); - previewCaptureExport(request.id, profile) + const operation = batch + ? previewBatchCaptureExport(captureIds, profile) + : previewCaptureExport(request.id, profile); + operation .then((value) => { if (!cancelled) setPreview(value); }) .catch((reason: unknown) => { if (!cancelled) setError(reason instanceof Error ? reason.message : "Export preview could not be generated."); }); return () => { cancelled = true; }; - }, [request.id, profile]); + }, [batch, captureIds, profile, request.id]); useEffect(() => { const closeOnEscape = (event: KeyboardEvent) => { @@ -641,7 +656,10 @@ function ExportDialog({ request, onClose }: { request: CapturedRequest; onClose: if (!preview || saving) return; setSaving(true); setError(""); - saveCaptureExport(request.id, preview) + const operation = batch + ? saveBatchCaptureExport(captureIds, preview) + : saveCaptureExport(request.id, preview); + operation .then((receipt) => { if (receipt) setSavedPath(receipt.path); }) .catch((reason: unknown) => { setError(reason instanceof Error ? reason.message : "Export file could not be written."); @@ -651,7 +669,7 @@ function ExportDialog({ request, onClose }: { request: CapturedRequest; onClose: return
{ if (event.target === event.currentTarget && !saving) onClose(); }}>
-
{request.provider} · {request.operation}

Export request

+
{batch ? `Batch · ${requests.length} requests` : `${request.provider} · ${request.operation}`}

{batch ? "Export visible requests" : "Export request"}

{(["minimal", "reproducible", "forensic"] as ExportProfile[]).map((value) => )} diff --git a/apps/desktop/src/workspace.ts b/apps/desktop/src/workspace.ts index 7ce0ded..5c8e4af 100644 --- a/apps/desktop/src/workspace.ts +++ b/apps/desktop/src/workspace.ts @@ -21,6 +21,8 @@ export interface CaptureRuntimeError { detail: string; } +const MAX_BATCH_EXPORT_REQUESTS = 200; + export async function loadWorkspace(): Promise { if (window.__TAURI_INTERNALS__) { return invoke("bootstrap_workspace"); @@ -112,6 +114,45 @@ export async function saveCaptureExport( }; } +export async function previewBatchCaptureExport( + captureIds: string[], + profile: ExportProfile, +): Promise { + validateBatchCaptureIds(captureIds); + if (window.__TAURI_INTERNALS__) { + return invoke("preview_batch_capture_export", { captureIds, profile }); + } + const workspace = fixture as unknown as WorkspaceBootstrap; + const requests = captureIds.map((captureId) => { + const request = workspace.requests.find((candidate) => candidate.id === captureId); + if (!request) throw new Error(`Capture ${captureId} is unavailable for export.`); + return request; + }); + return fixtureBatchExportPreview(requests, profile); +} + +export async function saveBatchCaptureExport( + captureIds: string[], + preview: ExportPreview, +): Promise { + validateBatchCaptureIds(captureIds); + if (window.__TAURI_INTERNALS__) { + const path = await save({ + defaultPath: preview.suggestedFilename, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (!path) return null; + return invoke("write_batch_capture_export", { + captureIds, + profile: preview.profile, + exportedAtUnixMs: preview.exportedAtUnixMs, + expectedSha256: preview.contentSha256, + path, + }); + } + return downloadExportPreview(preview); +} + export async function subscribeToCaptureEvents(handlers: { onUpdated: (event: CaptureUpdated) => void; onError: (event: CaptureRuntimeError) => void; @@ -143,6 +184,43 @@ async function fixtureExportPreview( profile: ExportProfile, ): Promise { const exportedAtUnixMs = Date.now(); + return finishFixtureExport( + { + formatVersion: "0.1", + policyVersion: "0.1", + desktopApiVersion: "0.1", + profile, + exportedAtUnixMs, + request: fixtureExportPayload(request, profile), + }, + profile, + exportedAtUnixMs, + `codeischeap-${request.id.replace(/[^a-z0-9_-]/gi, "_")}-${profile}.json`, + ); +} + +async function fixtureBatchExportPreview( + requests: CapturedRequest[], + profile: ExportProfile, +): Promise { + const exportedAtUnixMs = Date.now(); + return finishFixtureExport( + { + formatVersion: "0.1", + policyVersion: "0.1", + desktopApiVersion: "0.1", + profile, + exportedAtUnixMs, + requestCount: requests.length, + requests: requests.map((request) => fixtureExportPayload(request, profile)), + }, + profile, + exportedAtUnixMs, + `codeischeap-batch-${requests.length}-${profile}.json`, + ); +} + +function fixtureExportPayload(request: CapturedRequest, profile: ExportProfile): unknown { const metadata = { id: request.id, observedAtUnixMs: request.observedAtUnixMs, @@ -184,22 +262,23 @@ async function fixtureExportPreview( ...request.detail, }, }[profile]; - const content = `${JSON.stringify({ - formatVersion: "0.1", - policyVersion: "0.1", - desktopApiVersion: "0.1", - profile, - exportedAtUnixMs, - redactionCount: 0, - request: payload, - }, null, 2)}\n`; + return payload; +} + +async function finishFixtureExport( + document: Record, + profile: ExportProfile, + exportedAtUnixMs: number, + suggestedFilename: string, +): Promise { + const content = `${JSON.stringify({ ...document, redactionCount: 0 }, null, 2)}\n`; const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content)); const contentSha256 = Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); return { profile, - suggestedFilename: `codeischeap-${request.id.replace(/[^a-z0-9_-]/gi, "_")}-${profile}.json`, + suggestedFilename, content, byteCount: new TextEncoder().encode(content).length, contentSha256, @@ -209,6 +288,34 @@ async function fixtureExportPreview( }; } +function validateBatchCaptureIds(captureIds: string[]): void { + if (captureIds.length === 0) throw new Error("Batch export requires at least one capture."); + if (captureIds.length > MAX_BATCH_EXPORT_REQUESTS) { + throw new Error(`Batch export supports at most ${MAX_BATCH_EXPORT_REQUESTS} captures.`); + } + const unique = new Set(captureIds); + if (unique.size !== captureIds.length) { + throw new Error("Batch export cannot contain duplicate capture IDs."); + } + if (captureIds.some((captureId) => captureId.length === 0)) { + throw new Error("Batch export capture IDs cannot be empty."); + } +} + +function downloadExportPreview(preview: ExportPreview): ExportReceipt { + const url = URL.createObjectURL(new Blob([preview.content], { type: "application/json" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = preview.suggestedFilename; + anchor.click(); + URL.revokeObjectURL(url); + return { + path: preview.suggestedFilename, + byteCount: preview.byteCount, + redactionCount: preview.redactions.length, + }; +} + function fixtureReproductionParameters(request: CapturedRequest): Record { const raw = request.detail.raw; if (!raw || Array.isArray(raw) || typeof raw !== "object") return {}; diff --git a/crates/desktop-api/src/export.rs b/crates/desktop-api/src/export.rs index de9351a..7e06eca 100644 --- a/crates/desktop-api/src/export.rs +++ b/crates/desktop-api/src/export.rs @@ -63,19 +63,69 @@ pub fn build_export_preview( profile: ExportProfile, exported_at_unix_ms: u64, ) -> Result { - let payload = match profile { - ExportProfile::Minimal => minimal_payload(request), - ExportProfile::Reproducible => reproducible_payload(request)?, - ExportProfile::Forensic => forensic_payload(request)?, - }; - let mut document = json!({ + let document = json!({ + "formatVersion": EXPORT_FORMAT_VERSION, + "policyVersion": EXPORT_POLICY_VERSION, + "desktopApiVersion": DESKTOP_API_VERSION, + "profile": profile, + "exportedAtUnixMs": exported_at_unix_ms, + "request": request_payload(request, profile)?, + }); + finish_preview( + document, + profile, + exported_at_unix_ms, + suggested_filename(request, profile), + ) +} + +pub fn build_batch_export_preview( + requests: &[CapturedRequest], + profile: ExportProfile, + exported_at_unix_ms: u64, +) -> Result { + let payloads = requests + .iter() + .map(|request| request_payload(request, profile)) + .collect::, _>>()?; + let document = json!({ "formatVersion": EXPORT_FORMAT_VERSION, "policyVersion": EXPORT_POLICY_VERSION, "desktopApiVersion": DESKTOP_API_VERSION, "profile": profile, "exportedAtUnixMs": exported_at_unix_ms, - "request": payload, + "requestCount": requests.len(), + "requests": payloads, }); + finish_preview( + document, + profile, + exported_at_unix_ms, + format!( + "codeischeap-batch-{}-{}.json", + requests.len(), + profile.slug() + ), + ) +} + +fn request_payload( + request: &CapturedRequest, + profile: ExportProfile, +) -> Result { + match profile { + ExportProfile::Minimal => Ok(minimal_payload(request)), + ExportProfile::Reproducible => reproducible_payload(request), + ExportProfile::Forensic => forensic_payload(request), + } +} + +fn finish_preview( + mut document: Value, + profile: ExportProfile, + exported_at_unix_ms: u64, + suggested_filename: String, +) -> Result { let mut redactions = Vec::new(); scrub_value(&mut document, "", &mut redactions); document @@ -88,7 +138,7 @@ pub fn build_export_preview( let content_sha256 = format!("{:x}", Sha256::digest(content.as_bytes())); Ok(ExportPreview { profile, - suggested_filename: suggested_filename(request, profile), + suggested_filename, content, byte_count, content_sha256, @@ -434,4 +484,29 @@ mod tests { } assert_eq!(redactions.len(), 6); } + + #[test] + fn batch_exports_scan_every_request_and_preserve_order() { + let first = request(); + let mut second = request(); + second.id = "capture-2".to_owned(); + second.prompt_preview = "Bearer secondbatchsecret".to_owned(); + + let preview = build_batch_export_preview(&[first, second], ExportProfile::Reproducible, 20) + .expect("batch preview must encode"); + let document: Value = serde_json::from_str(&preview.content).expect("preview must be JSON"); + + assert_eq!(document["requestCount"], 2); + assert_eq!(document["requests"][0]["metadata"]["id"], "capture/unsafe"); + assert_eq!(document["requests"][1]["metadata"]["id"], "capture-2"); + assert!(!preview.content.contains("secondbatchsecret")); + assert!(preview.redactions.iter().any(|redaction| { + redaction.pointer == "/requests/1/promptPreview" && redaction.category == "bearer_token" + })); + assert_eq!( + preview.suggested_filename, + "codeischeap-batch-2-reproducible.json" + ); + assert_eq!(preview.content_sha256.len(), 64); + } } diff --git a/crates/desktop-api/src/lib.rs b/crates/desktop-api/src/lib.rs index 7a2e428..f8ba544 100644 --- a/crates/desktop-api/src/lib.rs +++ b/crates/desktop-api/src/lib.rs @@ -4,7 +4,7 @@ mod export; pub use export::{ EXPORT_FORMAT_VERSION, EXPORT_POLICY_VERSION, ExportPreview, ExportProfile, ExportReceipt, - ExportRedaction, build_export_preview, + ExportRedaction, build_batch_export_preview, build_export_preview, }; use std::{fmt, ops::Range}; @@ -257,10 +257,7 @@ pub fn load_workspace(store: &EncryptedStore) -> Result Result Result { + store + .get_capture(capture_id)? + .map(map_capture) + .ok_or_else(|| DesktopApiError::MissingCapture(capture_id.to_owned())) +} + pub fn search_requests( store: &EncryptedStore, query: &str, @@ -288,12 +295,7 @@ pub fn search_requests( let summaries = store.search_captures(query, 200)?; summaries .into_iter() - .map(|summary| { - store - .get_capture(&summary.capture_id)? - .map(map_capture) - .ok_or_else(|| DesktopApiError::MissingCapture(summary.capture_id)) - }) + .map(|summary| load_request(store, &summary.capture_id)) .collect() } diff --git a/docs/progress.html b/docs/progress.html index b8ba7a7..3eae1f2 100644 --- a/docs/progress.html +++ b/docs/progress.html @@ -16,13 +16,13 @@

开发进度跟踪

本页是项目状态的单一入口。开发计划定义“应该做什么”,本页只记录“当前做到哪里、是否健康、下一步是什么”。

-
工程进度75%
+
工程进度76%
当前阶段恢复、安全与协议扩展
当前迭代S5 · 进行中 / S6 · 完成
最后更新2026-07-17
-
当前结论四厂商 Prompt IR、搜索和 Compare 已闭环;设置/诊断、首次连接空状态和安全 Gateway 恢复入口已接入。下一步推进批量导出与 macOS helper。
+
当前结论四厂商 Prompt IR、搜索和 Compare 已闭环;设置/诊断、安全 Gateway 恢复入口及批量脱敏导出已接入。下一步推进支持包、扩展 secret corpus 与 macOS helper。

1. 更新规则

    @@ -61,7 +61,7 @@

    4. 工作流进度

    Desktop / FrontendAPP-001~00692%In progressCodex / TBD采集首次捕获耗时并完成真实运行验收 Capture / NetworkCAP-001~00772%In progressCodex / TBD实现 macOS 特权代理 helper;生产签名随发布凭据补齐 Prompt / AdaptersPAR-001~007100%DoneCodex / TBD保持价格目录与 provider usage 映射可追溯 - Data / SecurityDAT-001~002、SEC-001~00455%In progressCodex / TBD继续日志/临时文件 canary 与批量导出 + Data / SecurityDAT-001~002、SEC-001~00460%In progressCodex / TBD继续日志/临时文件 canary、支持包与扩展 secret corpus Test / ReleaseTST-001~004、REL-001~00335%In progressCodex / TBD继续 TST-002 Proxy 取消/背压与证书生命周期测试
@@ -87,7 +87,7 @@

5. 当前迭代:S5 / S6

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范围策略和凭据 scrubberIn progress85%Codex / Security Reviewer TBD2026-07-28请求与响应 header/JSON body 双层脱敏及 DB/WAL canary 通过;日志和临时文件待补 - SEC-002脱敏与导出In progress65%Codex / Security Reviewer TBD2026-08-25单请求三档 profile、后端凭据模式扫描、稳定占位符、完整 JSON 预览、内容哈希校验、系统保存对话框和只新建文件已实现;批量导出、支持包与扩展 secret corpus 待完成 + SEC-002脱敏与导出In progress85%Codex / Security Reviewer TBD2026-08-25单请求及当前筛选结果均支持三档 JSON 导出、后端凭据扫描、完整预览、SHA-256 防陈旧和只新建文件;批量上限/重复/缺失校验已覆盖,支持包与扩展 secret corpus 待完成 PAR-001Prompt IR v0.1 与首批 fixturesDone100%Codex / TBD2026-07-14OpenAI/Anthropic fixtures、校验与 schema 完成 PAR-002解析器注册表与检测链Done100%Codex / TBD2026-07-15置信度排序、错误/panic 隔离、IR 校验、partial issues 与 Raw fallback 完成 PAR-003OpenAI-compatible 适配器Done100%Codex / TBD2026-07-15Responses、Chat、Completions、多模态与 tools golden fixtures 通过 @@ -119,7 +119,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 progressWindows 恢复、sidecar 崩溃回退、单请求安全导出及设置诊断已接入;继续 macOS helper、批量导出与其余故障注入 + S5CAP-006/007、SEC-002/003、APP-006、TST-003In progressWindows 恢复、sidecar 崩溃回退、单请求/批量安全导出及设置诊断已接入;继续 macOS helper、支持包与其余故障注入 S6PAR-005~007、APP-005Done四厂商适配器、token/成本/指纹、全文搜索和结构/文本 Compare 全部完成 S7TST-004、性能、可访问性、诊断与保留Not started功能冻结 S8SEC-004、REL-001/002、安全评审Not startedBeta 质量门槛通过 @@ -202,6 +202,7 @@

11. 决策与变更记录

2026-07-16Desktop / RecoveryCAP-006 Windows 桌面系统代理事务接入受信任 CA 下 Proxy 模式自动建立 WinINet 快照与 armed journal,桌面自身以 --journal 运行 watchdog;切回 Gateway、正常退出、强杀后 watchdog 或下次启动均恢复原设置,活 owner 不会被第二实例误恢复,Unix journal 权限固定为目录 0700/文件 0600Codex 2026-07-16Desktop / RecoveryCAP-006 sidecar 独立退出即时回退桌面按 250 ms 周期非阻塞检查 sidecar;异常退出或监控失败会撤销对应代际的 Proxy runtime、恢复系统代理、重新启用 Gateway 捕获并通知前端刷新,旧监控任务不能关闭后续新会话Codex 2026-07-16Security / ExportSEC-002 单请求安全导出闭环Minimal、Reproducible、Forensic 均由后端生成;已知 API key、Bearer、JWT 与私钥模式替换为稳定占位符并记录 JSON Pointer,用户预览后经系统对话框保存,写入前重算 SHA-256 防止流式内容变化,目标文件只允许新建Codex + 2026-07-17Security / ExportSEC-002 批量安全导出闭环当前搜索与筛选结果可按稳定顺序导出为单个版本化 JSON 文档;最多 200 条,空列表、重复 ID、缺失请求和陈旧哈希均拒绝写入,每条请求统一执行凭据扫描并通过系统保存对话框创建新文件Codex 2026-07-17Prompt / AdaptersPAR-005 Gemini 适配器完成v1/v1beta 两类生成端点的请求、JSON 与 SSE 响应进入 Prompt IR;systemInstruction、contents、多模态、函数声明/调用/结果、生成参数、finish reason 与 usage 均保留可回溯证据Codex 2026-07-17Prompt / AdaptersPAR-006 Ollama 适配器完成显式捕获本机 Ollama chat/generate;消息、system、suffix、多模态、函数调用、options、JSON 与 NDJSON usage 进入 Prompt IR,NDJSON chunk 可按 UTF-8 字节范围回到 RawCodex 2026-07-17Desktop / CompareAPP-005 Compare 与搜索完成桌面搜索接入 SQLCipher FTS;双请求以稳定规则比较消息位置、工具名称和参数名称,文本差异使用成熟 diff 引擎,基准选择、交换、筛选和键盘取消均有回归测试Codex diff --git a/docs/threat-model.html b/docs/threat-model.html index 516480c..6c0d214 100644 --- a/docs/threat-model.html +++ b/docs/threat-model.html @@ -50,7 +50,7 @@

威胁台账

TM-05本地 CA 私钥泄露或残留信任每设备 CA、跨平台私钥权限检查、指纹 UI、Windows CurrentUser 与 macOS User 幂等卸载已实现两平台交互式验收;发布前独立安全评审 TM-06崩溃后系统代理残留Windows/macOS 强杀恢复实验;Windows 桌面已接入私有 journal、watchdog、启动恢复和活 owner 校验;sidecar 单独退出会按代际恢复代理并回退 GatewaymacOS 特权 helper;端口冲突、PAC 与 CA 拒绝故障注入 TM-07sidecar 或更新包被替换打包 manifest 含 SHA-256签名、SBOM、启动前 hash 校验;发布硬门槛 - TM-08复制或导出泄露秘密单请求三档导出强制后端扫描与完整预览;稳定占位符、JSON Pointer、策略版本和 SHA-256 防陈旧已实现;文件只新建不覆盖扩展 secret corpus、批量导出与支持包 canary + TM-08复制或导出泄露秘密单请求及最多 200 条批量导出均强制后端扫描与完整预览;稳定占位符、JSON Pointer、策略版本、SHA-256 防陈旧、重复/缺失校验和只新建文件已实现扩展 secret corpus 与支持包 canary TM-09恶意 Prompt 触发 UI 注入React 默认转义、Tauri CSPRaw 仅纯文本、URL 不自动执行、XSS fixture TM-10超大/高速流量耗尽内存或磁盘IPC 帧上限、sidecar 有界队列记录降级、配额、磁盘压力与丢弃事件 TM-11将推断内容误标为已观察 PromptPrompt IR evidence 类型UI 强制显示 observed / inferred / unknown From 6c544875c1afd9cd55f89d4cb81c373e3061cc42 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 18 Jul 2026 00:22:11 +0800 Subject: [PATCH 2/2] style: align Tauri formatting with CI --- apps/desktop/src-tauri/src/lib.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b948fc8..8b111f4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -615,8 +615,7 @@ fn write_batch_capture_export_file( expected_sha256: &str, path: &Path, ) -> Result { - let preview = - build_batch_capture_export(store, capture_ids, profile, exported_at_unix_ms)?; + let preview = build_batch_capture_export(store, capture_ids, profile, exported_at_unix_ms)?; if preview.content_sha256 != expected_sha256 { return Err("a capture changed after preview; review the refreshed export".to_owned()); } @@ -1769,8 +1768,7 @@ mod tests { assert!(validate_batch_capture_ids(&[]).is_err()); assert!(validate_batch_capture_ids(&[String::new()]).is_err()); assert!( - validate_batch_capture_ids(&["capture-1".to_owned(), "capture-1".to_owned()]) - .is_err() + validate_batch_capture_ids(&["capture-1".to_owned(), "capture-1".to_owned()]).is_err() ); assert!( validate_batch_capture_ids(