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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "loom-router",
"private": true,
"version": "0.2.10",
"version": "0.2.11",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

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

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
56 changes: 55 additions & 1 deletion src-tauri/src/claude_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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!([
Expand Down
27 changes: 27 additions & 0 deletions src-tauri/src/cli_locator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,33 @@ fn join_unique_paths(paths: impl IntoIterator<Item = PathBuf>) -> Option<OsStrin
.flatten()
}

/// Env vars whose names indicate credentials or secrets. Matching is case
/// insensitive so Windows's case-insensitive environment cannot bypass it.
fn is_sensitive_env_key(key: &OsStr) -> 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) {
Expand Down
116 changes: 116 additions & 0 deletions src-tauri/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -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<f64>,
pub age_hours: Option<f64>,
}

pub fn codex_home() -> PathBuf {
Expand All @@ -96,24 +112,123 @@ 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::<toml::Value>(&raw).is_ok();
let count = std::fs::read_to_string(merged_catalog_path())
.ok()
.and_then(|s| serde_json::from_str::<Value>(&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(),
merged_catalog_present: merged_catalog_path().exists(),
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<i64>,
}

fn read_codex_auth_summary(path: &Path) -> Option<CodexAuthSummary> {
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<i64> {
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
Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/codex/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub fn capture_native_catalog(
let run = |extra: &str| -> anyhow::Result<String> {
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() {
Expand Down
Loading