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
6 changes: 6 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,12 @@ tauri-plugin-updater = "=2.9.0"

[dev-dependencies]
tempfile = "3.13"

# `test-util` only for the test build: it is what lets a test drive Tokio's
# clock (`#[tokio::test(start_paused = true)]`) rather than really sleeping out
# a multi-second timeout. Deliberately absent from the production features.
tokio = { workspace = true, features = ["test-util"] }

insta = { version = "1", features = ["yaml", "redactions"] }
assert_matches = "1"
# HTTP mock server for sync adapter integration tests.
Expand Down
29 changes: 28 additions & 1 deletion src-tauri/crates/agent-cli/src/managed_config/file_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,33 @@ fn unique_temp_path(path: &Path) -> PathBuf {
))
}

/// Create the staging file for an atomic write, owner-only.
///
/// The mode has to be set at creation because the temp file holds the payload
/// before the caller gets a chance to tighten permissions on the destination:
/// with a 0002 umask a plain create lands at 0664, so a credential would be
/// group-readable for the whole write, and would stay that way in a temp file
/// left behind by a crash.
///
/// There is no loose variant because nothing here wants one — every
/// destination in this module ends up 0600 anyway, via
/// [`app_paths::set_sensitive_file_permissions`].
fn create_staging_file(path: &Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
// The name carries pid + nanos, so `create_new` never collides in
// practice; it does guarantee we never inherit the mode of a file somebody
// else left at this path.
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path)
}

/// Crash-safe replace: write an owner-only sibling temp file, fsync, then
/// rename over the target.
pub(super) fn write_file_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
Expand All @@ -54,7 +81,7 @@ pub(super) fn write_file_atomic(path: &Path, bytes: &[u8]) -> Result<(), String>

let tmp = unique_temp_path(path);
let result = (|| {
let mut file = std::fs::File::create(&tmp)
let mut file = create_staging_file(&tmp)
.map_err(|err| format!("Failed to create {}: {err}", tmp.display()))?;
use std::io::Write;
file.write_all(bytes)
Expand Down
56 changes: 56 additions & 0 deletions src-tauri/crates/agent-cli/src/managed_config/generators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,67 @@ pub(super) fn generate_codex_managed_config(
"wire_api".to_string(),
toml::Value::String("responses".to_string()),
);
orgii.insert(
"supports_websockets".to_string(),
toml::Value::Boolean(false),
);
orgii.insert(
"request_max_retries".to_string(),
toml::Value::Integer(super::CODEX_REQUEST_MAX_RETRIES),
);
orgii.insert(
"stream_max_retries".to_string(),
toml::Value::Integer(super::CODEX_STREAM_MAX_RETRIES),
);
providers.insert(ORGII_PROVIDER_ID.to_string(), toml::Value::Table(orgii));

toml::to_string_pretty(&config).map_err(|err| format!("TOML serialize error: {err}"))
}

pub(super) fn generate_codex_hosted_profile(proxy_url: &str) -> Result<String, String> {
let proxy_url = proxy_url.trim();
if proxy_url.is_empty() {
return Err("Codex hosted profile requires a proxy URL".to_string());
}

let base_url = format!("{}/v1", proxy_url.trim_end_matches('/'));
let mut provider = toml::map::Map::new();
provider.insert("name".to_string(), toml::Value::String("Proxy".to_string()));
provider.insert("base_url".to_string(), toml::Value::String(base_url));
provider.insert(
"env_key".to_string(),
toml::Value::String("PROXY_TOKEN".to_string()),
);
provider.insert(
"requires_openai_auth".to_string(),
toml::Value::Boolean(false),
);
provider.insert(
"wire_api".to_string(),
toml::Value::String("responses".to_string()),
);
provider.insert(
"supports_websockets".to_string(),
toml::Value::Boolean(false),
);
provider.insert(
"request_max_retries".to_string(),
toml::Value::Integer(super::CODEX_REQUEST_MAX_RETRIES),
);
provider.insert(
"stream_max_retries".to_string(),
toml::Value::Integer(super::CODEX_STREAM_MAX_RETRIES),
);

let mut providers = toml::map::Map::new();
providers.insert("proxy".to_string(), toml::Value::Table(provider));
let mut root = toml::map::Map::new();
root.insert("model_providers".to_string(), toml::Value::Table(providers));

toml::to_string_pretty(&toml::Value::Table(root))
.map_err(|err| format!("TOML serialize error: {err}"))
}

pub(super) fn selected_model_or_default(selected_model: Option<&str>) -> &str {
selected_model
.map(str::trim)
Expand Down
32 changes: 32 additions & 0 deletions src-tauri/crates/agent-cli/src/managed_config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,38 @@ use operations::{
use registry::{supported_agent, unavailable_agent_message, MANAGED_CONFIG_ADAPTERS};
use transaction::recover_pending_transaction_unlocked;

/// Keep a small amount of Codex-native recovery without combining it with
/// ORGII's whole-process overload replay.
pub const CODEX_REQUEST_MAX_RETRIES: i64 = 2;
pub const CODEX_STREAM_MAX_RETRIES: i64 = 2;

/// Write the complete, ORGII-owned Codex profile used by one hosted session.
///
/// This deliberately does not merge with the user's global Codex config: the
/// caller points `CODEX_HOME` at a session-scoped directory, eliminating
/// cross-session and cross-instance last-writer-wins routing.
pub fn write_codex_hosted_profile(
profile_dir: &std::path::Path,
proxy_url: &str,
) -> Result<(), String> {
let content = generators::generate_codex_hosted_profile(proxy_url)?;
let config_path = profile_dir.join("config.toml");
if std::fs::read_to_string(&config_path).ok().as_deref() == Some(content.as_str()) {
return Ok(());
}
file_io::write_sensitive_file_atomic(&config_path, content.as_bytes())
}

/// Crash-safe replace of a CLI profile file: write an owner-only sibling temp
/// file, fsync, then rename over the target. The payload is never on disk
/// group- or world-readable, so callers holding credentials only need
/// [`app_paths::set_sensitive_file_permissions`] to pin the destination's
/// permissions (and to cover Windows ACLs) — and get to decide for themselves
/// whether a failure there is fatal.
pub fn write_cli_profile_file_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
file_io::write_file_atomic(path, bytes)
}

pub use dto::{
CliConfigManagedStatus, CliConfigMode, CliConfigProfileManifest,
CliConfigShutdownRestoreReport, CliConfigTargetFileManifest, CliConfigTargetFileStatus,
Expand Down
73 changes: 73 additions & 0 deletions src-tauri/crates/agent-cli/src/managed_config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ shell_tool = true
parsed["model_providers"]["orgii"]["requires_openai_auth"].as_bool(),
Some(false)
);
assert_eq!(
parsed["model_providers"]["orgii"]["supports_websockets"].as_bool(),
Some(false)
);
assert_eq!(
parsed["model_providers"]["orgii"]["request_max_retries"].as_integer(),
Some(CODEX_REQUEST_MAX_RETRIES)
);
assert_eq!(
parsed["model_providers"]["orgii"]["stream_max_retries"].as_integer(),
Some(CODEX_STREAM_MAX_RETRIES)
);
}

#[test]
Expand Down Expand Up @@ -863,3 +875,64 @@ fn missing_managed_mode_backup_is_never_recreated_from_active_config() {

assert!(result.is_err());
}

#[test]
fn hosted_codex_profile_is_owned_valid_toml_and_uses_bounded_internal_retries() {
let temp = tempfile::tempdir().unwrap();

write_codex_hosted_profile(temp.path(), "http://127.0.0.1:43123/").unwrap();

let content = std::fs::read_to_string(temp.path().join("config.toml")).unwrap();
let config: toml::Value = toml::from_str(&content).unwrap();
let proxy = &config["model_providers"]["proxy"];
assert_eq!(
proxy["base_url"].as_str(),
Some("http://127.0.0.1:43123/v1")
);
assert_eq!(proxy["env_key"].as_str(), Some("PROXY_TOKEN"));
assert_eq!(proxy["requires_openai_auth"].as_bool(), Some(false));
assert_eq!(proxy["wire_api"].as_str(), Some("responses"));
assert_eq!(proxy["supports_websockets"].as_bool(), Some(false));
assert_eq!(
proxy["request_max_retries"].as_integer(),
Some(CODEX_REQUEST_MAX_RETRIES)
);
assert_eq!(
proxy["stream_max_retries"].as_integer(),
Some(CODEX_STREAM_MAX_RETRIES)
);
}

#[cfg(unix)]
#[test]
fn credential_profile_writes_are_owner_only_from_the_first_byte() {
use std::os::unix::fs::PermissionsExt;

let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("auth.json");

write_cli_profile_file_atomic(&path, b"{\"token\":\"secret\"}").unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600,
"credential file must land owner-only without a separate chmod"
);

// Rewriting goes through a fresh temp file; the replacement must be just
// as private as the original, and no staging file may survive.
write_cli_profile_file_atomic(&path, b"{\"token\":\"rotated\"}").unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
let leftovers: Vec<_> = std::fs::read_dir(temp.path())
.unwrap()
.filter_map(Result::ok)
.map(|entry| entry.file_name())
.filter(|name| name.to_string_lossy().ends_with(".tmp"))
.collect();
assert!(
leftovers.is_empty(),
"staging files left behind: {leftovers:?}"
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,14 @@ impl AnthropicClient {
let refreshed = key_vault::key_store::KEY_SERVICE
.refresh_claude_code_oauth_key(&refresh_config.key_id, &rejected_access_token)
.await
.map_err(ProviderError::AuthError)?;
.map_err(ProviderError::AuthError)?
.into_key()
.ok_or_else(|| {
ProviderError::AuthError(format!(
"Key {} is not a native Claude OAuth account",
refresh_config.key_id
))
})?;

let access_token = refreshed
.session_token
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,14 @@ impl CodexNativeClient {
let refreshed = key_vault::key_store::KEY_SERVICE
.refresh_codex_oauth_key(&refresh_config.key_id, &rejected_access_token)
.await
.map_err(ProviderError::AuthError)?;
.map_err(ProviderError::AuthError)?
.into_key()
.ok_or_else(|| {
ProviderError::AuthError(format!(
"Key {} is not a native Codex OAuth account",
refresh_config.key_id
))
})?;

let access_token = refreshed
.session_token
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/crates/app-paths/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,16 @@ pub fn codex_cli_profile_dir(account_id: &str) -> PathBuf {
codex_cli_profile_root().join(sanitize_path_segment(account_id))
}

/// Session-scoped Codex CLI profile root for hosted-key sessions.
pub fn codex_hosted_cli_profile_root() -> PathBuf {
orgii_root().join("codex-hosted-cli-profiles")
}

/// Session-scoped Codex CLI profile dir for one hosted-key session.
pub fn codex_hosted_cli_profile_dir(session_id: &str) -> PathBuf {
codex_hosted_cli_profile_root().join(sanitize_path_segment(session_id))
}

/// Account-scoped Kiro CLI profile root: `~/.orgii/kiro-cli-profiles/`.
pub fn kiro_cli_profile_root() -> PathBuf {
orgii_root().join("kiro-cli-profiles")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,20 +125,11 @@ pub(crate) fn cli_agent_registry() -> Vec<CliAgentEntry> {
brand_color: "#10A37F",
docs_url: "https://developers.openai.com/codex/config-basic",
has_subscription_plan: true,
compatible_api_providers: &[
"openai_api",
"atlascloud_api",
"openrouter_api",
"azure_openai_api",
"deepseek_api",
"groq_api",
"xai_api",
"dashscope_api",
"moonshot_api",
"zenmux_api",
"longcat_api",
"vllm_api",
],
// Codex requires the Responses wire API. Generic OpenAI Chat
// compatibility is insufficient; keep this list limited to
// providers whose `/responses` route is explicitly supported or
// has been verified by ORGII.
compatible_api_providers: &["openai_api", "zenmux_api"],
config_files: vec![home_config("config", "Config", ".codex/config.toml", CliConfigFormat::Toml, false)],
is_complex_setup: false,
default_setup_method: None,
Expand Down
5 changes: 4 additions & 1 deletion src-tauri/crates/key-vault/src/commands/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,15 @@ mod compatibility_tests {

#[test]
fn compatibility_comes_from_the_central_cli_registry() {
assert!(is_cli_provider_compatible("codex", "atlascloud_api"));
assert!(is_cli_provider_compatible("codex", "zenmux_api"));
assert!(is_cli_provider_compatible("opencode", "atlascloud_api"));
assert!(is_cli_provider_compatible("claude_code", "atlascloud_api"));
assert!(is_cli_provider_compatible("codex", "openai_api"));
assert!(is_cli_provider_compatible("claude_code", "anthropic_api"));
assert!(!is_cli_provider_compatible("unknown", "openai_api"));
assert!(!is_cli_provider_compatible("codex", "deepseek_api"));
assert!(!is_cli_provider_compatible("codex", "atlascloud_api"));
assert!(!is_cli_provider_compatible("codex", "zhipu_api"));
assert_eq!(cli_agent_display_name("opencode"), Some("OpenCode"));
assert_eq!(cli_agent_display_name("unknown"), None);
}
Expand Down
7 changes: 5 additions & 2 deletions src-tauri/crates/key-vault/src/commands/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1191,7 +1191,7 @@ async fn refresh_oauth_key_for_quota(
key: &crate::key_store::ModelKey,
) -> Result<crate::key_store::ModelKey, String> {
let rejected_access_token = key.session_token.clone().unwrap_or_default();
match key.model_type {
let outcome = match key.model_type {
ModelType::ClaudeCode => {
KEY_SERVICE
.refresh_claude_code_oauth_key(&key.id, &rejected_access_token)
Expand All @@ -1206,7 +1206,10 @@ async fn refresh_oauth_key_for_quota(
"OAuth quota refresh is not supported for {}",
other.as_str()
)),
}
}?;
outcome
.into_key()
.ok_or_else(|| format!("Key {} is not a native OAuth account", key.id))
}

fn is_unauthorized_quota_error(error_message: &str) -> bool {
Expand Down
5 changes: 3 additions & 2 deletions src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,8 +483,9 @@ impl KeyService {
env.insert("OPENAI_API_KEY".to_string(), proxy_token.to_string());
env.insert("PROXY_TOKEN".to_string(), proxy_token.to_string());
// Note: OPENAI_BASE_URL is NOT set for proxy mode.
// The base URL is configured in ~/.codex/config.toml under
// [model_providers.proxy], and selected via `-c model_provider="proxy"`.
// The base URL is configured in the hosted session's isolated
// CODEX_HOME under [model_providers.proxy], and selected via
// `-c model_provider="proxy"`.
// This matches the market-worker's approach.
}
ModelType::Copilot => {
Expand Down
Loading
Loading