From 720b4d3d2eccad09c3eb3f7e04f58aa988d21648 Mon Sep 17 00:00:00 2001 From: brettheap Date: Sat, 25 Jul 2026 23:34:41 -0400 Subject: [PATCH 1/6] fix: discover WSL profiles on Windows --- Cargo.lock | 4 +- README.md | 9 +- crates/open-profiler-core/Cargo.toml | 2 +- crates/open-profiler-core/src/lib.rs | 129 +++++++++++++++++++++++---- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 7 files changed, 125 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ddd53c..a208979 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2028,7 +2028,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "opensoft-open-profiler" -version = "0.1.0" +version = "0.1.1" dependencies = [ "opensoft-open-profiler-core", "serde", @@ -2039,7 +2039,7 @@ dependencies = [ [[package]] name = "opensoft-open-profiler-core" -version = "0.1.0" +version = "0.1.1" dependencies = [ "dirs", "libc", diff --git a/README.md b/README.md index b155e14..fcd16c3 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,10 @@ supported logout/sign-in flow in control. See the official [Codex authentication guide](https://learn.chatgpt.com/docs/auth) for the supported credential-store modes. -On Windows, when `%USERPROFILE%\.chatgpt-profiles` is absent and exactly one WSL -profile store exists, openProfiler discovers it through `\\wsl.localhost`. -Set the profile-store environment variables below when more than one WSL store +On Windows, when the provider's profile store is absent from `%USERPROFILE%`, +openProfiler enumerates registered WSL distributions and discovers exactly one +Codex or Claude store through its distro-specific `\\wsl.localhost` path. Set +the profile-store environment variables below when more than one WSL store exists. ## Discovery @@ -140,7 +141,7 @@ cross-platform icons, CI, Dependabot, security policy, and contribution guide. Version tags publish Windows installers through the [`Windows Release`](.github/workflows/windows-release.yml) GitHub Actions workflow. The tag must match the version in `src-tauri/tauri.conf.json`; for -example, version `0.1.0` is released with tag `v0.1.0`. +example, version `0.1.1` is released with tag `v0.1.1`. The tagged GitHub prerelease contains: diff --git a/crates/open-profiler-core/Cargo.toml b/crates/open-profiler-core/Cargo.toml index f04410e..e7ee974 100644 --- a/crates/open-profiler-core/Cargo.toml +++ b/crates/open-profiler-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opensoft-open-profiler-core" -version = "0.1.0" +version = "0.1.1" description = "Secure LLM profile discovery and activation for openProfiler" edition.workspace = true license.workspace = true diff --git a/crates/open-profiler-core/src/lib.rs b/crates/open-profiler-core/src/lib.rs index 357d1c7..40fa964 100644 --- a/crates/open-profiler-core/src/lib.rs +++ b/crates/open-profiler-core/src/lib.rs @@ -135,6 +135,8 @@ impl DiscoveryConfig { .unwrap_or_else(|| home.join(".config")); let default_codex_profiles_home = Provider::Codex.default_profiles_home(&home); let default_codex_manifest = config_home.join("workbenches/openai-profiles.json"); + let default_claude_profiles_home = Provider::Claude.default_profiles_home(&home); + let default_claude_manifest = config_home.join("workbenches/claude-profiles.json"); #[cfg(windows)] let (discovered_codex_profiles_home, discovered_codex_manifest) = if default_codex_profiles_home.join("profiles").is_dir() { @@ -143,7 +145,7 @@ impl DiscoveryConfig { default_codex_manifest.clone(), ) } else { - discover_wsl_codex_defaults().unwrap_or_else(|| { + discover_wsl_defaults(Provider::Codex).unwrap_or_else(|| { ( default_codex_profiles_home.clone(), default_codex_manifest.clone(), @@ -155,6 +157,26 @@ impl DiscoveryConfig { default_codex_profiles_home.clone(), default_codex_manifest.clone(), ); + #[cfg(windows)] + let (discovered_claude_profiles_home, discovered_claude_manifest) = + if default_claude_profiles_home.join("profiles").is_dir() { + ( + default_claude_profiles_home.clone(), + default_claude_manifest.clone(), + ) + } else { + discover_wsl_defaults(Provider::Claude).unwrap_or_else(|| { + ( + default_claude_profiles_home.clone(), + default_claude_manifest.clone(), + ) + }) + }; + #[cfg(not(windows))] + let (discovered_claude_profiles_home, discovered_claude_manifest) = ( + default_claude_profiles_home.clone(), + default_claude_manifest.clone(), + ); let codex = ProviderConfig { provider: Provider::Codex, @@ -176,10 +198,10 @@ impl DiscoveryConfig { provider: Provider::Claude, manifest_path: env::var_os("CLAUDE_PROFILES_MANIFEST") .map(PathBuf::from) - .unwrap_or_else(|| config_home.join("workbenches/claude-profiles.json")), + .unwrap_or(discovered_claude_manifest), profiles_home: env::var_os("CLAUDE_PROFILES_HOME") .map(PathBuf::from) - .unwrap_or_else(|| Provider::Claude.default_profiles_home(&home)), + .unwrap_or(discovered_claude_profiles_home), active_home: env::var_os("OPENPROFILER_CLAUDE_ACTIVE_HOME") .or_else(|| env::var_os("PROFILE_SWITCHER_CLAUDE_ACTIVE_HOME")) .map(PathBuf::from) @@ -193,30 +215,32 @@ impl DiscoveryConfig { } #[cfg(windows)] -fn discover_wsl_codex_defaults() -> Option<(PathBuf, PathBuf)> { +fn discover_wsl_defaults(provider: Provider) -> Option<(PathBuf, PathBuf)> { let mut candidates = Vec::new(); - for wsl_root in [r"\\wsl.localhost", r"\\wsl$"] { - let Ok(distributions) = fs::read_dir(wsl_root) else { - continue; - }; - for distribution in distributions.filter_map(std::result::Result::ok) { - let home_root = distribution.path().join("home"); + for distribution in wsl_distribution_names() { + for wsl_root in [r"\\wsl.localhost", r"\\wsl$"] { + let home_root = PathBuf::from(format!(r"{wsl_root}\{distribution}\home")); let Ok(users) = fs::read_dir(home_root) else { continue; }; + let mut found_in_distribution = false; for user in users.filter_map(std::result::Result::ok) { let user_home = user.path(); - let profiles_home = user_home.join(".chatgpt-profiles"); + let profiles_home = provider.default_profiles_home(&user_home); if profiles_home.join("profiles").is_dir() { candidates.push(( profiles_home, - user_home.join(".config/workbenches/openai-profiles.json"), + user_home.join(".config/workbenches").join(match provider { + Provider::Codex => "openai-profiles.json", + Provider::Claude => "claude-profiles.json", + }), )); + found_in_distribution = true; } } - } - if !candidates.is_empty() { - break; + if found_in_distribution { + break; + } } } @@ -225,6 +249,58 @@ fn discover_wsl_codex_defaults() -> Option<(PathBuf, PathBuf)> { (candidates.len() == 1).then(|| candidates.remove(0)) } +#[cfg(windows)] +fn wsl_distribution_names() -> Vec { + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut command = std::process::Command::new("wsl.exe"); + command + .args(["--list", "--quiet"]) + .creation_flags(CREATE_NO_WINDOW); + let Ok(output) = command.output() else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + + parse_wsl_distribution_names(&output.stdout) +} + +#[cfg(windows)] +fn parse_wsl_distribution_names(bytes: &[u8]) -> Vec { + decode_wsl_output(bytes) + .lines() + .map(str::trim) + .filter(|name| { + !name.is_empty() + && *name != "." + && *name != ".." + && !name.contains('\\') + && !name.contains('/') + && !name.chars().any(char::is_control) + }) + .map(str::to_owned) + .collect() +} + +#[cfg(windows)] +fn decode_wsl_output(bytes: &[u8]) -> String { + let looks_utf16_le = + bytes.starts_with(&[0xff, 0xfe]) || bytes.chunks_exact(2).any(|pair| pair[1] == 0); + if !looks_utf16_le { + return String::from_utf8_lossy(bytes).into_owned(); + } + + let start = usize::from(bytes.starts_with(&[0xff, 0xfe])) * 2; + let units = bytes[start..] + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + String::from_utf16_lossy(&units) +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ProfileInventory { @@ -1517,6 +1593,29 @@ mod tests { .unwrap() } + #[cfg(windows)] + #[test] + fn parses_utf16_wsl_distribution_names_safely() { + let bytes = "Ubuntu-24.04\r\ndocker-desktop\r\n..\\escape\r\n" + .encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect::>(); + + assert_eq!( + parse_wsl_distribution_names(&bytes), + vec!["Ubuntu-24.04", "docker-desktop"] + ); + } + + #[cfg(windows)] + #[test] + fn parses_utf8_wsl_distribution_names() { + assert_eq!( + parse_wsl_distribution_names(b"Ubuntu\r\nUbuntu-Preview\r\n"), + vec!["Ubuntu", "Ubuntu-Preview"] + ); + } + #[test] fn discovers_codex_and_claude_profiles() { let temp = TempDir::new().unwrap(); diff --git a/package.json b/package.json index 6dae3c6..2e337d8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@opensoft/open-profiler", "private": true, - "version": "0.1.0", + "version": "0.1.1", "description": "A full LLM profile manager", "license": "Apache-2.0", "type": "module", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index da11074..15d5033 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opensoft-open-profiler" -version = "0.1.0" +version = "0.1.1" description = "A full LLM profile manager" edition.workspace = true license.workspace = true diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 696e5c6..aeb056f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "openProfiler", - "version": "0.1.0", + "version": "0.1.1", "identifier": "com.opensoft.openprofiler", "build": { "beforeDevCommand": "pnpm dev", From b078c9e986f7f8b24adccc4debb5c4d6c2951b95 Mon Sep 17 00:00:00 2001 From: brettheap Date: Sat, 25 Jul 2026 23:47:18 -0400 Subject: [PATCH 2/6] fix: harden WSL output decoding --- crates/open-profiler-core/src/lib.rs | 60 ++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/open-profiler-core/src/lib.rs b/crates/open-profiler-core/src/lib.rs index 40fa964..ca543f7 100644 --- a/crates/open-profiler-core/src/lib.rs +++ b/crates/open-profiler-core/src/lib.rs @@ -287,13 +287,43 @@ fn parse_wsl_distribution_names(bytes: &[u8]) -> Vec { #[cfg(windows)] fn decode_wsl_output(bytes: &[u8]) -> String { - let looks_utf16_le = - bytes.starts_with(&[0xff, 0xfe]) || bytes.chunks_exact(2).any(|pair| pair[1] == 0); - if !looks_utf16_le { + let has_utf16_bom = bytes.starts_with(&[0xff, 0xfe]); + let has_utf16_line_ending = bytes + .windows(4) + .any(|window| window == [b'\r', 0, b'\n', 0]) + || bytes.windows(2).any(|window| window == [b'\n', 0]); + let has_utf8_line_ending = bytes.contains(&b'\n') && !has_utf16_line_ending; + + if has_utf16_bom || has_utf16_line_ending { + return decode_utf16_le(bytes, has_utf16_bom); + } + if has_utf8_line_ending { return String::from_utf8_lossy(bytes).into_owned(); } - let start = usize::from(bytes.starts_with(&[0xff, 0xfe])) * 2; + let utf8 = std::str::from_utf8(bytes).ok(); + let utf16 = (bytes.len() % 2 == 0).then(|| decode_utf16_le(bytes, false)); + match (utf8, utf16) { + (Some(utf8), Some(utf16)) => { + let utf8_penalty = decoding_penalty(utf8); + let utf16_penalty = decoding_penalty(&utf16); + if utf8_penalty < utf16_penalty { + utf8.to_owned() + } else { + // Native wsl.exe emits UTF-16LE. Prefer it when both strict + // decodings are equally plausible and no line ending exists. + utf16 + } + } + (Some(utf8), None) => utf8.to_owned(), + (None, Some(utf16)) => utf16, + (None, None) => String::from_utf8_lossy(bytes).into_owned(), + } +} + +#[cfg(windows)] +fn decode_utf16_le(bytes: &[u8], has_bom: bool) -> String { + let start = usize::from(has_bom) * 2; let units = bytes[start..] .chunks_exact(2) .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) @@ -301,6 +331,17 @@ fn decode_wsl_output(bytes: &[u8]) -> String { String::from_utf16_lossy(&units) } +#[cfg(windows)] +fn decoding_penalty(value: &str) -> usize { + value + .chars() + .filter(|character| { + *character == '\u{fffd}' + || (*character != '\r' && *character != '\n' && character.is_control()) + }) + .count() +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ProfileInventory { @@ -1616,6 +1657,17 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn decodes_utf16_wsl_name_without_null_bytes() { + let bytes = "䅂" + .encode_utf16() + .flat_map(|unit| unit.to_le_bytes()) + .collect::>(); + + assert_eq!(decode_wsl_output(&bytes), "䅂"); + } + #[test] fn discovers_codex_and_claude_profiles() { let temp = TempDir::new().unwrap(); From 635c1b6fab1553df07c9643b3fd163456a242f70 Mon Sep 17 00:00:00 2001 From: brettheap Date: Sat, 25 Jul 2026 23:49:19 -0400 Subject: [PATCH 3/6] fix: satisfy Windows Clippy --- crates/open-profiler-core/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/open-profiler-core/src/lib.rs b/crates/open-profiler-core/src/lib.rs index ca543f7..4018943 100644 --- a/crates/open-profiler-core/src/lib.rs +++ b/crates/open-profiler-core/src/lib.rs @@ -302,7 +302,10 @@ fn decode_wsl_output(bytes: &[u8]) -> String { } let utf8 = std::str::from_utf8(bytes).ok(); - let utf16 = (bytes.len() % 2 == 0).then(|| decode_utf16_le(bytes, false)); + let utf16 = bytes + .len() + .is_multiple_of(2) + .then(|| decode_utf16_le(bytes, false)); match (utf8, utf16) { (Some(utf8), Some(utf16)) => { let utf8_penalty = decoding_penalty(utf8); From d620abc0246f4854fd6746bd0bf68c23eb109c8c Mon Sep 17 00:00:00 2001 From: brettheap Date: Sat, 25 Jul 2026 23:57:12 -0400 Subject: [PATCH 4/6] fix: harden Windows WSL discovery --- README.md | 6 ++-- crates/open-profiler-core/src/lib.rs | 46 ++++++++++++++++++---------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index fcd16c3..1001e96 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,9 @@ supported credential-store modes. On Windows, when the provider's profile store is absent from `%USERPROFILE%`, openProfiler enumerates registered WSL distributions and discovers exactly one -Codex or Claude store through its distro-specific `\\wsl.localhost` path. Set -the profile-store environment variables below when more than one WSL store -exists. +Codex or Claude store through distro-specific `\\wsl.localhost` or `\\wsl$` +paths. Set the profile-store environment variables below when more than one WSL +store exists. ## Discovery diff --git a/crates/open-profiler-core/src/lib.rs b/crates/open-profiler-core/src/lib.rs index 4018943..340c166 100644 --- a/crates/open-profiler-core/src/lib.rs +++ b/crates/open-profiler-core/src/lib.rs @@ -137,9 +137,22 @@ impl DiscoveryConfig { let default_codex_manifest = config_home.join("workbenches/openai-profiles.json"); let default_claude_profiles_home = Provider::Claude.default_profiles_home(&home); let default_claude_manifest = config_home.join("workbenches/claude-profiles.json"); + let codex_profiles_home_override = env::var_os("CODEX_PROFILES_HOME") + .or_else(|| env::var_os("CHATGPT_PROFILES_HOME")) + .map(PathBuf::from); + let codex_manifest_override = env::var_os("CODEX_PROFILES_MANIFEST") + .or_else(|| env::var_os("CHATGPT_PROFILES_MANIFEST")) + .map(PathBuf::from); + let claude_profiles_home_override = env::var_os("CLAUDE_PROFILES_HOME").map(PathBuf::from); + let claude_manifest_override = env::var_os("CLAUDE_PROFILES_MANIFEST").map(PathBuf::from); #[cfg(windows)] let (discovered_codex_profiles_home, discovered_codex_manifest) = - if default_codex_profiles_home.join("profiles").is_dir() { + if codex_profiles_home_override.is_some() { + ( + default_codex_profiles_home.clone(), + default_codex_manifest.clone(), + ) + } else if default_codex_profiles_home.join("profiles").is_dir() { ( default_codex_profiles_home.clone(), default_codex_manifest.clone(), @@ -159,7 +172,12 @@ impl DiscoveryConfig { ); #[cfg(windows)] let (discovered_claude_profiles_home, discovered_claude_manifest) = - if default_claude_profiles_home.join("profiles").is_dir() { + if claude_profiles_home_override.is_some() { + ( + default_claude_profiles_home.clone(), + default_claude_manifest.clone(), + ) + } else if default_claude_profiles_home.join("profiles").is_dir() { ( default_claude_profiles_home.clone(), default_claude_manifest.clone(), @@ -180,14 +198,8 @@ impl DiscoveryConfig { let codex = ProviderConfig { provider: Provider::Codex, - manifest_path: env::var_os("CODEX_PROFILES_MANIFEST") - .or_else(|| env::var_os("CHATGPT_PROFILES_MANIFEST")) - .map(PathBuf::from) - .unwrap_or(discovered_codex_manifest), - profiles_home: env::var_os("CODEX_PROFILES_HOME") - .or_else(|| env::var_os("CHATGPT_PROFILES_HOME")) - .map(PathBuf::from) - .unwrap_or(discovered_codex_profiles_home), + manifest_path: codex_manifest_override.unwrap_or(discovered_codex_manifest), + profiles_home: codex_profiles_home_override.unwrap_or(discovered_codex_profiles_home), active_home: env::var_os("OPENPROFILER_CODEX_ACTIVE_HOME") .or_else(|| env::var_os("PROFILE_SWITCHER_CODEX_ACTIVE_HOME")) .map(PathBuf::from) @@ -196,12 +208,8 @@ impl DiscoveryConfig { let claude = ProviderConfig { provider: Provider::Claude, - manifest_path: env::var_os("CLAUDE_PROFILES_MANIFEST") - .map(PathBuf::from) - .unwrap_or(discovered_claude_manifest), - profiles_home: env::var_os("CLAUDE_PROFILES_HOME") - .map(PathBuf::from) - .unwrap_or(discovered_claude_profiles_home), + manifest_path: claude_manifest_override.unwrap_or(discovered_claude_manifest), + profiles_home: claude_profiles_home_override.unwrap_or(discovered_claude_profiles_home), active_home: env::var_os("OPENPROFILER_CLAUDE_ACTIVE_HOME") .or_else(|| env::var_os("PROFILE_SWITCHER_CLAUDE_ACTIVE_HOME")) .map(PathBuf::from) @@ -254,7 +262,11 @@ fn wsl_distribution_names() -> Vec { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let mut command = std::process::Command::new("wsl.exe"); + let Some(system_root) = env::var_os("SystemRoot") else { + return Vec::new(); + }; + let wsl_executable = PathBuf::from(system_root).join("System32").join("wsl.exe"); + let mut command = std::process::Command::new(wsl_executable); command .args(["--list", "--quiet"]) .creation_flags(CREATE_NO_WINDOW); From b2939eed6081c33275918d0013977d5c508a3b02 Mon Sep 17 00:00:00 2001 From: brettheap Date: Sat, 25 Jul 2026 23:59:24 -0400 Subject: [PATCH 5/6] fix: simplify Windows discovery guard --- crates/open-profiler-core/src/lib.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/open-profiler-core/src/lib.rs b/crates/open-profiler-core/src/lib.rs index 340c166..9639591 100644 --- a/crates/open-profiler-core/src/lib.rs +++ b/crates/open-profiler-core/src/lib.rs @@ -147,12 +147,9 @@ impl DiscoveryConfig { let claude_manifest_override = env::var_os("CLAUDE_PROFILES_MANIFEST").map(PathBuf::from); #[cfg(windows)] let (discovered_codex_profiles_home, discovered_codex_manifest) = - if codex_profiles_home_override.is_some() { - ( - default_codex_profiles_home.clone(), - default_codex_manifest.clone(), - ) - } else if default_codex_profiles_home.join("profiles").is_dir() { + if codex_profiles_home_override.is_some() + || default_codex_profiles_home.join("profiles").is_dir() + { ( default_codex_profiles_home.clone(), default_codex_manifest.clone(), @@ -172,12 +169,9 @@ impl DiscoveryConfig { ); #[cfg(windows)] let (discovered_claude_profiles_home, discovered_claude_manifest) = - if claude_profiles_home_override.is_some() { - ( - default_claude_profiles_home.clone(), - default_claude_manifest.clone(), - ) - } else if default_claude_profiles_home.join("profiles").is_dir() { + if claude_profiles_home_override.is_some() + || default_claude_profiles_home.join("profiles").is_dir() + { ( default_claude_profiles_home.clone(), default_claude_manifest.clone(), From ffac368749e5f9062e897afbf9d87ed171cf36e2 Mon Sep 17 00:00:00 2001 From: brettheap Date: Sun, 26 Jul 2026 00:03:48 -0400 Subject: [PATCH 6/6] perf: stop ambiguous WSL scans early --- crates/open-profiler-core/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/open-profiler-core/src/lib.rs b/crates/open-profiler-core/src/lib.rs index 9639591..2ce480f 100644 --- a/crates/open-profiler-core/src/lib.rs +++ b/crates/open-profiler-core/src/lib.rs @@ -237,6 +237,9 @@ fn discover_wsl_defaults(provider: Provider) -> Option<(PathBuf, PathBuf)> { Provider::Claude => "claude-profiles.json", }), )); + if candidates.len() > 1 { + return None; + } found_in_distribution = true; } }