From 22a90205b7be0e5c897b048e0b9325ead27b7c7a Mon Sep 17 00:00:00 2001 From: Maxime Gaudin Date: Thu, 10 Sep 2026 09:08:08 +0200 Subject: [PATCH 1/2] fix(remote): warn when proxied void binary is outdated Stale remote binaries reject newer CLI flags (e.g. --cc) with clap "unexpected argument" errors that look like local parse bugs. Surface local/remote versions in `void remote status` and warn on proxy skew. Co-authored-by: Cursor --- crates/void-cli/src/cli.rs | 34 ++++++++++++++++++ crates/void-core/src/store/mod.rs | 46 ++++++++++++++++++++++++ crates/void-core/src/store/remote/ssh.rs | 11 +++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/void-cli/src/cli.rs b/crates/void-cli/src/cli.rs index ada969c1..93ed3b94 100644 --- a/crates/void-cli/src/cli.rs +++ b/crates/void-cli/src/cli.rs @@ -548,6 +548,40 @@ mod tests { assert!(matches!(cli.command, Some(Command::Forward(_)))); } + #[test] + fn parse_gmail_draft_create_accepts_cc_with_connection() { + // Regression for #66: --cc must not be rejected as a typo for --connection. + let cli = parse(&[ + "void", + "gmail", + "draft", + "create", + "--to", + "a@b.com", + "--cc", + "c@d.com", + "--subject", + "s", + "--body", + "b", + "--connection", + "me@example.com", + ]); + match cli.command { + Some(Command::Gmail(ref g)) => match &g.command { + commands::gmail::GmailCommand::Draft(d) => match &d.action { + commands::gmail::DraftAction::Create(a) => { + assert_eq!(a.cc.as_deref(), Some("c@d.com")); + assert_eq!(a.connection.as_deref(), Some("me@example.com")); + } + other => panic!("expected Create, got {other:?}"), + }, + other => panic!("expected Draft, got {other:?}"), + }, + other => panic!("expected Gmail, got {other:?}"), + } + } + // --- Unsupported connector forward rejection --- #[test] diff --git a/crates/void-core/src/store/mod.rs b/crates/void-core/src/store/mod.rs index a678785f..84394bfa 100644 --- a/crates/void-core/src/store/mod.rs +++ b/crates/void-core/src/store/mod.rs @@ -315,6 +315,8 @@ impl ResolvedContext { "ssh_reachable": ssh_check, "remote_daemon_running": daemon_running, "proxy_writes": remote.proxy_writes, + "local_version": env!("CARGO_PKG_VERSION"), + "remote_version": remote_void_version(&remote.ssh), })) } @@ -355,6 +357,8 @@ impl ResolvedContext { } }; + warn_remote_version_skew(targets.void_version.as_deref(), env!("CARGO_PKG_VERSION")); + let store_path = remote.ssh.resolve_path_on_host(&remote.remote_store_path)?; let mut parts = vec![targets.void_bin.clone(), "--config".to_string()]; @@ -472,6 +476,41 @@ fn remote_daemon_running(ssh: &SshTarget, remote_store_path: &str) -> bool { .unwrap_or(false) } +fn remote_void_version(ssh: &SshTarget) -> Option { + let output = ssh + .run_remote(&format!("{REMOTE_PATH_PREFIX}; void --version 2>/dev/null | head -n1")) + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Strip optional `void ` prefix from `--version` output. +pub(crate) fn normalize_void_version(raw: &str) -> &str { + raw.trim() + .strip_prefix("void ") + .unwrap_or(raw) + .trim() +} + +fn warn_remote_version_skew(remote_version: Option<&str>, local_version: &str) { + let Some(remote_raw) = remote_version else { + return; + }; + let remote = normalize_void_version(remote_raw); + let local = normalize_void_version(local_version); + if remote != local { + eprintln!( + "warning: remote void is {remote}, local is {local}. \ + Update the server binary or proxied flags may fail with confusing clap errors." + ); + } +} + fn shell_escape(arg: &str) -> String { if arg.is_empty() { return "''".to_string(); @@ -515,4 +554,11 @@ mod tests { let bin = "/Users/me/bin/void"; assert_eq!(shell_escape(bin), bin); } + + #[test] + fn normalize_void_version_strips_prefix() { + assert_eq!(normalize_void_version("void 0.11.1"), "0.11.1"); + assert_eq!(normalize_void_version("0.11.1"), "0.11.1"); + assert_eq!(normalize_void_version(" void 0.10.3 "), "0.10.3"); + } } diff --git a/crates/void-core/src/store/remote/ssh.rs b/crates/void-core/src/store/remote/ssh.rs index ed00091b..20289a91 100644 --- a/crates/void-core/src/store/remote/ssh.rs +++ b/crates/void-core/src/store/remote/ssh.rs @@ -9,6 +9,8 @@ use super::REMOTE_PATH_PREFIX; pub struct RemoteProxyTargets { pub config_path: String, pub void_bin: String, + /// Remote `void --version` output (e.g. `void 0.11.1`), if resolvable. + pub void_version: Option, } #[derive(Debug, Clone)] @@ -64,7 +66,8 @@ impl SshTarget { "{REMOTE_PATH_PREFIX}; \ home=$(printf %s \"$HOME\"); \ bin=$(command -v void); \ - printf '%s\n%s\n' \"$home\" \"$bin\"" + ver=$(\"$bin\" --version 2>/dev/null | head -n1); \ + printf '%s\n%s\n%s\n' \"$home\" \"$bin\" \"$ver\"" ))?; if !output.status.success() { return Err(ConfigError::Remote( @@ -89,6 +92,11 @@ impl SshTarget { .into(), ) })?; + let void_version = lines + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); let resolved_config = if let Some(rest) = config_path.strip_prefix("~/") { format!("{home}/{rest}") @@ -101,6 +109,7 @@ impl SshTarget { Ok(RemoteProxyTargets { config_path: resolved_config, void_bin: void_bin.to_string(), + void_version, }) } From 01a4aa4c9403ed8e03d75b77244ba528993f01f9 Mon Sep 17 00:00:00 2001 From: Maxime Gaudin Date: Thu, 10 Sep 2026 09:37:31 +0200 Subject: [PATCH 2/2] fix(remote): normalize version skew output and cover it with tests Apply review follow-ups on the version-skew warning: - Normalize both sides of the comparison, including `remote_version` in `void remote status`, so the reported client and server versions are directly comparable and tolerate trailing build metadata. - Skip the extra `void --version` SSH round-trip in `remote status` when the host is already known to be unreachable. - Split the warning text out of the emission path as `version_skew_message` and unit-test the None/equal/mismatch cases. - Run `cargo fmt`, document the new status fields, and add the changelog entry. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 ++++ crates/void-core/src/store/mod.rs | 69 ++++++++++++++++++++----------- docs/remote-store.md | 4 +- 3 files changed, 56 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f60cb0dc..2e760ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Remote** — `void remote status` reports `local_version` and `remote_version` so version skew between the client and the server binary is visible at a glance. + +### Fixed + +- **Remote** — Proxied write commands print a warning on stderr when the server's `void` is a different version than the local client, instead of surfacing confusing `unexpected argument` errors from the older remote binary. + ## [0.11.1] - 2026-08-20 ### Fixed diff --git a/crates/void-core/src/store/mod.rs b/crates/void-core/src/store/mod.rs index 84394bfa..2bd08692 100644 --- a/crates/void-core/src/store/mod.rs +++ b/crates/void-core/src/store/mod.rs @@ -302,6 +302,9 @@ impl ResolvedContext { .unwrap_or(false); let daemon_running = remote_daemon_running(&remote.ssh, &remote.remote_store_path); + let remote_version = ssh_check + .then(|| remote_void_version(&remote.ssh)) + .flatten(); Ok(serde_json::json!({ "mode": "remote", @@ -315,8 +318,8 @@ impl ResolvedContext { "ssh_reachable": ssh_check, "remote_daemon_running": daemon_running, "proxy_writes": remote.proxy_writes, - "local_version": env!("CARGO_PKG_VERSION"), - "remote_version": remote_void_version(&remote.ssh), + "local_version": normalize_void_version(env!("CARGO_PKG_VERSION")), + "remote_version": remote_version, })) } @@ -357,7 +360,11 @@ impl ResolvedContext { } }; - warn_remote_version_skew(targets.void_version.as_deref(), env!("CARGO_PKG_VERSION")); + if let Some(warning) = + version_skew_message(targets.void_version.as_deref(), env!("CARGO_PKG_VERSION")) + { + eprintln!("{warning}"); + } let store_path = remote.ssh.resolve_path_on_host(&remote.remote_store_path)?; @@ -478,37 +485,39 @@ fn remote_daemon_running(ssh: &SshTarget, remote_store_path: &str) -> bool { fn remote_void_version(ssh: &SshTarget) -> Option { let output = ssh - .run_remote(&format!("{REMOTE_PATH_PREFIX}; void --version 2>/dev/null | head -n1")) + .run_remote(&format!( + "{REMOTE_PATH_PREFIX}; void --version 2>/dev/null | head -n1" + )) .ok()?; if !output.status.success() { return None; } - String::from_utf8(output.stdout) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + let raw = String::from_utf8(output.stdout).ok()?; + let version = normalize_void_version(&raw); + (!version.is_empty()).then(|| version.to_string()) } -/// Strip optional `void ` prefix from `--version` output. -pub(crate) fn normalize_void_version(raw: &str) -> &str { - raw.trim() - .strip_prefix("void ") - .unwrap_or(raw) - .trim() +/// Reduce `void --version` output to a bare version string. +/// +/// Accepts `void 0.11.1`, `0.11.1`, and trailing build metadata +/// (`void 0.11.1 (abc1234)`), so comparisons never trip on formatting. +fn normalize_void_version(raw: &str) -> &str { + let raw = raw.trim(); + let rest = raw.strip_prefix("void ").unwrap_or(raw); + rest.split_whitespace().next().unwrap_or("") } -fn warn_remote_version_skew(remote_version: Option<&str>, local_version: &str) { - let Some(remote_raw) = remote_version else { - return; - }; - let remote = normalize_void_version(remote_raw); +/// Warning to emit when the remote `void` differs from the local one, if any. +fn version_skew_message(remote_version: Option<&str>, local_version: &str) -> Option { + let remote = normalize_void_version(remote_version?); let local = normalize_void_version(local_version); - if remote != local { - eprintln!( - "warning: remote void is {remote}, local is {local}. \ - Update the server binary or proxied flags may fail with confusing clap errors." - ); + if remote.is_empty() || remote == local { + return None; } + Some(format!( + "warning: remote void is {remote}, local is {local}. \ + Update the server binary or proxied flags may fail with confusing clap errors." + )) } fn shell_escape(arg: &str) -> String { @@ -560,5 +569,17 @@ mod tests { assert_eq!(normalize_void_version("void 0.11.1"), "0.11.1"); assert_eq!(normalize_void_version("0.11.1"), "0.11.1"); assert_eq!(normalize_void_version(" void 0.10.3 "), "0.10.3"); + assert_eq!(normalize_void_version("void 0.11.1 (abc1234)"), "0.11.1"); + assert_eq!(normalize_void_version(" "), ""); + } + + #[test] + fn version_skew_message_only_on_mismatch() { + assert_eq!(version_skew_message(None, "0.11.1"), None); + assert_eq!(version_skew_message(Some("void 0.11.1"), "0.11.1"), None); + assert_eq!(version_skew_message(Some(""), "0.11.1"), None); + let warning = version_skew_message(Some("void 0.10.3"), "0.11.1").expect("skew warning"); + assert!(warning.contains("remote void is 0.10.3")); + assert!(warning.contains("local is 0.11.1")); } } diff --git a/docs/remote-store.md b/docs/remote-store.md index d97d4fa0..8abe34a2 100644 --- a/docs/remote-store.md +++ b/docs/remote-store.md @@ -59,13 +59,15 @@ All `[store.remote]` options: ## Inspecting and refreshing ```bash -# SSH connectivity, cache age, remote daemon state +# SSH connectivity, cache age, remote daemon state, client/server versions void remote status # Force-refresh config + DB snapshot void remote refresh ``` +`void remote status` also reports `local_version` (this client) and `remote_version` (the server's `void`, `null` when unreachable). When they differ, proxied write commands print a warning: an outdated server binary rejects flags the local client accepts. + Useful flags: - `--config ` — pick a different local client profile