Skip to content
Open
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
6 changes: 6 additions & 0 deletions src-tauri/src/agents/backups/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1106,13 +1106,19 @@ fn linked_configuration_and_backup_directories_are_rejected() {
fs::read_to_string(outside.0.join("config.toml")).unwrap(),
"custom='outside'"
);
#[cfg(unix)]
fs::remove_file(link).unwrap();
#[cfg(windows)]
fs::remove_dir(link).unwrap();
let data = agent_data_directory(&paths).unwrap();
fs::create_dir_all(&data).unwrap();
link_directory(&outside.0, &data.join("backups"));
assert!(create_backup("codex", &home.0).is_err());
assert!(list_backups("codex", &home.0).is_err());
assert!(delete_backup("codex", &home.0, "1").is_err());
#[cfg(unix)]
fs::remove_file(data.join("backups")).unwrap();
#[cfg(windows)]
fs::remove_dir(data.join("backups")).unwrap();
}

Expand Down
109 changes: 109 additions & 0 deletions src-tauri/src/app_settings.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,114 @@
use super::*;

#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ApiAccessRecordIdentityInput {
pub(crate) provider_section: String,
pub(crate) record_name: String,
pub(crate) base_url: String,
pub(crate) api_keys: Vec<String>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ApiAccessBalanceEndpointUpdate {
pub(crate) previous_identity: Option<ApiAccessRecordIdentityInput>,
pub(crate) next_identity: Option<ApiAccessRecordIdentityInput>,
pub(crate) balance_url: String,
}

fn api_access_record_identity_input(
input: &ApiAccessRecordIdentityInput,
) -> Result<(String, String), String> {
Ok((
input.provider_section.trim().to_string(),
api_access_record_identity(
&input.provider_section,
&input.record_name,
&input.base_url,
&input.api_keys,
)?,
))
}

#[tauri::command]
pub(crate) fn resolve_api_access_balance_urls(
queries: Vec<ApiAccessRecordIdentityInput>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<Vec<Option<String>>, String> {
let config = gui_config_state.snapshot()?;
queries
.iter()
.map(|query| {
let (provider_section, record_identity) = api_access_record_identity_input(query)?;
Ok(config
.api_balance_endpoints
.iter()
.find(|entry| {
entry.provider_section == provider_section
&& entry.record_identity == record_identity
})
.map(|entry| entry.balance_url.clone()))
})
.collect()
}

pub(crate) fn apply_api_access_balance_endpoint_update(
config: &mut GuiConfigFile,
update: &ApiAccessBalanceEndpointUpdate,
) -> Result<(), String> {
let previous = update
.previous_identity
.as_ref()
.map(api_access_record_identity_input)
.transpose()?;
let next = update
.next_identity
.as_ref()
.map(api_access_record_identity_input)
.transpose()?;
let balance_url = update.balance_url.trim().to_string();
if let Some((provider_section, record_identity)) = next.as_ref() {
if !balance_url.is_empty() {
validate_gui_api_balance_endpoint(&GuiApiBalanceEndpoint {
provider_section: provider_section.clone(),
record_identity: record_identity.clone(),
balance_url: balance_url.clone(),
})?;
}
}
config.api_balance_endpoints.retain(|entry| {
let previous_match = previous.as_ref().is_some_and(|(section, identity)| {
entry.provider_section == *section && entry.record_identity == *identity
});
let next_match = next.as_ref().is_some_and(|(section, identity)| {
entry.provider_section == *section && entry.record_identity == *identity
});
!previous_match && !next_match
});
if let Some((provider_section, record_identity)) = next.as_ref() {
if !balance_url.is_empty() {
config.api_balance_endpoints.push(GuiApiBalanceEndpoint {
provider_section: provider_section.clone(),
record_identity: record_identity.clone(),
balance_url: reqwest::Url::parse(&balance_url)
.map_err(|_| API_BALANCE_INVALID_URL.to_string())?
.to_string(),
});
}
}
Ok(())
}

#[tauri::command]
pub(crate) fn save_api_access_balance_endpoint(
update: ApiAccessBalanceEndpointUpdate,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<(), String> {
gui_config_state.update(|config| apply_api_access_balance_endpoint_update(config, &update))?;
Ok(())
}

#[tauri::command]
pub(crate) fn health_check() -> &'static str {
"EasyCLIProxyAPI Rust backend is ready"
Expand Down
133 changes: 133 additions & 0 deletions src-tauri/src/core_config/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,94 @@ pub(crate) fn validate_api_access_provider_section(section: &str) -> Result<(),
}
}

pub(crate) const API_BALANCE_INVALID_PROVIDER: &str = "api_balance.invalid_provider";
pub(crate) const API_BALANCE_INVALID_IDENTITY: &str = "api_balance.invalid_identity";
pub(crate) const API_BALANCE_INVALID_URL: &str = "api_balance.invalid_url";
pub(crate) const API_BALANCE_INSECURE_URL: &str = "api_balance.insecure_url";
pub(crate) const API_BALANCE_INVALID_RECORD_NAME: &str = "api_balance.invalid_record_name";
pub(crate) const API_BALANCE_INVALID_BASE_URL: &str = "api_balance.invalid_base_url";

pub(crate) fn validate_gui_api_balance_endpoint(
endpoint: &GuiApiBalanceEndpoint,
) -> Result<(), String> {
validate_api_access_provider_section(&endpoint.provider_section)
.map_err(|_| API_BALANCE_INVALID_PROVIDER.to_string())?;
if endpoint.record_identity.len() != 64
|| !endpoint
.record_identity
.chars()
.all(|character| character.is_ascii_hexdigit())
{
return Err(API_BALANCE_INVALID_IDENTITY.to_string());
}
let value = endpoint.balance_url.trim();
if value.is_empty() || value.chars().count() > 2048 || value.chars().any(char::is_control) {
return Err(API_BALANCE_INVALID_URL.to_string());
}
let parsed = reqwest::Url::parse(value).map_err(|_| API_BALANCE_INVALID_URL.to_string())?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.fragment().is_some()
|| (parsed.scheme() == "http" && !is_loopback_host(parsed.host_str().unwrap_or_default()))
{
return Err(API_BALANCE_INSECURE_URL.to_string());
}
Ok(())
}

pub(crate) fn api_access_key_hash(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| sha256_bytes(value.as_bytes()))
}

pub(crate) fn api_access_record_identity(
provider_section: &str,
record_name: &str,
base_url: &str,
api_keys: &[String],
) -> Result<String, String> {
validate_api_access_provider_section(provider_section)
.map_err(|_| API_BALANCE_INVALID_PROVIDER.to_string())?;
let record_name = record_name.trim();
if record_name.chars().count() > 512 || record_name.chars().any(char::is_control) {
return Err(API_BALANCE_INVALID_RECORD_NAME.to_string());
}
let base_url = base_url.trim();
if base_url.chars().count() > 2048 || base_url.chars().any(char::is_control) {
return Err(API_BALANCE_INVALID_BASE_URL.to_string());
}
let normalized_base_url = if base_url.is_empty() {
String::new()
} else {
let parsed =
reqwest::Url::parse(base_url).map_err(|_| API_BALANCE_INVALID_BASE_URL.to_string())?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
{
return Err(API_BALANCE_INVALID_BASE_URL.to_string());
}
parsed.to_string()
};
let mut key_hashes = api_keys
.iter()
.filter_map(|key| api_access_key_hash(key))
.collect::<Vec<_>>();
key_hashes.sort_unstable();
key_hashes.dedup();
let material = [
provider_section.trim(),
record_name,
&normalized_base_url,
&key_hashes.join("\u{0}"),
]
.join("\u{0}");
Ok(sha256_bytes(material.as_bytes()))
}

pub(crate) fn usage_provider_section(provider: &str) -> Option<&'static str> {
match provider.trim().to_ascii_lowercase().as_str() {
"codex" => Some("codex-api-key"),
Expand Down Expand Up @@ -1627,6 +1710,34 @@ pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result<bool, St
if config.api_keys != original_api_keys {
changed = true;
}
let original_balance_endpoints = config.api_balance_endpoints.clone();
config.api_balance_endpoints = config
.api_balance_endpoints
.iter()
.filter_map(|entry| {
validate_gui_api_balance_endpoint(entry).ok().map(|_| {
let mut normalized = entry.clone();
normalized.provider_section = normalized.provider_section.trim().to_string();
normalized.record_identity = normalized.record_identity.trim().to_ascii_lowercase();
normalized.balance_url = reqwest::Url::parse(normalized.balance_url.trim())
.ok()
.map(|url| url.to_string())
.unwrap_or_default();
normalized
})
})
.fold(Vec::new(), |mut entries, entry| {
if !entries.iter().any(|existing: &GuiApiBalanceEndpoint| {
existing.provider_section == entry.provider_section
&& existing.record_identity == entry.record_identity
}) {
entries.push(entry);
}
entries
});
if config.api_balance_endpoints != original_balance_endpoints {
changed = true;
}
let proxy_url = if config.proxy_override {
network_proxy::normalize_optional_proxy_url(&config.proxy_url)?
} else {
Expand Down Expand Up @@ -1851,6 +1962,25 @@ pub(crate) fn write_gui_config_to_path(
"api-access-remarks",
Item::Value(Value::Array(api_access_remarks)),
);
let mut api_balance_endpoints = Array::new();
for entry in &config.api_balance_endpoints {
let mut table = InlineTable::new();
table.insert(
"provider-section",
Value::from(entry.provider_section.as_str()),
);
table.insert(
"record-identity",
Value::from(entry.record_identity.as_str()),
);
table.insert("balance-url", Value::from(entry.balance_url.as_str()));
api_balance_endpoints.push(Value::InlineTable(table));
}
set_codex_table_item(
root,
"api-balance-endpoints",
Item::Value(Value::Array(api_balance_endpoints)),
);

let content = document.to_string();
toml::from_str::<GuiConfigFile>(&content)
Expand Down Expand Up @@ -1882,6 +2012,9 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String>
validate_core_api_key(&entry.key)?;
validate_api_key_remark(&entry.remark)?;
}
for entry in &config.api_balance_endpoints {
validate_gui_api_balance_endpoint(entry)?;
}
for entry in &config.api_access_remarks {
validate_api_access_provider_section(&entry.provider_section)?;
if entry.api_key_hash.len() != 64
Expand Down
14 changes: 14 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ struct GuiConfigFile {
#[serde(deserialize_with = "deserialize_gui_api_keys")]
api_keys: Vec<GuiApiKeyEntry>,
api_access_remarks: Vec<GuiApiAccessRemark>,
api_balance_endpoints: Vec<GuiApiBalanceEndpoint>,
management_secret_key: String,
debug: bool,
commercial_mode: bool,
Expand Down Expand Up @@ -870,6 +871,14 @@ struct GuiApiAccessRemark {
remark: String,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct GuiApiBalanceEndpoint {
provider_section: String,
record_identity: String,
balance_url: String,
}

#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiAccessRemarkLocator {
Expand Down Expand Up @@ -939,6 +948,9 @@ impl Default for GuiConfigFile {
auth_dir: DEFAULT_AUTH_DIR.to_string(),
api_keys: vec![default_api_key_entry()],
api_access_remarks: Vec::new(),
api_balance_endpoints: Vec::new(),
// Populated with an OS-generated secret while loading the GUI
// configuration. Core hashes the value written into config.yaml.
management_secret_key: String::new(),
debug: false,
commercial_mode: false,
Expand Down Expand Up @@ -2590,7 +2602,9 @@ fn main() {
get_core_status,
get_gui_settings,
resolve_api_access_remarks,
resolve_api_access_balance_urls,
save_api_access_remark,
save_api_access_balance_endpoint,
set_app_locale,
resolve_windows_close_request,
get_software_settings,
Expand Down
Loading