diff --git a/.env.example b/.env.example index 9e71f27b0..add144845 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,14 @@ BACKEND_URL=https://staging-api.alphahuman.xyz # [required] Also read by Vite frontend (VITE_ prefix required for browser exposure) VITE_BACKEND_URL=https://staging-api.alphahuman.xyz +# --------------------------------------------------------------------------- +# Authentication (for skills OAuth proxy and debug scripts) +# --------------------------------------------------------------------------- +# [optional] Session JWT — used by QuickJS skills sandbox for oauth.fetch proxy calls. +# Also used by debug scripts (scripts/debug-skill.sh, scripts/debug-notion-live.sh). +# Get from login flow or browser devtools. +JWT_TOKEN= + # --------------------------------------------------------------------------- # Core process # --------------------------------------------------------------------------- diff --git a/app/src/lib/skills/manager.ts b/app/src/lib/skills/manager.ts index 14faec55d..c7fd9768d 100644 --- a/app/src/lib/skills/manager.ts +++ b/app/src/lib/skills/manager.ts @@ -8,7 +8,12 @@ import { callCoreRpc } from "../../services/coreRpcClient"; import { SkillRuntime } from "./runtime"; import { emitSkillStateChange } from "./skillEvents"; -import { setSetupComplete as rpcSetSetupComplete } from "./skillsApi"; +import { + getSkillSnapshot, + setSetupComplete as rpcSetSetupComplete, + revokeOAuth as rpcRevokeOAuth, + removePersistedOAuthCredential, +} from "./skillsApi"; import { syncToolsToBackend } from "./sync"; import type { SkillManifest, @@ -340,10 +345,53 @@ class SkillManager { } /** - * Disconnect a skill — stop it and reset setup state. + * Disconnect a skill — revoke OAuth credentials, stop it, and reset setup state. */ async disconnectSkill(skillId: string): Promise { + // Read the stored credential ID so oauth/revoked clears the right memory bucket. + let credentialId: string | undefined; + try { + const snap = await getSkillSnapshot(skillId); + const cred = snap?.state?.__oauth_credential as + | { credentialId?: string } + | string + | undefined; + if (cred && typeof cred === "object") { + credentialId = cred.credentialId; + } + } catch { + // Snapshot may fail if skill isn't registered yet + } + + // Revoke OAuth credential before stopping so the running skill can clean up + // its in-memory state and the event loop deletes oauth_credential.json. + let revokeSucceeded = false; + if (credentialId) { + try { + await rpcRevokeOAuth(skillId, credentialId); + revokeSucceeded = true; + } catch (err) { + console.debug( + "[SkillManager] oauth/revoked failed (runtime may be stopped):", + err, + ); + } + } + await this.stopSkill(skillId); + + // Host-side fallback: if the RPC couldn't reach the runtime (already stopped, + // or non-OAuth skill), delete the persisted credential file so it isn't + // restored on next start. + if (!revokeSucceeded) { + await removePersistedOAuthCredential(skillId).catch((err) => { + console.debug( + "[SkillManager] host-side credential cleanup failed:", + err, + ); + }); + } + await rpcSetSetupComplete(skillId, false).catch(() => {}); emitSkillStateChange(skillId); syncToolsToBackend(); diff --git a/app/src/lib/skills/skillsApi.ts b/app/src/lib/skills/skillsApi.ts index 15bdfc7d2..85f9d6616 100644 --- a/app/src/lib/skills/skillsApi.ts +++ b/app/src/lib/skills/skillsApi.ts @@ -118,6 +118,35 @@ export async function setSetupComplete(skillId: string, complete: boolean): Prom }); } +export async function revokeOAuth(skillId: string, integrationId: string): Promise { + await callCoreRpc({ + method: 'openhuman.skills_rpc', + params: { + skill_id: skillId, + method: 'oauth/revoked', + params: { integrationId }, + }, + }); +} + +/** + * Host-side fallback: delete oauth_credential.json from the skill's data dir. + * Used when the runtime is already stopped so oauth/revoked RPC can't reach it. + */ +export async function removePersistedOAuthCredential(skillId: string): Promise { + await callCoreRpc({ + method: 'openhuman.skills_data_write', + params: { skill_id: skillId, filename: 'oauth_credential.json', content: '' }, + }); +} + +export async function disableSkill(skillId: string): Promise { + await callCoreRpc({ + method: 'openhuman.skills_disable', + params: { skill_id: skillId }, + }); +} + export async function fetchRegistryFresh(): Promise { await callCoreRpc({ method: 'openhuman.skills_registry_fetch', diff --git a/scripts/debug-notion-live.sh b/scripts/debug-notion-live.sh new file mode 100755 index 000000000..5b8c42df2 --- /dev/null +++ b/scripts/debug-notion-live.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# +# debug-notion-live.sh — Debug Notion skill with a live backend + JWT. +# +# Loads environment from .env (BACKEND_URL, JWT_TOKEN, etc.) +# +# Tests the full OAuth proxy chain that the Notion skill uses: +# 1. Raw HTTP call to backend proxy endpoint +# 2. Skill startup with BACKEND_URL + session token +# 3. Tool call that uses oauth.fetch (proxied through backend) +# +# Usage: +# bash scripts/debug-notion-live.sh +# +# Environment variables (set in .env or override via export): +# BACKEND_URL — staging or prod backend +# JWT_TOKEN — session JWT +# CREDENTIAL_ID — Notion OAuth credential ID +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Load .env +if [ -f "$REPO_ROOT/.env" ]; then + source "$SCRIPT_DIR/load-dotenv.sh" "$REPO_ROOT/.env" +fi + +BACKEND_URL="${BACKEND_URL:-}" +JWT_TOKEN="${JWT_TOKEN:-}" +CREDENTIAL_ID="${CREDENTIAL_ID:-}" + +# Read credential ID from oauth_credential.json if not set +if [ -z "$CREDENTIAL_ID" ]; then + CRED_FILE="$HOME/.openhuman/skills_data/notion/oauth_credential.json" + if [ -f "$CRED_FILE" ]; then + CREDENTIAL_ID=$(python3 -c "import json; print(json.load(open('$CRED_FILE')).get('credentialId',''))" 2>/dev/null || echo "") + fi +fi + +if [ -z "$BACKEND_URL" ]; then + echo "ERROR: BACKEND_URL not set. Add it to .env or export it." + exit 1 +fi + +if [ -z "$JWT_TOKEN" ]; then + echo "ERROR: JWT_TOKEN not set. Add it to .env or export it." + exit 1 +fi + +echo "╔════════════════════════════════════════════════════════╗" +echo "║ Notion Skill Live Debug ║" +echo "╠════════════════════════════════════════════════════════╣" +echo "║ Backend: $BACKEND_URL" +echo "║ Credential ID: ${CREDENTIAL_ID:-}" +echo "║ JWT: ${JWT_TOKEN:0:20}..." +echo "╚════════════════════════════════════════════════════════╝" +echo "" + +# ── Step 1: Check backend health ── +echo "--- Step 1: Backend Health Check ---" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BACKEND_URL/settings" -H "Authorization: Bearer $JWT_TOKEN" 2>/dev/null || echo "000") +echo " GET /settings → HTTP $HTTP_CODE" + +if [ "$HTTP_CODE" = "000" ] || [ "$HTTP_CODE" = "502" ] || [ "$HTTP_CODE" = "503" ]; then + echo " ✗ Backend is DOWN (HTTP $HTTP_CODE)" + echo "" + echo " The backend at $BACKEND_URL is unreachable." + echo " The Notion skill uses oauth.fetch() which proxies through:" + echo " $BACKEND_URL/proxy/by-id/$CREDENTIAL_ID/{path}" + echo "" + echo " Fix: Bring the backend online, then re-run this script." + exit 1 +fi + +if [ "$HTTP_CODE" = "401" ]; then + echo " ✗ JWT is invalid or expired (HTTP 401)" + echo " Get a fresh JWT and set JWT_TOKEN in .env" + exit 1 +fi + +echo " ✓ Backend reachable (HTTP $HTTP_CODE)" + +# ── Step 2: Raw proxy call ── +if [ -n "$CREDENTIAL_ID" ]; then + echo "" + echo "--- Step 2: Raw OAuth Proxy Call ---" + echo " Testing: GET $BACKEND_URL/proxy/by-id/$CREDENTIAL_ID/v1/users?page_size=1" + PROXY_RESP=$(curl -s -w "\n__HTTP_CODE__:%{http_code}" \ + "$BACKEND_URL/proxy/by-id/$CREDENTIAL_ID/v1/users?page_size=1" \ + -H "Authorization: Bearer $JWT_TOKEN" \ + -H "Content-Type: application/json" 2>/dev/null || echo "__HTTP_CODE__:000") + + PROXY_BODY=$(echo "$PROXY_RESP" | sed '/__HTTP_CODE__/d') + PROXY_CODE=$(echo "$PROXY_RESP" | grep "__HTTP_CODE__" | cut -d: -f2) + + echo " HTTP $PROXY_CODE" + if [ "$PROXY_CODE" = "200" ]; then + echo " ✓ Notion API accessible via proxy" + echo " Response: ${PROXY_BODY:0:200}..." + else + echo " ✗ Proxy returned HTTP $PROXY_CODE" + echo " Response: $PROXY_BODY" + fi +else + echo "" + echo "--- Step 2: SKIPPED (no CREDENTIAL_ID) ---" +fi + +# ── Step 3: Test via Rust runtime ── +echo "" +echo "--- Step 3: Skill Runtime Test (with live backend) ---" + +export SKILL_DEBUG_ID=notion +export SKILL_DEBUG_TOOL=sync-status +export RUST_LOG="${RUST_LOG:-info}" + +STEP3_OUT=$(cargo test --test skills_debug_e2e skill_full_lifecycle -- --nocapture 2>&1) || true +STEP3_RC=${PIPESTATUS[0]:-$?} +echo "$STEP3_OUT" | grep -E "(✓|✗|·|---|====|Text:|Result:)" | head -40 || true +if [ "$STEP3_RC" -ne 0 ]; then + echo " ✗ cargo test exited with code $STEP3_RC" +fi + +echo "" +echo "--- Step 4: Notion Live Test (real data dir) ---" +echo "" +STEP4_OUT=$(RUN_LIVE_NOTION=1 cargo test --test skills_notion_live -- --nocapture 2>&1) || true +STEP4_RC=${PIPESTATUS[0]:-$?} +echo "$STEP4_OUT" | grep -E "(✓|✗|---|Step|Backend|OAuth|HTTP|status|connected|workspace|totals|Result:|is_error|Done|COMPLETE)" | head -30 || true +if [ "$STEP4_RC" -ne 0 ]; then + echo " ✗ cargo test exited with code $STEP4_RC" +fi + +echo "" +echo "=== Done ===" diff --git a/scripts/debug-skill.sh b/scripts/debug-skill.sh new file mode 100755 index 000000000..6742dc25f --- /dev/null +++ b/scripts/debug-skill.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# debug-skill.sh — Run the skills debug E2E test against a real skill. +# +# Loads environment from .env (BACKEND_URL, JWT_TOKEN, etc.) +# +# Usage: +# bash scripts/debug-skill.sh # test example-skill (auto-find dir) +# bash scripts/debug-skill.sh gmail # test a specific skill +# bash scripts/debug-skill.sh gmail /path/to/skills # explicit skills dir +# bash scripts/debug-skill.sh gmail "" get-emails '{"query":"test"}' +# +# Environment variables (set in .env or override via export): +# BACKEND_URL — backend API URL +# JWT_TOKEN — session JWT for OAuth proxy +# SKILL_DEBUG_ID — skill ID (default: example-skill) +# SKILL_DEBUG_DIR — path to skills dir containing skill folders +# SKILL_DEBUG_TOOL — tool name to call (default: first tool) +# SKILL_DEBUG_TOOL_ARGS — JSON args for the tool (default: "{}") +# SKILL_DEBUG_VERBOSE — "1" for verbose logging +# RUST_LOG — Rust log filter (default: info) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Load .env (won't overwrite vars already set in the shell) +if [ -f "$REPO_ROOT/.env" ]; then + source "$SCRIPT_DIR/load-dotenv.sh" "$REPO_ROOT/.env" +fi + +# Parse positional args +SKILL_ID="${1:-${SKILL_DEBUG_ID:-example-skill}}" +SKILLS_DIR="${2:-${SKILL_DEBUG_DIR:-}}" +TOOL_NAME="${3:-${SKILL_DEBUG_TOOL:-}}" +TOOL_ARGS="${4:-${SKILL_DEBUG_TOOL_ARGS:-}}" + +export SKILL_DEBUG_ID="$SKILL_ID" +[ -n "$SKILLS_DIR" ] && export SKILL_DEBUG_DIR="$SKILLS_DIR" +[ -n "$TOOL_NAME" ] && export SKILL_DEBUG_TOOL="$TOOL_NAME" +[ -n "$TOOL_ARGS" ] && export SKILL_DEBUG_TOOL_ARGS="$TOOL_ARGS" + +# Default log level +export RUST_LOG="${RUST_LOG:-info}" + +echo "╔══════════════════════════════════════════════════════╗" +echo "║ Skills Debug Runner ║" +echo "╠══════════════════════════════════════════════════════╣" +echo "║ Skill: $SKILL_ID" +echo "║ Skills dir: ${SKILL_DEBUG_DIR:-}" +echo "║ Tool: ${SKILL_DEBUG_TOOL:-}" +echo "║ Tool args: ${SKILL_DEBUG_TOOL_ARGS:-{}}" +echo "║ BACKEND_URL: ${BACKEND_URL:-}" +echo "║ JWT_TOKEN: ${JWT_TOKEN:+${JWT_TOKEN:0:20}...}" +echo "║ RUST_LOG: $RUST_LOG" +echo "╚══════════════════════════════════════════════════════╝" +echo "" + +cd "$REPO_ROOT" + +# Run just the full lifecycle test by default, with output +cargo test --test skills_debug_e2e skill_full_lifecycle -- --nocapture 2>&1 + +echo "" +echo "Done. To run all skill tests (including edge cases):" +echo " cargo test --test skills_debug_e2e -- --nocapture" diff --git a/tests/skills_debug_e2e.rs b/tests/skills_debug_e2e.rs new file mode 100644 index 000000000..e2df002b8 --- /dev/null +++ b/tests/skills_debug_e2e.rs @@ -0,0 +1,698 @@ +//! Skills runtime debug / integration tests. +//! +//! Exercises the full skill lifecycle through the RuntimeEngine: +//! discover → start → list tools → call tool → setup flow → tick/sync → stop +//! +//! By default uses the bundled `example-skill` from the openhuman-skills repo. +//! Override with env vars: +//! SKILL_DEBUG_ID — skill ID to test (default: "example-skill") +//! SKILL_DEBUG_DIR — path to skills directory containing skill folders +//! SKILL_DEBUG_TOOL — specific tool name to call (default: first tool found) +//! SKILL_DEBUG_TOOL_ARGS — JSON args for the tool call (default: "{}") +//! SKILL_DEBUG_VERBOSE — set to "1" for extra output +//! +//! Run: +//! cargo test --test skills_debug_e2e -- --nocapture +//! # or via the wrapper script: +//! bash scripts/debug-skill.sh [skill-id] [skills-dir] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; +use tempfile::tempdir; + +use openhuman_core::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +fn is_verbose() -> bool { + std::env::var("SKILL_DEBUG_VERBOSE") + .map(|v| v == "1" || v == "true") + .unwrap_or(false) +} + +fn banner(label: &str) { + let sep = "=".repeat(60); + eprintln!("\n{sep}"); + eprintln!(" {label}"); + eprintln!("{sep}"); +} + +fn step(label: &str) { + eprintln!("\n--- {label} ---"); +} + +fn ok(msg: &str) { + eprintln!(" ✓ {msg}"); +} + +fn fail(msg: &str) { + eprintln!(" ✗ {msg}"); +} + +fn info(msg: &str) { + eprintln!(" · {msg}"); +} + +/// Find the skills source directory. Returns None when not available (e.g. CI). +/// +/// Priority: +/// 1. SKILL_DEBUG_DIR env var +/// 2. ../openhuman-skills/skills (sibling repo) +/// 3. openhuman-skills/skills (subdir) +/// 4. Broader search in parent workspace +fn try_find_skills_dir() -> Option { + if let Ok(dir) = std::env::var("SKILL_DEBUG_DIR") { + let p = PathBuf::from(&dir); + if p.exists() { + return Some(p); + } + eprintln!("SKILL_DEBUG_DIR={dir} does not exist"); + return None; + } + + let cwd = std::env::current_dir().expect("cwd"); + + let candidates = [ + cwd.join("../openhuman-skills/skills"), + cwd.join("openhuman-skills/skills"), + cwd.join("../alphahuman/skills/skills"), + ]; + + for candidate in &candidates { + if candidate.exists() { + return Some(candidate.canonicalize().unwrap()); + } + } + + // Search parent workspace + if let Some(parent) = cwd.parent() { + for entry in std::fs::read_dir(parent).into_iter().flatten().flatten() { + let candidate = entry.path().join("skills/skills"); + if candidate.exists() && candidate.join("example-skill/manifest.json").exists() { + return Some(candidate.canonicalize().unwrap()); + } + } + } + + None +} + +/// Convenience wrapper that skips the test when no skills directory is found. +macro_rules! require_skills_dir { + () => { + match try_find_skills_dir() { + Some(dir) => dir, + None => { + eprintln!("SKIPPED: no skills directory available (set SKILL_DEBUG_DIR for CI)"); + return; + } + } + }; +} + +/// Create a RuntimeEngine with the given skills source dir and a temp data dir. +async fn create_engine(skills_dir: &Path, data_dir: &Path) -> Arc { + let engine = + RuntimeEngine::new(data_dir.to_path_buf()).expect("RuntimeEngine::new should succeed"); + let engine = Arc::new(engine); + + // Set the skills source directory + engine.set_skills_source_dir(skills_dir.to_path_buf()); + + // Set as global so RPC handlers can find it + set_global_engine(engine.clone()); + + engine +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +/// Full lifecycle test: discover → start → tools → call → setup → tick → stop +#[tokio::test] +async fn skill_full_lifecycle() { + let _ = env_logger::builder() + .filter_level(if is_verbose() { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + }) + .is_test(true) + .try_init(); + + let skill_id = env_or("SKILL_DEBUG_ID", "example-skill"); + let skills_dir = require_skills_dir!(); + let tmp = tempdir().expect("tempdir"); + let data_dir = tmp.path().join("skills_data"); + std::fs::create_dir_all(&data_dir).expect("create data_dir"); + + banner(&format!("Skills Debug E2E — skill: {skill_id}")); + info(&format!("Skills dir: {}", skills_dir.display())); + info(&format!("Data dir: {}", data_dir.display())); + + let engine = create_engine(&skills_dir, &data_dir).await; + + // ── 1. Discover ── + step("DISCOVER SKILLS"); + let manifests = engine.discover_skills().await; + match &manifests { + Ok(m) => { + ok(&format!("Found {} skill(s)", m.len())); + for manifest in m { + info(&format!( + " {} — {} (runtime: {}, auto_start: {})", + manifest.id, manifest.name, manifest.runtime, manifest.auto_start + )); + } + + // Verify target skill exists + let found = m.iter().any(|m| m.id == skill_id); + if found { + ok(&format!("Target skill '{skill_id}' found in discovery")); + } else { + fail(&format!( + "Target skill '{skill_id}' NOT found. Available: {}", + m.iter() + .map(|m| m.id.as_str()) + .collect::>() + .join(", ") + )); + panic!("Target skill not found in discovered skills"); + } + } + Err(e) => { + fail(&format!("Discovery failed: {e}")); + panic!("Skill discovery failed: {e}"); + } + } + + // ── 2. Start ── + step(&format!("START SKILL '{skill_id}'")); + let start_result = engine.start_skill(&skill_id).await; + match &start_result { + Ok(snapshot) => { + ok(&format!("Skill started — status: {:?}", snapshot.status)); + info(&format!(" Name: {}", snapshot.name)); + info(&format!(" Tools: {} registered", snapshot.tools.len())); + for tool in &snapshot.tools { + info(&format!(" - {} : {}", tool.name, tool.description)); + } + if let Some(err) = &snapshot.error { + fail(&format!(" Error: {err}")); + } + info(&format!( + " Published state keys: {:?}", + snapshot.state.keys().collect::>() + )); + } + Err(e) => { + fail(&format!("Start failed: {e}")); + panic!("Skill start failed: {e}"); + } + } + + let snapshot = start_result.unwrap(); + + // ── 3. List tools via RPC ── + step("LIST TOOLS (via RPC)"); + let tools_result = engine.rpc(&skill_id, "tools/list", json!({})).await; + match &tools_result { + Ok(val) => { + let tools = val.get("tools").and_then(|t| t.as_array()); + if let Some(tools) = tools { + ok(&format!("{} tool(s) via RPC", tools.len())); + for tool in tools { + let name = tool.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + info(&format!(" - {name}")); + } + } else { + ok(&format!("RPC result: {val}")); + } + } + Err(e) => { + fail(&format!("tools/list RPC failed: {e}")); + } + } + + // ── 4. Call a tool ── + step("CALL TOOL"); + let tool_name = env_or( + "SKILL_DEBUG_TOOL", + snapshot + .tools + .first() + .map(|t| t.name.as_str()) + .unwrap_or("get-status"), + ); + let tool_args: Value = std::env::var("SKILL_DEBUG_TOOL_ARGS") + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| json!({})); + + info(&format!( + "Calling tool '{tool_name}' with args: {tool_args}" + )); + + let call_result = tokio::time::timeout( + Duration::from_secs(30), + engine.call_tool(&skill_id, &tool_name, tool_args.clone()), + ) + .await; + + match call_result { + Ok(Ok(result)) => { + ok(&format!( + "Tool call succeeded (is_error: {})", + result.is_error + )); + for content in &result.content { + match content { + openhuman_core::openhuman::skills::types::ToolContent::Text { text } => { + info(&format!(" Text: {text}")); + } + openhuman_core::openhuman::skills::types::ToolContent::Json { data } => { + info(&format!(" JSON: {data}")); + } + } + } + if result.is_error { + fail("Tool returned is_error=true"); + } + } + Ok(Err(e)) => { + fail(&format!("Tool call error: {e}")); + } + Err(_) => { + fail("Tool call TIMED OUT (30s)"); + panic!("Tool call timed out"); + } + } + + // ── 4b. Call tool via RPC path (tools/call) ── + step("CALL TOOL (via RPC)"); + let rpc_call_result = tokio::time::timeout( + Duration::from_secs(30), + engine.rpc( + &skill_id, + "tools/call", + json!({ "name": tool_name, "arguments": tool_args }), + ), + ) + .await; + + match rpc_call_result { + Ok(Ok(val)) => { + ok(&format!("RPC tools/call succeeded: {val}")); + } + Ok(Err(e)) => { + fail(&format!("RPC tools/call error: {e}")); + } + Err(_) => { + fail("RPC tools/call TIMED OUT (30s)"); + } + } + + // ── 5. Setup flow ── + step("SETUP FLOW"); + let setup_result = tokio::time::timeout( + Duration::from_secs(10), + engine.rpc(&skill_id, "setup/start", json!({})), + ) + .await; + + match setup_result { + Ok(Ok(val)) => { + ok(&format!("setup/start returned: {val}")); + + // If there's a step with fields, try a submit with empty values + if let Some(step_id) = val.get("stepId").and_then(|s| s.as_str()) { + info(&format!("Got setup step: {step_id}")); + + if let Some(fields) = val.get("fields").and_then(|f| f.as_array()) { + info(&format!( + " Fields: {}", + fields + .iter() + .filter_map(|f| f.get("name").and_then(|n| n.as_str())) + .collect::>() + .join(", ") + )); + } + + // Cancel setup (don't actually submit — we don't have valid creds) + let cancel = engine.rpc(&skill_id, "setup/cancel", json!({})).await; + match cancel { + Ok(val) => ok(&format!("setup/cancel: {val}")), + Err(e) => info(&format!("setup/cancel: {e} (may be fine)")), + } + } + } + Ok(Err(e)) => { + info(&format!( + "setup/start returned error: {e} (expected if no setup handler)" + )); + } + Err(_) => { + fail("setup/start TIMED OUT (10s)"); + } + } + + // ── 6. Tick / Sync ── + step("TICK (sync)"); + let tick_result = tokio::time::timeout( + Duration::from_secs(15), + engine.rpc(&skill_id, "skill/tick", json!({})), + ) + .await; + + match tick_result { + Ok(Ok(val)) => { + ok(&format!("skill/tick returned: {val}")); + } + Ok(Err(e)) => { + info(&format!("skill/tick error: {e} (may be expected)")); + } + Err(_) => { + fail("skill/tick TIMED OUT (15s)"); + } + } + + // ── 7. Session lifecycle ── + step("SESSION LIFECYCLE"); + let session_id = "debug-session-1"; + + let session_start = engine + .rpc( + &skill_id, + "skill/sessionStart", + json!({ "sessionId": session_id }), + ) + .await; + match session_start { + Ok(val) => ok(&format!("sessionStart: {val}")), + Err(e) => info(&format!("sessionStart: {e} (may be expected)")), + } + + let session_end = engine + .rpc( + &skill_id, + "skill/sessionEnd", + json!({ "sessionId": session_id }), + ) + .await; + match session_end { + Ok(val) => ok(&format!("sessionEnd: {val}")), + Err(e) => info(&format!("sessionEnd: {e} (may be expected)")), + } + + // ── 8. Skill state check ── + step("SKILL STATE CHECK"); + let state = engine.get_skill_state(&skill_id); + match state { + Some(snap) => { + ok(&format!("Status: {:?}", snap.status)); + info(&format!("Tools: {}", snap.tools.len())); + info(&format!("Published state: {} key(s)", snap.state.len())); + if is_verbose() { + for (k, v) in &snap.state { + info(&format!(" {k} = {v}")); + } + } + } + None => { + fail("Skill not found in registry after start"); + } + } + + // ── 9. List all tools (cross-skill) ── + step("ALL TOOLS (cross-skill)"); + let all_tools = engine.all_tools(); + ok(&format!("{} tool(s) across all skills", all_tools.len())); + for (skill, tool) in &all_tools { + info(&format!(" {skill} :: {}", tool.name)); + } + + // ── 10. Stop ── + step(&format!("STOP SKILL '{skill_id}'")); + let stop_result = + tokio::time::timeout(Duration::from_secs(10), engine.stop_skill(&skill_id)).await; + + match stop_result { + Ok(Ok(())) => { + ok("Skill stopped cleanly"); + } + Ok(Err(e)) => { + fail(&format!("Stop failed: {e}")); + } + Err(_) => { + fail("Stop TIMED OUT (10s)"); + } + } + + // Verify stopped + let post_stop = engine.get_skill_state(&skill_id); + match post_stop { + Some(snap) => { + info(&format!("Post-stop status: {:?}", snap.status)); + } + None => { + ok("Skill unregistered after stop"); + } + } + + // ── 11. List skills (should show stopped/unregistered) ── + step("LIST ALL SKILLS (post-stop)"); + let all = engine.list_skills(); + ok(&format!("{} skill(s) still registered", all.len())); + for s in &all { + info(&format!(" {} — {:?}", s.skill_id, s.status)); + } + + banner("ALL CHECKS COMPLETE"); +} + +/// Test that calling a tool on a non-existent skill gives a clear error. +#[tokio::test] +async fn skill_not_found_gives_clear_error() { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Warn) + .is_test(true) + .try_init(); + + let tmp = tempdir().expect("tempdir"); + let data_dir = tmp.path().join("skills_data"); + std::fs::create_dir_all(&data_dir).unwrap(); + + let engine = RuntimeEngine::new(data_dir).expect("engine"); + let engine = Arc::new(engine); + set_global_engine(engine.clone()); + + let result = engine + .call_tool("nonexistent-skill", "some-tool", json!({})) + .await; + assert!(result.is_err(), "Expected error for non-existent skill"); + let err = result.unwrap_err(); + eprintln!(" Expected error: {err}"); + assert!( + err.contains("not found") || err.contains("not registered") || err.contains("No skill"), + "Error message should mention not found: {err}" + ); +} + +/// Test that discovering skills from an empty directory returns an empty list. +#[tokio::test] +async fn discover_empty_dir() { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Warn) + .is_test(true) + .try_init(); + + let tmp = tempdir().expect("tempdir"); + let data_dir = tmp.path().join("data"); + let skills_dir = tmp.path().join("skills"); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::create_dir_all(&skills_dir).unwrap(); + + let engine = RuntimeEngine::new(data_dir).expect("engine"); + let engine = Arc::new(engine); + engine.set_skills_source_dir(skills_dir); + + let manifests = engine.discover_skills().await.expect("discover"); + assert!( + manifests.is_empty(), + "Expected empty list from empty skills dir, got {} manifests", + manifests.len() + ); +} + +/// Stress test: start and stop the same skill rapidly. +#[tokio::test] +async fn skill_rapid_start_stop() { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Warn) + .is_test(true) + .try_init(); + + let skill_id = env_or("SKILL_DEBUG_ID", "example-skill"); + let skills_dir = require_skills_dir!(); + let tmp = tempdir().expect("tempdir"); + let data_dir = tmp.path().join("skills_data"); + std::fs::create_dir_all(&data_dir).unwrap(); + + let engine = create_engine(&skills_dir, &data_dir).await; + + for i in 0..3 { + eprintln!(" Round {}/3: start", i + 1); + let start = engine.start_skill(&skill_id).await; + match &start { + Ok(snap) => { + eprintln!(" Started: {:?}, {} tools", snap.status, snap.tools.len()); + } + Err(e) => { + eprintln!(" Start failed: {e}"); + // Don't panic — this tests resilience + } + } + + // Small delay to let the event loop spin + tokio::time::sleep(Duration::from_millis(200)).await; + + eprintln!(" Round {}/3: stop", i + 1); + let _ = engine.stop_skill(&skill_id).await; + tokio::time::sleep(Duration::from_millis(100)).await; + } + + eprintln!(" Rapid start/stop completed without panic"); +} + +/// Test disconnect flow: stop → oauth/revoked → verify credential cleaned up. +/// Mirrors what the frontend *should* do when a user clicks "Disconnect". +#[tokio::test] +async fn skill_disconnect_flow() { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Info) + .is_test(true) + .try_init(); + + let skill_id = env_or("SKILL_DEBUG_ID", "example-skill"); + let skills_dir = require_skills_dir!(); + let tmp = tempdir().expect("tempdir"); + let data_dir = tmp.path().join("skills_data"); + std::fs::create_dir_all(&data_dir).unwrap(); + + let engine = create_engine(&skills_dir, &data_dir).await; + + // ── Start skill ── + eprintln!("\n--- DISCONNECT TEST ---"); + eprintln!(" Starting skill '{skill_id}'..."); + let snap = engine.start_skill(&skill_id).await.expect("start"); + assert_eq!( + snap.status, + openhuman_core::openhuman::skills::types::SkillStatus::Running + ); + eprintln!(" ✓ Running, {} tools", snap.tools.len()); + + // ── Write a fake OAuth credential ── + let skill_data_dir = data_dir.join(&skill_id); + std::fs::create_dir_all(&skill_data_dir).unwrap(); + let cred_path = skill_data_dir.join("oauth_credential.json"); + std::fs::write( + &cred_path, + r#"{"credentialId":"test-cred-123","provider":"test","grantedScopes":[]}"#, + ) + .unwrap(); + assert!( + cred_path.exists(), + "Credential file should exist before disconnect" + ); + eprintln!(" ✓ Wrote fake oauth_credential.json"); + + // ── Simulate frontend disconnect: stop + set_setup_complete(false) ── + eprintln!(" Simulating frontend disconnectSkill()..."); + + // Step 1: Stop (what frontend does) + engine.stop_skill(&skill_id).await.expect("stop"); + eprintln!(" ✓ Skill stopped"); + + // Step 2: Reset setup_complete (what frontend does) + engine.preferences().set_setup_complete(&skill_id, false); + assert!(!engine.preferences().is_setup_complete(&skill_id)); + eprintln!(" ✓ setup_complete = false"); + + // ── Verify credential file still exists (BUG: disconnect doesn't clean it) ── + let cred_still_exists = cred_path.exists(); + if cred_still_exists { + eprintln!(" ⚠ oauth_credential.json still exists after disconnect (expected gap)"); + eprintln!(" → Frontend disconnect does NOT call oauth/revoked"); + eprintln!(" → On restart, the old credential will be restored"); + } else { + eprintln!(" ✓ oauth_credential.json cleaned up"); + } + + // ── Now test the proper flow: start + send oauth/revoked ── + eprintln!("\n Testing proper disconnect (with oauth/revoked)..."); + // Re-write credential for the proper test + std::fs::write( + &cred_path, + r#"{"credentialId":"test-cred-456","provider":"test","grantedScopes":[]}"#, + ) + .unwrap(); + + let snap2 = engine.start_skill(&skill_id).await.expect("restart"); + assert_eq!( + snap2.status, + openhuman_core::openhuman::skills::types::SkillStatus::Running + ); + eprintln!(" ✓ Restarted skill"); + + // Send oauth/revoked RPC (what disconnect SHOULD do) + let revoke_result = engine + .rpc( + &skill_id, + "oauth/revoked", + json!({"integrationId": "test-cred-456"}), + ) + .await; + match &revoke_result { + Ok(val) => eprintln!(" ✓ oauth/revoked returned: {val}"), + Err(e) => eprintln!(" · oauth/revoked: {e} (may be expected for non-OAuth skills)"), + } + + // Now stop + engine.stop_skill(&skill_id).await.expect("stop"); + engine.preferences().set_setup_complete(&skill_id, false); + eprintln!(" ✓ Stopped + setup_complete = false"); + + // Verify credential file is gone after oauth/revoked + assert!( + !cred_path.exists(), + "oauth_credential.json should be deleted after oauth/revoked but still exists at {}", + cred_path.display() + ); + eprintln!(" ✓ oauth_credential.json deleted after oauth/revoked"); + + // ── Verify restart after disconnect shows clean state ── + eprintln!("\n Verifying clean restart after proper disconnect..."); + let snap3 = engine + .start_skill(&skill_id) + .await + .expect("restart after disconnect"); + eprintln!( + " ✓ Restarted: {:?}, {} tools", + snap3.status, + snap3.tools.len() + ); + eprintln!( + " setup_complete: {}", + engine.preferences().is_setup_complete(&skill_id) + ); + + engine.stop_skill(&skill_id).await.expect("final stop"); + eprintln!("\n ✓ Disconnect flow test complete"); +} diff --git a/tests/skills_notion_live.rs b/tests/skills_notion_live.rs new file mode 100644 index 000000000..10fd9b4b3 --- /dev/null +++ b/tests/skills_notion_live.rs @@ -0,0 +1,319 @@ +//! Notion skill live debug test — uses real data directory and backend. +//! +//! This test uses: +//! - The real ~/.openhuman/skills_data/notion/ directory (with oauth_credential.json) +//! - BACKEND_URL env var for the OAuth proxy +//! - JWT_TOKEN env var for authentication +//! +//! Run: +//! BACKEND_URL=https://staging-api.alphahuman.xyz \ +//! JWT_TOKEN= \ +//! cargo test --test skills_notion_live -- --nocapture + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; + +use openhuman_core::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine}; + +fn try_find_skills_dir() -> Option { + if let Ok(dir) = std::env::var("SKILL_DEBUG_DIR") { + let p = PathBuf::from(&dir); + return if p.exists() { Some(p) } else { None }; + } + let cwd = std::env::current_dir().expect("cwd"); + for candidate in &[ + "../openhuman-skills/skills", + "openhuman-skills/skills", + "../alphahuman/skills/skills", + ] { + let p = cwd.join(candidate); + if p.exists() { + return Some(p.canonicalize().unwrap()); + } + } + if let Some(parent) = cwd.parent() { + for entry in std::fs::read_dir(parent).into_iter().flatten().flatten() { + let c = entry.path().join("skills/skills"); + if c.join("notion/manifest.json").exists() { + return Some(c.canonicalize().unwrap()); + } + } + } + None +} + +macro_rules! require_skills_dir { + () => { + match try_find_skills_dir() { + Some(dir) => dir, + None => { + eprintln!("SKIPPED: no skills directory available"); + return; + } + } + }; +} + +#[tokio::test] +async fn notion_live_with_real_data() { + // Opt-in: only runs when RUN_LIVE_NOTION=1 is set explicitly. + if std::env::var("RUN_LIVE_NOTION").unwrap_or_default() != "1" { + eprintln!("SKIPPED: set RUN_LIVE_NOTION=1 to run this live integration test"); + return; + } + + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Info) + .is_test(true) + .try_init(); + + let backend_url = + std::env::var("BACKEND_URL").expect("BACKEND_URL must be set for live Notion test"); + let jwt_token = std::env::var("JWT_TOKEN").expect("JWT_TOKEN must be set for live Notion test"); + let credential_id = + std::env::var("CREDENTIAL_ID").expect("CREDENTIAL_ID must be set for live Notion test"); + let skills_dir = require_skills_dir!(); + + let real_data_dir = PathBuf::from( + std::env::var("SKILLS_DATA_DIR").expect("SKILLS_DATA_DIR must be set for live Notion test"), + ); + + let sep = "=".repeat(60); + eprintln!("\n{sep}"); + eprintln!(" Notion Live Debug (real data dir)"); + eprintln!("{sep}"); + eprintln!(" Backend: {backend_url}"); + eprintln!(" JWT: ", jwt_token.len()); + eprintln!(" Credential ID: {credential_id}"); + eprintln!(" Skills dir: {}", skills_dir.display()); + eprintln!(" Data dir: {}", real_data_dir.display()); + + // Check oauth_credential.json exists (don't log contents — may contain secrets) + let cred_path = real_data_dir.join("notion/oauth_credential.json"); + if cred_path.exists() { + let size = std::fs::metadata(&cred_path).map(|m| m.len()).unwrap_or(0); + eprintln!(" OAuth cred: present ({size} bytes)"); + } else { + eprintln!(" OAuth cred: NOT FOUND at {}", cred_path.display()); + eprintln!(" (Skill will start without OAuth — tools that need API access will fail)"); + } + + // ── Step 1: Raw backend check ── + eprintln!("\n--- Step 1: Backend Health ---"); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + + let health = client + .get(format!("{backend_url}/settings")) + .header("Authorization", format!("Bearer {jwt_token}")) + .send() + .await; + + match health { + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + eprintln!(" GET /settings → HTTP {status}"); + if status.is_success() { + eprintln!(" ✓ Backend reachable, JWT valid"); + eprintln!(" Body: {}...", &body[..body.len().min(200)]); + } else if status.as_u16() == 502 { + eprintln!(" ✗ Backend DOWN (502 Bad Gateway)"); + eprintln!(" The staging server is unreachable. OAuth proxy will fail."); + eprintln!(" Continuing to test skill lifecycle anyway..."); + } else { + eprintln!(" ⚠ HTTP {status}: {body}"); + } + } + Err(e) => { + eprintln!(" ✗ Connection failed: {e}"); + } + } + + // ── Step 2: Raw proxy check ── + eprintln!("\n--- Step 2: OAuth Proxy Check ---"); + let proxy_url = format!("{backend_url}/proxy/by-id/{credential_id}/v1/users?page_size=1"); + eprintln!(" GET {proxy_url}"); + + let proxy = client + .get(&proxy_url) + .header("Authorization", format!("Bearer {jwt_token}")) + .header("Content-Type", "application/json") + .send() + .await; + + match proxy { + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + eprintln!(" HTTP {status}"); + if status.is_success() { + eprintln!(" ✓ Notion API accessible via proxy"); + eprintln!(" Body: {}...", &body[..body.len().min(300)]); + } else { + eprintln!( + " ✗ Proxy returned {status}: {}...", + &body[..body.len().min(200)] + ); + } + } + Err(e) => { + eprintln!(" ✗ Proxy request failed: {e}"); + } + } + + // ── Step 3: Start skill with real data dir ── + eprintln!("\n--- Step 3: Start Notion Skill (real data dir) ---"); + let engine = RuntimeEngine::new(real_data_dir.clone()).expect("engine"); + let engine = Arc::new(engine); + engine.set_skills_source_dir(skills_dir.clone()); + set_global_engine(engine.clone()); + + let start = engine.start_skill("notion").await; + match &start { + Ok(snap) => { + eprintln!(" ✓ Started — status: {:?}", snap.status); + eprintln!(" Tools: {}", snap.tools.len()); + eprintln!(" Published state:"); + for (k, v) in &snap.state { + if k.contains("status") + || k.contains("error") + || k.contains("auth") + || k == "workspaceName" + || k == "is_initialized" + { + eprintln!(" {k} = {v}"); + } + } + } + Err(e) => { + eprintln!(" ✗ Start failed: {e}"); + panic!("Skill start failed"); + } + } + + // ── Step 4: sync-status tool ── + eprintln!("\n--- Step 4: sync-status tool ---"); + let sync_status = tokio::time::timeout( + Duration::from_secs(15), + engine.call_tool("notion", "sync-status", json!({})), + ) + .await; + + match sync_status { + Ok(Ok(result)) => { + for content in &result.content { + match content { + openhuman_core::openhuman::skills::types::ToolContent::Text { text } => { + // Parse and pretty-print + if let Ok(v) = serde_json::from_str::(text) { + eprintln!(" ✓ sync-status:"); + eprintln!( + " connected: {}", + v.get("connected").unwrap_or(&json!(null)) + ); + eprintln!( + " workspace: {}", + v.get("workspace_name").unwrap_or(&json!(null)) + ); + eprintln!( + " last_sync: {}", + v.get("last_sync_time").unwrap_or(&json!(null)) + ); + eprintln!( + " last_sync_error: {}", + v.get("last_sync_error").unwrap_or(&json!(null)) + ); + if let Some(totals) = v.get("totals") { + eprintln!(" totals: {totals}"); + } + } else { + eprintln!(" ✓ Raw: {text}"); + } + } + _ => {} + } + } + } + Ok(Err(e)) => eprintln!(" ✗ Error: {e}"), + Err(_) => eprintln!(" ✗ TIMED OUT"), + } + + // ── Step 5: search tool (needs OAuth) ── + eprintln!("\n--- Step 5: search tool (needs OAuth proxy) ---"); + let search = tokio::time::timeout( + Duration::from_secs(30), + engine.call_tool("notion", "search", json!({"query": "test", "page_size": 3})), + ) + .await; + + match search { + Ok(Ok(result)) => { + eprintln!(" is_error: {}", result.is_error); + for content in &result.content { + match content { + openhuman_core::openhuman::skills::types::ToolContent::Text { text } => { + eprintln!(" Result: {}...", &text[..text.len().min(500)]); + } + _ => {} + } + } + } + Ok(Err(e)) => eprintln!(" ✗ Error: {e}"), + Err(_) => eprintln!(" ✗ TIMED OUT"), + } + + // ── Step 6: list-all-pages tool (needs OAuth) ── + eprintln!("\n--- Step 6: list-all-pages (needs OAuth proxy) ---"); + let pages = tokio::time::timeout( + Duration::from_secs(30), + engine.call_tool("notion", "list-all-pages", json!({"page_size": 3})), + ) + .await; + + match pages { + Ok(Ok(result)) => { + eprintln!(" is_error: {}", result.is_error); + for content in &result.content { + match content { + openhuman_core::openhuman::skills::types::ToolContent::Text { text } => { + eprintln!(" Result: {}...", &text[..text.len().min(500)]); + } + _ => {} + } + } + } + Ok(Err(e)) => eprintln!(" ✗ Error: {e}"), + Err(_) => eprintln!(" ✗ TIMED OUT"), + } + + // ── Step 7: Final state ── + eprintln!("\n--- Step 7: Final Skill State ---"); + if let Some(snap) = engine.get_skill_state("notion") { + eprintln!(" Status: {:?}", snap.status); + for (k, v) in &snap.state { + if k.contains("status") + || k.contains("error") + || k.contains("auth") + || k == "workspaceName" + { + eprintln!(" {k} = {v}"); + } + } + } + + // ── Cleanup ── + eprintln!("\n--- Stop ---"); + let _ = engine.stop_skill("notion").await; + eprintln!(" Done"); + + let sep = "=".repeat(60); + eprintln!("\n{sep}"); + eprintln!(" COMPLETE"); + eprintln!("{sep}\n"); +} diff --git a/tests/skills_rpc_e2e.rs b/tests/skills_rpc_e2e.rs new file mode 100644 index 000000000..c2857a736 --- /dev/null +++ b/tests/skills_rpc_e2e.rs @@ -0,0 +1,303 @@ +//! Skills RPC E2E test — exercises skill operations over HTTP JSON-RPC. +//! +//! Tests the full stack: HTTP request → JSON-RPC dispatch → RuntimeEngine → QuickJS. +//! +//! Environment variables (same as skills_debug_e2e.rs): +//! SKILL_DEBUG_ID — skill ID (default: "example-skill") +//! SKILL_DEBUG_DIR — path to skills directory +//! SKILL_DEBUG_TOOL — tool name to call +//! SKILL_DEBUG_TOOL_ARGS — JSON args for tool call +//! +//! Run: +//! cargo test --test skills_rpc_e2e -- --nocapture + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use axum::Router; +use serde_json::{json, Value}; +use tempfile::tempdir; + +use openhuman_core::core::jsonrpc::build_core_http_router; +use openhuman_core::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +fn try_find_skills_dir() -> Option { + if let Ok(dir) = std::env::var("SKILL_DEBUG_DIR") { + let p = PathBuf::from(&dir); + return if p.exists() { Some(p) } else { None }; + } + let cwd = std::env::current_dir().expect("cwd"); + for candidate in &[ + "../openhuman-skills/skills", + "openhuman-skills/skills", + "../alphahuman/skills/skills", + ] { + let p = cwd.join(candidate); + if p.exists() { + return Some(p.canonicalize().unwrap()); + } + } + if let Some(parent) = cwd.parent() { + for entry in std::fs::read_dir(parent).into_iter().flatten().flatten() { + let c = entry.path().join("skills/skills"); + if c.join("example-skill/manifest.json").exists() { + return Some(c.canonicalize().unwrap()); + } + } + } + None +} + +macro_rules! require_skills_dir { + () => { + match try_find_skills_dir() { + Some(dir) => dir, + None => { + eprintln!("SKIPPED: no skills directory available"); + return; + } + } + }; +} + +async fn serve_on_ephemeral( + app: Router, +) -> ( + SocketAddr, + tokio::task::JoinHandle>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("addr"); + let handle = tokio::spawn(async move { axum::serve(listener, app).await }); + (addr, handle) +} + +async fn rpc_call(base: &str, id: i64, method: &str, params: Value) -> Value { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .expect("client"); + let body = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + let url = format!("{}/rpc", base.trim_end_matches('/')); + let resp = client + .post(&url) + .json(&body) + .send() + .await + .unwrap_or_else(|e| panic!("POST {url}: {e}")); + assert!( + resp.status().is_success(), + "HTTP error {} for {}", + resp.status(), + method + ); + resp.json::() + .await + .unwrap_or_else(|e| panic!("json for {method}: {e}")) +} + +fn assert_rpc_ok(resp: &Value, context: &str) -> Value { + if let Some(err) = resp.get("error") { + panic!("{context}: unexpected JSON-RPC error: {err}"); + } + resp.get("result") + .cloned() + .unwrap_or_else(|| panic!("{context}: missing 'result' field in response: {resp}")) +} + +// ── Test ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn skills_over_http_rpc() { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Info) + .is_test(true) + .try_init(); + + let skill_id = env_or("SKILL_DEBUG_ID", "example-skill"); + let skills_dir = require_skills_dir!(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let data_dir = home.join("skills_data"); + std::fs::create_dir_all(&data_dir).unwrap(); + + // Set up env for isolated config + let openhuman_dir = home.join(".openhuman"); + std::fs::create_dir_all(&openhuman_dir).unwrap(); + std::fs::write( + openhuman_dir.join("config.toml"), + r#"api_url = "http://127.0.0.1:1" +default_model = "test" +[secrets] +encrypt = false +"#, + ) + .unwrap(); + + // Unsafe env overrides (tests are serialized) + unsafe { + std::env::set_var("HOME", home.as_os_str()); + std::env::remove_var("OPENHUMAN_WORKSPACE"); + std::env::remove_var("BACKEND_URL"); + std::env::remove_var("VITE_BACKEND_URL"); + } + + // Create and register engine + let engine = Arc::new(RuntimeEngine::new(data_dir).expect("engine")); + engine.set_skills_source_dir(skills_dir.clone()); + set_global_engine(engine.clone()); + + // Start the HTTP RPC server + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(50)).await; + + eprintln!("\n=== Skills HTTP RPC E2E ==="); + eprintln!(" Skill: {skill_id}"); + eprintln!(" RPC: {base}"); + eprintln!(" Skills: {}", skills_dir.display()); + + // 1. core.ping + eprintln!("\n--- core.ping ---"); + let ping = rpc_call(&base, 1, "core.ping", json!({})).await; + let r = assert_rpc_ok(&ping, "core.ping"); + assert_eq!(r.get("ok"), Some(&json!(true))); + eprintln!(" OK"); + + // 2. openhuman.skills_discover + eprintln!("\n--- openhuman.skills_discover ---"); + let discover = rpc_call(&base, 2, "openhuman.skills_discover", json!({})).await; + let r = assert_rpc_ok(&discover, "skills_discover"); + eprintln!( + " Result: {} skills", + r.as_array().map(|a| a.len()).unwrap_or(0) + ); + + // 3. openhuman.skills_start + eprintln!("\n--- openhuman.skills_start ---"); + let start = rpc_call( + &base, + 3, + "openhuman.skills_start", + json!({ "skill_id": skill_id }), + ) + .await; + let r = assert_rpc_ok(&start, "skills_start"); + eprintln!(" Status: {:?}", r.get("status")); + eprintln!( + " Tools: {}", + r.get("tools") + .and_then(|t| t.as_array()) + .map(|a| a.len()) + .unwrap_or(0) + ); + + // 4. openhuman.skills_list_tools + eprintln!("\n--- openhuman.skills_list_tools ---"); + let tools = rpc_call( + &base, + 4, + "openhuman.skills_list_tools", + json!({ "skill_id": skill_id }), + ) + .await; + let r = assert_rpc_ok(&tools, "skills_list_tools"); + let tool_list = r.get("tools").and_then(|t| t.as_array()); + if let Some(tools) = tool_list { + eprintln!(" {} tools:", tools.len()); + for t in tools.iter().take(5) { + eprintln!( + " - {}", + t.get("name").and_then(|n| n.as_str()).unwrap_or("?") + ); + } + if tools.len() > 5 { + eprintln!(" ... and {} more", tools.len() - 5); + } + } + + // 5. openhuman.skills_call_tool + eprintln!("\n--- openhuman.skills_call_tool ---"); + let tool_name = env_or("SKILL_DEBUG_TOOL", "get-status"); + let tool_args: Value = std::env::var("SKILL_DEBUG_TOOL_ARGS") + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| json!({})); + let call = rpc_call( + &base, + 5, + "openhuman.skills_call_tool", + json!({ "skill_id": skill_id, "tool_name": tool_name, "arguments": tool_args }), + ) + .await; + let r = assert_rpc_ok(&call, "skills_call_tool"); + eprintln!(" Result: {r}"); + + // 6. openhuman.skills_sync (tick) + eprintln!("\n--- openhuman.skills_sync ---"); + let sync = rpc_call( + &base, + 6, + "openhuman.skills_sync", + json!({ "skill_id": skill_id }), + ) + .await; + let r = assert_rpc_ok(&sync, "skills_sync"); + eprintln!(" Result: {r}"); + + // 7. openhuman.skills_status + eprintln!("\n--- openhuman.skills_status ---"); + let status = rpc_call( + &base, + 7, + "openhuman.skills_status", + json!({ "skill_id": skill_id }), + ) + .await; + let r = assert_rpc_ok(&status, "skills_status"); + eprintln!(" Status: {:?}", r.get("status")); + eprintln!( + " Published state keys: {:?}", + r.get("state") + .and_then(|s| s.as_object()) + .map(|o| o.keys().collect::>()) + ); + + // 8. openhuman.skills_stop + eprintln!("\n--- openhuman.skills_stop ---"); + let stop = rpc_call( + &base, + 8, + "openhuman.skills_stop", + json!({ "skill_id": skill_id }), + ) + .await; + let r = assert_rpc_ok(&stop, "skills_stop"); + eprintln!(" Result: {r}"); + + // 9. openhuman.skills_list (post-stop) + eprintln!("\n--- openhuman.skills_list (post-stop) ---"); + let list = rpc_call(&base, 9, "openhuman.skills_list", json!({})).await; + let r = assert_rpc_ok(&list, "skills_list"); + let skills = r.as_array(); + eprintln!(" {} skill(s)", skills.map(|a| a.len()).unwrap_or(0)); + + eprintln!("\n=== Skills HTTP RPC E2E COMPLETE ===\n"); + + rpc_join.abort(); +}