app
Codex
Public Made by Adomby adom
Codex in Adom Hydrogen: ecosystem setup, dock dashboard and live engineering demos.
main
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);
}
}