From 3aa762be3cc5aaf5e408c785d299943d1fcc0ad2 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:50 +0300 Subject: [PATCH 01/20] platform: find node next to npm-installed CLIs A GUI app starts with a minimal PATH, so `#!/usr/bin/env node` launchers failed: the Codex daemon restart was skipped without a message, and OpenCode commands failed. Add node's directory and the CLI's own (nvm, fnm and Volta keep node there) to the PATH of every npm-installed CLI Wizard runs, and look for Pi's node and npm next to Pi. --- crates/wizard-core/src/agents/codex.rs | 7 +++++-- crates/wizard-core/src/agents/detection.rs | 9 ++------- crates/wizard-core/src/agents/opencode.rs | 14 ++++---------- crates/wizard-core/src/agents/pi.rs | 10 ++++++++-- crates/wizard-core/src/platform/mod.rs | 16 ++++++++++++++++ 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/crates/wizard-core/src/agents/codex.rs b/crates/wizard-core/src/agents/codex.rs index 61c3b83..ff0ffc1 100644 --- a/crates/wizard-core/src/agents/codex.rs +++ b/crates/wizard-core/src/agents/codex.rs @@ -9,7 +9,8 @@ use crate::{ changes::{FileChange, FileSnapshot}, }, platform::{ - command_output, daemon_command_status, env_var_not_empty, find_executable, is_executable, + append_node_path, command_output, daemon_command_status, env_var_not_empty, + find_executable, is_executable, }, prepared_plan::{PreparedAgent, PreparedPlan, PreparedStep}, }; @@ -119,6 +120,7 @@ pub fn restart_daemon(executable: &Path, codex_home: &Path) -> Result<()> { // daemon on updates. It keeps variables later removed from `.env`, and one started by an // older Wizard stays attributed to Wizard by macOS. Bootstrap resets remote control, though. let mut command = tokio::process::Command::new(executable); + append_node_path(&mut command)?; command .args(["app-server", "daemon", "bootstrap"]) .env("CODEX_HOME", codex_home); @@ -163,7 +165,8 @@ fn managed_daemon_running(executable: &Path, codex_home: &Path) -> bool { // TODO: Distinguish an absent daemon from command failures and unsupported versions. // For now, spawn errors, timeouts, nonzero exit statuses and unmanaged servers all mean // "no daemon". - command_output(probe, Duration::from_secs(15)) + append_node_path(&mut probe) + .and_then(|()| command_output(probe, Duration::from_secs(15))) .ok() .filter(|output| output.status.success()) .and_then(|output| serde_json::from_slice::(&output.stdout).ok()) diff --git a/crates/wizard-core/src/agents/detection.rs b/crates/wizard-core/src/agents/detection.rs index f10ffbb..2ebd63c 100644 --- a/crates/wizard-core/src/agents/detection.rs +++ b/crates/wizard-core/src/agents/detection.rs @@ -3,7 +3,7 @@ use super::AgentDetection; #[cfg(target_os = "macos")] use crate::platform::env_var_not_empty; -use crate::platform::{append_command_path, command_output, find_executable}; +use crate::platform::{append_node_path, command_output, find_executable}; use anyhow::{Context as _, Result}; use core::time::Duration; use serde::Serialize; @@ -90,12 +90,7 @@ fn cli_version(agent: Agent, path: &Path) -> Result { .arg("--version") .stdout(Stdio::piped()) .stderr(Stdio::piped()); - if let Some(node) = find_executable("node", vec![], None)? { - let directory = node - .parent() - .context("Node executable has no parent directory")?; - append_command_path(&mut command, &[directory])?; - } + append_node_path(&mut command)?; let output = command_output(command, Duration::from_secs(5))?; anyhow::ensure!( output.status.success(), diff --git a/crates/wizard-core/src/agents/opencode.rs b/crates/wizard-core/src/agents/opencode.rs index 4feaf2e..9ffb26d 100644 --- a/crates/wizard-core/src/agents/opencode.rs +++ b/crates/wizard-core/src/agents/opencode.rs @@ -4,10 +4,7 @@ use super::{ }; use crate::{ config_files::{self, changes::FileChange}, - platform::{ - append_command_path, command_output, command_output_with_stdin, env_var_not_empty, - find_executable, - }, + platform::{append_node_path, command_output, command_output_with_stdin, env_var_not_empty}, prepared_plan::{PreparedAgent, PreparedPlan, PreparedStep}, }; use anyhow::{Context as _, Result, bail, ensure}; @@ -310,12 +307,7 @@ pub fn install_plugin(executable: &Path, config_dir: &Path) -> Result<()> { let cwd = tempfile::tempdir().context("failed to create OpenCode install working directory")?; let mut command = tokio::process::Command::new(executable); - if let Some(node) = find_executable("node", vec![], None)? { - append_command_path( - &mut command, - &[node.parent().context("Node executable has no parent")?], - )?; - } + append_node_path(&mut command)?; command .args(["--pure", "plugin", "-g", "--force", INSTALL_SOURCE]) .env( @@ -477,6 +469,7 @@ fn run_cli( let cwd = tempfile::tempdir().context("failed to create OpenCode working directory")?; let mut command = tokio::process::Command::new(executable); + append_node_path(&mut command)?; command .args(args) .env_remove("OPENCODE_CONFIG") @@ -517,6 +510,7 @@ pub fn add_plugin(executable: &Path, config_dir: &Path) -> Result<()> { let cwd = tempfile::tempdir().context("failed to create OpenCode install working directory")?; let mut command = tokio::process::Command::new(executable); + append_node_path(&mut command)?; command .args(["plugin", "add", INSTALL_SOURCE]) .env( diff --git a/crates/wizard-core/src/agents/pi.rs b/crates/wizard-core/src/agents/pi.rs index 03204d5..1f63fc5 100644 --- a/crates/wizard-core/src/agents/pi.rs +++ b/crates/wizard-core/src/agents/pi.rs @@ -171,8 +171,14 @@ pub fn remove_plugin(executable: &Path, agent_dir: &Path) -> Result<()> { } fn run_package_command(executable: &Path, dir: &Path, action: &str) -> Result<()> { - let node = find_executable("node", vec![], None)?.context("Node executable not found")?; - let npm = find_executable("npm", vec![], None)?.context("npm executable not found")?; + // Version managers (nvm, fnm, Volta) keep node and npm next to the Pi CLI. + let near: Vec<_> = executable + .parent() + .map(Path::to_owned) + .into_iter() + .collect(); + let node = find_executable("node", near.clone(), None)?.context("Node executable not found")?; + let npm = find_executable("npm", near, None)?.context("npm executable not found")?; let paths = [ node.parent().context("Node executable has no parent")?, npm.parent().context("npm executable has no parent")?, diff --git a/crates/wizard-core/src/platform/mod.rs b/crates/wizard-core/src/platform/mod.rs index 445e874..a16893b 100644 --- a/crates/wizard-core/src/platform/mod.rs +++ b/crates/wizard-core/src/platform/mod.rs @@ -42,6 +42,22 @@ pub(crate) fn append_command_path( Ok(()) } +/// Appends the program's directory and Node's to the command's PATH, so npm launchers +/// (`#!/usr/bin/env node`) also start from a GUI app with a minimal PATH. Version managers +/// (nvm, fnm, Volta) keep node next to the CLI. +pub(crate) fn append_node_path(command: &mut tokio::process::Command) -> Result<()> { + let program = PathBuf::from(command.as_std().get_program()); + let mut directories: Vec<&Path> = program.parent().into_iter().collect(); + let node = find_executable("node", vec![], None)?; + if let Some(node) = &node { + directories.push( + node.parent() + .context("Node executable has no parent directory")?, + ); + } + append_command_path(command, &directories) +} + /// Checks the last found executable first, then searches common directories, /// extra directories, the per-user Nix profile, and PATH. /// Expands a leading `~` path component. On Windows, also expands leading `%VAR%/` From 0093337d820a9eeec078f1b7b8f2c82264f874e0 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:51 +0300 Subject: [PATCH 02/20] platform: skip unusable PATH entries One PATH entry that is a file, unreadable or broken made executable lookup fail, so every agent showed an error. Skip it like a shell does. --- crates/wizard-core/src/agents/codex.rs | 2 +- crates/wizard-core/src/platform/mod.rs | 22 ++++++++++------------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/crates/wizard-core/src/agents/codex.rs b/crates/wizard-core/src/agents/codex.rs index ff0ffc1..7596950 100644 --- a/crates/wizard-core/src/agents/codex.rs +++ b/crates/wizard-core/src/agents/codex.rs @@ -376,7 +376,7 @@ fn daemon_executable(settings: &Settings) -> Result { for relative in candidates { let path = resources.join(relative); - if is_executable(&path)? { + if is_executable(&path) { return std::path::absolute(path).context("failed to resolve bundled Codex executable"); } } diff --git a/crates/wizard-core/src/platform/mod.rs b/crates/wizard-core/src/platform/mod.rs index a16893b..e5e4f31 100644 --- a/crates/wizard-core/src/platform/mod.rs +++ b/crates/wizard-core/src/platform/mod.rs @@ -68,7 +68,7 @@ pub(crate) fn find_executable( last_found: Option<&Path>, ) -> Result> { if let Some(path) = last_found - && is_executable(path)? + && is_executable(path) { return Ok(Some(std::path::absolute(path)?)); } @@ -155,7 +155,7 @@ pub(crate) fn find_executable( }; for filename in &filenames { let candidate = directory.join(filename); - if is_executable(&candidate)? { + if is_executable(&candidate) { return Ok(Some(std::path::absolute(candidate)?)); } } @@ -164,25 +164,23 @@ pub(crate) fn find_executable( Ok(None) } -pub(crate) fn is_executable(path: &Path) -> Result { - let metadata = match std::fs::metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(error).with_context(|| format!("failed to inspect {}", path.display())); - } +/// Any error means not executable: a PATH entry may be missing, a file or unreadable, and a +/// shell skips it too. +pub(crate) fn is_executable(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; }; if !metadata.is_file() { - return Ok(false); + return false; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; if metadata.permissions().mode() & 0o111 == 0 { - return Ok(false); + return false; } } - Ok(true) + true } /// Runs a prepared command with bounded execution time and no stdin. From 0a4f468fab47c9475916ec62048e1791dfdbdd4f Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:51 +0300 Subject: [PATCH 03/20] detection: Claude Desktop version is optional without dpkg-query On Linux systems without dpkg-query, detection failed instead of reporting an unknown version. --- crates/wizard-core/src/agents/detection.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/wizard-core/src/agents/detection.rs b/crates/wizard-core/src/agents/detection.rs index 2ebd63c..aec3170 100644 --- a/crates/wizard-core/src/agents/detection.rs +++ b/crates/wizard-core/src/agents/detection.rs @@ -171,18 +171,15 @@ pub(super) fn detect_desktop( .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()); - let output = match command_output(command, Duration::from_secs(5)) { - Ok(output) => output, - Err(error) => return AgentDetection::failed(&path, &error), - }; - if output.status.success() { - String::from_utf8(output.stdout).ok().and_then(|text| { + // The version is optional: without dpkg-query (non-Debian systems) it is unknown. + command_output(command, Duration::from_secs(5)) + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .and_then(|text| { text.strip_prefix("install ok installed ") .map(|version| version.trim().to_owned()) }) - } else { - None - } } else { None }; From d7bd3b6ca2ab7877ee20b3f44bd6cd89f0385444 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:51 +0300 Subject: [PATCH 04/20] config files: read symlinked configs, refuse to replace them Detection refused symlinked configs (common with dotfiles managers). Read through symlinks, and refuse only to write one, which would turn it into a regular file. Check every write before a plan's first step, so an install step never runs for a write that will be refused. --- crates/wizard-core/src/config_files/changes.rs | 17 ++++++++++------- crates/wizard-core/src/prepared_plan.rs | 11 +++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/wizard-core/src/config_files/changes.rs b/crates/wizard-core/src/config_files/changes.rs index 049f978..a60127a 100644 --- a/crates/wizard-core/src/config_files/changes.rs +++ b/crates/wizard-core/src/config_files/changes.rs @@ -60,18 +60,14 @@ impl FileSnapshot { } } - /// Reads the current UTF-8 file and its permissions without changing it. + /// Reads the current UTF-8 file and its permissions without changing it, following symlinks. /// A missing file is distinct from an existing empty file. pub(crate) fn read(path: &Path) -> Result { - let metadata = match fs::symlink_metadata(path) { + let metadata = match fs::metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Self::Missing), Err(error) => return Err(error).context("failed to inspect file"), }; - ensure!( - !metadata.file_type().is_symlink(), - "file is a symlink; refusing to read it" - ); ensure!(metadata.is_file(), "file must be a regular file"); let mut file = fs::File::open(path).context("failed to open file")?; @@ -157,7 +153,14 @@ impl FileChange { &self.path } - fn verify_before(&self) -> Result<()> { + pub(crate) fn verify_before(&self) -> Result<()> { + // Replacing a symlink would turn it into a regular file, so writes refuse symlinks. + let symlink = fs::symlink_metadata(&self.path).is_ok_and(|metadata| metadata.is_symlink()); + ensure!( + !symlink, + "{} is a symlink; refusing to change it", + self.path.display() + ); let current = FileSnapshot::read(&self.path) .with_context(|| format!("failed to reread {}", self.path.display()))?; ensure!( diff --git a/crates/wizard-core/src/prepared_plan.rs b/crates/wizard-core/src/prepared_plan.rs index 5985308..748e4ad 100644 --- a/crates/wizard-core/src/prepared_plan.rs +++ b/crates/wizard-core/src/prepared_plan.rs @@ -153,6 +153,17 @@ impl PreparedPlan { ) -> Result<()> { let _guard = self.agent.lock()?; + // Refuse a write (symlink, file changed since preparation) before an install step runs. + // Each write checks again. + for step in &self.steps { + if let PreparedStep::ChangeFile { change } | PreparedStep::PiApplyAuth { change, .. } = + step + && change.is_changed() + { + change.verify_before()?; + } + } + let backup_agent = match self.agent { PreparedAgent::Codex => Some(AgentKind::Codex), PreparedAgent::Claude => Some(AgentKind::Claude), From 7bc3167f4a7382cc471f65560eaa93ca482a5fb0 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:52 +0300 Subject: [PATCH 05/20] backups: never reuse or replace a backup, back up only changed files - Save the next ID before the backup, so a failed state save cannot reuse an ID, and never overwrite an existing backup file (another Wizard process may have used the ID). - Back up only the files a plan or a restore changes: restoring a file Wizard did not change (such as ~/.claude.json) rolled back unrelated edits. --- crates/wizard-core/src/config_files/backups.rs | 18 ++++++++++++------ crates/wizard-core/src/prepared_plan.rs | 5 ++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/wizard-core/src/config_files/backups.rs b/crates/wizard-core/src/config_files/backups.rs index 4013ea5..3ff7b1c 100644 --- a/crates/wizard-core/src/config_files/backups.rs +++ b/crates/wizard-core/src/config_files/backups.rs @@ -91,9 +91,7 @@ impl BackupManager { return Ok(()); } - let mut state = State::load(agent)?; - state.create_backup_if_needed(changes)?; - state.save() + State::load(agent)?.create_backup_if_needed(changes) } pub fn save_last_written(agent: AgentKind, changes: &[FileChange]) -> Result<()> { @@ -120,6 +118,8 @@ impl BackupManager { change.after = snapshot.clone(); changes.push(change); } + // Like a plan, back up only what the restore changes. + changes.retain(FileChange::is_changed); // Restore existing files first; delete files absent from the backup last. changes.sort_by_key(|change| matches!(change.after, FileSnapshot::Missing)); Self::apply_changes(agent, &changes).context("failed to restore backup") @@ -193,10 +193,10 @@ impl State { return Ok(()); } + // Save the next ID first, so a failed state save can never reuse an existing backup's ID. let id = self.get_next_backup_id(); - let backup = Backup::from_changes(id, changes); - backup.save(self.agent)?; - Ok(()) + self.save()?; + Backup::from_changes(id, changes).save(self.agent) } } @@ -251,6 +251,12 @@ impl Backup { let directory = agent.ensure_directory()?; let mut change = FileChange::read(directory.join(format!("{}.json", self.id)))?; + // Another Wizard process may have used this ID; never replace its backup. + ensure!( + matches!(change.before(), FileSnapshot::Missing), + "backup {} already exists", + self.id + ); change.after = FileSnapshot::new(content, 0o600); FileChange::apply_all(&[change]).context("failed to save backup") } diff --git a/crates/wizard-core/src/prepared_plan.rs b/crates/wizard-core/src/prepared_plan.rs index 748e4ad..fc2cdcb 100644 --- a/crates/wizard-core/src/prepared_plan.rs +++ b/crates/wizard-core/src/prepared_plan.rs @@ -173,8 +173,11 @@ impl PreparedPlan { let changes: Vec<_> = self .steps .iter() + // Only changed files: restoring an untouched file would roll back unrelated edits. .filter_map(|step| match step { - PreparedStep::ChangeFile { change } => Some(change.clone()), + PreparedStep::ChangeFile { change } if change.is_changed() => { + Some(change.clone()) + } _ => None, }) .collect(); From adae5d4ea818ded34627721507b6923d74ed97fc Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:52 +0300 Subject: [PATCH 06/20] config files: keep JSON key order JSON edits re-sorted every key of files like ~/.claude/settings.json. Enable serde_json's preserve_order and remove keys with shift_remove, since remove moves the last key into the gap. --- Cargo.lock | 1 + crates/wizard-core/Cargo.toml | 2 +- crates/wizard-core/src/agents/claude.rs | 2 +- crates/wizard-core/src/agents/claude_desktop.rs | 2 +- crates/wizard-core/src/agents/codex.rs | 2 +- crates/wizard-core/src/agents/opencode.rs | 2 +- crates/wizard-core/src/agents/pi.rs | 2 +- crates/wizard-core/src/config_files/json.rs | 1 + 8 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e192fc6..8a173aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3406,6 +3406,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap 2.14.2", "itoa", "memchr", "serde", diff --git a/crates/wizard-core/Cargo.toml b/crates/wizard-core/Cargo.toml index dd97635..9b0cfdc 100644 --- a/crates/wizard-core/Cargo.toml +++ b/crates/wizard-core/Cargo.toml @@ -20,7 +20,7 @@ log.workspace = true regex.workspace = true reqwest.workspace = true serde.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["preserve_order"] } # keep key order in user files semver.workspace = true sha2.workspace = true jsonc-parser.workspace = true diff --git a/crates/wizard-core/src/agents/claude.rs b/crates/wizard-core/src/agents/claude.rs index 55dd348..ccb1491 100644 --- a/crates/wizard-core/src/agents/claude.rs +++ b/crates/wizard-core/src/agents/claude.rs @@ -189,7 +189,7 @@ fn remove_inactive_routing_settings(env: &mut Map, current_mode: ], }; for key in previous_keys { - env.remove(*key); + env.shift_remove(*key); } } diff --git a/crates/wizard-core/src/agents/claude_desktop.rs b/crates/wizard-core/src/agents/claude_desktop.rs index 60633ea..1161021 100644 --- a/crates/wizard-core/src/agents/claude_desktop.rs +++ b/crates/wizard-core/src/agents/claude_desktop.rs @@ -188,7 +188,7 @@ pub fn prepare_routing(mode: RoutingMode, token: &str) -> Result { object.insert("inferenceGatewayAuthScheme".into(), json!("x-api-key")); } else if remove { for key in CONNECTION_KEYS { - object.remove(key); + object.shift_remove(key); } } diff --git a/crates/wizard-core/src/agents/codex.rs b/crates/wizard-core/src/agents/codex.rs index 7596950..31d6e87 100644 --- a/crates/wizard-core/src/agents/codex.rs +++ b/crates/wizard-core/src/agents/codex.rs @@ -329,7 +329,7 @@ fn prepare_desktop_proxy(path: PathBuf, enabled: bool) -> Result { config .as_object_mut() .context("ChatGPT Preferences must contain an object")? - .remove("proxy"); + .shift_remove("proxy"); } Ok(()) })?; diff --git a/crates/wizard-core/src/agents/opencode.rs b/crates/wizard-core/src/agents/opencode.rs index 9ffb26d..d5bc36c 100644 --- a/crates/wizard-core/src/agents/opencode.rs +++ b/crates/wizard-core/src/agents/opencode.rs @@ -235,7 +235,7 @@ pub fn prepare_hub(mode: HubMode, token: &str, settings: &Settings) -> Result Result Result { } /// Edits only the working snapshot. A semantic no-op preserves the original text. +/// Keys keep their order; remove them with `shift_remove`, since `remove` moves the last key. pub(crate) fn update( after: &mut FileSnapshot, edit: impl FnOnce(&mut Value) -> Result<()>, From 3f8edcd09f5c76f3a534692c7b591d9e24ca6291 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:52 +0300 Subject: [PATCH 07/20] claude desktop: remove only CoreInfra's connection - Disabled and Sub remove the inference settings only when they point at CoreInfra Hub, so another gateway and its key are kept (they are not backed up). This reverts 8e3f9d2: Claude ignores inference settings in the 1p mode that Sub selects. - Disabled returns a profile Wizard configured to the Claude account (1p), where sign-in works, and undoes Sub's disableAutoUpdates. --- .../wizard-core/src/agents/claude_desktop.rs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/wizard-core/src/agents/claude_desktop.rs b/crates/wizard-core/src/agents/claude_desktop.rs index 1161021..4f258e7 100644 --- a/crates/wizard-core/src/agents/claude_desktop.rs +++ b/crates/wizard-core/src/agents/claude_desktop.rs @@ -172,9 +172,12 @@ pub fn prepare_routing(mode: RoutingMode, token: &str) -> Result { } let proxy_url = format!("http://{}", wizard_proxy::CLAUDE_DESKTOP_ADDRESS); + // Wizard points every profile it configures at its proxy. + let managed = json::parse(&config.after)?["egressProxyUrl"] == proxy_url; json::update(&mut config.after, |config| { - let remove = mode != RoutingMode::Hub; - let remove_proxy = mode == RoutingMode::Disabled && config["egressProxyUrl"] == proxy_url; + // Remove only CoreInfra's connection; another gateway belongs to the user and is not + // backed up. Claude ignores inference settings in the 1p mode that Sub selects. + let remove = mode != RoutingMode::Hub && has_hub_connection(config); let object = config .as_object_mut() @@ -194,21 +197,25 @@ pub fn prepare_routing(mode: RoutingMode, token: &str) -> Result { if mode == RoutingMode::Sub { object.insert("disableAutoUpdates".into(), json!(true)); + } else if managed { + // Undo Sub's override only in a profile Wizard configured. + object.shift_remove("disableAutoUpdates"); } if mode != RoutingMode::Disabled { object.insert("egressProxyUrl".into(), json!(proxy_url)); - object.remove("egressProxyPacUrl"); - } else if remove_proxy { - object.remove("egressProxyUrl"); + object.shift_remove("egressProxyPacUrl"); + } else if managed { + object.shift_remove("egressProxyUrl"); } Ok(()) })?; - if mode != RoutingMode::Disabled { + // Disabled returns a Wizard profile to the Claude account (1p), where sign-in works. + if mode != RoutingMode::Disabled || managed { json::update(&mut preferences.after, |preferences| { preferences["deploymentMode"] = - json!(if mode == RoutingMode::Sub { "1p" } else { "3p" }); + json!(if mode == RoutingMode::Hub { "3p" } else { "1p" }); Ok(()) })?; } From 2a1cc039956b35b8ecfea1359aca8c1afdc3843e Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:52 +0300 Subject: [PATCH 08/20] claude desktop: recheck the installed version before replacing it Claude may update itself after Wizard downloaded an update; installing the older download would then downgrade it. --- crates/wizard-core/src/agents/claude_desktop_install.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/wizard-core/src/agents/claude_desktop_install.rs b/crates/wizard-core/src/agents/claude_desktop_install.rs index 04460dc..ca7b3f6 100644 --- a/crates/wizard-core/src/agents/claude_desktop_install.rs +++ b/crates/wizard-core/src/agents/claude_desktop_install.rs @@ -1087,6 +1087,12 @@ fn install_release_inner(download: &DownloadedRelease) -> Result<()> { )?; verify_bundle(&staged, &release.version)?; + // Claude may have updated itself since the check; never replace a different version. + let installed = detect_target(release.target.path.as_deref())?; + ensure!( + installed.version == release.target.version, + "Claude Desktop изменился после проверки обновления; проверьте обновление снова" + ); // Check all Claude instances, including a running app at a different path. Never terminate them. ensure!( !app_is_running(BUNDLE_ID), From 8ab3d9b24f51fff247ae40a5af9083c769409ab4 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:52 +0300 Subject: [PATCH 09/20] codex: fix ChatGPT proxy cleanup and step order - Disabling on Windows without ChatGPT installed failed. - Clean up the proxy settings after ChatGPT was uninstalled. - Save the proxy setting before the daemon restart, which can fail after ChatGPT already points at the listener. --- crates/wizard-core/src/agents/codex.rs | 29 +++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/wizard-core/src/agents/codex.rs b/crates/wizard-core/src/agents/codex.rs index 31d6e87..4128eba 100644 --- a/crates/wizard-core/src/agents/codex.rs +++ b/crates/wizard-core/src/agents/codex.rs @@ -245,18 +245,33 @@ pub fn prepare_routing_for_gui( settings.last_paths.codex_desktop.as_deref(), ) { AgentDetection::Found(_) => true, - AgentDetection::NotFound => return Ok(plan), + // Clean up a configuration left from before ChatGPT was removed. + AgentDetection::NotFound => false, AgentDetection::Error(error) => { anyhow::bail!("failed to detect ChatGPT for local proxy: {error}") } } }; - let path = desktop_preferences_path()?.context("ChatGPT profile location is unavailable")?; - let change = prepare_desktop_proxy(path, enabled)?; - plan.steps.insert(0, PreparedStep::ChangeFile { change }); - plan.steps.push(PreparedStep::SetCodexProxyEnabled { - token: enabled.then(|| token.to_owned()), - }); + // Without ChatGPT installed (Windows), there is no profile to clean up when disabling. + match desktop_preferences_path()? { + Some(path) => { + let change = prepare_desktop_proxy(path, enabled)?; + plan.steps.insert(0, PreparedStep::ChangeFile { change }); + } + None => ensure!(!enabled, "ChatGPT profile location is unavailable"), + } + // Before the daemon restart: it can fail after Preferences already points at the listener. + let restart = plan + .steps + .iter() + .position(|step| matches!(step, PreparedStep::CodexRestartDaemon { .. })) + .unwrap_or(plan.steps.len()); + plan.steps.insert( + restart, + PreparedStep::SetCodexProxyEnabled { + token: enabled.then(|| token.to_owned()), + }, + ); Ok(plan) } From 23942dc899386ac0ce15bc77c09202eaba9f1cea Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:53 +0300 Subject: [PATCH 10/20] codex: refuse to enable when ChatGPT was never launched ChatGPT creates its profile on first launch; enabling before that silently left ChatGPT without the proxy. On macOS, detect ChatGPT by its bundle ID, since the classic ChatGPT app has the same name. Linux detection accepts any `chatgpt` executable, so there a missing profile is still only logged. --- crates/wizard-core/src/agents/codex.rs | 6 ++++++ crates/wizard-core/src/agents/detection.rs | 23 +++++++++++++++------- crates/wizard-core/src/platform/macos.rs | 20 +++++++++++++------ 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/crates/wizard-core/src/agents/codex.rs b/crates/wizard-core/src/agents/codex.rs index 4128eba..14fb8bc 100644 --- a/crates/wizard-core/src/agents/codex.rs +++ b/crates/wizard-core/src/agents/codex.rs @@ -315,7 +315,13 @@ fn prepare_desktop_proxy(path: PathBuf, enabled: bool) -> Result { use serde_json::json; let mut change = FileChange::read(std::path::absolute(path)?)?; + // ChatGPT creates its profile on first launch; until then there is nothing to clean up. if matches!(change.after, FileSnapshot::Missing) { + // Linux detection accepts any `chatgpt` executable, which may be another program. + ensure!( + !enabled || cfg!(target_os = "linux"), + "ChatGPT ещё не запускался: откройте его один раз и повторите." + ); log::info!("ChatGPT proxy configuration skipped: profile Preferences file is missing"); return Ok(change); } diff --git a/crates/wizard-core/src/agents/detection.rs b/crates/wizard-core/src/agents/detection.rs index aec3170..2d8c67a 100644 --- a/crates/wizard-core/src/agents/detection.rs +++ b/crates/wizard-core/src/agents/detection.rs @@ -125,10 +125,12 @@ pub(super) fn detect_desktop( ) -> AgentDetection { #[cfg(target_os = "macos")] { - let app_path = Path::new(match agent { - DesktopAgent::Codex => "/Applications/ChatGPT.app", - DesktopAgent::Claude => CLAUDE_APP_PATH, - }); + let (app_path, bundle_id) = match agent { + // The classic ChatGPT app (com.openai.chat) has the same name. + DesktopAgent::Codex => ("/Applications/ChatGPT.app", Some("com.openai.codex")), + DesktopAgent::Claude => (CLAUDE_APP_PATH, None), + }; + let app_path = Path::new(app_path); let app_name = app_path .file_name() .expect("desktop app path has a filename"); @@ -144,10 +146,17 @@ pub(super) fn detect_desktop( Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) => return AgentDetection::failed(&path, &error), } - return match crate::platform::macos::read_app_version(&path) { - Ok(version) => AgentDetection::Found(AgentInfo { path, version }), - Err(error) => AgentDetection::failed(&path, &error), + let info = match crate::platform::macos::read_app_info(&path) { + Ok(info) => info, + Err(error) => return AgentDetection::failed(&path, &error), }; + if bundle_id.is_some_and(|id| info.identifier.as_deref() != Some(id)) { + continue; + } + return AgentDetection::Found(AgentInfo { + path, + version: info.version, + }); } AgentDetection::NotFound } diff --git a/crates/wizard-core/src/platform/macos.rs b/crates/wizard-core/src/platform/macos.rs index df361e8..ade53ab 100644 --- a/crates/wizard-core/src/platform/macos.rs +++ b/crates/wizard-core/src/platform/macos.rs @@ -24,19 +24,27 @@ pub(crate) fn app_is_running(bundle_id: &str) -> bool { } #[derive(Deserialize)] -struct AppInfo { +pub(crate) struct AppInfo { + #[serde(rename = "CFBundleIdentifier")] + pub identifier: Option, #[serde(rename = "CFBundleShortVersionString")] - version: Option, + pub version: Option, } -/// Reads an app bundle's version without launching the application. +/// Reads an app bundle's identifier and version without launching the application. /// `plist` handles both XML and binary Info.plist files. -pub(crate) fn read_app_version(app: &Path) -> Result> { +pub(crate) fn read_app_info(app: &Path) -> Result { let path = app.join("Contents/Info.plist"); log::debug!("reading application metadata: {}", path.display()); - let info: AppInfo = + let mut info: AppInfo = plist::from_file(&path).with_context(|| format!("failed to read {}", path.display()))?; - Ok(info.version.filter(|version| !version.trim().is_empty())) + info.version = info.version.filter(|version| !version.trim().is_empty()); + Ok(info) +} + +/// Reads an app bundle's version without launching the application. +pub(crate) fn read_app_version(app: &Path) -> Result> { + Ok(read_app_info(app)?.version) } #[cfg(test)] From 053bfadb0948162c949959e1039537066b012697 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:53 +0300 Subject: [PATCH 11/20] cli: refuse Codex changes while the GUI's ChatGPT proxy is on The CLI cannot update ChatGPT's profile or the GUI's listener. --- crates/wizard-cli/src/agent.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/wizard-cli/src/agent.rs b/crates/wizard-cli/src/agent.rs index b7519a6..198ee18 100644 --- a/crates/wizard-cli/src/agent.rs +++ b/crates/wizard-cli/src/agent.rs @@ -91,6 +91,11 @@ pub(crate) fn run( AgentCommand::Codex { command } => { let (mode, args) = routing_mode(&command); let settings = settings::load_from_file()?; + // The CLI cannot update ChatGPT's profile or the GUI's listener that it uses. + ensure!( + settings.codex_proxy_enabled.is_none(), + "Настройка Codex доступна только в GUI, пока в нём включён прокси для ChatGPT." + ); let Some(token) = resolve_token( runtime, yes, From 125c785737bc682b3f004e56df34429e96d0db85 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:53 +0300 Subject: [PATCH 12/20] pi: remove the CoreInfra key without the plugin Disabling Pi left the key in auth.json when the plugin was already removed. --- crates/wizard-core/src/agents/pi.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/wizard-core/src/agents/pi.rs b/crates/wizard-core/src/agents/pi.rs index 9186bfc..c6755ba 100644 --- a/crates/wizard-core/src/agents/pi.rs +++ b/crates/wizard-core/src/agents/pi.rs @@ -154,7 +154,7 @@ pub fn prepare_hub(mode: HubMode, token: &str, settings: &Settings) -> Result Date: Fri, 30 Oct 2026 14:24:53 +0300 Subject: [PATCH 13/20] gui: start a stopped proxy on apply, per app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «Применить» did not restart a stopped listener unless the token changed. Sync the applied app's listener after every apply, even when saving the setting fails after it was saved (tray, autostart), and only that app's, so one listener's failure does not fail the other app's apply. --- crates/wizard-gui/src/agents.rs | 28 +++++++++++++++++----------- crates/wizard-gui/src/proxy.rs | 27 ++++++++++++++++++--------- crates/wizard-gui/src/settings.rs | 14 ++------------ 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/crates/wizard-gui/src/agents.rs b/crates/wizard-gui/src/agents.rs index bc1dd76..63b254a 100644 --- a/crates/wizard-gui/src/agents.rs +++ b/crates/wizard-gui/src/agents.rs @@ -1,6 +1,6 @@ //! Tauri commands for agent discovery and configuration. -use crate::{hub_tokens, revision_signal::RevisionSignal, settings}; +use crate::{hub_tokens, proxy, revision_signal::RevisionSignal, settings}; use anyhow::{Context as _, Result}; use serde::{Deserialize, Serialize}; use tauri::Manager as _; @@ -188,22 +188,28 @@ pub(crate) async fn apply_agent_plan( let proxy_app = app.clone(); let result = tauri::async_runtime::spawn_blocking(move || { + // Sync even when the setting is unchanged: «Применить» restarts a stopped listener. + // Also sync after a failed save: tray or autostart can fail after the setting is saved. plan.apply_with_proxies( |token| { - tauri::async_runtime::block_on(settings::update_state( - &proxy_app, - move |settings| { + tauri::async_runtime::block_on(async { + let saved = settings::update_state(&proxy_app, move |settings| { settings.claude_proxy_enabled = token; - }, - )) + }) + .await; + let synced = proxy::sync_one(&proxy_app, proxy::App::ClaudeDesktop).await; + saved.and(synced) + }) }, |token| { - tauri::async_runtime::block_on(settings::update_state( - &proxy_app, - move |settings| { + tauri::async_runtime::block_on(async { + let saved = settings::update_state(&proxy_app, move |settings| { settings.codex_proxy_enabled = token; - }, - )) + }) + .await; + let synced = proxy::sync_one(&proxy_app, proxy::App::ChatGpt).await; + saved.and(synced) + }) }, ) }) diff --git a/crates/wizard-gui/src/proxy.rs b/crates/wizard-gui/src/proxy.rs index 0184e37..66e5352 100644 --- a/crates/wizard-gui/src/proxy.rs +++ b/crates/wizard-gui/src/proxy.rs @@ -16,7 +16,7 @@ pub(crate) struct Proxy { } #[derive(Clone, Copy)] -enum App { +pub(crate) enum App { ClaudeDesktop, ChatGpt, } @@ -132,21 +132,30 @@ pub(crate) fn raise_open_file_limit() { ); } -/// Called at startup and after a change of either app's proxy setting. Each listener runs while -/// its app has a token and is restarted when that token changes; one failure does not skip the other. +/// Called at startup for both apps; one failure does not skip the other. pub(crate) async fn sync(app: &tauri::AppHandle) -> Result<()> { - let state = app.state::(); - let mut listeners = state.0.lock().await; - let settings = settings::get_state(app)?; - let mut results = Vec::new(); for proxy_app in App::ALL { - let token = proxy_app.token(&settings); - results.push(sync_app(app, listeners.get(proxy_app), proxy_app, token).await); + results.push(sync_one(app, proxy_app).await); } combine(results) } +/// Called after applying the app's proxy setting. Its listener runs while the app has a token and +/// is restarted when that token changes; a stopped or failed listener is started again. +pub(crate) async fn sync_one(app: &tauri::AppHandle, proxy_app: App) -> Result<()> { + let state = app.state::(); + let mut listeners = state.0.lock().await; + let settings = settings::get_state(app)?; + sync_app( + app, + listeners.get(proxy_app), + proxy_app, + proxy_app.token(&settings), + ) + .await +} + async fn sync_app( app: &tauri::AppHandle, slot: &mut Option, diff --git a/crates/wizard-gui/src/settings.rs b/crates/wizard-gui/src/settings.rs index 9b80550..9b50c3b 100644 --- a/crates/wizard-gui/src/settings.rs +++ b/crates/wizard-gui/src/settings.rs @@ -57,29 +57,19 @@ pub(crate) async fn update_state( if next == current { return Ok(()); } - let proxy_changed = next.claude_proxy_enabled != current.claude_proxy_enabled - || next.codex_proxy_enabled != current.codex_proxy_enabled; #[cfg(any(target_os = "macos", target_os = "windows"))] let proxy_enabled = next.local_proxy_enabled(); settings::save_to_file(&next)?; signal.notify(next); - if !proxy_changed { - return Ok(()); - } - let proxy_result = tauri::async_runtime::block_on(crate::proxy::sync(&app)); - if let Err(error) = &proxy_result { - log::error!("proxy update failed: {error:#}"); - } - + // Listeners are synced by the caller that changes a proxy setting. #[cfg(any(target_os = "macos", target_os = "windows"))] if proxy_enabled != current.local_proxy_enabled() { crate::tray::set_enabled(&app, proxy_enabled)?; crate::autostart::set_enabled(&app, proxy_enabled)?; } - - proxy_result + Ok(()) }) .await .context("settings save task failed")? From 2b6539a2e99212b7aa5a393633e2605fc6f7327c Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:53 +0300 Subject: [PATCH 14/20] gui: keep observing agent state after an error One error stopped the agent list from updating until restart. Retry the observer inside its loop, and never fail the state because the discovered paths could not be cached. --- crates/wizard-gui/src/agents.rs | 7 +++++-- .../wizard-ui/src/agents/agents_state.ts | 19 ++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/wizard-gui/src/agents.rs b/crates/wizard-gui/src/agents.rs index 63b254a..da878eb 100644 --- a/crates/wizard-gui/src/agents.rs +++ b/crates/wizard-gui/src/agents.rs @@ -42,12 +42,15 @@ pub(crate) async fn get_agent_state(app: tauri::AppHandle) -> Result("wait_for_update", { lastRevision: snapshot.revision }); + } catch (cause: unknown) { + if (stopped) return; + reportError("failed to observe agent state", cause); + setState({ + status: "error", + message: cause instanceof Error ? cause.message : String(cause), + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); // 1 sec } - } catch (cause: unknown) { - if (stopped) return; - reportError("failed to observe agent state", cause); - setState({ - status: "error", - message: cause instanceof Error ? cause.message : String(cause), - }); } } From 53db4135091af3ce66543545dcd8bc523581c798 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:54 +0300 Subject: [PATCH 15/20] hub tokens: ignore stale 401s and logouts during issue - A 401 for a session that a later login or cookie refresh replaced cleared the new account's profile and tokens. - Check the session generation after the refresh that follows issuing a token, which a logout can also interrupt. --- crates/wizard-gui/src/hub_tokens.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/wizard-gui/src/hub_tokens.rs b/crates/wizard-gui/src/hub_tokens.rs index d2324d1..de92236 100644 --- a/crates/wizard-gui/src/hub_tokens.rs +++ b/crates/wizard-gui/src/hub_tokens.rs @@ -95,6 +95,8 @@ impl HubTokensManager { if let Err(error) = result { log::warn!("hub tokens load failed: {error:?}"); } + // A 401 for a session that was replaced meanwhile is an error, not a sign-out. + let signed_out = settings::get_state(app).is_ok_and(|s| s.hub_session.is_empty()); signal.update(|tokens| { if tokens.generation != generation { return None; @@ -107,7 +109,7 @@ impl HubTokensManager { generation, }, // A rejected session is signed out, which clears the list without an error. - Err(TokensError::Request(HubError::SignedOut)) => HubTokens { + Err(TokensError::Request(HubError::SignedOut)) if signed_out => HubTokens { generation, ..HubTokens::default() }, @@ -151,14 +153,14 @@ pub(crate) async fn issue(app: &tauri::AppHandle, name: &str) -> Result Date: Fri, 30 Oct 2026 14:24:54 +0300 Subject: [PATCH 16/20] ui: retry loading Hub tokens A failed token load disabled the token controls until restart. --- .../wizard-gui/wizard-ui/src/agents/AgentPage.tsx | 13 +++++++++++-- .../wizard-gui/wizard-ui/src/settings/hub_tokens.ts | 5 +++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx b/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx index c9da814..2858add 100644 --- a/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx +++ b/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx @@ -3,7 +3,7 @@ import { type AgentChange, type AgentDetection, type AgentId, type AgentStates, import { PreparedPlanDialog } from "./PreparedPlanDialog"; import { BackupDialog } from "./BackupDialog"; import { ClaudeDesktopInstall, useClaudeDesktopInstall } from "./ClaudeDesktopInstall"; -import { type HubTokensState, tokenLabel, tokenPrefix, useHubTokens } from "../settings/hub_tokens"; +import { type HubTokensState, refreshHubTokens, tokenLabel, tokenPrefix, useHubTokens } from "../settings/hub_tokens"; import type { HubAccountControl } from "../settings/HubAccount"; import { type Proxy, useProxy } from "./proxy"; import { reportError } from "../logs/log"; @@ -122,7 +122,16 @@ function TokenField({ token, hub, disabled }: { ))} {!hub.signedIn && Войдите в CoreInfra Hub для выбора токена.} - {hub.status === "error" && {hub.error}} + {hub.status === "error" && ( + + {hub.error}{" "} + + + )} ); } diff --git a/crates/wizard-gui/wizard-ui/src/settings/hub_tokens.ts b/crates/wizard-gui/wizard-ui/src/settings/hub_tokens.ts index c33b06c..65611b0 100644 --- a/crates/wizard-gui/wizard-ui/src/settings/hub_tokens.ts +++ b/crates/wizard-gui/wizard-ui/src/settings/hub_tokens.ts @@ -12,6 +12,11 @@ export type HubTokensState = | { status: "error"; tokens: HubToken[]; error: string } | { status: "ready"; tokens: HubToken[] }; +// Reloads the list after a failed load; the result arrives through `useHubTokens`. +export function refreshHubTokens(): Promise { + return invoke("refresh_hub_tokens"); +} + export function useHubTokens(): HubTokensState { const [state, setState] = useState({ status: "loading", tokens: [] }); From 0b6315172c321bd823cb8804773d3b02601a13d8 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:54 +0300 Subject: [PATCH 17/20] hub login: cap the callback request head --- crates/wizard-core/src/hub/login.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/wizard-core/src/hub/login.rs b/crates/wizard-core/src/hub/login.rs index 6549679..de0d42d 100644 --- a/crates/wizard-core/src/hub/login.rs +++ b/crates/wizard-core/src/hub/login.rs @@ -29,6 +29,8 @@ const CALLBACK_PATH: &str = "/callback"; const LOGIN_LINK_PARAMETER: &str = "login_link"; const LOGIN_PATH: &str = "/hub/api/login"; const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(15); +/// A browser's callback request is small; never buffer an endless one. +const MAX_REQUEST_HEAD: usize = 16 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LoginError { @@ -201,6 +203,9 @@ async fn read_request_head(stream: &mut TcpStream) -> Option> { } let searched = head.len().saturating_sub(3); head.extend_from_slice(&buffer[..size]); + if head.len() > MAX_REQUEST_HEAD { + return None; + } if let Some(end) = head[searched..] .windows(4) .position(|part| part == b"\r\n\r\n") From d140dd10692f3cbdbb561601ed08953d7cf5862d Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:54 +0300 Subject: [PATCH 18/20] remove dead code useSettings and get_settings_state, toml::get_value, and the TypeScript Backup.backup_version field were unused. --- crates/wizard-core/src/config_files/toml.rs | 9 ---- crates/wizard-gui/src/lib.rs | 1 - crates/wizard-gui/src/settings.rs | 16 +------ .../wizard-ui/src/agents/agents_state.ts | 1 - .../wizard-ui/src/settings/settings.ts | 42 ------------------- 5 files changed, 1 insertion(+), 68 deletions(-) delete mode 100644 crates/wizard-gui/wizard-ui/src/settings/settings.ts diff --git a/crates/wizard-core/src/config_files/toml.rs b/crates/wizard-core/src/config_files/toml.rs index ef05fd0..c071edc 100644 --- a/crates/wizard-core/src/config_files/toml.rs +++ b/crates/wizard-core/src/config_files/toml.rs @@ -24,15 +24,6 @@ pub(crate) fn get_string<'a>(doc: &'a DocumentMut, keys: &[&str]) -> Option<&'a item.as_str() } -#[expect(dead_code)] -pub(crate) fn get_value<'a>(doc: &'a DocumentMut, keys: &[&str]) -> Option<&'a Value> { - let mut item = doc.as_item(); - for key in keys { - item = item.as_table_like()?.get(key)?; - } - item.as_value() -} - /// Updates a value, preserving its comments and unrelated table entries. pub(crate) fn set_value( doc: &mut DocumentMut, diff --git a/crates/wizard-gui/src/lib.rs b/crates/wizard-gui/src/lib.rs index 332c945..30f2912 100644 --- a/crates/wizard-gui/src/lib.rs +++ b/crates/wizard-gui/src/lib.rs @@ -121,7 +121,6 @@ pub fn run_application() -> Result<()> { updater::get_update_state, updater::request_update, updater::retry_update_check, - settings::get_settings_state, hub_auth::get_hub_account_state, hub_auth::login_hub_account, hub_auth::logout_hub_account, diff --git a/crates/wizard-gui/src/settings.rs b/crates/wizard-gui/src/settings.rs index 9b50c3b..a0e1eb6 100644 --- a/crates/wizard-gui/src/settings.rs +++ b/crates/wizard-gui/src/settings.rs @@ -1,4 +1,4 @@ -use crate::revision_signal::{RevisionSignal, RevisionSnapshot}; +use crate::revision_signal::RevisionSignal; use anyhow::{Context as _, Result, anyhow, bail}; use std::sync::Mutex; use tauri::Manager as _; @@ -17,20 +17,6 @@ pub(crate) async fn initialize_state(app: &tauri::AppHandle) -> Result<()> { Ok(()) } -#[tauri::command] -pub(crate) async fn get_settings_state( - last_revision: Option, - signal: tauri::State<'_, RevisionSignal>, -) -> Result, String> { - let mut snapshot = signal - .wait(last_revision) - .await - .map_err(|error| format!("{error:#}"))?; - // Clear only the IPC copy; the persisted session remains backend-only. - snapshot.state.hub_session.clear(); - Ok(snapshot) -} - /// TODO: maybe update state when file changes pub(crate) fn get_state(app: &tauri::AppHandle) -> Result { let signal = app diff --git a/crates/wizard-gui/wizard-ui/src/agents/agents_state.ts b/crates/wizard-gui/wizard-ui/src/agents/agents_state.ts index 586c99b..ed9c5d6 100644 --- a/crates/wizard-gui/wizard-ui/src/agents/agents_state.ts +++ b/crates/wizard-gui/wizard-ui/src/agents/agents_state.ts @@ -41,7 +41,6 @@ export type BackupAgent = "codex" | "claude"; export type Backup = { id: number; - backup_version: number; time_created: string; files: Record; }; diff --git a/crates/wizard-gui/wizard-ui/src/settings/settings.ts b/crates/wizard-gui/wizard-ui/src/settings/settings.ts deleted file mode 100644 index 77a938f..0000000 --- a/crates/wizard-gui/wizard-ui/src/settings/settings.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { useEffect, useState } from "react"; -import { reportError } from "../logs/log"; - -export type Settings = { claude_proxy_enabled?: string; codex_proxy_enabled?: string }; -type SettingsSnapshot = Settings & { revision: number }; - -export function useSettings(): { settings: Settings | undefined; error: string | undefined } { - const [settings, setSettings] = useState(); - const [error, setError] = useState(); - - useEffect(() => { - let stopped = false; - - async function observe() { - let lastRevision: number | null = null; - while (!stopped) { - try { - const next: SettingsSnapshot = await invoke("get_settings_state", { lastRevision }); - if (stopped) return; - setSettings(next); - setError(undefined); - lastRevision = next.revision; - } catch (cause: unknown) { - if (stopped) return; - const message = "Не удалось получить настройки"; - reportError(message, cause); - setError(`${message}: ${cause instanceof Error ? cause.message : String(cause)}`); - lastRevision = null; - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - } - - void observe(); - return () => { - stopped = true; - }; - }, []); - - return { settings, error }; -} From 3f2abfb292634b505fe69febc19becc980b66210 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:54 +0300 Subject: [PATCH 19/20] xtask: fetch dev tags in one request The matching-refs endpoint is not paginated, so the page loop never ended once there were 100 or more dev tags. --- xtask/src/dev_tag.rs | 52 ++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/xtask/src/dev_tag.rs b/xtask/src/dev_tag.rs index 3e49142..17f9a78 100644 --- a/xtask/src/dev_tag.rs +++ b/xtask/src/dev_tag.rs @@ -57,35 +57,31 @@ pub(crate) fn parse_version(value: &str) -> Result { fn find(repository: &str, stable: &Version) -> Result> { let prefix = format!("v{stable}-dev."); + // This endpoint is not paginated: it ignores `page` and returns all matching refs at once. + let response = gh_api_json(&format!( + "repos/{repository}/git/matching-refs/tags/{prefix}" + ))?; + let refs = response + .as_array() + .context("GitHub matching refs response must be an array")?; let mut tags = Vec::new(); - for page in 1.. { - let endpoint = - format!("repos/{repository}/git/matching-refs/tags/{prefix}?per_page=100&page={page}"); - let response = gh_api_json(&endpoint)?; - let refs = response - .as_array() - .context("GitHub matching refs response must be an array")?; - for reference in refs { - let Some(sequence) = reference["ref"] - .as_str() - .and_then(|value| value.strip_prefix("refs/tags/")) - .and_then(|value| value.strip_prefix(&prefix)) - .and_then(|value| value.parse::().ok()) - else { - continue; - }; - let Some(commit) = reference["object"]["sha"].as_str() else { - continue; - }; - tags.push(DevTag { - version: Version::parse(&format!("{stable}-dev.{sequence}"))?, - sequence, - commit: commit.to_owned(), - }); - } - if refs.len() < 100 { - break; - } + for reference in refs { + let Some(sequence) = reference["ref"] + .as_str() + .and_then(|value| value.strip_prefix("refs/tags/")) + .and_then(|value| value.strip_prefix(&prefix)) + .and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(commit) = reference["object"]["sha"].as_str() else { + continue; + }; + tags.push(DevTag { + version: Version::parse(&format!("{stable}-dev.{sequence}"))?, + sequence, + commit: commit.to_owned(), + }); } Ok(tags) } From d77158330b9a7c1054cce96e0344c9a6369c3363 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Fri, 30 Oct 2026 14:24:54 +0300 Subject: [PATCH 20/20] ci: serialize dev releases Dev releases for different commits could publish latest-dev.json out of order. --- .github/workflows/dev-release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 009bc09..dc82808 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -7,7 +7,8 @@ permissions: contents: write concurrency: - group: dev-release-${{ github.sha }} + # One group for all commits: concurrent runs could publish latest-dev.json out of order. + group: dev-release cancel-in-progress: false jobs: