diff --git a/package.json b/package.json index 27d8d65..0ef85ce 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loom-router", "private": true, - "version": "0.2.10", + "version": "0.2.11", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 33c9f0d..1d16014 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2280,7 +2280,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loom-router" -version = "0.2.10" +version = "0.2.11" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 275fb53..93af7aa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ name = "loom-router" # Kept in lockstep with package.json and tauri.conf.json; the release # workflow verifies all three against the tag. -version = "0.2.10" +version = "0.2.11" description = "Weave any model into your coding agent's picker." authors = ["LoomRouter contributors"] license = "MIT" diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index cae8366..b17ad07 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -347,7 +347,7 @@ fn configure_print_command(cmd: &mut std::process::Command, model: &str) { // Print mode has no interactive permission prompt. Accept workspace // edits, while commands and network access remain explicitly allowlisted. .arg("--permission-mode") - .arg("acceptEdits") + .arg(claude_permission_mode()) .arg("--no-session-persistence") .arg("--prompt-suggestions") .arg("false") @@ -356,6 +356,14 @@ fn configure_print_command(cmd: &mut std::process::Command, model: &str) { .env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"); } +fn claude_permission_mode() -> String { + std::env::var("LOOM_CLAUDE_PERMISSION_MODE") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "acceptEdits".to_string()) +} + fn configure_child_environment(cmd: &mut std::process::Command, bin: &std::path::Path) { if let Some(path) = crate::cli_locator::child_path(bin) { cmd.env("PATH", path); @@ -417,6 +425,7 @@ pub fn stream_print_turn( let mut cmd = std::process::Command::new(&bin); crate::cli_locator::hide_console_window(&mut cmd); + crate::cli_locator::scrub_child_env_std(&mut cmd); configure_child_environment(&mut cmd, &bin); configure_claude_project(&mut cmd)?; configure_print_command(&mut cmd, &model); @@ -733,6 +742,7 @@ pub async fn run_print_turn( tokio::task::spawn_blocking(move || { let mut cmd = std::process::Command::new(&bin); crate::cli_locator::hide_console_window(&mut cmd); + crate::cli_locator::scrub_child_env_std(&mut cmd); configure_child_environment(&mut cmd, &bin); configure_claude_project(&mut cmd)?; configure_print_command(&mut cmd, &model); @@ -790,6 +800,7 @@ pub async fn run_print_turn_stream_json( tokio::task::spawn_blocking(move || { let mut cmd = std::process::Command::new(&bin); crate::cli_locator::hide_console_window(&mut cmd); + crate::cli_locator::scrub_child_env_std(&mut cmd); configure_child_environment(&mut cmd, &bin); configure_claude_project(&mut cmd)?; configure_print_command(&mut cmd, &model); @@ -1214,6 +1225,7 @@ pub async fn auth_status() -> ClaudeAuthStatus { }; }; let mut command = tokio::process::Command::new(bin); + crate::cli_locator::scrub_child_env_tokio(&mut command); command .args(["auth", "status"]) .env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") @@ -1418,6 +1430,27 @@ mod tests { std::fs::remove_file(path).unwrap(); } + #[test] + fn trust_claude_project_marks_only_this_project() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("repo"); + let config = dir.path().join(".claude.json"); + std::fs::create_dir_all(&project).unwrap(); + + trust_claude_project_in(&config, &project).unwrap(); + + let parsed: Value = serde_json::from_slice(&std::fs::read(&config).unwrap()).unwrap(); + let key = project + .canonicalize() + .unwrap() + .to_string_lossy() + .to_string(); + assert_eq!(parsed["projects"][key]["hasTrustDialogAccepted"], true); + assert!(!serde_json::to_string(&parsed) + .unwrap() + .contains("access_token")); + } + #[test] fn trusting_project_preserves_existing_claude_config() { let dir = tempfile::tempdir().unwrap(); @@ -1524,6 +1557,27 @@ mod tests { .all(|(key, _)| key != std::ffi::OsStr::new("CLAUDE_CODE_SKIP_BACKGROUND_PREFETCH"))); } + #[test] + fn claude_permission_mode_can_be_overridden_for_proxy_turns() { + let saved = std::env::var("LOOM_CLAUDE_PERMISSION_MODE").ok(); + // SAFETY: single-threaded test, restored below. + unsafe { std::env::set_var("LOOM_CLAUDE_PERMISSION_MODE", "bypassPermissions") } + let mut command = std::process::Command::new("claude"); + configure_print_command(&mut command, "claude-opus-5"); + let args: Vec<_> = command.get_args().collect(); + unsafe { + match saved { + Some(value) => std::env::set_var("LOOM_CLAUDE_PERMISSION_MODE", value), + None => std::env::remove_var("LOOM_CLAUDE_PERMISSION_MODE"), + } + } + let position = args + .iter() + .position(|arg| *arg == "--permission-mode") + .unwrap(); + assert_eq!(args[position + 1], "bypassPermissions"); + } + #[test] fn messages_have_images_detects_structured_image_parts() { let messages = serde_json::json!([ diff --git a/src-tauri/src/cli_locator.rs b/src-tauri/src/cli_locator.rs index 3308c2c..8bb0566 100644 --- a/src-tauri/src/cli_locator.rs +++ b/src-tauri/src/cli_locator.rs @@ -156,6 +156,33 @@ fn join_unique_paths(paths: impl IntoIterator) -> Option bool { + let key = key.to_string_lossy().to_ascii_uppercase(); + ["KEY", "SECRET", "TOKEN", "PASSWORD", "PASSWD", "CREDENTIAL"] + .iter() + .any(|marker| key.contains(marker)) +} + +/// Remove credential-shaped variables from a child std process environment. +pub(crate) fn scrub_child_env_std(command: &mut std::process::Command) { + for (key, _) in std::env::vars_os() { + if is_sensitive_env_key(&key) { + command.env_remove(key); + } + } +} + +/// Tokio command counterpart to [`scrub_child_env_std`]. +pub(crate) fn scrub_child_env_tokio(command: &mut tokio::process::Command) { + for (key, _) in std::env::vars_os() { + if is_sensitive_env_key(&key) { + command.env_remove(key); + } + } +} + // Windows subprocesses need CREATE_NO_WINDOW to avoid flashing a console. #[cfg(windows)] pub(crate) fn hide_console_window(command: &mut std::process::Command) { diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index aad5287..71558f0 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -42,10 +42,12 @@ //! token they can only fail, and leaving them in the picker is noise. use crate::config::AppConfig; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde::Serialize; use serde_json::Value; use std::collections::HashSet; use std::path::{Path, PathBuf}; +use std::time::SystemTime; #[path = "codex/config_patch.rs"] mod config_patch; @@ -61,6 +63,7 @@ pub use subagents::serve_subagent_mcp; pub struct CodexStatus { pub codex_home: String, pub config_exists: bool, + pub config_parseable: bool, pub managed_block_present: bool, /// A `# BEGIN` marker without a matching `# END`: an external rewrite of /// config.toml (e.g. the Codex desktop app re-serializing the file) @@ -74,6 +77,19 @@ pub struct CodexStatus { pub codex_cli_available: bool, /// Whether auto-apply is on (user clicked Apply at least once). pub integration_enabled: bool, + /// Presence and expiry of the local Codex session, never its token. + pub session: CodexSessionStatus, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CodexSessionStatus { + pub path: String, + pub present: bool, + pub usable: bool, + pub has_account_id: bool, + pub expired: bool, + pub expires_in_hours: Option, + pub age_hours: Option, } pub fn codex_home() -> PathBuf { @@ -96,14 +112,17 @@ pub fn status(config: &AppConfig) -> CodexStatus { let home = codex_home(); let cfg_path = home.join("config.toml"); let raw = std::fs::read_to_string(&cfg_path).unwrap_or_default(); + let config_parseable = toml::from_str::(&raw).is_ok(); let count = std::fs::read_to_string(merged_catalog_path()) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .and_then(|c| c.get("models").and_then(Value::as_array).map(Vec::len)) .unwrap_or(0); + let session = codex_session_status(&home.join("auth.json")); CodexStatus { codex_home: home.display().to_string(), config_exists: cfg_path.exists(), + config_parseable, managed_block_present: raw.contains(BEGIN_MARK), managed_block_orphaned: raw.contains(BEGIN_MARK) && !raw.contains(END_MARK), native_catalog_present: native_catalog_path().exists(), @@ -111,9 +130,105 @@ pub fn status(config: &AppConfig) -> CodexStatus { merged_model_count: count, codex_cli_available: codex_bin().is_some(), integration_enabled: config.codex_integration, + session, + } +} + +pub fn codex_session_status(path: &Path) -> CodexSessionStatus { + let present = path.is_file(); + let age_hours = if present { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .map(|modified| { + let age = SystemTime::now() + .duration_since(modified) + .map(|duration| duration.as_secs_f64() / 3600.0) + .unwrap_or_default(); + round_hours(age) + }) + } else { + None + }; + let session = if present { + read_codex_auth_summary(path) + } else { + None + }; + + let has_account_id = session + .as_ref() + .is_some_and(|session| session.has_account_id); + let expires_in_hours = session.as_ref().and_then(|session| { + session + .expires_at_ms + .map(|expires_at_ms| round_hours((expires_at_ms - now_ms()) as f64 / 3_600_000.0)) + }); + let expired = session + .as_ref() + .and_then(|session| session.expires_at_ms) + .is_some_and(|expires_at_ms| expires_at_ms - EXPIRY_SKEW_MS <= now_ms()); + let usable = session + .as_ref() + .is_some_and(|session| session.access_token_present) + && !expired; + + CodexSessionStatus { + path: path.display().to_string(), + present, + usable, + has_account_id, + expired, + expires_in_hours, + age_hours, } } +const EXPIRY_SKEW_MS: i64 = 120_000; + +struct CodexAuthSummary { + access_token_present: bool, + has_account_id: bool, + expires_at_ms: Option, +} + +fn read_codex_auth_summary(path: &Path) -> Option { + let parsed: Value = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?; + let tokens = parsed.get("tokens")?; + let access_token = tokens + .get("access_token") + .and_then(Value::as_str) + .unwrap_or(""); + let account_id = tokens + .get("account_id") + .and_then(Value::as_str) + .unwrap_or(""); + Some(CodexAuthSummary { + access_token_present: !access_token.is_empty(), + has_account_id: !account_id.is_empty(), + expires_at_ms: token_expiry_ms(access_token), + }) +} + +fn token_expiry_ms(access_token: &str) -> Option { + let payload = access_token.split('.').nth(1)?; + let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?; + let claims: Value = serde_json::from_slice(&bytes).ok()?; + let exp = claims.get("exp")?.as_i64()?; + exp.checked_mul(1_000) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + +fn round_hours(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} + /// Locate the Codex CLI, cached for the process. /// /// A macOS app launched from Finder does not inherit the shell's PATH - it @@ -242,6 +357,7 @@ fn install_codex_cli() -> anyhow::Result<()> { command }; crate::cli_locator::hide_console_window(&mut command); + crate::cli_locator::scrub_child_env_std(&mut command); let status = command.status()?; if !status.success() { diff --git a/src-tauri/src/codex/catalog.rs b/src-tauri/src/codex/catalog.rs index 99ed904..0f1e7cc 100644 --- a/src-tauri/src/codex/catalog.rs +++ b/src-tauri/src/codex/catalog.rs @@ -29,6 +29,7 @@ pub fn capture_native_catalog( let run = |extra: &str| -> anyhow::Result { let mut command = std::process::Command::new(&bin); crate::cli_locator::hide_console_window(&mut command); + crate::cli_locator::scrub_child_env_std(&mut command); let out = command .args(["debug", "models"]) .args(if extra.is_empty() { diff --git a/src-tauri/src/codex/config_patch.rs b/src-tauri/src/codex/config_patch.rs index 3b6eeb1..0a4b4ed 100644 --- a/src-tauri/src/codex/config_patch.rs +++ b/src-tauri/src/codex/config_patch.rs @@ -3,11 +3,117 @@ use super::{ loom_dir, merged_catalog_path, }; use crate::config::AppConfig; +use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::BTreeMap; +use std::path::PathBuf; pub const BEGIN_MARK: &str = "# BEGIN loom-router-managed"; pub const END_MARK: &str = "# END loom-router-managed"; +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexPatchState { + version: u32, + ownership_id: String, + previous_root_values: BTreeMap>, + previous_provider_sections: Vec, +} + +fn patch_state_path() -> PathBuf { + loom_dir().join("codex-config-patch.json") +} + +fn read_patch_state() -> Option { + let raw = std::fs::read_to_string(patch_state_path()).ok()?; + let state: CodexPatchState = serde_json::from_str(&raw).ok()?; + (state.version == 1).then_some(state) +} + +fn write_patch_state(state: &CodexPatchState) -> anyhow::Result<()> { + std::fs::create_dir_all(loom_dir())?; + crate::secure_fs::write_private( + &patch_state_path(), + serde_json::to_vec_pretty(state)?.as_slice(), + )?; + Ok(()) +} + +fn remove_patch_state() { + let _ = std::fs::remove_file(patch_state_path()); +} + +fn ensure_patch_state(stripped: &str) -> anyhow::Result<()> { + if read_patch_state().is_some() { + return Ok(()); + } + let previous_root_values = [ + "model", + "model_provider", + "openai_base_url", + "model_catalog_json", + "model_reasoning_effort", + ] + .into_iter() + .map(|key| (key.to_string(), root_value(stripped, key))) + .collect(); + let state = CodexPatchState { + version: 1, + ownership_id: uuid::Uuid::new_v4().simple().to_string(), + previous_root_values, + previous_provider_sections: loomrouter_sections(stripped), + }; + write_patch_state(&state) +} + +fn loomrouter_sections(contents: &str) -> Vec { + let lines: Vec<&str> = contents.lines().collect(); + let mut sections = Vec::new(); + let mut current: Vec<&str> = Vec::new(); + let mut current_is_loomrouter = false; + for line in lines { + let trimmed = line.trim_start(); + if trimmed.starts_with('[') && !trimmed.starts_with('#') { + if current_is_loomrouter && !current.is_empty() { + sections.push(current.join("\n")); + } + current.clear(); + current_is_loomrouter = is_loomrouter_table(line); + } + current.push(line); + } + if current_is_loomrouter && !current.is_empty() { + sections.push(current.join("\n")); + } + sections +} + +fn root_value(contents: &str, key: &str) -> Option { + let first_table = contents + .lines() + .position(|l| { + let t = l.trim_start(); + t.starts_with('[') && !t.starts_with('#') + }) + .unwrap_or(usize::MAX); + contents + .lines() + .take(first_table) + .filter(|l| is_root_assignment(l, key)) + .find_map(|l| assignment_value(l).map(|value| value.to_string())) +} + +fn assignment_value(line: &str) -> Option<&str> { + let value = line.split_once('=')?.1.trim(); + let value = value.split('#').next().unwrap_or(value).trim(); + Some( + value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))) + .unwrap_or(value), + ) +} + /// Apply the integration: refresh native catalog (best effort), write the /// merged catalog, and install the managed config block. pub fn apply(config: &AppConfig, port: u16) -> anyhow::Result<()> { @@ -71,6 +177,7 @@ pub fn apply(config: &AppConfig, port: u16) -> anyhow::Result<()> { // Pre-marker installs left the provider block unmarked; re-applying on // top of one duplicates the owned root keys, so migrate it away first. let stripped = strip_legacy_install(&stripped); + ensure_patch_state(&stripped)?; // The active model is a plain root key, so it has to be reconciled on // the *stripped* text: writing it inside the managed block would collide // with a `model` the user already has at the root, and a duplicated key @@ -378,14 +485,16 @@ pub fn remove(config: Option<&AppConfig>) -> anyhow::Result<()> { let stripped = strip_managed_block(&raw)?; // A legacy unmarked install is ours too: it leaves with us. let stripped = strip_legacy_install(&stripped); - let restored = match (config, root_model_key(&stripped)) { + let restored = restore_patch_state(&stripped); + let restored = match (config, root_model_key(&restored)) { (Some(cfg), Some(current)) if owns_slug(cfg, ¤t) => { - set_root_model_key(&stripped, cfg.codex_model_backup.as_deref()) + set_root_model_key(&restored, cfg.codex_model_backup.as_deref()) } - _ => stripped, + _ => restored, }; write_config_atomic(&cfg_path, &restored)?; } + remove_patch_state(); for path in [merged_catalog_path()] { if path.exists() { std::fs::remove_file(path)?; @@ -394,6 +503,56 @@ pub fn remove(config: Option<&AppConfig>) -> anyhow::Result<()> { Ok(()) } +fn set_root_value(stripped: &str, key: &str, value: Option<&str>) -> String { + let nl = detect_newline(stripped); + let mut lines: Vec = stripped.lines().map(str::to_string).collect(); + let first_table = lines + .iter() + .position(|l| { + let t = l.trim_start(); + t.starts_with('[') && !t.starts_with('#') + }) + .unwrap_or(lines.len()); + let existing = lines[..first_table] + .iter() + .position(|l| is_root_assignment(l, key)); + match (value, existing) { + (Some(value), Some(idx)) => lines[idx] = format!("{key} = \"{}\"", escape_toml(value)), + (Some(value), None) => lines.insert(0, format!("{key} = \"{}\"", escape_toml(value))), + (None, Some(idx)) => { + lines.remove(idx); + } + (None, None) => {} + } + let mut out = lines.join(nl); + if !out.is_empty() { + out.push_str(nl); + } + out +} + +fn restore_patch_state(stripped: &str) -> String { + let Some(state) = read_patch_state() else { + return stripped.to_string(); + }; + let mut restored = stripped.to_string(); + for (key, value) in state.previous_root_values { + if root_value(&restored, &key).is_none() { + restored = set_root_value(&restored, &key, value.as_deref()); + } + } + for section in state.previous_provider_sections { + if !restored.contains(section.as_str()) { + if !restored.ends_with('\n') { + restored.push('\n'); + } + restored.push_str(section.trim_end()); + restored.push('\n'); + } + } + restored +} + // --------------------------------------------------------------------------- // Multi-agent feature flag ([features] multi_agent in config.toml) // @@ -520,23 +679,29 @@ fn strip_managed_block(raw: &str) -> anyhow::Result { let nl = detect_newline(raw); let mut out = String::new(); let mut inside = false; + let mut inside_lines: Vec<&str> = Vec::new(); let mut saw_begin = false; let mut saw_end = false; for line in raw.lines() { let trimmed = line.trim(); if trimmed == BEGIN_MARK { inside = true; + inside_lines.clear(); saw_begin = true; continue; } if trimmed == END_MARK { inside = false; saw_end = true; + out.push_str(&hoist_foreign_tables(&inside_lines, nl)); + inside_lines.clear(); continue; } if !inside { out.push_str(line); out.push_str(nl); + } else { + inside_lines.push(line); } } if saw_begin && !saw_end { @@ -545,6 +710,36 @@ fn strip_managed_block(raw: &str) -> anyhow::Result { Ok(out) } +/// Keep foreign tables the Codex desktop app may have re-emitted inside our +/// managed block. Only loomrouter-owned tables are dropped from the block; +/// everything else is hoisted out before the block is removed. +fn hoist_foreign_tables(lines: &[&str], nl: &str) -> String { + let mut hoisted = String::new(); + let mut current: Vec<&str> = Vec::new(); + let mut flush = |current: &mut Vec<&str>| { + if let Some(header) = current.first().copied() { + let trimmed = header.trim_start(); + let is_table = trimmed.starts_with('[') && !trimmed.starts_with('#'); + if is_table && !is_loomrouter_table(header) { + for line in current.iter() { + hoisted.push_str(line); + hoisted.push_str(nl); + } + } + } + current.clear(); + }; + for line in lines { + let trimmed = line.trim_start(); + if trimmed.starts_with('[') && !trimmed.starts_with('#') { + flush(&mut current); + } + current.push(line); + } + flush(&mut current); + hoisted +} + /// Whether a `config.toml` line is the `[model_providers.loomrouter]` table /// header (or one of its sub-tables). fn is_loomrouter_table(line: &str) -> bool { diff --git a/src-tauri/src/codex/config_patch/tests.rs b/src-tauri/src/codex/config_patch/tests.rs index 51c61d0..194324e 100644 --- a/src-tauri/src/codex/config_patch/tests.rs +++ b/src-tauri/src/codex/config_patch/tests.rs @@ -52,6 +52,18 @@ fn strip_only_managed_block() { assert!(!out.contains("openai_base_url")); } +#[test] +fn strip_hoists_foreign_tables_inside_managed_block() { + let raw = "model = \"gpt-5\"\n# BEGIN loom-router-managed\nmodel_provider = \"loomrouter\"\nopenai_base_url = \"x\"\n\n[model_providers.loomrouter]\nwire_api = \"responses\"\n\n[marketplaces.openai-bundled]\nlast_updated = \"2026-08-06T13:58:21Z\"\n\n[mcp_servers.loomrouter_subagents]\ncommand = \"x\"\n# END loom-router-managed\n[profiles.work]\n"; + let out = strip_managed_block(raw).unwrap(); + assert!(!out.contains("loomrouter")); + assert!(!out.contains("wire_api")); + assert!(out.contains("[marketplaces.openai-bundled]")); + assert!(out.contains("last_updated = \"2026-08-06T13:58:21Z\"")); + assert!(out.contains("[profiles.work]")); + toml::from_str::(&out).unwrap(); +} + #[test] fn strip_refuses_begin_without_end() { // An orphan BEGIN with *no* loom-router content is genuinely @@ -333,6 +345,25 @@ fn apply_refuses_empty_catalog_and_rolls_back() { assert!(written.contains("model = \"gpt-5.5\"")); } +#[test] +fn patch_state_restores_previous_root_values_after_remove() { + let _guard = codex_home_guard(); + let tmp = std::env::temp_dir().join(format!("loom-codex-state-{}", std::process::id())); + std::env::set_var("CODEX_HOME", &tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + let previous = "openai_base_url = \"https://example.test/v1\"\nmodel_provider = \"openai\"\n"; + ensure_patch_state(previous).unwrap(); + let restored = restore_patch_state(""); + + std::env::remove_var("CODEX_HOME"); + let _ = std::fs::remove_dir_all(&tmp); + + assert!(restored.contains("openai_base_url = \"https://example.test/v1\"")); + assert!(restored.contains("model_provider = \"openai\"")); + toml::from_str::(&restored).unwrap(); +} + #[test] fn root_block_goes_before_first_table() { let raw = "model = \"gpt-5.5\"\n\n[plugins.\"a@b\"]\nenabled = true\n\n[[hooks.SessionStart]]\nmatcher = \"startup\"\n"; diff --git a/src-tauri/src/codex/subagents.rs b/src-tauri/src/codex/subagents.rs index 3a92d57..ef917d0 100644 --- a/src-tauri/src/codex/subagents.rs +++ b/src-tauri/src/codex/subagents.rs @@ -260,6 +260,7 @@ async fn run_task(task: SpawnTask, cwd: PathBuf, depth: u8, parent_sandbox: &str }; }; let mut command = tokio::process::Command::new(binary); + crate::cli_locator::scrub_child_env_tokio(&mut command); command.args(codex_task_args(&model, &sandbox, &cwd, &task.prompt)); command .stdin(Stdio::null()) @@ -393,6 +394,36 @@ mod tests { config } + fn config_with_claude_code_model() -> AppConfig { + let mut config = AppConfig::default(); + config.providers.insert( + "claude-code".into(), + Provider { + id: "claude-code".into(), + name: "Claude Code".into(), + protocol: ProviderProtocol::Anthropic, + base_url: "local".into(), + api_key: None, + keys: Vec::new(), + rotation_enabled: false, + has_key: true, + context_window: None, + user_agent: None, + models: vec![ProviderModel { + id: "claude-opus-5".into(), + label: None, + context_window: Some(1_000_000), + protocol: None, + fast_mode: true, + enabled: true, + supports_vision: true, + }], + enabled: true, + }, + ); + config + } + fn task(model: &str, sandbox: Option<&str>) -> SpawnTask { SpawnTask { name: "worker".into(), @@ -589,6 +620,28 @@ mod tests { .is_err()); } + #[test] + fn spawn_request_accepts_claude_code_model_and_builds_claude_worker_args() { + let config = config_with_claude_code_model(); + assert!(validate_spawn_request( + &config, + &[task("claude-code/claude-opus-5", None)], + 0, + "workspace-write", + ) + .is_ok()); + + let args = codex_task_args( + "claude-code/claude-opus-5", + "read-only", + std::path::Path::new("/tmp/work"), + "Review", + ); + assert_eq!(args[5], "claude-code/claude-opus-5"); + assert_eq!(args[7], "/tmp/work"); + assert_eq!(&args[args.len() - 1], "Review"); + } + #[test] fn enabled_model_list_tracks_provider_and_native_slug_mode() { let mut config = config_with_models(); diff --git a/src-tauri/src/codex/tests.rs b/src-tauri/src/codex/tests.rs index 5be18be..3171ed1 100644 --- a/src-tauri/src/codex/tests.rs +++ b/src-tauri/src/codex/tests.rs @@ -1,4 +1,6 @@ use super::*; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde_json::json; #[test] fn status_flags_orphaned_managed_block() { let _guard = codex_home_guard(); @@ -35,3 +37,58 @@ fn status_does_not_flag_complete_managed_block() { std::env::remove_var("CODEX_HOME"); let _ = std::fs::remove_dir_all(&tmp); } + +#[test] +fn status_reads_session_expiry_without_exposing_tokens() { + let _guard = codex_home_guard(); + let tmp = std::env::temp_dir().join(format!("loom-codex-session-{}", std::process::id())); + std::env::set_var("CODEX_HOME", &tmp); + std::fs::create_dir_all(&tmp).unwrap(); + + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#); + let exp = now_ms() / 1_000 + 3_600; + let payload = URL_SAFE_NO_PAD.encode(json!({"exp": exp}).to_string().as_bytes()); + let token = format!("{header}.{payload}.sig"); + std::fs::write( + tmp.join("auth.json"), + json!({"tokens": {"access_token": token, "account_id": "acct_123"}}).to_string(), + ) + .unwrap(); + + let status = codex_session_status(&tmp.join("auth.json")); + + std::env::remove_var("CODEX_HOME"); + let _ = std::fs::remove_dir_all(&tmp); + + assert!(status.present); + assert!(status.usable); + assert!(status.has_account_id); + assert!(!status.expired); + assert!(status.expires_in_hours.is_some()); + assert!(!status.path.contains(token.as_str())); +} + +#[test] +fn status_reports_expired_session_as_unusable() { + let _guard = codex_home_guard(); + let tmp = std::env::temp_dir().join(format!("loom-codex-expired-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#); + let exp = now_ms() / 1_000 - 3_600; + let payload = URL_SAFE_NO_PAD.encode(json!({"exp": exp}).to_string().as_bytes()); + let token = format!("{header}.{payload}.sig"); + std::fs::write( + tmp.join("auth.json"), + json!({"tokens": {"access_token": token, "account_id": "acct_123"}}).to_string(), + ) + .unwrap(); + + let status = codex_session_status(&tmp.join("auth.json")); + + let _ = std::fs::remove_dir_all(&tmp); + + assert!(status.present); + assert!(!status.usable); + assert!(status.expired); +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 026b353..92b4078 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -23,6 +23,11 @@ pub const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 128 * 1024 * 1024; /// into an unbounded in-memory buffer. pub const MAX_REQUEST_BODY_BYTES_HARD_LIMIT: usize = 1024 * 1024 * 1024; +/// Version of the persisted `config.json` schema. Bump this when a breaking +/// shape change ships, and add a migration below instead of silently +/// reinterpreting fields written by an older build. +pub const CURRENT_SCHEMA_VERSION: u32 = 1; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] #[derive(Default)] @@ -37,6 +42,20 @@ pub enum ProviderProtocol { Responses, } +/// Coarse provider family for routing quirks that depend on the gateway's +/// identity, not its wire protocol. Built-in presets carry this explicitly; +/// custom providers fall back to URL-derived detection. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum ProviderFamily { + Anthropic, + OpenRouter, + Kimi, + DeepSeek, + #[default] + OpenAi, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Provider { /// Stable slug, e.g. "deepseek", "openrouter". @@ -194,6 +213,10 @@ pub struct VisualAssistanceConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { + /// Version of the schema that wrote this file. Absent on legacy files and + /// backfilled to the current version by [`AppConfig::load`]. + #[serde(default = "default_schema_version")] + pub schema_version: u32, /// Proxy listen port on 127.0.0.1. #[serde(default = "default_port")] pub port: u16, @@ -265,6 +288,10 @@ fn default_port() -> u16 { 4180 } +fn default_schema_version() -> u32 { + CURRENT_SCHEMA_VERSION +} + fn default_max_request_body_bytes() -> usize { DEFAULT_MAX_REQUEST_BODY_BYTES } @@ -272,6 +299,7 @@ fn default_max_request_body_bytes() -> usize { impl Default for AppConfig { fn default() -> Self { Self { + schema_version: default_schema_version(), port: default_port(), max_request_body_bytes: default_max_request_body_bytes(), providers: BTreeMap::new(), @@ -313,6 +341,12 @@ impl AppConfig { tracing::warn!("invalid config at {}: {e}; starting fresh", path.display()); Self::default() }); + if cfg.schema_version == 0 { + cfg.schema_version = default_schema_version(); + } + if let Err(error) = cfg.validate() { + tracing::warn!("config at {} failed validation: {error}", path.display()); + } // A config written before the walkthrough existed has no // answer recorded. Its owner is plainly not a first-run // user, so mark it done rather than interrupting them. @@ -333,6 +367,39 @@ impl AppConfig { } } + /// Structural validation for the persisted config. This is intentionally + /// narrow: it rejects shapes that cannot be repaired by the migrations + /// below, but does not enforce provider/model business rules that depend + /// on live catalogs. + pub fn validate(&self) -> Result<(), String> { + if self.schema_version > CURRENT_SCHEMA_VERSION { + return Err(format!( + "config schema version {} is newer than this build supports ({CURRENT_SCHEMA_VERSION})", + self.schema_version + )); + } + if self.port == 0 { + return Err("port must be non-zero".to_string()); + } + for (provider_id, provider) in &self.providers { + if provider_id.trim().is_empty() { + return Err("provider id must not be empty".to_string()); + } + if provider_id.contains('/') { + return Err(format!("provider id '{provider_id}' must not contain '/'")); + } + if provider.name.trim().is_empty() { + return Err(format!("provider '{provider_id}' has an empty name")); + } + for model in &provider.models { + if model.id.trim().is_empty() { + return Err(format!("provider '{provider_id}' has an empty model id")); + } + } + } + Ok(()) + } + /// Older configs saved the claude-code preset with "(subscription)" in /// the display name. Normalize it on read so the UI never shows the /// stale label again. @@ -1149,4 +1216,26 @@ mod tests { assert_eq!(provider.keys[0].api_key.as_deref(), Some("secret")); assert!(config.migrated); } + + #[test] + fn validate_rejects_a_future_schema_version() { + let config = AppConfig { + schema_version: CURRENT_SCHEMA_VERSION + 1, + ..AppConfig::default() + }; + + let error = config.validate().unwrap_err(); + assert!(error.contains("newer than this build")); + } + + #[test] + fn validate_rejects_provider_ids_with_slashes() { + let mut config = AppConfig::default(); + config + .providers + .insert("bad/id".into(), legacy_provider(None)); + + let error = config.validate().unwrap_err(); + assert!(error.contains("must not contain '/'")); + } } diff --git a/src-tauri/src/providers.rs b/src-tauri/src/providers.rs index ade0615..d2b4087 100644 --- a/src-tauri/src/providers.rs +++ b/src-tauri/src/providers.rs @@ -10,7 +10,7 @@ // `family_of` / `apply_provider_auth`) can refer to // `crate::providers::Provider`. pub use crate::config::Provider; -use crate::config::ProviderProtocol; +use crate::config::{ProviderFamily, ProviderProtocol}; pub struct Preset { pub id: &'static str, @@ -18,6 +18,10 @@ pub struct Preset { /// Dialect the endpoint speaks, and the default for any model that does /// not name its own - including everything discovery turns up. pub protocol: ProviderProtocol, + /// Gateway identity used for routing quirks that do not map cleanly to + /// a wire protocol. Unlike `family_of`'s URL fallback, this is explicit + /// preset metadata and cannot be spoofed by a custom endpoint URL. + pub family: ProviderFamily, pub base_url: &'static str, /// Models seeded on add (official IDs, or for endpoints where /// discovery is unreliable). @@ -49,11 +53,12 @@ const fn md(id: &'static str, protocol: ProviderProtocol) -> PresetModel { } macro_rules! preset { - ($id:literal, $name:literal, $proto:expr, $url:literal) => { + ($id:literal, $name:literal, $proto:expr, $family:expr, $url:literal) => { Preset { id: $id, name: $name, protocol: $proto, + family: $family, base_url: $url, default_models: &[], user_agent: None, @@ -89,6 +94,7 @@ pub const PRESETS: &[Preset] = &[ id: "claude-code", name: "Claude Code", protocol: ProviderProtocol::Anthropic, + family: ProviderFamily::Anthropic, // No remote endpoint: requests are served by the local `claude` CLI // on behalf of the user's own subscription. Discovery (state.rs) // short-circuits this value and returns the curated catalog. @@ -106,6 +112,7 @@ pub const PRESETS: &[Preset] = &[ id: "kimi-coding", name: "Kimi Code - Coding Plan", protocol: ProviderProtocol::OpenAI, + family: ProviderFamily::Kimi, base_url: "https://api.kimi.com/coding/v1", // Official model IDs from the Kimi Code docs; tier-gated upstream. default_models: &[ @@ -122,60 +129,70 @@ pub const PRESETS: &[Preset] = &[ "moonshot-global", "Kimi API (Global)", ProviderProtocol::OpenAI, + ProviderFamily::Kimi, "https://api.moonshot.ai/v1" ), preset!( "moonshot-cn", "Kimi API (China)", ProviderProtocol::OpenAI, + ProviderFamily::Kimi, "https://api.moonshot.cn/v1" ), preset!( "deepseek", "DeepSeek", ProviderProtocol::OpenAI, + ProviderFamily::DeepSeek, "https://api.deepseek.com/v1" ), preset!( "openrouter", "OpenRouter", ProviderProtocol::OpenAI, + ProviderFamily::OpenRouter, "https://openrouter.ai/api/v1" ), preset!( "groq", "Groq", ProviderProtocol::OpenAI, + ProviderFamily::OpenAi, "https://api.groq.com/openai/v1" ), preset!( "together", "Together AI", ProviderProtocol::OpenAI, + ProviderFamily::OpenAi, "https://api.together.xyz/v1" ), preset!( "mistral", "Mistral AI", ProviderProtocol::OpenAI, + ProviderFamily::OpenAi, "https://api.mistral.ai/v1" ), preset!( "siliconflow", "SiliconFlow", ProviderProtocol::OpenAI, + ProviderFamily::OpenAi, "https://api.siliconflow.cn/v1" ), preset!( "zai-coding", "Z.ai GLM Coding Plan", ProviderProtocol::OpenAI, + ProviderFamily::OpenAi, "https://api.z.ai/api/coding/paas/v4" ), preset!( "anthropic", "Anthropic", ProviderProtocol::Anthropic, + ProviderFamily::Anthropic, "https://api.anthropic.com/v1" ), // OpenCode Zen/Go: one gateway per subscription, three dialects behind @@ -187,6 +204,7 @@ pub const PRESETS: &[Preset] = &[ id: "opencode-zen", name: "OpenCode Zen", protocol: ProviderProtocol::OpenAI, + family: ProviderFamily::OpenAi, base_url: "https://opencode.ai/zen/v1", default_models: &[ md("kimi-k3", ProviderProtocol::OpenAI), @@ -218,6 +236,7 @@ pub const PRESETS: &[Preset] = &[ id: "opencode-go", name: "OpenCode Go", protocol: ProviderProtocol::OpenAI, + family: ProviderFamily::OpenAi, base_url: "https://opencode.ai/zen/go/v1", default_models: &[ md("kimi-k3", ProviderProtocol::OpenAI), @@ -239,6 +258,31 @@ pub const PRESETS: &[Preset] = &[ }, ]; +/// Resolve a provider's family from its built-in preset when available, and +/// only fall back to URL detection for user-defined custom endpoints. +pub fn family_for(provider: &Provider) -> ProviderFamily { + PRESETS + .iter() + .find(|preset| preset.id == provider.id) + .map(|preset| preset.family) + .unwrap_or_else(|| family_from_url(&provider.base_url)) +} + +fn family_from_url(url: &str) -> ProviderFamily { + let url = url.to_ascii_lowercase(); + if url.contains("anthropic") { + ProviderFamily::Anthropic + } else if url.contains("openrouter") { + ProviderFamily::OpenRouter + } else if url.contains("kimi") || url.contains("moonshot") { + ProviderFamily::Kimi + } else if url.contains("deepseek") { + ProviderFamily::DeepSeek + } else { + ProviderFamily::OpenAi + } +} + impl Provider { pub fn from_preset(preset: &Preset) -> Self { Self { diff --git a/src-tauri/src/proxy.rs b/src-tauri/src/proxy.rs index a86495d..41db6ba 100644 --- a/src-tauri/src/proxy.rs +++ b/src-tauri/src/proxy.rs @@ -71,7 +71,7 @@ use routing::{resolve, resolve_effective, RoutePlan}; pub use upstream::apply_provider_auth; #[cfg(test)] use upstream::classify_status; -use upstream::{build_upstream, needs_responses_function_tool_compat, send}; +use upstream::{build_upstream, needs_responses_function_tool_compat, send, send_outcome}; type EffectiveRoute = RoutePlan; @@ -86,6 +86,12 @@ const MAX_REQUEST_BODY_BYTES_ENV: &str = "LOOM_ROUTER_MAX_REQUEST_BODY_BYTES"; /// previous 16 MiB bound instead of inheriting the larger compaction limit. const CHAT_COMPLETIONS_MAX_REQUEST_BODY_BYTES: usize = 16 * 1024 * 1024; +/// Body ceiling for native image generation/edit routes. +/// +/// Codex image routes can carry JSON with base64 image payloads, so they need +/// more room than ordinary chat requests but far less than Responses. +const NATIVE_IMAGE_MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024; + /// Resolve the per-route body limit for Codex Responses and compaction. /// /// A fixed 16 MiB limit turned out to be too small for real automatic @@ -197,6 +203,26 @@ pub fn router_with_pools(config: SharedConfig, stats: SharedStats, key_pools: Ke CHAT_COMPLETIONS_MAX_REQUEST_BODY_BYTES, )), ) + .route( + "/images/generations", + post(handle_native_image) + .layer(DefaultBodyLimit::max(NATIVE_IMAGE_MAX_REQUEST_BODY_BYTES)), + ) + .route( + "/images/edits", + post(handle_native_image) + .layer(DefaultBodyLimit::max(NATIVE_IMAGE_MAX_REQUEST_BODY_BYTES)), + ) + .route( + "/v1/images/generations", + post(handle_native_image) + .layer(DefaultBodyLimit::max(NATIVE_IMAGE_MAX_REQUEST_BODY_BYTES)), + ) + .route( + "/v1/images/edits", + post(handle_native_image) + .layer(DefaultBodyLimit::max(NATIVE_IMAGE_MAX_REQUEST_BODY_BYTES)), + ) .fallback(log_unmatched) .with_state(ctx) // The Codex App sends request bodies compressed (gzip/br/zstd). @@ -569,6 +595,64 @@ async fn handle_compact( .unwrap()) } +/// Forward Codex native image generation/edit requests to ChatGPT. +/// +/// The Codex desktop app sends image work through the local proxy using the +/// same caller auth as Responses traffic. These routes are not provider-routed +/// work; they belong to OpenAI's native image backend. +async fn handle_native_image( + AxState(ctx): AxState, + uri: axum::http::Uri, + headers: HeaderMap, + body: Bytes, +) -> Result { + let base = std::env::var("CODEX_NATIVE_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api/codex".to_string()); + let target = native_image_target(Some(uri.path()), &base) + .ok_or_else(|| (StatusCode::NOT_FOUND, "unknown image route".to_string()))?; + + let mut req = ctx.client.post(&target).body(body); + for name in NATIVE_FORWARD_HEADERS { + if let Some(value) = headers.get(*name) { + if let Ok(v) = value.to_str() { + req = req.header(*name, v); + } + } + } + if let Some(content_type) = headers.get("content-type") { + if let Ok(value) = content_type.to_str() { + req = req.header("content-type", value); + } + } + let upstream = req.send().await.map_err(|e| { + let message = upstream_unreachable_error(target.as_str(), &e, "ChatGPT/OpenAI").to_string(); + (StatusCode::BAD_GATEWAY, message) + })?; + let status = + StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + tracing::info!(%target, %status, "native image passthrough"); + Ok(Response::builder() + .status(status) + .body(Body::from_stream(upstream.bytes_stream())) + .unwrap()) +} + +fn native_image_target(path: Option<&str>, base: &str) -> Option { + let path = path?; + if ![ + "/images/generations", + "/images/edits", + "/v1/images/generations", + "/v1/images/edits", + ] + .contains(&path) + { + return None; + } + let without_v1 = path.strip_prefix("/v1").unwrap_or(path); + Some(format!("{}{}", base.trim_end_matches('/'), without_v1)) +} + #[derive(Clone, Copy, PartialEq)] enum WireApi { Responses, diff --git a/src-tauri/src/proxy/dispatch.rs b/src-tauri/src/proxy/dispatch.rs index 80a7511..26da3e8 100644 --- a/src-tauri/src/proxy/dispatch.rs +++ b/src-tauri/src/proxy/dispatch.rs @@ -168,7 +168,20 @@ pub(super) async fn dispatch_routed( let (path, body, upstream_kind) = build_upstream(provider, &prepared_payload, upstream_model, wire)?; - let (upstream, key_id) = send(ctx, provider, path, &body).await?; + let upstream_result = send_outcome(ctx, provider, path, &body).await?; + if let Some(network_error) = &upstream_result.outcome.network_error { + tracing::debug!( + provider = %provider.id, + %upstream_model, + status = ?upstream_result.outcome.status, + timed_out = upstream_result.outcome.timed_out, + "upstream network failure normalized: {network_error}" + ); + } + let Some(upstream) = upstream_result.response else { + return Err(anyhow!(upstream_result.error.unwrap_or_default())); + }; + let key_id = upstream_result.key_id; let turn = Turn::new(&provider.id, &stats_model, "http", Some(started)).with_key(key_id.as_deref()); let status = diff --git a/src-tauri/src/proxy/realtime.rs b/src-tauri/src/proxy/realtime.rs index 62c8351..c0dc3bd 100644 --- a/src-tauri/src/proxy/realtime.rs +++ b/src-tauri/src/proxy/realtime.rs @@ -387,7 +387,8 @@ async fn summarize_dropped_turns( WireApi::Responses, ) .ok()?; - let (resp, _key_id) = send(ctx, provider, path, &body).await.ok()?; + let result = send_outcome(ctx, provider, path, &body).await.ok()?; + let resp = result.response?; if !resp.status().is_success() { return None; } @@ -963,7 +964,11 @@ async fn ws_routed_events( upstream_kind, payload, ); - let (upstream, key_id) = send(ctx, provider, path, &body).await?; + let upstream_result = send_outcome(ctx, provider, path, &body).await?; + let Some(upstream) = upstream_result.response else { + bail!(upstream_result.error.unwrap_or_default()); + }; + let key_id = upstream_result.key_id; let status = upstream.status(); if !status.is_success() { log_rejected_upstream_request(provider, path, status, &body); diff --git a/src-tauri/src/proxy/routing.rs b/src-tauri/src/proxy/routing.rs index bd01baf..e396f9a 100644 --- a/src-tauri/src/proxy/routing.rs +++ b/src-tauri/src/proxy/routing.rs @@ -3,28 +3,10 @@ use anyhow::{anyhow, bail}; use axum::http::HeaderMap; use serde_json::Value; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ProviderFamily { - Anthropic, - OpenRouter, - Kimi, - DeepSeek, - OpenAi, -} +pub use crate::config::ProviderFamily; pub fn family_of(provider: &crate::providers::Provider) -> ProviderFamily { - let url = provider.base_url.to_ascii_lowercase(); - if url.contains("anthropic") { - ProviderFamily::Anthropic - } else if url.contains("openrouter") { - ProviderFamily::OpenRouter - } else if url.contains("kimi") || url.contains("moonshot") { - ProviderFamily::Kimi - } else if url.contains("deepseek") { - ProviderFamily::DeepSeek - } else { - ProviderFamily::OpenAi - } + crate::providers::family_for(provider) } /// A provider protocol is a default: a discovered model can name a more diff --git a/src-tauri/src/proxy/tests_keys.rs b/src-tauri/src/proxy/tests_keys.rs index 8682ae7..153b8ab 100644 --- a/src-tauri/src/proxy/tests_keys.rs +++ b/src-tauri/src/proxy/tests_keys.rs @@ -614,6 +614,27 @@ async fn ut_042_provider_with_no_enabled_key_returns_a_config_error() { assert!(error.to_string().contains("no enabled API key")); } +#[tokio::test] +async fn ut_042c_send_outcome_keeps_network_facts_separate_from_status() { + let ctx = test_ctx(KeyPools::new()); + let provider = keyed_provider( + "http://127.0.0.1:1/v1".into(), + vec![key("key-a", "secret-a")], + false, + ); + + let result = send_outcome(&ctx, &provider, "responses", &json!({"model": "m"})) + .await + .unwrap(); + + assert!(result.response.is_none()); + assert!(result.key_id.is_none()); + assert!(result.error.is_some()); + assert!(result.outcome.status.is_none()); + assert!(result.outcome.network_error.is_some()); + assert!(!result.outcome.timed_out); +} + #[tokio::test] async fn ut_042b_all_keys_cooling_is_not_reported_as_a_config_error() { // The keys are configured and enabled; they are merely resting after a diff --git a/src-tauri/src/proxy/tests_routing.rs b/src-tauri/src/proxy/tests_routing.rs index 1be1f02..c98192e 100644 --- a/src-tauri/src/proxy/tests_routing.rs +++ b/src-tauri/src/proxy/tests_routing.rs @@ -949,3 +949,46 @@ fn claude_chat_remote_images_select_structured_cli_input() { }; assert!(rendered.contains("https://example.test/image.png")); } + +#[test] +fn native_image_target_strips_v1_and_preserves_native_image_paths() { + assert_eq!( + native_image_target( + Some("/images/generations"), + "https://chatgpt.com/backend-api/codex" + ), + Some("https://chatgpt.com/backend-api/codex/images/generations".to_string()) + ); + assert_eq!( + native_image_target( + Some("/v1/images/edits"), + "https://chatgpt.com/backend-api/codex" + ), + Some("https://chatgpt.com/backend-api/codex/images/edits".to_string()) + ); + assert_eq!( + native_image_target( + Some("/v1/responses"), + "https://chatgpt.com/backend-api/codex" + ), + None + ); +} + +#[test] +fn family_resolution_prefers_preset_metadata_over_url_heuristics() { + let mut provider = multi_dialect_provider(); + provider.id = "deepseek".to_string(); + provider.base_url = "https://custom-proxy.invalid/v1".to_string(); + + assert_eq!(family_of(&provider), ProviderFamily::DeepSeek); +} + +#[test] +fn family_resolution_falls_back_for_custom_endpoints() { + let mut provider = multi_dialect_provider(); + provider.id = "custom-endpoint".to_string(); + provider.base_url = "https://api.moonshot.example/v1".to_string(); + + assert_eq!(family_of(&provider), ProviderFamily::Kimi); +} diff --git a/src-tauri/src/proxy/upstream.rs b/src-tauri/src/proxy/upstream.rs index 92d7714..902560e 100644 --- a/src-tauri/src/proxy/upstream.rs +++ b/src-tauri/src/proxy/upstream.rs @@ -9,6 +9,34 @@ use anyhow::bail; use axum::http::StatusCode; use serde_json::{json, Value}; +/// Independent facts about an upstream attempt. A request can time out while +/// the OS also reports an exit/status, or fail with no HTTP response at all; +/// callers must be able to read those outcomes separately instead of deriving +/// them from one nested error branch. +#[derive(Debug, Clone, Default)] +pub(super) struct UpstreamOutcome { + pub status: Option, + pub timed_out: bool, + pub network_error: Option, +} + +/// One normalized routed attempt: the optional HTTP response, the key that +/// served it, the orthogonal facts, and an error string when no response was +/// produced. This keeps callers from re-deriving timeout/network/status facts +/// from one nested `Result`. +pub(super) struct UpstreamResponse { + pub response: Option, + pub key_id: Option, + pub outcome: UpstreamOutcome, + pub error: Option, +} + +#[derive(Debug)] +struct UpstreamRequestError { + message: String, + timed_out: bool, +} + /// Apply the provider's upstream authentication to an outgoing request. /// The scheme follows the model's wire protocol, because a gateway may host /// Anthropic and OpenAI routes at the same base URL. @@ -44,6 +72,20 @@ pub(super) async fn send( path: &str, body: &Value, ) -> anyhow::Result<(reqwest::Response, Option)> { + let result = send_outcome(ctx, provider, path, body).await?; + let Some(response) = result.response else { + bail!(result.error.unwrap_or_default()); + }; + Ok((response, result.key_id)) +} + +/// [`send`] plus the normalized orthogonal outcome of the winning attempt. +pub(super) async fn send_outcome( + ctx: &ProxyCtx, + provider: &Provider, + path: &str, + body: &Value, +) -> anyhow::Result { // AppConfig::load migrates legacy keys before runtime. This fallback only // keeps test fixtures and hand-built configs with no key list working. let eligible = if provider.keys.is_empty() { @@ -70,16 +112,24 @@ pub(super) async fn send( // Keys that are configured and enabled but currently ineligible are // cooling down after recent failures. Reporting that as a missing key // sends the user hunting for a settings problem that is not there. - if provider.keys.iter().any(|key| key.enabled) { - bail!( + let error = if provider.keys.iter().any(|key| key.enabled) { + format!( "provider '{}' has no key available right now: every enabled key is cooling down after recent failures", provider.id - ); - } - bail!("provider '{}' has no enabled API key", provider.id); + ) + } else { + format!("provider '{}' has no enabled API key", provider.id) + }; + return Ok(UpstreamResponse { + response: None, + key_id: None, + outcome: UpstreamOutcome::default(), + error: Some(error), + }); } let mut last_response: Option<(reqwest::Response, String)> = None; let mut last_error = None; + let mut last_outcome = UpstreamOutcome::default(); for key in &eligible { let mut provider = provider.clone(); provider.api_key = key.api_key.clone(); @@ -88,7 +138,16 @@ pub(super) async fn send( match result { Ok(res) if res.status().is_success() => { ctx.key_pools.record_success(&provider.id, &key.id).await; - return Ok((res, Some(key.id.clone()))); + let outcome = UpstreamOutcome { + status: Some(res.status()), + ..UpstreamOutcome::default() + }; + return Ok(UpstreamResponse { + response: Some(res), + key_id: Some(key.id.clone()), + outcome, + error: None, + }); } Ok(res) => { let status = res.status(); @@ -96,7 +155,16 @@ pub(super) async fn send( // The request is at fault: hand the upstream's own answer // back instead of replaying it against every remaining key, // and leave the pool alone - no key is to blame. - return Ok((res, Some(key.id.clone()))); + let outcome = UpstreamOutcome { + status: Some(status), + ..UpstreamOutcome::default() + }; + return Ok(UpstreamResponse { + response: Some(res), + key_id: Some(key.id.clone()), + outcome, + error: None, + }); }; ctx.key_pools .record_failure(&provider.id, &key.id, failure, retry_after_seconds(&res)) @@ -110,25 +178,44 @@ pub(super) async fn send( "upstream rejected the request; trying the next key" ); last_response = Some((res, key.id.clone())); + last_outcome = UpstreamOutcome { + status: Some(status), + ..UpstreamOutcome::default() + }; } Err(error) => { ctx.key_pools .record_failure(&provider.id, &key.id, FailureKind::Transient, None) .await; - last_error = Some(error.to_string()); + last_error = Some(error.message.clone()); + last_outcome = UpstreamOutcome { + status: None, + timed_out: error.timed_out, + network_error: Some(error.message), + }; } } } // Every key answered with a failure status: return the last one so the // caller forwards the upstream's real status and body. if let Some((res, key_id)) = last_response { - return Ok((res, Some(key_id))); + return Ok(UpstreamResponse { + response: Some(res), + key_id: Some(key_id), + outcome: last_outcome, + error: None, + }); } - bail!( - "provider '{}' is unavailable after exhausting configured keys: {}", - provider.id, - last_error.unwrap_or_default() - ) + Ok(UpstreamResponse { + response: None, + key_id: None, + outcome: last_outcome, + error: Some(format!( + "provider '{}' is unavailable after exhausting configured keys: {}", + provider.id, + last_error.unwrap_or_default() + )), + }) } async fn send_with_key( @@ -136,10 +223,13 @@ async fn send_with_key( provider: &Provider, path: &str, body: &Value, -) -> anyhow::Result { +) -> Result { let url = format!("{}/{}", provider.base_url.trim_end_matches('/'), path); if provider.api_key.is_none() { - anyhow::bail!("provider '{}' has no API key", provider.id); + return Err(UpstreamRequestError { + message: format!("provider '{}' has no API key", provider.id), + timed_out: false, + }); } let mut request = ctx.client.post(&url).json(body); @@ -147,10 +237,14 @@ async fn send_with_key( request = request.header("user-agent", user_agent); } request = apply_provider_auth(request, provider, body.get("model").and_then(Value::as_str)); - request - .send() - .await - .map_err(|e| upstream_unreachable_error(&url, &e, &format!("provider '{}'", provider.id))) + request.send().await.map_err(|e| { + let message = upstream_unreachable_error(&url, &e, &format!("provider '{}'", provider.id)) + .to_string(); + UpstreamRequestError { + timed_out: e.is_timeout(), + message, + } + }) } /// How an upstream status reflects on the key that sent the request. diff --git a/src-tauri/src/secure_fs.rs b/src-tauri/src/secure_fs.rs index 7a66613..584bb88 100644 --- a/src-tauri/src/secure_fs.rs +++ b/src-tauri/src/secure_fs.rs @@ -64,36 +64,45 @@ pub fn restrict_permissions(path: &Path) { } } -/// `.tmp`, as a sibling so the final rename never crosses a -/// filesystem boundary (a cross-device rename is not atomic and fails). -fn temp_sibling(path: &Path) -> PathBuf { - let mut tmp = path.as_os_str().to_owned(); - tmp.push(".tmp"); - PathBuf::from(tmp) +/// Create a random sibling temp name. A predictable `.tmp` invites a +/// symlink race: an attacker who can write the parent directory can plant a +/// link there and make the subsequent open follow it. The random suffix plus +/// `create_new` below makes that attack fail closed. +fn random_temp_sibling(path: &Path) -> PathBuf { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file"); + path.with_file_name(format!(".{name}.tmp.{}", uuid::Uuid::new_v4())) } /// Create the temp file already private, then write into it. /// /// The mode is set at creation rather than after the write, so the content /// is never briefly world-readable - a window a later `set_permissions` -/// cannot close. -fn write_private_temp(tmp: &Path, contents: &[u8]) -> io::Result<()> { +/// cannot close. `create_new` refuses to follow an existing symlink or +/// overwrite a stale file. +fn write_private_temp_exclusive(tmp: &Path, contents: &[u8]) -> io::Result<()> { + use std::io::Write; + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); #[cfg(unix)] { - use std::io::Write; use std::os::unix::fs::OpenOptionsExt; - let mut f = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(PRIVATE_FILE_MODE) - .open(tmp)?; - f.write_all(contents)?; - f.sync_all()?; + options.mode(PRIVATE_FILE_MODE); } - #[cfg(not(unix))] - { - std::fs::write(tmp, contents)?; + + let mut file = options.open(tmp)?; + if let Err(error) = file.write_all(contents) { + drop(file); + let _ = std::fs::remove_file(tmp); + return Err(error); + } + if let Err(error) = file.sync_all() { + drop(file); + let _ = std::fs::remove_file(tmp); + return Err(error); } Ok(()) } @@ -109,12 +118,15 @@ pub fn write_private(path: &Path, contents: &[u8]) -> io::Result<()> { create_dir_private(parent)?; } } - let tmp = temp_sibling(path); - write_private_temp(&tmp, contents)?; + let tmp = random_temp_sibling(path); + write_private_temp_exclusive(&tmp, contents)?; // Windows cannot rename over an existing destination. #[cfg(windows)] let _ = std::fs::remove_file(path); - std::fs::rename(&tmp, path)?; + if let Err(error) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(error); + } // The rename preserves the temp file's mode on Unix; this is a backstop // for the non-Unix path and for pre-existing targets. restrict_permissions(path); @@ -153,6 +165,22 @@ mod tests { std::fs::metadata(path).unwrap().permissions().mode() & 0o777 } + fn temp_siblings(path: &Path) -> Vec { + let parent = path.parent().unwrap(); + let name = path.file_name().unwrap().to_string_lossy(); + std::fs::read_dir(parent) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|candidate| { + candidate + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.starts_with(&format!(".{name}.tmp."))) + }) + .collect() + } + #[test] fn write_private_creates_the_file_and_its_parent() { let dir = tempfile::tempdir().unwrap(); @@ -169,7 +197,7 @@ mod tests { write_private(&path, b"new").unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); // The temp file must not survive a successful write. - assert!(!temp_sibling(&path).exists()); + assert!(temp_siblings(&path).is_empty()); } #[test] @@ -231,4 +259,21 @@ mod tests { create_dir_private(&nested).unwrap(); assert_eq!(mode_of(&nested), 0o700); } + + #[cfg(unix)] + #[test] + fn exclusive_temp_write_does_not_follow_a_planted_symlink() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let victim = dir.path().join("victim"); + let tmp = dir.path().join("config.toml.tmp"); + std::fs::write(&victim, b"original").unwrap(); + symlink(&victim, &tmp).unwrap(); + + let error = write_private_temp_exclusive(&tmp, b"replaced").unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original"); + } } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index a1ea799..6825c19 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -560,6 +560,7 @@ mod tests { codex::CodexStatus { codex_home: String::new(), config_exists: true, + config_parseable: true, managed_block_present, managed_block_orphaned: false, native_catalog_present: managed_block_present, @@ -567,6 +568,15 @@ mod tests { merged_model_count: usize::from(managed_block_present), codex_cli_available: true, integration_enabled: true, + session: codex::CodexSessionStatus { + path: String::new(), + present: false, + usable: false, + has_account_id: false, + expired: false, + expires_in_hours: None, + age_hours: None, + }, } } diff --git a/src-tauri/src/translate/response.rs b/src-tauri/src/translate/response.rs index 75f4348..84461c9 100644 --- a/src-tauri/src/translate/response.rs +++ b/src-tauri/src/translate/response.rs @@ -2,7 +2,9 @@ use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; use super::stream::UpstreamKind; -use super::tools::{synthetic_id, unwrap_freeform_arguments, TOOL_SEARCH_NAME}; +use super::tools::{ + coerce_function_call_arguments, synthetic_id, unwrap_freeform_arguments, TOOL_SEARCH_NAME, +}; pub(crate) fn now_unix() -> u64 { std::time::SystemTime::now() @@ -382,7 +384,7 @@ pub(crate) fn function_call_item(call_id: &str, name: &str, arguments: &str) -> "status": "completed", "call_id": call_id, "name": name, - "arguments": arguments, + "arguments": coerce_function_call_arguments(arguments), }) } diff --git a/src-tauri/src/translate/tests_a.rs b/src-tauri/src/translate/tests_a.rs index 2ed6567..5203737 100644 --- a/src-tauri/src/translate/tests_a.rs +++ b/src-tauri/src/translate/tests_a.rs @@ -1,4 +1,5 @@ use super::*; +use crate::translate::response::function_call_item; use serde_json::{json, Value}; use std::collections::BTreeSet; @@ -398,6 +399,44 @@ fn a_schemaless_function_tool_is_not_treated_as_freeform() { assert_eq!(chat[0]["function"]["description"], "pong"); } +#[test] +fn union_root_tool_schemas_are_flattened_for_strict_providers() { + let payload = json!({ + "input": [{"role":"user","content":[{"type":"input_text","text":"hi"}]}], + "tools": [{ + "type": "function", + "name": "automation_update", + "description": "Update automations", + "parameters": { + "oneOf": [ + {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}, + {"type": "object", "properties": {"mode": {"type": "string"}}, "required": ["mode"]} + ], + "required": ["common"] + } + }], + "stream": false + }); + + let out = responses_to_chat(&payload, "m", false).unwrap(); + let params = &out["tools"][0]["function"]["parameters"]; + + assert_eq!(params["type"], "object"); + assert_eq!(params["properties"]["id"]["type"], "string"); + assert_eq!(params["properties"]["mode"]["type"], "string"); + assert!(params["required"] + .as_array() + .unwrap() + .contains(&json!("common"))); +} + +#[test] +fn whole_number_tool_arguments_are_coerced_before_codex_deserializes_them() { + let item = function_call_item("call_1", "automation_update", "{\"limit\":20000.0}"); + + assert_eq!(item["arguments"], "{\"limit\":20000}"); +} + #[test] fn freeform_tool_names_and_unwrap_round_trip() { let payload = json!({ diff --git a/src-tauri/src/translate/tools.rs b/src-tauri/src/translate/tools.rs index f5ac6d1..202260e 100644 --- a/src-tauri/src/translate/tools.rs +++ b/src-tauri/src/translate/tools.rs @@ -10,7 +10,7 @@ //! provider-specific extensions are dropped (best effort). use serde_json::{json, Value}; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; // --------------------------------------------------------------------------- // Synthetic item ids @@ -311,9 +311,7 @@ pub(crate) fn is_freeform_tool(t: &Value) -> bool { /// an argument the tool does not take. pub(crate) fn tool_parameters(t: &Value, freeform: bool) -> Value { match t.get("parameters") { - Some(Value::Object(m)) if m.get("type").and_then(Value::as_str) == Some("object") => { - t.get("parameters").cloned().unwrap() - } + Some(Value::Object(_)) => normalize_object_root_schema(t.get("parameters").unwrap()), _ if freeform => json!({ "type": "object", "properties": { @@ -328,6 +326,170 @@ pub(crate) fn tool_parameters(t: &Value, freeform: bool) -> Value { } } +/// Normalize a Codex tool parameter schema for providers that require an +/// object root. Codex ships union-rooted tools; chat-completions providers +/// reject the whole request unless the union branches are merged into one +/// callable object schema. +fn normalize_object_root_schema(schema: &Value) -> Value { + if is_object_root(schema) { + return schema.clone(); + } + let branches = object_branches(schema, schema, &mut Default::default(), 0); + if branches.is_empty() { + return json!({ "type": "object", "properties": {} }); + } + let mut properties = serde_json::Map::new(); + if let Some(root) = schema.get("properties").and_then(Value::as_object) { + properties.extend(root.clone()); + } + for branch in &branches { + let Some(branch_props) = branch.get("properties").and_then(Value::as_object) else { + continue; + }; + for (name, property) in branch_props { + if !properties.contains_key(name) { + properties.insert(name.clone(), property.clone()); + } + } + } + let mut required: Vec = Vec::new(); + if let Some(root) = schema.get("required").and_then(Value::as_array) { + required.extend(root.clone()); + } + let required_arrays: Vec> = branches + .iter() + .filter_map(|branch| branch.get("required").and_then(Value::as_array)) + .cloned() + .collect(); + let union_required = required_arrays + .into_iter() + .reduce(|left, right| { + left.into_iter() + .filter(|value| right.contains(value)) + .collect() + }) + .unwrap_or_default(); + for value in union_required { + if !required.contains(&value) { + required.push(value); + } + } + let mut out = json!({ + "type": "object", + "properties": properties, + "additionalProperties": true, + }); + if !required.is_empty() { + out["required"] = Value::Array(required); + } + if let Some(defs) = schema.get("$defs").or_else(|| schema.get("definitions")) { + out["$defs"] = defs.clone(); + } + if let Some(description) = schema.get("description").and_then(Value::as_str) { + out["description"] = Value::String(description.to_string()); + } + out +} + +fn is_object_root(schema: &Value) -> bool { + let Some(map) = schema.as_object() else { + return false; + }; + if ["anyOf", "oneOf", "allOf"] + .iter() + .any(|key| map.contains_key(*key)) + { + return false; + } + map.get("type").and_then(Value::as_str) == Some("object") + || map.get("properties").is_some_and(Value::is_object) +} + +fn resolve_ref<'a>(reference: &str, root: &'a Value) -> Option<&'a Value> { + let path = reference.strip_prefix("#/")?; + let mut current = root; + for raw_segment in path.split('/') { + let segment = raw_segment.replace("~1", "/").replace("~0", "~"); + current = current.as_object()?.get(&segment)?; + } + Some(current) +} + +fn object_branches<'a>( + schema: &'a Value, + root: &'a Value, + seen: &mut HashSet, + depth: usize, +) -> Vec<&'a Value> { + const MAX_DEPTH: usize = 8; + let Some(map) = schema.as_object() else { + return Vec::new(); + }; + if depth > MAX_DEPTH { + return Vec::new(); + } + if let Some(reference) = map.get("$ref").and_then(Value::as_str) { + if !seen.insert(reference.to_string()) { + return Vec::new(); + } + return resolve_ref(reference, root) + .map(|resolved| object_branches(resolved, root, seen, depth + 1)) + .unwrap_or_default(); + } + let mut branches = Vec::new(); + for keyword in ["anyOf", "oneOf", "allOf"] { + let Some(values) = map.get(keyword).and_then(Value::as_array) else { + continue; + }; + for branch in values { + branches.extend(object_branches(branch, root, seen, depth + 1)); + } + } + if map.get("type").and_then(Value::as_str) == Some("object") + || map.get("properties").is_some_and(Value::is_object) + { + branches.push(schema); + } + branches +} + +/// Normalize numeric literals some routed models emit as floats into whole +/// JSON numbers before Codex deserializes integer fields. +pub(crate) fn coerce_function_call_arguments(raw: &str) -> String { + let Ok(mut parsed) = serde_json::from_str::(raw) else { + return raw.to_string(); + }; + coerce_whole_number_json(&mut parsed); + serde_json::to_string(&parsed).unwrap_or_else(|_| raw.to_string()) +} + +fn coerce_whole_number_json(value: &mut Value) { + match value { + Value::Number(_) => { + let integer = value.as_i64().or_else(|| { + value + .as_f64() + .filter(|number| number.fract() == 0.0) + .map(|number| number as i64) + }); + if let Some(integer) = integer { + *value = json!(integer); + } + } + Value::Array(values) => { + for value in values { + coerce_whole_number_json(value); + } + } + Value::Object(map) => { + for (_, value) in map.iter_mut() { + coerce_whole_number_json(value); + } + } + _ => {} + } +} + /// Reverse [`tool_parameters`] on a model tool call: freeform tools travel as /// `{"input": ""}` through Chat, and Codex's freeform handler needs /// exactly the raw text. Anything that is not that shape passes through diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8c74650..c98d938 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": "LoomRouter", - "version": "0.2.10", + "version": "0.2.11", "identifier": "dev.loomrouter.app", "build": { "beforeDevCommand": "bun run dev", diff --git a/src-tauri/tests/live_provider_smoke.rs b/src-tauri/tests/live_provider_smoke.rs new file mode 100644 index 0000000..52d82e6 --- /dev/null +++ b/src-tauri/tests/live_provider_smoke.rs @@ -0,0 +1,29 @@ +//! Optional live-provider smoke tests. +//! +//! These self-skip unless the matching API key is present, so keyless CI and +//! local development stay green while a developer with real credentials can +//! prove the public endpoint still works. + +#[tokio::test] +async fn deepseek_models_endpoint_is_live() { + let Ok(key) = std::env::var("LOOM_ROUTER_DEEPSEEK_API_KEY") else { + eprintln!("skipping: LOOM_ROUTER_DEEPSEEK_API_KEY is not set"); + return; + }; + + let client = reqwest::Client::new(); + let response = client + .get("https://api.deepseek.com/v1/models") + .bearer_auth(key) + .send() + .await + .expect("live DeepSeek request failed"); + + assert!( + response.status().is_success(), + "status: {}", + response.status() + ); + let body: serde_json::Value = response.json().await.expect("models payload is not JSON"); + assert!(body.get("data").is_some_and(serde_json::Value::is_array)); +} diff --git a/src/App.test.tsx b/src/App.test.tsx index a3ce1bf..088a02f 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -15,6 +15,7 @@ let getConfig: () => Promise = () => Promise.reject(new Error('unset' const statusPayload = (managed_block_orphaned: boolean): CodexStatus => ({ codex_home: '~/.codex', config_exists: true, + config_parseable: true, managed_block_present: false, managed_block_orphaned, native_catalog_present: false, @@ -22,6 +23,15 @@ const statusPayload = (managed_block_orphaned: boolean): CodexStatus => ({ merged_model_count: 0, codex_cli_available: true, integration_enabled: false, + session: { + path: '~/.codex/auth.json', + present: false, + usable: false, + has_account_id: false, + expired: false, + expires_in_hours: null, + age_hours: null, + }, }) let codexStatus: () => Promise = () => Promise.resolve(statusPayload(false)) let codexApply = vi.fn(() => Promise.resolve()) diff --git a/src/i18n/en.ts b/src/i18n/en.ts index d9f2ce5..992bd36 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -197,8 +197,13 @@ const en = { mergedCatalog: 'Merged catalog', modelsInPicker: '{{count}} external models in the picker', codexHome: 'Codex home', + configParseable: 'Codex config parses', cliAvailable: 'Codex CLI detected', cliMissingHint: 'Not found. LoomRouter checks your PATH, your login shell and the usual install locations - if Codex lives somewhere else, set CODEX_BIN to its full path and reopen the app.', + session: 'Codex session', + sessionMissing: 'No local Codex session found', + sessionExpired: 'Local Codex session is expired', + sessionUsable: 'Local Codex session is usable', nativeCatalog: 'Native catalog captured', restartHint: 'Fully quit and reopen Codex after applying - Codex only loads the catalog at startup.', orphanedHint: 'Codex config was rewritten externally and lost its managed block markers. Apply or remove the integration to repair it - your own settings are preserved.', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 7a71919..b7e7977 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -195,8 +195,13 @@ const es: DeepPartial = { mergedCatalog: 'Catálogo combinado', modelsInPicker: '{{count}} modelos externos en el selector', codexHome: 'Carpeta de Codex', + configParseable: 'Config de Codex válida', cliAvailable: 'CLI de Codex detectada', cliMissingHint: 'No encontrada. LoomRouter busca en tu PATH, en tu shell de inicio y en las ubicaciones habituales - si Codex está en otro sitio, define CODEX_BIN con la ruta completa y reabre la app.', + session: 'Sesión de Codex', + sessionMissing: 'No se encontró una sesión local de Codex', + sessionExpired: 'La sesión local de Codex expiró', + sessionUsable: 'La sesión local de Codex es utilizable', nativeCatalog: 'Catálogo nativo capturado', restartHint: 'Cierra por completo y vuelve a abrir Codex después de aplicar: Codex solo carga el catálogo al iniciar.', orphanedHint: 'La configuración de Codex se reescribió externamente y perdió los marcadores del bloque gestionado. Aplica o quita la integración para repararlo: tus ajustes se conservan.', diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index f378845..0eeb8b8 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -194,8 +194,13 @@ const pt: DeepPartial = { mergedCatalog: 'Catálogo mesclado', modelsInPicker: '{{count}} modelos externos no seletor', codexHome: 'Pasta do Codex', + configParseable: 'Config do Codex válido', cliAvailable: 'CLI do Codex detectada', cliMissingHint: 'Não encontrada. O LoomRouter procura no seu PATH, no seu shell de login e nos locais de instalação comuns - se o Codex estiver em outro lugar, defina CODEX_BIN com o caminho completo e reabra o app.', + session: 'Sessão do Codex', + sessionMissing: 'Nenhuma sessão local do Codex encontrada', + sessionExpired: 'A sessão local do Codex expirou', + sessionUsable: 'A sessão local do Codex está utilizável', nativeCatalog: 'Catálogo nativo capturado', restartHint: 'Feche completamente e reabra o Codex após aplicar - o Codex só carrega o catálogo na inicialização.', orphanedHint: 'O config do Codex foi reescrito externamente e perdeu os marcadores do bloco gerenciado. Aplique ou remova a integração para reparar - suas configurações são preservadas.', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index e17205b..85de9ab 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -193,8 +193,13 @@ const zh: DeepPartial = { mergedCatalog: '合并目录', modelsInPicker: '选择器中有 {{count}} 个外部模型', codexHome: 'Codex 主目录', + configParseable: 'Codex 配置可解析', cliAvailable: '已检测到 Codex CLI', cliMissingHint: '未找到。LoomRouter 会检查你的 PATH、登录 shell 以及常见安装位置 - 如果 Codex 在别处,请将 CODEX_BIN 设为完整路径后重新打开应用。', + session: 'Codex 会话', + sessionMissing: '未找到本地 Codex 会话', + sessionExpired: '本地 Codex 会话已过期', + sessionUsable: '本地 Codex 会话可用', nativeCatalog: '已捕获原生目录', restartHint: '应用后请完全退出并重新打开 Codex - Codex 只在启动时加载目录。', orphanedHint: 'Codex 配置被外部重写,丢失了受管块的标记。应用或移除集成即可修复 - 你自己的设置会被保留。', diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index f08a3bd..c7ba781 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -103,12 +103,15 @@ describe('codex integration', () => { for (const key of [ 'codex_home', 'config_exists', + 'config_parseable', 'managed_block_present', + 'managed_block_orphaned', 'native_catalog_present', 'merged_catalog_present', 'merged_model_count', 'codex_cli_available', 'integration_enabled', + 'session', ]) { expect(status).toHaveProperty(key) expect(status[key as keyof typeof status]).not.toBeUndefined() diff --git a/src/lib/api.ts b/src/lib/api.ts index a64bc43..fba477f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -456,6 +456,7 @@ function mock(cmd: string, args?: Record): Promise { return Promise.resolve({ codex_home: '~/.codex', config_exists: true, + config_parseable: true, managed_block_present: mockState.codexApplied, managed_block_orphaned: false, native_catalog_present: mockState.codexApplied, @@ -463,6 +464,15 @@ function mock(cmd: string, args?: Record): Promise { merged_model_count: mockState.codexApplied ? 1 : 0, codex_cli_available: true, integration_enabled: mockState.codexApplied, + session: { + path: '~/.codex/auth.json', + present: false, + usable: false, + has_account_id: false, + expired: false, + expires_in_hours: null, + age_hours: null, + }, } as T) case 'codex_apply': mockState.codexApplied = true diff --git a/src/pages/Codex.test.tsx b/src/pages/Codex.test.tsx index 52bf823..de74613 100644 --- a/src/pages/Codex.test.tsx +++ b/src/pages/Codex.test.tsx @@ -34,6 +34,7 @@ vi.mock('@/lib/api', () => ({ Promise.resolve({ codex_home: '~/.codex', config_exists: true, + config_parseable: true, managed_block_present: true, managed_block_orphaned: orphaned, native_catalog_present: true, @@ -41,6 +42,15 @@ vi.mock('@/lib/api', () => ({ merged_model_count: 3, codex_cli_available: true, integration_enabled: true, + session: { + path: '~/.codex/auth.json', + present: false, + usable: false, + has_account_id: false, + expired: false, + expires_in_hours: null, + age_hours: null, + }, }), getConfig: () => Promise.resolve({ diff --git a/src/pages/Codex.tsx b/src/pages/Codex.tsx index f2cb793..d22e3b6 100644 --- a/src/pages/Codex.tsx +++ b/src/pages/Codex.tsx @@ -119,6 +119,7 @@ export default function CodexPage() { + {/* A red row with no explanation is where this bug stranded people: say what to do instead of just failing. */} + ({ Promise.resolve({ codex_home: '~/.codex', config_exists: true, + config_parseable: true, managed_block_present: codexManaged, managed_block_orphaned: false, native_catalog_present: codexManaged, @@ -159,6 +160,15 @@ vi.mock('@/lib/api', () => ({ merged_model_count: codexManaged ? 1 : 0, codex_cli_available: codexCliAvailable, integration_enabled: codexManaged, + session: { + path: '~/.codex/auth.json', + present: false, + usable: false, + has_account_id: false, + expired: false, + expires_in_hours: null, + age_hours: null, + }, }), codexApply: () => codexApply(), detectTools: () => Promise.resolve(structuredClone(detection)), diff --git a/src/pages/Server.test.tsx b/src/pages/Server.test.tsx index f30c7ae..20f2651 100644 --- a/src/pages/Server.test.tsx +++ b/src/pages/Server.test.tsx @@ -83,6 +83,7 @@ vi.mock('@/lib/api', () => ({ Promise.resolve({ codex_home: '~/.codex', config_exists: true, + config_parseable: true, managed_block_present: true, managed_block_orphaned: false, native_catalog_present: true, @@ -90,6 +91,15 @@ vi.mock('@/lib/api', () => ({ merged_model_count: 2, codex_cli_available: true, integration_enabled: true, + session: { + path: '~/.codex/auth.json', + present: false, + usable: false, + has_account_id: false, + expired: false, + expires_in_hours: null, + age_hours: null, + }, }), serverStart: () => Promise.resolve(), serverStop: () => Promise.resolve(), diff --git a/src/types/index.ts b/src/types/index.ts index 1fcdad1..93fbb0b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -173,6 +173,7 @@ export interface ClaudeAuthStatus { export interface CodexStatus { codex_home: string config_exists: boolean + config_parseable: boolean managed_block_present: boolean managed_block_orphaned: boolean native_catalog_present: boolean @@ -180,6 +181,17 @@ export interface CodexStatus { merged_model_count: number codex_cli_available: boolean integration_enabled: boolean + session: CodexSessionStatus +} + +export interface CodexSessionStatus { + path: string + present: boolean + usable: boolean + has_account_id: boolean + expired: boolean + expires_in_hours: number | null + age_hours: number | null } /// One model's behaviour over the summarised window - the numbers that