fix(skills): enforce per-skill runtime tool isolation (#140)

* Add unit tests for Mnemonic page

- Introduced comprehensive tests for the Mnemonic page, covering initial render, copy to clipboard functionality, confirmation checkbox behavior, and mode switching between generate and import.
- Validated user interactions, including input handling and button states, ensuring robust functionality and user experience.
- Enhanced test coverage for various scenarios, including validation of mnemonic phrases and loading states during operations.

* test: add cross-stack test coverage for core and tauri flows

Add focused Rust and frontend tests for core process startup behavior, CLI argument parsing, JSON-RPC error handling, and Tauri command/RPC mapping paths to improve confidence for issue #57.

Closes #57

Made-with: Cursor

* refactor(tests): streamline test code and improve readability

Consolidate mock imports and simplify function calls in coreRpcClient tests. Adjust formatting in Rust core_process and CLI tests for better clarity. Update mnemonic test assertions for improved accuracy.

Made-with: Cursor

* fix(e2e): harden deep-link login flow reliability

Stabilize auth deep-link handling and E2E delivery with readiness guards, retries, and new listener unit tests so login/onboarding flows are deterministic for issue #70.

Closes #70

Made-with: Cursor

* refactor(tests): update agent initialization in tests for consistency

Refactor test cases to use a tuple return from `build_agent_with`, improving consistency in agent setup across multiple tests. This change enhances readability and maintains uniformity in the test structure.

Made-with: Cursor

* fix(skills): enforce per-skill runtime tool isolation

Add explicit tool-call origin policy in the QuickJS runtime so skills cannot invoke other skills' tools, while preserving external orchestration through RPC/socket surfaces. Also remove the generic skills_call tool path and document the isolation contract for skill authors.

Closes #94

Made-with: Cursor
This commit is contained in:
Mega Mind
2026-03-31 13:58:56 -07:00
committed by GitHub
parent ccb151be8a
commit 2b33afeefe
12 changed files with 270 additions and 344 deletions
+29
View File
@@ -201,6 +201,13 @@ Typical operations:
Tool calls can be sync or async in JS. Async calls are awaited with runtime polling and timeout handling in the QuickJS event loop layer.
### Isolation guarantees
- Each started skill runs in its own QuickJS context (`AsyncContext`) and does not share mutable JS globals with other skills.
- Restarting a skill creates a fresh context; prior `globalThis` mutations are not retained.
- Host-level policy blocks skill-to-skill tool invocation. A running skill can only invoke its own tool surface.
- External orchestrators (UI/RPC/socket MCP) can still target tools on any running skill by explicit `skill_id`.
---
## 8) JSON-RPC Surface (`openhuman.skills_*`)
@@ -283,6 +290,8 @@ High-level pattern:
3. Incoming tool calls route to `skill_id` + `tool_name`.
4. Core executes via runtime and returns `ToolResult`.
Tool naming over MCP remains `skillId__toolName` for external orchestration.
---
## 12) Environment Variables and Configuration
@@ -345,6 +354,7 @@ High-level pattern:
5. Tool-call failures:
- verify skill status is `Running`
- inspect skill error in snapshot
- cross-skill denied errors mean a skill attempted to invoke another skill's tool; this is blocked by design
### Useful runtime truths
@@ -399,3 +409,22 @@ When changing behavior, test both:
- Avoid introducing new legacy skill metadata paths.
- Add traceable logs around install/start/setup/tool call boundaries.
---
## 18) Skill Author Isolation Contract
### Guaranteed
- Your skill runs in its own QuickJS context and event loop when started.
- Your skill's `globalThis` state is isolated from other running skills.
- Your skill can publish state only for itself; state is namespaced by `skill_id`.
- Cross-skill tool invocation from within a skill is denied by host policy.
- External host orchestration (UI/RPC/socket MCP) may call your tools by explicit `skill_id`.
### Not supported / undefined behavior
- Do not rely on `globalThis.skills.callTool` for inter-skill calls.
- Do not rely on in-memory JS globals surviving a stop/restart cycle.
- Do not assume execution ordering across different skills.
- Do not treat runtime-internal bridge objects as stable public APIs.
+67
View File
@@ -0,0 +1,67 @@
# Skills Runtime Isolation (QuickJS)
This document defines OpenHuman's skill isolation contract for the QuickJS runtime.
## Goals
- Keep each running skill isolated in JavaScript execution state.
- Prevent lateral movement where one skill invokes another skill's tools.
- Preserve external orchestration from trusted host surfaces (RPC/UI/socket MCP).
## Runtime Architecture
### Per-skill components
- `QjsSkillInstance` (`src/openhuman/skills/qjs_skill_instance`) per started skill.
- One dedicated `AsyncRuntime` + `AsyncContext` created during skill spawn.
- Skill-local JS globals (`globalThis`), module state, and in-memory variables.
- Skill-local data path (`skills_data/<skill_id>/...`) and namespaced memory writes.
- Skill-local message loop handling `SkillMessage` commands.
### Shared infrastructure
- `RuntimeEngine` as lifecycle orchestrator.
- `SkillRegistry` for tracking/routing running skills.
- `SocketManager` for external MCP transport.
- Schedulers (`CronScheduler`, `PingScheduler`) and preferences store.
## Lifecycle and Reset Semantics
1. `start_skill(skill_id)` creates a fresh skill instance and QuickJS context.
2. The instance initializes (`init/start`) and registers tools/state.
3. During runtime, only that skill's event loop mutates its JS state.
4. `stop_skill(skill_id)` stops the loop and transitions status.
5. Restart creates a new instance/context; previous JS globals are not reused.
Failure modes:
- Tool dispatch to non-running skill returns a status error.
- Reply channel drop returns a deterministic runtime error.
- Policy denials return explicit "cross-skill forbidden" errors.
## Tool Invocation Policy
Policy is enforced at host boundary (Rust), not in JS:
- `External` origin (JSON-RPC/UI/socket MCP): may call any target skill tool.
- `SkillSelf { skill_id }` origin: may call only tools owned by the same `skill_id`.
- Cross-skill attempt from `SkillSelf` is denied before dispatch.
This policy is implemented in `SkillRegistry::call_tool_scoped`.
## Forbidden Paths
- A running skill may not invoke another skill's tools.
- Inter-skill bridge helpers must not bypass host policy.
- `skills_call` generic cross-skill agent tool is not part of the default tool registry.
## Testing Requirements
Runtime policy tests must cover:
- external origin allowed,
- same-skill origin allowed,
- cross-skill origin denied with clear error.
Regression checks should also ensure the default agent tool registry does not include
legacy cross-skill helper surfaces.
+1 -97
View File
@@ -159,54 +159,6 @@ impl Tool for EchoTool {
}
}
/// A generic skills bridge test-double tool.
struct SkillsCallEchoTool;
#[async_trait]
impl Tool for SkillsCallEchoTool {
fn name(&self) -> &str {
"skills_call"
}
fn description(&self) -> &str {
"Calls a skill tool"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"skill_id": {"type": "string"},
"tool_name": {"type": "string"},
"arguments": {"type": "object"}
},
"required": ["skill_id", "tool_name"]
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let skill_id = args
.get("skill_id")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let tool_name = args
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let message = args
.get("arguments")
.and_then(|v| v.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("empty");
Ok(ToolResult {
success: true,
output: format!("{skill_id}:{tool_name}:{message}"),
error: None,
})
}
}
/// A tool that always fails execution.
struct FailingTool;
@@ -893,7 +845,7 @@ async fn e2e_native_loop_executes_text_fallback_tool_calls_and_persists_history(
text_response("Completed via tool"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(NativeToolDispatcher),
@@ -1337,54 +1289,6 @@ fn native_dispatcher_converts_tool_results_to_tool_messages() {
assert_eq!(messages[1].role, "tool");
}
#[tokio::test]
async fn native_dispatcher_executes_generic_skills_call_tool() {
let provider = Box::new(ScriptedProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc-skills-1".into(),
name: "skills_call".into(),
arguments: serde_json::json!({
"skill_id": "e2e-runtime",
"tool_name": "echo",
"arguments": { "message": "hello from agent test" }
})
.to_string(),
}]),
text_response("skills call done"),
]));
let mut agent = build_agent_with(
provider,
vec![Box::new(SkillsCallEchoTool)],
Box::new(NativeToolDispatcher),
);
let response = agent.turn("Use skills_call").await.unwrap();
assert_eq!(response, "skills call done");
let result_payloads: Vec<String> = agent
.history()
.iter()
.filter_map(|msg| match msg {
ConversationMessage::ToolResults(results) => Some(
results
.iter()
.map(|r| r.content.clone())
.collect::<Vec<_>>()
.join("\n"),
),
_ => None,
})
.collect();
assert!(
result_payloads
.iter()
.any(|payload| payload.contains("e2e-runtime:echo:hello from agent test")),
"expected skills_call output in tool results, got: {result_payloads:?}"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 23. XML tool instructions generation
// ═══════════════════════════════════════════════════════════════════════════
+10 -54
View File
@@ -1,8 +1,8 @@
//! Inter-skill communication bridge.
//!
//! Allows skills to list other running skills and call their tools.
//! Tool calls are performed on a separate OS thread with a mini Tokio
//! runtime to avoid deadlocking the V8 runtime.
//! Deliberately restricted: running skills cannot invoke tools owned by
//! other skills. Keep this boundary explicit even if bridge APIs are
//! reintroduced in the future.
use std::sync::Arc;
@@ -33,58 +33,14 @@ pub fn list_skills(registry: &Arc<SkillRegistry>) -> String {
///
/// Returns the ToolResult as a JSON string.
pub fn call_tool(
registry: &Arc<SkillRegistry>,
_registry: &Arc<SkillRegistry>,
caller_skill_id: &str,
target_skill_id: &str,
tool_name: &str,
arguments_json: &str,
_tool_name: &str,
_arguments_json: &str,
) -> Result<String, String> {
// Prevent self-calls (would deadlock — the skill's message loop
// is blocked waiting for us, so it can't process the tool call)
if caller_skill_id == target_skill_id {
return Err("Cannot call tools on self (would deadlock)".to_string());
}
let registry = registry.clone();
let target = target_skill_id.to_string();
let tool = tool_name.to_string();
let arguments: serde_json::Value =
serde_json::from_str(arguments_json).unwrap_or(serde_json::json!({}));
let (tx, rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
// Try to use existing runtime first, fallback to creating new one if needed
let arguments_clone = arguments.clone();
let runtime_result = tokio::runtime::Handle::try_current()
.map(|handle| {
// Use existing runtime by blocking on it
handle.block_on(async { registry.call_tool(&target, &tool, arguments).await })
})
.or_else(|_| {
// Only create new runtime if we're not in an async context
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("Failed to create runtime: {e}"))
.map(|rt| {
rt.block_on(async {
registry.call_tool(&target, &tool, arguments_clone).await
})
})
});
let result = match runtime_result {
Ok(tool_result) => tool_result,
Err(e) => Err(e),
};
let _ = tx.send(result);
});
let result = rx
.recv_timeout(std::time::Duration::from_secs(60))
.map_err(|e| format!("Inter-skill tool call timed out: {e}"))??;
serde_json::to_string(&result).map_err(|e| format!("Failed to serialize tool result: {e}"))
Err(format!(
"Cross-skill tool invocation is disabled: '{}' cannot call '{}'",
caller_skill_id, target_skill_id
))
}
+21 -1
View File
@@ -38,7 +38,7 @@ use crate::openhuman::skills::preferences::PreferencesStore;
use crate::openhuman::skills::qjs_skill_instance::{BridgeDeps, QjsSkillInstance};
use crate::openhuman::skills::skill_registry::SkillRegistry;
use crate::openhuman::skills::socket_manager::SocketManager;
use crate::openhuman::skills::types::{SkillSnapshot, SkillStatus, ToolResult};
use crate::openhuman::skills::types::{SkillSnapshot, SkillStatus, ToolCallOrigin, ToolResult};
// IdbStorage removed during runtime cleanup
/// The central runtime engine using QuickJS.
@@ -506,6 +506,26 @@ impl RuntimeEngine {
.await
}
/// Call a tool from inside a running skill. Enforces self-only invocation.
pub async fn call_tool_as_skill(
&self,
caller_skill_id: &str,
target_skill_id: &str,
tool_name: &str,
arguments: serde_json::Value,
) -> Result<ToolResult, String> {
self.registry
.call_tool_scoped(
ToolCallOrigin::SkillSelf {
skill_id: caller_skill_id.to_string(),
},
target_skill_id,
tool_name,
arguments,
)
.await
}
/// Broadcast a server event to all running skills.
pub async fn broadcast_event(&self, event: &str, data: serde_json::Value) {
self.registry.broadcast_event(event, data).await;
+3 -3
View File
@@ -927,12 +927,12 @@ globalThis.cron = {
// ============================================================================
globalThis.skills = {
list: function () {
console.warn('[skills] list not implemented in QuickJS runtime yet');
console.warn('[skills] list is intentionally unavailable in isolated runtime');
return [];
},
callTool: function (skillId, toolName, args) {
console.warn('[skills] callTool not implemented in QuickJS runtime yet');
return { error: 'Not implemented' };
console.warn('[skills] callTool is disabled by runtime isolation policy');
return { error: 'Cross-skill invocation is disabled' };
},
};
+124 -2
View File
@@ -8,7 +8,8 @@ use tokio::sync::{mpsc, oneshot};
use crate::openhuman::skills::qjs_skill_instance::SkillState;
use crate::openhuman::skills::types::{
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolDefinition, ToolResult,
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolCallOrigin, ToolDefinition,
ToolResult,
};
/// Entry in the registry for a single skill.
@@ -112,13 +113,37 @@ impl SkillRegistry {
.map(|e| e.state.read().status)
}
/// Call a tool on a specific skill. Returns a oneshot receiver for the result.
/// Call a tool on a specific skill from external host surfaces.
pub async fn call_tool(
&self,
skill_id: &str,
tool_name: &str,
arguments: serde_json::Value,
) -> Result<ToolResult, String> {
self.call_tool_scoped(ToolCallOrigin::External, skill_id, tool_name, arguments)
.await
}
/// Call a tool with an explicit origin so runtime policy can enforce boundaries.
pub async fn call_tool_scoped(
&self,
origin: ToolCallOrigin,
skill_id: &str,
tool_name: &str,
arguments: serde_json::Value,
) -> Result<ToolResult, String> {
if let ToolCallOrigin::SkillSelf {
skill_id: caller_skill_id,
} = &origin
{
if caller_skill_id != skill_id {
return Err(format!(
"Cross-skill tool calls are forbidden: '{}' cannot call '{}.{}'",
caller_skill_id, skill_id, tool_name
));
}
}
log::info!(
"[skill:{}] call_tool '{}' — dispatching to event loop",
skill_id,
@@ -323,3 +348,100 @@ impl Default for SkillRegistry {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::skills::types::{SkillConfig, SkillStatus, ToolCallOrigin};
fn register_running_skill(registry: &SkillRegistry, skill_id: &str) {
let (tx, mut rx) = mpsc::channel(8);
let state = Arc::new(RwLock::new(SkillState {
status: SkillStatus::Running,
tools: vec![],
error: None,
published_state: HashMap::new(),
}));
let task = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if let SkillMessage::CallTool { reply, .. } = msg {
let _ = reply.send(Ok(ToolResult {
content: vec![],
is_error: false,
}));
}
}
});
registry.register(
skill_id,
SkillConfig {
skill_id: skill_id.to_string(),
name: skill_id.to_string(),
entry_point: "index.js".to_string(),
memory_limit: 1024,
auto_start: false,
},
tx,
state,
task,
);
}
#[tokio::test]
async fn external_origin_can_call_any_skill_tool() {
let registry = SkillRegistry::new();
register_running_skill(&registry, "alpha");
let result = registry
.call_tool_scoped(
ToolCallOrigin::External,
"alpha",
"echo",
serde_json::json!({}),
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn skill_origin_cannot_call_other_skill_tool() {
let registry = SkillRegistry::new();
register_running_skill(&registry, "alpha");
register_running_skill(&registry, "beta");
let err = registry
.call_tool_scoped(
ToolCallOrigin::SkillSelf {
skill_id: "alpha".to_string(),
},
"beta",
"echo",
serde_json::json!({}),
)
.await
.expect_err("cross-skill call should be denied");
assert!(err.contains("Cross-skill tool calls are forbidden"));
}
#[tokio::test]
async fn skill_origin_can_call_own_tool() {
let registry = SkillRegistry::new();
register_running_skill(&registry, "alpha");
let result = registry
.call_tool_scoped(
ToolCallOrigin::SkillSelf {
skill_id: "alpha".to_string(),
},
"alpha",
"echo",
serde_json::json!({}),
)
.await;
assert!(result.is_ok());
}
}
+5 -2
View File
@@ -29,7 +29,7 @@ use {
// SkillRegistry only available on desktop
use crate::openhuman::skills::skill_registry::SkillRegistry;
use crate::openhuman::skills::types::{SkillSnapshot, SkillStatus};
use crate::openhuman::skills::types::{SkillSnapshot, SkillStatus, ToolCallOrigin};
/// Events emitted to the frontend via Tauri.
#[allow(dead_code)]
@@ -727,7 +727,10 @@ async fn handle_mcp_tool_call(
let registry = shared.registry.read().clone();
if let Some(registry) = registry {
match registry.call_tool(skill_id, tool_name, arguments).await {
match registry
.call_tool_scoped(ToolCallOrigin::External, skill_id, tool_name, arguments)
.await
{
Ok(tool_result) => json!({
"content": tool_result.content,
"isError": tool_result.is_error,
+10 -1
View File
@@ -1,4 +1,4 @@
//! Core type definitions for the V8 skill runtime.
//! Core type definitions for the QuickJS skill runtime.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -105,6 +105,15 @@ pub enum SkillMessage {
LoadParams { params: serde_json::Value },
}
/// Origin of a tool-call request entering the skill runtime.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolCallOrigin {
/// Calls initiated by trusted host surfaces (RPC/UI/socket MCP).
External,
/// Calls initiated from inside a running skill.
SkillSelf { skill_id: String },
}
/// Result of executing a tool.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
-2
View File
@@ -29,7 +29,6 @@ pub mod schema;
mod schemas;
pub mod screenshot;
pub mod shell;
pub mod skills_call;
pub mod traits;
pub mod web_search_tool;
@@ -66,7 +65,6 @@ pub use schemas::{
};
pub use screenshot::ScreenshotTool;
pub use shell::ShellTool;
pub use skills_call::SkillsCallTool;
pub use traits::Tool;
#[allow(unused_imports)]
pub use traits::{ToolResult, ToolSpec};
-3
View File
@@ -94,7 +94,6 @@ pub fn all_tools_with_runtime(
security.clone(),
workspace_dir.to_path_buf(),
)),
Box::new(SkillsCallTool::new()),
];
if browser_config.enabled {
@@ -253,7 +252,6 @@ mod tests {
assert!(names.contains(&"schedule"));
assert!(names.contains(&"pushover"));
assert!(names.contains(&"proxy_config"));
assert!(names.contains(&"skills_call"));
}
#[test]
@@ -293,7 +291,6 @@ mod tests {
assert!(names.contains(&"browser_open"));
assert!(names.contains(&"pushover"));
assert!(names.contains(&"proxy_config"));
assert!(names.contains(&"skills_call"));
}
#[test]
-179
View File
@@ -1,179 +0,0 @@
use async_trait::async_trait;
use serde_json::{Map, Value};
use crate::openhuman::skills::require_engine;
use crate::openhuman::tools::{Tool, ToolResult};
pub struct SkillsCallTool;
impl SkillsCallTool {
pub fn new() -> Self {
Self
}
fn validate_args(args: Value) -> anyhow::Result<(String, String, Value)> {
let obj = args
.as_object()
.ok_or_else(|| anyhow::anyhow!("arguments must be a JSON object"))?;
let skill_id = obj
.get("skill_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing required field: skill_id"))?
.to_string();
let tool_name = obj
.get("tool_name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
.ok_or_else(|| anyhow::anyhow!("missing required field: tool_name"))?
.to_string();
let arguments = obj
.get("arguments")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
Ok((skill_id, tool_name, arguments))
}
fn render_skill_output(result: &crate::openhuman::skills::types::ToolResult) -> String {
let mut parts = Vec::new();
for block in &result.content {
match block {
crate::openhuman::skills::types::ToolContent::Text { text } => {
parts.push(text.clone());
}
crate::openhuman::skills::types::ToolContent::Json { data } => {
parts.push(data.to_string());
}
}
}
if parts.is_empty() {
String::new()
} else {
parts.join("\n")
}
}
}
#[async_trait]
impl Tool for SkillsCallTool {
fn name(&self) -> &str {
"skills_call"
}
fn description(&self) -> &str {
"Call a running QuickJS skill tool by skill_id and tool_name."
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"skill_id": {
"type": "string",
"description": "The runtime skill id (for example: notion, google, github)."
},
"tool_name": {
"type": "string",
"description": "The tool name exported by that skill."
},
"arguments": {
"type": "object",
"description": "Arguments for the skill tool call. Defaults to {}."
}
},
"required": ["skill_id", "tool_name"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let (skill_id, tool_name, arguments) = Self::validate_args(args)?;
let engine = require_engine().map_err(anyhow::Error::msg)?;
let result = engine
.call_tool(&skill_id, &tool_name, arguments)
.await
.map_err(anyhow::Error::msg)?;
Ok(ToolResult {
success: !result.is_error,
output: Self::render_skill_output(&result),
error: result
.is_error
.then(|| "skill tool reported an error".to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_declares_required_fields() {
let schema = SkillsCallTool::new().parameters_schema();
let required = schema["required"]
.as_array()
.expect("required must be an array");
assert!(required.contains(&Value::String("skill_id".to_string())));
assert!(required.contains(&Value::String("tool_name".to_string())));
}
#[test]
fn validate_args_defaults_arguments_object() {
let (skill_id, tool_name, arguments) = SkillsCallTool::validate_args(serde_json::json!({
"skill_id": "notion",
"tool_name": "search"
}))
.expect("args should be valid");
assert_eq!(skill_id, "notion");
assert_eq!(tool_name, "search");
assert!(arguments.is_object());
}
#[test]
fn render_skill_output_joins_content_blocks() {
let rendered =
SkillsCallTool::render_skill_output(&crate::openhuman::skills::types::ToolResult {
content: vec![
crate::openhuman::skills::types::ToolContent::Text {
text: "first".to_string(),
},
crate::openhuman::skills::types::ToolContent::Json {
data: serde_json::json!({"ok": true}),
},
],
is_error: false,
});
assert!(rendered.contains("first"));
assert!(rendered.contains(r#"{"ok":true}"#));
}
#[tokio::test]
async fn execute_fails_when_runtime_is_not_initialized() {
let tool = SkillsCallTool::new();
let result = tool
.execute(serde_json::json!({
"skill_id": "missing-runtime",
"tool_name": "echo",
"arguments": {}
}))
.await;
assert!(result.is_err());
let err = result.err().expect("error expected").to_string();
assert!(
err.contains("skill runtime not initialized"),
"unexpected error: {err}"
);
}
}