diff --git a/Cargo.lock b/Cargo.lock index 273e555..0bf8fae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,6 +150,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "codeischeap-proxy-recovery" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "windows-sys 0.61.2", +] + [[package]] name = "cpufeatures" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index ea4501b..340d532 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/capture-ipc", "crates/gateway", "crates/prompt-ir"] +members = ["crates/capture-ipc", "crates/gateway", "crates/prompt-ir", "crates/proxy-recovery"] resolver = "3" [workspace.package] @@ -18,3 +18,4 @@ serde_json = "1.0" tokio = { version = "1.45", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tokio-stream = "0.1" url = "2.5" +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/README.md b/README.md index bdf16be..1c7d7fa 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Rust crate 位于 `crates/prompt-ir`,公开 JSON Schema 位于 `schemas/prompt 捕获 sidecar 位于 `sidecars/mitmproxy`,跨进程契约位于 `crates/capture-ipc`,公开 CaptureEnvelope Schema 位于 `schemas/capture-envelope/v0.1.schema.json`。sidecar 的安装、测试与打包命令见 [`sidecars/mitmproxy/README.md`](./sidecars/mitmproxy/README.md)。 +系统代理事务与独立恢复 watchdog 的平台无关核心位于 `crates/proxy-recovery`;当前使用文件 backend 做强杀故障注入,真实 Windows/macOS backend 尚在开发。 + 启动 Gateway Spike: ```powershell diff --git a/crates/proxy-recovery/Cargo.toml b/crates/proxy-recovery/Cargo.toml new file mode 100644 index 0000000..d820b1a --- /dev/null +++ b/crates/proxy-recovery/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "codeischeap-proxy-recovery" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +description = "Transactional system proxy recovery core for CodeIsCheap" + +[dependencies] +serde.workspace = true +serde_json.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys.workspace = true diff --git a/crates/proxy-recovery/README.md b/crates/proxy-recovery/README.md new file mode 100644 index 0000000..aa5b283 --- /dev/null +++ b/crates/proxy-recovery/README.md @@ -0,0 +1,13 @@ +# Proxy recovery core + +This crate owns the transaction state machine used before CodeIsCheap changes system proxy settings. + +1. Read the exact original settings. +2. Persist an armed recovery journal. +3. Spawn an independent watchdog and wait for its `ready` handshake. +4. Apply the desired proxy settings. +5. On normal shutdown, restore and disarm the watchdog. +6. On owner-process death, pipe EOF makes the watchdog restore its in-memory snapshot. +7. On the next startup, an armed journal provides a second recovery path. + +The included file backend exists only for deterministic crash injection. Production Windows and macOS backends, private journal-directory permissions, and platform notifications are separate work; `SPIKE-003` remains in progress until those backends pass real setting/restore experiments. diff --git a/crates/proxy-recovery/src/bin/proxy-recovery-spike.rs b/crates/proxy-recovery/src/bin/proxy-recovery-spike.rs new file mode 100644 index 0000000..c68cb94 --- /dev/null +++ b/crates/proxy-recovery/src/bin/proxy-recovery-spike.rs @@ -0,0 +1,42 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use codeischeap_proxy_recovery::{FileProxyBackend, ProxySession, ProxySettings}; + +fn main() -> Result<(), Box> { + let mut arguments = env::args_os().skip(1); + if arguments.next().as_deref() != Some("hold".as_ref()) { + return Err("usage: proxy-recovery-spike hold ".into()); + } + let state = next_path(&mut arguments, "state")?; + let journal = next_path(&mut arguments, "journal")?; + let ready = next_path(&mut arguments, "ready")?; + let watchdog = next_path(&mut arguments, "watchdog")?; + if arguments.next().is_some() { + return Err("unexpected proxy-recovery-spike argument".into()); + } + + let desired = ProxySettings::Manual { + http_proxy: "http://127.0.0.1:3210".to_owned(), + https_proxy: "http://127.0.0.1:3210".to_owned(), + bypass: vec!["localhost".to_owned(), "127.0.0.1".to_owned()], + }; + let _session = ProxySession::begin(FileProxyBackend::new(state), desired, journal, watchdog)?; + fs::write(ready, b"ready\n")?; + loop { + thread::sleep(Duration::from_secs(60)); + } +} + +fn next_path( + arguments: &mut impl Iterator, + name: &str, +) -> Result> { + arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| format!("missing {name} path").into()) +} diff --git a/crates/proxy-recovery/src/bin/proxy-watchdog.rs b/crates/proxy-recovery/src/bin/proxy-watchdog.rs new file mode 100644 index 0000000..21c0498 --- /dev/null +++ b/crates/proxy-recovery/src/bin/proxy-watchdog.rs @@ -0,0 +1,20 @@ +use std::env; +use std::path::PathBuf; + +use codeischeap_proxy_recovery::run_watchdog; + +fn main() -> Result<(), Box> { + let mut arguments = env::args_os().skip(1); + if arguments.next().as_deref() != Some("--journal".as_ref()) { + return Err("usage: proxy-watchdog --journal ".into()); + } + let journal = arguments + .next() + .map(PathBuf::from) + .ok_or("missing journal path")?; + if arguments.next().is_some() { + return Err("unexpected proxy-watchdog argument".into()); + } + run_watchdog(&journal)?; + Ok(()) +} diff --git a/crates/proxy-recovery/src/lib.rs b/crates/proxy-recovery/src/lib.rs new file mode 100644 index 0000000..4f4edb1 --- /dev/null +++ b/crates/proxy-recovery/src/lib.rs @@ -0,0 +1,402 @@ +//! Transactional proxy snapshots and an out-of-process recovery watchdog. +//! +//! Platform backends plug into this state machine. The file backend exists for +//! deterministic crash injection and must not be used as a system proxy backend. + +use std::fmt; +use std::fs::{self, File}; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +pub const RECOVERY_JOURNAL_VERSION: &str = "0.1"; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum ProxySettings { + Disabled, + Manual { + http_proxy: String, + https_proxy: String, + #[serde(default)] + bypass: Vec, + }, + AutoConfig { + url: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum BackendDescriptor { + File { state_path: PathBuf }, +} + +pub trait ProxyBackend { + fn descriptor(&self) -> BackendDescriptor; + fn read(&self) -> Result; + fn apply(&self, settings: &ProxySettings) -> Result<(), RecoveryError>; +} + +#[derive(Debug, Clone)] +pub struct FileProxyBackend { + state_path: PathBuf, +} + +impl FileProxyBackend { + pub fn new(state_path: impl Into) -> Self { + Self { + state_path: state_path.into(), + } + } +} + +impl ProxyBackend for FileProxyBackend { + fn descriptor(&self) -> BackendDescriptor { + BackendDescriptor::File { + state_path: self.state_path.clone(), + } + } + + fn read(&self) -> Result { + let bytes = fs::read(&self.state_path).map_err(RecoveryError::Io)?; + serde_json::from_slice(&bytes).map_err(RecoveryError::Json) + } + + fn apply(&self, settings: &ProxySettings) -> Result<(), RecoveryError> { + write_json_atomic(&self.state_path, settings) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JournalStatus { + Armed, + Restored, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecoveryJournal { + pub version: String, + pub transaction_id: String, + pub owner_pid: u32, + pub status: JournalStatus, + pub backend: BackendDescriptor, + pub original: ProxySettings, + pub desired: ProxySettings, +} + +#[derive(Debug)] +pub enum RecoveryError { + Io(io::Error), + Json(serde_json::Error), + InvalidJournalVersion(String), + WatchdogDidNotBecomeReady, + WatchdogExitedEarly, + UnsupportedBackend, +} + +impl fmt::Display for RecoveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "proxy recovery I/O failed: {error}"), + Self::Json(_) => write!(formatter, "proxy recovery data is invalid"), + Self::InvalidJournalVersion(version) => { + write!( + formatter, + "proxy recovery journal version {version} is unsupported" + ) + } + Self::WatchdogDidNotBecomeReady => { + write!(formatter, "proxy watchdog did not become ready") + } + Self::WatchdogExitedEarly => write!(formatter, "proxy watchdog exited before arming"), + Self::UnsupportedBackend => write!(formatter, "proxy recovery backend is unsupported"), + } + } +} + +impl std::error::Error for RecoveryError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Json(error) => Some(error), + _ => None, + } + } +} + +impl From for RecoveryError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +pub struct ProxySession { + backend: B, + journal_path: PathBuf, + original: ProxySettings, + watchdog: Option, + restored: bool, +} + +impl ProxySession { + pub fn begin( + backend: B, + desired: ProxySettings, + journal_path: impl Into, + watchdog_executable: impl AsRef, + ) -> Result { + let journal_path = journal_path.into(); + let original = backend.read()?; + let journal = RecoveryJournal { + version: RECOVERY_JOURNAL_VERSION.to_owned(), + transaction_id: transaction_id(), + owner_pid: std::process::id(), + status: JournalStatus::Armed, + backend: backend.descriptor(), + original: original.clone(), + desired: desired.clone(), + }; + write_json_atomic(&journal_path, &journal)?; + + let watchdog = match WatchdogHandle::spawn(watchdog_executable.as_ref(), &journal_path) { + Ok(watchdog) => watchdog, + Err(error) => { + let _ = fs::remove_file(&journal_path); + return Err(error); + } + }; + + if let Err(error) = backend.apply(&desired) { + let _ = backend.apply(&original); + let _ = mark_restored(&journal_path); + let mut watchdog = watchdog; + let _ = watchdog.disarm(); + let _ = fs::remove_file(&journal_path); + return Err(error); + } + + Ok(Self { + backend, + journal_path, + original, + watchdog: Some(watchdog), + restored: false, + }) + } + + pub fn restore(mut self) -> Result<(), RecoveryError> { + self.restore_inner() + } + + fn restore_inner(&mut self) -> Result<(), RecoveryError> { + if self.restored { + return Ok(()); + } + self.backend.apply(&self.original)?; + mark_restored(&self.journal_path)?; + if let Some(mut watchdog) = self.watchdog.take() { + watchdog.disarm()?; + } + remove_if_exists(&self.journal_path)?; + self.restored = true; + Ok(()) + } +} + +impl Drop for ProxySession { + fn drop(&mut self) { + let _ = self.restore_inner(); + } +} + +struct WatchdogHandle { + child: Child, + stdin: Option, +} + +impl WatchdogHandle { + fn spawn(executable: &Path, journal_path: &Path) -> Result { + let mut child = Command::new(executable) + .arg("--journal") + .arg(journal_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let stdin = child + .stdin + .take() + .ok_or(RecoveryError::WatchdogExitedEarly)?; + let stdout = child + .stdout + .take() + .ok_or(RecoveryError::WatchdogExitedEarly)?; + let mut ready = String::new(); + BufReader::new(stdout).read_line(&mut ready)?; + if ready.trim() != "ready" { + let _ = child.kill(); + let _ = child.wait(); + return Err(RecoveryError::WatchdogDidNotBecomeReady); + } + Ok(Self { + child, + stdin: Some(stdin), + }) + } + + fn disarm(&mut self) -> Result<(), RecoveryError> { + if let Some(mut stdin) = self.stdin.take() { + stdin.write_all(b"disarm\n")?; + stdin.flush()?; + } + let status = self.child.wait()?; + if !status.success() { + return Err(RecoveryError::WatchdogExitedEarly); + } + Ok(()) + } +} + +pub fn run_watchdog(journal_path: &Path) -> Result<(), RecoveryError> { + let armed_journal = load_journal(journal_path)?; + validate_journal(&armed_journal)?; + println!("ready"); + io::stdout().flush()?; + + let mut command = String::new(); + io::stdin().read_to_string(&mut command)?; + let latest = load_journal(journal_path)?; + if command.trim() == "disarm" && latest.status == JournalStatus::Restored { + remove_if_exists(journal_path)?; + return Ok(()); + } + + restore_snapshot(journal_path, &armed_journal) +} + +pub fn recover_from_journal(journal_path: &Path) -> Result { + if !journal_path.exists() { + return Ok(false); + } + let journal = load_journal(journal_path)?; + validate_journal(&journal)?; + if journal.status == JournalStatus::Restored { + remove_if_exists(journal_path)?; + return Ok(false); + } + + restore_snapshot(journal_path, &journal)?; + Ok(true) +} + +fn restore_snapshot(journal_path: &Path, journal: &RecoveryJournal) -> Result<(), RecoveryError> { + let backend = backend_from_descriptor(&journal.backend)?; + backend.apply(&journal.original)?; + let mut restored = journal.clone(); + restored.status = JournalStatus::Restored; + write_json_atomic(journal_path, &restored)?; + remove_if_exists(journal_path)?; + Ok(()) +} + +fn backend_from_descriptor( + descriptor: &BackendDescriptor, +) -> Result { + match descriptor { + BackendDescriptor::File { state_path } => Ok(FileProxyBackend::new(state_path)), + } +} + +fn load_journal(path: &Path) -> Result { + let bytes = fs::read(path)?; + serde_json::from_slice(&bytes).map_err(RecoveryError::Json) +} + +fn validate_journal(journal: &RecoveryJournal) -> Result<(), RecoveryError> { + if journal.version != RECOVERY_JOURNAL_VERSION { + return Err(RecoveryError::InvalidJournalVersion( + journal.version.clone(), + )); + } + Ok(()) +} + +fn mark_restored(path: &Path) -> Result<(), RecoveryError> { + let mut journal = load_journal(path)?; + journal.status = JournalStatus::Restored; + write_json_atomic(path, &journal) +} + +fn write_json_atomic(path: &Path, value: &T) -> Result<(), RecoveryError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let temporary = temporary_path(path); + let mut file = File::create(&temporary)?; + serde_json::to_writer_pretty(&mut file, value).map_err(RecoveryError::Json)?; + file.write_all(b"\n")?; + file.sync_all()?; + replace_file(&temporary, path)?; + Ok(()) +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, target: &Path) -> io::Result<()> { + fs::rename(source, target) +} + +#[cfg(windows)] +fn replace_file(source: &Path, target: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let source: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); + let target: Vec = target.as_os_str().encode_wide().chain(Some(0)).collect(); + let result = unsafe { + MoveFileExW( + source.as_ptr(), + target.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn remove_if_exists(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn temporary_path(path: &Path) -> PathBuf { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("proxy-recovery"); + path.with_file_name(format!(".{name}.{}.{}.tmp", std::process::id(), sequence)) +} + +fn transaction_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{}-{nanos}", std::process::id()) +} diff --git a/crates/proxy-recovery/tests/recovery.rs b/crates/proxy-recovery/tests/recovery.rs new file mode 100644 index 0000000..8971615 --- /dev/null +++ b/crates/proxy-recovery/tests/recovery.rs @@ -0,0 +1,212 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use codeischeap_proxy_recovery::{ + FileProxyBackend, JournalStatus, ProxyBackend, ProxySession, ProxySettings, + RECOVERY_JOURNAL_VERSION, RecoveryJournal, recover_from_journal, +}; + +fn original_settings() -> ProxySettings { + ProxySettings::AutoConfig { + url: "https://config.example.test/proxy.pac".to_owned(), + } +} + +fn desired_settings() -> ProxySettings { + ProxySettings::Manual { + http_proxy: "http://127.0.0.1:3210".to_owned(), + https_proxy: "http://127.0.0.1:3210".to_owned(), + bypass: vec!["localhost".to_owned(), "127.0.0.1".to_owned()], + } +} + +#[test] +fn normal_restore_returns_to_the_exact_snapshot() { + let root = test_directory("normal"); + let state = root.join("proxy-state.json"); + let journal = root.join("recovery.json"); + let backend = FileProxyBackend::new(&state); + backend + .apply(&original_settings()) + .expect("original state must write"); + + let session = ProxySession::begin( + backend.clone(), + desired_settings(), + &journal, + env!("CARGO_BIN_EXE_proxy-watchdog"), + ) + .expect("session must begin"); + assert_eq!(backend.read().expect("state must read"), desired_settings()); + + session.restore().expect("session must restore"); + + assert_eq!( + backend.read().expect("state must read"), + original_settings() + ); + assert!(!journal.exists()); + fs::remove_dir_all(root).expect("test directory must clean up"); +} + +#[test] +fn watchdog_restores_after_the_owner_is_force_killed() { + let root = test_directory("force-kill"); + let state = root.join("proxy-state.json"); + let journal = root.join("recovery.json"); + let ready = root.join("ready"); + let backend = FileProxyBackend::new(&state); + backend + .apply(&original_settings()) + .expect("original state must write"); + + let mut owner = ChildGuard( + Command::new(env!("CARGO_BIN_EXE_proxy-recovery-spike")) + .arg("hold") + .arg(&state) + .arg(&journal) + .arg(&ready) + .arg(env!("CARGO_BIN_EXE_proxy-watchdog")) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("owner process must start"), + ); + wait_until(Duration::from_secs(5), || ready.exists()); + assert_eq!(backend.read().expect("state must read"), desired_settings()); + + owner.0.kill().expect("owner process must be force killed"); + owner.0.wait().expect("owner process must exit"); + + wait_until(Duration::from_secs(5), || { + backend.read().ok() == Some(original_settings()) && !journal.exists() + }); + fs::remove_dir_all(root).expect("test directory must clean up"); +} + +#[test] +fn watchdog_uses_the_snapshot_loaded_before_ready() { + let root = test_directory("journal-tamper"); + let state = root.join("proxy-state.json"); + let unrelated = root.join("unrelated-state.json"); + let journal = root.join("recovery.json"); + let ready = root.join("ready"); + let backend = FileProxyBackend::new(&state); + let unrelated_backend = FileProxyBackend::new(&unrelated); + backend + .apply(&original_settings()) + .expect("original state must write"); + unrelated_backend + .apply(&ProxySettings::Disabled) + .expect("unrelated state must write"); + + let mut owner = ChildGuard( + Command::new(env!("CARGO_BIN_EXE_proxy-recovery-spike")) + .arg("hold") + .arg(&state) + .arg(&journal) + .arg(&ready) + .arg(env!("CARGO_BIN_EXE_proxy-watchdog")) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("owner process must start"), + ); + wait_until(Duration::from_secs(5), || ready.exists()); + + let mut tampered: RecoveryJournal = + serde_json::from_slice(&fs::read(&journal).expect("journal must read")) + .expect("journal must deserialize"); + tampered.backend = unrelated_backend.descriptor(); + tampered.original = desired_settings(); + fs::write( + &journal, + serde_json::to_vec_pretty(&tampered).expect("tampered journal must serialize"), + ) + .expect("tampered journal must write"); + + owner.0.kill().expect("owner process must be force killed"); + owner.0.wait().expect("owner process must exit"); + + wait_until(Duration::from_secs(5), || { + backend.read().ok() == Some(original_settings()) && !journal.exists() + }); + assert_eq!( + unrelated_backend.read().expect("unrelated state must read"), + ProxySettings::Disabled + ); + fs::remove_dir_all(root).expect("test directory must clean up"); +} + +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[test] +fn startup_recovery_repairs_an_armed_journal() { + let root = test_directory("startup"); + let state = root.join("proxy-state.json"); + let journal = root.join("recovery.json"); + let backend = FileProxyBackend::new(&state); + backend + .apply(&original_settings()) + .expect("original state must write"); + backend + .apply(&desired_settings()) + .expect("desired state must write"); + let recovery = RecoveryJournal { + version: RECOVERY_JOURNAL_VERSION.to_owned(), + transaction_id: "startup-recovery-test".to_owned(), + owner_pid: u32::MAX, + status: JournalStatus::Armed, + backend: backend.descriptor(), + original: original_settings(), + desired: desired_settings(), + }; + fs::write( + &journal, + serde_json::to_vec_pretty(&recovery).expect("journal must serialize"), + ) + .expect("journal must write"); + + assert!(recover_from_journal(&journal).expect("startup recovery must succeed")); + assert_eq!( + backend.read().expect("state must read"), + original_settings() + ); + assert!(!journal.exists()); + fs::remove_dir_all(root).expect("test directory must clean up"); +} + +fn wait_until(timeout: Duration, condition: impl Fn() -> bool) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if condition() { + return; + } + thread::sleep(Duration::from_millis(25)); + } + panic!("condition was not met within {timeout:?}"); +} + +fn test_directory(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = env::temp_dir().join(format!( + "codeischeap-{label}-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory must exist"); + path +} diff --git a/docs/progress.html b/docs/progress.html index f36645c..a8e457b 100644 --- a/docs/progress.html +++ b/docs/progress.html @@ -22,7 +22,7 @@

开发进度跟踪

最后更新2026-07-14
-
当前结论Gateway 与 mitmproxy sidecar 两条采集路径均已通过技术验证;sidecar 在跨进程前删除凭据,单文件制品可真实启动和转发。下一步验证 Windows/macOS 系统代理恢复。
+
当前结论Gateway 与 mitmproxy sidecar 两条采集路径已验证;代理恢复核心也已通过正常退出、强杀和启动修复测试。SPIKE-003 仍需接入真实 Windows/macOS backend 后才能完成。

1. 更新规则

    @@ -45,7 +45,7 @@

    2. 项目健康度

    3. 里程碑

    - + @@ -68,14 +68,14 @@

    4. 工作流进度

    5. 当前迭代:S0

    目标:证明关键技术路径可行,并冻结第一版工程契约。初始实现于 2026-07-14 开始,正式团队 owner 尚待确认。

    ID里程碑权重进度状态目标Owner
    M0Discovery 与技术验证5%
    80%
    In progressW2TBD
    M0Discovery 与技术验证5%
    85%
    In progressW2TBD
    M1基础设施与加密数据链路15%
    0%
    Not startedW4TBD
    M2Gateway 与双厂商闭环20%
    0%
    Not startedW8TBD
    M3显式代理、安全与恢复20%
    0%
    Not startedW12TBD
    - + - +
    ID任务状态进度Owner目标日期备注
    ARC-001建立仓库与模块边界In progress65%Codex / TBD2026-07-28Prompt IR、Gateway、Capture IPC 与 sidecar 边界已建立
    ARC-001建立仓库与模块边界In progress70%Codex / TBD2026-07-28Prompt IR、Gateway、Capture IPC、sidecar 与恢复核心边界已建立
    ARC-002建立 Windows/macOS CIDone100%Codex / TBD2026-07-14Windows、macOS、Linux PR 检查通过
    ARC-003CaptureEnvelope 与 IPC 草案In progress40%Codex / TBD2026-07-28Envelope schema、认证帧与 Rust/Python golden fixture 已完成
    ARC-004完成关键 ADRNot started0%TBDTBDTauri、sidecar、DB
    PAR-001Prompt IR v0.1 与首批 fixturesDone100%Codex / TBD2026-07-14OpenAI/Anthropic fixtures、校验与 schema 完成
    SPIKE-001Gateway 流式透明转发验证Done100%Codex / TBD2026-07-14双向流式、取消传递、头清理与稳定 502 集成测试通过
    SPIKE-002mitmproxy sidecar IPC/打包验证Done100%Codex / TBD2026-07-14凭据清理、IPC、打包与真实转发通过
    SPIKE-003Windows/macOS 代理恢复验证Not started0%TBDTBD包含强杀和重启
    SPIKE-003Windows/macOS 代理恢复验证In progress40%Codex / TBD2026-07-28事务 journal、独立 watchdog、强杀与启动修复通过;待真实 OS backend
    SPIKE-004Threat model v1Not started0%TBDTBD确定凭据删除边界
    @@ -142,6 +142,7 @@

    11. 决策与变更记录

    2026-07-14Quality新增三平台 Rust CI 与 Anthropic 契约样本固定 fmt、test、clippy 与跨厂商契约Codex 2026-07-14Spike完成 Gateway 双向流式透明转发验证确认取消传递、hop-by-hop 头清理与稳定错误边界Codex 2026-07-14Spike完成 mitmproxy sidecar IPC 与打包验证固定版本、凭据前置清理、认证 IPC 与三平台制品检查Codex + 2026-07-14Implementation建立系统代理事务与独立 watchdog 核心异常退出和下次启动均可恢复可信快照;真实平台接入仍待完成Codex 后续范围、架构、日期或资源变化均在此追加,并链接对应 ADR/会议结论。