main
John Lauer Add pre-UAC toast guidance and verify completed Windows setup c5a1b54 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
mod app_tools;
mod desktop;
mod process;
mod rpc;

use serde::Deserialize;
use serde_json::{json, Value};
use std::{
    collections::HashMap,
    env,
    io::Read,
    path::Path,
    sync::{Arc, Mutex},
    thread,
    time::{Duration, Instant},
};
use tiny_http::{Header, Method, Request, Response, Server};

const VERSION: &str = env!("CARGO_PKG_VERSION");
const CATALOG: &str = include_str!("../verbs.json");
#[derive(Deserialize)]
struct Command {
    command: String,
    #[serde(default)]
    args: Value,
}
#[derive(Clone, Default)]
struct Caller {
    headers: HashMap<String, String>,
    thread: String,
}
struct Job {
    owner: String,
    verb: String,
    started: Instant,
    result: Option<Value>,
}
struct State {
    rpc: Mutex<Option<Arc<rpc::Rpc>>>,
    owner: Mutex<Option<String>>,
    jobs: Mutex<HashMap<String, Job>>,
    mutation: Mutex<()>,
}

fn failure(code: &str) -> Value {
    json!({"success":false,"errorCode":code.split(':').next().unwrap_or(code),"error":code,
        "_hint":"Inspect codex_status and codex_describe. After a timeout, poll the existing job or events before retrying a mutation."})
}
fn finish(mut result: Value) -> Value {
    if !result.is_object() {
        result = json!({"result":result});
    }
    let obj = result.as_object_mut().unwrap();
    obj.entry("success").or_insert(json!(true));
    obj.entry("_hint").or_insert(json!("Use codex_describe for input schemas; codex_events and codex_requests expose progress and requests waiting for a reply."));
    obj.insert("bridge".into(), json!("codex"));
    obj.entry("version").or_insert(json!(VERSION));
    obj.insert("bridgeVersion".into(), json!(VERSION));
    redact(&mut result);
    result
}
fn setup_guidance(mut result: Value) -> Value {
    let hwnd = result["desktop"]["windows"][0]["hwnd"]
        .as_u64()
        .or_else(|| result["windows"][0]["hwnd"].as_u64());
    result["_setupHint"] = json!("Before clicking Finish setup or triggering any Codex setup step expected to request Windows elevation, send the _beforeUac AB toast on the SAME target and with the SAME caller identity. Tell the user in chat too. Send it BEFORE the secure-desktop prompt opens, not afterward. A toast acknowledgment is not UAC consent or proof of banner visibility; wait for the user to approve the native Windows prompt and verify setup completion. Do not send an upcoming-UAC warning for a read-only check or an already-completed setup.");
    result["_beforeUac"] = json!({"command":"notify_user","args":{
        "title":"Codex setup needs your approval",
        "body":"A Windows administrator (UAC) prompt is coming for Codex setup. Please approve the native Windows prompt for OpenAI's Codex/ChatGPT app to continue. This notification cannot approve it.",
        "level":"warning","durationLong":true,
        "focus":hwnd.map(|hwnd|json!({"hwnd":hwnd})).unwrap_or(json!({"app":"ChatGPT"}))
    }});
    result
}
fn redact(v: &mut Value) {
    match v {
        Value::Object(map) => {
            for (key, value) in map {
                let normalized = key.replace('_', "").to_ascii_lowercase();
                if [
                    "accesstoken",
                    "refreshtoken",
                    "idtoken",
                    "apikey",
                    "openaiapikey",
                    "authorization",
                    "bearertoken",
                    "password",
                    "clientsecret",
                    "httpheaders",
                    "env",
                ]
                .contains(&normalized.as_str())
                    && !value.is_boolean()
                {
                    *value = json!("[REDACTED]");
                } else {
                    redact(value);
                }
            }
        }
        Value::Array(a) => a.iter_mut().for_each(redact),
        _ => {}
    }
}
fn ab(command: &str, args: Value, caller: &Caller) -> Result<Value, String> {
    let base = env::var("ADOM_DIRECT_API_URL").map_err(|_| "ab_callback_not_configured")?;
    let url = ureq::AgentBuilder::new()
        .timeout(Duration::from_secs(35))
        .build();
    let mut req = url
        .post(&format!("{}/command", base.trim_end_matches('/')))
        .set("Content-Type", "application/json");
    for (k, v) in &caller.headers {
        req = req.set(k, v);
    }
    req = req.set("X-Adom-Caller-Delegate", "codex");
    if let Ok(token) = env::var("ADOM_BRIDGE_TOKEN") {
        req = req.set("X-Adom-Bridge-Token", &token);
    }
    let raw: Value = req
        .send_json(json!({"command":command,"args":args}))
        .map_err(|_| "ab_callback_failed")?
        .into_json()
        .map_err(|_| "ab_invalid_json")?;
    normalize(raw)
}
fn normalize(mut v: Value) -> Result<Value, String> {
    for _ in 0..4 {
        if v["success"] == false || v["ok"] == false || v["status"] == "error" {
            return Err(format!(
                "ab_error: {}",
                v.get("errorCode")
                    .or(v.get("error"))
                    .unwrap_or(&json!("unknown"))
            ));
        }
        if let Some(output) = v["output"].as_str() {
            if let Ok(inner) = serde_json::from_str(output) {
                v = inner;
                continue;
            }
        }
        break;
    }
    Ok(v)
}
impl State {
    fn backend(&self) -> Result<Arc<rpc::Rpc>, String> {
        self.rpc
            .lock()
            .unwrap()
            .as_ref()
            .filter(|r| r.alive())
            .cloned()
            .ok_or("not_connected: call codex_connect".into())
    }
    fn check_owner(&self, caller: &Caller) -> Result<(), String> {
        if let Some(owner) = self.owner.lock().unwrap().as_ref() {
            if owner != &caller.thread {
                return Err(format!("session_owned_by_other_thread: {owner}"));
            }
        }
        Ok(())
    }
    fn dispatch(&self, command: &str, mut args: Value, caller: &Caller) -> Result<Value, String> {
        match command {
            "codex_describe" => Ok(setup_guidance(serde_json::from_str(CATALOG).unwrap())),
            "codex_status" | "codex_readiness" => {
                let backend = self.rpc.lock().unwrap();
                Ok(setup_guidance(
                    json!({"desktop":desktop::inventory()?,"connected":backend.as_ref().is_some_and(|r|r.alive()),
                    "transportMode":backend.as_ref().map(|r|&r.mode),"ownerThread":*self.owner.lock().unwrap(),
                    "authCachePresent":desktop::home().join("auth.json").is_file(),"codexHome":desktop::home()}),
                ))
            }
            "codex_progress" => {
                let jobs = self.jobs.lock().unwrap();
                let Some(id) = args["jobId"].as_str() else {
                    return Ok(
                        json!({"jobs":jobs.iter().filter(|(_,j)|j.owner==caller.thread).map(|(id,j)|json!({"jobId":id,"command":j.verb,"stillRunning":j.result.is_none(),"elapsedSec":j.started.elapsed().as_secs_f64()})).collect::<Vec<_>>(),"_hint":"Pass a jobId to retrieve its result. Jobs are retained for one hour in this bridge process."}),
                    );
                };
                let j = jobs.get(id).ok_or("job_not_found")?;
                if j.owner != caller.thread {
                    return Err(format!("job_owned_by_other_thread: {}", j.owner));
                }
                Ok(
                    json!({"jobId":id,"command":j.verb,"ownerThread":j.owner,"elapsedSec":j.started.elapsed().as_secs_f64(),
                    "stillRunning":j.result.is_none(),"state":if j.result.is_none(){"running"}else{"complete"},"result":j.result}),
                )
            }
            "codex_desktop_install" => {
                desktop::install(args["acceptAgreements"] == true).map(setup_guidance)
            }
            "codex_desktop_uninstall" => {
                self.check_owner(caller)?;
                if args["confirm"] != true {
                    return Err("confirm_required: pass confirm:true for the explicitly requested desktop removal".into());
                }
                if self.backend().is_ok() {
                    return Err("disconnect_before_desktop_uninstall".into());
                }
                desktop::uninstall()
            }
            "codex_desktop_open" => desktop::launch().map(setup_guidance),
            "codex_state" => {
                let inventory = desktop::inventory()?;
                let mut shots = Vec::new();
                for window in inventory["windows"].as_array().unwrap_or(&vec![]) {
                    shots.push(json!({"window":window,"capture":ab("desktop_screenshot_window",json!({"hwnd":window["hwnd"],"format":"base64"}),caller)?}));
                }
                Ok(
                    json!({"desktop":inventory,"shots":shots,"focusEvents":[],"windowEtiquette":"background"}),
                )
            }
            "codex_connect" => {
                self.check_owner(caller)?;
                if let Ok(r) = self.backend() {
                    return Ok(
                        json!({"connected":true,"mode":r.mode,"ownerThread":*self.owner.lock().unwrap()}),
                    );
                }
                let exe = desktop::executable()?;
                let proxy = args["mode"].as_str().unwrap_or("standalone") == "desktop";
                if proxy && cfg!(windows) {
                    return Err("daemon_proxy_unsupported_on_windows: use standalone for backend RPC; codex_desktop_tools and codex_desktop_tool_call communicate directly with the desktop".into());
                }
                let r = rpc::Rpc::start(&exe, proxy)?;
                *self.rpc.lock().unwrap() = Some(r.clone());
                *self.owner.lock().unwrap() = Some(caller.thread.clone());
                Ok(
                    json!({"connected":true,"mode":r.mode,"executable":exe,"ownerThread":caller.thread,
                    "_hint":"desktop-daemon-proxy attaches to the native daemon. bridge-owned-app-server is a separate backend using the same local Codex home; it does not prove a desktop window changed."}),
                )
            }
            "codex_disconnect" | "codex_uninstall" => {
                if !(command == "codex_uninstall" && args["cascade"] == true) {
                    self.check_owner(caller)?;
                    if self.backend().is_ok_and(|r| r.busy()) {
                        return Err("active_turn_or_request: finish or interrupt the active turn before disconnecting".into());
                    }
                }
                if let Some(r) = self.rpc.lock().unwrap().take() {
                    r.stop();
                }
                *self.owner.lock().unwrap() = None;
                Ok(
                    json!({"disconnected":true,"preservedDesktop":true,"preservedCodexHome":true,
                    "_hint":"Only the bridge connection was stopped. AB removes its bridge cache. Desktop removal is a separate explicit codex_desktop_uninstall operation."}),
                )
            }
            "codex_auth_import" => {
                self.check_owner(caller)?;
                if self.backend().is_ok() {
                    return Err("disconnect_before_auth_import".into());
                }
                desktop::auth_import(Path::new(args["path"].as_str().ok_or("path_required")?))
            }
            "codex_events" => {
                self.check_owner(caller)?;
                Ok(self
                    .backend()?
                    .events(args["sinceSeq"].as_u64().unwrap_or(0)))
            }
            "codex_requests" => {
                self.check_owner(caller)?;
                Ok(json!({"requests":self.backend()?.requests()}))
            }
            "codex_request_reply" => {
                self.check_owner(caller)?;
                self.backend()?
                    .reply(args["requestId"].clone(), args["result"].clone())
            }
            "codex_desktop_tools" => app_tools::request(
                "tools/list",
                json!({"threadStartKind":"all"}),
                args["desktopPipe"].as_str(),
            ),
            "codex_desktop_tool_call" => {
                self.check_owner(caller)?;
                let name = args["tool"].as_str().ok_or("tool_required")?;
                let list = app_tools::request(
                    "tools/list",
                    json!({"threadStartKind":"all"}),
                    args["desktopPipe"].as_str(),
                )?;
                let tool = list["result"]["tools"]
                    .as_array()
                    .ok_or("invalid_desktop_catalog")?
                    .iter()
                    .find(|t| t["name"] == name)
                    .ok_or("unknown_desktop_tool")?;
                let thread_id = args["threadId"]
                    .as_str()
                    .ok_or("native_threadId_required")?;
                let result = app_tools::request(
                    "tools/call",
                    json!({"namespace":tool["namespace"],"tool":name,
                    "arguments":args.get("arguments").cloned().unwrap_or(json!({})),"threadId":thread_id,
                    "turnId":args.get("turnId").cloned().unwrap_or(json!(format!("adom-{}",uuid::Uuid::new_v4()))),
                    "callId":format!("adom-{}",uuid::Uuid::new_v4())}),
                    list["desktopPipe"].as_str(),
                )?;
                if result["result"]["success"] == false {
                    return Ok(
                        json!({"success":false,"errorCode":"native_tool_failed","native":result}),
                    );
                }
                Ok(result)
            }
            _ => {
                self.check_owner(caller)?;
                if command == "codex_login_start"
                    && !matches!(args["type"].as_str(), Some("chatgpt" | "chatgptDeviceCode"))
                {
                    return Err("unsupported_login_type".into());
                }
                let method = rpc_method(command).ok_or("unknown_command")?;
                let query = args["query"].as_str().unwrap_or("").to_ascii_lowercase();
                let offset = args["cursor"]
                    .as_str()
                    .and_then(|v| v.parse::<usize>().ok())
                    .unwrap_or(0);
                let limit = args["limit"].as_u64().unwrap_or(30).clamp(1, 100) as usize;
                if command == "codex_plugin_list" {
                    if let Some(obj) = args.as_object_mut() {
                        obj.remove("query");
                        obj.remove("cursor");
                        obj.remove("limit");
                    }
                }
                let result = self
                    .backend()?
                    .call(method, args, Duration::from_secs(120))?;
                if command == "codex_plugin_list" {
                    return Ok(
                        json!({"result":plugin_page(result,&query,offset,limit),"method":method}),
                    );
                }
                Ok(json!({"result":result,"method":method}))
            }
        }
    }
}
fn rpc_method(verb: &str) -> Option<&'static str> {
    Some(match verb {
        "codex_account_read" => "account/read",
        "codex_login_start" => "account/login/start",
        "codex_login_cancel" => "account/login/cancel",
        "codex_models" => "model/list",
        "codex_thread_list" => "thread/list",
        "codex_thread_read" => "thread/read",
        "codex_thread_start" => "thread/start",
        "codex_thread_resume" => "thread/resume",
        "codex_thread_fork" => "thread/fork",
        "codex_thread_archive" => "thread/archive",
        "codex_thread_unsubscribe" => "thread/unsubscribe",
        "codex_thread_turns" => "thread/turns/list",
        "codex_thread_items" => "thread/items/list",
        "codex_turn_start" => "turn/start",
        "codex_turn_steer" => "turn/steer",
        "codex_turn_interrupt" => "turn/interrupt",
        "codex_plugin_list" => "plugin/list",
        "codex_plugin_read" => "plugin/read",
        "codex_plugin_install" => "plugin/install",
        "codex_plugin_uninstall" => "plugin/uninstall",
        "codex_skills_list" => "skills/list",
        "codex_mcp_status" => "mcpServerStatus/list",
        "codex_config_read" => "config/read",
        _ => return None,
    })
}
fn plugin_page(result: Value, query: &str, offset: usize, limit: usize) -> Value {
    let mut plugins = Vec::new();
    for marketplace in result["marketplaces"].as_array().unwrap_or(&vec![]) {
        for p in marketplace["plugins"].as_array().unwrap_or(&vec![]) {
            let searchable = format!(
                "{} {} {} {}",
                p["name"],
                p["id"],
                p["interface"]["displayName"],
                p["interface"]["shortDescription"]
            )
            .to_ascii_lowercase();
            if !searchable.contains(query) {
                continue;
            }
            plugins.push(json!({"name":p["name"],"id":p["id"],"installed":p["installed"],"enabled":p["enabled"],
                "availability":p["availability"],"marketplaceName":marketplace["name"],"marketplacePath":marketplace["path"]}));
        }
    }
    let total = plugins.len();
    let next =
        (offset.saturating_add(limit) < total).then(|| offset.saturating_add(limit).to_string());
    json!({"plugins":plugins.into_iter().skip(offset).take(limit).collect::<Vec<_>>(),"total":total,"nextCursor":next,"marketplaceLoadErrors":result["marketplaceLoadErrors"]})
}
fn is_job(verb: &str) -> bool {
    matches!(
        verb,
        "codex_desktop_install"
            | "codex_desktop_uninstall"
            | "codex_desktop_open"
            | "codex_plugin_install"
            | "codex_plugin_uninstall"
            | "codex_login_start"
            | "codex_connect"
            | "codex_desktop_tool_call"
    )
}
fn main() {
    let args: Vec<String> = env::args().collect();
    if args.iter().any(|a| a == "--version") {
        println!("codex-bridge {VERSION}");
        return;
    }
    let port = args
        .windows(2)
        .find(|a| a[0] == "--port")
        .map(|a| a[1].parse::<u16>().expect("valid port"))
        .unwrap_or(0);
    let host = env::var("ADOM_BIND_HOST").unwrap_or("127.0.0.1".into());
    let ip: std::net::IpAddr = host.parse().expect("ADOM_BIND_HOST must be an IP address");
    assert!(ip.is_loopback(), "bridge must bind loopback");
    let server = Server::http((host.as_str(), port)).expect("bind bridge");
    eprintln!("codex-bridge {VERSION} listening {}", server.server_addr());
    let state = Arc::new(State {
        rpc: Mutex::new(None),
        owner: Mutex::new(None),
        jobs: Mutex::new(HashMap::new()),
        mutation: Mutex::new(()),
    });
    for request in server.incoming_requests() {
        let state = state.clone();
        thread::spawn(move || serve(request, state));
    }
}
fn serve(mut req: Request, state: Arc<State>) {
    let send = |req: Request, status: u16, v: Value| {
        let response = Response::from_string(finish(v).to_string())
            .with_status_code(status)
            .with_header(Header::from_bytes("Content-Type", "application/json").unwrap());
        let _ = req.respond(response);
    };
    if req.method() == &Method::Get && matches!(req.url(), "/health" | "/status") {
        send(
            req,
            200,
            json!({"led":"green","summary":"Rust bridge ready"}),
        );
        return;
    }
    if req.method() != &Method::Post || req.url() != "/command" {
        send(req, 404, failure("not_found"));
        return;
    }
    if req.headers().iter().any(|h| h.field.equiv("Origin")) {
        send(req, 403, failure("browser_origin_refused"));
        return;
    }
    let mut caller = Caller::default();
    for header in req.headers() {
        let name = header.field.as_str().as_str().to_ascii_lowercase();
        if name.starts_with("x-adom-caller-") {
            caller.headers.insert(name, header.value.as_str().into());
        }
    }
    caller.thread = caller
        .headers
        .get("x-adom-caller-thread")
        .cloned()
        .unwrap_or_default();
    let mut raw = String::new();
    if req
        .as_reader()
        .take(1_048_577)
        .read_to_string(&mut raw)
        .is_err()
        || raw.len() > 1_048_576
    {
        send(req, 400, failure("body_too_large_or_invalid"));
        return;
    }
    let command: Command = match serde_json::from_str(&raw) {
        Ok(c) => c,
        Err(_) => {
            send(req, 400, failure("invalid_json"));
            return;
        }
    };
    if caller.thread.trim().is_empty() {
        caller.thread = command.args["caller"]["aiThread"]
            .as_str()
            .unwrap_or("")
            .to_string();
        caller
            .headers
            .insert("x-adom-caller-thread".into(), caller.thread.clone());
    }
    if caller.thread.trim().is_empty() {
        send(req, 400, failure("caller_identity_required"));
        return;
    }
    eprintln!("[verb] {} caller={:?}", command.command, caller.thread);
    if is_job(&command.command) {
        let mut jobs = state.jobs.lock().unwrap();
        if let Some((id, _)) = jobs
            .iter()
            .find(|(_, j)| j.result.is_none() && j.verb == command.command)
        {
            send(
                req,
                200,
                json!({"success":false,"errorCode":"operation_in_progress","jobId":id,"stillRunning":true,"_hint":"Poll codex_progress with the existing jobId."}),
            );
            return;
        }
        jobs.retain(|_, j| j.result.is_none() || j.started.elapsed() < Duration::from_secs(3600));
        if jobs.len() >= 100 {
            send(req, 429, failure("job_limit"));
            return;
        }
        let id = uuid::Uuid::new_v4().to_string();
        jobs.insert(
            id.clone(),
            Job {
                owner: caller.thread.clone(),
                verb: command.command.clone(),
                started: Instant::now(),
                result: None,
            },
        );
        drop(jobs);
        let worker = state.clone();
        let job_id = id.clone();
        thread::spawn(move || {
            let _guard = worker.mutation.lock().unwrap();
            let result = worker
                .dispatch(&command.command, command.args, &caller)
                .unwrap_or_else(|e| failure(&e));
            if let Some(job) = worker.jobs.lock().unwrap().get_mut(&job_id) {
                job.result = Some(finish(result));
            }
        });
        send(
            req,
            200,
            json!({"jobId":id,"stillRunning":true,"state":"running","_next":["codex_progress"],"_hint":"Poll codex_progress with this jobId. Do not resubmit the operation."}),
        );
    } else {
        let _guard = if matches!(
            command.command.as_str(),
            "codex_disconnect" | "codex_uninstall" | "codex_auth_import"
        ) {
            match state.mutation.try_lock() {
                Ok(g) => Some(g),
                Err(_) => {
                    send(req,200,failure("operation_in_progress: poll codex_progress before changing connection or credentials"));
                    return;
                }
            }
        } else {
            None
        };
        let result = state
            .dispatch(&command.command, command.args, &caller)
            .unwrap_or_else(|e| failure(&e));
        send(req, 200, result);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn setup_hints_require_toast_before_uac_without_claiming_consent() {
        let failure = setup_guidance(json!({"success":false,"desktop":{"windows":[{"hwnd":123}]}}));
        assert_eq!(failure["success"], false);
        assert_eq!(failure["_beforeUac"]["command"], "notify_user");
        assert_eq!(failure["_beforeUac"]["args"]["focus"]["hwnd"], 123);
        assert!(failure["_setupHint"].as_str().unwrap().contains("BEFORE"));
        assert!(failure["_setupHint"]
            .as_str()
            .unwrap()
            .contains("not UAC consent"));
        assert!(failure["_beforeUac"]["args"].get("onClick").is_none());
        assert_eq!(
            setup_guidance(json!({}))["_beforeUac"]["args"]["focus"]["app"],
            "ChatGPT"
        );
        assert_eq!(
            setup_guidance(json!({"windows":[{"hwnd":456}]}))["_beforeUac"]["args"]["focus"]
                ["hwnd"],
            456
        );
    }
    #[test]
    fn credential_fields_are_redacted_recursively() {
        let mut v = json!({"nested":[{"access_token":"secret","refreshToken":"secret","id_token":"secret"}],"account":{"type":"chatgpt"}});
        redact(&mut v);
        assert!(!v.to_string().contains("secret"));
        assert_eq!(v["account"]["type"], "chatgpt");
    }
    #[test]
    fn semantic_failure_in_nested_ab_response_is_not_success() {
        assert!(normalize(
            json!({"success":true,"output":"{\"success\":false,\"errorCode\":\"blocked\"}"})
        )
        .is_err());
    }
    #[test]
    fn no_arbitrary_rpc_or_automatic_logout() {
        assert_eq!(rpc_method("account/logout"), None);
        assert_eq!(rpc_method("codex_logout"), None);
        assert_eq!(rpc_method("codex_plugin_install"), Some("plugin/install"));
    }
    #[test]
    fn all_catalog_commands_route() {
        let v: Value = serde_json::from_str(CATALOG).unwrap();
        for verb in v["verbs"].as_array().unwrap() {
            let name = verb["name"].as_str().unwrap();
            assert!(
                rpc_method(name).is_some()
                    || matches!(
                        name,
                        "codex_describe"
                            | "codex_status"
                            | "codex_readiness"
                            | "codex_state"
                            | "codex_progress"
                            | "codex_desktop_install"
                            | "codex_desktop_uninstall"
                            | "codex_desktop_open"
                            | "codex_connect"
                            | "codex_disconnect"
                            | "codex_uninstall"
                            | "codex_auth_import"
                            | "codex_events"
                            | "codex_requests"
                            | "codex_request_reply"
                            | "codex_desktop_tools"
                            | "codex_desktop_tool_call"
                    ),
                "Unrouted {name}"
            );
        }
        let manifest: Value = serde_json::from_str(include_str!("../bridge.json")).unwrap();
        let catalog: std::collections::HashSet<_> = v["verbs"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v["name"].as_str().unwrap())
            .collect();
        let declared: std::collections::HashSet<_> = manifest["verbs"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap())
            .collect();
        assert_eq!(catalog, declared);
    }
    #[test]
    fn plugin_pages_are_bounded_and_filter_before_pagination() {
        let r = json!({"marketplaces":[{"name":"test","path":"/marketplace.json","plugins":[{"name":"alpha","id":"alpha@test"},{"name":"beta","id":"beta@test"},{"name":"alphabet","id":"alphabet@test"}]}]});
        let p = plugin_page(r, "alpha", 0, 1);
        assert_eq!(p["total"], 2);
        assert_eq!(p["plugins"].as_array().unwrap().len(), 1);
        assert_eq!(p["nextCursor"], "1");
    }
    #[test]
    fn desktop_version_is_not_overwritten_by_bridge_version() {
        let r = finish(json!({"version":"26.901.6511.0"}));
        assert_eq!(r["version"], "26.901.6511.0");
        assert_eq!(r["bridgeVersion"], VERSION);
    }
}