mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
888 lines
29 KiB
Rust
888 lines
29 KiB
Rust
//! Focused raw-line coverage for owned domains with local-only dependencies.
|
|
//!
|
|
//! This suite drives public APIs for the agent, inference, and composio slices
|
|
//! against temp directories and loopback Axum servers. It avoids live providers.
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use axum::extract::State;
|
|
use axum::http::{HeaderMap, StatusCode};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::{delete, get, post};
|
|
use axum::{Json, Router};
|
|
use serde_json::{json, Map, Value};
|
|
use tempfile::tempdir;
|
|
|
|
use openhuman_core::openhuman::agent::task_board::{
|
|
board_for_thread, TaskApprovalMode, TaskBoard, TaskBoardCard, TaskBoardStore, TaskCardStatus,
|
|
};
|
|
use openhuman_core::openhuman::composio::ComposioClient;
|
|
use openhuman_core::openhuman::config::{
|
|
CapabilityProviderConfig, CapabilityProviderTrustState, Config, McpServerConfig,
|
|
};
|
|
use openhuman_core::openhuman::inference::provider::compatible::{
|
|
AuthStyle, OpenAiCompatibleProvider,
|
|
};
|
|
use openhuman_core::openhuman::inference::provider::{
|
|
ChatMessage, ChatRequest, Provider, ProviderDelta,
|
|
};
|
|
use openhuman_core::openhuman::integrations::IntegrationClient;
|
|
use openhuman_core::openhuman::tool_registry::{
|
|
all_tool_registry_controller_schemas, all_tool_registry_registered_controllers,
|
|
capability_provider_by_id, capability_provider_diagnostics, get_tool,
|
|
is_capability_provider_trusted_enabled, list_capability_providers, list_tools,
|
|
normalize_capability_provider_id,
|
|
};
|
|
use openhuman_core::openhuman::tool_registry::{
|
|
denials as tool_registry_denials, ops as tool_registry_ops,
|
|
};
|
|
use openhuman_core::openhuman::tools::ToolSpec;
|
|
|
|
static OWNED_DOMAIN_ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
#[derive(Clone, Default)]
|
|
struct ProviderMockState {
|
|
chat_requests: Arc<Mutex<Vec<Value>>>,
|
|
response_requests: Arc<Mutex<Vec<Value>>>,
|
|
auth_headers: Arc<Mutex<Vec<Option<String>>>>,
|
|
user_agents: Arc<Mutex<Vec<Option<String>>>>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct ComposioMockState {
|
|
requests: Arc<Mutex<Vec<(String, String, Option<Value>, Option<String>)>>>,
|
|
}
|
|
|
|
async fn serve_provider_mock() -> (String, ProviderMockState) {
|
|
let state = ProviderMockState::default();
|
|
let app = Router::new()
|
|
.route("/v1/chat/completions", post(provider_chat))
|
|
.route("/v1/responses", post(provider_responses))
|
|
.with_state(state.clone());
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind provider mock");
|
|
let addr = listener.local_addr().expect("provider mock addr");
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app)
|
|
.await
|
|
.expect("provider mock serve");
|
|
});
|
|
(format!("http://{addr}/v1"), state)
|
|
}
|
|
|
|
async fn provider_chat(
|
|
State(state): State<ProviderMockState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<Value>,
|
|
) -> Response {
|
|
state
|
|
.auth_headers
|
|
.lock()
|
|
.expect("auth headers")
|
|
.push(header(&headers, "authorization"));
|
|
state
|
|
.user_agents
|
|
.lock()
|
|
.expect("user agents")
|
|
.push(header(&headers, "user-agent"));
|
|
state
|
|
.chat_requests
|
|
.lock()
|
|
.expect("chat requests")
|
|
.push(body.clone());
|
|
|
|
if body.pointer("/stream").and_then(Value::as_bool) == Some(true) {
|
|
return Json(json!({
|
|
"choices": [{
|
|
"message": {
|
|
"content": "stream fallback body",
|
|
"reasoning_content": "stream thinking"
|
|
}
|
|
}],
|
|
"usage": {
|
|
"prompt_tokens": 3,
|
|
"completion_tokens": 4,
|
|
"total_tokens": 7,
|
|
"prompt_tokens_details": { "cached_tokens": 2 }
|
|
}
|
|
}))
|
|
.into_response();
|
|
}
|
|
|
|
if body.get("tools").is_some() {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({ "error": "unknown parameter: tools" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
if body.pointer("/model").and_then(Value::as_str) == Some("missing-chat") {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({ "error": "chat completions unavailable" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
Json(json!({
|
|
"choices": [{
|
|
"message": {
|
|
"content": "<think>hidden</think>visible answer",
|
|
"reasoning_content": "model reasoning",
|
|
"tool_calls": [{
|
|
"id": "call-1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"arguments": { "query": "openhuman" }
|
|
}
|
|
}]
|
|
}
|
|
}],
|
|
"openhuman": {
|
|
"usage": {
|
|
"input_tokens": 11,
|
|
"output_tokens": 13,
|
|
"cached_input_tokens": 5
|
|
},
|
|
"billing": { "charged_amount_usd": 0.0123 }
|
|
}
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn provider_responses(
|
|
State(state): State<ProviderMockState>,
|
|
Json(body): Json<Value>,
|
|
) -> Response {
|
|
state
|
|
.response_requests
|
|
.lock()
|
|
.expect("response requests")
|
|
.push(body);
|
|
Json(json!({
|
|
"output_text": "responses fallback answer",
|
|
"output": []
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn serve_composio_mock() -> (String, ComposioMockState) {
|
|
let state = ComposioMockState::default();
|
|
let app = Router::new()
|
|
.route(
|
|
"/agent-integrations/composio/toolkits",
|
|
get(composio_toolkits),
|
|
)
|
|
.route("/agent-integrations/composio/tools", get(composio_tools))
|
|
.route(
|
|
"/agent-integrations/composio/authorize",
|
|
post(composio_authorize),
|
|
)
|
|
.route(
|
|
"/agent-integrations/composio/connections/{id}",
|
|
delete(composio_delete_connection),
|
|
)
|
|
.route(
|
|
"/agent-integrations/composio/triggers/available",
|
|
get(composio_available_triggers),
|
|
)
|
|
.with_state(state.clone());
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind composio mock");
|
|
let addr = listener.local_addr().expect("composio mock addr");
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app)
|
|
.await
|
|
.expect("composio mock serve");
|
|
});
|
|
(format!("http://{addr}"), state)
|
|
}
|
|
|
|
async fn composio_toolkits(State(state): State<ComposioMockState>, headers: HeaderMap) -> Response {
|
|
record_composio(
|
|
&state,
|
|
"GET",
|
|
"/agent-integrations/composio/toolkits",
|
|
None,
|
|
&headers,
|
|
);
|
|
Json(json!({ "success": true, "data": { "toolkits": ["gmail", "github"] } })).into_response()
|
|
}
|
|
|
|
async fn composio_tools(
|
|
State(state): State<ComposioMockState>,
|
|
headers: HeaderMap,
|
|
uri: axum::http::Uri,
|
|
) -> Response {
|
|
record_composio(
|
|
&state,
|
|
"GET",
|
|
uri.path_and_query().unwrap().as_str(),
|
|
None,
|
|
&headers,
|
|
);
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"tools": [{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "GMAIL_SEND_EMAIL",
|
|
"description": "Send email",
|
|
"parameters": { "type": "object" }
|
|
}
|
|
}]
|
|
}
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn composio_authorize(
|
|
State(state): State<ComposioMockState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<Value>,
|
|
) -> Response {
|
|
record_composio(
|
|
&state,
|
|
"POST",
|
|
"/agent-integrations/composio/authorize",
|
|
Some(body),
|
|
&headers,
|
|
);
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"connectUrl": "https://example.test/connect",
|
|
"connectionId": "conn_123"
|
|
}
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn composio_delete_connection(
|
|
State(state): State<ComposioMockState>,
|
|
headers: HeaderMap,
|
|
axum::extract::Path(id): axum::extract::Path<String>,
|
|
) -> Response {
|
|
record_composio(
|
|
&state,
|
|
"DELETE",
|
|
&format!("/agent-integrations/composio/connections/{id}"),
|
|
None,
|
|
&headers,
|
|
);
|
|
Json(json!({
|
|
"success": true,
|
|
"data": { "deleted": true, "memory_chunks_deleted": 2 }
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn composio_available_triggers(
|
|
State(state): State<ComposioMockState>,
|
|
headers: HeaderMap,
|
|
uri: axum::http::Uri,
|
|
) -> Response {
|
|
record_composio(
|
|
&state,
|
|
"GET",
|
|
uri.path_and_query().unwrap().as_str(),
|
|
None,
|
|
&headers,
|
|
);
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"triggers": [{
|
|
"slug": "GMAIL_NEW_GMAIL_MESSAGE",
|
|
"scope": "static",
|
|
"defaultConfig": { "labelIds": ["INBOX"] },
|
|
"requiredConfigKeys": ["labelIds"]
|
|
}]
|
|
}
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
fn record_composio(
|
|
state: &ComposioMockState,
|
|
method: &str,
|
|
path: &str,
|
|
body: Option<Value>,
|
|
headers: &HeaderMap,
|
|
) {
|
|
state.requests.lock().expect("composio requests").push((
|
|
method.to_string(),
|
|
path.to_string(),
|
|
body,
|
|
header(headers, "authorization"),
|
|
));
|
|
}
|
|
|
|
fn header(headers: &HeaderMap, name: &str) -> Option<String> {
|
|
headers
|
|
.get(name)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(ToString::to_string)
|
|
}
|
|
|
|
fn owned_domain_config(workspace_root: &std::path::Path) -> Config {
|
|
let mut config = Config::default();
|
|
config.workspace_dir = workspace_root.join("workspace");
|
|
config.config_path = workspace_root.join("config.toml");
|
|
std::fs::create_dir_all(&config.workspace_dir).expect("workspace dir");
|
|
config
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn openai_compatible_provider_covers_auth_temperature_tool_fallback_and_responses() {
|
|
let (base_url, state) = serve_provider_mock().await;
|
|
let provider = OpenAiCompatibleProvider::new_with_user_agent(
|
|
"owned-mock",
|
|
&base_url,
|
|
Some("secret-token"),
|
|
AuthStyle::Bearer,
|
|
"OpenHumanOwnedCoverage/1.0",
|
|
)
|
|
.with_temperature_unsupported_models(vec!["gpt-5*".to_string()]);
|
|
|
|
let tool = ToolSpec {
|
|
name: "lookup".to_string(),
|
|
description: "Lookup a record".to_string(),
|
|
parameters: json!({ "type": "object" }),
|
|
};
|
|
let messages = vec![ChatMessage::system("system"), ChatMessage::user("hello")];
|
|
let fallback_response = provider
|
|
.chat(
|
|
ChatRequest {
|
|
messages: &messages,
|
|
tools: Some(&[tool.clone(), tool]),
|
|
stream: None,
|
|
},
|
|
"gpt-5-mini",
|
|
0.6,
|
|
)
|
|
.await
|
|
.expect("provider chat with tool fallback");
|
|
assert!(
|
|
fallback_response
|
|
.text
|
|
.as_deref()
|
|
.is_some_and(|text| text.contains("\"tool_calls\"")),
|
|
"tool-schema fallback should return the history-path text payload"
|
|
);
|
|
assert!(fallback_response.tool_calls.is_empty());
|
|
|
|
let response = provider
|
|
.chat(
|
|
ChatRequest {
|
|
messages: &messages,
|
|
tools: None,
|
|
stream: None,
|
|
},
|
|
"gpt-5-mini",
|
|
0.6,
|
|
)
|
|
.await
|
|
.expect("provider native chat");
|
|
assert_eq!(response.text.as_deref(), Some("visible answer"));
|
|
assert_eq!(response.tool_calls.len(), 1);
|
|
assert_eq!(response.tool_calls[0].name, "lookup");
|
|
assert_eq!(response.tool_calls[0].arguments, r#"{"query":"openhuman"}"#);
|
|
let usage = response.usage.expect("usage");
|
|
assert_eq!(usage.input_tokens, 11);
|
|
assert_eq!(usage.output_tokens, 13);
|
|
assert_eq!(usage.cached_input_tokens, 5);
|
|
assert_eq!(usage.charged_amount_usd, 0.0123);
|
|
|
|
let chat_requests = state.chat_requests.lock().expect("chat requests").clone();
|
|
assert!(
|
|
chat_requests.len() >= 2,
|
|
"tool rejection should force a retry without native tools"
|
|
);
|
|
assert_eq!(
|
|
chat_requests[0].pointer("/tools/0/function/name"),
|
|
Some(&json!("lookup"))
|
|
);
|
|
assert!(chat_requests[0].get("temperature").is_none());
|
|
assert!(chat_requests[1].get("tools").is_none());
|
|
|
|
let auth_headers = state.auth_headers.lock().expect("auth headers").clone();
|
|
assert!(
|
|
auth_headers
|
|
.iter()
|
|
.any(|header| header.as_deref() == Some("Bearer secret-token")),
|
|
"bearer auth should be sent"
|
|
);
|
|
let user_agents = state.user_agents.lock().expect("user agents").clone();
|
|
assert!(
|
|
user_agents
|
|
.iter()
|
|
.any(|header| header.as_deref() == Some("OpenHumanOwnedCoverage/1.0")),
|
|
"custom user-agent should be sent"
|
|
);
|
|
|
|
let fallback_text = provider
|
|
.chat_with_history(&[ChatMessage::user("fallback please")], "missing-chat", 0.4)
|
|
.await
|
|
.expect("responses fallback");
|
|
assert_eq!(fallback_text, "responses fallback answer");
|
|
assert_eq!(
|
|
state.response_requests.lock().expect("response requests")[0].pointer("/input/0/content"),
|
|
Some(&json!([{"text": "fallback please", "type": "input_text"}]))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn openai_compatible_provider_streaming_json_fallback_aggregates_response() {
|
|
let (base_url, _state) = serve_provider_mock().await;
|
|
let provider = OpenAiCompatibleProvider::new("owned-mock", &base_url, None, AuthStyle::None);
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<ProviderDelta>(4);
|
|
let messages = vec![ChatMessage::user("stream please")];
|
|
|
|
let response = provider
|
|
.chat(
|
|
ChatRequest {
|
|
messages: &messages,
|
|
tools: None,
|
|
stream: Some(&tx),
|
|
},
|
|
"stream-model",
|
|
0.7,
|
|
)
|
|
.await
|
|
.expect("streaming JSON fallback");
|
|
|
|
assert_eq!(response.text.as_deref(), Some("stream fallback body"));
|
|
assert_eq!(
|
|
response.reasoning_content.as_deref(),
|
|
Some("stream thinking")
|
|
);
|
|
assert!(
|
|
rx.try_recv().is_err(),
|
|
"non-SSE fallback should not emit deltas"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn composio_client_round_trips_backend_paths_and_payload_normalization() {
|
|
let (base_url, state) = serve_composio_mock().await;
|
|
let client = ComposioClient::new(Arc::new(IntegrationClient::new(
|
|
format!("{base_url}/openai/v1/chat/completions"),
|
|
"jwt-token".to_string(),
|
|
)));
|
|
|
|
let toolkits = client.list_toolkits().await.expect("toolkits");
|
|
assert_eq!(toolkits.toolkits, vec!["gmail", "github"]);
|
|
|
|
let tools = client
|
|
.list_tools(
|
|
Some(&[
|
|
" gmail ".to_string(),
|
|
"".to_string(),
|
|
"github repo".to_string(),
|
|
]),
|
|
Some(&[" mail ".to_string()]),
|
|
)
|
|
.await
|
|
.expect("tools");
|
|
assert_eq!(tools.tools[0].function.name, "GMAIL_SEND_EMAIL");
|
|
|
|
let authorized = client
|
|
.authorize(
|
|
" gmail ",
|
|
Some(json!({
|
|
"oauth_scopes": "profile https://www.googleapis.com/auth/gmail.readonly"
|
|
})),
|
|
)
|
|
.await
|
|
.expect("authorize");
|
|
assert_eq!(authorized.connection_id, "conn_123");
|
|
|
|
let triggers = client
|
|
.list_available_triggers(" gmail ", Some("conn 123"))
|
|
.await
|
|
.expect("available triggers");
|
|
assert_eq!(triggers.triggers[0].slug, "GMAIL_NEW_GMAIL_MESSAGE");
|
|
|
|
let deleted = client
|
|
.delete_connection(" conn_123 ")
|
|
.await
|
|
.expect("delete connection");
|
|
assert!(deleted.deleted);
|
|
assert_eq!(deleted.memory_chunks_deleted, 2);
|
|
|
|
let requests = state.requests.lock().expect("composio requests").clone();
|
|
assert_eq!(requests[0].3.as_deref(), Some("Bearer jwt-token"));
|
|
assert!(
|
|
requests.iter().any(|(_, path, _, _)| path
|
|
== "/agent-integrations/composio/tools?toolkits=gmail,github%20repo&tags=mail"),
|
|
"list_tools should trim blanks and URL-encode query values: {requests:?}"
|
|
);
|
|
let authorize_body = requests
|
|
.iter()
|
|
.find(|(method, path, _, _)| {
|
|
method == "POST" && path == "/agent-integrations/composio/authorize"
|
|
})
|
|
.and_then(|(_, _, body, _)| body.clone())
|
|
.expect("authorize body");
|
|
assert_eq!(authorize_body.get("toolkit"), Some(&json!("gmail")));
|
|
assert_eq!(
|
|
authorize_body.pointer("/oauth_scopes/0"),
|
|
Some(&json!("profile"))
|
|
);
|
|
assert!(authorize_body["oauth_scopes"]
|
|
.as_array()
|
|
.expect("oauth scopes")
|
|
.iter()
|
|
.any(|scope| scope == "https://www.googleapis.com/auth/gmail.readonly"));
|
|
assert!(
|
|
requests.iter().any(|(method, path, _, _)| {
|
|
method == "DELETE" && path == "/agent-integrations/composio/connections/conn_123"
|
|
}),
|
|
"delete_connection should use DELETE route: {requests:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn agent_task_board_store_normalizes_persists_and_surfaces_errors() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let store = TaskBoardStore::new(dir.path().to_path_buf());
|
|
|
|
assert_eq!(TaskCardStatus::InProgress.as_str(), "in_progress");
|
|
assert_eq!(TaskApprovalMode::NotRequired.as_str(), "not_required");
|
|
assert!(store.get(" missing ").expect("get missing").is_none());
|
|
assert!(store
|
|
.get(" ")
|
|
.expect_err("blank id")
|
|
.contains("thread_id"));
|
|
|
|
let saved = store
|
|
.put(TaskBoard {
|
|
thread_id: " thread-owned ".to_string(),
|
|
cards: vec![
|
|
TaskBoardCard {
|
|
id: " ".to_string(),
|
|
title: " Draft owned coverage ".to_string(),
|
|
status: TaskCardStatus::Blocked,
|
|
objective: Some(" Raise raw coverage ".to_string()),
|
|
plan: vec![
|
|
" inspect ".to_string(),
|
|
" ".to_string(),
|
|
" test ".to_string(),
|
|
],
|
|
assigned_agent: Some(" agent ".to_string()),
|
|
allowed_tools: vec![" cargo ".to_string(), "".to_string()],
|
|
approval_mode: Some(TaskApprovalMode::Required),
|
|
acceptance_criteria: vec![" tests pass ".to_string()],
|
|
evidence: vec![" coverage measured ".to_string()],
|
|
notes: Some(" waiting ".to_string()),
|
|
session_thread_id: Some(" task-sess-owned ".to_string()),
|
|
blocker: None,
|
|
source_metadata: None,
|
|
order: 99,
|
|
updated_at: String::new(),
|
|
},
|
|
TaskBoardCard {
|
|
id: "drop-me".to_string(),
|
|
title: " ".to_string(),
|
|
status: TaskCardStatus::Todo,
|
|
objective: None,
|
|
plan: Vec::new(),
|
|
assigned_agent: None,
|
|
allowed_tools: Vec::new(),
|
|
approval_mode: None,
|
|
acceptance_criteria: Vec::new(),
|
|
evidence: Vec::new(),
|
|
notes: None,
|
|
session_thread_id: None,
|
|
blocker: None,
|
|
source_metadata: None,
|
|
order: 99,
|
|
updated_at: String::new(),
|
|
},
|
|
],
|
|
updated_at: String::new(),
|
|
})
|
|
.expect("put task board");
|
|
|
|
assert_eq!(saved.thread_id, "thread-owned");
|
|
assert_eq!(saved.cards.len(), 1);
|
|
assert!(saved.cards[0].id.starts_with("task-"));
|
|
assert_eq!(saved.cards[0].title, "Draft owned coverage");
|
|
assert_eq!(saved.cards[0].plan, vec!["inspect", "test"]);
|
|
assert_eq!(saved.cards[0].blocker.as_deref(), Some("waiting"));
|
|
// A padded session_thread_id is trimmed (not dropped) on persist.
|
|
assert_eq!(
|
|
saved.cards[0].session_thread_id.as_deref(),
|
|
Some("task-sess-owned")
|
|
);
|
|
assert_eq!(saved.cards[0].order, 0);
|
|
|
|
let loaded = board_for_thread(dir.path(), " thread-owned ")
|
|
.expect("board_for_thread")
|
|
.cards;
|
|
assert_eq!(loaded[0].approval_mode, Some(TaskApprovalMode::Required));
|
|
// …and the normalized value survives a reload from disk.
|
|
assert_eq!(
|
|
loaded[0].session_thread_id.as_deref(),
|
|
Some("task-sess-owned")
|
|
);
|
|
|
|
assert!(store.delete("thread-owned").expect("delete present"));
|
|
assert!(!store.delete("thread-owned").expect("delete missing"));
|
|
|
|
let missing = board_for_thread(dir.path(), "thread-owned").expect("missing board");
|
|
assert!(missing.cards.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn tool_registry_public_apis_cover_entries_diagnostics_and_provider_policy() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let mut config = owned_domain_config(dir.path());
|
|
config.mcp_client.enabled = true;
|
|
config.mcp_client.servers = vec![
|
|
McpServerConfig {
|
|
name: "filesystem".to_string(),
|
|
enabled: true,
|
|
allowed_tools: vec!["read_file".to_string(), "write_file".to_string()],
|
|
disallowed_tools: vec!["delete_file".to_string()],
|
|
..McpServerConfig::default()
|
|
},
|
|
McpServerConfig {
|
|
name: "disabled".to_string(),
|
|
enabled: false,
|
|
..McpServerConfig::default()
|
|
},
|
|
];
|
|
config.capability_providers = vec![
|
|
CapabilityProviderConfig {
|
|
id: " Acme Tools ".to_string(),
|
|
display_name: " Acme Tooling ".to_string(),
|
|
source_uri: Some(" https://example.test/acme.json ".to_string()),
|
|
source_digest: Some(" sha256:abc ".to_string()),
|
|
trust_state: CapabilityProviderTrustState::Trusted,
|
|
enabled: true,
|
|
},
|
|
CapabilityProviderConfig {
|
|
id: "zeta.provider".to_string(),
|
|
display_name: " ".to_string(),
|
|
source_uri: Some(" ".to_string()),
|
|
source_digest: None,
|
|
trust_state: CapabilityProviderTrustState::Untrusted,
|
|
enabled: false,
|
|
},
|
|
];
|
|
|
|
assert_eq!(
|
|
normalize_capability_provider_id(" Acme Tools "),
|
|
Ok("acme-tools".to_string())
|
|
);
|
|
assert!(normalize_capability_provider_id(" ").is_err());
|
|
assert!(normalize_capability_provider_id(&"x".repeat(120)).is_err());
|
|
|
|
let providers = list_capability_providers(&config).expect("providers");
|
|
assert_eq!(
|
|
providers
|
|
.iter()
|
|
.map(|provider| provider.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["acme-tools", "zeta.provider"]
|
|
);
|
|
assert_eq!(providers[0].display_name, "Acme Tooling");
|
|
assert_eq!(
|
|
providers[0].source_uri.as_deref(),
|
|
Some("https://example.test/acme.json")
|
|
);
|
|
assert_eq!(providers[1].display_name, "zeta.provider");
|
|
assert!(providers[1].source_uri.is_none());
|
|
|
|
let acme = capability_provider_by_id(&config, " acme tools ")
|
|
.expect("provider lookup")
|
|
.expect("acme provider");
|
|
assert_eq!(acme.id, "acme-tools");
|
|
assert!(is_capability_provider_trusted_enabled(
|
|
&config,
|
|
"ACME TOOLS"
|
|
));
|
|
assert!(!is_capability_provider_trusted_enabled(
|
|
&config,
|
|
"zeta.provider"
|
|
));
|
|
assert!(capability_provider_by_id(&config, "missing")
|
|
.expect("missing provider lookup")
|
|
.is_none());
|
|
|
|
let diagnostics = tool_registry_ops::diagnostics_for_config(&config).value;
|
|
assert!(diagnostics.total_tools > 0);
|
|
assert!(diagnostics.enabled_tools > 0);
|
|
assert!(diagnostics.json_rpc_tools > 0);
|
|
assert!(diagnostics
|
|
.possible_write_surfaces
|
|
.iter()
|
|
.any(|tool_id| tool_id.contains("execute") || tool_id.contains("write")));
|
|
assert!(diagnostics
|
|
.policy_surfaces
|
|
.iter()
|
|
.any(|tool_id| tool_id == "tool_registry.diagnostics"));
|
|
assert_eq!(diagnostics.mcp_allowlists.server_count, 2);
|
|
assert_eq!(diagnostics.mcp_allowlists.enabled_server_count, 1);
|
|
assert!(diagnostics.mcp_allowlists.servers[0].has_allowlist);
|
|
assert!(diagnostics.mcp_allowlists.servers[0].has_denylist);
|
|
assert_eq!(diagnostics.capability_providers.total_providers, 2);
|
|
assert_eq!(diagnostics.capability_providers.enabled_providers, 1);
|
|
assert_eq!(
|
|
diagnostics.capability_providers.trusted_enabled_providers,
|
|
1
|
|
);
|
|
|
|
let mut duplicate_config = owned_domain_config(dir.path());
|
|
duplicate_config.capability_providers = vec![
|
|
CapabilityProviderConfig {
|
|
id: "Acme Tools".to_string(),
|
|
..CapabilityProviderConfig::default()
|
|
},
|
|
CapabilityProviderConfig {
|
|
id: "acme-tools".to_string(),
|
|
..CapabilityProviderConfig::default()
|
|
},
|
|
];
|
|
let duplicate_diagnostics = capability_provider_diagnostics(&duplicate_config);
|
|
assert_eq!(duplicate_diagnostics.total_providers, 2);
|
|
assert!(duplicate_diagnostics.registry_errors[0].contains("duplicate"));
|
|
|
|
tool_registry_denials::record(
|
|
"tools.write_file",
|
|
"approval_required",
|
|
"write",
|
|
"medium risk",
|
|
);
|
|
let denials = tool_registry_denials::list(1);
|
|
assert_eq!(denials.len(), 1);
|
|
assert_eq!(denials[0].tool_name, "tools.write_file");
|
|
tool_registry_denials::record("", "policy", "blocked", "ignored");
|
|
tool_registry_denials::record(" tools.blank ", "", "", "");
|
|
tool_registry_denials::record("tools.secret", "policy", "blocked", "Bearer secret");
|
|
tool_registry_denials::record("tools.long", "policy", "blocked", &"a".repeat(500));
|
|
let recent_denials = tool_registry_denials::list(4);
|
|
assert_eq!(recent_denials[0].tool_name, "tools.long");
|
|
assert_eq!(recent_denials[0].reason.chars().count(), 241);
|
|
assert_eq!(recent_denials[1].reason, "[redacted: sensitive content]");
|
|
assert_eq!(recent_denials[2].policy, "unknown");
|
|
assert_eq!(recent_denials[2].action, "blocked");
|
|
assert_eq!(recent_denials[2].reason, "<empty>");
|
|
|
|
let registry = list_tools().value.tools;
|
|
assert!(registry.iter().any(|entry| {
|
|
entry.tool_id == "tools.web_search"
|
|
&& entry.tags.iter().any(|tag| tag == "retrieval")
|
|
&& entry.route.pointer("/protocol").and_then(Value::as_str) == Some("json_rpc")
|
|
}));
|
|
assert!(registry.iter().any(|entry| {
|
|
entry.tool_id.contains("memory") && entry.tags.iter().any(|tag| tag == "memory")
|
|
}));
|
|
|
|
let web_search = get_tool(" tools.web_search ")
|
|
.expect("get web search")
|
|
.value;
|
|
assert_eq!(web_search.title, "Web Search");
|
|
assert_eq!(
|
|
web_search
|
|
.input_schema
|
|
.pointer("/additionalProperties")
|
|
.and_then(Value::as_bool),
|
|
Some(false)
|
|
);
|
|
assert!(get_tool(" ")
|
|
.expect_err("blank tool id")
|
|
.contains("tool_id"));
|
|
assert!(get_tool("missing.tool")
|
|
.expect_err("missing tool")
|
|
.contains("tool not found"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_registry_controller_handlers_cover_list_get_and_validation_paths() {
|
|
let schemas = all_tool_registry_controller_schemas();
|
|
assert_eq!(
|
|
schemas
|
|
.iter()
|
|
.map(|schema| schema.function)
|
|
.collect::<Vec<_>>(),
|
|
vec!["list", "get", "diagnostics"]
|
|
);
|
|
|
|
let controllers = all_tool_registry_registered_controllers();
|
|
assert_eq!(controllers.len(), 3);
|
|
|
|
let list_handler = controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "list")
|
|
.expect("list controller")
|
|
.handler;
|
|
let list_value = list_handler(Map::new()).await.expect("list value");
|
|
assert!(list_value
|
|
.get("tools")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|tools| !tools.is_empty()));
|
|
|
|
let get_handler = controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "get")
|
|
.expect("get controller")
|
|
.handler;
|
|
let mut params = Map::new();
|
|
params.insert("tool_id".to_string(), json!("tools.web_search"));
|
|
let get_value = get_handler(params).await.expect("get value");
|
|
assert_eq!(
|
|
get_value.get("tool_id").and_then(Value::as_str),
|
|
Some("tools.web_search")
|
|
);
|
|
|
|
let mut blank_params = Map::new();
|
|
blank_params.insert("tool_id".to_string(), json!(" "));
|
|
assert!(get_handler(blank_params)
|
|
.await
|
|
.expect_err("blank tool id")
|
|
.contains("non-empty string"));
|
|
|
|
let mut typed_params = Map::new();
|
|
typed_params.insert("tool_id".to_string(), json!(42));
|
|
assert!(get_handler(typed_params)
|
|
.await
|
|
.expect_err("numeric tool id")
|
|
.contains("non-empty string"));
|
|
|
|
let diagnostics_handler = controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "diagnostics")
|
|
.expect("diagnostics controller")
|
|
.handler;
|
|
let dir = tempdir().expect("tempdir");
|
|
let previous_workspace = {
|
|
let _env_guard = OWNED_DOMAIN_ENV_LOCK.lock().expect("env lock");
|
|
let previous_workspace = std::env::var_os("OPENHUMAN_WORKSPACE");
|
|
std::env::set_var("OPENHUMAN_WORKSPACE", dir.path());
|
|
previous_workspace
|
|
};
|
|
let diagnostics_value = diagnostics_handler(Map::new())
|
|
.await
|
|
.expect("diagnostics value");
|
|
{
|
|
let _env_guard = OWNED_DOMAIN_ENV_LOCK.lock().expect("env lock");
|
|
match previous_workspace {
|
|
Some(value) => std::env::set_var("OPENHUMAN_WORKSPACE", value),
|
|
None => std::env::remove_var("OPENHUMAN_WORKSPACE"),
|
|
}
|
|
}
|
|
assert!(diagnostics_value
|
|
.get("total_tools")
|
|
.or_else(|| diagnostics_value.pointer("/diagnostics/total_tools"))
|
|
.and_then(Value::as_u64)
|
|
.is_some_and(|count| count > 0));
|
|
}
|