main
John Lauer Fix Codex Store agreement flags and recover transient launch observations; test on crr 9fee13e 1mo ago
use crate::process::{powershell, ps_quote, run};
use serde_json::{json, Value};
use std::{
    env, fs,
    path::{Path, PathBuf},
    process::Command,
    time::Duration,
};

pub const PACKAGE: &str = "OpenAI.Codex";
pub const STORE_ID: &str = "9PLM9XGG6VKS";
pub fn home() -> PathBuf {
    env::var_os("CODEX_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| {
            PathBuf::from(
                env::var_os("USERPROFILE")
                    .or_else(|| env::var_os("HOME"))
                    .unwrap_or_default(),
            )
            .join(".codex")
        })
}
pub fn inventory() -> Result<Value, String> {
    if !cfg!(windows) {
        return Ok(json!({"installed":false,"platform":env::consts::OS,"supported":false}));
    }
    let result = powershell(
        &format!(
            r#"$ErrorActionPreference='Stop'; $p=Get-AppxPackage {PACKAGE};
$processes=@(Get-Process Codex,ChatGPT -ErrorAction SilentlyContinue | Where-Object {{$p -and $_.Path -and $_.Path.StartsWith($p.InstallLocation,[StringComparison]::OrdinalIgnoreCase)}} | ForEach-Object {{@{{pid=$_.Id;hwnd=$_.MainWindowHandle.ToInt64();title=$_.MainWindowTitle;path=$_.Path}}}});
$items=@($processes | Where-Object {{$_.hwnd -ne 0}});
if($p){{ @{{installed=$true;platform='windows';supported=$true;name=$p.Name;version=$p.Version.ToString();path=$p.InstallLocation;family=$p.PackageFamilyName;packageFullName=$p.PackageFullName;windows=$items;processes=$processes}} | ConvertTo-Json -Depth 5 -Compress }}
else {{ @{{installed=$false;platform='windows';supported=$true;windows=$items}} | ConvertTo-Json -Depth 5 -Compress }}"#
        ),
        Duration::from_secs(15),
    )?;
    if result["success"] != true {
        return Err(format!("desktop_inventory_failed: {result}"));
    }
    serde_json::from_str(
        result["stdout"]
            .as_str()
            .unwrap_or("")
            .trim_start_matches('\u{feff}')
            .trim(),
    )
    .map_err(|e| format!("desktop_inventory_invalid_json: {e}; diagnostics={result}"))
}
pub fn executable() -> Result<PathBuf, String> {
    if let Some(path) = env::var_os("CODEX_BRIDGE_CLI") {
        let path = PathBuf::from(path);
        if path.is_file() {
            return Ok(path);
        }
        return Err("configured_cli_missing".into());
    }
    let inv = inventory()?;
    let root = PathBuf::from(inv["path"].as_str().ok_or("desktop_not_installed")?);
    if cfg!(windows) {
        let result = powershell(
            r#"$ErrorActionPreference='Stop'; $p=Get-AppxPackage OpenAI.Codex; $all=@(Get-CimInstance Win32_Process); $parents=@($all | Where-Object {$_.ExecutablePath -and $_.ExecutablePath.StartsWith($p.InstallLocation,[StringComparison]::OrdinalIgnoreCase)} | ForEach-Object {$_.ProcessId}); $cli=$all | Where-Object {$_.Name -eq 'codex.exe' -and $parents -contains $_.ParentProcessId} | Select-Object -First 1; if($cli){@{path=$cli.ExecutablePath}|ConvertTo-Json -Compress}"#,
            Duration::from_secs(15),
        )?;
        if let Ok(value) =
            serde_json::from_str::<Value>(result["stdout"].as_str().unwrap_or("").trim())
        {
            if let Some(path) = value["path"].as_str() {
                let path = PathBuf::from(path);
                if path.is_file() {
                    return Ok(path);
                }
            }
        }
        return Err("desktop_backend_not_running: open the desktop app first so it activates its bundled CLI".into());
    }
    for relative in [
        "app/resources/codex.exe",
        "app/resources/bin/codex.exe",
        "resources/codex.exe",
    ] {
        let path = root.join(relative);
        if path.is_file() {
            return Ok(path);
        }
    }
    Err("desktop_cli_not_found".into())
}
fn install_args(accept: bool) -> Vec<&'static str> {
    let mut args = vec![
        "install",
        "--id",
        STORE_ID,
        "--exact",
        "--source",
        "msstore",
        "--silent",
        "--disable-interactivity",
    ];
    if accept {
        args.extend(["--accept-source-agreements", "--accept-package-agreements"]);
    }
    args
}

pub fn install(accept: bool) -> Result<Value, String> {
    if !cfg!(windows) {
        return Err("unsupported_platform".into());
    }
    let before = inventory()?;
    if before["installed"] == true {
        return Ok(json!({"alreadyInstalled":true,"desktop":before}));
    }
    let mut cmd = Command::new("winget.exe");
    let args = install_args(accept);
    cmd.args(&args);
    let mut result = match run(cmd, None, Duration::from_secs(900)) {
        Ok(result) => result,
        Err(error) => json!({"success":false,"error":error}),
    };
    result["program"] = json!("winget.exe");
    result["args"] = json!(args);
    Ok(install_result(accept, result, inventory()))
}

fn install_result(accept: bool, result: Value, after: Result<Value, String>) -> Value {
    let after = match after {
        Ok(after) => after,
        Err(error) => {
            return json!({"success":false,"errorCode":"install_verification_failed",
            "installer":result,"inventoryError":error,
            "_hint":"The installer may have completed. Check codex_status before retrying installation."});
        }
    };
    if after["installed"] != true {
        let agreement_blocked =
            !accept && matches!(result["exitCode"].as_i64(), Some(-1978335162 | -1978335167));
        return json!({"success":false,"errorCode":if agreement_blocked {"blocked_on_user"} else {"install_failed"},
            "blockedBy":if agreement_blocked {json!("Microsoft Store source and package agreements")} else {Value::Null},
            "installer":result,"desktop":after,"_hint":"Inspect installer.args, exitCode, stdout and stderr. Only after the user accepts BOTH Microsoft Store source and package agreements on this target, call codex_desktop_install with acceptAgreements:true."});
    }
    json!({"desktop":after,"installer":result})
}
pub fn uninstall() -> Result<Value, String> {
    let before = inventory()?;
    if before["installed"] != true {
        return Ok(json!({"alreadyAbsent":true}));
    }
    if before["processes"]
        .as_array()
        .is_some_and(|a| !a.is_empty())
    {
        return Err("desktop_running_close_it_first".into());
    }
    let result=powershell(&format!("$ErrorActionPreference='Stop'; Get-AppxPackage {PACKAGE} | Remove-AppxPackage -ErrorAction Stop"),Duration::from_secs(180))?;
    let after = inventory()?;
    Ok(
        json!({"success":result["success"]==true && after["installed"]==false,"desktop":after,
        "removal":result,"preservedCodexHome":home()}),
    )
}
pub fn launch() -> Result<Value, String> {
    let inv = inventory()?;
    if inv["installed"] != true {
        return Err("desktop_not_installed".into());
    }
    if inv["windows"].as_array().is_some_and(|a| !a.is_empty()) {
        return Ok(inv);
    }
    let family = inv["family"].as_str().ok_or("missing_package_family")?;
    let script=format!("$ErrorActionPreference='Stop'; Start-Process explorer.exe -ArgumentList {} -WindowStyle Minimized",ps_quote(&format!("shell:AppsFolder\\{family}!App")));
    let result = powershell(&script, Duration::from_secs(15))?;
    if result["success"] != true {
        return Err(format!("desktop_launch_failed: {result}"));
    }
    observe_window(
        inventory,
        || std::thread::sleep(Duration::from_millis(500)),
        30,
    )
}

fn observe_window(
    mut inspect: impl FnMut() -> Result<Value, String>,
    mut pause: impl FnMut(),
    attempts: usize,
) -> Result<Value, String> {
    let mut failures = 0;
    let mut last_error = None;
    for attempt in 0..attempts {
        match inspect() {
            Ok(mut inv) if inv["windows"].as_array().is_some_and(|a| !a.is_empty()) => {
                if failures > 0 {
                    inv["observationWarnings"] =
                        json!({"failedPolls":failures,"lastError":last_error});
                }
                return Ok(inv);
            }
            Ok(_) => {}
            // App activation can race a package/process query. Observe again, never relaunch.
            Err(error) => {
                failures += 1;
                last_error = Some(error);
            }
        }
        if attempt + 1 < attempts {
            pause();
        }
    }
    Err(format!(
        "desktop_window_not_observed: {}",
        json!({"failedPolls":failures,"lastInventoryError":last_error,
        "_hint":"Activation was sent once. Check codex_status and the app window before retrying open."})
    ))
}
pub fn auth_import(path: &Path) -> Result<Value, String> {
    auth_import_to(path, &home())
}
fn auth_import_to(path: &Path, dir: &Path) -> Result<Value, String> {
    use std::io::Write;
    if !path.is_absolute() {
        return Err("auth_source_must_be_absolute".into());
    }
    if fs::metadata(path)
        .map_err(|_| "auth_source_unreadable")?
        .len()
        > 128 * 1024
    {
        return Err("auth_source_too_large".into());
    }
    let bytes = fs::read(path).map_err(|_| "auth_source_unreadable")?;
    if bytes.len() > 128 * 1024 {
        return Err("auth_source_too_large".into());
    }
    let auth: Value = serde_json::from_slice(&bytes).map_err(|_| "invalid_auth_json")?;
    if !auth["tokens"]["access_token"].is_string() && !auth["OPENAI_API_KEY"].is_string() {
        return Err("unsupported_auth_cache".into());
    }
    fs::create_dir_all(dir).map_err(|_| "auth_directory_failed")?;
    let dest = dir.join("auth.json");
    let mut options = fs::OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }
    let mut file = options
        .open(&dest)
        .map_err(|_| "auth_destination_exists_or_unwritable")?;
    if cfg!(windows) {
        let secured=powershell(&format!("$ErrorActionPreference='Stop'; $acl=Get-Acl {}; $acl.SetAccessRuleProtection($true,$false); $rule=New-Object System.Security.AccessControl.FileSystemAccessRule([System.Security.Principal.WindowsIdentity]::GetCurrent().Name,'FullControl','Allow'); $acl.SetAccessRule($rule); Set-Acl -Path {} -AclObject $acl",ps_quote(&dest.to_string_lossy()),ps_quote(&dest.to_string_lossy())),Duration::from_secs(10))?;
        if secured["success"] != true {
            drop(file);
            let _ = fs::remove_file(&dest);
            return Err("auth_permissions_failed".into());
        }
    }
    if file
        .write_all(&bytes)
        .and_then(|_| file.sync_all())
        .is_err()
    {
        drop(file);
        let _ = fs::remove_file(&dest);
        return Err("auth_write_failed".into());
    }
    Ok(
        json!({"imported":true,"credentialValuesReturned":false,"destination":dest,
        "_hint":"Call codex_account_read to verify authenticated identity. Existing destination credentials are never overwritten."}),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn installer_diagnostics_survive_verification_failure() {
        let installer = json!({"exitCode":0,"args":install_args(true),"stdout":"Installed"});
        let result = install_result(
            true,
            installer.clone(),
            Err("desktop_inventory_failed: timed out".into()),
        );
        assert_eq!(result["errorCode"], "install_verification_failed");
        assert_eq!(result["installer"], installer);
    }
    #[test]
    fn unrelated_install_errors_are_not_reported_as_missing_consent() {
        for code in [-1978335162, -1978335167] {
            assert_eq!(
                install_result(
                    false,
                    json!({"exitCode":code}),
                    Ok(json!({"installed":false}))
                )["errorCode"],
                "blocked_on_user"
            );
        }
        for installer in [json!({"exitCode":1}), json!({"error":"winget unavailable"})] {
            assert_eq!(
                install_result(false, installer, Ok(json!({"installed":false})))["errorCode"],
                "install_failed"
            );
        }
    }
    #[test]
    fn launch_observation_recovers_after_transient_inventory_error() {
        let mut results = [
            Err("desktop_inventory_failed: transient".into()),
            Ok(json!({"windows":[]})),
            Ok(json!({"windows":[{"hwnd":42,"title":"Sign in"}]})),
        ]
        .into_iter();
        let mut pauses = 0;
        let result = observe_window(|| results.next().unwrap(), || pauses += 1, 3).unwrap();
        assert_eq!(result["windows"][0]["hwnd"], 42);
        assert_eq!(result["observationWarnings"]["failedPolls"], 1);
        assert_eq!(pauses, 2);
    }
    #[test]
    fn launch_observation_failure_is_bounded_and_preserves_diagnostics() {
        let mut calls = 0;
        let error = observe_window(
            || {
                calls += 1;
                Err("desktop_inventory_failed: test detail".into())
            },
            || {},
            3,
        )
        .unwrap_err();
        assert_eq!(calls, 3);
        assert!(error.starts_with("desktop_window_not_observed:"));
        assert!(error.contains("test detail"));
        assert!(observe_window(|| Ok(json!({"windows":[]})), || {}, 2).is_err());
    }
    #[test]
    fn install_requires_consent_for_both_agreement_flags() {
        let args = install_args(false);
        assert!(!args.iter().any(|arg| arg.starts_with("--accept-")));
        let accepted = install_args(true);
        assert_eq!(&accepted[..args.len()], args);
        assert_eq!(
            &accepted[args.len()..],
            ["--accept-source-agreements", "--accept-package-agreements"]
        );
        assert!(args.contains(&"--disable-interactivity"));
        assert!(args.windows(2).any(|pair| pair == ["--id", STORE_ID]));
        assert!(args.windows(2).any(|pair| pair == ["--source", "msstore"]));
    }
    #[test]
    fn import_preserves_existing_credentials_and_returns_no_values() {
        let root = env::temp_dir().join(format!("codex-auth-test-{}", uuid::Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();
        let source = root.join("source.json");
        let dest = root.join("home");
        fs::write(
            &source,
            br#"{"tokens":{"access_token":"test-only-placeholder"}}"#,
        )
        .unwrap();
        let result = auth_import_to(&source, &dest).unwrap();
        assert!(!result.to_string().contains("test-only-placeholder"));
        assert!(auth_import_to(&source, &dest).is_err());
        assert_eq!(
            fs::read(&source).unwrap(),
            fs::read(dest.join("auth.json")).unwrap()
        );
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            assert_eq!(
                fs::metadata(dest.join("auth.json"))
                    .unwrap()
                    .permissions()
                    .mode()
                    & 0o777,
                0o600
            );
        }
        fs::remove_dir_all(root).unwrap();
    }
}