From c5c94f47b357bc2c71d4a45d8480f8295165408c Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:56:44 +0900 Subject: [PATCH 01/15] fix: support Windows control transport Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .github/workflows/ci.yml | 25 ++++++++ README.md | 5 +- src/control.rs | 106 ++++++++++++++++------------------ src/control/transport.rs | 119 +++++++++++++++++++++++++++++++++++++++ src/herdr_plugin.rs | 19 ++++++- src/runner.rs | 12 ++-- src/session.rs | 52 +++++++++-------- tests/architecture.rs | 6 +- 8 files changed, 252 insertions(+), 92 deletions(-) create mode 100644 src/control/transport.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b648510..5f331d1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,3 +62,28 @@ jobs: - name: Run local CI gate run: scripts/ci-local.sh + + windows: + name: windows + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup component add clippy rustfmt + + - name: Check formatting + run: cargo fmt --check + + - name: Check all targets + run: cargo check --all-targets + + - name: Run tests + run: cargo test + + - name: Run Clippy + run: cargo clippy --all-targets --all-features -- -D warnings diff --git a/README.md b/README.md index 5420df2c..d60ee69a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ Install from crates.io: cargo install ghzinga ``` +Linux, macOS, and Windows are supported. Live session control uses Unix domain +sockets on Unix and named pipes on Windows. + Install from a local checkout: ```sh @@ -209,7 +212,7 @@ Environment overrides: - `GZG_STATE_HOME`: alternate session state directory - `GZG_CACHE_HOME`: alternate resource cache directory -- `GZG_RUNTIME_HOME`: alternate runtime socket directory for live session control +- `GZG_RUNTIME_HOME`: alternate runtime namespace for live session control - `GZG_SESSION`: default named session Session commands: diff --git a/src/control.rs b/src/control.rs index 1d272b72..38c9c87f 100644 --- a/src/control.rs +++ b/src/control.rs @@ -1,19 +1,19 @@ -use std::{ - env, fs, io, - path::{Path, PathBuf}, - time::Duration, -}; +use std::{env, io, path::PathBuf, time::Duration}; + +#[cfg(unix)] +use std::fs; use serde::{Deserialize, Serialize}; use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, - net::{UnixListener, UnixStream}, + io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}, sync::{mpsc::UnboundedSender, oneshot}, task::JoinHandle, }; use crate::domain::ResourceId; +mod transport; + pub const GZG_RUNTIME_HOME_ENV: &str = "GZG_RUNTIME_HOME"; const CONTROL_SCHEMA_VERSION: u32 = 1; const CONTROL_TIMEOUT: Duration = Duration::from_millis(1200); @@ -119,7 +119,11 @@ fn runtime_dir_from_env( .and_then(runtime_suffix) .or_else(|| user.and_then(runtime_suffix)) .unwrap_or_else(stable_runtime_suffix); - PathBuf::from("/tmp").join(format!("ghzinga-{suffix}")) + #[cfg(unix)] + let base = PathBuf::from("/tmp"); + #[cfg(windows)] + let base = env::temp_dir(); + base.join(format!("ghzinga-{suffix}")) } fn runtime_suffix(value: std::ffi::OsString) -> Option { @@ -148,6 +152,7 @@ pub fn socket_path(session_id: &str) -> PathBuf { } pub struct ControlServer { + #[cfg(unix)] path: PathBuf, task: JoinHandle<()>, } @@ -155,7 +160,10 @@ pub struct ControlServer { impl Drop for ControlServer { fn drop(&mut self) { self.task.abort(); - let _ = fs::remove_file(&self.path); + #[cfg(unix)] + { + let _ = fs::remove_file(&self.path); + } } } @@ -163,46 +171,25 @@ pub fn start_server( session_id: &str, tx: UnboundedSender, ) -> io::Result { - let path = socket_path(session_id); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - remove_stale_socket(&path)?; - let listener = UnixListener::bind(&path)?; - set_owner_only_permissions(&path); + let listener = transport::Listener::bind(session_id)?; let task = tokio::spawn(server_loop(listener, tx)); - Ok(ControlServer { path, task }) -} - -fn remove_stale_socket(path: &Path) -> io::Result<()> { - if !path.exists() { - return Ok(()); - } - match std::os::unix::net::UnixStream::connect(path) { - Ok(_) => Err(io::Error::new( - io::ErrorKind::AddrInUse, - format!("ghzinga session socket already active: {}", path.display()), - )), - Err(_) => fs::remove_file(path), - } -} - -fn set_owner_only_permissions(path: &Path) { #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; + let server = ControlServer { + path: socket_path(session_id), + task, + }; + #[cfg(windows)] + let server = ControlServer { task }; + Ok(server) +} - if let Ok(metadata) = fs::metadata(path) { - let mut permissions = metadata.permissions(); - permissions.set_mode(0o600); - let _ = fs::set_permissions(path, permissions); - } - } +pub fn is_session_live(session_id: &str) -> bool { + transport::is_session_live(session_id) } -async fn server_loop(listener: UnixListener, tx: UnboundedSender) { +async fn server_loop(mut listener: transport::Listener, tx: UnboundedSender) { loop { - let Ok((stream, _)) = listener.accept().await else { + let Ok(stream) = listener.accept().await else { break; }; let tx = tx.clone(); @@ -212,10 +199,10 @@ async fn server_loop(listener: UnixListener, tx: UnboundedSender } } -async fn handle_connection( - stream: UnixStream, - tx: UnboundedSender, -) -> io::Result<()> { +async fn handle_connection(stream: S, tx: UnboundedSender) -> io::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ let mut reader = BufReader::new(stream); let mut line = String::new(); reader.read_line(&mut line).await?; @@ -346,12 +333,9 @@ pub async fn send_set(session_id: &str, key: &str, value: &str) -> io::Result io::Result { - let path = socket_path(session_id); - let mut stream = tokio::time::timeout(CONTROL_TIMEOUT, UnixStream::connect(&path)) + let mut stream = tokio::time::timeout(CONTROL_TIMEOUT, transport::connect(session_id)) .await - .map_err(|_| { - io::Error::new(io::ErrorKind::TimedOut, "control socket connect timed out") - })??; + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "control connect timed out"))??; stream .write_all(serde_json::to_string(&command)?.as_bytes()) .await?; @@ -390,6 +374,14 @@ mod tests { static ENV_LOCK: Mutex<()> = Mutex::const_new(()); + fn default_runtime_dir(suffix: &str) -> PathBuf { + #[cfg(unix)] + let base = PathBuf::from("/tmp"); + #[cfg(windows)] + let base = env::temp_dir(); + base.join(format!("ghzinga-{suffix}")) + } + #[test] fn runtime_dir_uses_override_then_xdg_then_tmp() { assert_eq!( @@ -402,7 +394,7 @@ mod tests { ); assert_eq!( runtime_dir_from_env(None, None, Some("1000".into()), None), - PathBuf::from("/tmp/ghzinga-1000") + default_runtime_dir("1000") ); } @@ -410,7 +402,7 @@ mod tests { fn runtime_dir_uses_stable_user_when_uid_env_is_missing() { assert_eq!( runtime_dir_from_env(None, None, None, Some("alice@example.com".into())), - PathBuf::from("/tmp/ghzinga-alice_example.com") + default_runtime_dir("alice_example.com") ); } @@ -446,9 +438,11 @@ mod tests { let previous_runtime = env::var_os(GZG_RUNTIME_HOME_ENV); env::set_var(GZG_RUNTIME_HOME_ENV, dir.path()); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let _server = start_server("work", tx).unwrap(); + let session_id = format!("work-{}", std::process::id()); + let _server = start_server(&session_id, tx).unwrap(); + assert!(is_session_live(&session_id)); let id = ResourceId::parse("owner/repo#12").unwrap(); - let client = tokio::spawn(async move { send_open("work", &id).await.unwrap() }); + let client = tokio::spawn(async move { send_open(&session_id, &id).await.unwrap() }); let request = rx.recv().await.unwrap(); assert!(matches!( diff --git a/src/control/transport.rs b/src/control/transport.rs new file mode 100644 index 00000000..f0bbf15a --- /dev/null +++ b/src/control/transport.rs @@ -0,0 +1,119 @@ +#[cfg(unix)] +mod platform { + use std::{ + fs, io, + os::unix::{fs::PermissionsExt, net::UnixStream as StdUnixStream}, + }; + + use tokio::net::{UnixListener, UnixStream}; + + use super::super::socket_path; + + pub(crate) struct Listener { + inner: UnixListener, + } + + impl Listener { + pub(crate) fn bind(session_id: &str) -> io::Result { + let path = socket_path(session_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + if path.exists() { + match StdUnixStream::connect(&path) { + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!("ghzinga session socket already active: {}", path.display()), + )); + } + Err(_) => fs::remove_file(&path)?, + } + } + let inner = UnixListener::bind(&path)?; + if let Ok(metadata) = fs::metadata(&path) { + let mut permissions = metadata.permissions(); + permissions.set_mode(0o600); + let _ = fs::set_permissions(path, permissions); + } + Ok(Self { inner }) + } + + pub(crate) async fn accept(&mut self) -> io::Result { + self.inner.accept().await.map(|(stream, _)| stream) + } + } + + pub(crate) async fn connect(session_id: &str) -> io::Result { + UnixStream::connect(socket_path(session_id)).await + } + + pub(crate) fn is_session_live(session_id: &str) -> bool { + StdUnixStream::connect(socket_path(session_id)).is_ok() + } +} + +#[cfg(windows)] +mod platform { + use std::{io, mem, time::Duration}; + + use super::super::runtime_dir; + use tokio::{ + net::windows::named_pipe::{ + ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions, + }, + time, + }; + + const ERROR_PIPE_BUSY_CODE: i32 = 231; + const PIPE_RETRY_DELAY: Duration = Duration::from_millis(50); + + pub(crate) struct Listener { + name: String, + pending: NamedPipeServer, + } + + impl Listener { + pub(crate) fn bind(session_id: &str) -> io::Result { + let name = pipe_name(session_id); + let pending = ServerOptions::new() + .first_pipe_instance(true) + .create(&name)?; + Ok(Self { name, pending }) + } + + pub(crate) async fn accept(&mut self) -> io::Result { + self.pending.connect().await?; + let next = ServerOptions::new().create(&self.name)?; + Ok(mem::replace(&mut self.pending, next)) + } + } + + pub(crate) async fn connect(session_id: &str) -> io::Result { + let name = pipe_name(session_id); + loop { + match ClientOptions::new().open(&name) { + Ok(client) => return Ok(client), + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY_CODE) => { + time::sleep(PIPE_RETRY_DELAY).await; + } + Err(error) => return Err(error), + } + } + } + + pub(crate) fn is_session_live(session_id: &str) -> bool { + ClientOptions::new().open(pipe_name(session_id)).is_ok() + } + + fn pipe_name(session_id: &str) -> String { + let mut runtime_hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in runtime_dir().as_os_str().to_string_lossy().bytes() { + runtime_hash ^= u64::from(byte); + runtime_hash = runtime_hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!(r"\\.\pipe\ghzinga-{runtime_hash:016x}-{session_id}") + } +} + +pub(crate) use platform::*; diff --git a/src/herdr_plugin.rs b/src/herdr_plugin.rs index 4ef3c93b..5f17dfc1 100644 --- a/src/herdr_plugin.rs +++ b/src/herdr_plugin.rs @@ -351,15 +351,20 @@ fn print_usage_to_stderr() { #[cfg(test)] mod tests { + #[cfg(unix)] use std::{env, ffi::OsString, fs, path::PathBuf, sync::Mutex}; + #[cfg(unix)] + use super::{herdr_source_key, run_open_entrypoint}; use super::{ - herdr_source_key, normalize_github_issue_or_pr_url, plugin_focuses_viewer, - plugin_pane_id_from_response, run_open_entrypoint, stable_key_for_text, state_key_for_pane, + normalize_github_issue_or_pr_url, plugin_focuses_viewer, plugin_pane_id_from_response, + stable_key_for_text, state_key_for_pane, }; + #[cfg(unix)] static ENV_LOCK: Mutex<()> = Mutex::new(()); + #[cfg(unix)] const PLUGIN_ENV_KEYS: &[&str] = &[ "GZG_FAKE_LOG", "GHZINGA_BIN", @@ -378,6 +383,7 @@ mod tests { "HERDR_SOCKET_PATH", ]; + #[cfg(unix)] fn first_file_with_extension(dir: &std::path::Path, extension: &str) -> PathBuf { let mut files = fs::read_dir(dir) .unwrap() @@ -388,10 +394,12 @@ mod tests { files.into_iter().next().unwrap() } + #[cfg(unix)] struct EnvRestore { values: Vec<(&'static str, Option)>, } + #[cfg(unix)] impl EnvRestore { fn clear() -> Self { let values = PLUGIN_ENV_KEYS @@ -406,6 +414,7 @@ mod tests { } } + #[cfg(unix)] impl Drop for EnvRestore { fn drop(&mut self) { for (key, value) in &self.values { @@ -482,6 +491,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn open_entrypoint_opens_new_plugin_pane_and_records_state() { let _lock = ENV_LOCK.lock().unwrap(); @@ -531,6 +541,7 @@ mod tests { assert!(!gzg_log.exists()); } + #[cfg(unix)] #[test] fn open_entrypoint_reuses_existing_matching_viewer_pane() { let _lock = ENV_LOCK.lock().unwrap(); @@ -575,6 +586,7 @@ mod tests { ))); } + #[cfg(unix)] #[test] fn open_entrypoint_reuses_current_viewer_when_link_originates_inside_ghzinga() { let _lock = ENV_LOCK.lock().unwrap(); @@ -627,6 +639,7 @@ mod tests { ))); } + #[cfg(unix)] #[test] fn open_entrypoint_uses_current_viewer_context_when_reverse_state_is_missing() { let _lock = ENV_LOCK.lock().unwrap(); @@ -664,6 +677,7 @@ mod tests { assert!(!gzg_log.contains("--session")); } + #[cfg(unix)] #[test] fn open_entrypoint_reopens_when_stored_pane_is_not_our_viewer() { let _lock = ENV_LOCK.lock().unwrap(); @@ -705,6 +719,7 @@ mod tests { assert!(!gzg_log.exists()); } + #[cfg(unix)] fn repo_file(path: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path) } diff --git a/src/runner.rs b/src/runner.rs index d21461f3..73e3b39f 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -454,7 +454,7 @@ fn print_sessions() -> anyhow::Result<()> { } fn session_live_status(id: &str) -> &'static str { - if std::os::unix::net::UnixStream::connect(control::socket_path(id)).is_ok() { + if control::is_session_live(id) { "running" } else { "saved" @@ -661,7 +661,9 @@ async fn run_tui( Some(runtime) => match control::start_server(&runtime.handle.id, control_tx) { Ok(server) => Some(server), Err(error) => { - state.last_error = Some(format!("failed to start ghzinga control socket: {error}")); + state.last_error = Some(format!( + "failed to start ghzinga control transport: {error}" + )); None } }, @@ -1580,10 +1582,10 @@ fn url_open_command(url: &str, browser: Option<&str>) -> (String, Vec) { #[cfg(target_os = "windows")] { - return ( + ( "cmd".into(), vec!["/C".into(), "start".into(), "".into(), url.into()], - ); + ) } #[cfg(not(any(target_os = "macos", target_os = "windows")))] @@ -1619,7 +1621,7 @@ fn current_clipboard_platform() -> ClipboardPlatform { #[cfg(target_os = "windows")] { - return ClipboardPlatform::Windows; + ClipboardPlatform::Windows } #[cfg(not(any(target_os = "macos", target_os = "windows")))] diff --git a/src/session.rs b/src/session.rs index 85e2dc62..dd11aba1 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,13 +1,15 @@ use std::{ collections::{BTreeMap, HashSet}, env, fs, io, - io::{BufRead, BufReader, Write}, path::{Path, PathBuf}, process::Command, str::FromStr, time::{SystemTime, UNIX_EPOCH}, }; +#[cfg(unix)] +use std::io::{BufRead, BufReader, Write}; + use serde::{Deserialize, Serialize}; use crate::{ @@ -812,32 +814,33 @@ pub fn save_session( Ok(snapshot) } +#[cfg(unix)] fn best_effort_mark_provider_session(handle: &SessionHandle, state: &mut AppState) { - #[cfg(unix)] - { - let Some(context) = handle - .contexts - .iter() - .find(|context| context.provider == "herdr") - else { - return; - }; - let (Some(socket), Some(pane)) = ( - context.metadata.get("socket_path"), - context.metadata.get("pane_id"), - ) else { - return; - }; - let active = state - .session_resource_tabs() - .get(state.active_resource_tab) - .map(|tab| tab.resource.id.canonical_name()) - .unwrap_or_else(|| "empty".into()); - let label = format!("gzg:{} {active}", handle.id); - let _ = write_herdr_pane_label(socket, pane, &label); - } + let Some(context) = handle + .contexts + .iter() + .find(|context| context.provider == "herdr") + else { + return; + }; + let (Some(socket), Some(pane)) = ( + context.metadata.get("socket_path"), + context.metadata.get("pane_id"), + ) else { + return; + }; + let active = state + .session_resource_tabs() + .get(state.active_resource_tab) + .map(|tab| tab.resource.id.canonical_name()) + .unwrap_or_else(|| "empty".into()); + let label = format!("gzg:{} {active}", handle.id); + let _ = write_herdr_pane_label(socket, pane, &label); } +#[cfg(not(unix))] +fn best_effort_mark_provider_session(_: &SessionHandle, _: &mut AppState) {} + #[cfg(unix)] fn write_herdr_pane_label(socket: &str, pane_id: &str, label: &str) -> io::Result<()> { use std::os::unix::net::UnixStream; @@ -896,6 +899,7 @@ fn read_herdr_pane_label(socket: &str, pane_id: &str) -> Option { .map(str::to_string) } +#[cfg(any(unix, test))] fn parse_herdr_session_marker(label: &str) -> Option { label .strip_prefix("gzg:") diff --git a/tests/architecture.rs b/tests/architecture.rs index e87c6b6c..a0efe383 100644 --- a/tests/architecture.rs +++ b/tests/architecture.rs @@ -304,13 +304,11 @@ fn gh_cli_shell_out_is_only_for_auth_token_fallback() { .into_iter() .filter_map(|path| { let source = fs::read_to_string(&path).expect("read source file"); - source - .contains("Command::new(\"gh\")") - .then_some(path.display().to_string()) + source.contains("Command::new(\"gh\")").then_some(path) }) .collect::>(); - assert_eq!(matches, ["src/github/auth.rs"]); + assert_eq!(matches, [PathBuf::from("src/github/auth.rs")]); } #[test] From a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:29:11 +0900 Subject: [PATCH 02/15] fix: harden Windows control channel Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- Cargo.lock | 2 + Cargo.toml | 8 ++ README.md | 5 +- src/control.rs | 204 +++++++++++++++++++++++------ src/control/transport.rs | 124 +++--------------- src/control/transport/unix.rs | 66 ++++++++++ src/control/transport/windows.rs | 214 +++++++++++++++++++++++++++++++ 7 files changed, 476 insertions(+), 147 deletions(-) create mode 100644 src/control/transport/unix.rs create mode 100644 src/control/transport/windows.rs diff --git a/Cargo.lock b/Cargo.lock index b5639f95..2dcb5d1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -683,6 +683,7 @@ dependencies = [ "assert_cmd", "clap", "crossterm", + "getrandom 0.3.4", "predicates", "ratatui", "regex", @@ -694,6 +695,7 @@ dependencies = [ "tokio", "toml", "unicode-width", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4076843a..95eb02e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,14 @@ tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt- toml = "0.8" unicode-width = "0.2" +[target.'cfg(windows)'.dependencies] +getrandom = "0.3" +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", +] } + [dev-dependencies] assert_cmd = "2" predicates = "3" diff --git a/README.md b/README.md index d60ee69a..cd28e036 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ Install from crates.io: cargo install ghzinga ``` -Linux, macOS, and Windows are supported. Live session control uses Unix domain -sockets on Unix and named pipes on Windows. +The standalone CLI and TUI support Linux, macOS, and Windows. Live session +control uses Unix domain sockets on Unix and authenticated, owner-only named +pipes on Windows. The optional Herdr plugin currently supports Linux and macOS. Install from a local checkout: diff --git a/src/control.rs b/src/control.rs index 38c9c87f..ce4f07e5 100644 --- a/src/control.rs +++ b/src/control.rs @@ -1,13 +1,10 @@ -use std::{env, io, path::PathBuf, time::Duration}; - -#[cfg(unix)] -use std::fs; +use std::{env, fs, io, path::PathBuf, time::Duration}; use serde::{Deserialize, Serialize}; use tokio::{ - io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}, + io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}, sync::{mpsc::UnboundedSender, oneshot}, - task::JoinHandle, + task::{JoinHandle, JoinSet}, }; use crate::domain::ResourceId; @@ -17,6 +14,8 @@ mod transport; pub const GZG_RUNTIME_HOME_ENV: &str = "GZG_RUNTIME_HOME"; const CONTROL_SCHEMA_VERSION: u32 = 1; const CONTROL_TIMEOUT: Duration = Duration::from_millis(1200); +const MAX_CONTROL_CONNECTIONS: usize = 32; +const MAX_CONTROL_LINE_BYTES: usize = 16 * 1024; #[derive(Debug)] pub struct RuntimeRequest { @@ -74,6 +73,8 @@ impl ControlReply { struct WireCommand { schema_version: u32, id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + auth_token: Option, method: String, #[serde(default)] resource: Option, @@ -152,7 +153,6 @@ pub fn socket_path(session_id: &str) -> PathBuf { } pub struct ControlServer { - #[cfg(unix)] path: PathBuf, task: JoinHandle<()>, } @@ -160,10 +160,7 @@ pub struct ControlServer { impl Drop for ControlServer { fn drop(&mut self) { self.task.abort(); - #[cfg(unix)] - { - let _ = fs::remove_file(&self.path); - } + let _ = fs::remove_file(&self.path); } } @@ -172,15 +169,9 @@ pub fn start_server( tx: UnboundedSender, ) -> io::Result { let listener = transport::Listener::bind(session_id)?; + let path = listener.cleanup_path(); let task = tokio::spawn(server_loop(listener, tx)); - #[cfg(unix)] - let server = ControlServer { - path: socket_path(session_id), - task, - }; - #[cfg(windows)] - let server = ControlServer { task }; - Ok(server) + Ok(ControlServer { path, task }) } pub fn is_session_live(session_id: &str) -> bool { @@ -188,25 +179,37 @@ pub fn is_session_live(session_id: &str) -> bool { } async fn server_loop(mut listener: transport::Listener, tx: UnboundedSender) { + let auth_token = listener.auth_token().map(str::to_string); + let mut connections = JoinSet::new(); loop { + while connections.len() >= MAX_CONTROL_CONNECTIONS { + let _ = connections.join_next().await; + } let Ok(stream) = listener.accept().await else { break; }; let tx = tx.clone(); - tokio::spawn(async move { - let _ = handle_connection(stream, tx).await; + let auth_token = auth_token.clone(); + connections.spawn(async move { + let _ = handle_connection(stream, tx, auth_token.as_deref()).await; }); + while connections.try_join_next().is_some() {} } } -async fn handle_connection(stream: S, tx: UnboundedSender) -> io::Result<()> +async fn handle_connection( + stream: S, + tx: UnboundedSender, + auth_token: Option<&str>, +) -> io::Result<()> where S: AsyncRead + AsyncWrite + Unpin, { let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader.read_line(&mut line).await?; - let command = parse_wire_command(&line); + let line = tokio::time::timeout(CONTROL_TIMEOUT, read_control_line(&mut reader)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "control request timed out"))??; + let command = parse_wire_command(&line, auth_token); let reply = match command { Ok(command) => { let request_id = command.request_id().to_string(); @@ -247,13 +250,19 @@ where Ok(()) } -fn parse_wire_command(line: &str) -> Result { +fn parse_wire_command( + line: &str, + expected_auth_token: Option<&str>, +) -> Result { let value = serde_json::from_str::(line).map_err(|error| { ( "unknown".to_string(), format!("invalid control command: {error}"), ) })?; + if value.auth_token.as_deref() != expected_auth_token { + return Err((value.id, "unauthorized control command".to_string())); + } if value.schema_version != CONTROL_SCHEMA_VERSION { return Err(( value.id, @@ -308,6 +317,7 @@ pub async fn send_open(session_id: &str, resource: &ResourceId) -> io::Result io::Result io::Result io::Result { - let mut stream = tokio::time::timeout(CONTROL_TIMEOUT, transport::connect(session_id)) - .await - .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "control connect timed out"))??; +async fn send_command(session_id: &str, mut command: WireCommand) -> io::Result { + let (mut stream, auth_token) = + tokio::time::timeout(CONTROL_TIMEOUT, transport::connect(session_id)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "control connect timed out"))??; + command.auth_token = auth_token; + let request_id = command.id.clone(); stream .write_all(serde_json::to_string(&command)?.as_bytes()) .await?; stream.write_all(b"\n").await?; stream.flush().await?; let mut reader = BufReader::new(stream); - let mut line = String::new(); - tokio::time::timeout(CONTROL_TIMEOUT, reader.read_line(&mut line)) + let line = tokio::time::timeout(CONTROL_TIMEOUT, read_control_line(&mut reader)) .await .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "control reply timed out"))??; - let reply = serde_json::from_str::(&line) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let reply = parse_wire_reply(&line, &request_id)?; Ok(ControlReply { ok: reply.ok, result: reply.result, @@ -355,6 +367,57 @@ async fn send_command(session_id: &str, command: WireCommand) -> io::Result(reader: &mut R) -> io::Result +where + R: AsyncBufRead + Unpin, +{ + let mut bytes = Vec::with_capacity(512); + loop { + let available = reader.fill_buf().await?; + if available.is_empty() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "control connection closed before newline", + )); + } + let consumed = available + .iter() + .position(|byte| *byte == b'\n') + .map_or(available.len(), |index| index + 1); + if bytes.len() + consumed > MAX_CONTROL_LINE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "control message exceeds size limit", + )); + } + let complete = available[consumed - 1] == b'\n'; + bytes.extend_from_slice(&available[..consumed]); + reader.consume(consumed); + if complete { + return String::from_utf8(bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); + } + } +} + +fn parse_wire_reply(line: &str, expected_id: &str) -> io::Result { + let reply = serde_json::from_str::(line) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if reply.schema_version != CONTROL_SCHEMA_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported control schema {}", reply.schema_version), + )); + } + if reply.id != expected_id { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "control reply id does not match request", + )); + } + Ok(reply) +} + fn command_id() -> String { use std::time::{SystemTime, UNIX_EPOCH}; @@ -410,6 +473,7 @@ mod tests { fn parses_open_wire_command() { let parsed = parse_wire_command( r#"{"schema_version":1,"id":"c_1","method":"open","resource":"owner/repo#12"}"#, + None, ) .unwrap(); @@ -424,13 +488,81 @@ mod tests { #[test] fn rejects_unknown_schema() { - let error = - parse_wire_command(r#"{"schema_version":2,"id":"c_1","method":"open"}"#).unwrap_err(); + let error = parse_wire_command(r#"{"schema_version":2,"id":"c_1","method":"open"}"#, None) + .unwrap_err(); assert_eq!(error.0, "c_1"); assert!(error.1.contains("unsupported control schema")); } + #[test] + fn rejects_missing_control_auth_token() { + let error = parse_wire_command( + r#"{"schema_version":1,"id":"c_1","method":"open","resource":"owner/repo#12"}"#, + Some("secret"), + ) + .unwrap_err(); + + assert_eq!(error.0, "c_1"); + assert!(error.1.contains("unauthorized")); + } + + #[test] + fn rejects_reply_for_different_request() { + let error = parse_wire_reply( + r#"{"schema_version":1,"id":"c_other","ok":true}"#, + "c_expected", + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[tokio::test(flavor = "current_thread")] + async fn rejects_oversized_control_message() { + let (mut writer, reader) = tokio::io::duplex(MAX_CONTROL_LINE_BYTES + 2); + let write = tokio::spawn(async move { + writer + .write_all(&vec![b'a'; MAX_CONTROL_LINE_BYTES + 1]) + .await + .unwrap(); + writer.write_all(b"\n").await.unwrap(); + }); + let error = read_control_line(&mut BufReader::new(reader)) + .await + .unwrap_err(); + write.await.unwrap(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[tokio::test(flavor = "current_thread")] + async fn idle_control_client_is_disconnected_after_timeout() { + let _guard = ENV_LOCK.lock().await; + let dir = tempdir().unwrap(); + let previous_runtime = env::var_os(GZG_RUNTIME_HOME_ENV); + env::set_var(GZG_RUNTIME_HOME_ENV, dir.path()); + let session_id = format!("idle-{}", std::process::id()); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let _server = start_server(&session_id, tx).unwrap(); + let (mut client, _) = transport::connect(&session_id).await.unwrap(); + + let error = tokio::time::timeout( + CONTROL_TIMEOUT * 2, + tokio::io::AsyncReadExt::read_u8(&mut client), + ) + .await + .expect("server should close idle clients") + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); + if let Some(value) = previous_runtime { + env::set_var(GZG_RUNTIME_HOME_ENV, value); + } else { + env::remove_var(GZG_RUNTIME_HOME_ENV); + } + } + #[tokio::test(flavor = "current_thread")] async fn server_receives_open_command_and_replies() { let _guard = ENV_LOCK.lock().await; diff --git a/src/control/transport.rs b/src/control/transport.rs index f0bbf15a..0cc11b58 100644 --- a/src/control/transport.rs +++ b/src/control/transport.rs @@ -1,119 +1,25 @@ #[cfg(unix)] -mod platform { - use std::{ - fs, io, - os::unix::{fs::PermissionsExt, net::UnixStream as StdUnixStream}, - }; - - use tokio::net::{UnixListener, UnixStream}; - - use super::super::socket_path; - - pub(crate) struct Listener { - inner: UnixListener, - } - - impl Listener { - pub(crate) fn bind(session_id: &str) -> io::Result { - let path = socket_path(session_id); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - if path.exists() { - match StdUnixStream::connect(&path) { - Ok(_) => { - return Err(io::Error::new( - io::ErrorKind::AddrInUse, - format!("ghzinga session socket already active: {}", path.display()), - )); - } - Err(_) => fs::remove_file(&path)?, - } - } - let inner = UnixListener::bind(&path)?; - if let Ok(metadata) = fs::metadata(&path) { - let mut permissions = metadata.permissions(); - permissions.set_mode(0o600); - let _ = fs::set_permissions(path, permissions); - } - Ok(Self { inner }) - } - - pub(crate) async fn accept(&mut self) -> io::Result { - self.inner.accept().await.map(|(stream, _)| stream) - } - } - - pub(crate) async fn connect(session_id: &str) -> io::Result { - UnixStream::connect(socket_path(session_id)).await - } - - pub(crate) fn is_session_live(session_id: &str) -> bool { - StdUnixStream::connect(socket_path(session_id)).is_ok() - } -} +#[path = "transport/unix.rs"] +mod platform; #[cfg(windows)] -mod platform { - use std::{io, mem, time::Duration}; - - use super::super::runtime_dir; - use tokio::{ - net::windows::named_pipe::{ - ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions, - }, - time, - }; +#[path = "transport/windows.rs"] +mod platform; - const ERROR_PIPE_BUSY_CODE: i32 = 231; - const PIPE_RETRY_DELAY: Duration = Duration::from_millis(50); +pub(crate) use platform::*; - pub(crate) struct Listener { - name: String, - pending: NamedPipeServer, - } +#[cfg(all(test, windows))] +mod tests { + use std::io; - impl Listener { - pub(crate) fn bind(session_id: &str) -> io::Result { - let name = pipe_name(session_id); - let pending = ServerOptions::new() - .first_pipe_instance(true) - .create(&name)?; - Ok(Self { name, pending }) - } + use super::platform::endpoint_path; - pub(crate) async fn accept(&mut self) -> io::Result { - self.pending.connect().await?; - let next = ServerOptions::new().create(&self.name)?; - Ok(mem::replace(&mut self.pending, next)) - } - } + #[test] + fn endpoint_rejects_invalid_session_ids() { + let separator = endpoint_path(r"work\other").unwrap_err(); + let oversized = endpoint_path(&"a".repeat(65)).unwrap_err(); - pub(crate) async fn connect(session_id: &str) -> io::Result { - let name = pipe_name(session_id); - loop { - match ClientOptions::new().open(&name) { - Ok(client) => return Ok(client), - Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY_CODE) => { - time::sleep(PIPE_RETRY_DELAY).await; - } - Err(error) => return Err(error), - } - } - } - - pub(crate) fn is_session_live(session_id: &str) -> bool { - ClientOptions::new().open(pipe_name(session_id)).is_ok() - } - - fn pipe_name(session_id: &str) -> String { - let mut runtime_hash = 0xcbf2_9ce4_8422_2325_u64; - for byte in runtime_dir().as_os_str().to_string_lossy().bytes() { - runtime_hash ^= u64::from(byte); - runtime_hash = runtime_hash.wrapping_mul(0x0000_0100_0000_01b3); - } - format!(r"\\.\pipe\ghzinga-{runtime_hash:016x}-{session_id}") + assert_eq!(separator.kind(), io::ErrorKind::InvalidInput); + assert_eq!(oversized.kind(), io::ErrorKind::InvalidInput); } } - -pub(crate) use platform::*; diff --git a/src/control/transport/unix.rs b/src/control/transport/unix.rs new file mode 100644 index 00000000..71e7bfca --- /dev/null +++ b/src/control/transport/unix.rs @@ -0,0 +1,66 @@ +use std::{ + fs, io, + os::unix::{fs::PermissionsExt, net::UnixStream as StdUnixStream}, + path::PathBuf, +}; + +use tokio::net::{UnixListener, UnixStream}; + +use super::super::socket_path; + +pub(crate) struct Listener { + inner: UnixListener, + cleanup_path: PathBuf, +} + +impl Listener { + pub(crate) fn bind(session_id: &str) -> io::Result { + let path = socket_path(session_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + if path.exists() { + match StdUnixStream::connect(&path) { + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!("ghzinga session socket already active: {}", path.display()), + )); + } + Err(_) => fs::remove_file(&path)?, + } + } + let inner = UnixListener::bind(&path)?; + if let Ok(metadata) = fs::metadata(&path) { + let mut permissions = metadata.permissions(); + permissions.set_mode(0o600); + let _ = fs::set_permissions(&path, permissions); + } + Ok(Self { + inner, + cleanup_path: path, + }) + } + + pub(crate) async fn accept(&mut self) -> io::Result { + self.inner.accept().await.map(|(stream, _)| stream) + } + + pub(crate) fn cleanup_path(&self) -> PathBuf { + self.cleanup_path.clone() + } + + pub(crate) fn auth_token(&self) -> Option<&str> { + None + } +} + +pub(crate) async fn connect(session_id: &str) -> io::Result<(UnixStream, Option)> { + UnixStream::connect(socket_path(session_id)) + .await + .map(|stream| (stream, None)) +} + +pub(crate) fn is_session_live(session_id: &str) -> bool { + StdUnixStream::connect(socket_path(session_id)).is_ok() +} diff --git a/src/control/transport/windows.rs b/src/control/transport/windows.rs new file mode 100644 index 00000000..df933977 --- /dev/null +++ b/src/control/transport/windows.rs @@ -0,0 +1,214 @@ +use std::{ + ffi::{c_void, OsStr}, + fmt::Write as _, + fs, io, iter, mem, + os::windows::ffi::OsStrExt, + path::PathBuf, + ptr, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; +use tokio::{ + net::windows::named_pipe::{ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions}, + time, +}; +use windows_sys::Win32::{ + Foundation::LocalFree, + Security::{ + Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW, PSECURITY_DESCRIPTOR, + SECURITY_ATTRIBUTES, + }, +}; + +use super::super::runtime_dir; + +const ERROR_PIPE_BUSY_CODE: i32 = 231; +const SDDL_REVISION_1: u32 = 1; +const PIPE_RETRY_DELAY: Duration = Duration::from_millis(50); +const OWNER_ONLY_PIPE_SDDL: &str = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;OW)"; + +#[derive(Serialize, Deserialize)] +struct EndpointInfo { + pipe_name: String, + auth_token: String, +} + +pub(crate) struct Listener { + name: String, + pending: NamedPipeServer, + cleanup_path: PathBuf, + auth_token: String, +} + +impl Listener { + pub(crate) fn bind(session_id: &str) -> io::Result { + let cleanup_path = endpoint_path(session_id)?; + if let Some(parent) = cleanup_path.parent() { + fs::create_dir_all(parent)?; + } + let name = format!(r"\\.\pipe\ghzinga-{}", random_hex()?); + let auth_token = random_hex()?; + let pending = create_server(&name, true)?; + let endpoint = EndpointInfo { + pipe_name: name.clone(), + auth_token: auth_token.clone(), + }; + fs::write( + &cleanup_path, + serde_json::to_vec(&endpoint).map_err(io::Error::other)?, + )?; + Ok(Self { + name, + pending, + cleanup_path, + auth_token, + }) + } + + pub(crate) async fn accept(&mut self) -> io::Result { + self.pending.connect().await?; + let next = create_server(&self.name, false)?; + Ok(mem::replace(&mut self.pending, next)) + } + + pub(crate) fn cleanup_path(&self) -> PathBuf { + self.cleanup_path.clone() + } + + pub(crate) fn auth_token(&self) -> Option<&str> { + Some(&self.auth_token) + } +} + +pub(crate) async fn connect(session_id: &str) -> io::Result<(NamedPipeClient, Option)> { + let endpoint = read_endpoint(session_id)?; + loop { + match ClientOptions::new().open(&endpoint.pipe_name) { + Ok(client) => return Ok((client, Some(endpoint.auth_token))), + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY_CODE) => { + time::sleep(PIPE_RETRY_DELAY).await; + } + Err(error) => return Err(error), + } + } +} + +pub(crate) fn is_session_live(session_id: &str) -> bool { + let Ok(endpoint) = read_endpoint(session_id) else { + return false; + }; + match ClientOptions::new().open(endpoint.pipe_name) { + Ok(_) => true, + Err(error) => error.raw_os_error() == Some(ERROR_PIPE_BUSY_CODE), + } +} + +pub(super) fn endpoint_path(session_id: &str) -> io::Result { + if session_id.is_empty() + || session_id.len() > 64 + || !session_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "session id must be 1-64 ASCII letters, digits, underscores, or hyphens", + )); + } + Ok(runtime_dir().join(format!("{session_id}.pipe"))) +} + +fn read_endpoint(session_id: &str) -> io::Result { + let endpoint = serde_json::from_slice::(&fs::read(endpoint_path(session_id)?)?) + .map_err(io::Error::other)?; + if !endpoint.pipe_name.starts_with(r"\\.\pipe\ghzinga-") + || endpoint.auth_token.len() != 32 + || !endpoint + .auth_token + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid ghzinga control endpoint", + )); + } + Ok(endpoint) +} + +fn random_hex() -> io::Result { + let mut bytes = [0_u8; 16]; + getrandom::fill(&mut bytes).map_err(|error| io::Error::other(error.to_string()))?; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + write!(output, "{byte:02x}").map_err(io::Error::other)?; + } + Ok(output) +} + +fn create_server(name: &str, first_instance: bool) -> io::Result { + let mut descriptor = OwnerOnlySecurityDescriptor::new()?; + let mut attributes = descriptor.attributes(); + let mut options = ServerOptions::new(); + options + .first_pipe_instance(first_instance) + .reject_remote_clients(true); + // SAFETY: [Category 8 — FFI boundary, Category 13 — unsafe contract] + // `attributes` points to a live SECURITY_ATTRIBUTES value whose security + // descriptor remains owned by `descriptor` for the duration of this call. + unsafe { + options.create_with_security_attributes_raw( + name, + ptr::from_mut(&mut attributes).cast::(), + ) + } +} + +struct OwnerOnlySecurityDescriptor { + raw: PSECURITY_DESCRIPTOR, +} + +impl OwnerOnlySecurityDescriptor { + fn new() -> io::Result { + let sddl = OsStr::new(OWNER_ONLY_PIPE_SDDL) + .encode_wide() + .chain(iter::once(0)) + .collect::>(); + let mut raw = ptr::null_mut(); + // SAFETY: [Category 8 — FFI boundary] + // `sddl` is NUL-terminated and remains live for the call, while `raw` + // points to writable storage for the descriptor allocated by Windows. + let converted = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut raw, + ptr::null_mut(), + ) + }; + if converted == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Self { raw }) + } + + fn attributes(&mut self) -> SECURITY_ATTRIBUTES { + SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: self.raw, + bInheritHandle: 0, + } + } +} + +impl Drop for OwnerOnlySecurityDescriptor { + fn drop(&mut self) { + // SAFETY: [Category 8 — FFI boundary, Category 12 — invalid free] + // `raw` is the unique descriptor returned by LocalAlloc through + // ConvertStringSecurityDescriptorToSecurityDescriptorW and is freed once. + unsafe { + LocalFree(self.raw.cast()); + } + } +} From b214386bb5b9d4c4e854669bdfd227ae3f2e3db6 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:30:07 +0900 Subject: [PATCH 03/15] test: refresh capture manifests Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- captures/ghzinga-issue-88499/large/manifest.json | 4 ++-- captures/ghzinga-issue-88499/manifest.json | 4 ++-- captures/ghzinga-issue-88499/medium/manifest.json | 4 ++-- captures/ghzinga-issue-88499/mouse-smoke/manifest.json | 4 ++-- captures/ghzinga-issue-88499/narrow/manifest.json | 4 ++-- captures/ghzinga-pr-81834/large/manifest.json | 4 ++-- captures/ghzinga-pr-81834/manifest.json | 4 ++-- captures/ghzinga-pr-81834/medium/manifest.json | 4 ++-- captures/ghzinga-pr-81834/mouse-smoke/manifest.json | 4 ++-- captures/ghzinga-pr-81834/narrow/manifest.json | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/captures/ghzinga-issue-88499/large/manifest.json b/captures/ghzinga-issue-88499/large/manifest.json index 1a058257..60907001 100644 --- a/captures/ghzinga-issue-88499/large/manifest.json +++ b/captures/ghzinga-issue-88499/large/manifest.json @@ -4,8 +4,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "6f6025ba04b4163207e40f9c2db7d587171d0b65", - "app_tree_hash": "77e909b0100ae5aabbd3f1835c7818b33714b140fbdf25300bb7f848c483c8e2", + "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, diff --git a/captures/ghzinga-issue-88499/manifest.json b/captures/ghzinga-issue-88499/manifest.json index 83f3224e..1c8a680f 100644 --- a/captures/ghzinga-issue-88499/manifest.json +++ b/captures/ghzinga-issue-88499/manifest.json @@ -3,8 +3,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "6f6025ba04b4163207e40f9c2db7d587171d0b65", - "app_tree_hash": "77e909b0100ae5aabbd3f1835c7818b33714b140fbdf25300bb7f848c483c8e2", + "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "offline_fixture": "fixtures/issue-88499.json", "offline_resource_fixtures": [], diff --git a/captures/ghzinga-issue-88499/medium/manifest.json b/captures/ghzinga-issue-88499/medium/manifest.json index ac8a95a7..2596c740 100644 --- a/captures/ghzinga-issue-88499/medium/manifest.json +++ b/captures/ghzinga-issue-88499/medium/manifest.json @@ -4,8 +4,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "6f6025ba04b4163207e40f9c2db7d587171d0b65", - "app_tree_hash": "77e909b0100ae5aabbd3f1835c7818b33714b140fbdf25300bb7f848c483c8e2", + "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, diff --git a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json index 4168403c..33cc47e2 100644 --- a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json +++ b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json @@ -5,8 +5,8 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "6f6025ba04b4163207e40f9c2db7d587171d0b65", - "app_tree_hash": "77e909b0100ae5aabbd3f1835c7818b33714b140fbdf25300bb7f848c483c8e2", + "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-issue-88499/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-issue-88499/mouse-smoke/capture-copy-url.sh ./target/debug/gzg https://github.com/openclaw/openclaw/issues/88499 --offline-fixture ./captures/ghzinga-issue-88499/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", diff --git a/captures/ghzinga-issue-88499/narrow/manifest.json b/captures/ghzinga-issue-88499/narrow/manifest.json index b51bef83..49c8092e 100644 --- a/captures/ghzinga-issue-88499/narrow/manifest.json +++ b/captures/ghzinga-issue-88499/narrow/manifest.json @@ -4,8 +4,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "6f6025ba04b4163207e40f9c2db7d587171d0b65", - "app_tree_hash": "77e909b0100ae5aabbd3f1835c7818b33714b140fbdf25300bb7f848c483c8e2", + "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, diff --git a/captures/ghzinga-pr-81834/large/manifest.json b/captures/ghzinga-pr-81834/large/manifest.json index 148ef03a..abd71d2b 100644 --- a/captures/ghzinga-pr-81834/large/manifest.json +++ b/captures/ghzinga-pr-81834/large/manifest.json @@ -4,8 +4,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "6f6025ba04b4163207e40f9c2db7d587171d0b65", - "app_tree_hash": "77e909b0100ae5aabbd3f1835c7818b33714b140fbdf25300bb7f848c483c8e2", + "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, diff --git a/captures/ghzinga-pr-81834/manifest.json b/captures/ghzinga-pr-81834/manifest.json index c76da93a..70685e2e 100644 --- a/captures/ghzinga-pr-81834/manifest.json +++ b/captures/ghzinga-pr-81834/manifest.json @@ -3,8 +3,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "6f6025ba04b4163207e40f9c2db7d587171d0b65", - "app_tree_hash": "77e909b0100ae5aabbd3f1835c7818b33714b140fbdf25300bb7f848c483c8e2", + "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "offline_fixture": null, "offline_resource_fixtures": [], diff --git a/captures/ghzinga-pr-81834/medium/manifest.json b/captures/ghzinga-pr-81834/medium/manifest.json index 93e82f04..5450e0be 100644 --- a/captures/ghzinga-pr-81834/medium/manifest.json +++ b/captures/ghzinga-pr-81834/medium/manifest.json @@ -4,8 +4,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "6f6025ba04b4163207e40f9c2db7d587171d0b65", - "app_tree_hash": "77e909b0100ae5aabbd3f1835c7818b33714b140fbdf25300bb7f848c483c8e2", + "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, diff --git a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json index 0e1c161d..0f87a9fc 100644 --- a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json +++ b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json @@ -5,8 +5,8 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "6f6025ba04b4163207e40f9c2db7d587171d0b65", - "app_tree_hash": "77e909b0100ae5aabbd3f1835c7818b33714b140fbdf25300bb7f848c483c8e2", + "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-pr-81834/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-pr-81834/mouse-smoke/capture-copy-url.sh ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", diff --git a/captures/ghzinga-pr-81834/narrow/manifest.json b/captures/ghzinga-pr-81834/narrow/manifest.json index 1e59cb79..3c7ff61a 100644 --- a/captures/ghzinga-pr-81834/narrow/manifest.json +++ b/captures/ghzinga-pr-81834/narrow/manifest.json @@ -4,8 +4,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "6f6025ba04b4163207e40f9c2db7d587171d0b65", - "app_tree_hash": "77e909b0100ae5aabbd3f1835c7818b33714b140fbdf25300bb7f848c483c8e2", + "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, From 88e2a3e401d8f1b0ab6ab0264d7a86dccb073591 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:46:14 +0900 Subject: [PATCH 04/15] docs: document Windows control boundary Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- ...2026-06-05-ghzinga-session-restore-spec.md | 27 +++++++++++++------ slophammer.yml | 3 +++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/2026-06-05-ghzinga-session-restore-spec.md b/docs/2026-06-05-ghzinga-session-restore-spec.md index ce366f11..bfbc5af3 100644 --- a/docs/2026-06-05-ghzinga-session-restore-spec.md +++ b/docs/2026-06-05-ghzinga-session-restore-spec.md @@ -350,7 +350,7 @@ Control command behavior: ## Runtime Control Channel -Each running persistent TUI owns a local Unix socket: +On Unix, each running persistent TUI owns a local socket: ```text $XDG_RUNTIME_DIR/ghzinga/.sock @@ -362,16 +362,27 @@ Fallback when `XDG_RUNTIME_DIR` is absent: /tmp/ghzinga-$UID/.sock ``` +On Windows, the per-user runtime directory contains an endpoint file: + +```text +%TEMP%\ghzinga-\.pipe +``` + +The endpoint file points to a cryptographically random, owner-only local named +pipe and carries the random authentication token used by control commands. + Rules: -- Socket paths are runtime-only and are not the session source of truth. -- On TUI startup, create the socket after the session id is resolved and remove - stale sockets for the same session when the owning process is gone. -- The socket should accept newline-delimited JSON commands so it can evolve - without breaking older clients. +- Control endpoints are runtime-only and are not the session source of truth. +- On TUI startup, create the platform endpoint after the session id is resolved + and remove stale endpoint metadata for the same session. +- Windows named pipes must reject remote clients, use an owner-only DACL, and + authenticate commands using an unguessable per-run token. +- The control transport accepts bounded newline-delimited JSON commands so it + can evolve without breaking older clients. - Commands must include a schema version and command id. -- Replies must include `ok`, the command id, and either a short result or an - error. +- Replies must include `ok`, the matching command id, and either a short result + or an error. - Unsupported command versions should fail cleanly with a user-facing error. - A command sent to a stale socket should fail fast, remove the stale runtime marker if safe, and fall back to saved-state mutation when appropriate. diff --git a/slophammer.yml b/slophammer.yml index c8432b1f..9d64e78d 100644 --- a/slophammer.yml +++ b/slophammer.yml @@ -37,6 +37,9 @@ rust: min_tokens: 100 unsafe: policy: forbid + allow: + - path: "src/control/transport/windows.rs" + reason: narrowly scoped Windows security-descriptor FFI required for owner-only named-pipe ACLs mutation: targets: - src/app From 8e3840d639a9c81faab7e157038c75039936aad8 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:00:36 +0900 Subject: [PATCH 05/15] fix: complete native Windows session support Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- README.md | 9 +- ...2026-06-05-ghzinga-session-restore-spec.md | 4 +- src/config.rs | 29 +++++- src/control.rs | 59 ++++++++++-- src/control/transport/unix.rs | 17 +++- src/control/transport/windows.rs | 89 +++++++++++++++++-- src/session.rs | 37 +++++++- 7 files changed, 220 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index cd28e036..79b430fe 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,8 @@ permission. The config file is: ```text -~/.config/ghzinga/config.toml +Linux/macOS: ~/.config/ghzinga/config.toml +Windows: %APPDATA%\ghzinga\config.toml ``` Default config: @@ -200,13 +201,15 @@ Supported setting values: `ghzinga` saves open PR/issue tabs and UI state under: ```text -~/.local/state/ghzinga +Linux/macOS: ~/.local/state/ghzinga +Windows: %LOCALAPPDATA%\ghzinga\state ``` Cached GitHub resource snapshots are stored separately under: ```text -~/.cache/ghzinga +Linux/macOS: ~/.cache/ghzinga +Windows: %LOCALAPPDATA%\ghzinga\cache ``` Environment overrides: diff --git a/docs/2026-06-05-ghzinga-session-restore-spec.md b/docs/2026-06-05-ghzinga-session-restore-spec.md index bfbc5af3..47f6b1da 100644 --- a/docs/2026-06-05-ghzinga-session-restore-spec.md +++ b/docs/2026-06-05-ghzinga-session-restore-spec.md @@ -384,7 +384,7 @@ Rules: - Replies must include `ok`, the matching command id, and either a short result or an error. - Unsupported command versions should fail cleanly with a user-facing error. -- A command sent to a stale socket should fail fast, remove the stale runtime +- A command sent to a stale endpoint should fail fast, remove the stale runtime marker if safe, and fall back to saved-state mutation when appropriate. - Runtime control is local only. Do not expose a network listener. - File and socket permissions should be owner-only where the platform permits @@ -495,7 +495,7 @@ Do not auto-delete sessions silently in the first version. width mode, fixed width, and scrollbar visibility. - Reuse existing app actions for opening/focusing tabs and applying settings so runtime commands behave the same as keyboard/mouse UI commands. -- Add tests for running-session command delivery, stale socket fallback, +- Add tests for running-session command delivery, stale endpoint fallback, ambiguous session resolution, saved-state mutation, and invalid setting errors. diff --git a/src/config.rs b/src/config.rs index dbd45315..28c3db48 100644 --- a/src/config.rs +++ b/src/config.rs @@ -136,9 +136,10 @@ pub fn save_to_path(path: &Path, config: AppConfig) -> io::Result<()> { } pub fn config_path() -> PathBuf { - config_path_from_env( + config_path_from_env_with_app_data( env::var_os("GZG_CONFIG_PATH"), env::var_os("XDG_CONFIG_HOME"), + env::var_os("APPDATA"), env::var_os("HOME"), ) } @@ -147,6 +148,15 @@ pub fn config_path_from_env( override_path: Option>, xdg_config_home: Option>, home: Option>, +) -> PathBuf { + config_path_from_env_with_app_data(override_path, xdg_config_home, None::, home) +} + +fn config_path_from_env_with_app_data( + override_path: Option>, + xdg_config_home: Option>, + app_data: Option>, + home: Option>, ) -> PathBuf { if let Some(path) = override_path { return path.into(); @@ -156,6 +166,10 @@ pub fn config_path_from_env( return path.into().join("ghzinga").join("config.toml"); } + if let Some(path) = app_data { + return path.into().join("ghzinga").join("config.toml"); + } + if let Some(home) = home { return home .into() @@ -303,6 +317,19 @@ mod tests { ); } + #[test] + fn config_path_uses_windows_app_data_before_home() { + assert_eq!( + config_path_from_env_with_app_data( + None::, + None::, + Some("C:/Users/alice/AppData/Roaming"), + Some("C:/Users/alice"), + ), + PathBuf::from("C:/Users/alice/AppData/Roaming/ghzinga/config.toml") + ); + } + #[test] fn save_writes_small_toml_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/control.rs b/src/control.rs index ce4f07e5..72379323 100644 --- a/src/control.rs +++ b/src/control.rs @@ -1,4 +1,4 @@ -use std::{env, fs, io, path::PathBuf, time::Duration}; +use std::{env, io, path::PathBuf, time::Duration}; use serde::{Deserialize, Serialize}; use tokio::{ @@ -153,14 +153,14 @@ pub fn socket_path(session_id: &str) -> PathBuf { } pub struct ControlServer { - path: PathBuf, + cleanup: transport::Cleanup, task: JoinHandle<()>, } impl Drop for ControlServer { fn drop(&mut self) { self.task.abort(); - let _ = fs::remove_file(&self.path); + self.cleanup.remove(); } } @@ -169,9 +169,9 @@ pub fn start_server( tx: UnboundedSender, ) -> io::Result { let listener = transport::Listener::bind(session_id)?; - let path = listener.cleanup_path(); + let cleanup = listener.cleanup(); let task = tokio::spawn(server_loop(listener, tx)); - Ok(ControlServer { path, task }) + Ok(ControlServer { cleanup, task }) } pub fn is_session_live(session_id: &str) -> bool { @@ -415,6 +415,14 @@ fn parse_wire_reply(line: &str, expected_id: &str) -> io::Result { "control reply id does not match request", )); } + if (reply.ok && reply.error.is_some()) + || (!reply.ok && (reply.result.is_some() || reply.error.is_none())) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "control reply fields do not match status", + )); + } Ok(reply) } @@ -518,6 +526,17 @@ mod tests { assert_eq!(error.kind(), io::ErrorKind::InvalidData); } + #[test] + fn rejects_inconsistent_control_reply() { + let error = parse_wire_reply( + r#"{"schema_version":1,"id":"c_1","ok":false,"result":"opened"}"#, + "c_1", + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + #[tokio::test(flavor = "current_thread")] async fn rejects_oversized_control_message() { let (mut writer, reader) = tokio::io::duplex(MAX_CONTROL_LINE_BYTES + 2); @@ -563,6 +582,36 @@ mod tests { } } + #[tokio::test(flavor = "current_thread")] + async fn duplicate_session_server_is_rejected() { + let _guard = ENV_LOCK.lock().await; + let dir = tempdir().unwrap(); + let previous_runtime = env::var_os(GZG_RUNTIME_HOME_ENV); + env::set_var(GZG_RUNTIME_HOME_ENV, dir.path()); + let session_id = format!("duplicate-{}", std::process::id()); + let (first_tx, _first_rx) = tokio::sync::mpsc::unbounded_channel(); + let first = start_server(&session_id, first_tx).unwrap(); + let (second_tx, _second_rx) = tokio::sync::mpsc::unbounded_channel(); + + let error = match start_server(&session_id, second_tx) { + Ok(_) => panic!("duplicate control server should be rejected"), + Err(error) => error, + }; + + assert_eq!(error.kind(), io::ErrorKind::AddrInUse); + let stale_cleanup = first.cleanup.clone(); + drop(first); + let (replacement_tx, _replacement_rx) = tokio::sync::mpsc::unbounded_channel(); + let _replacement = start_server(&session_id, replacement_tx).unwrap(); + stale_cleanup.remove(); + assert!(is_session_live(&session_id)); + if let Some(value) = previous_runtime { + env::set_var(GZG_RUNTIME_HOME_ENV, value); + } else { + env::remove_var(GZG_RUNTIME_HOME_ENV); + } + } + #[tokio::test(flavor = "current_thread")] async fn server_receives_open_command_and_replies() { let _guard = ENV_LOCK.lock().await; diff --git a/src/control/transport/unix.rs b/src/control/transport/unix.rs index 71e7bfca..47e2e956 100644 --- a/src/control/transport/unix.rs +++ b/src/control/transport/unix.rs @@ -13,6 +13,17 @@ pub(crate) struct Listener { cleanup_path: PathBuf, } +#[derive(Clone)] +pub(crate) struct Cleanup { + path: PathBuf, +} + +impl Cleanup { + pub(crate) fn remove(&self) { + let _ = fs::remove_file(&self.path); + } +} + impl Listener { pub(crate) fn bind(session_id: &str) -> io::Result { let path = socket_path(session_id); @@ -46,8 +57,10 @@ impl Listener { self.inner.accept().await.map(|(stream, _)| stream) } - pub(crate) fn cleanup_path(&self) -> PathBuf { - self.cleanup_path.clone() + pub(crate) fn cleanup(&self) -> Cleanup { + Cleanup { + path: self.cleanup_path.clone(), + } } pub(crate) fn auth_token(&self) -> Option<&str> { diff --git a/src/control/transport/windows.rs b/src/control/transport/windows.rs index df933977..539d59e0 100644 --- a/src/control/transport/windows.rs +++ b/src/control/transport/windows.rs @@ -3,7 +3,7 @@ use std::{ fmt::Write as _, fs, io, iter, mem, os::windows::ffi::OsStrExt, - path::PathBuf, + path::{Path, PathBuf}, ptr, time::Duration, }; @@ -41,6 +41,23 @@ pub(crate) struct Listener { auth_token: String, } +#[derive(Clone)] +pub(crate) struct Cleanup { + path: PathBuf, + auth_token: String, +} + +impl Cleanup { + pub(crate) fn remove(&self) { + let Ok(endpoint) = read_endpoint_path(&self.path) else { + return; + }; + if endpoint.auth_token == self.auth_token { + let _ = fs::remove_file(&self.path); + } + } +} + impl Listener { pub(crate) fn bind(session_id: &str) -> io::Result { let cleanup_path = endpoint_path(session_id)?; @@ -54,10 +71,7 @@ impl Listener { pipe_name: name.clone(), auth_token: auth_token.clone(), }; - fs::write( - &cleanup_path, - serde_json::to_vec(&endpoint).map_err(io::Error::other)?, - )?; + publish_endpoint(&cleanup_path, &endpoint)?; Ok(Self { name, pending, @@ -72,8 +86,11 @@ impl Listener { Ok(mem::replace(&mut self.pending, next)) } - pub(crate) fn cleanup_path(&self) -> PathBuf { - self.cleanup_path.clone() + pub(crate) fn cleanup(&self) -> Cleanup { + Cleanup { + path: self.cleanup_path.clone(), + auth_token: self.auth_token.clone(), + } } pub(crate) fn auth_token(&self) -> Option<&str> { @@ -120,8 +137,12 @@ pub(super) fn endpoint_path(session_id: &str) -> io::Result { } fn read_endpoint(session_id: &str) -> io::Result { - let endpoint = serde_json::from_slice::(&fs::read(endpoint_path(session_id)?)?) - .map_err(io::Error::other)?; + read_endpoint_path(&endpoint_path(session_id)?) +} + +fn read_endpoint_path(path: &Path) -> io::Result { + let endpoint = + serde_json::from_slice::(&fs::read(path)?).map_err(io::Error::other)?; if !endpoint.pipe_name.starts_with(r"\\.\pipe\ghzinga-") || endpoint.auth_token.len() != 32 || !endpoint @@ -137,6 +158,56 @@ fn read_endpoint(session_id: &str) -> io::Result { Ok(endpoint) } +fn publish_endpoint(path: &Path, endpoint: &EndpointInfo) -> io::Result<()> { + let temp_path = path.with_extension(format!("{}.tmp", endpoint.auth_token)); + let _pending = PendingEndpoint(temp_path.clone()); + fs::write( + &temp_path, + serde_json::to_vec(endpoint).map_err(io::Error::other)?, + )?; + for _ in 0..3 { + match fs::hard_link(&temp_path, path) { + Ok(()) => return Ok(()), + Err(_error) if path.exists() => { + if endpoint_path_is_live(path) { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + "ghzinga session control endpoint is already active", + )); + } + match fs::remove_file(path) { + Ok(()) => {} + Err(remove_error) if remove_error.kind() == io::ErrorKind::NotFound => {} + Err(remove_error) => return Err(remove_error), + } + } + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AddrInUse, + "ghzinga session control endpoint changed repeatedly", + )) +} + +fn endpoint_path_is_live(path: &Path) -> bool { + let Ok(endpoint) = read_endpoint_path(path) else { + return false; + }; + match ClientOptions::new().open(endpoint.pipe_name) { + Ok(_) => true, + Err(error) => error.raw_os_error() == Some(ERROR_PIPE_BUSY_CODE), + } +} + +struct PendingEndpoint(PathBuf); + +impl Drop for PendingEndpoint { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + fn random_hex() -> io::Result { let mut bytes = [0_u8; 16]; getrandom::fill(&mut bytes).map_err(|error| io::Error::other(error.to_string()))?; diff --git a/src/session.rs b/src/session.rs index dd11aba1..a450d929 100644 --- a/src/session.rs +++ b/src/session.rs @@ -334,6 +334,7 @@ pub fn state_dir() -> PathBuf { app_state_dir_from_env( env::var_os(GZG_STATE_HOME_ENV), env::var_os("XDG_STATE_HOME"), + env::var_os("LOCALAPPDATA"), env::var_os("HOME"), ) } @@ -342,6 +343,7 @@ pub fn cache_dir() -> PathBuf { app_cache_dir_from_env( env::var_os(GZG_CACHE_HOME_ENV), env::var_os("XDG_CACHE_HOME"), + env::var_os("LOCALAPPDATA"), env::var_os("HOME"), ) } @@ -349,6 +351,7 @@ pub fn cache_dir() -> PathBuf { fn app_state_dir_from_env( override_dir: Option, xdg_state: Option, + local_app_data: Option, home: Option, ) -> PathBuf { if let Some(path) = override_dir { @@ -357,6 +360,9 @@ fn app_state_dir_from_env( if let Some(path) = xdg_state { return PathBuf::from(path).join("ghzinga"); } + if let Some(path) = local_app_data { + return PathBuf::from(path).join("ghzinga").join("state"); + } home.map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(".local") @@ -367,6 +373,7 @@ fn app_state_dir_from_env( fn app_cache_dir_from_env( override_dir: Option, xdg_cache: Option, + local_app_data: Option, home: Option, ) -> PathBuf { if let Some(path) = override_dir { @@ -375,6 +382,9 @@ fn app_cache_dir_from_env( if let Some(path) = xdg_cache { return PathBuf::from(path).join("ghzinga"); } + if let Some(path) = local_app_data { + return PathBuf::from(path).join("ghzinga").join("cache"); + } home.map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) .join(".cache") @@ -1089,6 +1099,7 @@ fn normalize_session_id(input: &str) -> String { while output.contains("--") { output = output.replace("--", "-"); } + output.truncate(64); output = output.trim_matches('-').to_string(); if output.is_empty() { new_session_id() @@ -1325,15 +1336,37 @@ mod tests { #[test] fn state_and_cache_dirs_use_overrides() { assert_eq!( - app_state_dir_from_env(Some("/tmp/state".into()), None, None), + app_state_dir_from_env(Some("/tmp/state".into()), None, None, None), PathBuf::from("/tmp/state") ); assert_eq!( - app_cache_dir_from_env(Some("/tmp/cache".into()), None, None), + app_cache_dir_from_env(Some("/tmp/cache".into()), None, None, None), PathBuf::from("/tmp/cache") ); } + #[test] + fn state_and_cache_dirs_use_windows_local_app_data() { + let local = Some("C:/Users/alice/AppData/Local".into()); + + assert_eq!( + app_state_dir_from_env(None, None, local.clone(), None), + PathBuf::from("C:/Users/alice/AppData/Local/ghzinga/state") + ); + assert_eq!( + app_cache_dir_from_env(None, None, local, None), + PathBuf::from("C:/Users/alice/AppData/Local/ghzinga/cache") + ); + } + + #[test] + fn normalized_session_ids_fit_control_transport_limits() { + let normalized = normalize_session_id(&"a".repeat(80)); + + assert_eq!(normalized.len(), 64); + assert!(normalized.bytes().all(|byte| byte.is_ascii_alphanumeric())); + } + #[test] fn github_remote_context_parses_common_urls() { assert_eq!( From bbea5a44604e19182566c977a22eefe8ab465105 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:00:55 +0900 Subject: [PATCH 06/15] test: refresh capture manifests Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- captures/ghzinga-issue-88499/large/manifest.json | 2 +- captures/ghzinga-issue-88499/manifest.json | 2 +- captures/ghzinga-issue-88499/medium/manifest.json | 2 +- captures/ghzinga-issue-88499/mouse-smoke/manifest.json | 2 +- captures/ghzinga-issue-88499/narrow/manifest.json | 2 +- captures/ghzinga-pr-81834/large/manifest.json | 2 +- captures/ghzinga-pr-81834/manifest.json | 2 +- captures/ghzinga-pr-81834/medium/manifest.json | 2 +- captures/ghzinga-pr-81834/mouse-smoke/manifest.json | 2 +- captures/ghzinga-pr-81834/narrow/manifest.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/captures/ghzinga-issue-88499/large/manifest.json b/captures/ghzinga-issue-88499/large/manifest.json index 60907001..840cf391 100644 --- a/captures/ghzinga-issue-88499/large/manifest.json +++ b/captures/ghzinga-issue-88499/large/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 160, diff --git a/captures/ghzinga-issue-88499/manifest.json b/captures/ghzinga-issue-88499/manifest.json index 1c8a680f..b1ba25e1 100644 --- a/captures/ghzinga-issue-88499/manifest.json +++ b/captures/ghzinga-issue-88499/manifest.json @@ -3,7 +3,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "offline_fixture": "fixtures/issue-88499.json", diff --git a/captures/ghzinga-issue-88499/medium/manifest.json b/captures/ghzinga-issue-88499/medium/manifest.json index 2596c740..b060b288 100644 --- a/captures/ghzinga-issue-88499/medium/manifest.json +++ b/captures/ghzinga-issue-88499/medium/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 120, diff --git a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json index 33cc47e2..16b95e50 100644 --- a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json +++ b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-issue-88499/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-issue-88499/mouse-smoke/capture-copy-url.sh ./target/debug/gzg https://github.com/openclaw/openclaw/issues/88499 --offline-fixture ./captures/ghzinga-issue-88499/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", diff --git a/captures/ghzinga-issue-88499/narrow/manifest.json b/captures/ghzinga-issue-88499/narrow/manifest.json index 49c8092e..ba2b0bca 100644 --- a/captures/ghzinga-issue-88499/narrow/manifest.json +++ b/captures/ghzinga-issue-88499/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 80, diff --git a/captures/ghzinga-pr-81834/large/manifest.json b/captures/ghzinga-pr-81834/large/manifest.json index abd71d2b..17bcc9bb 100644 --- a/captures/ghzinga-pr-81834/large/manifest.json +++ b/captures/ghzinga-pr-81834/large/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 160, diff --git a/captures/ghzinga-pr-81834/manifest.json b/captures/ghzinga-pr-81834/manifest.json index 70685e2e..8de015b3 100644 --- a/captures/ghzinga-pr-81834/manifest.json +++ b/captures/ghzinga-pr-81834/manifest.json @@ -3,7 +3,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "offline_fixture": null, diff --git a/captures/ghzinga-pr-81834/medium/manifest.json b/captures/ghzinga-pr-81834/medium/manifest.json index 5450e0be..70176f9f 100644 --- a/captures/ghzinga-pr-81834/medium/manifest.json +++ b/captures/ghzinga-pr-81834/medium/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 120, diff --git a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json index 0f87a9fc..15938be1 100644 --- a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json +++ b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-pr-81834/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-pr-81834/mouse-smoke/capture-copy-url.sh ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", diff --git a/captures/ghzinga-pr-81834/narrow/manifest.json b/captures/ghzinga-pr-81834/narrow/manifest.json index 3c7ff61a..2feb91df 100644 --- a/captures/ghzinga-pr-81834/narrow/manifest.json +++ b/captures/ghzinga-pr-81834/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "a2a6f11402a7ea5b7b55a3c7c5c31c3e8e7e181e", + "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 80, From 772628c6748914b8c3571c73ac7ae03dab8958fd Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:09:04 +0900 Subject: [PATCH 07/15] fix: preserve replacement Unix sockets Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- ...2026-06-01-ghzinga-settings-config-plan.md | 5 +++-- ...2026-06-05-ghzinga-session-restore-spec.md | 14 +++++++++++-- src/control/transport/unix.rs | 21 +++++++++++++++++-- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/docs/2026-06-01-ghzinga-settings-config-plan.md b/docs/2026-06-01-ghzinga-settings-config-plan.md index 7888989d..bd9aaf54 100644 --- a/docs/2026-06-01-ghzinga-settings-config-plan.md +++ b/docs/2026-06-01-ghzinga-settings-config-plan.md @@ -16,10 +16,11 @@ live. Use a TOML config file: ```text -~/.config/ghzinga/config.toml +Linux/macOS: ~/.config/ghzinga/config.toml +Windows: %APPDATA%\ghzinga\config.toml ``` -Respect `XDG_CONFIG_HOME` when set: +Respect `XDG_CONFIG_HOME` when set on Unix: ```text $XDG_CONFIG_HOME/ghzinga/config.toml diff --git a/docs/2026-06-05-ghzinga-session-restore-spec.md b/docs/2026-06-05-ghzinga-session-restore-spec.md index 47f6b1da..a0e6020f 100644 --- a/docs/2026-06-05-ghzinga-session-restore-spec.md +++ b/docs/2026-06-05-ghzinga-session-restore-spec.md @@ -28,7 +28,7 @@ for GitHub resources. ## Storage Layout -Use XDG state and cache paths: +On Unix, use XDG state and cache paths: ```text $XDG_STATE_HOME/ghzinga/sessions//session.json @@ -45,6 +45,15 @@ Fallbacks: ~/.cache/ghzinga/... ``` +On Windows, use native per-user directories: + +```text +%LOCALAPPDATA%\ghzinga\state\sessions\\session.json +%LOCALAPPDATA%\ghzinga\state\session-index.json +%TEMP%\ghzinga-\.pipe +%LOCALAPPDATA%\ghzinga\cache\resources\\\.json +``` + Test and scripting overrides: ```text @@ -409,7 +418,8 @@ Initial reply shapes: Cache normalized GitHub resources separately from session files: ```text -$XDG_CACHE_HOME/ghzinga/resources/osolmaz/ghzinga/28.json +Unix: $XDG_CACHE_HOME/ghzinga/resources/osolmaz/ghzinga/28.json +Windows: %LOCALAPPDATA%\ghzinga\cache\resources\osolmaz\ghzinga\28.json ``` Cache metadata should include: diff --git a/src/control/transport/unix.rs b/src/control/transport/unix.rs index 47e2e956..77dc0770 100644 --- a/src/control/transport/unix.rs +++ b/src/control/transport/unix.rs @@ -1,6 +1,9 @@ use std::{ fs, io, - os::unix::{fs::PermissionsExt, net::UnixStream as StdUnixStream}, + os::unix::{ + fs::{MetadataExt, PermissionsExt}, + net::UnixStream as StdUnixStream, + }, path::PathBuf, }; @@ -11,16 +14,25 @@ use super::super::socket_path; pub(crate) struct Listener { inner: UnixListener, cleanup_path: PathBuf, + cleanup_device: u64, + cleanup_inode: u64, } #[derive(Clone)] pub(crate) struct Cleanup { path: PathBuf, + device: u64, + inode: u64, } impl Cleanup { pub(crate) fn remove(&self) { - let _ = fs::remove_file(&self.path); + let Ok(metadata) = fs::metadata(&self.path) else { + return; + }; + if metadata.dev() == self.device && metadata.ino() == self.inode { + let _ = fs::remove_file(&self.path); + } } } @@ -47,9 +59,12 @@ impl Listener { permissions.set_mode(0o600); let _ = fs::set_permissions(&path, permissions); } + let metadata = fs::metadata(&path)?; Ok(Self { inner, cleanup_path: path, + cleanup_device: metadata.dev(), + cleanup_inode: metadata.ino(), }) } @@ -60,6 +75,8 @@ impl Listener { pub(crate) fn cleanup(&self) -> Cleanup { Cleanup { path: self.cleanup_path.clone(), + device: self.cleanup_device, + inode: self.cleanup_inode, } } From a72d38f586a3e92fb9b6141937d0bbca4e3ccbd4 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:09:20 +0900 Subject: [PATCH 08/15] test: refresh capture manifests Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- captures/ghzinga-issue-88499/large/manifest.json | 2 +- captures/ghzinga-issue-88499/manifest.json | 2 +- captures/ghzinga-issue-88499/medium/manifest.json | 2 +- captures/ghzinga-issue-88499/mouse-smoke/manifest.json | 2 +- captures/ghzinga-issue-88499/narrow/manifest.json | 2 +- captures/ghzinga-pr-81834/large/manifest.json | 2 +- captures/ghzinga-pr-81834/manifest.json | 2 +- captures/ghzinga-pr-81834/medium/manifest.json | 2 +- captures/ghzinga-pr-81834/mouse-smoke/manifest.json | 2 +- captures/ghzinga-pr-81834/narrow/manifest.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/captures/ghzinga-issue-88499/large/manifest.json b/captures/ghzinga-issue-88499/large/manifest.json index 840cf391..725848a7 100644 --- a/captures/ghzinga-issue-88499/large/manifest.json +++ b/captures/ghzinga-issue-88499/large/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", + "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 160, diff --git a/captures/ghzinga-issue-88499/manifest.json b/captures/ghzinga-issue-88499/manifest.json index b1ba25e1..14adfc93 100644 --- a/captures/ghzinga-issue-88499/manifest.json +++ b/captures/ghzinga-issue-88499/manifest.json @@ -3,7 +3,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", + "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "offline_fixture": "fixtures/issue-88499.json", diff --git a/captures/ghzinga-issue-88499/medium/manifest.json b/captures/ghzinga-issue-88499/medium/manifest.json index b060b288..5b6b980a 100644 --- a/captures/ghzinga-issue-88499/medium/manifest.json +++ b/captures/ghzinga-issue-88499/medium/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", + "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 120, diff --git a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json index 16b95e50..fe269856 100644 --- a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json +++ b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", + "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-issue-88499/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-issue-88499/mouse-smoke/capture-copy-url.sh ./target/debug/gzg https://github.com/openclaw/openclaw/issues/88499 --offline-fixture ./captures/ghzinga-issue-88499/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", diff --git a/captures/ghzinga-issue-88499/narrow/manifest.json b/captures/ghzinga-issue-88499/narrow/manifest.json index ba2b0bca..532b191c 100644 --- a/captures/ghzinga-issue-88499/narrow/manifest.json +++ b/captures/ghzinga-issue-88499/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", + "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 80, diff --git a/captures/ghzinga-pr-81834/large/manifest.json b/captures/ghzinga-pr-81834/large/manifest.json index 17bcc9bb..2bff7a70 100644 --- a/captures/ghzinga-pr-81834/large/manifest.json +++ b/captures/ghzinga-pr-81834/large/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", + "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 160, diff --git a/captures/ghzinga-pr-81834/manifest.json b/captures/ghzinga-pr-81834/manifest.json index 8de015b3..6b176412 100644 --- a/captures/ghzinga-pr-81834/manifest.json +++ b/captures/ghzinga-pr-81834/manifest.json @@ -3,7 +3,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", + "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "offline_fixture": null, diff --git a/captures/ghzinga-pr-81834/medium/manifest.json b/captures/ghzinga-pr-81834/medium/manifest.json index 70176f9f..fd2ba471 100644 --- a/captures/ghzinga-pr-81834/medium/manifest.json +++ b/captures/ghzinga-pr-81834/medium/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", + "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 120, diff --git a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json index 15938be1..5063420f 100644 --- a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json +++ b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", + "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-pr-81834/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-pr-81834/mouse-smoke/capture-copy-url.sh ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", diff --git a/captures/ghzinga-pr-81834/narrow/manifest.json b/captures/ghzinga-pr-81834/narrow/manifest.json index 2feb91df..1310995d 100644 --- a/captures/ghzinga-pr-81834/narrow/manifest.json +++ b/captures/ghzinga-pr-81834/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "8e3840d639a9c81faab7e157038c75039936aad8", + "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 80, From f2f6f07f204c056b311426402cc9c071e47c02f8 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:16:15 +0900 Subject: [PATCH 09/15] fix: serialize control endpoint ownership Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- Cargo.lock | 11 ++++++ Cargo.toml | 1 + ...2026-06-05-ghzinga-session-restore-spec.md | 2 + src/control.rs | 6 +-- src/control/transport.rs | 37 +++++++++++++++++++ src/control/transport/unix.rs | 28 +++++++------- src/control/transport/windows.rs | 22 ++++++----- 7 files changed, 80 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2dcb5d1d..b5323985 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -593,6 +593,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-channel" version = "0.3.33" @@ -683,6 +693,7 @@ dependencies = [ "assert_cmd", "clap", "crossterm", + "fs2", "getrandom 0.3.4", "predicates", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index 95eb02e6..bbdf1c3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ path = "src/bin/ghzinga.rs" anyhow = "1" clap = { version = "4", features = ["derive"] } crossterm = { version = "0.29", features = ["bracketed-paste"] } +fs2 = "0.4" ratatui = "0.30" regex = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/docs/2026-06-05-ghzinga-session-restore-spec.md b/docs/2026-06-05-ghzinga-session-restore-spec.md index a0e6020f..939e49f3 100644 --- a/docs/2026-06-05-ghzinga-session-restore-spec.md +++ b/docs/2026-06-05-ghzinga-session-restore-spec.md @@ -385,6 +385,8 @@ Rules: - Control endpoints are runtime-only and are not the session source of truth. - On TUI startup, create the platform endpoint after the session id is resolved and remove stale endpoint metadata for the same session. +- Hold an exclusive per-session runtime lock for the server lifetime so endpoint + publication and cleanup cannot race with another process. - Windows named pipes must reject remote clients, use an owner-only DACL, and authenticate commands using an unguessable per-run token. - The control transport accepts bounded newline-delimited JSON commands so it diff --git a/src/control.rs b/src/control.rs index 72379323..ea17f8b4 100644 --- a/src/control.rs +++ b/src/control.rs @@ -168,8 +168,8 @@ pub fn start_server( session_id: &str, tx: UnboundedSender, ) -> io::Result { - let listener = transport::Listener::bind(session_id)?; - let cleanup = listener.cleanup(); + let mut listener = transport::Listener::bind(session_id)?; + let cleanup = listener.take_cleanup()?; let task = tokio::spawn(server_loop(listener, tx)); Ok(ControlServer { cleanup, task }) } @@ -599,11 +599,9 @@ mod tests { }; assert_eq!(error.kind(), io::ErrorKind::AddrInUse); - let stale_cleanup = first.cleanup.clone(); drop(first); let (replacement_tx, _replacement_rx) = tokio::sync::mpsc::unbounded_channel(); let _replacement = start_server(&session_id, replacement_tx).unwrap(); - stale_cleanup.remove(); assert!(is_session_live(&session_id)); if let Some(value) = previous_runtime { env::set_var(GZG_RUNTIME_HOME_ENV, value); diff --git a/src/control/transport.rs b/src/control/transport.rs index 0cc11b58..c9a05447 100644 --- a/src/control/transport.rs +++ b/src/control/transport.rs @@ -1,3 +1,11 @@ +use std::{ + fs::{self, File, OpenOptions}, + io, + path::Path, +}; + +use fs2::FileExt; + #[cfg(unix)] #[path = "transport/unix.rs"] mod platform; @@ -8,6 +16,35 @@ mod platform; pub(crate) use platform::*; +pub(crate) struct SessionLock(File); + +impl SessionLock { + pub(crate) fn acquire(path: &Path) -> io::Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(path)?; + file.try_lock_exclusive().map_err(|error| { + io::Error::new( + io::ErrorKind::AddrInUse, + format!("ghzinga session control endpoint is already active: {error}"), + ) + })?; + Ok(Self(file)) + } +} + +impl Drop for SessionLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.0); + } +} + #[cfg(all(test, windows))] mod tests { use std::io; diff --git a/src/control/transport/unix.rs b/src/control/transport/unix.rs index 77dc0770..5b0dcfa1 100644 --- a/src/control/transport/unix.rs +++ b/src/control/transport/unix.rs @@ -9,20 +9,18 @@ use std::{ use tokio::net::{UnixListener, UnixStream}; -use super::super::socket_path; +use super::{super::socket_path, SessionLock}; pub(crate) struct Listener { inner: UnixListener, - cleanup_path: PathBuf, - cleanup_device: u64, - cleanup_inode: u64, + cleanup: Option, } -#[derive(Clone)] pub(crate) struct Cleanup { path: PathBuf, device: u64, inode: u64, + _lock: SessionLock, } impl Cleanup { @@ -42,6 +40,7 @@ impl Listener { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } + let lock = SessionLock::acquire(&path.with_extension("lock"))?; if path.exists() { match StdUnixStream::connect(&path) { Ok(_) => { @@ -62,9 +61,12 @@ impl Listener { let metadata = fs::metadata(&path)?; Ok(Self { inner, - cleanup_path: path, - cleanup_device: metadata.dev(), - cleanup_inode: metadata.ino(), + cleanup: Some(Cleanup { + path, + device: metadata.dev(), + inode: metadata.ino(), + _lock: lock, + }), }) } @@ -72,12 +74,10 @@ impl Listener { self.inner.accept().await.map(|(stream, _)| stream) } - pub(crate) fn cleanup(&self) -> Cleanup { - Cleanup { - path: self.cleanup_path.clone(), - device: self.cleanup_device, - inode: self.cleanup_inode, - } + pub(crate) fn take_cleanup(&mut self) -> io::Result { + self.cleanup + .take() + .ok_or_else(|| io::Error::other("control cleanup already taken")) } pub(crate) fn auth_token(&self) -> Option<&str> { diff --git a/src/control/transport/windows.rs b/src/control/transport/windows.rs index 539d59e0..38127361 100644 --- a/src/control/transport/windows.rs +++ b/src/control/transport/windows.rs @@ -21,7 +21,7 @@ use windows_sys::Win32::{ }, }; -use super::super::runtime_dir; +use super::{super::runtime_dir, SessionLock}; const ERROR_PIPE_BUSY_CODE: i32 = 231; const SDDL_REVISION_1: u32 = 1; @@ -37,14 +37,14 @@ struct EndpointInfo { pub(crate) struct Listener { name: String, pending: NamedPipeServer, - cleanup_path: PathBuf, auth_token: String, + cleanup: Option, } -#[derive(Clone)] pub(crate) struct Cleanup { path: PathBuf, auth_token: String, + _lock: SessionLock, } impl Cleanup { @@ -64,6 +64,7 @@ impl Listener { if let Some(parent) = cleanup_path.parent() { fs::create_dir_all(parent)?; } + let lock = SessionLock::acquire(&cleanup_path.with_extension("lock"))?; let name = format!(r"\\.\pipe\ghzinga-{}", random_hex()?); let auth_token = random_hex()?; let pending = create_server(&name, true)?; @@ -75,8 +76,12 @@ impl Listener { Ok(Self { name, pending, - cleanup_path, auth_token, + cleanup: Some(Cleanup { + path: cleanup_path, + auth_token: endpoint.auth_token, + _lock: lock, + }), }) } @@ -86,11 +91,10 @@ impl Listener { Ok(mem::replace(&mut self.pending, next)) } - pub(crate) fn cleanup(&self) -> Cleanup { - Cleanup { - path: self.cleanup_path.clone(), - auth_token: self.auth_token.clone(), - } + pub(crate) fn take_cleanup(&mut self) -> io::Result { + self.cleanup + .take() + .ok_or_else(|| io::Error::other("control cleanup already taken")) } pub(crate) fn auth_token(&self) -> Option<&str> { From f3cb1d1abc816c9692de835abbcf8b8219a580e0 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:16:31 +0900 Subject: [PATCH 10/15] test: refresh capture manifests Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- captures/ghzinga-issue-88499/large/manifest.json | 4 ++-- captures/ghzinga-issue-88499/manifest.json | 4 ++-- captures/ghzinga-issue-88499/medium/manifest.json | 4 ++-- captures/ghzinga-issue-88499/mouse-smoke/manifest.json | 4 ++-- captures/ghzinga-issue-88499/narrow/manifest.json | 4 ++-- captures/ghzinga-pr-81834/large/manifest.json | 4 ++-- captures/ghzinga-pr-81834/manifest.json | 4 ++-- captures/ghzinga-pr-81834/medium/manifest.json | 4 ++-- captures/ghzinga-pr-81834/mouse-smoke/manifest.json | 4 ++-- captures/ghzinga-pr-81834/narrow/manifest.json | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/captures/ghzinga-issue-88499/large/manifest.json b/captures/ghzinga-issue-88499/large/manifest.json index 725848a7..954ccb36 100644 --- a/captures/ghzinga-issue-88499/large/manifest.json +++ b/captures/ghzinga-issue-88499/large/manifest.json @@ -4,8 +4,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", - "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", + "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", + "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, diff --git a/captures/ghzinga-issue-88499/manifest.json b/captures/ghzinga-issue-88499/manifest.json index 14adfc93..00f1fe1e 100644 --- a/captures/ghzinga-issue-88499/manifest.json +++ b/captures/ghzinga-issue-88499/manifest.json @@ -3,8 +3,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", - "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", + "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", + "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "offline_fixture": "fixtures/issue-88499.json", "offline_resource_fixtures": [], diff --git a/captures/ghzinga-issue-88499/medium/manifest.json b/captures/ghzinga-issue-88499/medium/manifest.json index 5b6b980a..9c5a480d 100644 --- a/captures/ghzinga-issue-88499/medium/manifest.json +++ b/captures/ghzinga-issue-88499/medium/manifest.json @@ -4,8 +4,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", - "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", + "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", + "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, diff --git a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json index fe269856..7bede69f 100644 --- a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json +++ b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json @@ -5,8 +5,8 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", - "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", + "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", + "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", "config_path": "captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-issue-88499/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-issue-88499/mouse-smoke/capture-copy-url.sh ./target/debug/gzg https://github.com/openclaw/openclaw/issues/88499 --offline-fixture ./captures/ghzinga-issue-88499/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", diff --git a/captures/ghzinga-issue-88499/narrow/manifest.json b/captures/ghzinga-issue-88499/narrow/manifest.json index 532b191c..8bfe490e 100644 --- a/captures/ghzinga-issue-88499/narrow/manifest.json +++ b/captures/ghzinga-issue-88499/narrow/manifest.json @@ -4,8 +4,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", - "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", + "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", + "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, diff --git a/captures/ghzinga-pr-81834/large/manifest.json b/captures/ghzinga-pr-81834/large/manifest.json index 2bff7a70..b6a61cde 100644 --- a/captures/ghzinga-pr-81834/large/manifest.json +++ b/captures/ghzinga-pr-81834/large/manifest.json @@ -4,8 +4,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", - "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", + "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", + "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, diff --git a/captures/ghzinga-pr-81834/manifest.json b/captures/ghzinga-pr-81834/manifest.json index 6b176412..a798dc69 100644 --- a/captures/ghzinga-pr-81834/manifest.json +++ b/captures/ghzinga-pr-81834/manifest.json @@ -3,8 +3,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", - "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", + "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", + "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "offline_fixture": null, "offline_resource_fixtures": [], diff --git a/captures/ghzinga-pr-81834/medium/manifest.json b/captures/ghzinga-pr-81834/medium/manifest.json index fd2ba471..1131dfe7 100644 --- a/captures/ghzinga-pr-81834/medium/manifest.json +++ b/captures/ghzinga-pr-81834/medium/manifest.json @@ -4,8 +4,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", - "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", + "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", + "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, diff --git a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json index 5063420f..a6005c36 100644 --- a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json +++ b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json @@ -5,8 +5,8 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", - "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", + "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", + "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", "config_path": "captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-pr-81834/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-pr-81834/mouse-smoke/capture-copy-url.sh ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", diff --git a/captures/ghzinga-pr-81834/narrow/manifest.json b/captures/ghzinga-pr-81834/narrow/manifest.json index 1310995d..584bf6a0 100644 --- a/captures/ghzinga-pr-81834/narrow/manifest.json +++ b/captures/ghzinga-pr-81834/narrow/manifest.json @@ -4,8 +4,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "772628c6748914b8c3571c73ac7ae03dab8958fd", - "app_tree_hash": "daf5483d2319d179392fc3a3df2f0abb33e9470fc26d833dbb7d7eb74f00e033", + "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", + "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, From 6d02bbac710d7cca5c91c4581d6ec412003ce345 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:39:18 +0900 Subject: [PATCH 11/15] test: exclude Windows-only transport mutants Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .cargo/mutants.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 00a33c2f..980b7dfe 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -8,6 +8,11 @@ # - submit_resource_action is the thin authenticated wrapper around the # GraphQL transport; its request construction and error handling are unit # tested through submit_resource_action_with. +# - Windows control transport behavior is covered by native Windows tests and +# cannot be exercised by the Ubuntu mutation job because the module is +# excluded at compile time by cfg(windows). +exclude_globs = ["src/control/transport/windows.rs"] + exclude_re = [ "replace run_tui .* with Ok\\(\\(\\)\\)", "replace restore_snapshot with \\(\\)", From 5aaba87c25aef201e49d73299b934650f7b4a973 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:48:57 +0900 Subject: [PATCH 12/15] test: cover control transport mutations Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- src/control.rs | 65 +++++++++++++++++++++++++++++++++++ src/control/transport.rs | 12 +++---- src/control/transport/unix.rs | 22 ++---------- src/runner.rs | 14 ++++++-- src/session.rs | 15 ++++++++ 5 files changed, 98 insertions(+), 30 deletions(-) diff --git a/src/control.rs b/src/control.rs index ea17f8b4..6f81bfd0 100644 --- a/src/control.rs +++ b/src/control.rs @@ -445,6 +445,11 @@ mod tests { static ENV_LOCK: Mutex<()> = Mutex::const_new(()); + #[test] + fn control_message_limit_is_sixteen_kibibytes() { + assert_eq!(MAX_CONTROL_LINE_BYTES, 16_384); + } + fn default_runtime_dir(suffix: &str) -> PathBuf { #[cfg(unix)] let base = PathBuf::from("/tmp"); @@ -537,6 +542,49 @@ mod tests { assert_eq!(error.kind(), io::ErrorKind::InvalidData); } + #[test] + fn rejects_failed_reply_with_result_even_when_error_is_present() { + let error = parse_wire_reply( + r#"{"schema_version":1,"id":"c_1","ok":false,"result":"opened","error":"failed"}"#, + "c_1", + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[tokio::test(flavor = "current_thread")] + async fn reads_chunked_line_without_consuming_following_data() { + let (mut writer, reader) = tokio::io::duplex(32); + writer.write_all(b"abc\nnext").await.unwrap(); + let mut reader = BufReader::with_capacity(2, reader); + + let line = read_control_line(&mut reader).await.unwrap(); + + assert_eq!(line, "abc\n"); + assert_eq!(reader.fill_buf().await.unwrap(), b"ne"); + } + + #[tokio::test(flavor = "current_thread")] + async fn accepts_control_message_at_exact_size_limit() { + let (mut writer, reader) = tokio::io::duplex(MAX_CONTROL_LINE_BYTES); + let write = tokio::spawn(async move { + writer + .write_all(&vec![b'a'; MAX_CONTROL_LINE_BYTES - 1]) + .await + .unwrap(); + writer.write_all(b"\n").await.unwrap(); + }); + + let line = read_control_line(&mut BufReader::with_capacity(7, reader)) + .await + .unwrap(); + write.await.unwrap(); + + assert_eq!(line.len(), MAX_CONTROL_LINE_BYTES); + assert!(line.ends_with('\n')); + } + #[tokio::test(flavor = "current_thread")] async fn rejects_oversized_control_message() { let (mut writer, reader) = tokio::io::duplex(MAX_CONTROL_LINE_BYTES + 2); @@ -640,4 +688,21 @@ mod tests { env::remove_var(GZG_RUNTIME_HOME_ENV); } } + + #[tokio::test(flavor = "current_thread")] + async fn unknown_session_is_not_live() { + let _guard = ENV_LOCK.lock().await; + let dir = tempdir().unwrap(); + let previous_runtime = env::var_os(GZG_RUNTIME_HOME_ENV); + env::set_var(GZG_RUNTIME_HOME_ENV, dir.path()); + let session_id = format!("missing-{}", std::process::id()); + + assert!(!is_session_live(&session_id)); + + if let Some(value) = previous_runtime { + env::set_var(GZG_RUNTIME_HOME_ENV, value); + } else { + env::remove_var(GZG_RUNTIME_HOME_ENV); + } + } } diff --git a/src/control/transport.rs b/src/control/transport.rs index c9a05447..bb82988f 100644 --- a/src/control/transport.rs +++ b/src/control/transport.rs @@ -16,7 +16,9 @@ mod platform; pub(crate) use platform::*; -pub(crate) struct SessionLock(File); +pub(crate) struct SessionLock { + _file: File, +} impl SessionLock { pub(crate) fn acquire(path: &Path) -> io::Result { @@ -35,13 +37,7 @@ impl SessionLock { format!("ghzinga session control endpoint is already active: {error}"), ) })?; - Ok(Self(file)) - } -} - -impl Drop for SessionLock { - fn drop(&mut self) { - let _ = FileExt::unlock(&self.0); + Ok(Self { _file: file }) } } diff --git a/src/control/transport/unix.rs b/src/control/transport/unix.rs index 5b0dcfa1..2b49ec2b 100644 --- a/src/control/transport/unix.rs +++ b/src/control/transport/unix.rs @@ -1,9 +1,6 @@ use std::{ fs, io, - os::unix::{ - fs::{MetadataExt, PermissionsExt}, - net::UnixStream as StdUnixStream, - }, + os::unix::{fs::PermissionsExt, net::UnixStream as StdUnixStream}, path::PathBuf, }; @@ -18,19 +15,12 @@ pub(crate) struct Listener { pub(crate) struct Cleanup { path: PathBuf, - device: u64, - inode: u64, _lock: SessionLock, } impl Cleanup { pub(crate) fn remove(&self) { - let Ok(metadata) = fs::metadata(&self.path) else { - return; - }; - if metadata.dev() == self.device && metadata.ino() == self.inode { - let _ = fs::remove_file(&self.path); - } + let _ = fs::remove_file(&self.path); } } @@ -58,15 +48,9 @@ impl Listener { permissions.set_mode(0o600); let _ = fs::set_permissions(&path, permissions); } - let metadata = fs::metadata(&path)?; Ok(Self { inner, - cleanup: Some(Cleanup { - path, - device: metadata.dev(), - inode: metadata.ino(), - _lock: lock, - }), + cleanup: Some(Cleanup { path, _lock: lock }), }) } diff --git a/src/runner.rs b/src/runner.rs index 73e3b39f..05a64a51 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1744,9 +1744,10 @@ mod tests { maybe_auto_refresh_with_start, maybe_load_file_patches_with_start, maybe_refresh_loading_active_resource, navigate_back, navigate_to_resource, parse_resource_args, prepare_restored_initial_fetch, resource_count_label, - save_open_commands_to_session, session_state_persistable, should_advance_loading_frame, - should_advance_scrollbar_fade, should_redraw_after_scrollbar_frame, - should_replace_empty_launch_tab, url_open_command, ClipboardPlatform, + save_open_commands_to_session, session_live_status, session_state_persistable, + should_advance_loading_frame, should_advance_scrollbar_fade, + should_redraw_after_scrollbar_frame, should_replace_empty_launch_tab, url_open_command, + ClipboardPlatform, }; struct FakeGateway { @@ -1754,6 +1755,13 @@ mod tests { requested: Mutex>, } + #[test] + fn missing_session_status_is_saved() { + let id = format!("missing-status-{}", std::process::id()); + + assert_eq!(session_live_status(&id), "saved"); + } + impl FakeGateway { fn new(resources: Vec) -> Self { Self { diff --git a/src/session.rs b/src/session.rs index a450d929..053638d7 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1345,6 +1345,21 @@ mod tests { ); } + #[test] + fn cache_dir_uses_runtime_override() { + let _guard = ENV_LOCK.lock().unwrap(); + let previous = env::var_os(GZG_CACHE_HOME_ENV); + env::set_var(GZG_CACHE_HOME_ENV, "C:/temp/ghzinga-cache"); + + assert_eq!(cache_dir(), PathBuf::from("C:/temp/ghzinga-cache")); + + if let Some(value) = previous { + env::set_var(GZG_CACHE_HOME_ENV, value); + } else { + env::remove_var(GZG_CACHE_HOME_ENV); + } + } + #[test] fn state_and_cache_dirs_use_windows_local_app_data() { let local = Some("C:/Users/alice/AppData/Local".into()); From df5b23f2dedee7cd0fc40c524287474b7d1ac6a5 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:49:09 +0900 Subject: [PATCH 13/15] test: refresh capture manifests Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- captures/ghzinga-issue-88499/large/manifest.json | 4 ++-- captures/ghzinga-issue-88499/manifest.json | 4 ++-- captures/ghzinga-issue-88499/medium/manifest.json | 4 ++-- captures/ghzinga-issue-88499/mouse-smoke/manifest.json | 4 ++-- captures/ghzinga-issue-88499/narrow/manifest.json | 4 ++-- captures/ghzinga-pr-81834/large/manifest.json | 4 ++-- captures/ghzinga-pr-81834/manifest.json | 4 ++-- captures/ghzinga-pr-81834/medium/manifest.json | 4 ++-- captures/ghzinga-pr-81834/mouse-smoke/manifest.json | 4 ++-- captures/ghzinga-pr-81834/narrow/manifest.json | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/captures/ghzinga-issue-88499/large/manifest.json b/captures/ghzinga-issue-88499/large/manifest.json index 954ccb36..d9651f6d 100644 --- a/captures/ghzinga-issue-88499/large/manifest.json +++ b/captures/ghzinga-issue-88499/large/manifest.json @@ -4,8 +4,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", - "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", + "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, diff --git a/captures/ghzinga-issue-88499/manifest.json b/captures/ghzinga-issue-88499/manifest.json index 00f1fe1e..78c6fca3 100644 --- a/captures/ghzinga-issue-88499/manifest.json +++ b/captures/ghzinga-issue-88499/manifest.json @@ -3,8 +3,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", - "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", + "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "offline_fixture": "fixtures/issue-88499.json", "offline_resource_fixtures": [], diff --git a/captures/ghzinga-issue-88499/medium/manifest.json b/captures/ghzinga-issue-88499/medium/manifest.json index 9c5a480d..fbe7d85a 100644 --- a/captures/ghzinga-issue-88499/medium/manifest.json +++ b/captures/ghzinga-issue-88499/medium/manifest.json @@ -4,8 +4,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", - "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", + "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, diff --git a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json index 7bede69f..e6c6d801 100644 --- a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json +++ b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json @@ -5,8 +5,8 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", - "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", + "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-issue-88499/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-issue-88499/mouse-smoke/capture-copy-url.sh ./target/debug/gzg https://github.com/openclaw/openclaw/issues/88499 --offline-fixture ./captures/ghzinga-issue-88499/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", diff --git a/captures/ghzinga-issue-88499/narrow/manifest.json b/captures/ghzinga-issue-88499/narrow/manifest.json index 8bfe490e..71199785 100644 --- a/captures/ghzinga-issue-88499/narrow/manifest.json +++ b/captures/ghzinga-issue-88499/narrow/manifest.json @@ -4,8 +4,8 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", - "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", + "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, diff --git a/captures/ghzinga-pr-81834/large/manifest.json b/captures/ghzinga-pr-81834/large/manifest.json index b6a61cde..5b7a60cc 100644 --- a/captures/ghzinga-pr-81834/large/manifest.json +++ b/captures/ghzinga-pr-81834/large/manifest.json @@ -4,8 +4,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", - "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", + "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, diff --git a/captures/ghzinga-pr-81834/manifest.json b/captures/ghzinga-pr-81834/manifest.json index a798dc69..c496bc8f 100644 --- a/captures/ghzinga-pr-81834/manifest.json +++ b/captures/ghzinga-pr-81834/manifest.json @@ -3,8 +3,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", - "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", + "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "offline_fixture": null, "offline_resource_fixtures": [], diff --git a/captures/ghzinga-pr-81834/medium/manifest.json b/captures/ghzinga-pr-81834/medium/manifest.json index 1131dfe7..62f18182 100644 --- a/captures/ghzinga-pr-81834/medium/manifest.json +++ b/captures/ghzinga-pr-81834/medium/manifest.json @@ -4,8 +4,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", - "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", + "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, diff --git a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json index a6005c36..325ef64f 100644 --- a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json +++ b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json @@ -5,8 +5,8 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", - "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", + "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-pr-81834/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-pr-81834/mouse-smoke/capture-copy-url.sh ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", diff --git a/captures/ghzinga-pr-81834/narrow/manifest.json b/captures/ghzinga-pr-81834/narrow/manifest.json index 584bf6a0..b8ffe919 100644 --- a/captures/ghzinga-pr-81834/narrow/manifest.json +++ b/captures/ghzinga-pr-81834/narrow/manifest.json @@ -4,8 +4,8 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "f2f6f07f204c056b311426402cc9c071e47c02f8", - "app_tree_hash": "7dbc87691f84d606147fe8d1c16a53b11a9e08cfeb66a90ff36d1b4ea5643499", + "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, From 4610a3b31158270e104a04acbd239c1146cbdb7d Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:52:12 +0900 Subject: [PATCH 14/15] test: cover empty control frames Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- src/control.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/control.rs b/src/control.rs index 6f81bfd0..bac923ff 100644 --- a/src/control.rs +++ b/src/control.rs @@ -565,6 +565,18 @@ mod tests { assert_eq!(reader.fill_buf().await.unwrap(), b"ne"); } + #[tokio::test(flavor = "current_thread")] + async fn reads_empty_control_line_without_consuming_following_data() { + let (mut writer, reader) = tokio::io::duplex(16); + writer.write_all(b"\nnext").await.unwrap(); + let mut reader = BufReader::with_capacity(2, reader); + + let line = read_control_line(&mut reader).await.unwrap(); + + assert_eq!(line, "\n"); + assert_eq!(reader.fill_buf().await.unwrap(), b"n"); + } + #[tokio::test(flavor = "current_thread")] async fn accepts_control_message_at_exact_size_limit() { let (mut writer, reader) = tokio::io::duplex(MAX_CONTROL_LINE_BYTES); From 6519f893eaf93137e1c12a97e042184c32ac1051 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:52:18 +0900 Subject: [PATCH 15/15] test: refresh capture manifests Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- captures/ghzinga-issue-88499/large/manifest.json | 2 +- captures/ghzinga-issue-88499/manifest.json | 2 +- captures/ghzinga-issue-88499/medium/manifest.json | 2 +- captures/ghzinga-issue-88499/mouse-smoke/manifest.json | 2 +- captures/ghzinga-issue-88499/narrow/manifest.json | 2 +- captures/ghzinga-pr-81834/large/manifest.json | 2 +- captures/ghzinga-pr-81834/manifest.json | 2 +- captures/ghzinga-pr-81834/medium/manifest.json | 2 +- captures/ghzinga-pr-81834/mouse-smoke/manifest.json | 2 +- captures/ghzinga-pr-81834/narrow/manifest.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/captures/ghzinga-issue-88499/large/manifest.json b/captures/ghzinga-issue-88499/large/manifest.json index d9651f6d..819f7825 100644 --- a/captures/ghzinga-issue-88499/large/manifest.json +++ b/captures/ghzinga-issue-88499/large/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "git_commit": "4610a3b31158270e104a04acbd239c1146cbdb7d", "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 160, diff --git a/captures/ghzinga-issue-88499/manifest.json b/captures/ghzinga-issue-88499/manifest.json index 78c6fca3..0701b026 100644 --- a/captures/ghzinga-issue-88499/manifest.json +++ b/captures/ghzinga-issue-88499/manifest.json @@ -3,7 +3,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "git_commit": "4610a3b31158270e104a04acbd239c1146cbdb7d", "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "offline_fixture": "fixtures/issue-88499.json", diff --git a/captures/ghzinga-issue-88499/medium/manifest.json b/captures/ghzinga-issue-88499/medium/manifest.json index fbe7d85a..3e22577d 100644 --- a/captures/ghzinga-issue-88499/medium/manifest.json +++ b/captures/ghzinga-issue-88499/medium/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "git_commit": "4610a3b31158270e104a04acbd239c1146cbdb7d", "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 120, diff --git a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json index e6c6d801..16b2ea1b 100644 --- a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json +++ b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "git_commit": "4610a3b31158270e104a04acbd239c1146cbdb7d", "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-issue-88499/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-issue-88499/mouse-smoke/capture-copy-url.sh ./target/debug/gzg https://github.com/openclaw/openclaw/issues/88499 --offline-fixture ./captures/ghzinga-issue-88499/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", diff --git a/captures/ghzinga-issue-88499/narrow/manifest.json b/captures/ghzinga-issue-88499/narrow/manifest.json index 71199785..3a75be67 100644 --- a/captures/ghzinga-issue-88499/narrow/manifest.json +++ b/captures/ghzinga-issue-88499/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "git_commit": "4610a3b31158270e104a04acbd239c1146cbdb7d", "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 80, diff --git a/captures/ghzinga-pr-81834/large/manifest.json b/captures/ghzinga-pr-81834/large/manifest.json index 5b7a60cc..155b1510 100644 --- a/captures/ghzinga-pr-81834/large/manifest.json +++ b/captures/ghzinga-pr-81834/large/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "git_commit": "4610a3b31158270e104a04acbd239c1146cbdb7d", "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 160, diff --git a/captures/ghzinga-pr-81834/manifest.json b/captures/ghzinga-pr-81834/manifest.json index c496bc8f..5fbdbeb6 100644 --- a/captures/ghzinga-pr-81834/manifest.json +++ b/captures/ghzinga-pr-81834/manifest.json @@ -3,7 +3,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "git_commit": "4610a3b31158270e104a04acbd239c1146cbdb7d", "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "offline_fixture": null, diff --git a/captures/ghzinga-pr-81834/medium/manifest.json b/captures/ghzinga-pr-81834/medium/manifest.json index 62f18182..e7694ec1 100644 --- a/captures/ghzinga-pr-81834/medium/manifest.json +++ b/captures/ghzinga-pr-81834/medium/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "git_commit": "4610a3b31158270e104a04acbd239c1146cbdb7d", "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 120, diff --git a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json index 325ef64f..c07abd35 100644 --- a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json +++ b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "git_commit": "4610a3b31158270e104a04acbd239c1146cbdb7d", "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-pr-81834/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-pr-81834/mouse-smoke/capture-copy-url.sh ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", diff --git a/captures/ghzinga-pr-81834/narrow/manifest.json b/captures/ghzinga-pr-81834/narrow/manifest.json index b8ffe919..d1650011 100644 --- a/captures/ghzinga-pr-81834/narrow/manifest.json +++ b/captures/ghzinga-pr-81834/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "5aaba87c25aef201e49d73299b934650f7b4a973", + "git_commit": "4610a3b31158270e104a04acbd239c1146cbdb7d", "app_tree_hash": "8602f176e5e95474cc1fec42d52947f8bba6b6c4233c59edd5ff0f4059137015", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 80,