mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 20:46:24 +00:00
553 lines
18 KiB
Rust
553 lines
18 KiB
Rust
//! Skill registry E2E: exercises browse, search, sources, and install
|
|
//! JSON-RPC endpoints against a real core router.
|
|
//!
|
|
//! Run: `cargo test --test skill_registry_e2e`
|
|
//!
|
|
//! The test uses a local fixture catalog and local SKILL.md download URL so CI
|
|
//! does not depend on the live Hermes API.
|
|
|
|
use std::net::SocketAddr;
|
|
use std::path::Path;
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use axum::http::header::AUTHORIZATION;
|
|
use axum::routing::get;
|
|
use axum::Router;
|
|
use serde_json::{json, Value};
|
|
use tempfile::tempdir;
|
|
|
|
use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
|
|
use openhuman_core::core::jsonrpc::build_core_http_router;
|
|
|
|
// ── Constants ──────────────────────────────────────────────────────────────
|
|
|
|
const TEST_RPC_TOKEN: &str = "skill-registry-e2e-token";
|
|
|
|
// ── One-time auth init ─────────────────────────────────────────────────────
|
|
|
|
static SKILL_REGISTRY_AUTH_INIT: OnceLock<()> = OnceLock::new();
|
|
|
|
fn ensure_test_rpc_auth() {
|
|
SKILL_REGISTRY_AUTH_INIT.get_or_init(|| {
|
|
unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) };
|
|
let token_dir = std::env::temp_dir().join("openhuman-skill-registry-e2e-auth");
|
|
init_rpc_token(&token_dir).expect("init rpc auth token for skill_registry_e2e");
|
|
});
|
|
}
|
|
|
|
// ── Env lock (process-global env vars must not race) ──────────────────────
|
|
|
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
static KEYRING_INIT: OnceLock<()> = OnceLock::new();
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
KEYRING_INIT.get_or_init(|| unsafe {
|
|
std::env::set_var("OPENHUMAN_KEYRING_BACKEND", "file");
|
|
});
|
|
let mutex = ENV_LOCK.get_or_init(|| Mutex::new(()));
|
|
match mutex.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
}
|
|
}
|
|
|
|
// ── EnvVarGuard ───────────────────────────────────────────────────────────
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
old: Option<String>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set_to_path(key: &'static str, path: &Path) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
unsafe { std::env::set_var(key, path.as_os_str()) };
|
|
Self { key, old }
|
|
}
|
|
|
|
fn set(key: &'static str, value: &str) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
unsafe { std::env::set_var(key, value) };
|
|
Self { key, old }
|
|
}
|
|
|
|
fn unset(key: &'static str) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
unsafe { std::env::remove_var(key) };
|
|
Self { key, old }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
match &self.old {
|
|
Some(v) => unsafe { std::env::set_var(self.key, v) },
|
|
None => unsafe { std::env::remove_var(self.key) },
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Server helpers ─────────────────────────────────────────────────────────
|
|
|
|
async fn serve_on_ephemeral(
|
|
app: axum::Router,
|
|
) -> (
|
|
SocketAddr,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
ensure_test_rpc_auth();
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind ephemeral port");
|
|
let addr = listener.local_addr().expect("local_addr");
|
|
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
|
|
(addr, handle)
|
|
}
|
|
|
|
async fn serve_fixture_catalog() -> (
|
|
SocketAddr,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
async fn catalog() -> axum::Json<Value> {
|
|
axum::Json(json!([
|
|
{
|
|
"name": "git-helper",
|
|
"description": "Automate git status and branch triage.",
|
|
"overview": "Fixture skill for registry tests.",
|
|
"category": "software-development",
|
|
"categoryLabel": "Software Development",
|
|
"source": "fixture",
|
|
"tags": ["git", "workflow"],
|
|
"platforms": ["linux", "macos"],
|
|
"author": "OpenHuman Test",
|
|
"version": "1.0.0",
|
|
"license": "MIT",
|
|
"envVars": [],
|
|
"commands": ["git"],
|
|
"docsPath": "fixture/software-development/software-development-git-helper"
|
|
},
|
|
{
|
|
"name": "notes-helper",
|
|
"description": "Summarize notes.",
|
|
"category": "productivity",
|
|
"source": "fixture",
|
|
"tags": ["notes"],
|
|
"platforms": ["linux", "macos"],
|
|
"envVars": [],
|
|
"commands": []
|
|
}
|
|
]))
|
|
}
|
|
|
|
async fn skill_md() -> &'static str {
|
|
r#"---
|
|
name: git-helper
|
|
description: Automate git status and branch triage.
|
|
version: 1.0.0
|
|
author: OpenHuman Test
|
|
license: MIT
|
|
metadata:
|
|
id: git-helper
|
|
hermes:
|
|
tags: [git, workflow]
|
|
---
|
|
|
|
# Git Helper
|
|
|
|
## When to Use
|
|
Use when git state needs summarizing.
|
|
|
|
## Procedure
|
|
Run `git status --short` and report the result.
|
|
"#
|
|
}
|
|
|
|
let app = Router::new()
|
|
.route("/skills.json", get(catalog))
|
|
.route("/skills/git-helper/SKILL.md", get(skill_md));
|
|
serve_on_ephemeral(app).await
|
|
}
|
|
|
|
// ── JSON-RPC helpers ───────────────────────────────────────────────────────
|
|
|
|
async fn post_json_rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(120))
|
|
.build()
|
|
.expect("build reqwest client");
|
|
let body = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"method": method,
|
|
"params": params,
|
|
});
|
|
let url = format!("{}/rpc", rpc_base.trim_end_matches('/'));
|
|
let resp = client
|
|
.post(&url)
|
|
.header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("POST {url}: {e}"));
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"HTTP error {} for {method}",
|
|
resp.status()
|
|
);
|
|
resp.json::<Value>()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("parse json for {method}: {e}"))
|
|
}
|
|
|
|
fn assert_no_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value {
|
|
if let Some(err) = v.get("error") {
|
|
panic!("{context}: JSON-RPC error: {err}");
|
|
}
|
|
v.get("result")
|
|
.unwrap_or_else(|| panic!("{context}: missing `result` field: {v}"))
|
|
}
|
|
|
|
// ── Test ───────────────────────────────────────────────────────────────────
|
|
|
|
/// End-to-end coverage for the `openhuman.skill_registry_*` endpoints.
|
|
///
|
|
/// Steps:
|
|
/// 1. `sources` — lists the distinct upstream sources from the Hermes catalog.
|
|
/// 2. `browse` — fetches the live catalog (force_refresh = true).
|
|
/// 3. `search` — queries for "git" and expects at least one match.
|
|
/// 4. `schemas` — exposes CLI/RPC schemas for prod smoke scripts.
|
|
/// 5. `install` — happy-path install of a skill.
|
|
/// 6. `install` — duplicate install returns idempotent success with no new skills.
|
|
/// 7. `uninstall` — removes the installed skill.
|
|
#[tokio::test]
|
|
async fn skill_registry_e2e_sources_browse_search_install() {
|
|
let _env_lock = env_lock();
|
|
|
|
let tmp = tempdir().expect("create tempdir");
|
|
let home = tmp.path();
|
|
let openhuman_home = home.join(".openhuman");
|
|
|
|
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
|
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
|
let _token_guard = EnvVarGuard::set(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN);
|
|
let _keyring_guard = EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file");
|
|
|
|
let cfg_dir = openhuman_home.clone();
|
|
std::fs::create_dir_all(&cfg_dir).expect("create .openhuman dir");
|
|
std::fs::write(
|
|
cfg_dir.join("config.toml"),
|
|
r#"api_url = "http://127.0.0.1:9"
|
|
default_model = "skill-e2e-model"
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
"#,
|
|
)
|
|
.expect("write config.toml");
|
|
|
|
let user_cfg_dir = openhuman_home.join("users").join("local");
|
|
std::fs::create_dir_all(&user_cfg_dir).expect("create users/local dir");
|
|
std::fs::write(
|
|
user_cfg_dir.join("config.toml"),
|
|
r#"api_url = "http://127.0.0.1:9"
|
|
default_model = "skill-e2e-model"
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
"#,
|
|
)
|
|
.expect("write users/local/config.toml");
|
|
|
|
let (fixture_addr, fixture_join) = serve_fixture_catalog().await;
|
|
let fixture_base = format!("http://{fixture_addr}");
|
|
let _catalog_guard = EnvVarGuard::set(
|
|
"OPENHUMAN_SKILL_REGISTRY_CATALOG_URL",
|
|
&format!("{fixture_base}/skills.json"),
|
|
);
|
|
let _download_guard = EnvVarGuard::set(
|
|
"OPENHUMAN_SKILL_REGISTRY_DOWNLOAD_BASE_URL",
|
|
&format!("{fixture_base}/skills"),
|
|
);
|
|
let _local_http_guard = EnvVarGuard::set("OPENHUMAN_SKILL_INSTALL_ALLOW_LOCAL_HTTP", "1");
|
|
|
|
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
|
let rpc_base = format!("http://{rpc_addr}");
|
|
|
|
// ── Step 1: sources ────────────────────────────────────────────────────
|
|
|
|
let sources_resp = post_json_rpc(
|
|
&rpc_base,
|
|
9001,
|
|
"openhuman.skill_registry_sources",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let sources_result = assert_no_jsonrpc_error(&sources_resp, "skill_registry_sources");
|
|
|
|
let sources = sources_result
|
|
.get("sources")
|
|
.and_then(Value::as_array)
|
|
.expect("sources result must contain a `sources` array");
|
|
|
|
assert!(
|
|
!sources.is_empty(),
|
|
"expected at least one source from the Hermes catalog"
|
|
);
|
|
|
|
// Sources should be string values (e.g. "built-in", "ClawHub", "skills.sh").
|
|
for source in sources {
|
|
assert!(
|
|
source.is_string(),
|
|
"each source must be a string, got: {source}"
|
|
);
|
|
}
|
|
|
|
// ── Step 2: browse (force_refresh = true) ─────────────────────────────
|
|
|
|
let browse_resp = post_json_rpc(
|
|
&rpc_base,
|
|
9002,
|
|
"openhuman.skill_registry_browse",
|
|
json!({ "force_refresh": true }),
|
|
)
|
|
.await;
|
|
let browse_result = assert_no_jsonrpc_error(&browse_resp, "skill_registry_browse");
|
|
|
|
let entries = browse_result
|
|
.get("entries")
|
|
.and_then(Value::as_array)
|
|
.expect("browse result must contain an `entries` array");
|
|
|
|
assert!(
|
|
!entries.is_empty(),
|
|
"browse catalog must return at least one entry after force_refresh"
|
|
);
|
|
|
|
// Every entry must carry the required fields.
|
|
let required_entry_fields = [
|
|
"id",
|
|
"name",
|
|
"description",
|
|
"download_url",
|
|
"source",
|
|
"category",
|
|
];
|
|
for entry in entries.iter().take(10) {
|
|
for field in &required_entry_fields {
|
|
assert!(
|
|
entry.get(field).is_some(),
|
|
"catalog entry missing field '{field}': {entry}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Step 3: search ────────────────────────────────────────────────────
|
|
|
|
let search_resp = post_json_rpc(
|
|
&rpc_base,
|
|
9003,
|
|
"openhuman.skill_registry_search",
|
|
json!({ "query": "git" }),
|
|
)
|
|
.await;
|
|
let search_result = assert_no_jsonrpc_error(&search_resp, "skill_registry_search (git)");
|
|
|
|
let search_entries = search_result
|
|
.get("entries")
|
|
.and_then(Value::as_array)
|
|
.expect("search result must contain an `entries` array");
|
|
|
|
assert!(
|
|
!search_entries.is_empty(),
|
|
"search for 'git' must return at least one match"
|
|
);
|
|
|
|
for entry in search_entries.iter().take(5) {
|
|
let name = entry
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
let desc = entry
|
|
.get("description")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
let tags: Vec<String> = entry
|
|
.get("tags")
|
|
.and_then(Value::as_array)
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|t| t.as_str())
|
|
.map(str::to_lowercase)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let category = entry
|
|
.get("category")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
let author = entry
|
|
.get("author")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
|
|
let matches = name.contains("git")
|
|
|| desc.contains("git")
|
|
|| tags.iter().any(|t| t.contains("git"))
|
|
|| category.contains("git")
|
|
|| author.contains("git");
|
|
assert!(
|
|
matches,
|
|
"search result entry does not match query 'git': {entry}"
|
|
);
|
|
}
|
|
|
|
// ── Step 4: schemas ───────────────────────────────────────────────────
|
|
|
|
let schemas_resp = post_json_rpc(
|
|
&rpc_base,
|
|
9004,
|
|
"openhuman.skill_registry_schemas",
|
|
json!({}),
|
|
)
|
|
.await;
|
|
let schemas_result = assert_no_jsonrpc_error(&schemas_resp, "skill_registry_schemas");
|
|
let schemas = schemas_result
|
|
.get("schemas")
|
|
.and_then(Value::as_array)
|
|
.expect("schemas result must contain a `schemas` array");
|
|
assert!(
|
|
schemas.iter().any(|schema| {
|
|
schema.get("function").and_then(Value::as_str) == Some("install")
|
|
&& schema.get("namespace").and_then(Value::as_str) == Some("skill_registry")
|
|
}),
|
|
"schemas must include skill_registry install schema: {schemas:?}"
|
|
);
|
|
|
|
// ── Step 5: install (happy path) ──────────────────────────────────────
|
|
|
|
// Find the fixture entry with a local download_url.
|
|
let install_target = entries
|
|
.iter()
|
|
.find(|e| {
|
|
e.get("download_url")
|
|
.and_then(Value::as_str)
|
|
.map(|u| u == format!("{fixture_base}/skills/git-helper/SKILL.md"))
|
|
.unwrap_or(false)
|
|
})
|
|
.expect("expected the fixture git-helper download_url");
|
|
|
|
let entry_id = install_target
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.expect("install_target id");
|
|
|
|
let install_resp = post_json_rpc(
|
|
&rpc_base,
|
|
9005,
|
|
"openhuman.skill_registry_install",
|
|
json!({ "entry_id": entry_id }),
|
|
)
|
|
.await;
|
|
let install_result = assert_no_jsonrpc_error(&install_resp, "skill_registry_install (happy)");
|
|
|
|
let install_url = install_result
|
|
.get("url")
|
|
.and_then(Value::as_str)
|
|
.expect("install result must contain `url`");
|
|
assert!(
|
|
!install_url.is_empty(),
|
|
"install result `url` must not be empty"
|
|
);
|
|
|
|
let install_stdout = install_result
|
|
.get("stdout")
|
|
.and_then(Value::as_str)
|
|
.expect("install result must contain `stdout`");
|
|
assert!(
|
|
install_stdout.contains("Installed to"),
|
|
"install stdout should mention 'Installed to', got: {install_stdout}"
|
|
);
|
|
|
|
let _install_stderr = install_result
|
|
.get("stderr")
|
|
.expect("install result must contain `stderr`");
|
|
|
|
let new_skills = install_result
|
|
.get("new_skills")
|
|
.and_then(Value::as_array)
|
|
.expect("install result must contain `new_skills` array");
|
|
assert!(
|
|
new_skills.iter().any(|s| s.as_str() == Some(entry_id)),
|
|
"new_skills must contain '{entry_id}', got: {new_skills:?}"
|
|
);
|
|
|
|
// Verify the SKILL.md file actually landed on disk.
|
|
let skill_file = home
|
|
.join(".openhuman")
|
|
.join("skills")
|
|
.join(entry_id)
|
|
.join("SKILL.md");
|
|
assert!(
|
|
skill_file.exists(),
|
|
"SKILL.md should exist on disk at {}, but was not found",
|
|
skill_file.display()
|
|
);
|
|
|
|
// ── Step 6: install (duplicate no-op success) ────────────────────────
|
|
|
|
let dup_resp = post_json_rpc(
|
|
&rpc_base,
|
|
9006,
|
|
"openhuman.skill_registry_install",
|
|
json!({ "entry_id": entry_id }),
|
|
)
|
|
.await;
|
|
let dup_result = assert_no_jsonrpc_error(&dup_resp, "skill_registry_install (duplicate)");
|
|
let dup_stdout = dup_result
|
|
.get("stdout")
|
|
.and_then(Value::as_str)
|
|
.expect("duplicate install result must contain `stdout`");
|
|
assert!(
|
|
dup_stdout.contains("already installed"),
|
|
"duplicate install stdout should mention 'already installed', got: {dup_stdout}"
|
|
);
|
|
let dup_new_skills = dup_result
|
|
.get("new_skills")
|
|
.and_then(Value::as_array)
|
|
.expect("duplicate install result must contain `new_skills` array");
|
|
assert!(
|
|
dup_new_skills.is_empty(),
|
|
"duplicate install should report no new skills, got: {dup_new_skills:?}"
|
|
);
|
|
|
|
// ── Step 7: uninstall ─────────────────────────────────────────────────
|
|
|
|
let uninstall_resp = post_json_rpc(
|
|
&rpc_base,
|
|
9007,
|
|
"openhuman.skill_registry_uninstall",
|
|
json!({ "name": entry_id }),
|
|
)
|
|
.await;
|
|
let uninstall_result = assert_no_jsonrpc_error(&uninstall_resp, "skill_registry_uninstall");
|
|
assert_eq!(
|
|
uninstall_result.get("name").and_then(Value::as_str),
|
|
Some(entry_id)
|
|
);
|
|
assert!(
|
|
!skill_file.exists(),
|
|
"SKILL.md should be removed after uninstall at {}",
|
|
skill_file.display()
|
|
);
|
|
|
|
// ── Cleanup ───────────────────────────────────────────────────────────
|
|
|
|
rpc_join.abort();
|
|
fixture_join.abort();
|
|
}
|