codex-switch 0.1.8

Local CLI account switcher for Codex
use std::process::Command;

use anyhow::Result;

#[cfg(windows)]
use anyhow::Context;

#[cfg(windows)]
use std::collections::HashSet;

#[cfg(windows)]
use std::os::windows::process::CommandExt;

#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;

#[cfg(windows)]
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "PascalCase")]
struct WindowsCodexProcess {
    name: String,
    process_id: u32,
    parent_process_id: u32,
    #[serde(default)]
    command_line: String,
    #[serde(default)]
    main_window_title: String,
}

#[derive(Debug, Clone)]
pub struct CodexProcessInfo {
    pub count: usize,
    pub background_count: usize,
    pub managed_run_count: usize,
    pub can_switch: bool,
    pub pids: Vec<u32>,
}

pub fn check_codex_processes() -> Result<CodexProcessInfo> {
    let (pids, background_count, managed_run_count) = find_codex_processes()?;
    let count = pids.len();

    Ok(CodexProcessInfo {
        count,
        background_count,
        managed_run_count,
        can_switch: count == 0,
        pids,
    })
}

pub fn ensure_can_switch() -> Result<()> {
    let info = check_codex_processes()?;
    ensure_can_switch_info(&info)
}

pub fn ensure_can_switch_info(info: &CodexProcessInfo) -> Result<()> {
    if !info.can_switch {
        let pids = info
            .pids
            .iter()
            .map(u32::to_string)
            .collect::<Vec<_>>()
            .join(", ");
        let managed_detail = if info.managed_run_count > 0 {
            format!(
                " Ignored {} codex-switch run process(es).",
                info.managed_run_count
            )
        } else {
            String::new()
        };
        anyhow::bail!(
            "Detected {} active Codex process(es) (pid: {pids}). Close Codex before switching accounts. Ignored {} background process(es).{}",
            info.count,
            info.background_count,
            managed_detail
        );
    }
    Ok(())
}

fn find_codex_processes() -> Result<(Vec<u32>, usize, usize)> {
    #[cfg(unix)]
    {
        let mut pids = Vec::new();
        let mut background_count = 0;
        let mut managed_run_count = 0;

        let output = Command::new("ps")
            .args(["-axo", "pid=,tty=,command="])
            .output();

        if let Ok(output) = output {
            let stdout = String::from_utf8_lossy(&output.stdout);
            for line in stdout.lines() {
                let line = line.trim();
                if line.is_empty() {
                    continue;
                }

                let mut parts = line.split_whitespace();
                let Some(pid_str) = parts.next() else {
                    continue;
                };
                let Some(tty) = parts.next() else {
                    continue;
                };
                let command = parts.collect::<Vec<_>>().join(" ");
                if command.is_empty() {
                    continue;
                }

                let lowercase_command = command.to_ascii_lowercase();
                if lowercase_command.contains("codex-switcher")
                    || lowercase_command.contains("codex-switch")
                {
                    continue;
                }

                let first_token = command.split_whitespace().next().unwrap_or("");
                let is_codex_cli = first_token == "codex" || first_token.ends_with("/codex");
                let is_codex_desktop = command.contains(".app/Contents/MacOS/Codex")
                    && !command.contains("Codex Helper")
                    && !command.contains("CodexBar");

                if !is_codex_cli && !is_codex_desktop {
                    continue;
                }

                let Ok(pid) = pid_str.parse::<u32>() else {
                    continue;
                };

                if pid == std::process::id() || pids.contains(&pid) {
                    continue;
                }

                let is_ide_plugin = is_ide_plugin_process(&lowercase_command);
                let is_app_server = lowercase_command.contains("codex app-server");
                let has_tty = tty != "??" && tty != "?";

                if is_codex_switch_run_process(&lowercase_command) {
                    managed_run_count += 1;
                    continue;
                }

                if is_ide_plugin || is_app_server {
                    background_count += 1;
                    continue;
                }

                if is_codex_desktop || has_tty {
                    pids.push(pid);
                } else {
                    background_count += 1;
                }
            }
        }

        pids.sort_unstable();
        pids.dedup();

        return Ok((pids, background_count, managed_run_count));
    }

    #[cfg(windows)]
    {
        return find_windows_codex_processes();
    }

    #[allow(unreachable_code)]
    Ok((Vec::new(), 0, 0))
}

#[cfg(windows)]
fn find_windows_codex_processes() -> Result<(Vec<u32>, usize, usize)> {
    const POWERSHELL_SCRIPT: &str = r#"
$windowTitles = @{}
Get-Process -Name Codex -ErrorAction SilentlyContinue | ForEach-Object {
  $windowTitles[[uint32]$_.Id] = $_.MainWindowTitle
}

Get-CimInstance Win32_Process |
  Where-Object { $_.Name -ieq 'Codex.exe' -or $_.Name -ieq 'codex.exe' } |
  ForEach-Object {
    [PSCustomObject]@{
      Name = $_.Name
      ProcessId = [uint32]$_.ProcessId
      ParentProcessId = [uint32]$_.ParentProcessId
      CommandLine = if ($_.CommandLine) { $_.CommandLine } else { '' }
      MainWindowTitle = if ($windowTitles.ContainsKey([uint32]$_.ProcessId)) {
        [string]$windowTitles[[uint32]$_.ProcessId]
      } else {
        ''
      }
    }
  } |
  ConvertTo-Json -Compress
"#;

    let output = Command::new("powershell.exe")
        .creation_flags(CREATE_NO_WINDOW)
        .args([
            "-NoProfile",
            "-NonInteractive",
            "-Command",
            POWERSHELL_SCRIPT,
        ])
        .output()
        .context("failed to query Windows process list")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("PowerShell process query failed: {}", stderr.trim());
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let processes = parse_windows_codex_processes(&stdout)?;

    let mut active_pids = Vec::new();
    let mut ignored_count = 0;
    let mut managed_run_count = 0;

    for process in processes
        .iter()
        .filter(|process| is_windows_codex_root_process(process))
    {
        let command = process.command_line.to_ascii_lowercase();
        if is_ide_plugin_process(&command) {
            ignored_count += 1;
            continue;
        }
        if is_codex_switch_run_process(&command) {
            managed_run_count += 1;
            continue;
        }

        let has_window = !process.main_window_title.trim().is_empty();
        let has_renderer =
            windows_has_descendant_matching(process.process_id, &processes, |child| {
                child
                    .command_line
                    .to_ascii_lowercase()
                    .contains("--type=renderer")
            });
        let has_app_server =
            windows_has_descendant_matching(process.process_id, &processes, |child| {
                let command = child.command_line.to_ascii_lowercase();
                command.contains("resources\\codex.exe") && command.contains("app-server")
            });

        if has_window || has_renderer || has_app_server {
            active_pids.push(process.process_id);
        } else {
            ignored_count += 1;
        }
    }

    active_pids.sort_unstable();
    active_pids.dedup();

    Ok((active_pids, ignored_count, managed_run_count))
}

#[cfg(windows)]
fn parse_windows_codex_processes(stdout: &str) -> Result<Vec<WindowsCodexProcess>> {
    let trimmed = stdout.trim();
    if trimmed.is_empty() {
        return Ok(Vec::new());
    }

    let value: serde_json::Value =
        serde_json::from_str(trimmed).context("failed to parse Windows process JSON")?;

    match value {
        serde_json::Value::Array(values) => values
            .into_iter()
            .map(|value| {
                serde_json::from_value(value)
                    .context("failed to deserialize Windows Codex process entry")
            })
            .collect(),
        value => {
            Ok(vec![serde_json::from_value(value).context(
                "failed to deserialize Windows Codex process entry",
            )?])
        }
    }
}

#[cfg(windows)]
fn is_windows_codex_root_process(process: &WindowsCodexProcess) -> bool {
    let name = process.name.to_ascii_lowercase();
    let command = process.command_line.to_ascii_lowercase();

    name == "codex.exe"
        && !command.contains("codex-switcher")
        && !command.contains("codex-switch")
        && !command.contains("--type=")
        && !command.contains("resources\\codex.exe")
}

#[cfg(any(unix, windows))]
fn is_ide_plugin_process(command: &str) -> bool {
    command.contains(".antigravity")
        || command.contains("openai.chatgpt")
        || command.contains(".vscode")
}

#[cfg(any(unix, windows))]
fn is_codex_switch_run_process(command: &str) -> bool {
    command.contains("--remote-auth-token-env codex_switch_remote_token")
        || command.contains("--remote-auth-token-env=codex_switch_remote_token")
}

#[cfg(windows)]
fn windows_has_descendant_matching<F>(
    root_pid: u32,
    processes: &[WindowsCodexProcess],
    mut predicate: F,
) -> bool
where
    F: FnMut(&WindowsCodexProcess) -> bool,
{
    let mut queue = vec![root_pid];
    let mut visited = HashSet::new();

    while let Some(parent_pid) = queue.pop() {
        for process in processes
            .iter()
            .filter(|process| process.parent_process_id == parent_pid)
        {
            if !visited.insert(process.process_id) {
                continue;
            }

            if predicate(process) {
                return true;
            }

            queue.push(process.process_id);
        }
    }

    false
}

#[cfg(test)]
mod tests {
    use super::{CodexProcessInfo, ensure_can_switch_info, is_codex_switch_run_process};

    #[test]
    fn managed_run_process_is_detected_by_remote_token_env() {
        assert!(is_codex_switch_run_process(
            "codex resume --remote ws://127.0.0.1:1234 --remote-auth-token-env codex_switch_remote_token"
        ));
        assert!(is_codex_switch_run_process(
            "codex --remote=ws://127.0.0.1:1234 --remote-auth-token-env=codex_switch_remote_token"
        ));
        assert!(!is_codex_switch_run_process(
            "codex resume --remote ws://127.0.0.1:1234 --remote-auth-token-env other_token"
        ));
    }

    #[test]
    fn managed_run_processes_do_not_block_switching_by_themselves() {
        let info = CodexProcessInfo {
            count: 0,
            background_count: 1,
            managed_run_count: 2,
            can_switch: true,
            pids: Vec::new(),
        };

        assert!(ensure_can_switch_info(&info).is_ok());
    }
}