mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
4153 lines
143 KiB
Rust
4153 lines
143 KiB
Rust
//! Raw-line oriented integration coverage for tools, approval, channels, and
|
|
//! tool_registry surfaces that are not covered by the narrower controller tests.
|
|
|
|
use std::collections::{BTreeSet, HashMap};
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use async_trait::async_trait;
|
|
use axum::body::Bytes;
|
|
use axum::extract::Request;
|
|
use axum::http::{header::AUTHORIZATION, Method, StatusCode};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::any;
|
|
use axum::Router;
|
|
use reqwest::StatusCode as ReqwestStatusCode;
|
|
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::event_bus::{DomainEvent, EventHandler};
|
|
use openhuman_core::core::jsonrpc::build_core_http_router;
|
|
use openhuman_core::core::socketio::WebChannelEvent;
|
|
use openhuman_core::openhuman::agent::harness::definition::{
|
|
AgentDefinition, AgentDefinitionRegistry, AgentTier, DefinitionSource, ModelSpec, PromptSource,
|
|
SandboxMode, SkillsWildcard, SubagentEntry, ToolScope as AgentToolScope,
|
|
};
|
|
use openhuman_core::openhuman::agent::host_runtime::NativeRuntime;
|
|
use openhuman_core::openhuman::channels::email_channel::EmailConfig;
|
|
use openhuman_core::openhuman::channels::irc::IrcChannelConfig;
|
|
use openhuman_core::openhuman::channels::proactive::ProactiveMessageSubscriber;
|
|
use openhuman_core::openhuman::channels::traits::ChannelMessage;
|
|
use openhuman_core::openhuman::channels::yuanbao::config::YuanbaoConfig;
|
|
use openhuman_core::openhuman::channels::yuanbao::errors::{
|
|
AUTH_FAILED_CODES, AUTH_RETRYABLE_CODES, NO_RECONNECT_CLOSE_CODES,
|
|
};
|
|
use openhuman_core::openhuman::channels::yuanbao::inbound::{
|
|
InboundPipeline, PipelineOutcome, PipelineState,
|
|
};
|
|
use openhuman_core::openhuman::channels::yuanbao::media::{
|
|
build_file_msg_body, build_image_msg_body, guess_mime_type, image_format_code, is_image,
|
|
parse_image_size,
|
|
};
|
|
use openhuman_core::openhuman::channels::yuanbao::proto::{
|
|
decode_auth_bind_rsp, decode_conn_msg, decode_inbound_json, decode_inbound_push,
|
|
decode_push_msg, encode_auth_bind, encode_conn_msg, encode_msg_body_element, encode_ping,
|
|
encode_push_ack,
|
|
};
|
|
use openhuman_core::openhuman::channels::yuanbao::proto_constants::{cmd, cmd_type, module};
|
|
use openhuman_core::openhuman::channels::yuanbao::sign::{
|
|
build_timestamp, compute_signature, generate_nonce, SignManager,
|
|
};
|
|
use openhuman_core::openhuman::channels::yuanbao::splitter::split_markdown;
|
|
use openhuman_core::openhuman::channels::yuanbao::types::{
|
|
Account as YuanbaoAccount, ConnFrame as YuanbaoConnFrame,
|
|
ConnectionState as YuanbaoConnectionState, GroupInfo as YuanbaoGroupInfo,
|
|
GroupMember as YuanbaoGroupMember, GroupMemberListPage as YuanbaoGroupMemberListPage,
|
|
ImMsgSeq as YuanbaoImMsgSeq, ImageInfo as YuanbaoImageInfo,
|
|
InboundMessage as YuanbaoInboundMessage, MessageKind as YuanbaoMessageKind,
|
|
MsgBodyElement as YuanbaoMsgBodyElement, MsgContent as YuanbaoMsgContent,
|
|
Source as YuanbaoSource,
|
|
};
|
|
use openhuman_core::openhuman::channels::yuanbao::wire::{
|
|
decode_varint, encode_field_bytes, encode_field_string, encode_field_varint, encode_varint,
|
|
get_bytes, get_repeated_bytes, get_string, get_varint, next_seq_no, parse_fields, FieldValue,
|
|
};
|
|
use openhuman_core::openhuman::channels::yuanbao::YuanbaoChannel;
|
|
use openhuman_core::openhuman::channels::{
|
|
doctor_channels, Channel, CliChannel, DingTalkChannel, EmailChannel, IMessageChannel,
|
|
IrcChannel, LinqChannel, MattermostChannel, QQChannel, SendMessage, SignalChannel,
|
|
SlackChannel, WhatsAppChannel,
|
|
};
|
|
use openhuman_core::openhuman::composio::all_composio_agent_tools;
|
|
use openhuman_core::openhuman::config::schema::{
|
|
CapabilityProviderConfig, CapabilityProviderTrustState, NodeConfig, WhatsAppConfig,
|
|
};
|
|
use openhuman_core::openhuman::config::{Config, IMessageConfig, WebhookConfig};
|
|
use openhuman_core::openhuman::context::prompt::ConnectedIntegration;
|
|
use openhuman_core::openhuman::credentials::{
|
|
AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME,
|
|
};
|
|
use openhuman_core::openhuman::javascript::NodeBootstrap;
|
|
use openhuman_core::openhuman::memory::{
|
|
Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts,
|
|
};
|
|
use openhuman_core::openhuman::security::{AuditLogger, AutonomyLevel, SecurityPolicy};
|
|
use openhuman_core::openhuman::tool_registry::ops::diagnostics_for_config;
|
|
use openhuman_core::openhuman::tool_registry::{
|
|
all_tool_registry_controller_schemas, all_tool_registry_registered_controllers,
|
|
capability_provider_by_id, capability_provider_diagnostics, capability_provider_registry,
|
|
denials, get_tool, is_capability_provider_trusted_enabled, list_capability_providers,
|
|
list_tools, normalize_capability_provider_id, registry_entries,
|
|
CapabilityProviderRegistryError,
|
|
};
|
|
use openhuman_core::openhuman::tools::generated::{
|
|
admit_generated_tool_definitions, generated_tools_from_definitions, GeneratedToolAdapter,
|
|
GeneratedToolAdmissionConfig, GeneratedToolDefinition, GeneratedToolRisk,
|
|
};
|
|
use openhuman_core::openhuman::tools::local_cli::tools_wrappers_list_json;
|
|
use openhuman_core::openhuman::tools::orchestrator_tools::collect_orchestrator_tools;
|
|
use openhuman_core::openhuman::tools::{
|
|
all_tools, all_tools_controller_schemas, all_tools_registered_controllers,
|
|
decode_data_url_bytes, default_tools, extract_data_url, extract_saved_path,
|
|
write_bytes_to_path, ApplyPatchTool, BrowserAction, BrowserTool, CleaningStrategy,
|
|
ComputerUseConfig, CsvExportTool, CurrentTimeTool, DefaultToolPolicy, DetectToolsTool,
|
|
EditFileTool, FileReadTool, FileWriteTool, GitbooksGetPageTool, GitbooksSearchTool, GlobTool,
|
|
GrepTool, InsertSqlRecordTool, ListFilesTool, LspTool, NodeExecTool, NpmExecTool,
|
|
PermissionLevel, PolicyDecision, ProxyConfigTool, ReadDiffTool, RunLinterTool, RunTestsTool,
|
|
SchemaCleanr, Tool, ToolCallOptions, ToolCategory, ToolPolicy, ToolResult, ToolScope,
|
|
UpdateApplyTool, UpdateMemoryMdTool, WebFetchTool, WorkspaceStateTool,
|
|
};
|
|
|
|
const TEST_RPC_TOKEN: &str = "tools-approval-channels-raw-e2e-token";
|
|
|
|
static AUTH_INIT: OnceLock<()> = OnceLock::new();
|
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
old: Option<String>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
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 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 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),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Harness {
|
|
_tmp: TempDir,
|
|
_guards: Vec<EnvVarGuard>,
|
|
rpc_base: String,
|
|
rpc_join: tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
backend_join: tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct StubMemory;
|
|
|
|
#[async_trait]
|
|
impl Memory for StubMemory {
|
|
fn name(&self) -> &str {
|
|
"stub"
|
|
}
|
|
|
|
async fn store(
|
|
&self,
|
|
_namespace: &str,
|
|
_key: &str,
|
|
_content: &str,
|
|
_category: MemoryCategory,
|
|
_session_id: Option<&str>,
|
|
) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn recall(
|
|
&self,
|
|
_query: &str,
|
|
_limit: usize,
|
|
_opts: RecallOpts<'_>,
|
|
) -> Result<Vec<MemoryEntry>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn get(&self, _namespace: &str, _key: &str) -> Result<Option<MemoryEntry>> {
|
|
Ok(None)
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
_namespace: Option<&str>,
|
|
_category: Option<&MemoryCategory>,
|
|
_session_id: Option<&str>,
|
|
) -> Result<Vec<MemoryEntry>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn forget(&self, _namespace: &str, _key: &str) -> Result<bool> {
|
|
Ok(false)
|
|
}
|
|
|
|
async fn namespace_summaries(&self) -> Result<Vec<NamespaceSummary>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn count(&self) -> Result<usize> {
|
|
Ok(0)
|
|
}
|
|
|
|
async fn health_check(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
struct EchoGeneratedAdapter;
|
|
|
|
#[async_trait]
|
|
impl GeneratedToolAdapter for EchoGeneratedAdapter {
|
|
fn id(&self) -> &str {
|
|
"echo-generated"
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
definition: &GeneratedToolDefinition,
|
|
args: Value,
|
|
) -> Result<ToolResult> {
|
|
Ok(ToolResult::success(
|
|
json!({
|
|
"tool": definition.name,
|
|
"adapter": definition.adapter_id,
|
|
"args": args
|
|
})
|
|
.to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct CapturingChannel {
|
|
sent: Mutex<Vec<SendMessage>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for CapturingChannel {
|
|
fn name(&self) -> &str {
|
|
"capture"
|
|
}
|
|
|
|
// External channel exposes a proactive delivery target so the proactive
|
|
// router resolves a recipient for it (#3794 — recipient-less proactive sends
|
|
// are skipped for channels that return `None`).
|
|
fn proactive_target(&self) -> Option<String> {
|
|
Some("capture".to_string())
|
|
}
|
|
|
|
async fn send(&self, message: &SendMessage) -> Result<()> {
|
|
self.sent
|
|
.lock()
|
|
.expect("capture lock")
|
|
.push(message.clone());
|
|
Ok(())
|
|
}
|
|
|
|
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn coverage_agent_definition(
|
|
id: &str,
|
|
when_to_use: &str,
|
|
delegate_name: Option<&str>,
|
|
) -> AgentDefinition {
|
|
AgentDefinition {
|
|
id: id.into(),
|
|
when_to_use: when_to_use.into(),
|
|
display_name: None,
|
|
system_prompt: PromptSource::Inline(String::new()),
|
|
omit_identity: true,
|
|
omit_memory_context: true,
|
|
omit_safety_preamble: true,
|
|
omit_skills_catalog: true,
|
|
omit_profile: true,
|
|
omit_memory_md: true,
|
|
model: ModelSpec::Inherit,
|
|
temperature: 0.4,
|
|
tools: AgentToolScope::Wildcard,
|
|
disallowed_tools: vec![],
|
|
skill_filter: None,
|
|
extra_tools: vec![],
|
|
max_iterations: 8,
|
|
iteration_policy: Default::default(),
|
|
max_result_chars: None,
|
|
timeout_secs: None,
|
|
sandbox_mode: SandboxMode::None,
|
|
background: false,
|
|
trigger_memory_agent: Default::default(),
|
|
subagents: vec![],
|
|
delegate_name: delegate_name.map(str::to_string),
|
|
agent_tier: AgentTier::Worker,
|
|
source: DefinitionSource::Builtin,
|
|
}
|
|
}
|
|
|
|
fn coverage_connected_integration(
|
|
toolkit: &str,
|
|
description: &str,
|
|
connected: bool,
|
|
) -> ConnectedIntegration {
|
|
ConnectedIntegration {
|
|
toolkit: toolkit.into(),
|
|
description: description.into(),
|
|
tools: vec![],
|
|
gated_tools: vec![],
|
|
connected,
|
|
connections: Vec::new(),
|
|
non_active_status: None,
|
|
}
|
|
}
|
|
|
|
struct DefaultPathTool;
|
|
|
|
#[async_trait]
|
|
impl openhuman_core::openhuman::tools::Tool for DefaultPathTool {
|
|
fn name(&self) -> &str {
|
|
"default_path_tool"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Covers default Tool trait metadata paths."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"value": { "type": "string" }
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn execute(&self, args: Value) -> Result<ToolResult> {
|
|
Ok(ToolResult::success(args.to_string()))
|
|
}
|
|
}
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
ENV_LOCK
|
|
.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|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-tools-channels-e2e-auth");
|
|
init_rpc_token(&token_dir).expect("init rpc auth token");
|
|
});
|
|
}
|
|
|
|
async fn serve_rpc() -> (
|
|
std::net::SocketAddr,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
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)
|
|
}
|
|
|
|
async fn serve_backend() -> (
|
|
std::net::SocketAddr,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind backend listener");
|
|
let addr = listener.local_addr().expect("backend listener addr");
|
|
let router = Router::new().route("/{*path}", any(mock_backend));
|
|
let join = tokio::spawn(async move { axum::serve(listener, router).await });
|
|
(addr, join)
|
|
}
|
|
|
|
async fn mock_backend(request: Request) -> Response {
|
|
let method = request.method().clone();
|
|
let uri = request.uri().clone();
|
|
let path = uri.path().to_string();
|
|
let query = uri.query().unwrap_or_default().to_string();
|
|
let body = axum::body::to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap_or_else(|_| Bytes::new());
|
|
let json_body = if body.is_empty() {
|
|
Value::Null
|
|
} else {
|
|
serde_json::from_slice(&body).unwrap_or_else(|_| Value::Null)
|
|
};
|
|
|
|
if method == Method::GET && path == "/plain" {
|
|
return (StatusCode::OK, "plain coverage body").into_response();
|
|
}
|
|
if method == Method::GET && path == "/redirect" {
|
|
return (
|
|
StatusCode::FOUND,
|
|
[(axum::http::header::LOCATION, "https://example.test/next")],
|
|
"redirecting",
|
|
)
|
|
.into_response();
|
|
}
|
|
if method == Method::POST && path == "/mcp" {
|
|
let rpc_method = json_body
|
|
.get("method")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
if rpc_method == "notifications/initialized" {
|
|
return StatusCode::NO_CONTENT.into_response();
|
|
}
|
|
let id = json_body.get("id").cloned().unwrap_or(json!(1));
|
|
let result = match rpc_method {
|
|
"initialize" => json!({
|
|
"protocolVersion": "2025-11-25",
|
|
"capabilities": { "tools": { "listChanged": false } },
|
|
"serverInfo": { "name": "coverage-gitbooks", "version": "1.0.0" }
|
|
}),
|
|
"tools/list" => json!({
|
|
"tools": [
|
|
{
|
|
"name": "searchDocumentation",
|
|
"description": "Search docs",
|
|
"inputSchema": { "type": "object", "properties": { "query": { "type": "string" } } }
|
|
},
|
|
{
|
|
"name": "getPage",
|
|
"description": "Get page",
|
|
"inputSchema": { "type": "object", "properties": { "url": { "type": "string" } } }
|
|
}
|
|
]
|
|
}),
|
|
"tools/call" => {
|
|
let params = json_body.get("params").cloned().unwrap_or(Value::Null);
|
|
json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!(
|
|
"gitbooks mocked {}",
|
|
params.get("name").and_then(Value::as_str).unwrap_or("unknown")
|
|
)
|
|
}]
|
|
})
|
|
}
|
|
_ => {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
axum::Json(json!({ "error": format!("unexpected mcp method {rpc_method}") })),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
return (
|
|
[(
|
|
axum::http::header::HeaderName::from_static("mcp-session-id"),
|
|
"coverage-session",
|
|
)],
|
|
axum::Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let payload = match (method, path.as_str()) {
|
|
(Method::GET, "/auth/me") => json!({
|
|
"success": true,
|
|
"user": {
|
|
"id": "user-e2e",
|
|
"telegramId": "telegram-user-1",
|
|
"discord_id": "discord-user-1"
|
|
}
|
|
}),
|
|
(Method::POST, "/auth/channels/telegram/link-token") => {
|
|
json!({ "success": true, "data": { "linkToken": "telegram-link-e2e" } })
|
|
}
|
|
(Method::POST, "/auth/channels/discord/link-token") => {
|
|
json!({ "success": true, "data": { "token": "discord-link-e2e" } })
|
|
}
|
|
(Method::POST, "/channels/telegram/messages") => json!({
|
|
"success": true,
|
|
"data": { "messageId": "msg-1", "channel": "telegram", "echo": json_body }
|
|
}),
|
|
(Method::POST, "/channels/telegram/reactions") => json!({
|
|
"success": true,
|
|
"data": { "ok": true, "reaction": json_body }
|
|
}),
|
|
(Method::POST, "/channels/telegram/threads") => json!({
|
|
"success": true,
|
|
"data": { "threadId": "thread-1", "title": json_body.get("title").cloned().unwrap_or(Value::Null) }
|
|
}),
|
|
(Method::PATCH, "/channels/telegram/threads/thread-1") => json!({
|
|
"success": true,
|
|
"data": { "threadId": "thread-1", "action": json_body.get("action").cloned().unwrap_or(Value::Null) }
|
|
}),
|
|
(Method::GET, "/channels/telegram/threads") => json!({
|
|
"success": true,
|
|
"data": {
|
|
"query": query,
|
|
"threads": [{ "threadId": "thread-1", "active": true }]
|
|
}
|
|
}),
|
|
(Method::GET, "/agent-integrations/composio/toolkits") => json!({
|
|
"success": true,
|
|
"data": { "toolkits": ["gmail", "github", "slack"] }
|
|
}),
|
|
(Method::GET, "/agent-integrations/composio/connections") => json!({
|
|
"success": true,
|
|
"data": {
|
|
"connections": [
|
|
{
|
|
"id": "conn-gmail-1",
|
|
"toolkit": " Gmail ",
|
|
"status": "ACTIVE",
|
|
"createdAt": "2026-05-29T12:00:00Z"
|
|
},
|
|
{
|
|
"id": "conn-slack-pending",
|
|
"toolkit": "slack",
|
|
"status": "pending",
|
|
"createdAt": "2026-05-29T12:05:00Z"
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
(Method::POST, "/agent-integrations/composio/authorize") => json!({
|
|
"success": true,
|
|
"data": {
|
|
"connectUrl": format!(
|
|
"https://connect.example.test/{}",
|
|
json_body.get("toolkit").and_then(Value::as_str).unwrap_or("unknown")
|
|
),
|
|
"connectionId": "conn-new-1"
|
|
}
|
|
}),
|
|
(Method::GET, "/agent-integrations/composio/tools") => json!({
|
|
"success": true,
|
|
"data": {
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "GMAIL_FETCH_EMAILS",
|
|
"description": "Fetch matching Gmail messages for the user.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": { "type": "string" },
|
|
"maxResults": { "type": "integer" }
|
|
},
|
|
"required": ["query"]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "GMAIL_UNCURATED_INTERNAL",
|
|
"description": "Backend-only action that should be filtered out.",
|
|
"parameters": { "type": "object", "properties": {} }
|
|
}
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "SLACK_POST_MESSAGE",
|
|
"description": "Slack write action for an unconnected toolkit.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": { "text": { "type": "string" } },
|
|
"required": ["text"]
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
(Method::POST, "/agent-integrations/composio/execute") => json!({
|
|
"success": true,
|
|
"data": {
|
|
"data": {
|
|
"tool": json_body.get("tool").cloned().unwrap_or(Value::Null),
|
|
"arguments": json_body.get("arguments").cloned().unwrap_or(Value::Null)
|
|
},
|
|
"successful": true,
|
|
"error": null,
|
|
"costUsd": 0.015,
|
|
"markdownFormatted": "Fetched 1 matching Gmail message."
|
|
}
|
|
}),
|
|
(Method::POST, "/api/v5/robotLogic/sign-token") => json!({
|
|
"code": 0,
|
|
"data": {
|
|
"token": "yuanbao-token-e2e",
|
|
"bot_id": "yuanbao-bot-e2e",
|
|
"product": "openhuman",
|
|
"source": "coverage",
|
|
"duration": 120
|
|
}
|
|
}),
|
|
_ => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
axum::Json(json!({ "success": false, "error": format!("unhandled {path}") })),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
(StatusCode::OK, axum::Json(payload)).into_response()
|
|
}
|
|
|
|
fn write_config(openhuman_dir: &Path, api_url: &str) {
|
|
std::fs::create_dir_all(openhuman_dir).expect("create config dir");
|
|
let cfg = format!(
|
|
r#"api_url = "{api_url}"
|
|
default_model = "e2e-model"
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
|
|
[local_ai]
|
|
enabled = false
|
|
|
|
[memory]
|
|
provider = "none"
|
|
embedding_provider = "none"
|
|
embedding_model = "none"
|
|
embedding_dimensions = 0
|
|
|
|
[memory_tree]
|
|
embedding_strict = false
|
|
|
|
[autonomy]
|
|
level = "full"
|
|
workspace_only = false
|
|
max_actions_per_hour = 50
|
|
require_approval_for_medium_risk = false
|
|
block_high_risk_commands = false
|
|
auto_approve = []
|
|
|
|
[node]
|
|
enabled = false
|
|
|
|
[gitbooks]
|
|
enabled = false
|
|
|
|
[mcp_client]
|
|
enabled = true
|
|
|
|
[[mcp_client.servers]]
|
|
name = "filesystem"
|
|
command = "node"
|
|
args = ["server.js"]
|
|
enabled = true
|
|
allowed_tools = ["read_file"]
|
|
disallowed_tools = ["write_file"]
|
|
"#
|
|
);
|
|
std::fs::write(openhuman_dir.join("config.toml"), cfg).expect("write config.toml");
|
|
}
|
|
|
|
async fn setup() -> Harness {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let home = tmp.path();
|
|
let workspace = home.join("openhuman-workspace");
|
|
let (backend_addr, backend_join) = serve_backend().await;
|
|
let api_url = format!("http://{backend_addr}");
|
|
|
|
write_config(&workspace, &api_url);
|
|
write_config(&home.join(".openhuman"), &api_url);
|
|
|
|
let guards = vec![
|
|
EnvVarGuard::set_to_path("HOME", home),
|
|
EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace),
|
|
EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"),
|
|
EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"),
|
|
EnvVarGuard::set("OPENHUMAN_TELEGRAM_BOT_USERNAME", "coverage_bot"),
|
|
EnvVarGuard::unset("BACKEND_URL"),
|
|
EnvVarGuard::unset("VITE_BACKEND_URL"),
|
|
EnvVarGuard::unset("OPENHUMAN_API_URL"),
|
|
EnvVarGuard::unset("OPENHUMAN_LSP_ENABLED"),
|
|
];
|
|
|
|
let config = Config::load_or_init()
|
|
.await
|
|
.expect("load config for app session seed");
|
|
let auth = AuthService::from_config(&config);
|
|
auth.store_provider_token(
|
|
APP_SESSION_PROVIDER,
|
|
DEFAULT_AUTH_PROFILE_NAME,
|
|
"header.payload.local",
|
|
HashMap::from([("user_id".to_string(), "user-e2e".to_string())]),
|
|
true,
|
|
)
|
|
.expect("seed app-session token");
|
|
|
|
let (rpc_addr, rpc_join) = serve_rpc().await;
|
|
Harness {
|
|
_tmp: tmp,
|
|
_guards: guards,
|
|
rpc_base: format!("http://{rpc_addr}"),
|
|
rpc_join,
|
|
backend_join,
|
|
}
|
|
}
|
|
|
|
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(),
|
|
ReqwestStatusCode::OK,
|
|
"{method} HTTP status"
|
|
);
|
|
response
|
|
.json::<Value>()
|
|
.await
|
|
.unwrap_or_else(|err| panic!("json for {method}: {err}"))
|
|
}
|
|
|
|
fn payload<'a>(value: &'a Value, context: &str) -> &'a Value {
|
|
if let Some(error) = value.get("error") {
|
|
panic!("{context}: unexpected JSON-RPC error: {error}");
|
|
}
|
|
let result = value
|
|
.get("result")
|
|
.unwrap_or_else(|| panic!("{context}: missing result: {value}"));
|
|
result.get("result").unwrap_or(result)
|
|
}
|
|
|
|
fn error_message<'a>(value: &'a Value, context: &str) -> &'a str {
|
|
value
|
|
.get("error")
|
|
.and_then(|error| error.get("message"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("{context}: missing error message: {value}"))
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn composio_agent_tools_cover_backend_discovery_markdown_and_execution_paths() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
let config = Config::load_or_init()
|
|
.await
|
|
.expect("load config for composio tools");
|
|
let tools = all_composio_agent_tools(&config);
|
|
let names = tools.iter().map(|tool| tool.name()).collect::<Vec<_>>();
|
|
assert_eq!(
|
|
names,
|
|
vec![
|
|
"composio_list_toolkits",
|
|
"composio_list_connections",
|
|
"composio_authorize",
|
|
"composio_connect",
|
|
"composio_list_tools",
|
|
"composio_execute",
|
|
]
|
|
);
|
|
assert!(tools
|
|
.iter()
|
|
.all(|tool| tool.category() == ToolCategory::Workflow));
|
|
|
|
let list_toolkits = tools
|
|
.iter()
|
|
.find(|tool| tool.name() == "composio_list_toolkits")
|
|
.expect("list toolkits tool");
|
|
let toolkits = list_toolkits
|
|
.execute(json!({}))
|
|
.await
|
|
.expect("list toolkits executes");
|
|
assert!(!toolkits.is_error, "{}", toolkits.output());
|
|
assert!(toolkits.output().contains("gmail"));
|
|
|
|
let list_connections = tools
|
|
.iter()
|
|
.find(|tool| tool.name() == "composio_list_connections")
|
|
.expect("list connections tool");
|
|
let connections = list_connections
|
|
.execute(json!({}))
|
|
.await
|
|
.expect("list connections executes");
|
|
assert!(!connections.is_error, "{}", connections.output());
|
|
assert!(connections.output().contains("conn-gmail-1"));
|
|
assert!(
|
|
!connections.output().contains("conn-slack-pending"),
|
|
"pending connections should be filtered before reaching the agent"
|
|
);
|
|
|
|
let list_tools = tools
|
|
.iter()
|
|
.find(|tool| tool.name() == "composio_list_tools")
|
|
.expect("list tools tool");
|
|
assert!(list_tools.supports_markdown());
|
|
let discovered = list_tools
|
|
.execute_with_options(
|
|
json!({
|
|
"toolkits": [" gmail "],
|
|
"tags": ["readOnlyHint"],
|
|
"include_unconnected": false
|
|
}),
|
|
ToolCallOptions {
|
|
prefer_markdown: true,
|
|
},
|
|
)
|
|
.await
|
|
.expect("list tools executes");
|
|
assert!(!discovered.is_error, "{}", discovered.output());
|
|
assert!(discovered.output().contains("GMAIL_FETCH_EMAILS"));
|
|
assert!(!discovered.output().contains("GMAIL_UNCURATED_INTERNAL"));
|
|
assert!(!discovered.output().contains("SLACK_POST_MESSAGE"));
|
|
let markdown = discovered
|
|
.markdown_formatted
|
|
.as_deref()
|
|
.expect("markdown rendering");
|
|
assert!(markdown.contains("# Composio tools"));
|
|
assert!(markdown.contains("**req:** query"));
|
|
assert!(markdown.contains("**opt:** maxResults"));
|
|
|
|
let authorize = tools
|
|
.iter()
|
|
.find(|tool| tool.name() == "composio_authorize")
|
|
.expect("authorize tool");
|
|
let missing_toolkit = authorize
|
|
.execute(json!({}))
|
|
.await
|
|
.expect("authorize validates params");
|
|
assert!(missing_toolkit.is_error);
|
|
assert!(missing_toolkit.output().contains("'toolkit' is required"));
|
|
let handoff = authorize
|
|
.execute(json!({ "toolkit": "gmail" }))
|
|
.await
|
|
.expect("authorize executes");
|
|
assert!(!handoff.is_error, "{}", handoff.output());
|
|
assert!(handoff
|
|
.output()
|
|
.contains("https://connect.example.test/gmail"));
|
|
assert!(handoff.output().contains("conn-new-1"));
|
|
|
|
let execute = tools
|
|
.iter()
|
|
.find(|tool| tool.name() == "composio_execute")
|
|
.expect("execute tool");
|
|
let missing_action = execute
|
|
.execute(json!({ "arguments": {} }))
|
|
.await
|
|
.expect("execute validates tool");
|
|
assert!(missing_action.is_error);
|
|
assert!(missing_action.output().contains("'tool' is required"));
|
|
let executed = execute
|
|
.execute(json!({
|
|
"tool": "GMAIL_FETCH_EMAILS",
|
|
"connection_id": "conn-gmail-1",
|
|
"arguments": { "query": "from:alice@example.test", "maxResults": 1 }
|
|
}))
|
|
.await
|
|
.expect("execute dispatches");
|
|
assert!(!executed.is_error, "{}", executed.output());
|
|
assert_eq!(executed.output(), "Fetched 1 matching Gmail message.");
|
|
|
|
harness.rpc_join.abort();
|
|
harness.backend_join.abort();
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn channels_rpc_covers_credentials_managed_backend_and_error_paths() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let list = rpc(&harness.rpc_base, 1, "openhuman.channels_list", json!({})).await;
|
|
let channels = payload(&list, "channels_list")
|
|
.as_array()
|
|
.expect("channels list");
|
|
assert!(channels
|
|
.iter()
|
|
.any(|channel| channel.get("id").and_then(Value::as_str) == Some("telegram")));
|
|
|
|
let describe = rpc(
|
|
&harness.rpc_base,
|
|
2,
|
|
"openhuman.channels_describe",
|
|
json!({ "channel": " telegram " }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&describe, "channels_describe")
|
|
.get("id")
|
|
.and_then(Value::as_str),
|
|
Some("telegram")
|
|
);
|
|
let unknown = rpc(
|
|
&harness.rpc_base,
|
|
3,
|
|
"openhuman.channels_describe",
|
|
json!({ "channel": "missing" }),
|
|
)
|
|
.await;
|
|
assert!(error_message(&unknown, "unknown channel").contains("unknown channel"));
|
|
|
|
let bad_mode = rpc(
|
|
&harness.rpc_base,
|
|
4,
|
|
"openhuman.channels_connect",
|
|
json!({ "channel": "telegram", "authMode": "bad_mode" }),
|
|
)
|
|
.await;
|
|
assert!(error_message(&bad_mode, "bad auth mode").contains("invalid authMode"));
|
|
|
|
let unsupported_mode = rpc(
|
|
&harness.rpc_base,
|
|
24,
|
|
"openhuman.channels_connect",
|
|
json!({ "channel": "web", "authMode": "bot_token", "credentials": {} }),
|
|
)
|
|
.await;
|
|
assert!(error_message(&unsupported_mode, "unsupported auth mode").contains("does not support"));
|
|
|
|
let non_object_creds = rpc(
|
|
&harness.rpc_base,
|
|
25,
|
|
"openhuman.channels_test",
|
|
json!({ "channel": "telegram", "authMode": "bot_token", "credentials": "bad" }),
|
|
)
|
|
.await;
|
|
assert!(error_message(&non_object_creds, "non-object credentials")
|
|
.contains("credentials must be a JSON object"));
|
|
|
|
let telegram_managed = rpc(
|
|
&harness.rpc_base,
|
|
26,
|
|
"openhuman.channels_connect",
|
|
json!({ "channel": "telegram", "authMode": "managed_dm", "credentials": {} }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&telegram_managed, "connect telegram managed dm")
|
|
.get("status")
|
|
.and_then(Value::as_str),
|
|
Some("pending_auth")
|
|
);
|
|
assert_eq!(
|
|
payload(&telegram_managed, "connect telegram managed dm")
|
|
.get("auth_action")
|
|
.and_then(Value::as_str),
|
|
Some("telegram_managed_dm")
|
|
);
|
|
|
|
let discord_oauth = rpc(
|
|
&harness.rpc_base,
|
|
27,
|
|
"openhuman.channels_connect",
|
|
json!({ "channel": "discord", "authMode": "oauth", "credentials": {} }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&discord_oauth, "connect discord oauth")
|
|
.get("auth_action")
|
|
.and_then(Value::as_str),
|
|
Some("discord_oauth")
|
|
);
|
|
|
|
let missing_creds = rpc(
|
|
&harness.rpc_base,
|
|
5,
|
|
"openhuman.channels_test",
|
|
json!({ "channel": "telegram", "authMode": "bot_token", "credentials": {} }),
|
|
)
|
|
.await;
|
|
assert!(error_message(&missing_creds, "missing creds").contains("missing required fields"));
|
|
|
|
let test_ok = rpc(
|
|
&harness.rpc_base,
|
|
6,
|
|
"openhuman.channels_test",
|
|
json!({
|
|
"channel": "telegram",
|
|
"authMode": "bot_token",
|
|
"credentials": { "bot_token": "123456:test", "allowed_users": "alice, bob" }
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&test_ok, "channels_test")
|
|
.get("success")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let connect_telegram = rpc(
|
|
&harness.rpc_base,
|
|
7,
|
|
"openhuman.channels_connect",
|
|
json!({
|
|
"channel": "telegram",
|
|
"authMode": "bot_token",
|
|
"credentials": { "bot_token": "123456:test", "allowed_users": "alice, bob" }
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&connect_telegram, "connect telegram")
|
|
.get("status")
|
|
.and_then(Value::as_str),
|
|
Some("connected")
|
|
);
|
|
|
|
let connect_discord = rpc(
|
|
&harness.rpc_base,
|
|
8,
|
|
"openhuman.channels_connect",
|
|
json!({
|
|
"channel": "discord",
|
|
"authMode": "bot_token",
|
|
"credentials": {
|
|
"bot_token": "discord-token",
|
|
"guild_id": "guild-1",
|
|
"channel_id": "channel-1",
|
|
"allowed_users": ["u1", "u2"],
|
|
"listen_to_bots": true,
|
|
"mention_only": false
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&connect_discord, "connect discord")
|
|
.get("restart_required")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let connect_imessage = rpc(
|
|
&harness.rpc_base,
|
|
9,
|
|
"openhuman.channels_connect",
|
|
json!({
|
|
"channel": "imessage",
|
|
"authMode": "managed_dm",
|
|
"credentials": { "allowed_contacts": "mom@example.com, +15551234567" }
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&connect_imessage, "connect imessage")
|
|
.get("status")
|
|
.and_then(Value::as_str),
|
|
Some("connected")
|
|
);
|
|
|
|
let lark_test = rpc(
|
|
&harness.rpc_base,
|
|
28,
|
|
"openhuman.channels_test",
|
|
json!({
|
|
"channel": "lark",
|
|
"authMode": "api_key",
|
|
"credentials": {
|
|
"app_id": "cli_lark",
|
|
"app_secret": "lark-secret",
|
|
"use_feishu": "yes",
|
|
"allowed_users": [" user-a ", "@user-b,user-a"]
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&lark_test, "test lark")
|
|
.get("success")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let connect_lark = rpc(
|
|
&harness.rpc_base,
|
|
29,
|
|
"openhuman.channels_connect",
|
|
json!({
|
|
"channel": "lark",
|
|
"authMode": "api_key",
|
|
"credentials": {
|
|
"app_id": "cli_lark",
|
|
"app_secret": "lark-secret",
|
|
"receive_mode": "websocket",
|
|
"port": "8080"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&connect_lark, "connect lark")
|
|
.get("status")
|
|
.and_then(Value::as_str),
|
|
Some("connected")
|
|
);
|
|
|
|
let connect_dingtalk = rpc(
|
|
&harness.rpc_base,
|
|
30,
|
|
"openhuman.channels_connect",
|
|
json!({
|
|
"channel": "dingtalk",
|
|
"authMode": "api_key",
|
|
"credentials": {
|
|
"client_id": "ding-client",
|
|
"client_secret": "ding-secret",
|
|
"allowed_users": "u1\n@u2"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&connect_dingtalk, "connect dingtalk")
|
|
.get("status")
|
|
.and_then(Value::as_str),
|
|
Some("connected")
|
|
);
|
|
|
|
let status = rpc(
|
|
&harness.rpc_base,
|
|
10,
|
|
"openhuman.channels_status",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let entries = payload(&status, "channels_status")
|
|
.as_array()
|
|
.expect("status entries");
|
|
assert!(entries.iter().any(|entry| {
|
|
entry.get("channel_id").and_then(Value::as_str) == Some("telegram")
|
|
&& entry.get("connected").and_then(Value::as_bool) == Some(true)
|
|
}));
|
|
assert!(entries.iter().any(|entry| {
|
|
entry.get("channel_id").and_then(Value::as_str) == Some("imessage")
|
|
&& entry.get("connected").and_then(Value::as_bool) == Some(true)
|
|
}));
|
|
assert!(entries.iter().any(|entry| {
|
|
entry.get("channel_id").and_then(Value::as_str) == Some("lark")
|
|
&& entry.get("connected").and_then(Value::as_bool) == Some(true)
|
|
}));
|
|
assert!(entries.iter().any(|entry| {
|
|
entry.get("channel_id").and_then(Value::as_str) == Some("dingtalk")
|
|
&& entry.get("connected").and_then(Value::as_bool) == Some(true)
|
|
}));
|
|
|
|
let filtered_status = rpc(
|
|
&harness.rpc_base,
|
|
11,
|
|
"openhuman.channels_status",
|
|
json!({ "channel": " discord " }),
|
|
)
|
|
.await;
|
|
assert!(payload(&filtered_status, "filtered status")
|
|
.as_array()
|
|
.expect("filtered status entries")
|
|
.iter()
|
|
.all(|entry| entry.get("channel_id").and_then(Value::as_str) == Some("discord")));
|
|
|
|
let telegram_start = rpc(
|
|
&harness.rpc_base,
|
|
12,
|
|
"openhuman.channels_telegram_login_start",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&telegram_start, "telegram login start")
|
|
.get("botUsername")
|
|
.and_then(Value::as_str),
|
|
Some("coverage_bot")
|
|
);
|
|
let telegram_check = rpc(
|
|
&harness.rpc_base,
|
|
13,
|
|
"openhuman.channels_telegram_login_check",
|
|
json!({ "linkToken": "telegram-link-e2e" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&telegram_check, "telegram login check")
|
|
.get("linked")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let discord_start = rpc(
|
|
&harness.rpc_base,
|
|
14,
|
|
"openhuman.channels_discord_link_start",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
assert!(payload(&discord_start, "discord link start")
|
|
.get("instructions")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|instructions| instructions.contains("discord-link-e2e")));
|
|
let discord_check = rpc(
|
|
&harness.rpc_base,
|
|
15,
|
|
"openhuman.channels_discord_link_check",
|
|
json!({ "linkToken": "discord-link-e2e" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&discord_check, "discord link check")
|
|
.get("linked")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let send = rpc(
|
|
&harness.rpc_base,
|
|
16,
|
|
"openhuman.channels_send_message",
|
|
json!({ "channel": "telegram", "message": { "text": "hello", "threadId": "thread-1" } }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&send, "send message")
|
|
.get("messageId")
|
|
.and_then(Value::as_str),
|
|
Some("msg-1")
|
|
);
|
|
let reaction = rpc(
|
|
&harness.rpc_base,
|
|
17,
|
|
"openhuman.channels_send_reaction",
|
|
json!({ "channel": "telegram", "reaction": { "messageId": "msg-1", "emoji": "+1" } }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&reaction, "send reaction")
|
|
.get("ok")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
let create_thread = rpc(
|
|
&harness.rpc_base,
|
|
18,
|
|
"openhuman.channels_create_thread",
|
|
json!({ "channel": "telegram", "title": "Coverage Thread" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&create_thread, "create thread")
|
|
.get("threadId")
|
|
.and_then(Value::as_str),
|
|
Some("thread-1")
|
|
);
|
|
let update_thread = rpc(
|
|
&harness.rpc_base,
|
|
19,
|
|
"openhuman.channels_update_thread",
|
|
json!({ "channel": "telegram", "threadId": "thread-1", "action": "close" }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&update_thread, "update thread")
|
|
.get("action")
|
|
.and_then(Value::as_str),
|
|
Some("close")
|
|
);
|
|
let list_threads = rpc(
|
|
&harness.rpc_base,
|
|
20,
|
|
"openhuman.channels_list_threads",
|
|
json!({ "channel": "telegram", "active": true }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&list_threads, "list threads")
|
|
.pointer("/threads/0/threadId")
|
|
.and_then(Value::as_str),
|
|
Some("thread-1")
|
|
);
|
|
|
|
let bad_update = rpc(
|
|
&harness.rpc_base,
|
|
21,
|
|
"openhuman.channels_update_thread",
|
|
json!({ "channel": "telegram", "threadId": "thread-1", "action": "archive" }),
|
|
)
|
|
.await;
|
|
assert!(error_message(&bad_update, "bad update action").contains("action must be"));
|
|
|
|
let disconnect_telegram = rpc(
|
|
&harness.rpc_base,
|
|
22,
|
|
"openhuman.channels_disconnect",
|
|
json!({ "channel": "telegram", "authMode": "bot_token", "clearMemory": false }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&disconnect_telegram, "disconnect telegram")
|
|
.get("disconnected")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let disconnect_imessage = rpc(
|
|
&harness.rpc_base,
|
|
23,
|
|
"openhuman.channels_disconnect",
|
|
json!({ "channel": "imessage", "authMode": "managed_dm", "clearMemory": false }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&disconnect_imessage, "disconnect imessage")
|
|
.get("memory_chunks_deleted")
|
|
.and_then(Value::as_u64),
|
|
Some(0)
|
|
);
|
|
|
|
let disconnect_lark = rpc(
|
|
&harness.rpc_base,
|
|
31,
|
|
"openhuman.channels_disconnect",
|
|
json!({ "channel": "lark", "authMode": "api_key", "clearMemory": false }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&disconnect_lark, "disconnect lark")
|
|
.get("disconnected")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
let disconnect_dingtalk = rpc(
|
|
&harness.rpc_base,
|
|
32,
|
|
"openhuman.channels_disconnect",
|
|
json!({ "channel": "dingtalk", "authMode": "api_key", "clearMemory": false }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&disconnect_dingtalk, "disconnect dingtalk")
|
|
.get("disconnected")
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
|
|
harness.rpc_join.abort();
|
|
harness.backend_join.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn tools_and_tool_registry_public_surfaces_cover_schema_and_assembly_paths() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let mut config = Config {
|
|
workspace_dir: dir.path().to_path_buf(),
|
|
config_path: dir.path().join("config.toml"),
|
|
..Config::default()
|
|
};
|
|
config.node.enabled = false;
|
|
config.browser.enabled = true;
|
|
config.http_request.allowed_domains = vec![
|
|
"*".to_string(),
|
|
"docs.openhuman.ai".to_string(),
|
|
"example.com".to_string(),
|
|
];
|
|
config.gitbooks.enabled = true;
|
|
config.computer_control.enabled = true;
|
|
config.learning.enabled = true;
|
|
config.learning.tool_tracking_enabled = true;
|
|
config.mcp_client.enabled = true;
|
|
|
|
let security = Arc::new(SecurityPolicy::from_config(
|
|
&config.autonomy,
|
|
&config.workspace_dir,
|
|
&config.workspace_dir,
|
|
));
|
|
let memory: Arc<dyn Memory> = Arc::new(StubMemory);
|
|
let tools = all_tools(
|
|
Arc::new(config.clone()),
|
|
&security,
|
|
AuditLogger::disabled(),
|
|
memory,
|
|
&config.browser,
|
|
&config.http_request,
|
|
&config.workspace_dir,
|
|
&HashMap::new(),
|
|
&config,
|
|
);
|
|
let names = tools.iter().map(|tool| tool.name()).collect::<Vec<_>>();
|
|
for expected in [
|
|
"shell",
|
|
"file_read",
|
|
"grep",
|
|
"browser_open",
|
|
"browser",
|
|
"http_request",
|
|
"web_fetch",
|
|
"curl",
|
|
"gitbooks_search",
|
|
"gitbooks_get_page",
|
|
"mouse",
|
|
"keyboard",
|
|
"tool_stats",
|
|
"screenshot",
|
|
"image_info",
|
|
] {
|
|
assert!(
|
|
names.contains(&expected),
|
|
"missing tool {expected}; got {names:?}"
|
|
);
|
|
}
|
|
assert!(!names.contains(&"node_exec"));
|
|
assert!(!names.contains(&"npm_exec"));
|
|
|
|
let baseline = default_tools(security);
|
|
assert_eq!(baseline.len(), 3);
|
|
assert_eq!(baseline[0].scope(), ToolScope::All);
|
|
assert_eq!(baseline[0].permission_level(), PermissionLevel::Execute);
|
|
|
|
let wrappers = tools_wrappers_list_json();
|
|
assert!(wrappers
|
|
.pointer("/result/wrappers")
|
|
.and_then(Value::as_array)
|
|
.expect("wrapper list")
|
|
.iter()
|
|
.any(|wrapper| wrapper.get("name").and_then(Value::as_str) == Some("screenshot")));
|
|
|
|
let tool_schemas = all_tools_controller_schemas();
|
|
let tool_controllers = all_tools_registered_controllers();
|
|
assert_eq!(tool_schemas.len(), tool_controllers.len());
|
|
assert!(tool_schemas
|
|
.iter()
|
|
.any(|schema| schema.function == "web_search"));
|
|
|
|
let registry_schemas = all_tool_registry_controller_schemas();
|
|
let registry_controllers = all_tool_registry_registered_controllers();
|
|
assert_eq!(registry_schemas.len(), 3);
|
|
assert_eq!(registry_schemas.len(), registry_controllers.len());
|
|
assert!(registry_entries()
|
|
.iter()
|
|
.any(|entry| entry.tool_id == "tools.web_search"));
|
|
let listed = list_tools()
|
|
.into_cli_compatible_json()
|
|
.expect("list_tools json");
|
|
let listed_tools = listed
|
|
.get("tools")
|
|
.and_then(Value::as_array)
|
|
.expect("listed tools");
|
|
let first_tool_id = listed_tools
|
|
.first()
|
|
.and_then(|tool| tool.get("tool_id"))
|
|
.and_then(Value::as_str)
|
|
.expect("first registry tool id");
|
|
let found = get_tool(first_tool_id)
|
|
.expect("get first registry tool")
|
|
.into_cli_compatible_json()
|
|
.expect("get_tool json");
|
|
assert_eq!(
|
|
found.get("tool_id").and_then(Value::as_str),
|
|
Some(first_tool_id)
|
|
);
|
|
assert!(get_tool(" ")
|
|
.expect_err("blank registry id should fail")
|
|
.contains("non-empty"));
|
|
assert!(get_tool("tools.missing")
|
|
.expect_err("missing registry id should fail")
|
|
.contains("tools.missing"));
|
|
|
|
let dirty_schema = json!({
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"properties": {
|
|
"kind": {
|
|
"anyOf": [
|
|
{ "type": "string", "const": "alpha" },
|
|
{ "type": "string", "const": "beta" }
|
|
]
|
|
},
|
|
"age": { "$ref": "#/$defs/Age", "description": "age field" },
|
|
"nullable": { "type": ["string", "null"] },
|
|
"unresolved": { "$ref": "#/$defs/Missing", "title": "missing ref" },
|
|
"cycle": { "$ref": "#/$defs/Cycle", "description": "cycle ref" }
|
|
},
|
|
"$defs": {
|
|
"Age": { "type": "integer", "minimum": 0 },
|
|
"Cycle": { "$ref": "#/$defs/Cycle" }
|
|
}
|
|
});
|
|
let gemini = SchemaCleanr::clean_for_gemini(dirty_schema.clone());
|
|
assert_eq!(
|
|
gemini.pointer("/properties/kind/type"),
|
|
Some(&json!("string"))
|
|
);
|
|
assert_eq!(
|
|
gemini.pointer("/properties/kind/enum"),
|
|
Some(&json!(["alpha", "beta"]))
|
|
);
|
|
assert_eq!(
|
|
gemini.pointer("/properties/age/type"),
|
|
Some(&json!("integer"))
|
|
);
|
|
assert_eq!(
|
|
gemini.pointer("/properties/age/description"),
|
|
Some(&json!("age field"))
|
|
);
|
|
assert_eq!(
|
|
gemini.pointer("/properties/nullable/type"),
|
|
Some(&json!("string"))
|
|
);
|
|
assert_eq!(
|
|
gemini.pointer("/properties/unresolved/title"),
|
|
Some(&json!("missing ref"))
|
|
);
|
|
assert!(SchemaCleanr::validate(&gemini).is_ok());
|
|
assert!(SchemaCleanr::validate(&json!("not-object")).is_err());
|
|
assert!(SchemaCleanr::validate(&json!({ "properties": {} })).is_err());
|
|
|
|
let anthropic = SchemaCleanr::clean(dirty_schema.clone(), CleaningStrategy::Anthropic);
|
|
assert!(anthropic.get("$defs").is_none());
|
|
let openai = SchemaCleanr::clean_for_openai(dirty_schema);
|
|
assert!(openai.get("$defs").is_some());
|
|
|
|
let policy = DefaultToolPolicy;
|
|
assert_eq!(
|
|
policy.evaluate("anything", &json!({ "arg": true })),
|
|
PolicyDecision::Allow
|
|
);
|
|
|
|
let default_tool = DefaultPathTool;
|
|
let spec = default_tool.spec();
|
|
assert_eq!(spec.name, "default_path_tool");
|
|
assert_eq!(
|
|
spec.description,
|
|
"Covers default Tool trait metadata paths."
|
|
);
|
|
assert_eq!(default_tool.permission_level(), PermissionLevel::ReadOnly);
|
|
assert_eq!(
|
|
default_tool.permission_level_with_args(&json!({ "value": "x" })),
|
|
PermissionLevel::ReadOnly
|
|
);
|
|
assert_eq!(default_tool.scope(), ToolScope::All);
|
|
assert_eq!(default_tool.category(), ToolCategory::System);
|
|
assert!(!default_tool.supports_markdown());
|
|
assert!(!default_tool.is_concurrency_safe(&json!({})));
|
|
assert!(!default_tool.external_effect());
|
|
assert!(!default_tool.external_effect_with_args(&json!({})));
|
|
assert!(default_tool.generated_runtime_context(&json!({})).is_none());
|
|
assert!(default_tool.max_result_size_chars().is_none());
|
|
|
|
let png_data_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
|
let raw_screenshot = format!(
|
|
"noise\nScreenshot saved to: {}\n{png_data_url}\n",
|
|
dir.path().join("shot.png").display()
|
|
);
|
|
assert_eq!(
|
|
extract_data_url(&raw_screenshot).as_deref(),
|
|
Some(png_data_url)
|
|
);
|
|
assert_eq!(
|
|
extract_saved_path(&raw_screenshot).as_deref(),
|
|
Some(dir.path().join("shot.png").as_path())
|
|
);
|
|
let decoded = decode_data_url_bytes(png_data_url).expect("decode png");
|
|
assert_eq!(&decoded[..4], b"\x89PNG");
|
|
assert!(decode_data_url_bytes("data:text/plain;base64,aGVsbG8=")
|
|
.expect_err("non-image data URL rejected")
|
|
.contains("invalid data URL"));
|
|
let nested = dir.path().join("screens").join("nested").join("shot.png");
|
|
write_bytes_to_path(&nested, &decoded).expect("write screenshot bytes");
|
|
assert_eq!(
|
|
std::fs::read(&nested).expect("read screenshot bytes"),
|
|
decoded
|
|
);
|
|
|
|
let computer = ComputerUseConfig {
|
|
api_key: Some("secret-key".into()),
|
|
window_allowlist: vec!["OpenHuman".into()],
|
|
max_coordinate_x: Some(1920),
|
|
max_coordinate_y: Some(1080),
|
|
..ComputerUseConfig::default()
|
|
};
|
|
let debug = format!("{computer:?}");
|
|
assert!(debug.contains("[REDACTED]"));
|
|
assert!(!debug.contains("secret-key"));
|
|
let action = serde_json::to_value(BrowserAction::Screenshot {
|
|
path: Some("shot.png".into()),
|
|
full_page: true,
|
|
})
|
|
.expect("serialize browser action");
|
|
assert_eq!(action.pointer("/screenshot/path"), Some(&json!("shot.png")));
|
|
assert_eq!(action.pointer("/screenshot/full_page"), Some(&json!(true)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn orchestrator_tool_synthesis_covers_agent_and_integration_delegation_edges() {
|
|
// This test reads the process-global connection/toolkit registry (the
|
|
// integrations tool's available-toolkit list). Sibling tests mutate
|
|
// OPENHUMAN_WORKSPACE under env_lock; without holding it here, a concurrent
|
|
// workspace swap trampled our view and dropped gmail_pro/slack_bot from the
|
|
// unknown-toolkit suggestion (flaky only under llvm-cov's slower parallel
|
|
// run). Hold the same lock so this test is hermetic without serializing the
|
|
// whole suite.
|
|
let _lock = env_lock();
|
|
let mut registry = AgentDefinitionRegistry::default();
|
|
registry.insert(coverage_agent_definition(
|
|
"researcher",
|
|
"Use for careful public-source research.",
|
|
Some("research"),
|
|
));
|
|
|
|
let mut orchestrator = coverage_agent_definition("orchestrator", "Route to specialists.", None);
|
|
orchestrator.subagents = vec![
|
|
SubagentEntry::AgentId("researcher".into()),
|
|
SubagentEntry::AgentId("summarizer".into()),
|
|
SubagentEntry::AgentId("missing-agent".into()),
|
|
SubagentEntry::Skills(SkillsWildcard {
|
|
skills: "gmail".into(),
|
|
}),
|
|
SubagentEntry::Skills(SkillsWildcard { skills: "*".into() }),
|
|
];
|
|
|
|
let tools = collect_orchestrator_tools(
|
|
&orchestrator,
|
|
®istry,
|
|
&[
|
|
coverage_connected_integration("GMail Pro", "Send and triage mail.", true),
|
|
coverage_connected_integration("Slack-Bot", "", true),
|
|
coverage_connected_integration(
|
|
"Slack.Bot",
|
|
"Duplicate sanitized slug should be dropped.",
|
|
true,
|
|
),
|
|
coverage_connected_integration("Disconnected", "Should be skipped.", false),
|
|
],
|
|
);
|
|
|
|
let names = tools.iter().map(|tool| tool.name()).collect::<Vec<_>>();
|
|
assert_eq!(names, vec!["research", "delegate_to_integrations_agent"]);
|
|
|
|
let research = &tools[0];
|
|
assert!(research
|
|
.description()
|
|
.contains("direct tools are insufficient"));
|
|
assert!(research
|
|
.description()
|
|
.contains("careful public-source research"));
|
|
assert_eq!(research.permission_level(), PermissionLevel::Execute);
|
|
assert_eq!(research.category(), ToolCategory::System);
|
|
assert_eq!(
|
|
research.parameters_schema().pointer("/required/0"),
|
|
Some(&json!("prompt"))
|
|
);
|
|
let missing_prompt = research
|
|
.execute(json!({}))
|
|
.await
|
|
.expect("blank delegation prompt returns tool error");
|
|
assert!(missing_prompt.is_error);
|
|
assert!(missing_prompt.output().contains("prompt"));
|
|
|
|
let integrations = &tools[1];
|
|
let schema = integrations.parameters_schema();
|
|
assert_eq!(
|
|
schema.pointer("/properties/toolkit/enum"),
|
|
Some(&json!(["gmail_pro", "slack_bot"]))
|
|
);
|
|
let description = integrations.description();
|
|
assert!(description.contains("gmail_pro: Send and triage mail."));
|
|
assert!(description.contains("slack_bot: External integration via Slack-Bot"));
|
|
assert!(!description.contains("Slack.Bot"));
|
|
assert!(!description.contains("Disconnected"));
|
|
|
|
let missing_toolkit = integrations
|
|
.execute(json!({ "prompt": "send a message" }))
|
|
.await
|
|
.expect("missing toolkit returns tool error");
|
|
assert!(missing_toolkit.is_error);
|
|
assert!(missing_toolkit.output().contains("toolkit"));
|
|
|
|
let unknown_toolkit = integrations
|
|
.execute(json!({ "toolkit": "calendar", "prompt": "create an event" }))
|
|
.await
|
|
.expect("unknown toolkit returns tool error");
|
|
assert!(unknown_toolkit.is_error);
|
|
assert!(unknown_toolkit.output().contains("gmail_pro"));
|
|
assert!(unknown_toolkit.output().contains("slack_bot"));
|
|
|
|
let blank_prompt = integrations
|
|
.execute(json!({ "toolkit": "GMail-Pro", "prompt": " " }))
|
|
.await
|
|
.expect("blank prompt returns tool error after slug normalization");
|
|
assert!(blank_prompt.is_error);
|
|
assert!(blank_prompt.output().contains("prompt"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn browser_tool_with_agent_browser_shim_covers_action_parser_and_command_paths() {
|
|
let _lock = env_lock();
|
|
let dir = tempdir().expect("tempdir");
|
|
let bin_dir = dir.path().join("bin");
|
|
std::fs::create_dir(&bin_dir).expect("create fake bin dir");
|
|
let shim_path = bin_dir.join("agent-browser");
|
|
std::fs::write(
|
|
&shim_path,
|
|
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo agent-browser-shim; exit 0; fi\necho '{\"success\":true,\"data\":{\"ok\":true}}'\n",
|
|
)
|
|
.expect("write agent-browser shim");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&shim_path, std::fs::Permissions::from_mode(0o755))
|
|
.expect("chmod agent-browser shim");
|
|
}
|
|
|
|
let old_path = std::env::var("PATH").unwrap_or_default();
|
|
let _path_guard = EnvVarGuard::set("PATH", &format!("{}:{old_path}", bin_dir.display()));
|
|
|
|
let security = Arc::new(SecurityPolicy::from_config(
|
|
&Config::default().autonomy,
|
|
dir.path(),
|
|
dir.path(),
|
|
));
|
|
let tool = BrowserTool::new_with_backend(
|
|
security,
|
|
vec!["example.com".into()],
|
|
Some("coverage-session".into()),
|
|
"agent_browser".into(),
|
|
true,
|
|
"http://127.0.0.1:9515".into(),
|
|
None,
|
|
ComputerUseConfig::default(),
|
|
);
|
|
assert!(BrowserTool::is_agent_browser_available().await);
|
|
assert_eq!(tool.name(), "browser");
|
|
|
|
for args in [
|
|
json!({ "action": "open", "url": "https://example.com/path" }),
|
|
json!({ "action": "snapshot", "interactive_only": false, "compact": false, "depth": u64::MAX }),
|
|
json!({ "action": "click", "selector": "@e1" }),
|
|
json!({ "action": "fill", "selector": "#name", "value": "Ada" }),
|
|
json!({ "action": "type", "selector": "#name", "text": " Lovelace" }),
|
|
json!({ "action": "get_text", "selector": "main" }),
|
|
json!({ "action": "get_title" }),
|
|
json!({ "action": "get_url" }),
|
|
json!({ "action": "screenshot", "path": "shot.png", "full_page": true }),
|
|
json!({ "action": "wait", "selector": ".ready" }),
|
|
json!({ "action": "wait", "ms": 25 }),
|
|
json!({ "action": "wait", "text": "Loaded" }),
|
|
json!({ "action": "press", "key": "Enter" }),
|
|
json!({ "action": "hover", "selector": ".menu" }),
|
|
json!({ "action": "scroll", "direction": "down", "pixels": u64::MAX }),
|
|
json!({ "action": "is_visible", "selector": ".result" }),
|
|
json!({ "action": "close" }),
|
|
json!({ "action": "find", "by": "text", "value": "Submit", "find_action": "fill", "fill_value": "done" }),
|
|
] {
|
|
let result = tool
|
|
.execute(args)
|
|
.await
|
|
.expect("browser action should execute through shim");
|
|
assert!(!result.is_error, "{}", result.output());
|
|
assert!(result.output().contains("\"ok\": true"));
|
|
}
|
|
|
|
for (args, expected) in [
|
|
(
|
|
json!({ "action": "open", "url": "file:///tmp/secret.txt" }),
|
|
"file:// URLs",
|
|
),
|
|
(
|
|
json!({ "action": "fill", "selector": "#name" }),
|
|
"Missing 'value'",
|
|
),
|
|
(
|
|
json!({ "action": "find", "by": "text", "value": "Submit" }),
|
|
"Missing 'find_action'",
|
|
),
|
|
(
|
|
json!({ "action": "mouse_move", "x": 1, "y": 2 }),
|
|
"agent_browser",
|
|
),
|
|
(json!({ "action": "does_not_exist" }), "Unknown action"),
|
|
] {
|
|
let observed = match tool.execute(args).await {
|
|
Ok(result) => {
|
|
assert!(result.is_error);
|
|
result.output().to_string()
|
|
}
|
|
Err(error) => error.to_string(),
|
|
};
|
|
assert!(
|
|
observed.contains(expected),
|
|
"expected {expected:?} in {observed}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_diff_tool_reports_empty_diff_and_git_errors() {
|
|
let dir = tempdir().expect("tempdir");
|
|
std::process::Command::new("git")
|
|
.args(["init"])
|
|
.current_dir(dir.path())
|
|
.output()
|
|
.expect("git init");
|
|
std::fs::write(dir.path().join("README.md"), "coverage\n").expect("write fixture");
|
|
std::process::Command::new("git")
|
|
.args(["add", "README.md"])
|
|
.current_dir(dir.path())
|
|
.output()
|
|
.expect("git add");
|
|
std::process::Command::new("git")
|
|
.args([
|
|
"-c",
|
|
"user.email=coverage@example.test",
|
|
"-c",
|
|
"user.name=Coverage",
|
|
"commit",
|
|
"-m",
|
|
"initial",
|
|
])
|
|
.current_dir(dir.path())
|
|
.output()
|
|
.expect("git commit");
|
|
|
|
let tool = ReadDiffTool::new(dir.path().to_path_buf());
|
|
assert_eq!(tool.name(), "read_diff");
|
|
assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly);
|
|
assert_eq!(
|
|
tool.parameters_schema().pointer("/properties/staged/type"),
|
|
Some(&json!("boolean"))
|
|
);
|
|
let empty = tool.execute(json!({})).await.expect("empty diff");
|
|
assert!(!empty.is_error);
|
|
assert!(empty.output().contains("No changes found"));
|
|
|
|
std::fs::write(dir.path().join("README.md"), "coverage\nchanged\n").expect("edit fixture");
|
|
let diff = tool
|
|
.execute(json!({ "path_filter": "README.md" }))
|
|
.await
|
|
.expect("diff");
|
|
assert!(!diff.is_error);
|
|
assert!(diff.output().contains("+changed"));
|
|
|
|
let missing_ref = tool
|
|
.execute(json!({ "base": "refs/heads/does-not-exist" }))
|
|
.await
|
|
.expect("git error is a tool result");
|
|
assert!(missing_ref.is_error);
|
|
assert!(missing_ref.output().contains("does-not-exist"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channels_public_helpers_cover_cli_trait_defaults_and_webhook_parsers() {
|
|
let cli = CliChannel::new();
|
|
assert_eq!(cli.name(), "cli");
|
|
assert!(cli
|
|
.send(&SendMessage::new("hello from coverage", "stdout"))
|
|
.await
|
|
.is_ok());
|
|
assert!(cli.health_check().await);
|
|
assert!(cli.start_typing("user").await.is_ok());
|
|
assert!(cli.stop_typing("user").await.is_ok());
|
|
assert!(!cli.supports_reactions());
|
|
assert!(!cli.supports_draft_updates());
|
|
assert!(cli
|
|
.send_draft(&SendMessage::new("draft", "user"))
|
|
.await
|
|
.expect("default send_draft")
|
|
.is_none());
|
|
assert!(cli.update_draft("user", "msg-1", "draft").await.is_ok());
|
|
assert!(cli
|
|
.finalize_draft("user", "msg-1", "final", Some("thread-1"))
|
|
.await
|
|
.is_ok());
|
|
|
|
let threaded = SendMessage::with_subject("body", "recipient", "subject")
|
|
.in_thread(Some("thread-1".to_string()));
|
|
assert_eq!(threaded.subject.as_deref(), Some("subject"));
|
|
assert_eq!(threaded.thread_ts.as_deref(), Some("thread-1"));
|
|
|
|
let whatsapp = WhatsAppChannel::new(
|
|
"token".into(),
|
|
"phone-id".into(),
|
|
"verify-me".into(),
|
|
vec!["+15551234567".into()],
|
|
);
|
|
assert_eq!(whatsapp.name(), "whatsapp");
|
|
assert_eq!(whatsapp.verify_token(), "verify-me");
|
|
let whatsapp_messages = whatsapp.parse_webhook_payload(&json!({
|
|
"entry": [{
|
|
"changes": [{
|
|
"value": {
|
|
"messages": [{
|
|
"id": "wamid.1",
|
|
"from": "15551234567",
|
|
"timestamp": "1780000000",
|
|
"text": { "body": "hi from whatsapp" }
|
|
}, {
|
|
"id": "wamid.2",
|
|
"from": "15550000000",
|
|
"timestamp": "1780000001",
|
|
"text": { "body": "blocked" }
|
|
}, {
|
|
"id": "wamid.3",
|
|
"from": "15551234567",
|
|
"timestamp": "bad",
|
|
"image": { "id": "media" }
|
|
}]
|
|
}
|
|
}]
|
|
}]
|
|
}));
|
|
assert_eq!(whatsapp_messages.len(), 1);
|
|
assert_eq!(whatsapp_messages[0].sender, "+15551234567");
|
|
assert_eq!(whatsapp_messages[0].content, "hi from whatsapp");
|
|
assert!(whatsapp.parse_webhook_payload(&json!({})).is_empty());
|
|
|
|
let linq = LinqChannel::new("linq-token".into(), "+15557654321".into(), vec!["*".into()]);
|
|
assert_eq!(linq.name(), "linq");
|
|
assert_eq!(linq.phone_number(), "+15557654321");
|
|
let linq_messages = linq.parse_webhook_payload(&json!({
|
|
"event_type": "message.received",
|
|
"data": {
|
|
"chat_id": "chat-1",
|
|
"from": "15551234567",
|
|
"recipient_phone": "+15557654321",
|
|
"service": "iMessage",
|
|
"is_from_me": false,
|
|
"message": {
|
|
"id": "linq-msg-1",
|
|
"parts": [
|
|
{ "type": "text", "value": "hello" },
|
|
{ "type": "media", "url": "https://cdn.example.test/image.png", "mime_type": "image/png" },
|
|
{ "type": "media", "url": "https://cdn.example.test/file.pdf", "mime_type": "application/pdf" }
|
|
]
|
|
}
|
|
}
|
|
}));
|
|
assert_eq!(linq_messages.len(), 1);
|
|
assert_eq!(linq_messages[0].sender, "+15551234567");
|
|
assert!(linq_messages[0].content.contains("hello"));
|
|
assert!(linq_messages[0]
|
|
.content
|
|
.contains("[IMAGE:https://cdn.example.test/image.png]"));
|
|
assert!(linq
|
|
.parse_webhook_payload(&json!({ "event_type": "message.sent" }))
|
|
.is_empty());
|
|
assert!(linq
|
|
.parse_webhook_payload(&json!({
|
|
"event_type": "message.received",
|
|
"data": { "is_from_me": true }
|
|
}))
|
|
.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn channel_provider_public_paths_cover_pre_network_errors_and_utilities() {
|
|
let dingtalk = DingTalkChannel::new("client".into(), "secret".into(), vec!["*".into()]);
|
|
assert_eq!(dingtalk.name(), "dingtalk");
|
|
let missing_webhook = dingtalk
|
|
.send(&SendMessage::new("reply", "chat-without-session"))
|
|
.await
|
|
.expect_err("dingtalk send should fail before network without a session webhook");
|
|
assert!(missing_webhook.to_string().contains("No session webhook"));
|
|
|
|
let slack = SlackChannel::new("xoxb-test".into(), None, vec!["U1".into()]);
|
|
assert_eq!(slack.name(), "slack");
|
|
let (tx, _rx) = tokio::sync::mpsc::channel::<ChannelMessage>(1);
|
|
let slack_listen = slack
|
|
.listen(tx)
|
|
.await
|
|
.expect_err("slack listen should require channel_id before polling");
|
|
assert!(slack_listen.to_string().contains("channel_id required"));
|
|
|
|
let mattermost = MattermostChannel::new(
|
|
"https://mattermost.example.test///".into(),
|
|
"token".into(),
|
|
None,
|
|
vec!["*".into()],
|
|
true,
|
|
false,
|
|
);
|
|
assert_eq!(mattermost.name(), "mattermost");
|
|
let (tx, _rx) = tokio::sync::mpsc::channel::<ChannelMessage>(1);
|
|
let mattermost_listen = mattermost
|
|
.listen(tx)
|
|
.await
|
|
.expect_err("mattermost listen should require channel_id before polling");
|
|
assert!(mattermost_listen
|
|
.to_string()
|
|
.contains("channel_id required"));
|
|
assert!(mattermost.stop_typing("channel:root").await.is_ok());
|
|
|
|
let imessage = IMessageChannel::new(vec!["friend@example.test".into()]);
|
|
assert_eq!(imessage.name(), "imessage");
|
|
let invalid_target = imessage
|
|
.send(&SendMessage::new("hello", "not a valid recipient"))
|
|
.await
|
|
.expect_err("invalid iMessage target should be rejected before osascript");
|
|
assert!(invalid_target
|
|
.to_string()
|
|
.contains("Invalid iMessage target"));
|
|
|
|
let mut email_config = EmailConfig {
|
|
allowed_senders: vec![
|
|
"ALICE@example.test".into(),
|
|
"@trusted.test".into(),
|
|
"domain.test".into(),
|
|
],
|
|
..EmailConfig::default()
|
|
};
|
|
assert_eq!(email_config.imap_port, 993);
|
|
assert_eq!(email_config.smtp_port, 465);
|
|
assert_eq!(email_config.imap_folder, "INBOX");
|
|
assert!(email_config.smtp_tls);
|
|
let email = EmailChannel::new(email_config.clone());
|
|
assert_eq!(email.name(), "email");
|
|
assert!(email.is_sender_allowed("alice@example.test"));
|
|
assert!(email.is_sender_allowed("alerts@trusted.test"));
|
|
assert!(email.is_sender_allowed("bot@domain.test"));
|
|
assert!(!email.is_sender_allowed("mallory@example.test"));
|
|
assert_eq!(
|
|
EmailChannel::strip_html("<div>Hello<br><strong>friend</strong></div>"),
|
|
"Hellofriend"
|
|
);
|
|
email_config.allowed_senders = vec!["*".into()];
|
|
assert!(EmailChannel::new(email_config).is_sender_allowed("anyone@elsewhere.test"));
|
|
|
|
let qq = QQChannel::new("app".into(), "secret".into(), vec!["*".into()]);
|
|
assert_eq!(qq.name(), "qq");
|
|
let signal = SignalChannel::new(
|
|
"http://127.0.0.1:1///".into(),
|
|
"+15551234567".into(),
|
|
Some("dm".into()),
|
|
vec!["*".into()],
|
|
true,
|
|
true,
|
|
);
|
|
assert_eq!(signal.name(), "signal");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn web_channel_public_paths_cover_event_delivery_and_validation_errors() {
|
|
let mut rx = openhuman_core::openhuman::channels::web::subscribe_web_channel_events();
|
|
openhuman_core::openhuman::channels::web::publish_web_channel_event(WebChannelEvent {
|
|
event: "coverage_event".to_string(),
|
|
client_id: "client-1".to_string(),
|
|
thread_id: "thread-1".to_string(),
|
|
request_id: "request-1".to_string(),
|
|
message: Some("hello web channel".to_string()),
|
|
..Default::default()
|
|
});
|
|
let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
|
.await
|
|
.expect("web channel event should be delivered")
|
|
.expect("web channel event");
|
|
assert_eq!(event.event, "coverage_event");
|
|
assert_eq!(event.client_id, "client-1");
|
|
assert_eq!(event.thread_id, "thread-1");
|
|
assert_eq!(event.message.as_deref(), Some("hello web channel"));
|
|
|
|
assert_eq!(
|
|
openhuman_core::openhuman::channels::web::start_chat(
|
|
"",
|
|
"thread-1",
|
|
"hello",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
openhuman_core::openhuman::channels::web::ChatRequestMetadata::default(),
|
|
)
|
|
.await
|
|
.expect_err("blank client_id"),
|
|
"client_id is required"
|
|
);
|
|
assert_eq!(
|
|
openhuman_core::openhuman::channels::web::start_chat(
|
|
"client-1",
|
|
"",
|
|
"hello",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
openhuman_core::openhuman::channels::web::ChatRequestMetadata::default(),
|
|
)
|
|
.await
|
|
.expect_err("blank thread_id"),
|
|
"thread_id is required"
|
|
);
|
|
assert_eq!(
|
|
openhuman_core::openhuman::channels::web::start_chat(
|
|
"client-1",
|
|
"thread-1",
|
|
" ",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
openhuman_core::openhuman::channels::web::ChatRequestMetadata::default(),
|
|
)
|
|
.await
|
|
.expect_err("blank message"),
|
|
"message is required"
|
|
);
|
|
|
|
assert_eq!(
|
|
openhuman_core::openhuman::channels::web::cancel_chat("", "thread-1")
|
|
.await
|
|
.expect_err("blank cancel client_id"),
|
|
"client_id is required"
|
|
);
|
|
assert_eq!(
|
|
openhuman_core::openhuman::channels::web::cancel_chat("client-1", "")
|
|
.await
|
|
.expect_err("blank cancel thread_id"),
|
|
"thread_id is required"
|
|
);
|
|
assert!(
|
|
openhuman_core::openhuman::channels::web::cancel_chat("client-1", "thread-1")
|
|
.await
|
|
.expect("cancel with no in-flight request")
|
|
.is_none()
|
|
);
|
|
openhuman_core::openhuman::channels::web::invalidate_thread_sessions("thread-1").await;
|
|
assert!(
|
|
openhuman_core::openhuman::channels::web::in_flight_entries_for_test()
|
|
.await
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proactive_subscriber_routes_web_and_active_external_channel_without_network() {
|
|
async fn recv_proactive_thread(
|
|
rx: &mut tokio::sync::broadcast::Receiver<WebChannelEvent>,
|
|
thread_id: &str,
|
|
) -> WebChannelEvent {
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
|
loop {
|
|
let now = tokio::time::Instant::now();
|
|
assert!(now < deadline, "timed out waiting for {thread_id}");
|
|
let remaining = deadline - now;
|
|
let event = tokio::time::timeout(remaining, rx.recv())
|
|
.await
|
|
.expect("proactive web event should be delivered")
|
|
.expect("proactive web event");
|
|
if event.event == "proactive_message" && event.thread_id == thread_id {
|
|
return event;
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut rx = openhuman_core::openhuman::channels::web::subscribe_web_channel_events();
|
|
let capture = Arc::new(CapturingChannel::default());
|
|
let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
|
|
channels.insert("capture".into(), capture.clone());
|
|
|
|
let subscriber = ProactiveMessageSubscriber::new(Arc::new(channels), Some("capture".into()));
|
|
assert_eq!(subscriber.name(), "channels::proactive");
|
|
assert_eq!(subscriber.domains(), Some(&["cron"][..]));
|
|
|
|
subscriber
|
|
.handle(&DomainEvent::AgentTurnStarted {
|
|
session_id: "ignored".into(),
|
|
channel: "web".into(),
|
|
})
|
|
.await;
|
|
assert!(capture.sent.lock().expect("capture lock").is_empty());
|
|
|
|
subscriber
|
|
.handle(&DomainEvent::ProactiveMessageRequested {
|
|
source: "cron:coverage".into(),
|
|
message: "send through active external channel".into(),
|
|
job_name: Some("coverage_job".into()),
|
|
})
|
|
.await;
|
|
|
|
let web_event = recv_proactive_thread(&mut rx, "proactive:coverage_job").await;
|
|
assert_eq!(web_event.event, "proactive_message");
|
|
assert_eq!(web_event.client_id, "system");
|
|
assert_eq!(web_event.thread_id, "proactive:coverage_job");
|
|
assert_eq!(
|
|
web_event.full_response.as_deref(),
|
|
Some("send through active external channel")
|
|
);
|
|
assert_eq!(web_event.success, Some(true));
|
|
|
|
let sent = capture.sent.lock().expect("capture lock").clone();
|
|
assert_eq!(sent.len(), 1);
|
|
assert_eq!(sent[0].content, "send through active external channel");
|
|
// Recipient is now resolved from the channel's proactive_target (#3794),
|
|
// rather than the previously-empty recipient.
|
|
assert_eq!(sent[0].recipient, "capture");
|
|
|
|
subscriber.set_active_channel(Some("web".into()));
|
|
subscriber
|
|
.handle(&DomainEvent::ProactiveMessageRequested {
|
|
source: "cron:web-only".into(),
|
|
message: "web skips external duplicate".into(),
|
|
job_name: None,
|
|
})
|
|
.await;
|
|
let web_only_event = recv_proactive_thread(&mut rx, "proactive:system").await;
|
|
assert_eq!(web_only_event.thread_id, "proactive:system");
|
|
assert_eq!(
|
|
web_only_event.full_response.as_deref(),
|
|
Some("web skips external duplicate")
|
|
);
|
|
assert_eq!(capture.sent.lock().expect("capture lock").len(), 1);
|
|
|
|
subscriber.set_active_channel(Some("missing".into()));
|
|
subscriber
|
|
.handle(&DomainEvent::ProactiveMessageRequested {
|
|
source: "cron:missing".into(),
|
|
message: "missing external channel is logged only".into(),
|
|
job_name: Some("missing".into()),
|
|
})
|
|
.await;
|
|
let missing_event = recv_proactive_thread(&mut rx, "proactive:missing").await;
|
|
assert_eq!(missing_event.thread_id, "proactive:missing");
|
|
assert_eq!(capture.sent.lock().expect("capture lock").len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn yuanbao_shared_types_cover_message_extractors_and_state_variants() {
|
|
let frame = YuanbaoConnFrame {
|
|
cmd_type: 2,
|
|
cmd: "push".into(),
|
|
module: "yuanbao_openclaw_proxy".into(),
|
|
seq_no: 42,
|
|
msg_id: "frame-1".into(),
|
|
need_ack: true,
|
|
status: 0,
|
|
data: vec![1, 2, 3],
|
|
};
|
|
assert_eq!(frame.cmd_type, 2);
|
|
assert_eq!(frame.cmd, "push");
|
|
assert_eq!(frame.module, "yuanbao_openclaw_proxy");
|
|
assert_eq!(frame.seq_no, 42);
|
|
assert_eq!(frame.msg_id, "frame-1");
|
|
assert!(frame.need_ack);
|
|
assert_eq!(frame.status, 0);
|
|
assert_eq!(frame.data, vec![1, 2, 3]);
|
|
|
|
let text = YuanbaoMsgBodyElement {
|
|
msg_type: "TIMTextElem".into(),
|
|
msg_content: YuanbaoMsgContent {
|
|
text: Some("first".into()),
|
|
..Default::default()
|
|
},
|
|
};
|
|
let second_text = YuanbaoMsgBodyElement {
|
|
msg_type: "TIMTextElem".into(),
|
|
msg_content: YuanbaoMsgContent {
|
|
text: Some("second".into()),
|
|
..Default::default()
|
|
},
|
|
};
|
|
let image = YuanbaoMsgBodyElement {
|
|
msg_type: "TIMImageElem".into(),
|
|
msg_content: YuanbaoMsgContent {
|
|
uuid: Some("uuid".into()),
|
|
image_format: Some(3),
|
|
data: Some("inline-data".into()),
|
|
desc: Some("image desc".into()),
|
|
ext: Some("{}".into()),
|
|
sound: Some("sound-id".into()),
|
|
image_info_array: vec![
|
|
YuanbaoImageInfo {
|
|
image_type: 1,
|
|
size: 100,
|
|
width: 640,
|
|
height: 480,
|
|
url: "https://cdn.example.test/original.png".into(),
|
|
},
|
|
YuanbaoImageInfo {
|
|
image_type: 3,
|
|
size: 10,
|
|
width: 64,
|
|
height: 48,
|
|
url: String::new(),
|
|
},
|
|
],
|
|
index: Some(1),
|
|
url: Some("https://cdn.example.test/original.png".into()),
|
|
file_size: Some(100),
|
|
file_name: Some("photo.png".into()),
|
|
..Default::default()
|
|
},
|
|
};
|
|
|
|
let message = YuanbaoInboundMessage {
|
|
callback_command: "C2C.Callback".into(),
|
|
from_account: "sender-1".into(),
|
|
to_account: "bot-1".into(),
|
|
sender_nickname: "Alice".into(),
|
|
group_id: "group-id".into(),
|
|
group_code: "group-code".into(),
|
|
group_name: "Coverage Group".into(),
|
|
msg_seq: 7,
|
|
msg_random: 9,
|
|
msg_time: 1_780_000_000,
|
|
msg_key: "key".into(),
|
|
msg_id: "msg-1".into(),
|
|
msg_body: vec![text, image, second_text],
|
|
cloud_custom_data: "{}".into(),
|
|
event_time: 1_780_000_001,
|
|
bot_owner_id: "owner".into(),
|
|
recall_msg_seq_list: vec![YuanbaoImMsgSeq {
|
|
msg_seq: 6,
|
|
msg_id: "old-msg".into(),
|
|
}],
|
|
claw_msg_type: 1,
|
|
private_from_group_code: "private-group".into(),
|
|
trace_id: "trace".into(),
|
|
};
|
|
assert!(message.is_group());
|
|
assert!(message.is_recall());
|
|
assert_eq!(message.chat_id(), "group-code");
|
|
assert_eq!(message.extract_text(), "first\nsecond");
|
|
assert_eq!(
|
|
message.extract_image_urls(),
|
|
vec!["https://cdn.example.test/original.png".to_string()]
|
|
);
|
|
assert_eq!(message.callback_command, "C2C.Callback");
|
|
assert_eq!(message.to_account, "bot-1");
|
|
assert_eq!(message.sender_nickname, "Alice");
|
|
assert_eq!(message.group_id, "group-id");
|
|
assert_eq!(message.group_name, "Coverage Group");
|
|
assert_eq!(message.msg_seq, 7);
|
|
assert_eq!(message.msg_random, 9);
|
|
assert_eq!(message.msg_time, 1_780_000_000);
|
|
assert_eq!(message.msg_key, "key");
|
|
assert_eq!(message.cloud_custom_data, "{}");
|
|
assert_eq!(message.event_time, 1_780_000_001);
|
|
assert_eq!(message.bot_owner_id, "owner");
|
|
assert_eq!(message.claw_msg_type, 1);
|
|
assert_eq!(message.private_from_group_code, "private-group");
|
|
assert_eq!(message.trace_id, "trace");
|
|
|
|
let dm = YuanbaoInboundMessage {
|
|
from_account: "dm-user".into(),
|
|
..Default::default()
|
|
};
|
|
assert!(!dm.is_group());
|
|
assert!(!dm.is_recall());
|
|
assert_eq!(dm.chat_id(), "dm-user");
|
|
assert!(dm.extract_text().is_empty());
|
|
assert!(dm.extract_image_urls().is_empty());
|
|
|
|
let group_source = YuanbaoSource {
|
|
from_account: "sender".into(),
|
|
sender_nickname: "Alice".into(),
|
|
group_code: "group-code".into(),
|
|
is_group: true,
|
|
};
|
|
assert_eq!(group_source.reply_target(), "g:group-code");
|
|
assert_eq!(group_source.sender_nickname, "Alice");
|
|
let dm_source = YuanbaoSource {
|
|
from_account: "sender".into(),
|
|
is_group: false,
|
|
..Default::default()
|
|
};
|
|
assert_eq!(dm_source.reply_target(), "sender");
|
|
|
|
for kind in [
|
|
YuanbaoMessageKind::Text,
|
|
YuanbaoMessageKind::Image,
|
|
YuanbaoMessageKind::File,
|
|
YuanbaoMessageKind::Voice,
|
|
YuanbaoMessageKind::Mixed,
|
|
YuanbaoMessageKind::Recall,
|
|
] {
|
|
assert_eq!(kind, kind);
|
|
}
|
|
assert_eq!(YuanbaoMessageKind::default(), YuanbaoMessageKind::Text);
|
|
|
|
let group_info = YuanbaoGroupInfo {
|
|
code: 0,
|
|
message: "ok".into(),
|
|
group_name: "Coverage Group".into(),
|
|
owner_id: "owner".into(),
|
|
owner_nickname: "Owner".into(),
|
|
member_count: 2,
|
|
};
|
|
assert_eq!(group_info.owner_nickname, "Owner");
|
|
assert_eq!(group_info.member_count, 2);
|
|
let member = YuanbaoGroupMember {
|
|
user_id: "member-1".into(),
|
|
nickname: "Member".into(),
|
|
role: 1,
|
|
join_time: 123,
|
|
name_card: "Card".into(),
|
|
};
|
|
let page = YuanbaoGroupMemberListPage {
|
|
code: 0,
|
|
message: "ok".into(),
|
|
members: vec![member],
|
|
next_offset: 20,
|
|
is_complete: false,
|
|
};
|
|
assert_eq!(page.members[0].role, 1);
|
|
assert_eq!(page.next_offset, 20);
|
|
assert!(!page.is_complete);
|
|
|
|
let account = YuanbaoAccount {
|
|
uid: "bot".into(),
|
|
nickname: "Coverage Bot".into(),
|
|
connect_id: "connect".into(),
|
|
};
|
|
let account_json = serde_json::to_string(&account).expect("serialize yuanbao account");
|
|
assert!(account_json.contains("Coverage Bot"));
|
|
let decoded: YuanbaoAccount =
|
|
serde_json::from_str(&account_json).expect("deserialize yuanbao account");
|
|
assert_eq!(decoded.connect_id, "connect");
|
|
|
|
assert_ne!(
|
|
YuanbaoConnectionState::Disconnected,
|
|
YuanbaoConnectionState::Connecting
|
|
);
|
|
assert_eq!(
|
|
YuanbaoConnectionState::Authenticating,
|
|
YuanbaoConnectionState::Authenticating
|
|
);
|
|
assert_eq!(
|
|
YuanbaoConnectionState::Connected,
|
|
YuanbaoConnectionState::Connected
|
|
);
|
|
assert_eq!(
|
|
YuanbaoConnectionState::Reconnecting,
|
|
YuanbaoConnectionState::Reconnecting
|
|
);
|
|
}
|
|
|
|
fn yuanbao_pipeline_config() -> YuanbaoConfig {
|
|
YuanbaoConfig {
|
|
app_key: "app-key".into(),
|
|
app_secret: String::new(),
|
|
token: "token".into(),
|
|
ws_domain: "wss://yuanbao.example.test/ws".into(),
|
|
api_domain: "https://yuanbao.example.test".into(),
|
|
bot_id: "bot-uid".into(),
|
|
bot_name: "CoverageBot".into(),
|
|
owner_id: "owner-uid".into(),
|
|
dm_access: "open".into(),
|
|
group_access: "open".into(),
|
|
group_at_required: true,
|
|
..YuanbaoConfig::default()
|
|
}
|
|
}
|
|
|
|
fn yuanbao_pipeline(config: &YuanbaoConfig) -> InboundPipeline {
|
|
let state = PipelineState::new(config, config.bot_id.clone());
|
|
InboundPipeline::new(state)
|
|
}
|
|
|
|
fn yuanbao_inbound_json(fields: Value) -> Vec<u8> {
|
|
let mut base = json!({
|
|
"callback_command": "C2C.Callback",
|
|
"from_account": "alice-uid",
|
|
"to_account": "bot-uid",
|
|
"sender_nickname": "Alice",
|
|
"msg_seq": 1,
|
|
"msg_time": 1_780_000_000u64,
|
|
"msg_id": "msg-coverage-1",
|
|
"msg_body": [{
|
|
"msg_type": "TIMTextElem",
|
|
"msg_content": { "text": "hello CoverageBot" }
|
|
}]
|
|
});
|
|
let obj = base.as_object_mut().expect("base object");
|
|
for (key, value) in fields.as_object().expect("fields object") {
|
|
obj.insert(key.clone(), value.clone());
|
|
}
|
|
serde_json::to_vec(&base).expect("serialize yuanbao inbound json")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn yuanbao_channel_and_inbound_pipeline_cover_dispatch_filter_and_error_paths() {
|
|
let mut config = yuanbao_pipeline_config();
|
|
config.apply_env_defaults();
|
|
assert!(config.validate().is_ok());
|
|
assert_eq!(config.api_domain, "https://yuanbao.example.test");
|
|
assert_eq!(config.ws_domain, "wss://yuanbao.example.test/ws");
|
|
|
|
let channel = YuanbaoChannel::new(config.clone()).expect("construct yuanbao channel");
|
|
assert_eq!(channel.name(), "yuanbao");
|
|
assert!(channel.supports_draft_updates());
|
|
assert!(!channel.supports_reactions());
|
|
assert!(!channel.health_check().await);
|
|
let draft = channel
|
|
.send_draft(&SendMessage::new("draft body", "alice-uid"))
|
|
.await
|
|
.expect("yuanbao draft marker");
|
|
assert_eq!(draft.as_deref(), Some("yb-draft:alice-uid"));
|
|
assert!(channel
|
|
.update_draft("alice-uid", "yb-draft:alice-uid", "partial")
|
|
.await
|
|
.is_ok());
|
|
|
|
let pipeline = yuanbao_pipeline(&config);
|
|
match pipeline
|
|
.process(&yuanbao_inbound_json(json!({
|
|
"msg_id": "dm-1",
|
|
"msg_body": [{
|
|
"msg_type": "TIMTextElem",
|
|
"msg_content": { "text": "plain dm" }
|
|
}]
|
|
})))
|
|
.await
|
|
{
|
|
PipelineOutcome::Dispatch(ctx) => {
|
|
assert_eq!(ctx.text, "plain dm");
|
|
assert_eq!(ctx.source.reply_target(), "alice-uid");
|
|
assert_eq!(ctx.kind, YuanbaoMessageKind::Text);
|
|
assert!(!ctx.is_owner_command);
|
|
}
|
|
other => panic!("expected DM dispatch, got {other:?}"),
|
|
}
|
|
|
|
let duplicate = pipeline
|
|
.process(&yuanbao_inbound_json(json!({
|
|
"msg_id": "dm-1",
|
|
"msg_body": [{
|
|
"msg_type": "TIMTextElem",
|
|
"msg_content": { "text": "plain dm duplicate" }
|
|
}]
|
|
})))
|
|
.await;
|
|
assert!(matches!(duplicate, PipelineOutcome::Filtered("dedup")));
|
|
|
|
let recall = pipeline
|
|
.process(&yuanbao_inbound_json(json!({
|
|
"msg_id": "recall-1",
|
|
"recall_msg_seq_list": [{ "msg_seq": 1, "msg_id": "old" }]
|
|
})))
|
|
.await;
|
|
assert!(matches!(recall, PipelineOutcome::Filtered("recall_guard")));
|
|
|
|
let placeholder = pipeline
|
|
.process(&yuanbao_inbound_json(json!({
|
|
"msg_id": "placeholder-1",
|
|
"msg_body": [{
|
|
"msg_type": "TIMTextElem",
|
|
"msg_content": { "text": "[image]" }
|
|
}]
|
|
})))
|
|
.await;
|
|
assert!(matches!(
|
|
placeholder,
|
|
PipelineOutcome::Filtered("placeholder_filter")
|
|
));
|
|
|
|
let mut closed_config = config.clone();
|
|
closed_config.dm_access = "closed".into();
|
|
let closed = yuanbao_pipeline(&closed_config)
|
|
.process(&yuanbao_inbound_json(json!({ "msg_id": "closed-1" })))
|
|
.await;
|
|
assert!(matches!(closed, PipelineOutcome::Filtered("access_guard")));
|
|
|
|
let group_without_mention = pipeline
|
|
.process(&yuanbao_inbound_json(json!({
|
|
"callback_command": "Group.Callback",
|
|
"from_account": "group-user",
|
|
"group_code": "group-1",
|
|
"msg_id": "group-no-at",
|
|
"msg_body": [{
|
|
"msg_type": "TIMTextElem",
|
|
"msg_content": { "text": "hello group" }
|
|
}]
|
|
})))
|
|
.await;
|
|
assert!(matches!(
|
|
group_without_mention,
|
|
PipelineOutcome::Filtered("group_at_guard")
|
|
));
|
|
|
|
match pipeline
|
|
.process(&yuanbao_inbound_json(json!({
|
|
"callback_command": "Group.Callback",
|
|
"from_account": "group-user",
|
|
"group_code": "group-1",
|
|
"sender_nickname": "Group Alice",
|
|
"msg_id": "group-at-1",
|
|
"msg_body": [{
|
|
"msg_type": "TIMTextElem",
|
|
"msg_content": { "text": "@CoverageBot summarize this" }
|
|
}]
|
|
})))
|
|
.await
|
|
{
|
|
PipelineOutcome::Dispatch(ctx) => {
|
|
assert!(ctx.source.is_group);
|
|
assert_eq!(ctx.source.reply_target(), "g:group-1");
|
|
assert_eq!(ctx.text, "summarize this");
|
|
assert!(ctx.is_at_bot);
|
|
}
|
|
other => panic!("expected group dispatch, got {other:?}"),
|
|
}
|
|
|
|
match pipeline
|
|
.process(&yuanbao_inbound_json(json!({
|
|
"callback_command": "Group.Callback",
|
|
"from_account": "owner-uid",
|
|
"group_code": "group-1",
|
|
"msg_id": "owner-command-1",
|
|
"msg_body": [{
|
|
"msg_type": "TIMTextElem",
|
|
"msg_content": { "text": "/status" }
|
|
}]
|
|
})))
|
|
.await
|
|
{
|
|
PipelineOutcome::Dispatch(ctx) => {
|
|
assert!(ctx.is_owner_command);
|
|
assert_eq!(ctx.text, "/status");
|
|
}
|
|
other => panic!("expected owner command dispatch, got {other:?}"),
|
|
}
|
|
|
|
match pipeline
|
|
.process(&yuanbao_inbound_json(json!({
|
|
"msg_id": "mixed-1",
|
|
"msg_body": [{
|
|
"msg_type": "TIMTextElem",
|
|
"msg_content": { "text": "caption" }
|
|
}, {
|
|
"msg_type": "TIMImageElem",
|
|
"msg_content": {
|
|
"image_info_array": [{
|
|
"image_type": 1,
|
|
"size": 10,
|
|
"width": 4,
|
|
"height": 3,
|
|
"url": "https://cdn.example.test/cat.png"
|
|
}]
|
|
}
|
|
}]
|
|
})))
|
|
.await
|
|
{
|
|
PipelineOutcome::Dispatch(ctx) => {
|
|
assert_eq!(ctx.kind, YuanbaoMessageKind::Mixed);
|
|
assert_eq!(ctx.image_urls, vec!["https://cdn.example.test/cat.png"]);
|
|
}
|
|
other => panic!("expected mixed dispatch, got {other:?}"),
|
|
}
|
|
|
|
match pipeline.process(b"{not valid json").await {
|
|
PipelineOutcome::Failed(err) => assert!(err.to_string().contains("decode")),
|
|
other => panic!("expected decode failure, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn tool_registry_rpc_controllers_cover_list_get_diagnostics_and_errors() {
|
|
let _lock = env_lock();
|
|
let harness = setup().await;
|
|
|
|
let list = rpc(
|
|
&harness.rpc_base,
|
|
101,
|
|
"openhuman.tool_registry_list",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let tools = payload(&list, "tool_registry_list")
|
|
.get("tools")
|
|
.and_then(Value::as_array)
|
|
.expect("registry tools");
|
|
assert!(tools
|
|
.iter()
|
|
.any(|tool| tool.get("tool_id").and_then(Value::as_str) == Some("tools.web_search")));
|
|
|
|
let found = rpc(
|
|
&harness.rpc_base,
|
|
102,
|
|
"openhuman.tool_registry_get",
|
|
json!({ "tool_id": " tools.web_search " }),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
payload(&found, "tool_registry_get")
|
|
.get("tool_id")
|
|
.and_then(Value::as_str),
|
|
Some("tools.web_search")
|
|
);
|
|
|
|
let missing_id = rpc(
|
|
&harness.rpc_base,
|
|
103,
|
|
"openhuman.tool_registry_get",
|
|
json!({ "tool_id": " " }),
|
|
)
|
|
.await;
|
|
assert!(error_message(&missing_id, "tool_registry_get blank id")
|
|
.contains("tool_id must be a non-empty string"));
|
|
let unknown_tool = rpc(
|
|
&harness.rpc_base,
|
|
104,
|
|
"openhuman.tool_registry_get",
|
|
json!({ "tool_id": "tools.not_real" }),
|
|
)
|
|
.await;
|
|
assert!(
|
|
error_message(&unknown_tool, "tool_registry_get missing tool").contains("tool not found")
|
|
);
|
|
|
|
let diagnostics = rpc(
|
|
&harness.rpc_base,
|
|
105,
|
|
"openhuman.tool_registry_diagnostics",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let diagnostics = payload(&diagnostics, "tool_registry_diagnostics");
|
|
assert!(diagnostics
|
|
.get("total_tools")
|
|
.and_then(Value::as_u64)
|
|
.is_some_and(|count| count > 0));
|
|
assert!(diagnostics
|
|
.pointer("/mcp_allowlists/server_count")
|
|
.and_then(Value::as_u64)
|
|
.is_some());
|
|
assert!(diagnostics
|
|
.pointer("/mcp_write_audit/enabled")
|
|
.and_then(Value::as_bool)
|
|
.is_some());
|
|
|
|
harness.rpc_join.abort();
|
|
harness.backend_join.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn tool_registry_provider_and_denial_paths_cover_diagnostics() {
|
|
assert_eq!(
|
|
normalize_capability_provider_id(" Trusted Runtime.Provider "),
|
|
Ok("trusted-runtime.provider".to_string())
|
|
);
|
|
assert!(matches!(
|
|
normalize_capability_provider_id("!!!"),
|
|
Err(CapabilityProviderRegistryError::InvalidId { .. })
|
|
));
|
|
let empty_provider_id_error =
|
|
normalize_capability_provider_id(" ").expect_err("empty provider id should fail");
|
|
assert_eq!(
|
|
empty_provider_id_error.to_string(),
|
|
"invalid provider id: \"\""
|
|
);
|
|
assert!(matches!(
|
|
normalize_capability_provider_id(&"x".repeat(128)),
|
|
Err(CapabilityProviderRegistryError::InvalidId { .. })
|
|
));
|
|
|
|
let mut config = Config::default();
|
|
config.capability_providers = vec![
|
|
CapabilityProviderConfig {
|
|
id: "Trusted Runtime.Provider".into(),
|
|
display_name: " Trusted Runtime ".into(),
|
|
source_uri: Some(" https://example.test/catalog.json ".into()),
|
|
source_digest: Some(" sha256:abc123 ".into()),
|
|
trust_state: CapabilityProviderTrustState::Trusted,
|
|
enabled: true,
|
|
},
|
|
CapabilityProviderConfig {
|
|
id: "Disabled Provider".into(),
|
|
display_name: String::new(),
|
|
source_uri: Some(" ".into()),
|
|
source_digest: None,
|
|
trust_state: CapabilityProviderTrustState::Untrusted,
|
|
enabled: false,
|
|
},
|
|
];
|
|
|
|
let registry = capability_provider_registry(&config).expect("provider registry");
|
|
assert_eq!(registry.list().len(), 2);
|
|
assert!(registry.is_trusted_enabled("trusted runtime.provider"));
|
|
assert!(!registry.is_trusted_enabled("disabled provider"));
|
|
assert_eq!(
|
|
registry
|
|
.get("disabled provider")
|
|
.expect("disabled provider")
|
|
.display_name,
|
|
"disabled-provider"
|
|
);
|
|
assert_eq!(
|
|
list_capability_providers(&config)
|
|
.expect("list providers")
|
|
.len(),
|
|
2
|
|
);
|
|
assert_eq!(
|
|
capability_provider_by_id(&config, "TRUSTED RUNTIME.PROVIDER")
|
|
.expect("provider lookup")
|
|
.expect("provider")
|
|
.source_uri
|
|
.as_deref(),
|
|
Some("https://example.test/catalog.json")
|
|
);
|
|
assert!(is_capability_provider_trusted_enabled(
|
|
&config,
|
|
"trusted runtime.provider"
|
|
));
|
|
let provider_diagnostics = capability_provider_diagnostics(&config);
|
|
assert_eq!(provider_diagnostics.total_providers, 2);
|
|
assert_eq!(provider_diagnostics.enabled_providers, 1);
|
|
assert_eq!(provider_diagnostics.trusted_providers, 1);
|
|
assert_eq!(provider_diagnostics.trusted_enabled_providers, 1);
|
|
|
|
let mut duplicate_config = config.clone();
|
|
duplicate_config
|
|
.capability_providers
|
|
.push(CapabilityProviderConfig {
|
|
id: "Trusted Runtime.Provider".into(),
|
|
..CapabilityProviderConfig::default()
|
|
});
|
|
assert!(matches!(
|
|
capability_provider_registry(&duplicate_config),
|
|
Err(CapabilityProviderRegistryError::DuplicateId { .. })
|
|
));
|
|
assert!(!is_capability_provider_trusted_enabled(
|
|
&duplicate_config,
|
|
"trusted runtime.provider"
|
|
));
|
|
let duplicate_diagnostics = capability_provider_diagnostics(&duplicate_config);
|
|
assert_eq!(duplicate_diagnostics.total_providers, 3);
|
|
assert_eq!(duplicate_diagnostics.registry_errors.len(), 1);
|
|
|
|
denials::record(" coverage.tool ", "", "", "");
|
|
denials::record(
|
|
"secret.tool",
|
|
"generated",
|
|
"execute",
|
|
"blocked Bearer super-secret-token",
|
|
);
|
|
let recent_denials = denials::list(2);
|
|
assert_eq!(recent_denials[0].tool_name, "secret.tool");
|
|
assert_eq!(recent_denials[0].reason, "[redacted: sensitive content]");
|
|
assert_eq!(recent_denials[1].policy, "unknown");
|
|
assert_eq!(recent_denials[1].action, "blocked");
|
|
assert_eq!(recent_denials[1].reason, "<empty>");
|
|
denials::record(" ", "policy", "deny", "ignored because tool name is blank");
|
|
assert_eq!(denials::list(1)[0].tool_name, "secret.tool");
|
|
denials::record("long.tool", "policy", "deny", &"a".repeat(10_000));
|
|
let long_denial = denials::list(1).into_iter().next().expect("long denial");
|
|
assert_eq!(long_denial.tool_name, "long.tool");
|
|
assert!(long_denial.reason.ends_with('…'));
|
|
assert!(long_denial.reason.chars().count() <= 241);
|
|
|
|
let diagnostics = diagnostics_for_config(&config).value;
|
|
assert!(diagnostics.total_tools > 0);
|
|
assert!(diagnostics.enabled_tools > 0);
|
|
assert!(diagnostics
|
|
.policy_surfaces
|
|
.iter()
|
|
.any(|surface| surface == "approval.decide"));
|
|
assert!(diagnostics
|
|
.recent_denials
|
|
.iter()
|
|
.any(|denial| denial.tool_name == "secret.tool"));
|
|
assert_eq!(diagnostics.capability_providers.total_providers, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn generated_tools_raw_paths_cover_admission_validation_and_execution() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"message": { "type": "string" }
|
|
},
|
|
"required": ["message"]
|
|
});
|
|
let mut definition = GeneratedToolDefinition::new(
|
|
" generated.echo ",
|
|
" Execute through the generated adapter. ",
|
|
schema.clone(),
|
|
" echo-generated ",
|
|
);
|
|
definition.permission_level = PermissionLevel::Write;
|
|
definition.category = ToolCategory::Workflow;
|
|
definition.scope = ToolScope::All;
|
|
definition.provider_id = Some(" Trusted.Runtime ".into());
|
|
definition.capability_id = Some(" messages.send ".into());
|
|
definition.source_digest = Some(" sha256:generated ".into());
|
|
definition.risk = Some(GeneratedToolRisk::ExternalWrite);
|
|
definition.policy_surface = Some(" generated.surface ".into());
|
|
|
|
let admission = GeneratedToolAdmissionConfig {
|
|
enforce_provenance: true,
|
|
trusted_providers: BTreeSet::from(["TRUSTED.RUNTIME".to_string(), "bad/provider".into()]),
|
|
disabled_providers: BTreeSet::from(["ignored/provider".into()]),
|
|
existing_tool_names: BTreeSet::from(["reserved.tool".to_string()]),
|
|
..Default::default()
|
|
};
|
|
let report = admit_generated_tool_definitions(vec![definition.clone()], &admission);
|
|
assert_eq!(report.rejected, Vec::new());
|
|
assert_eq!(report.admitted.len(), 1);
|
|
let admitted = report.admitted[0].clone();
|
|
assert_eq!(admitted.name, "generated.echo");
|
|
assert_eq!(
|
|
admitted.description,
|
|
"Execute through the generated adapter."
|
|
);
|
|
assert_eq!(admitted.adapter_id, "echo-generated");
|
|
assert_eq!(admitted.provider_id.as_deref(), Some("trusted.runtime"));
|
|
assert_eq!(admitted.capability_id.as_deref(), Some("messages.send"));
|
|
assert_eq!(admitted.source_digest.as_deref(), Some("sha256:generated"));
|
|
assert_eq!(
|
|
admitted.policy_surface.as_deref(),
|
|
Some("generated.surface")
|
|
);
|
|
|
|
let adapter = Arc::new(EchoGeneratedAdapter);
|
|
let tools = generated_tools_from_definitions(vec![admitted.clone()], adapter.clone())
|
|
.expect("generated tools should instantiate");
|
|
assert_eq!(tools.len(), 1);
|
|
assert_eq!(tools[0].name(), "generated.echo");
|
|
assert_eq!(
|
|
tools[0].description(),
|
|
"Execute through the generated adapter."
|
|
);
|
|
assert_eq!(tools[0].permission_level(), PermissionLevel::Write);
|
|
assert_eq!(tools[0].category(), ToolCategory::Workflow);
|
|
assert_eq!(tools[0].scope(), ToolScope::All);
|
|
assert_eq!(tools[0].parameters_schema(), schema);
|
|
assert!(tools[0].external_effect());
|
|
let result = tools[0]
|
|
.execute(json!({ "message": "hello" }))
|
|
.await
|
|
.expect("generated tool execution");
|
|
assert!(result.output().contains("generated.echo"));
|
|
assert!(result.output().contains("hello"));
|
|
|
|
let mut duplicate = admitted.clone();
|
|
duplicate.name = "reserved.tool".into();
|
|
let duplicate_report = admit_generated_tool_definitions(vec![duplicate], &admission);
|
|
assert!(duplicate_report.admitted.is_empty());
|
|
assert!(duplicate_report.rejected[0].reason.contains("duplicate"));
|
|
|
|
let mut unsafe_name = admitted.clone();
|
|
unsafe_name.name = "Bad Tool".into();
|
|
let unsafe_report = admit_generated_tool_definitions(vec![unsafe_name], &admission);
|
|
assert!(unsafe_report.admitted.is_empty());
|
|
assert!(unsafe_report.rejected[0]
|
|
.reason
|
|
.contains("unsupported characters"));
|
|
|
|
let mut missing_provider = admitted.clone();
|
|
missing_provider.provider_id = None;
|
|
let missing_provider_report =
|
|
admit_generated_tool_definitions(vec![missing_provider], &admission);
|
|
assert!(missing_provider_report.admitted.is_empty());
|
|
assert!(missing_provider_report.rejected[0]
|
|
.reason
|
|
.contains("missing provider_id"));
|
|
|
|
let mut bad_schema = admitted.clone();
|
|
bad_schema.parameters_schema = json!({ "properties": {} });
|
|
let bad_schema_report = admit_generated_tool_definitions(vec![bad_schema], &admission);
|
|
assert!(bad_schema_report.admitted.is_empty());
|
|
assert!(bad_schema_report.rejected[0]
|
|
.reason
|
|
.contains("invalid schema"));
|
|
|
|
let mut adapter_mismatch = admitted.clone();
|
|
adapter_mismatch.adapter_id = "other-adapter".into();
|
|
match generated_tools_from_definitions(vec![adapter_mismatch], adapter) {
|
|
Ok(_) => panic!("adapter mismatch should fail"),
|
|
Err(error) => assert!(error.to_string().contains("requires adapter")),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn filesystem_and_system_tool_edges_cover_deterministic_error_and_success_paths() {
|
|
let dir = tempdir().expect("tempdir");
|
|
|
|
let memory_tool = UpdateMemoryMdTool::new(dir.path().to_path_buf());
|
|
assert_eq!(memory_tool.name(), "update_memory_md");
|
|
assert_eq!(memory_tool.permission_level(), PermissionLevel::Write);
|
|
assert_eq!(
|
|
memory_tool
|
|
.parameters_schema()
|
|
.pointer("/properties/file/enum/0"),
|
|
Some(&json!("MEMORY.md"))
|
|
);
|
|
|
|
let bad_file = memory_tool
|
|
.execute(json!({
|
|
"file": "NOTES.md",
|
|
"action": "append",
|
|
"content": "ignored"
|
|
}))
|
|
.await
|
|
.expect("bad memory file returns tool error");
|
|
assert!(bad_file.is_error);
|
|
assert!(bad_file.output().contains("not allowed"));
|
|
|
|
let appended = memory_tool
|
|
.execute(json!({
|
|
"file": "MEMORY.md",
|
|
"action": "append",
|
|
"content": "first note"
|
|
}))
|
|
.await
|
|
.expect("append memory note");
|
|
assert!(!appended.is_error, "{}", appended.output());
|
|
assert_eq!(
|
|
std::fs::read_to_string(dir.path().join("MEMORY.md")).expect("read MEMORY.md"),
|
|
"first note\n"
|
|
);
|
|
|
|
let replaced_new = memory_tool
|
|
.execute(json!({
|
|
"file": "MEMORY.md",
|
|
"action": "replace_section",
|
|
"section_title": "Facts",
|
|
"content": "alpha"
|
|
}))
|
|
.await
|
|
.expect("append missing section");
|
|
assert!(!replaced_new.is_error, "{}", replaced_new.output());
|
|
assert!(std::fs::read_to_string(dir.path().join("MEMORY.md"))
|
|
.expect("read MEMORY.md after section append")
|
|
.contains("## Facts\nalpha\n"));
|
|
|
|
let replaced_existing = memory_tool
|
|
.execute(json!({
|
|
"file": "MEMORY.md",
|
|
"action": "replace_section",
|
|
"section_title": "Facts",
|
|
"content": "beta"
|
|
}))
|
|
.await
|
|
.expect("replace existing section");
|
|
assert!(
|
|
!replaced_existing.is_error,
|
|
"{}",
|
|
replaced_existing.output()
|
|
);
|
|
let memory_md = std::fs::read_to_string(dir.path().join("MEMORY.md"))
|
|
.expect("read MEMORY.md after replace");
|
|
assert!(memory_md.contains("## Facts\nbeta\n"));
|
|
assert!(!memory_md.contains("alpha"));
|
|
|
|
let missing_section_title = memory_tool
|
|
.execute(json!({
|
|
"file": "SKILL.md",
|
|
"action": "replace_section",
|
|
"content": "body"
|
|
}))
|
|
.await
|
|
.expect_err("missing section_title is argument error");
|
|
assert!(missing_section_title.to_string().contains("section_title"));
|
|
|
|
let unknown_action = memory_tool
|
|
.execute(json!({
|
|
"file": "SKILL.md",
|
|
"action": "rewrite",
|
|
"content": "body"
|
|
}))
|
|
.await
|
|
.expect("unknown action returns tool error");
|
|
assert!(unknown_action.is_error);
|
|
assert!(unknown_action.output().contains("Unknown action"));
|
|
|
|
let linter = RunLinterTool::new(dir.path().to_path_buf());
|
|
assert_eq!(linter.name(), "run_linter");
|
|
assert_eq!(linter.permission_level(), PermissionLevel::Execute);
|
|
assert_eq!(
|
|
linter
|
|
.parameters_schema()
|
|
.pointer("/properties/linter/default"),
|
|
Some(&json!("auto"))
|
|
);
|
|
let auto = linter
|
|
.execute(json!({ "linter": "auto" }))
|
|
.await
|
|
.expect("auto linter without project files");
|
|
assert!(auto.is_error);
|
|
assert!(auto.output().contains("Could not detect project type"));
|
|
let bad_eslint_path = linter
|
|
.execute(json!({ "linter": "eslint", "path": "../escape.js" }))
|
|
.await
|
|
.expect("eslint rejects escaping path before spawn");
|
|
assert!(bad_eslint_path.is_error);
|
|
assert!(bad_eslint_path.output().contains("relative path"));
|
|
let unknown_linter = linter
|
|
.execute(json!({ "linter": "rubocop" }))
|
|
.await
|
|
.expect("unknown linter");
|
|
assert!(unknown_linter.is_error);
|
|
assert!(unknown_linter.output().contains("Unknown linter"));
|
|
|
|
std::fs::write(dir.path().join("visible.txt"), "hello").expect("write visible file");
|
|
std::fs::create_dir(dir.path().join("visible_dir")).expect("create visible dir");
|
|
let workspace = WorkspaceStateTool::new(dir.path().to_path_buf());
|
|
assert_eq!(workspace.name(), "read_workspace_state");
|
|
assert_eq!(workspace.permission_level(), PermissionLevel::ReadOnly);
|
|
let state = workspace
|
|
.execute(json!({ "include_tree": true, "recent_commits": 2 }))
|
|
.await
|
|
.expect("workspace state");
|
|
assert!(!state.is_error, "{}", state.output());
|
|
assert!(state.output().contains("## Git Status"));
|
|
assert!(state.output().contains("visible.txt"));
|
|
assert!(state.output().contains("visible_dir/"));
|
|
let no_tree = workspace
|
|
.execute(json!({ "include_tree": false }))
|
|
.await
|
|
.expect("workspace state without tree");
|
|
assert!(!no_tree.output().contains("Directory Tree"));
|
|
|
|
let insert = InsertSqlRecordTool::new();
|
|
assert_eq!(insert.name(), "insert_sql_record");
|
|
assert_eq!(insert.permission_level(), PermissionLevel::Write);
|
|
let missing_session = insert
|
|
.execute(json!({ "role": "user", "content": "hello" }))
|
|
.await
|
|
.expect_err("missing session_id");
|
|
assert!(missing_session.to_string().contains("session_id"));
|
|
let invalid_role = insert
|
|
.execute(json!({
|
|
"session_id": "s1",
|
|
"role": "system",
|
|
"content": "hello"
|
|
}))
|
|
.await
|
|
.expect("invalid role returns tool error");
|
|
assert!(invalid_role.is_error);
|
|
assert!(invalid_role.output().contains("Invalid role"));
|
|
let blank_content = insert
|
|
.execute(json!({
|
|
"session_id": "s1",
|
|
"role": "tool",
|
|
"content": " "
|
|
}))
|
|
.await
|
|
.expect("blank content returns tool error");
|
|
assert!(blank_content.is_error);
|
|
assert!(blank_content.output().contains("content"));
|
|
let staged = insert
|
|
.execute(json!({
|
|
"session_id": "s1",
|
|
"role": "assistant",
|
|
"content": "remember this",
|
|
"lesson": "short lesson"
|
|
}))
|
|
.await
|
|
.expect("valid insert is currently pending implementation");
|
|
assert!(staged.is_error);
|
|
assert!(staged.output().contains("FTS5/SQLite insert pending"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn proxy_config_tool_covers_temp_config_runtime_env_and_validation_paths() {
|
|
let _lock = env_lock();
|
|
let dir = tempdir().expect("tempdir");
|
|
let _http_guard = EnvVarGuard::unset("HTTP_PROXY");
|
|
let _https_guard = EnvVarGuard::unset("HTTPS_PROXY");
|
|
let _all_guard = EnvVarGuard::unset("ALL_PROXY");
|
|
let _no_guard = EnvVarGuard::unset("NO_PROXY");
|
|
|
|
let mut config = Config {
|
|
workspace_dir: dir.path().join("workspace"),
|
|
config_path: dir.path().join("config.toml"),
|
|
..Config::default()
|
|
};
|
|
config.autonomy.level = openhuman_core::openhuman::security::AutonomyLevel::Full;
|
|
config.save().await.expect("write temp config");
|
|
|
|
let security = Arc::new(SecurityPolicy::from_config(
|
|
&config.autonomy,
|
|
&config.workspace_dir,
|
|
&config.workspace_dir,
|
|
));
|
|
let tool = ProxyConfigTool::new(Arc::new(config.clone()), security);
|
|
assert_eq!(tool.name(), "proxy_config");
|
|
assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly);
|
|
assert_eq!(
|
|
tool.parameters_schema()
|
|
.pointer("/properties/action/enum/0"),
|
|
Some(&json!("get"))
|
|
);
|
|
|
|
let initial = tool.execute(json!({ "action": "get" })).await.expect("get");
|
|
assert!(!initial.is_error, "{}", initial.output());
|
|
assert!(initial.output().contains("\"proxy\""));
|
|
|
|
let services = tool
|
|
.execute(json!({ "action": "list_services" }))
|
|
.await
|
|
.expect("list services");
|
|
assert!(!services.is_error, "{}", services.output());
|
|
assert!(services.output().contains("provider.openai"));
|
|
assert!(services.output().contains("tool.http_request"));
|
|
|
|
let invalid_scope = tool
|
|
.execute(json!({ "action": "set", "scope": "elsewhere" }))
|
|
.await
|
|
.expect("invalid scope is tool error");
|
|
assert!(invalid_scope.is_error);
|
|
assert!(invalid_scope.output().contains("Invalid scope"));
|
|
|
|
let bad_no_proxy = tool
|
|
.execute(json!({ "action": "set", "no_proxy": [123] }))
|
|
.await
|
|
.expect("bad no_proxy is tool error");
|
|
assert!(bad_no_proxy.is_error);
|
|
assert!(bad_no_proxy
|
|
.output()
|
|
.contains("array must only contain strings"));
|
|
|
|
let set_services = tool
|
|
.execute(json!({
|
|
"action": "set",
|
|
"enabled": true,
|
|
"scope": "services",
|
|
"http_proxy": "http://127.0.0.1:8888",
|
|
"https_proxy": null,
|
|
"no_proxy": "localhost, 127.0.0.1",
|
|
"services": [" provider.openai ", "tool.http_request", ""]
|
|
}))
|
|
.await
|
|
.expect("set services proxy");
|
|
assert!(!set_services.is_error, "{}", set_services.output());
|
|
assert!(set_services
|
|
.output()
|
|
.contains("Proxy configuration updated"));
|
|
assert!(set_services.output().contains("provider.openai"));
|
|
|
|
let apply_wrong_scope = tool
|
|
.execute(json!({ "action": "apply_env" }))
|
|
.await
|
|
.expect("apply_env wrong scope is tool error");
|
|
assert!(apply_wrong_scope.is_error);
|
|
assert!(apply_wrong_scope.output().contains("environment"));
|
|
|
|
let set_environment = tool
|
|
.execute(json!({
|
|
"action": "set",
|
|
"enabled": true,
|
|
"scope": "environment",
|
|
"http_proxy": "http://127.0.0.1:8888",
|
|
"https_proxy": "http://127.0.0.1:8889",
|
|
"all_proxy": "",
|
|
"no_proxy": ["localhost", "127.0.0.1"]
|
|
}))
|
|
.await
|
|
.expect("set environment proxy");
|
|
assert!(!set_environment.is_error, "{}", set_environment.output());
|
|
assert_eq!(
|
|
std::env::var("HTTP_PROXY").as_deref(),
|
|
Ok("http://127.0.0.1:8888")
|
|
);
|
|
assert_eq!(
|
|
std::env::var("HTTPS_PROXY").as_deref(),
|
|
Ok("http://127.0.0.1:8889")
|
|
);
|
|
|
|
let clear_env = tool
|
|
.execute(json!({ "action": "clear_env" }))
|
|
.await
|
|
.expect("clear env");
|
|
assert!(!clear_env.is_error, "{}", clear_env.output());
|
|
assert!(std::env::var("HTTP_PROXY").is_err());
|
|
|
|
let disable = tool
|
|
.execute(json!({ "action": "disable", "clear_env": true }))
|
|
.await
|
|
.expect("disable proxy");
|
|
assert!(!disable.is_error, "{}", disable.output());
|
|
assert!(disable.output().contains("Proxy disabled"));
|
|
|
|
let unknown = tool
|
|
.execute(json!({ "action": "unknown" }))
|
|
.await
|
|
.expect_err("unknown action is an argument error");
|
|
assert!(unknown.to_string().contains("Unknown action"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn filesystem_search_and_system_probe_tools_cover_success_and_error_paths() {
|
|
let _lock = env_lock();
|
|
let dir = tempdir().expect("tempdir");
|
|
std::fs::create_dir_all(dir.path().join("src")).expect("create src dir");
|
|
std::fs::create_dir_all(dir.path().join("node_modules")).expect("create skipped dir");
|
|
std::fs::write(
|
|
dir.path().join("src").join("main.rs"),
|
|
"Alpha\nbeta\nalpha tail\n",
|
|
)
|
|
.expect("write grep fixture");
|
|
std::fs::write(
|
|
dir.path().join("node_modules").join("hidden.txt"),
|
|
"alpha hidden\n",
|
|
)
|
|
.expect("write skipped fixture");
|
|
|
|
let security = Arc::new(SecurityPolicy::from_config(
|
|
&Config::default().autonomy,
|
|
dir.path(),
|
|
dir.path(),
|
|
));
|
|
|
|
let file_write = FileWriteTool::new(security.clone());
|
|
assert_eq!(file_write.name(), "file_write");
|
|
assert_eq!(file_write.permission_level(), PermissionLevel::Write);
|
|
assert!(!file_write.external_effect_with_args(&json!({ "path": "new.txt" })));
|
|
let wrote = file_write
|
|
.execute(json!({ "path": "notes/new.txt", "content": "one\ntwo\none\n" }))
|
|
.await
|
|
.expect("file write executes");
|
|
assert!(!wrote.is_error, "{}", wrote.output());
|
|
assert!(file_write.external_effect_with_args(&json!({ "path": "notes/new.txt" })));
|
|
|
|
let file_read = FileReadTool::new(security.clone());
|
|
assert_eq!(file_read.name(), "file_read");
|
|
assert!(file_read.is_concurrency_safe(&json!({})));
|
|
let read = file_read
|
|
.execute(json!({ "path": "notes/new.txt" }))
|
|
.await
|
|
.expect("file read executes");
|
|
assert_eq!(read.output(), "one\ntwo\none\n");
|
|
let missing_path = file_read
|
|
.execute(json!({ "path": "missing.txt" }))
|
|
.await
|
|
.expect("missing read is tool error");
|
|
assert!(missing_path.is_error);
|
|
assert!(missing_path.output().contains("Failed to resolve"));
|
|
|
|
let list = ListFilesTool::new(security.clone());
|
|
assert_eq!(list.name(), "list");
|
|
let listing = list.execute(json!({ "path": "." })).await.expect("list");
|
|
assert!(!listing.is_error, "{}", listing.output());
|
|
assert!(listing.output().contains("dir\tnotes"));
|
|
|
|
let glob = GlobTool::new(security.clone());
|
|
assert_eq!(glob.name(), "glob");
|
|
assert!(glob.is_concurrency_safe(&json!({})));
|
|
let globbed = glob
|
|
.execute(json!({ "pattern": "notes/*.txt", "max_results": 1 }))
|
|
.await
|
|
.expect("glob");
|
|
assert!(!globbed.is_error, "{}", globbed.output());
|
|
assert!(globbed.output().contains("notes/new.txt"));
|
|
let bad_glob = glob
|
|
.execute(json!({ "pattern": "[" }))
|
|
.await
|
|
.expect("bad glob is tool error");
|
|
assert!(bad_glob.is_error);
|
|
assert!(bad_glob.output().contains("Invalid glob pattern"));
|
|
|
|
let edit = EditFileTool::new(security.clone());
|
|
assert_eq!(edit.name(), "edit");
|
|
assert!(edit.external_effect_with_args(&json!({})));
|
|
let duplicate_edit = edit
|
|
.execute(json!({
|
|
"path": "notes/new.txt",
|
|
"old_string": "one",
|
|
"new_string": "three"
|
|
}))
|
|
.await
|
|
.expect("duplicate edit is tool error");
|
|
assert!(duplicate_edit.is_error);
|
|
assert!(duplicate_edit.output().contains("matches 2 times"));
|
|
let edited = edit
|
|
.execute(json!({
|
|
"path": "notes/new.txt",
|
|
"old_string": "one",
|
|
"new_string": "three",
|
|
"replace_all": true
|
|
}))
|
|
.await
|
|
.expect("edit replace all");
|
|
assert!(!edited.is_error, "{}", edited.output());
|
|
assert_eq!(
|
|
std::fs::read_to_string(dir.path().join("notes/new.txt")).expect("read edited"),
|
|
"three\ntwo\nthree\n"
|
|
);
|
|
|
|
let patch = ApplyPatchTool::new(security.clone());
|
|
assert_eq!(patch.name(), "apply_patch");
|
|
let empty_patch = patch
|
|
.execute(json!({ "edits": [] }))
|
|
.await
|
|
.expect("empty patch is tool error");
|
|
assert!(empty_patch.is_error);
|
|
assert!(empty_patch.output().contains("empty"));
|
|
let patched = patch
|
|
.execute(json!({
|
|
"edits": [{
|
|
"path": "notes/new.txt",
|
|
"old_string": "two",
|
|
"new_string": "four"
|
|
}]
|
|
}))
|
|
.await
|
|
.expect("apply patch");
|
|
assert!(!patched.is_error, "{}", patched.output());
|
|
assert!(std::fs::read_to_string(dir.path().join("notes/new.txt"))
|
|
.expect("read patched")
|
|
.contains("four"));
|
|
|
|
let csv = CsvExportTool::new(security.clone());
|
|
assert_eq!(csv.name(), "csv_export");
|
|
let csv_result = csv
|
|
.execute(json!({
|
|
"data": r#"[{"name":"Ada, Lovelace","active":true,"score":7},{"name":"Grace","active":false,"score":9}]"#,
|
|
"filename": "coverage.csv",
|
|
"columns": ["name", "active", "score"]
|
|
}))
|
|
.await
|
|
.expect("csv export");
|
|
assert!(!csv_result.is_error, "{}", csv_result.output());
|
|
let csv_body =
|
|
std::fs::read_to_string(dir.path().join("exports/coverage.csv")).expect("read csv export");
|
|
assert!(csv_body.contains("\"Ada, Lovelace\",true,7"));
|
|
let csv_bad_json = csv
|
|
.execute(json!({ "data": "not-json", "filename": "bad.csv" }))
|
|
.await
|
|
.expect("bad csv json is tool error");
|
|
assert!(csv_bad_json.is_error);
|
|
assert!(csv_bad_json.output().contains("Failed to parse data"));
|
|
|
|
let grep = GrepTool::new(security);
|
|
assert_eq!(grep.name(), "grep");
|
|
assert_eq!(grep.permission_level(), PermissionLevel::ReadOnly);
|
|
assert!(grep.is_concurrency_safe(&json!({})));
|
|
let matches = grep
|
|
.execute(json!({
|
|
"pattern": "alpha",
|
|
"case_insensitive": true,
|
|
"path": "src",
|
|
"max_matches": 1
|
|
}))
|
|
.await
|
|
.expect("grep executes");
|
|
assert!(!matches.is_error, "{}", matches.output());
|
|
assert!(matches.output().contains("truncated at 1"));
|
|
assert!(matches.output().contains("src/main.rs:1:Alpha"));
|
|
assert!(!matches.output().contains("hidden"));
|
|
let invalid_regex = grep
|
|
.execute(json!({ "pattern": "([unterminated" }))
|
|
.await
|
|
.expect("invalid regex returns tool error");
|
|
assert!(invalid_regex.is_error);
|
|
assert!(invalid_regex.output().contains("Invalid regex"));
|
|
|
|
let detect = DetectToolsTool::new();
|
|
assert_eq!(detect.name(), "detect_tools");
|
|
let detected = detect
|
|
.execute(json!({ "tools": ["definitely_not_a_real_binary_xyz_123"] }))
|
|
.await
|
|
.expect("detect tools executes");
|
|
assert!(!detected.is_error);
|
|
let detected_json: Value = serde_json::from_str(&detected.output()).expect("detect json");
|
|
assert_eq!(detected_json.get("probed").and_then(Value::as_u64), Some(1));
|
|
assert_eq!(
|
|
detected_json.pointer("/missing/0").and_then(Value::as_str),
|
|
Some("definitely_not_a_real_binary_xyz_123")
|
|
);
|
|
|
|
let lsp = LspTool::new();
|
|
assert_eq!(lsp.name(), "lsp");
|
|
assert_eq!(lsp.permission_level(), PermissionLevel::ReadOnly);
|
|
let lsp_result = lsp
|
|
.execute(json!({ "kind": "hover", "language": "rust", "file": "src/main.rs" }))
|
|
.await
|
|
.expect("lsp returns stub result");
|
|
assert!(lsp_result.is_error);
|
|
assert!(lsp_result.output().contains("not yet implemented"));
|
|
|
|
let run_tests = RunTestsTool::new(dir.path().to_path_buf());
|
|
assert_eq!(run_tests.name(), "run_tests");
|
|
let no_project = run_tests
|
|
.execute(json!({ "runner": "auto" }))
|
|
.await
|
|
.expect("run_tests auto without project");
|
|
assert!(no_project.is_error);
|
|
assert!(no_project
|
|
.output()
|
|
.contains("Could not detect project type"));
|
|
let bad_runner = run_tests
|
|
.execute(json!({ "runner": "gradle" }))
|
|
.await
|
|
.expect("run_tests bad runner");
|
|
assert!(bad_runner.is_error);
|
|
assert!(bad_runner.output().contains("Unknown test runner"));
|
|
|
|
let update_apply = UpdateApplyTool::new(Arc::new(SecurityPolicy::from_config(
|
|
&Config::default().autonomy,
|
|
dir.path(),
|
|
dir.path(),
|
|
)));
|
|
assert_eq!(update_apply.name(), "update_apply");
|
|
assert_eq!(update_apply.permission_level(), PermissionLevel::Dangerous);
|
|
let missing_consent = update_apply
|
|
.execute(json!({}))
|
|
.await
|
|
.expect("update apply consent guard");
|
|
assert!(missing_consent.is_error);
|
|
assert!(missing_consent.output().contains("explicit user consent"));
|
|
|
|
let current_time = CurrentTimeTool::new();
|
|
assert!(current_time.supports_markdown());
|
|
let time_result = current_time
|
|
.execute_with_options(
|
|
json!({ "timezone": "Not/AReal_Zone" }),
|
|
ToolCallOptions {
|
|
prefer_markdown: true,
|
|
},
|
|
)
|
|
.await
|
|
.expect("current time executes");
|
|
assert!(!time_result.is_error, "{}", time_result.output());
|
|
assert!(time_result.output().contains("requested_timezone_error"));
|
|
assert!(time_result
|
|
.markdown_formatted
|
|
.as_deref()
|
|
.is_some_and(|md| md.contains("timezone error")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn node_and_npm_exec_tools_cover_validation_policy_and_disabled_runtime_paths() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let workspace = dir.path().join("workspace");
|
|
std::fs::create_dir_all(&workspace).expect("workspace");
|
|
|
|
let mut config = Config {
|
|
workspace_dir: workspace.clone(),
|
|
config_path: dir.path().join("config.toml"),
|
|
..Config::default()
|
|
};
|
|
config.autonomy.level = AutonomyLevel::Full;
|
|
config.node = NodeConfig {
|
|
enabled: false,
|
|
..NodeConfig::default()
|
|
};
|
|
|
|
let full_security = Arc::new(SecurityPolicy::from_config(
|
|
&config.autonomy,
|
|
&config.workspace_dir,
|
|
&config.workspace_dir,
|
|
));
|
|
let readonly_security = Arc::new(SecurityPolicy::from_config(
|
|
&openhuman_core::openhuman::config::AutonomyConfig {
|
|
level: AutonomyLevel::ReadOnly,
|
|
..config.autonomy.clone()
|
|
},
|
|
&config.workspace_dir,
|
|
&config.workspace_dir,
|
|
));
|
|
let runtime = Arc::new(NativeRuntime::new());
|
|
let bootstrap = Arc::new(NodeBootstrap::new(
|
|
config.node.clone(),
|
|
workspace,
|
|
reqwest::Client::new(),
|
|
));
|
|
|
|
let node = NodeExecTool::new(full_security.clone(), runtime.clone(), bootstrap.clone());
|
|
assert_eq!(node.name(), "node_exec");
|
|
assert_eq!(node.permission_level(), PermissionLevel::Execute);
|
|
assert!(node.description().contains("Execute JavaScript"));
|
|
assert_eq!(
|
|
node.parameters_schema()
|
|
.pointer("/properties/inline_code/type"),
|
|
Some(&json!("string"))
|
|
);
|
|
|
|
let missing_mode = node.execute(json!({})).await.expect("node mode validation");
|
|
assert!(missing_mode.is_error);
|
|
assert!(missing_mode.output().contains("exactly one"));
|
|
let conflicting_mode = node
|
|
.execute(json!({
|
|
"inline_code": "console.log('hi')",
|
|
"script_path": "script.js",
|
|
}))
|
|
.await
|
|
.expect("node conflict validation");
|
|
assert!(conflicting_mode.is_error);
|
|
assert!(conflicting_mode.output().contains("exactly one"));
|
|
|
|
let readonly_node = NodeExecTool::new(
|
|
readonly_security.clone(),
|
|
runtime.clone(),
|
|
bootstrap.clone(),
|
|
);
|
|
let blocked = readonly_node
|
|
.execute(json!({ "inline_code": "console.log('blocked')" }))
|
|
.await
|
|
.expect("node read-only block");
|
|
assert!(blocked.is_error);
|
|
assert!(blocked.output().contains("read-only mode"));
|
|
|
|
let disabled_runtime = node
|
|
.execute(json!({ "inline_code": "console.log('disabled')" }))
|
|
.await
|
|
.expect("node disabled runtime");
|
|
assert!(disabled_runtime.is_error);
|
|
assert!(disabled_runtime
|
|
.output()
|
|
.contains("Node.js runtime unavailable"));
|
|
|
|
let npm = NpmExecTool::new(full_security, runtime.clone(), bootstrap.clone());
|
|
assert_eq!(npm.name(), "npm_exec");
|
|
assert_eq!(npm.permission_level(), PermissionLevel::Execute);
|
|
assert!(npm.description().contains("npm subcommand"));
|
|
assert_eq!(
|
|
npm.parameters_schema().pointer("/required/0"),
|
|
Some(&json!("subcommand"))
|
|
);
|
|
|
|
let missing_subcommand = npm.execute(json!({})).await.expect("npm missing");
|
|
assert!(missing_subcommand.is_error);
|
|
assert!(missing_subcommand
|
|
.output()
|
|
.contains("requires a `subcommand`"));
|
|
let empty_subcommand = npm
|
|
.execute(json!({ "subcommand": " " }))
|
|
.await
|
|
.expect("npm empty");
|
|
assert!(empty_subcommand.is_error);
|
|
assert!(empty_subcommand.output().contains("cannot be empty"));
|
|
let rejected_subcommand = npm
|
|
.execute(json!({ "subcommand": "run && echo nope" }))
|
|
.await
|
|
.expect("npm metachar rejection");
|
|
assert!(rejected_subcommand.is_error);
|
|
assert!(rejected_subcommand.output().contains("rejected subcommand"));
|
|
let disallowed_subcommand = npm
|
|
.execute(json!({ "subcommand": "publish" }))
|
|
.await
|
|
.expect("npm disallowed mutation");
|
|
assert!(disallowed_subcommand.is_error);
|
|
assert!(disallowed_subcommand.output().contains("refuses to run"));
|
|
|
|
let readonly_npm = NpmExecTool::new(readonly_security, runtime, bootstrap);
|
|
let blocked_npm = readonly_npm
|
|
.execute(json!({ "subcommand": "test" }))
|
|
.await
|
|
.expect("npm read-only block");
|
|
assert!(blocked_npm.is_error);
|
|
assert!(blocked_npm.output().contains("read-only mode"));
|
|
|
|
let disabled_npm = npm
|
|
.execute(json!({ "subcommand": "test", "timeout_secs": 1 }))
|
|
.await
|
|
.expect("npm disabled runtime");
|
|
assert!(disabled_npm.is_error);
|
|
assert!(disabled_npm
|
|
.output()
|
|
.contains("Node.js runtime unavailable"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn doctor_channels_covers_no_channel_and_local_validation_paths() {
|
|
let mut empty = Config::default();
|
|
empty.channels_config = openhuman_core::openhuman::config::ChannelsConfig::default();
|
|
doctor_channels(empty)
|
|
.await
|
|
.expect("empty channel doctor is ok");
|
|
|
|
let mut config = Config::default();
|
|
config.channels_config = openhuman_core::openhuman::config::ChannelsConfig::default();
|
|
config.channels_config.imessage = Some(IMessageConfig {
|
|
allowed_contacts: Vec::new(),
|
|
});
|
|
config.channels_config.whatsapp = Some(WhatsAppConfig {
|
|
access_token: None,
|
|
phone_number_id: Some("phone-number-id".into()),
|
|
verify_token: None,
|
|
app_secret: None,
|
|
session_path: None,
|
|
pair_phone: None,
|
|
pair_code: None,
|
|
allowed_numbers: Vec::new(),
|
|
});
|
|
config.channels_config.webhook = Some(WebhookConfig {
|
|
port: 0,
|
|
secret: Some("secret".into()),
|
|
});
|
|
|
|
doctor_channels(config)
|
|
.await
|
|
.expect("doctor handles local iMessage check and invalid WhatsApp config");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn irc_channel_public_constructor_and_preconnect_send_are_deterministic() {
|
|
let irc = IrcChannel::new(IrcChannelConfig {
|
|
server: "irc.example.test".into(),
|
|
port: 6697,
|
|
nickname: "openhuman".into(),
|
|
username: None,
|
|
channels: vec!["#coverage".into()],
|
|
allowed_users: vec!["Alice".into()],
|
|
server_password: None,
|
|
nickserv_password: Some("nickserv-secret".into()),
|
|
sasl_password: Some("sasl-secret".into()),
|
|
verify_tls: false,
|
|
});
|
|
|
|
assert_eq!(irc.name(), "irc");
|
|
assert_eq!(
|
|
irc.send(&SendMessage::new("hello", "#coverage"))
|
|
.await
|
|
.expect_err("send before listen should not hit network")
|
|
.to_string(),
|
|
"IRC not connected"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn web_fetch_and_gitbooks_tools_use_local_http_backends() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let (addr, join) = serve_backend().await;
|
|
let base = format!("http://{addr}");
|
|
let security = Arc::new(SecurityPolicy::from_config(
|
|
&Config::default().autonomy,
|
|
dir.path(),
|
|
dir.path(),
|
|
));
|
|
|
|
let fetch = WebFetchTool::new(security, vec!["*".into()], Some(0), Some(5));
|
|
assert_eq!(fetch.name(), "web_fetch");
|
|
assert_eq!(fetch.permission_level(), PermissionLevel::ReadOnly);
|
|
assert!(fetch.is_concurrency_safe(&json!({})));
|
|
assert_eq!(fetch.max_result_size_chars(), Some(50_000));
|
|
let loopback_block = fetch
|
|
.execute(json!({ "url": format!("{base}/plain"), "max_bytes": 8 }))
|
|
.await
|
|
.expect("web fetch blocks loopback before network");
|
|
assert!(loopback_block.is_error);
|
|
assert!(loopback_block
|
|
.output()
|
|
.contains("Blocked local/private host"));
|
|
let bad_scheme = fetch
|
|
.execute(json!({ "url": "file:///tmp/secret" }))
|
|
.await
|
|
.expect("web fetch bad scheme");
|
|
assert!(bad_scheme.is_error);
|
|
assert!(bad_scheme.output().contains("URL rejected"));
|
|
|
|
let endpoint = format!("{base}/mcp");
|
|
let search = GitbooksSearchTool::new(endpoint.clone(), 5);
|
|
assert_eq!(search.name(), "gitbooks_search");
|
|
assert_eq!(search.permission_level(), PermissionLevel::ReadOnly);
|
|
let blank_query = search
|
|
.execute(json!({ "query": " " }))
|
|
.await
|
|
.expect("blank query");
|
|
assert!(blank_query.is_error);
|
|
assert!(blank_query.output().contains("empty"));
|
|
let searched = search
|
|
.execute(json!({ "query": "channels coverage" }))
|
|
.await
|
|
.expect("gitbooks search");
|
|
assert!(!searched.is_error, "{}", searched.output());
|
|
assert!(searched
|
|
.output()
|
|
.contains("gitbooks mocked searchDocumentation"));
|
|
|
|
let get_page = GitbooksGetPageTool::new(endpoint, 5);
|
|
assert_eq!(get_page.name(), "gitbooks_get_page");
|
|
let blank_url = get_page
|
|
.execute(json!({ "url": "" }))
|
|
.await
|
|
.expect("blank page url");
|
|
assert!(blank_url.is_error);
|
|
assert!(blank_url.output().contains("empty"));
|
|
let page = get_page
|
|
.execute(json!({ "url": "https://tinyhumans.gitbook.io/openhuman/test" }))
|
|
.await
|
|
.expect("gitbooks get page");
|
|
assert!(!page.is_error, "{}", page.output());
|
|
assert!(page.output().contains("gitbooks mocked getPage"));
|
|
|
|
join.abort();
|
|
}
|
|
|
|
#[test]
|
|
fn yuanbao_config_wire_and_splitter_helpers_cover_public_deterministic_paths() {
|
|
assert!(NO_RECONNECT_CLOSE_CODES.contains(&4012));
|
|
assert!(AUTH_FAILED_CODES.contains(&40001));
|
|
assert!(AUTH_RETRYABLE_CODES.contains(&40010));
|
|
|
|
let mut cfg = YuanbaoConfig::default();
|
|
assert_eq!(cfg.env, "prod");
|
|
assert_eq!(cfg.bot_version, "0.1.0");
|
|
assert_eq!(cfg.dm_access, "open");
|
|
assert_eq!(cfg.group_access, "allowlist");
|
|
assert!(cfg.group_at_required);
|
|
assert_eq!(cfg.max_message_length, 4500);
|
|
assert_eq!(cfg.max_media_mb, 50);
|
|
assert!(cfg
|
|
.validate()
|
|
.expect_err("default config invalid")
|
|
.to_string()
|
|
.contains("app_key"));
|
|
cfg.env = "pre".into();
|
|
cfg.apply_env_defaults();
|
|
assert_eq!(cfg.api_domain, "https://bot-pre.yuanbao.tencent.com");
|
|
assert_eq!(
|
|
cfg.ws_domain,
|
|
"wss://bot-wss-pre.yuanbao.tencent.com/wss/connection"
|
|
);
|
|
assert!(cfg
|
|
.validate()
|
|
.expect_err("missing token or secret invalid")
|
|
.to_string()
|
|
.contains("app_key"));
|
|
cfg.app_key = "app-key".into();
|
|
assert!(cfg
|
|
.validate()
|
|
.expect_err("missing token or secret invalid")
|
|
.to_string()
|
|
.contains("token"));
|
|
cfg.token = "pre-provisioned-token".into();
|
|
assert!(cfg.validate().is_ok());
|
|
|
|
let mut explicit = YuanbaoConfig {
|
|
app_key: "app-key".into(),
|
|
token: "token".into(),
|
|
api_domain: "https://custom-api.example.test".into(),
|
|
ws_domain: "wss://custom-ws.example.test".into(),
|
|
..YuanbaoConfig::default()
|
|
};
|
|
explicit.apply_env_defaults();
|
|
assert_eq!(explicit.api_domain, "https://custom-api.example.test");
|
|
assert_eq!(explicit.ws_domain, "wss://custom-ws.example.test");
|
|
assert!(explicit.validate().is_ok());
|
|
|
|
let seq_a = next_seq_no();
|
|
let seq_b = next_seq_no();
|
|
assert_eq!(seq_b, seq_a + 1);
|
|
|
|
let mut varint = Vec::new();
|
|
encode_varint(300, &mut varint);
|
|
assert_eq!(varint, vec![0xac, 0x02]);
|
|
assert_eq!(decode_varint(&varint, 0).expect("decode varint"), (300, 2));
|
|
assert!(decode_varint(&[0x80], 0)
|
|
.expect_err("truncated varint")
|
|
.to_string()
|
|
.contains("truncated varint"));
|
|
assert!(decode_varint(&[0xff; 10], 0)
|
|
.expect_err("overflow varint")
|
|
.to_string()
|
|
.contains("overflow"));
|
|
|
|
let mut fields_buf = Vec::new();
|
|
encode_field_varint(1, 42, &mut fields_buf);
|
|
encode_field_string(2, "hello", &mut fields_buf);
|
|
encode_field_bytes(2, b"again", &mut fields_buf);
|
|
fields_buf.push((3 << 3) | 5);
|
|
fields_buf.extend_from_slice(&0x1234_5678_u32.to_le_bytes());
|
|
fields_buf.push((4 << 3) | 1);
|
|
fields_buf.extend_from_slice(&0x0102_0304_0506_0708_u64.to_le_bytes());
|
|
|
|
let fields = parse_fields(&fields_buf).expect("parse mixed fields");
|
|
assert_eq!(get_varint(&fields, 1), 42);
|
|
assert_eq!(get_string(&fields, 2), "hello");
|
|
assert_eq!(get_bytes(&fields, 2), b"hello".to_vec());
|
|
assert_eq!(
|
|
get_repeated_bytes(&fields, 2),
|
|
vec![b"hello".to_vec(), b"again".to_vec()]
|
|
);
|
|
assert_eq!(get_varint(&fields, 99), 0);
|
|
assert_eq!(get_string(&fields, 99), "");
|
|
assert!(get_bytes(&fields, 99).is_empty());
|
|
assert!(fields
|
|
.iter()
|
|
.any(|(_, value)| matches!(value, FieldValue::Fixed32(0x1234_5678))));
|
|
assert!(fields
|
|
.iter()
|
|
.any(|(_, value)| matches!(value, FieldValue::Fixed64(0x0102_0304_0506_0708))));
|
|
assert!(parse_fields(&[((9 << 3) | 3) as u8])
|
|
.expect_err("unsupported wire type")
|
|
.to_string()
|
|
.contains("unsupported wire type"));
|
|
assert!(parse_fields(&[((9 << 3) | 2) as u8, 5, b'a'])
|
|
.expect_err("truncated len field")
|
|
.to_string()
|
|
.contains("truncated len field"));
|
|
|
|
assert_eq!(split_markdown("short", 100), vec!["short"]);
|
|
let fenced = "intro\n```rust\nfn alpha() {}\nfn beta() {}\n```\noutro\n";
|
|
let chunks = split_markdown(fenced, 32);
|
|
assert!(chunks.len() > 1);
|
|
assert!(chunks.iter().any(|chunk| chunk.contains("```rust")));
|
|
assert!(chunks.iter().all(|chunk| !chunk.trim().is_empty()));
|
|
let hard_split = split_markdown("é".repeat(8).as_str(), 3);
|
|
assert!(hard_split.len() > 1);
|
|
assert!(hard_split.iter().all(|chunk| chunk.len() <= 4));
|
|
}
|
|
|
|
#[test]
|
|
fn yuanbao_media_and_proto_helpers_cover_public_roundtrips() {
|
|
assert_eq!(guess_mime_type("PHOTO.JPG"), "image/jpeg");
|
|
assert_eq!(
|
|
guess_mime_type("slides.pptx"),
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
);
|
|
assert_eq!(
|
|
guess_mime_type("archive.unknown"),
|
|
"application/octet-stream"
|
|
);
|
|
assert!(is_image("avatar.webp", ""));
|
|
assert!(is_image("no-extension", "image/png"));
|
|
assert!(!is_image("notes.txt", ""));
|
|
assert_eq!(image_format_code("image/jpeg"), 1);
|
|
assert_eq!(image_format_code("image/gif"), 2);
|
|
assert_eq!(image_format_code("image/png"), 3);
|
|
assert_eq!(image_format_code("image/bmp"), 4);
|
|
assert_eq!(image_format_code("image/heic"), 255);
|
|
|
|
let png = [
|
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
|
|
0x52, 0x00, 0x00, 0x01, 0x40, 0x00, 0x00, 0x00, 0xF0,
|
|
];
|
|
let png_dims = parse_image_size(&png).expect("png dims");
|
|
assert_eq!(png_dims.width, 320);
|
|
assert_eq!(png_dims.height, 240);
|
|
let gif_dims = parse_image_size(b"GIF89a\x40\x01\xF0\x00rest").expect("gif dims");
|
|
assert_eq!(gif_dims.width, 320);
|
|
assert_eq!(gif_dims.height, 240);
|
|
let mut webp_vp8x = b"RIFF\x00\x00\x00\x00WEBPVP8X".to_vec();
|
|
webp_vp8x.extend_from_slice(&[0u8; 8]);
|
|
webp_vp8x.extend_from_slice(&[0x3F, 0x01, 0x00, 0xEF, 0x00, 0x00]);
|
|
let webp_dims = parse_image_size(&webp_vp8x).expect("webp dims");
|
|
assert_eq!(webp_dims.width, 320);
|
|
assert_eq!(webp_dims.height, 240);
|
|
assert!(parse_image_size(b"not-an-image").is_none());
|
|
|
|
let image_body = build_image_msg_body(
|
|
"https://cdn.example.test/cat.png",
|
|
None,
|
|
Some("cat.png"),
|
|
1024,
|
|
800,
|
|
600,
|
|
"image/png",
|
|
);
|
|
assert_eq!(image_body[0].msg_type, "TIMImageElem");
|
|
assert_eq!(image_body[0].msg_content.uuid.as_deref(), Some("cat.png"));
|
|
assert_eq!(image_body[0].msg_content.image_format, Some(3));
|
|
assert_eq!(
|
|
image_body[0].msg_content.image_info_array[0].url,
|
|
"https://cdn.example.test/cat.png"
|
|
);
|
|
let file_body = build_file_msg_body(
|
|
"https://cdn.example.test/report.pdf",
|
|
"report.pdf",
|
|
Some("file-uuid"),
|
|
2048,
|
|
);
|
|
assert_eq!(file_body[0].msg_type, "TIMFileElem");
|
|
assert_eq!(
|
|
file_body[0].msg_content.file_name.as_deref(),
|
|
Some("report.pdf")
|
|
);
|
|
assert_eq!(file_body[0].msg_content.file_size, Some(2048));
|
|
|
|
let frame_buf = encode_conn_msg(
|
|
cmd_type::REQUEST,
|
|
cmd::PING,
|
|
7,
|
|
"msg-7",
|
|
module::CONN_ACCESS,
|
|
b"payload",
|
|
);
|
|
let frame = decode_conn_msg(&frame_buf).expect("decode conn msg");
|
|
assert_eq!(frame.cmd_type, cmd_type::REQUEST);
|
|
assert_eq!(frame.cmd, cmd::PING);
|
|
assert_eq!(frame.seq_no, 7);
|
|
assert_eq!(frame.msg_id, "msg-7");
|
|
assert_eq!(frame.data, b"payload");
|
|
let ping = decode_conn_msg(&encode_ping("ping-1")).expect("decode ping");
|
|
assert_eq!(ping.cmd, cmd::PING);
|
|
let ack = decode_conn_msg(&encode_push_ack(&YuanbaoConnFrame {
|
|
cmd_type: cmd_type::PUSH,
|
|
cmd: "push".into(),
|
|
seq_no: 9,
|
|
msg_id: "push-1".into(),
|
|
need_ack: true,
|
|
status: 0,
|
|
module: module::BIZ_PKG.into(),
|
|
data: Vec::new(),
|
|
}))
|
|
.expect("decode ack");
|
|
assert_eq!(ack.cmd_type, cmd_type::PUSH_ACK);
|
|
assert_eq!(ack.msg_id, "push-1");
|
|
let auth = decode_conn_msg(&encode_auth_bind(
|
|
"biz", "uid", "openclaw", "token", "auth-1", "1.0.0", "linux", "2.0.0", "pre",
|
|
))
|
|
.expect("decode auth bind");
|
|
assert_eq!(auth.cmd, cmd::AUTH_BIND);
|
|
assert_eq!(auth.module, module::CONN_ACCESS);
|
|
assert!(!auth.data.is_empty());
|
|
|
|
let mut auth_rsp = Vec::new();
|
|
encode_field_varint(1, 0, &mut auth_rsp);
|
|
encode_field_string(2, "ok", &mut auth_rsp);
|
|
encode_field_string(3, "connect-1", &mut auth_rsp);
|
|
let auth_rsp = decode_auth_bind_rsp(&auth_rsp).expect("decode auth rsp");
|
|
assert_eq!(auth_rsp.message, "ok");
|
|
assert_eq!(auth_rsp.connect_id, "connect-1");
|
|
|
|
let mut push_msg = Vec::new();
|
|
encode_field_string(1, "inbound_message", &mut push_msg);
|
|
encode_field_string(2, module::BIZ_PKG, &mut push_msg);
|
|
encode_field_string(3, "push-msg-1", &mut push_msg);
|
|
encode_field_bytes(4, b"biz-payload", &mut push_msg);
|
|
let decoded_push = decode_push_msg(&push_msg).expect("decode push msg");
|
|
assert_eq!(decoded_push.cmd, "inbound_message");
|
|
assert_eq!(decoded_push.data, b"biz-payload");
|
|
|
|
let text_el = YuanbaoMsgBodyElement {
|
|
msg_type: "TIMTextElem".into(),
|
|
msg_content: YuanbaoMsgContent {
|
|
text: Some("hello from proto".into()),
|
|
..Default::default()
|
|
},
|
|
};
|
|
let mut inbound = Vec::new();
|
|
encode_field_string(1, "C2C.Callback", &mut inbound);
|
|
encode_field_string(2, "sender", &mut inbound);
|
|
encode_field_string(3, "bot", &mut inbound);
|
|
encode_field_string(4, "Alice", &mut inbound);
|
|
encode_field_varint(8, 11, &mut inbound);
|
|
encode_field_varint(10, 1_780_000_000, &mut inbound);
|
|
encode_field_string(12, "msg-11", &mut inbound);
|
|
encode_field_bytes(13, &encode_msg_body_element(&text_el), &mut inbound);
|
|
let mut recall = Vec::new();
|
|
encode_field_varint(1, 10, &mut recall);
|
|
encode_field_string(2, "old-msg", &mut recall);
|
|
encode_field_bytes(17, &recall, &mut inbound);
|
|
let mut log_ext = Vec::new();
|
|
encode_field_string(1, "trace-11", &mut log_ext);
|
|
encode_field_bytes(20, &log_ext, &mut inbound);
|
|
let decoded_inbound = decode_inbound_push(&inbound).expect("decode inbound push");
|
|
assert_eq!(decoded_inbound.callback_command, "C2C.Callback");
|
|
assert_eq!(decoded_inbound.extract_text(), "hello from proto");
|
|
assert_eq!(decoded_inbound.recall_msg_seq_list[0].msg_id, "old-msg");
|
|
assert_eq!(decoded_inbound.trace_id, "trace-11");
|
|
|
|
let decoded_json = decode_inbound_json(
|
|
br#"{
|
|
"callback_command": "Group.Callback",
|
|
"from_account": "sender-json",
|
|
"group_code": "group-json",
|
|
"msg_seq": 12,
|
|
"msg_body": [{
|
|
"msg_type": "TIMImageElem",
|
|
"msg_content": {
|
|
"uuid": "img-json",
|
|
"image_format": 3,
|
|
"image_info_array": [{
|
|
"image_type": 1,
|
|
"size": 50,
|
|
"width": 10,
|
|
"height": 20,
|
|
"url": "https://cdn.example.test/json.png"
|
|
}]
|
|
}
|
|
}],
|
|
"recall_msg_seq_list": [{ "msg_seq": 11, "msg_id": "old-json" }],
|
|
"log_ext": { "trace_id": "trace-json" }
|
|
}"#,
|
|
)
|
|
.expect("decode inbound json");
|
|
assert!(decoded_json.is_group());
|
|
assert_eq!(
|
|
decoded_json.extract_image_urls(),
|
|
vec!["https://cdn.example.test/json.png".to_string()]
|
|
);
|
|
assert_eq!(decoded_json.recall_msg_seq_list[0].msg_seq, 11);
|
|
assert_eq!(decoded_json.trace_id, "trace-json");
|
|
assert!(decode_inbound_json(b"[]")
|
|
.expect_err("json root must be object")
|
|
.to_string()
|
|
.contains("json root is not an object"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn yuanbao_sign_manager_uses_local_sign_token_backend_and_cache() {
|
|
let (addr, join) = serve_backend().await;
|
|
let api_domain = format!("http://{addr}");
|
|
|
|
let signature = compute_signature("nonce", "2026-05-29T10:00:00+08:00", "app-key", "secret");
|
|
assert_eq!(signature.len(), 64);
|
|
assert!(signature.chars().all(|ch| ch.is_ascii_hexdigit()));
|
|
assert_eq!(
|
|
signature,
|
|
compute_signature("nonce", "2026-05-29T10:00:00+08:00", "app-key", "secret")
|
|
);
|
|
let nonce = generate_nonce();
|
|
assert_eq!(nonce.len(), 32);
|
|
assert!(nonce.chars().all(|ch| ch.is_ascii_hexdigit()));
|
|
let timestamp = build_timestamp();
|
|
assert!(timestamp.ends_with("+08:00"));
|
|
|
|
let manager = SignManager::new(reqwest::Client::new());
|
|
let entry = manager
|
|
.get_token("app-key", "secret", &api_domain, "pre")
|
|
.await
|
|
.expect("sign manager fetches token");
|
|
assert_eq!(entry.token, "yuanbao-token-e2e");
|
|
assert_eq!(entry.bot_id, "yuanbao-bot-e2e");
|
|
assert_eq!(entry.product, "openhuman");
|
|
assert_eq!(entry.source, "coverage");
|
|
assert!(entry.is_valid());
|
|
assert!(entry.seconds_remaining() > 0);
|
|
|
|
let cached = manager
|
|
.cached("app-key")
|
|
.await
|
|
.expect("cached token remains valid");
|
|
assert_eq!(cached.token, entry.token);
|
|
let refreshed = manager
|
|
.force_refresh("app-key", "secret", &api_domain, "")
|
|
.await
|
|
.expect("force refresh fetches token");
|
|
assert_eq!(refreshed.bot_id, "yuanbao-bot-e2e");
|
|
manager.clear_locks().await;
|
|
join.abort();
|
|
}
|