//! Focused JSON-RPC E2E coverage for Worker B domains: //! inference, agent, tools, tool_registry, and approval. //! //! These tests boot the real Axum JSON-RPC router over HTTP and exercise //! deterministic controller paths. External-service paths are asserted at //! validation/config boundaries so the suite stays hermetic. use std::net::SocketAddr; use std::path::Path; use std::sync::{Mutex, OnceLock}; use std::time::Duration; use axum::http::header::AUTHORIZATION; use reqwest::StatusCode; use serde_json::{json, Value}; use tempfile::{tempdir, TempDir}; use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR}; use openhuman_core::core::jsonrpc::build_core_http_router; const TEST_RPC_TOKEN: &str = "worker-b-domain-e2e-token"; static AUTH_INIT: OnceLock<()> = OnceLock::new(); static ENV_LOCK: OnceLock> = OnceLock::new(); struct EnvVarGuard { key: &'static str, old: Option, } impl EnvVarGuard { fn set_to_path(key: &'static str, path: &Path) -> Self { let old = std::env::var(key).ok(); std::env::set_var(key, path.as_os_str()); Self { key, old } } fn set(key: &'static str, value: &str) -> Self { let old = std::env::var(key).ok(); std::env::set_var(key, value); Self { key, old } } fn unset(key: &'static str) -> Self { let old = std::env::var(key).ok(); std::env::remove_var(key); Self { key, old } } } impl Drop for EnvVarGuard { fn drop(&mut self) { match &self.old { Some(value) => std::env::set_var(self.key, value), None => std::env::remove_var(self.key), } } } fn env_lock() -> std::sync::MutexGuard<'static, ()> { let mutex = ENV_LOCK.get_or_init(|| Mutex::new(())); match mutex.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), } } fn ensure_rpc_auth() { AUTH_INIT.get_or_init(|| { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN); let token_dir = std::env::temp_dir().join("openhuman-worker-b-domain-e2e-auth"); init_rpc_token(&token_dir).expect("init rpc auth token"); }); } async fn serve_rpc() -> ( SocketAddr, tokio::task::JoinHandle>, ) { ensure_rpc_auth(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind rpc listener"); let addr = listener.local_addr().expect("rpc listener addr"); let router = build_core_http_router(false); let join = tokio::spawn(async move { axum::serve(listener, router).await }); (addr, join) } fn write_min_config(openhuman_dir: &Path) { std::fs::create_dir_all(openhuman_dir).expect("create .openhuman"); let cfg = r#"api_url = "http://127.0.0.1:9" default_model = "e2e-model" default_temperature = 0.2 [secrets] encrypt = false [local_ai] enabled = false [memory] provider = "none" embedding_provider = "none" embedding_model = "none" embedding_dimensions = 0 [memory_tree] embedding_strict = false "#; std::fs::write(openhuman_dir.join("config.toml"), cfg).expect("write config.toml"); let _: openhuman_core::openhuman::config::Config = toml::from_str(cfg).expect("test config must match schema"); } struct TestHarness { _tmp: TempDir, _guards: Vec, rpc_base: String, join: tokio::task::JoinHandle>, } async fn setup() -> TestHarness { let tmp = tempdir().expect("tempdir"); let home = tmp.path(); let openhuman_home = home.join(".openhuman"); write_min_config(&openhuman_home); let guards = vec![ EnvVarGuard::set_to_path("HOME", home), EnvVarGuard::unset("OPENHUMAN_WORKSPACE"), EnvVarGuard::unset("BACKEND_URL"), EnvVarGuard::unset("VITE_BACKEND_URL"), EnvVarGuard::unset("OPENHUMAN_API_URL"), EnvVarGuard::unset("OPENHUMAN_LOCAL_AI_TIER"), EnvVarGuard::unset("OPENHUMAN_LM_STUDIO_BASE_URL"), EnvVarGuard::unset("LM_STUDIO_BASE_URL"), EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"), EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"), EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""), EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""), ]; let _ = openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry::init_global_builtins(); let (addr, join) = serve_rpc().await; TestHarness { _tmp: tmp, _guards: guards, rpc_base: format!("http://{addr}"), join, } } async fn schema(rpc_base: &str) -> Value { let url = format!("{}/schema", rpc_base.trim_end_matches('/')); reqwest::get(&url) .await .unwrap_or_else(|err| panic!("GET {url}: {err}")) .json::() .await .expect("schema json") } async fn rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value { let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .expect("client"); let url = format!("{}/rpc", rpc_base.trim_end_matches('/')); let response = client .post(&url) .header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}")) .json(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params, })) .send() .await .unwrap_or_else(|err| panic!("POST {url} {method}: {err}")); assert_eq!( response.status(), StatusCode::OK, "HTTP transport should accept {method}" ); response .json::() .await .unwrap_or_else(|err| panic!("json for {method}: {err}")) } fn ok<'a>(value: &'a Value, context: &str) -> &'a Value { if let Some(error) = value.get("error") { panic!("{context}: unexpected JSON-RPC error: {error}"); } value .get("result") .unwrap_or_else(|| panic!("{context}: missing result: {value}")) } fn err<'a>(value: &'a Value, context: &str) -> &'a Value { value .get("error") .unwrap_or_else(|| panic!("{context}: expected JSON-RPC error, got: {value}")) } fn payload<'a>(value: &'a Value, context: &str) -> &'a Value { let result = ok(value, context); result.get("result").unwrap_or(result) } fn error_message<'a>(value: &'a Value, context: &str) -> &'a str { err(value, context) .get("message") .and_then(Value::as_str) .unwrap_or_else(|| panic!("{context}: error missing message: {value}")) } #[tokio::test] async fn worker_b_schema_catalog_exposes_all_controller_methods() { let _lock = env_lock(); let harness = setup().await; let catalog = schema(&harness.rpc_base).await; let methods = catalog .get("methods") .and_then(Value::as_array) .expect("schema methods array"); for expected in [ "openhuman.inference_status", "openhuman.inference_get_client_config", "openhuman.inference_update_model_settings", "openhuman.inference_update_local_settings", "openhuman.inference_list_models", "openhuman.inference_device_profile", "openhuman.inference_presets", "openhuman.inference_apply_preset", "openhuman.inference_diagnostics", "openhuman.inference_openai_oauth_start", "openhuman.inference_openai_oauth_complete", "openhuman.inference_openai_oauth_status", "openhuman.inference_openai_oauth_disconnect", "openhuman.inference_summarize", "openhuman.inference_prompt", "openhuman.inference_vision_prompt", "openhuman.inference_test_provider_model", "openhuman.inference_should_react", "openhuman.inference_analyze_sentiment", "openhuman.agent_chat", "openhuman.agent_chat_simple", "openhuman.agent_server_status", "openhuman.agent_list_definitions", "openhuman.agent_get_definition", "openhuman.agent_reload_definitions", "openhuman.agent_triage_evaluate", "openhuman.profiles_list", "openhuman.profiles_select", "openhuman.profiles_upsert", "openhuman.profiles_delete", "openhuman.tools_composio_execute", "openhuman.tools_web_search", "openhuman.tools_seltz_search", "openhuman.tools_querit_search", "openhuman.tools_searxng_search", "openhuman.tools_apify_linkedin_scrape", "openhuman.tools_polymarket_execute", "openhuman.tool_registry_list", "openhuman.tool_registry_get", "openhuman.tool_registry_diagnostics", "openhuman.approval_list_pending", "openhuman.approval_list_recent_decisions", "openhuman.approval_decide", ] { assert!( methods .iter() .any(|method| { method.get("method").and_then(Value::as_str) == Some(expected) }), "schema catalog must expose {expected}" ); } harness.join.abort(); } #[tokio::test] async fn inference_settings_oauth_and_validation_paths_are_reachable() { let _lock = env_lock(); let harness = setup().await; let update_model = rpc( &harness.rpc_base, 10_001, "openhuman.inference_update_model_settings", json!({ "default_model": "worker-b-model", "default_temperature": 0.4, "model_routes": [ { "hint": "chat", "model": "worker-b-model" } ], "cloud_providers": [ { "slug": "worker-b-cloud", "label": "Worker B Cloud", "endpoint": "http://127.0.0.1:9/v1", "auth_style": "none", "default_model": "worker-b-cloud-model" } ], "chat_provider": "worker-b-cloud" }), ) .await; ok(&update_model, "inference_update_model_settings"); let client_config = rpc( &harness.rpc_base, 10_002, "openhuman.inference_get_client_config", json!({}), ) .await; assert_eq!( payload(&client_config, "inference_get_client_config") .get("default_model") .and_then(Value::as_str), Some("worker-b-model") ); let bad_provider = rpc( &harness.rpc_base, 10_003, "openhuman.inference_update_model_settings", json!({ "cloud_providers": [ { "slug": "bad-auth-style", "endpoint": "http://127.0.0.1:9/v1", "auth_style": "cookie" } ] }), ) .await; assert!( error_message(&bad_provider, "bad provider auth style").contains("unknown auth_style"), "bad provider auth_style should fail before config write: {bad_provider}" ); let update_local = rpc( &harness.rpc_base, 10_004, "openhuman.inference_update_local_settings", json!({ "runtime_enabled": true, "opt_in_confirmed": true, "provider": "lm_studio", "base_url": "http://127.0.0.1:9/v1", "model_id": "worker-b-local", "chat_model_id": "worker-b-local" }), ) .await; assert_eq!( payload(&update_local, "inference_update_local_settings") .pointer("/config/local_ai/provider") .and_then(Value::as_str), Some("lm_studio") ); for (idx, (method, params, expected)) in [ ( "openhuman.inference_list_models", json!({ "provider_id": "missing-provider" }), "provider", ), ( "openhuman.inference_apply_preset", json!({ "tier": "not-a-tier" }), "invalid tier", ), ( "openhuman.inference_openai_oauth_complete", json!({ "callback_url": "http://localhost/callback?state=missing&code=nope" }), "no pending oauth session", ), ( "openhuman.inference_prompt", json!({}), "missing required param 'prompt'", ), ( "openhuman.inference_vision_prompt", json!({ "prompt": "describe", "image_refs": [] }), "image", ), ] .into_iter() .enumerate() { let response = rpc(&harness.rpc_base, 10_100 + idx as i64, method, params).await; let message = error_message(&response, method); assert!( message.to_ascii_lowercase().contains(expected), "{method} should fail deterministically with '{expected}', got {response}" ); } for (idx, method) in [ "openhuman.inference_status", "openhuman.inference_device_profile", "openhuman.inference_presets", "openhuman.inference_diagnostics", "openhuman.inference_openai_oauth_status", "openhuman.inference_openai_oauth_disconnect", ] .into_iter() .enumerate() { let response = rpc(&harness.rpc_base, 10_200 + idx as i64, method, json!({})).await; assert!( ok(&response, method).is_object(), "{method} should return an object payload: {response}" ); } harness.join.abort(); } #[tokio::test] async fn agent_definitions_profiles_and_validation_paths_are_reachable() { let _lock = env_lock(); let harness = setup().await; let definitions = rpc( &harness.rpc_base, 20_001, "openhuman.agent_list_definitions", json!({}), ) .await; let defs = ok(&definitions, "agent_list_definitions") .get("definitions") .and_then(Value::as_array) .expect("definitions array"); assert!( defs.iter() .any(|definition| definition.get("id").and_then(Value::as_str) == Some("orchestrator")), "built-in orchestrator definition should be listed: {definitions}" ); let orchestrator = rpc( &harness.rpc_base, 20_002, "openhuman.agent_get_definition", json!({ "id": "orchestrator" }), ) .await; assert_eq!( ok(&orchestrator, "agent_get_definition") .pointer("/definition/id") .and_then(Value::as_str), Some("orchestrator") ); let reload = rpc( &harness.rpc_base, 20_003, "openhuman.agent_reload_definitions", json!({}), ) .await; assert_eq!( ok(&reload, "agent_reload_definitions") .get("status") .and_then(Value::as_str), Some("noop") ); for (idx, (method, params, expected)) in [ ( "openhuman.agent_get_definition", json!({ "id": "missing-worker-b-agent" }), "not found", ), ( "openhuman.profiles_upsert", json!({ "profile": { "id": "bad-worker-b-profile", "name": "Bad Worker B", "description": "Exercise unknown agent validation", "agentId": "missing-worker-b-agent", "allowedTools": [], "builtIn": false } }), "not found", ), ( "openhuman.profiles_select", json!({ "profile_id": "missing-worker-b-profile" }), "not found", ), ( "openhuman.agent_chat", json!({}), "missing required param 'message'", ), ( "openhuman.agent_chat_simple", json!({}), "missing required param 'message'", ), ( "openhuman.agent_triage_evaluate", json!({ "source": "unsupported", "display_label": "Unsupported trigger", "payload": {} }), "unsupported trigger source", ), ] .into_iter() .enumerate() { let response = rpc(&harness.rpc_base, 20_100 + idx as i64, method, params).await; let message = error_message(&response, method); assert!( message.to_ascii_lowercase().contains(expected), "{method} should fail deterministically with '{expected}', got {response}" ); } let profiles = rpc( &harness.rpc_base, 20_200, "openhuman.profiles_list", json!({}), ) .await; assert_eq!( ok(&profiles, "profiles_list") .get("activeProfileId") .and_then(Value::as_str), Some("default") ); let status = rpc( &harness.rpc_base, 20_201, "openhuman.agent_server_status", json!({}), ) .await; assert!( ok(&status, "agent_server_status").is_object(), "agent_server_status should return an object: {status}" ); harness.join.abort(); } #[tokio::test] async fn tools_and_tool_registry_paths_are_reachable_without_live_services() { let _lock = env_lock(); let harness = setup().await; let registry = rpc( &harness.rpc_base, 30_001, "openhuman.tool_registry_list", json!({}), ) .await; let tools = ok(®istry, "tool_registry_list") .get("tools") .and_then(Value::as_array) .expect("tools array"); assert!( tools .iter() .any(|tool| tool.get("tool_id").and_then(Value::as_str) == Some("tools.web_search")), "tool registry should list JSON-RPC-backed tools.web_search: {registry}" ); let web_search_entry = rpc( &harness.rpc_base, 30_002, "openhuman.tool_registry_get", json!({ "tool_id": "tools.web_search" }), ) .await; assert_eq!( ok(&web_search_entry, "tool_registry_get") .get("tool_id") .and_then(Value::as_str), Some("tools.web_search") ); let diagnostics = rpc( &harness.rpc_base, 30_003, "openhuman.tool_registry_diagnostics", json!({}), ) .await; assert!( payload(&diagnostics, "tool_registry_diagnostics") .get("total_tools") .and_then(Value::as_u64) .is_some_and(|count| count > 0), "diagnostics should include non-zero total_tools: {diagnostics}" ); for (idx, (method, params, expected)) in [ ( "openhuman.tool_registry_get", json!({ "tool_id": "" }), "non-empty string", ), ( "openhuman.tool_registry_get", json!({ "tool_id": "missing.worker_b" }), "tool not found", ), ("openhuman.tools_composio_execute", json!({}), "action"), ( "openhuman.tools_web_search", json!({ "query": "worker b", "max_results": 1 }), "Sign in first", ), ( "openhuman.tools_seltz_search", json!({ "query": "worker b", "max_results": 1 }), "Seltz search is not enabled", ), ( "openhuman.tools_querit_search", json!({ "query": "worker b", "max_results": 1 }), "Querit search is not enabled", ), ( "openhuman.tools_searxng_search", json!({ "query": "worker b", "categories": ["general"] }), "SearXNG search is not enabled", ), ( "openhuman.tools_apify_linkedin_scrape", json!({ "profile_url": "https://www.linkedin.com/in/example" }), "Sign in first", ), ( "openhuman.tools_polymarket_execute", json!({ "action": "markets", "arguments": {} }), "disabled", ), ] .into_iter() .enumerate() { let response = rpc(&harness.rpc_base, 30_100 + idx as i64, method, params).await; let message = error_message(&response, method); assert!( message.contains(expected), "{method} should fail deterministically with '{expected}', got {response}" ); } harness.join.abort(); } #[tokio::test] async fn approval_read_and_decision_validation_paths_are_reachable() { let _lock = env_lock(); let harness = setup().await; let pending = rpc( &harness.rpc_base, 40_001, "openhuman.approval_list_pending", json!({}), ) .await; assert!( ok(&pending, "approval_list_pending").is_array(), "fresh approval pending list should be an array: {pending}" ); let recent = rpc( &harness.rpc_base, 40_002, "openhuman.approval_list_recent_decisions", json!({ "limit": 3 }), ) .await; assert!( ok(&recent, "approval_list_recent_decisions").is_array(), "fresh recent decisions list should be an array: {recent}" ); for (idx, (params, expected)) in [ (json!({ "limit": "3" }), "expected unsigned integer"), ( json!({ "request_id": "worker-b-request", "decision": "maybe" }), "invalid 'decision'", ), ( json!({ "decision": "deny" }), "missing required param 'request_id'", ), ( json!({ "request_id": "worker-b-request", "decision": "deny" }), "approval gate is not installed", ), ] .into_iter() .enumerate() { let method = if idx == 0 { "openhuman.approval_list_recent_decisions" } else { "openhuman.approval_decide" }; let response = rpc(&harness.rpc_base, 40_100 + idx as i64, method, params).await; let message = error_message(&response, method); assert!( message.contains(expected), "{method} should fail deterministically with '{expected}', got {response}" ); } harness.join.abort(); }