use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::secrets::{CredentialLocation, CredentialMapping};
use crate::tools::wasm::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WebhookCapability, WorkspaceCapability,
};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CapabilitiesFile {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub wit_version: Option<String>,
#[serde(default)]
pub http: Option<HttpCapabilitySchema>,
#[serde(default)]
pub secrets: Option<SecretsCapabilitySchema>,
#[serde(default)]
pub tool_invoke: Option<ToolInvokeCapabilitySchema>,
#[serde(default)]
pub workspace: Option<WorkspaceCapabilitySchema>,
#[serde(default)]
pub webhook: Option<WebhookCapabilitySchema>,
#[serde(default)]
pub websocket: Option<serde_json::Value>,
#[serde(default)]
pub auth: Option<AuthCapabilitySchema>,
#[serde(default)]
pub setup: Option<ToolSetupSchema>,
#[serde(default, skip_serializing)]
pub capabilities: Option<Box<CapabilitiesFile>>,
}
const MAX_DESCRIPTION_CHARS: usize = 4096;
impl CapabilitiesFile {
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
let mut caps = serde_json::from_str::<Self>(json).map(Self::resolve_nested)?;
caps.enforce_limits();
Ok(caps)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
let mut caps = serde_json::from_slice::<Self>(bytes).map(Self::resolve_nested)?;
caps.enforce_limits();
Ok(caps)
}
fn enforce_limits(&mut self) {
if let Some(ref desc) = self.description
&& desc.len() > MAX_DESCRIPTION_CHARS
{
let truncated = &desc[..desc.floor_char_boundary(MAX_DESCRIPTION_CHARS)];
tracing::warn!(
"Capabilities description truncated from {} to {} chars",
desc.len(),
MAX_DESCRIPTION_CHARS,
);
self.description = Some(truncated.to_string());
}
}
const MAX_NESTED_DEPTH: usize = 8;
fn resolve_nested(self) -> Self {
self.resolve_nested_inner(0)
}
fn resolve_nested_inner(mut self, depth: usize) -> Self {
if depth > Self::MAX_NESTED_DEPTH {
tracing::warn!(
"Capabilities nesting exceeds maximum depth of {}, stopping resolution",
Self::MAX_NESTED_DEPTH
);
return self;
}
if let Some(inner) = self.capabilities.take() {
let inner = inner.resolve_nested_inner(depth + 1);
self.description = self.description.or(inner.description);
self.http = self.http.or(inner.http);
self.secrets = self.secrets.or(inner.secrets);
self.tool_invoke = self.tool_invoke.or(inner.tool_invoke);
self.workspace = self.workspace.or(inner.workspace);
self.webhook = self.webhook.or(inner.webhook);
self.websocket = self.websocket.or(inner.websocket);
self.auth = self.auth.or(inner.auth);
self.setup = self.setup.or(inner.setup);
}
self
}
pub fn validate(&self, name: &str) {
const MIN_PROMPT_LENGTH: usize = 30;
if let Some(setup) = &self.setup {
if !setup.required_secrets.is_empty() && self.auth.is_none() {
tracing::warn!(
tool = name,
"setup.required_secrets defined but no 'auth' section — \
chat-based auth card will not display for this tool"
);
}
for secret in &setup.required_secrets {
if secret.prompt.len() < MIN_PROMPT_LENGTH {
tracing::warn!(
tool = name,
secret = secret.name,
prompt = secret.prompt,
"setup.required_secrets prompt is shorter than {} chars — \
consider a more descriptive prompt that tells the user where to find this value",
MIN_PROMPT_LENGTH
);
}
}
}
if let Some(auth) = &self.auth
&& auth.oauth.is_none()
{
if auth.setup_url.is_none() {
tracing::warn!(
tool = name,
"auth section has no OAuth and no setup_url — \
user has no link to obtain credentials"
);
}
if auth.instructions.is_none() {
tracing::warn!(
tool = name,
"auth section has no OAuth and no instructions — \
user has no guidance on how to obtain credentials"
);
}
}
}
pub fn to_capabilities(&self) -> Capabilities {
let mut caps = Capabilities::default();
if let Some(http) = &self.http {
caps.http = Some(http.to_http_capability());
}
if let Some(secrets) = &self.secrets {
caps.secrets = Some(SecretsCapability {
allowed_names: secrets.allowed_names.clone(),
});
}
if let Some(tool_invoke) = &self.tool_invoke {
caps.tool_invoke = Some(ToolInvokeCapability {
aliases: tool_invoke.aliases.clone(),
rate_limit: tool_invoke
.rate_limit
.as_ref()
.map(|r| r.to_rate_limit_config())
.unwrap_or_default(),
});
}
if let Some(workspace) = &self.workspace {
caps.workspace_read = Some(WorkspaceCapability {
allowed_prefixes: workspace.allowed_prefixes.clone(),
reader: None, });
}
if let Some(webhook) = &self.webhook {
caps.webhook = Some(webhook.to_webhook_capability());
}
caps.websocket = self.websocket.clone();
caps
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HttpCapabilitySchema {
#[serde(default)]
pub allowlist: Vec<EndpointPatternSchema>,
#[serde(default)]
pub credentials: HashMap<String, CredentialMappingSchema>,
#[serde(default)]
pub rate_limit: Option<RateLimitSchema>,
#[serde(default)]
pub max_request_bytes: Option<usize>,
#[serde(default)]
pub max_response_bytes: Option<usize>,
#[serde(default)]
pub timeout_secs: Option<u64>,
}
impl HttpCapabilitySchema {
fn to_http_capability(&self) -> HttpCapability {
let mut cap = HttpCapability {
allowlist: self
.allowlist
.iter()
.map(|p| p.to_endpoint_pattern())
.collect(),
credentials: self
.credentials
.values()
.map(|m| (m.secret_name.clone(), m.to_credential_mapping()))
.collect(),
rate_limit: self
.rate_limit
.as_ref()
.map(|r| r.to_rate_limit_config())
.unwrap_or_default(),
..Default::default()
};
if let Some(max) = self.max_request_bytes {
cap.max_request_bytes = max;
}
if let Some(max) = self.max_response_bytes {
cap.max_response_bytes = max;
}
if let Some(secs) = self.timeout_secs {
cap.timeout = Duration::from_secs(secs);
}
cap
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointPatternSchema {
pub host: String,
#[serde(default)]
pub path_prefix: Option<String>,
#[serde(default)]
pub methods: Vec<String>,
}
impl EndpointPatternSchema {
fn to_endpoint_pattern(&self) -> EndpointPattern {
EndpointPattern {
host: self.host.clone(),
path_prefix: self.path_prefix.clone(),
methods: self.methods.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialMappingSchema {
pub secret_name: String,
pub location: CredentialLocationSchema,
#[serde(default)]
pub host_patterns: Vec<String>,
}
impl CredentialMappingSchema {
fn to_credential_mapping(&self) -> CredentialMapping {
CredentialMapping {
secret_name: self.secret_name.clone(),
location: self.location.to_credential_location(),
host_patterns: self.host_patterns.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CredentialLocationSchema {
Bearer,
Basic { username: String },
Header {
#[serde(alias = "header_name")]
name: String,
#[serde(default)]
prefix: Option<String>,
},
QueryParam { name: String },
UrlPath { placeholder: String },
}
impl CredentialLocationSchema {
fn to_credential_location(&self) -> CredentialLocation {
match self {
CredentialLocationSchema::Bearer => CredentialLocation::AuthorizationBearer,
CredentialLocationSchema::Basic { username } => {
CredentialLocation::AuthorizationBasic {
username: username.clone(),
}
}
CredentialLocationSchema::Header { name, prefix } => CredentialLocation::Header {
name: name.clone(),
prefix: prefix.clone(),
},
CredentialLocationSchema::QueryParam { name } => {
CredentialLocation::QueryParam { name: name.clone() }
}
CredentialLocationSchema::UrlPath { placeholder } => CredentialLocation::UrlPath {
placeholder: placeholder.clone(),
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitSchema {
#[serde(default = "default_requests_per_minute")]
pub requests_per_minute: u32,
#[serde(default = "default_requests_per_hour")]
pub requests_per_hour: u32,
}
fn default_requests_per_minute() -> u32 {
60
}
fn default_requests_per_hour() -> u32 {
1000
}
impl RateLimitSchema {
fn to_rate_limit_config(&self) -> RateLimitConfig {
RateLimitConfig {
requests_per_minute: self.requests_per_minute,
requests_per_hour: self.requests_per_hour,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsCapabilitySchema {
#[serde(default)]
pub allowed_names: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolInvokeCapabilitySchema {
#[serde(default)]
pub aliases: HashMap<String, String>,
#[serde(default)]
pub rate_limit: Option<RateLimitSchema>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkspaceCapabilitySchema {
#[serde(default)]
pub allowed_prefixes: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WebhookCapabilitySchema {
#[serde(default)]
pub secret_header: Option<String>,
#[serde(default)]
pub secret_name: Option<String>,
#[serde(default)]
pub signature_key_secret_name: Option<String>,
#[serde(default)]
pub hmac_secret_name: Option<String>,
#[serde(default)]
pub hmac_signature_header: Option<String>,
#[serde(default)]
pub hmac_timestamp_header: Option<String>,
#[serde(default)]
pub hmac_prefix: Option<String>,
}
impl WebhookCapabilitySchema {
fn to_webhook_capability(&self) -> WebhookCapability {
WebhookCapability {
secret_header: self.secret_header.clone(),
secret_name: self.secret_name.clone(),
signature_key_secret_name: self.signature_key_secret_name.clone(),
hmac_secret_name: self.hmac_secret_name.clone(),
hmac_signature_header: self.hmac_signature_header.clone(),
hmac_timestamp_header: self.hmac_timestamp_header.clone(),
hmac_prefix: self.hmac_prefix.clone(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuthCapabilitySchema {
pub secret_name: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub oauth: Option<OAuthConfigSchema>,
#[serde(default)]
pub instructions: Option<String>,
#[serde(default)]
pub setup_url: Option<String>,
#[serde(default)]
pub token_hint: Option<String>,
#[serde(default)]
pub env_var: Option<String>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub validation_endpoint: Option<ValidationEndpointSchema>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OAuthConfigSchema {
pub authorization_url: String,
pub token_url: String,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub client_id_env: Option<String>,
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default)]
pub client_secret_env: Option<String>,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default = "default_true")]
pub use_pkce: bool,
#[serde(default)]
pub extra_params: std::collections::HashMap<String, String>,
#[serde(default = "default_access_token_field")]
pub access_token_field: String,
}
fn default_true() -> bool {
true
}
fn default_access_token_field() -> String {
"access_token".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ValidationEndpointSchema {
pub url: String,
#[serde(default = "default_method")]
pub method: String,
#[serde(default = "default_success_status")]
pub success_status: u16,
#[serde(default)]
pub headers: HashMap<String, String>,
}
fn default_method() -> String {
"GET".to_string()
}
fn default_success_status() -> u16 {
200
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolSetupSchema {
#[serde(default)]
pub required_secrets: Vec<ToolSecretSetupSchema>,
#[serde(default)]
pub required_fields: Vec<ToolFieldSetupSchema>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSecretSetupSchema {
pub name: String,
pub prompt: String,
#[serde(default)]
pub optional: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFieldSetupSchema {
pub name: String,
pub prompt: String,
#[serde(default)]
pub optional: bool,
#[serde(default = "default_tool_setup_field_input_type")]
pub input_type: ToolSetupFieldInputType,
#[serde(default)]
pub setting_path: Option<String>,
#[serde(default)]
pub restart_required: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolSetupFieldInputType {
#[default]
Text,
Password,
}
fn default_tool_setup_field_input_type() -> ToolSetupFieldInputType {
ToolSetupFieldInputType::Text
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::tools::wasm::capabilities_schema::{CapabilitiesFile, CredentialLocationSchema};
#[test]
fn test_parse_minimal() {
let json = "{}";
let caps = CapabilitiesFile::from_json(json).unwrap();
assert!(caps.http.is_none());
assert!(caps.secrets.is_none());
}
#[test]
fn test_parse_http_allowlist() {
let json = r#"{
"http": {
"allowlist": [
{ "host": "api.slack.com", "path_prefix": "/api/", "methods": ["GET", "POST"] }
]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
assert_eq!(http.allowlist.len(), 1);
assert_eq!(http.allowlist[0].host, "api.slack.com");
assert_eq!(http.allowlist[0].path_prefix, Some("/api/".to_string()));
assert_eq!(http.allowlist[0].methods, vec!["GET", "POST"]);
}
#[test]
fn test_parse_credentials() {
let json = r#"{
"http": {
"allowlist": [{ "host": "slack.com" }],
"credentials": {
"slack": {
"secret_name": "slack_bot_token",
"location": { "type": "bearer" },
"host_patterns": ["slack.com", "*.slack.com"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
assert_eq!(http.credentials.len(), 1);
let cred = http.credentials.get("slack").unwrap();
assert_eq!(cred.secret_name, "slack_bot_token");
assert!(matches!(cred.location, CredentialLocationSchema::Bearer));
assert_eq!(cred.host_patterns, vec!["slack.com", "*.slack.com"]);
}
#[test]
fn test_parse_custom_header_credential() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.example.com" }],
"credentials": {
"api_key": {
"secret_name": "my_api_key",
"location": { "type": "header", "name": "X-API-Key", "prefix": "Key " },
"host_patterns": ["api.example.com"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
let cred = http.credentials.get("api_key").unwrap();
match &cred.location {
CredentialLocationSchema::Header { name, prefix } => {
assert_eq!(name, "X-API-Key");
assert_eq!(prefix, &Some("Key ".to_string()));
}
_ => panic!("Expected Header location"),
}
}
#[test]
fn test_parse_url_path_credential() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.telegram.org" }],
"credentials": {
"telegram_bot": {
"secret_name": "telegram_bot_token",
"location": {
"type": "url_path",
"placeholder": "{TELEGRAM_BOT_TOKEN}"
},
"host_patterns": ["api.telegram.org"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
let cred = http.credentials.get("telegram_bot").unwrap();
match &cred.location {
CredentialLocationSchema::UrlPath { placeholder } => {
assert_eq!(placeholder, "{TELEGRAM_BOT_TOKEN}");
}
_ => panic!("Expected UrlPath location"),
}
}
#[test]
fn test_parse_secrets_capability() {
let json = r#"{
"secrets": {
"allowed_names": ["slack_*", "openai_key"]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let secrets = caps.secrets.unwrap();
assert_eq!(secrets.allowed_names, vec!["slack_*", "openai_key"]);
}
#[test]
fn test_parse_tool_invoke() {
let json = r#"{
"tool_invoke": {
"aliases": {
"search": "brave_search",
"calc": "calculator"
},
"rate_limit": {
"requests_per_minute": 10,
"requests_per_hour": 100
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let tool_invoke = caps.tool_invoke.unwrap();
assert_eq!(
tool_invoke.aliases.get("search"),
Some(&"brave_search".to_string())
);
let rate = tool_invoke.rate_limit.unwrap();
assert_eq!(rate.requests_per_minute, 10);
}
#[test]
fn test_parse_workspace() {
let json = r#"{
"workspace": {
"allowed_prefixes": ["context/", "daily/"]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let workspace = caps.workspace.unwrap();
assert_eq!(workspace.allowed_prefixes, vec!["context/", "daily/"]);
}
#[test]
fn test_parse_webhook_capability() {
let json = r#"{
"webhook": {
"hmac_secret_name": "github_webhook_secret",
"hmac_signature_header": "x-hub-signature-256",
"hmac_prefix": "sha256="
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let webhook = caps.webhook.unwrap();
assert_eq!(
webhook.hmac_secret_name.as_deref(),
Some("github_webhook_secret")
);
assert_eq!(
webhook.hmac_signature_header.as_deref(),
Some("x-hub-signature-256")
);
}
#[test]
fn test_to_capabilities() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.slack.com", "path_prefix": "/api/" }],
"rate_limit": { "requests_per_minute": 50, "requests_per_hour": 500 }
},
"secrets": {
"allowed_names": ["slack_token"]
}
}"#;
let file = CapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
assert!(caps.http.is_some());
let http = caps.http.unwrap();
assert_eq!(http.allowlist.len(), 1);
assert_eq!(http.rate_limit.requests_per_minute, 50);
assert!(caps.secrets.is_some());
let secrets = caps.secrets.unwrap();
assert!(secrets.is_allowed("slack_token"));
}
#[test]
fn test_full_slack_example() {
let json = r#"{
"http": {
"allowlist": [
{ "host": "slack.com", "path_prefix": "/api/", "methods": ["GET", "POST"] }
],
"credentials": {
"slack_bot_token": {
"secret_name": "slack_bot_token",
"location": { "type": "bearer" },
"host_patterns": ["slack.com"]
}
},
"rate_limit": { "requests_per_minute": 50, "requests_per_hour": 1000 }
},
"secrets": {
"allowed_names": ["slack_bot_token"]
}
}"#;
let file = CapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
let http = caps.http.unwrap();
assert_eq!(http.allowlist[0].host, "slack.com");
assert!(http.credentials.contains_key("slack_bot_token"));
let secrets = caps.secrets.unwrap();
assert!(secrets.is_allowed("slack_bot_token"));
}
#[test]
fn test_parse_auth_capability() {
let json = r#"{
"auth": {
"secret_name": "notion_api_token",
"display_name": "Notion",
"instructions": "Create an integration at notion.so/my-integrations",
"setup_url": "https://www.notion.so/my-integrations",
"token_hint": "Starts with 'secret_' or 'ntn_'",
"env_var": "NOTION_TOKEN",
"provider": "notion",
"validation_endpoint": {
"url": "https://api.notion.com/v1/users/me",
"method": "GET",
"success_status": 200
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let auth = caps.auth.unwrap();
assert_eq!(auth.secret_name, "notion_api_token");
assert_eq!(auth.display_name, Some("Notion".to_string()));
assert_eq!(auth.env_var, Some("NOTION_TOKEN".to_string()));
assert_eq!(auth.provider, Some("notion".to_string()));
let validation = auth.validation_endpoint.unwrap();
assert_eq!(validation.url, "https://api.notion.com/v1/users/me");
assert_eq!(validation.method, "GET");
assert_eq!(validation.success_status, 200);
}
#[test]
fn test_parse_auth_minimal() {
let json = r#"{
"auth": {
"secret_name": "my_api_key"
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let auth = caps.auth.unwrap();
assert_eq!(auth.secret_name, "my_api_key");
assert!(auth.display_name.is_none());
assert!(auth.setup_url.is_none());
}
#[test]
fn test_header_location_with_name_field() {
let json = r#"{
"http": {
"allowlist": [{ "host": "discord.com" }],
"credentials": {
"bot_token": {
"secret_name": "discord_bot_token",
"location": { "type": "header", "name": "Authorization", "prefix": "Bot " },
"host_patterns": ["discord.com"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
let cred = http.credentials.get("bot_token").unwrap();
match &cred.location {
CredentialLocationSchema::Header { name, prefix } => {
assert_eq!(name, "Authorization");
assert_eq!(prefix, &Some("Bot ".to_string()));
}
_ => panic!("Expected Header location"),
}
}
#[test]
fn test_header_location_with_header_name_alias() {
let json = r#"{
"http": {
"allowlist": [{ "host": "discord.com" }],
"credentials": {
"bot_token": {
"secret_name": "discord_bot_token",
"location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " },
"host_patterns": ["discord.com"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
let cred = http.credentials.get("bot_token").unwrap();
match &cred.location {
CredentialLocationSchema::Header { name, prefix } => {
assert_eq!(name, "Authorization");
assert_eq!(prefix, &Some("Bot ".to_string()));
}
_ => panic!("Expected Header location"),
}
}
#[test]
fn test_discord_capabilities_file_parses() {
let json = r#"{
"type": "channel",
"name": "discord",
"description": "Discord channel",
"setup": {
"required_secrets": [
{
"name": "discord_bot_token",
"prompt": "Enter your Discord Bot Token",
"optional": false
},
{
"name": "discord_public_key",
"prompt": "Enter your Discord Public Key",
"optional": false
}
]
},
"capabilities": {
"http": {
"allowlist": [{ "host": "discord.com", "path_prefix": "/api/v10" }],
"credentials": {
"discord_bot_token": {
"secret_name": "discord_bot_token",
"location": { "type": "header", "name": "Authorization", "prefix": "Bot " },
"host_patterns": ["discord.com"]
}
}
}
},
"config": {
"require_signature_verification": true
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
assert!(http.credentials.contains_key("discord_bot_token"));
}
#[test]
fn test_header_location_missing_name_fails() {
let json = r#"{
"http": {
"allowlist": [{ "host": "example.com" }],
"credentials": {
"api_key": {
"secret_name": "my_key",
"location": { "type": "header", "prefix": "Key " },
"host_patterns": ["example.com"]
}
}
}
}"#;
assert!(
CapabilitiesFile::from_json(json).is_err(),
"Header without name or header_name should fail deserialization"
);
}
#[test]
fn test_resolve_nested_outer_takes_precedence() {
let json = r#"{
"http": {
"allowlist": [{ "host": "outer.example.com" }]
},
"capabilities": {
"http": {
"allowlist": [{ "host": "inner.example.com" }]
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
assert_eq!(
http.allowlist[0].host, "outer.example.com",
"Outer http should take precedence over inner"
);
}
#[test]
fn test_resolve_nested_doubly_nested() {
let json = r#"{
"capabilities": {
"capabilities": {
"http": {
"allowlist": [{ "host": "deep.example.com" }]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
assert_eq!(
http.allowlist[0].host, "deep.example.com",
"Doubly-nested capabilities should be resolved"
);
}
#[test]
fn test_resolve_nested_all_fields_promoted() {
let json = r#"{
"capabilities": {
"secrets": {
"allowed_names": ["my_secret"]
},
"workspace": {
"allowed_prefixes": ["data/"]
},
"auth": {
"secret_name": "my_auth_token"
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert!(caps.secrets.is_some(), "secrets should be promoted");
assert!(caps.workspace.is_some(), "workspace should be promoted");
assert!(caps.auth.is_some(), "auth should be promoted");
assert_eq!(caps.secrets.unwrap().allowed_names, vec!["my_secret"]);
assert_eq!(caps.workspace.unwrap().allowed_prefixes, vec!["data/"]);
assert_eq!(caps.auth.unwrap().secret_name, "my_auth_token");
}
#[test]
fn test_parse_tool_setup_schema() {
let json = r#"{
"setup": {
"required_secrets": [
{
"name": "google_oauth_client_id",
"prompt": "Google OAuth Client ID"
},
{
"name": "google_oauth_client_secret",
"prompt": "Google OAuth Client Secret",
"optional": true
}
],
"required_fields": [
{
"name": "llm_backend",
"prompt": "LLM Provider",
"setting_path": "llm_backend",
"restart_required": true
},
{
"name": "selected_model",
"prompt": "Model Name",
"input_type": "text",
"setting_path": "selected_model"
}
]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let setup = caps.setup.unwrap();
assert_eq!(setup.required_secrets.len(), 2);
assert_eq!(setup.required_secrets[0].name, "google_oauth_client_id");
assert_eq!(setup.required_secrets[0].prompt, "Google OAuth Client ID");
assert!(!setup.required_secrets[0].optional);
assert_eq!(setup.required_secrets[1].name, "google_oauth_client_secret");
assert!(setup.required_secrets[1].optional);
assert_eq!(setup.required_fields.len(), 2);
assert_eq!(setup.required_fields[0].name, "llm_backend");
assert_eq!(
setup.required_fields[0].setting_path.as_deref(),
Some("llm_backend")
);
assert!(setup.required_fields[0].restart_required);
assert_eq!(
setup.required_fields[0].input_type,
crate::tools::wasm::capabilities_schema::ToolSetupFieldInputType::Text
);
assert_eq!(setup.required_fields[1].name, "selected_model");
}
#[test]
fn test_tool_setup_field_input_type_defaults_to_text() {
let json = r#"{
"setup": {
"required_fields": [
{
"name": "provider",
"prompt": "Provider"
},
{
"name": "token_hint",
"prompt": "Token Hint",
"input_type": "password"
}
]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let setup = caps.setup.unwrap();
assert_eq!(
setup.required_fields[0].input_type,
crate::tools::wasm::capabilities_schema::ToolSetupFieldInputType::Text
);
assert_eq!(
setup.required_fields[1].input_type,
crate::tools::wasm::capabilities_schema::ToolSetupFieldInputType::Password
);
}
#[test]
fn test_resolve_nested_setup_promoted() {
let json = r#"{
"capabilities": {
"setup": {
"required_secrets": [
{ "name": "my_secret", "prompt": "Enter secret" }
]
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert!(
caps.setup.is_some(),
"setup should be promoted from inner capabilities"
);
assert_eq!(caps.setup.unwrap().required_secrets[0].name, "my_secret");
}
#[test]
fn test_validate_setup_without_auth_warns() {
let json = r#"{
"setup": {
"required_secrets": [
{ "name": "api_key", "prompt": "Enter your API key from the provider dashboard settings page" }
]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
caps.validate("test-tool");
}
#[test]
fn test_validate_manual_auth_missing_fields() {
let json = r#"{
"auth": {
"secret_name": "my_api_key"
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
caps.validate("test-tool");
}
#[test]
fn test_validate_clean_tool() {
let json = r#"{
"auth": {
"secret_name": "my_api_key",
"setup_url": "https://example.com/api-keys",
"instructions": "Go to example.com/api-keys and create a new key"
},
"setup": {
"required_secrets": [
{
"name": "my_api_key",
"prompt": "Enter your API key from https://example.com/api-keys"
}
]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
caps.validate("clean-tool");
}
#[test]
fn test_resolve_nested_empty_capabilities_noop() {
let json = r#"{
"http": {
"allowlist": [{ "host": "preserved.example.com" }]
},
"capabilities": {}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
assert_eq!(
http.allowlist[0].host, "preserved.example.com",
"Empty inner capabilities should not clobber outer http"
);
}
#[test]
fn test_discord_websocket_config_preserved_in_runtime_capabilities() {
let json = r#"{
"capabilities": {
"http": {
"allowlist": [{ "host": "discord.com", "path_prefix": "/api/v10" }]
},
"websocket": {
"url": "wss://gateway.discord.gg/?v=10&encoding=json",
"connect_on_start": true,
"identify": {
"intents": 513,
"properties": {
"os": "linux",
"browser": "ironclaw",
"device": "ironclaw"
}
}
}
}
}"#;
let file = CapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
assert_eq!(
caps.websocket,
Some(json!({
"url": "wss://gateway.discord.gg/?v=10&encoding=json",
"connect_on_start": true,
"identify": {
"intents": 513,
"properties": {
"os": "linux",
"browser": "ironclaw",
"device": "ironclaw"
}
}
}))
);
}
#[test]
fn test_parse_description() {
let json = r#"{
"description": "Search the web using Brave Search API"
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("Search the web using Brave Search API")
);
}
#[test]
fn test_parse_without_description() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.example.com" }]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert!(
caps.description.is_none(),
"description should be None when not provided"
);
}
#[test]
fn test_parameters_field_silently_ignored() {
let json = r#"{
"description": "A tool",
"parameters": {
"type": "object",
"properties": { "action": { "type": "string" } }
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(caps.description.as_deref(), Some("A tool"));
}
#[test]
fn test_resolve_nested_description_promoted() {
let json = r#"{
"capabilities": {
"description": "Inner tool description"
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("Inner tool description"),
"description should be promoted from inner capabilities"
);
}
#[test]
fn test_resolve_nested_outer_description_takes_precedence() {
let json = r#"{
"description": "Outer description wins",
"capabilities": {
"description": "Inner description loses"
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
assert_eq!(
caps.description.as_deref(),
Some("Outer description wins"),
"Outer description should take precedence over inner"
);
}
#[test]
fn test_resolve_nested_depth_limit() {
let mut json = r#"{ "description": "leaf" }"#.to_string();
for _ in 0..20 {
json = format!(r#"{{ "capabilities": {json} }}"#);
}
let _caps = CapabilitiesFile::from_json(&json).unwrap();
}
#[test]
fn test_description_truncated_at_limit() {
let long_desc = "x".repeat(10_000);
let json = format!(r#"{{ "description": "{long_desc}" }}"#);
let caps = CapabilitiesFile::from_json(&json).unwrap();
let desc = caps.description.unwrap();
assert!(
desc.len() <= super::MAX_DESCRIPTION_CHARS + 50, "description should be truncated to ~{} chars, got {}",
super::MAX_DESCRIPTION_CHARS,
desc.len()
);
}
}