Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 生成。契约变更后执行:

Expand All @@ -55,6 +55,8 @@ sidecar IPC 协议为 `0.2`:仅监听 loopback,使用每次 Proxy 会话重

Gateway 和 Proxy 捕获会按显式客户端标签、User-Agent 规则或捕获模式回退生成带置信度的应用归因。Gateway 客户端可设置 `x-codeischeap-client: <application>` 提供高置信度标签;该内部请求头会在持久化和转发上游前删除。当前归因不声明进程 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:
Expand Down
107 changes: 77 additions & 30 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -77,6 +77,7 @@ struct DesktopState {
proxy: AsyncMutex<Option<ProxyRuntime>>,
mode: AsyncMutex<CaptureMode>,
capture_active: Arc<AtomicBool>,
proxy_session_event_count: Arc<AtomicU64>,
next_proxy_generation: AtomicU64,
proxy_recovery_checked: AtomicBool,
sidecar_bundle: Option<Arc<SidecarBundle>>,
Expand Down Expand Up @@ -105,6 +106,16 @@ struct ProxyRuntime {
shutdown: Option<oneshot::Sender<()>>,
}

struct ProxyCaptureContext {
app: AppHandle,
store: SharedStore,
capture_active: Arc<AtomicBool>,
session_event_count: Arc<AtomicU64>,
listener: TcpListener,
token: String,
policy: CapturePolicy,
}

impl ProxyRuntime {
fn shutdown(mut self) -> Result<(), String> {
if let Some(shutdown) = self.shutdown.take() {
Expand Down Expand Up @@ -147,6 +158,7 @@ struct RuntimeSnapshot {
gateway_endpoint: Option<String>,
proxy_endpoint: Option<String>,
system_proxy_active: bool,
proxy_session_event_count: u64,
certificate_authority: CertificateAuthority,
}

Expand Down Expand Up @@ -232,7 +244,11 @@ async fn search_workspace(
}

#[tauri::command]
async fn set_capture_active(active: bool, state: State<'_, DesktopState>) -> Result<bool, String> {
async fn set_capture_active(
active: bool,
app: AppHandle,
state: State<'_, DesktopState>,
) -> Result<WorkspaceBootstrap, String> {
let mode = *state.mode.lock().await;
match mode {
CaptureMode::Gateway => {
Expand All @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -1287,36 +1311,30 @@ fn generate_ipc_token() -> Result<String, String> {
Ok(token)
}

async fn process_proxy_events(
app: AppHandle,
store: SharedStore,
capture_active: Arc<AtomicBool>,
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| {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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(),
},
);
Expand All @@ -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]
Expand All @@ -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,
};

Expand All @@ -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(),
},
);
Expand All @@ -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)]
Expand Down Expand Up @@ -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,
Expand Down
84 changes: 83 additions & 1 deletion apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});

Expand Down Expand Up @@ -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(<App />);
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;
Expand Down
9 changes: 3 additions & 6 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
Loading
Loading