Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,11 @@ 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
exists.
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 distro-specific `\\wsl.localhost` or `\\wsl$`
paths. Set the profile-store environment variables below when more than one WSL
store exists.

## Discovery

Expand Down Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion crates/open-profiler-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
219 changes: 191 additions & 28 deletions crates/open-profiler-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,27 @@ 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");
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.join("profiles").is_dir()
{
(
default_codex_profiles_home.clone(),
default_codex_manifest.clone(),
)
} else {
discover_wsl_codex_defaults().unwrap_or_else(|| {
discover_wsl_defaults(Provider::Codex).unwrap_or_else(|| {
(
Comment thread
brettheap marked this conversation as resolved.
default_codex_profiles_home.clone(),
default_codex_manifest.clone(),
Expand All @@ -155,17 +167,33 @@ impl DiscoveryConfig {
default_codex_profiles_home.clone(),
default_codex_manifest.clone(),
);
#[cfg(windows)]
let (discovered_claude_profiles_home, discovered_claude_manifest) =
if claude_profiles_home_override.is_some()
|| 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,
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)
Expand All @@ -174,12 +202,8 @@ impl DiscoveryConfig {

let claude = ProviderConfig {
provider: Provider::Claude,
manifest_path: env::var_os("CLAUDE_PROFILES_MANIFEST")
.map(PathBuf::from)
.unwrap_or_else(|| config_home.join("workbenches/claude-profiles.json")),
profiles_home: env::var_os("CLAUDE_PROFILES_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| Provider::Claude.default_profiles_home(&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)
Expand All @@ -193,30 +217,35 @@ 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",
Comment thread
brettheap marked this conversation as resolved.
Provider::Claude => "claude-profiles.json",
}),
));
if candidates.len() > 1 {
return None;
}
found_in_distribution = true;
}
}
}
if !candidates.is_empty() {
break;
if found_in_distribution {
break;
}
}
}

Expand All @@ -225,6 +254,106 @@ fn discover_wsl_codex_defaults() -> Option<(PathBuf, PathBuf)> {
(candidates.len() == 1).then(|| candidates.remove(0))
}

#[cfg(windows)]
fn wsl_distribution_names() -> Vec<String> {
use std::os::windows::process::CommandExt;

const CREATE_NO_WINDOW: u32 = 0x0800_0000;
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);
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<String> {
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 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 utf8 = std::str::from_utf8(bytes).ok();
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);
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]]))
.collect::<Vec<_>>();
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 {
Expand Down Expand Up @@ -1517,6 +1646,40 @@ 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::<Vec<_>>();

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"]
);
}

#[cfg(windows)]
#[test]
fn decodes_utf16_wsl_name_without_null_bytes() {
let bytes = "䅂"
.encode_utf16()
.flat_map(|unit| unit.to_le_bytes())
.collect::<Vec<_>>();

assert_eq!(decode_wsl_output(&bytes), "䅂");
}

#[test]
fn discovers_codex_and_claude_profiles() {
let temp = TempDir::new().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading