From 61a123a746589b19f4a97033193fc3729affdf7c Mon Sep 17 00:00:00 2001 From: Peniel Ben Date: Mon, 16 Feb 2026 12:58:13 +0100 Subject: [PATCH] Add GitHub tool and Discord channel (#34) * Add GitHub tool for IronClaw - manage repos, issues, PRs, and workflows * Add Discord channel for IronClaw - slash commands and button interactions * Security fixes: URL encoding, secret validation, Discord button handler - Add URL encoding for all path segments and query parameters (P1) - Add path segment validation to prevent path traversal - Add secret_exists check for better error messages (P2) - Fix http_request signature to use 5 args (P2) - Fix Discord button handler to check member field (P2) - Fix typo in Discord slash command format (P2) - Add github.capabilities.json and discord.capabilities.json (Blocker) - Add Cargo.toml for Discord channel (Blocker) - Add limit caps (max 100) for all list operations (P3) - Remove debug logging * Apply Copilot review fixes Security & Code Quality: - Use secret_get instead of workspace_read for GitHub token - Remove manual Authorization header (host injects via capabilities) - Add validation for file paths (reject path traversal) - Add validation for workflow_id and git refs - Fix url_encode_query comment - Add release profile optimizations to Cargo.toml files - Fix package names to match conventions (github-tool, discord-channel) - Add metadata fields to Cargo.toml - Fix rate limits to be consistent (60/min, 3600/hr) - Fix Discord user_name to filter empty global_name - Fix Discord metadata serialization error handling - Update Discord README to clarify which secrets are used by host vs WASM - Better formatting for Discord command option values * applied all PR change requests and comments * cleaned up workspace * Adding validation for empty path segments and event enum in GitHub tool * addedvalidation for events and vaidation to reject empty file path in github tools and implemented safe UTF-8 trunacating * added codegen units and updated truncating logic also update capabilities.json as requested by copilot review * added codegen units and updated truncating logic also update capabilities.json as requested by copilot review * fixed message trucating and remove url_encode alias, also appled all requested changes from last PR comment --------- Co-authored-by: root Co-authored-by: Peni Co-authored-by: Illia Polosukhin Co-authored-by: firat.sertgoz --- channels-src/discord/Cargo.toml | 23 + channels-src/discord/README.md | 121 +++ .../discord/discord.capabilities.json | 39 + channels-src/discord/src/lib.rs | 476 ++++++++++ tools-src/github/Cargo.toml | 22 + tools-src/github/README.md | 189 ++++ .../github/github-tool.capabilities.json | 41 + tools-src/github/src/lib.rs | 845 ++++++++++++++++++ 8 files changed, 1756 insertions(+) create mode 100644 channels-src/discord/Cargo.toml create mode 100644 channels-src/discord/README.md create mode 100644 channels-src/discord/discord.capabilities.json create mode 100644 channels-src/discord/src/lib.rs create mode 100644 tools-src/github/Cargo.toml create mode 100644 tools-src/github/README.md create mode 100644 tools-src/github/github-tool.capabilities.json create mode 100644 tools-src/github/src/lib.rs diff --git a/channels-src/discord/Cargo.toml b/channels-src/discord/Cargo.toml new file mode 100644 index 00000000..6edd6e64 --- /dev/null +++ b/channels-src/discord/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "discord-channel" +version = "0.1.0" +edition = "2021" +description = "Discord channel for IronClaw" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +wit-bindgen = "0.41.0" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +strip = true +opt-level = "s" +lto = true +codegen-units = 1 + + diff --git a/channels-src/discord/README.md b/channels-src/discord/README.md new file mode 100644 index 00000000..6cb0199f --- /dev/null +++ b/channels-src/discord/README.md @@ -0,0 +1,121 @@ +# Discord Channel for IronClaw + +WASM channel for Discord integration - handle slash commands and button interactions via webhooks. + +## Features + +- **Slash Commands** - Process Discord slash commands +- **Button Interactions** - Handle button clicks +- **Thread Support** - Respond in threads +- **DM Support** - Handle direct messages + +## Setup + +1. Create a Discord Application at +2. Create a Bot and get the token +3. Set up Interactions URL to point to your IronClaw instance +4. Copy the Application ID and Public Key +5. Store in IronClaw secrets: + + ```bash + ironclaw secret set discord_bot_token YOUR_BOT_TOKEN + ``` + + **Note:** The `discord_bot_token` secret is the only value read directly by this + Discord channel WASM component. The `discord_app_id` and `discord_public_key` + secrets are used by the IronClaw host (for example, to verify Discord + interaction signatures and manage slash command registration) and are not + accessed from the WASM module itself. + +## Discord Configuration + +### Register Slash Commands + +```bash +curl -X POST \ + -H "Authorization: Bot YOUR_BOT_TOKEN" \ + -H "Content-Type: application/json" \ + https://discord.com/api/v10/applications/YOUR_APP_ID/commands \ + -d '{ + "name": "ask", + "description": "Ask the AI agent", + "options": [{ + "name": "question", + "description": "Your question", + "type": 3, + "required": true + }] + }' +``` + +### Set Interactions Endpoint + +In your Discord app settings, set: + +- Interactions Endpoint URL: `https://your-ironclaw.com/webhook/discord` + +## Usage Examples + +### Slash Command + +User types: `/ask question: What is the weather?` + +The agent receives: + +```text +User: @username +Content: /ask question: What is the weather? +``` + +### Button Click + +When a user clicks a button in a message, the agent receives: + +```text +User: @username +Content: [Button clicked] Original message content +``` + +## Error Handling + +If an internal error occurs (e.g., metadata serialization failure), the tool attempts to send an ephemeral message to the user: + +```text +❌ Internal Error: Failed to process command metadata. +``` + +Check the host logs for detailed error information. + +## Advanced Usage + +### Embeds + +To send embeds, include an `embeds` array in the `metadata_json` field of the agent's response. The structure should match the Discord API `embed` object. + +## Troubleshooting + +### "Invalid Signature" + +- Check that `discord_public_key` is set correctly in IronClaw secrets. +- This validation happens on the host before reaching the WASM. + +### "401 Unauthorized" + +- Check that `discord_bot_token` is set correctly in IronClaw secrets. +- Ensure the bot is added to the server. + +### "Interaction Failed" + +- The interaction might have timed out (Discord requires a response within 3 seconds). +- The `interactions_endpoint_url` might be unreachable. + +## Building + +```bash +cd channels-src/discord +cargo build --target wasm32-wasi --release +``` + +## License + +MIT/Apache-2.0 diff --git a/channels-src/discord/discord.capabilities.json b/channels-src/discord/discord.capabilities.json new file mode 100644 index 00000000..6dc5f9fe --- /dev/null +++ b/channels-src/discord/discord.capabilities.json @@ -0,0 +1,39 @@ +{ + "type": "channel", + "name": "discord", + "description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages", + "capabilities": { + "http": { + "allowlist": [ + { "host": "discord.com", "path_prefix": "/api/v10" } + ], + "credentials": { + "discord_bot_token": { + "secret_name": "discord_bot_token", + "location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " }, + "host_patterns": ["discord.com"] + } + }, + "rate_limit": { + "requests_per_minute": 60, + "requests_per_hour": 3600 + } + }, + "secrets": { + "allowed_names": ["discord_bot_token", "discord_*"] + }, + "channel": { + "allowed_paths": ["/webhook/discord"], + "allow_polling": false, + "callback_timeout_secs": 45, + "workspace_prefix": "channels/discord/", + "emit_rate_limit": { + "messages_per_minute": 100, + "messages_per_hour": 5000 + } + } + }, + "config": { + "require_signature_verification": true + } +} \ No newline at end of file diff --git a/channels-src/discord/src/lib.rs b/channels-src/discord/src/lib.rs new file mode 100644 index 00000000..2fa8b192 --- /dev/null +++ b/channels-src/discord/src/lib.rs @@ -0,0 +1,476 @@ +//! Discord Gateway/Webhook channel for IronClaw. +//! +//! This WASM component implements the channel interface for handling Discord +//! interactions via webhooks and sending messages back to Discord. +//! +//! # Features +//! +//! - URL verification for Discord interactions +//! - Slash command handling +//! - Message event parsing (@mentions, DMs) +//! - Thread support for conversations +//! - Response posting via Discord Web API +//! - Automatic message truncation (> 2000 chars) +//! +//! # Security +//! +//! - Signature validation is handled by the host (webhook secrets) +//! - Bot token is injected by host during HTTP requests +//! - WASM never sees raw credentials + +wit_bindgen::generate!({ + world: "sandboxed-channel", + path: "../../wit/channel.wit", +}); + +use serde::{Deserialize, Serialize}; + +use exports::near::agent::channel::{ + AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, + OutgoingHttpResponse, StatusUpdate, +}; +use near::agent::channel_host::{self, EmittedMessage}; + +/// Discord interaction wrapper. +#[derive(Debug, Deserialize)] +struct DiscordInteraction { + /// Interaction type (1=Ping, 2=ApplicationCommand, 3=MessageComponent) + #[serde(rename = "type")] + interaction_type: u8, + + /// Interaction ID + id: String, + + /// Application ID + application_id: String, + + /// Guild ID (if in server) + #[allow(dead_code)] // Part of API payload, currently unused + guild_id: Option, + + /// Channel ID + channel_id: Option, + + /// Member info (if in server) + member: Option, + + /// User info (if DM) + user: Option, + + /// Command data (for slash commands) + data: Option, + + /// Message (for component interactions) + message: Option, + + /// Token for responding + token: String, +} + +#[derive(Debug, Deserialize, Clone)] +struct DiscordMember { + user: DiscordUser, + #[allow(dead_code)] // Part of API payload, currently unused + nick: Option, +} + +#[derive(Debug, Deserialize, Clone)] +struct DiscordUser { + id: String, + username: String, + global_name: Option, +} + +#[derive(Debug, Deserialize, Clone)] +struct DiscordCommandData { + #[allow(dead_code)] // Part of API payload, currently unused + id: String, + name: String, + options: Option>, +} + +#[derive(Debug, Deserialize, Clone)] +struct DiscordCommandOption { + name: String, + value: serde_json::Value, +} + +#[derive(Debug, Deserialize, Clone)] +struct DiscordMessage { + #[allow(dead_code)] // Part of API payload, currently unused + id: String, + content: String, + channel_id: String, + #[allow(dead_code)] // Part of API payload, currently unused + author: DiscordUser, +} + +/// Metadata stored with emitted messages for response routing. +#[derive(Debug, Serialize, Deserialize)] +struct DiscordMessageMetadata { + /// Discord channel ID + channel_id: String, + + /// Interaction ID for followups + interaction_id: String, + + /// Interaction token for responding + token: String, + + /// Application ID + application_id: String, + + /// Thread ID (for forum threads) + thread_id: Option, +} + +struct DiscordChannel; + +impl Guest for DiscordChannel { + fn on_start(_config_json: String) -> Result { + channel_host::log(channel_host::LogLevel::Info, "Discord channel starting"); + + Ok(ChannelConfig { + display_name: "Discord".to_string(), + http_endpoints: vec![HttpEndpointConfig { + path: "/webhook/discord".to_string(), + methods: vec!["POST".to_string()], + require_secret: true, + }], + poll: None, + }) + } + + fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse { + let body_str = match std::str::from_utf8(&req.body) { + Ok(s) => s, + Err(_) => { + return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"})); + } + }; + + let interaction: DiscordInteraction = match serde_json::from_str(body_str) { + Ok(i) => i, + Err(e) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Failed to parse Discord interaction: {}", e), + ); + return json_response(400, serde_json::json!({"error": "Invalid interaction"})); + } + }; + + match interaction.interaction_type { + // Ping - Discord verification + 1 => { + channel_host::log(channel_host::LogLevel::Info, "Responding to Discord ping"); + json_response(200, serde_json::json!({"type": 1})) + } + + // Application Command (slash command) + 2 => { + handle_slash_command(&interaction); + json_response( + 200, + serde_json::json!({ + "type": 5, + "data": { + "content": "🤔 Thinking..." + } + }), + ) + } + + // Message Component (buttons, selects) + 3 => { + if let Some(ref message) = interaction.message { + handle_message_component(&interaction, message); + } + json_response(200, serde_json::json!({"type": 6})) + } + + _ => { + channel_host::log( + channel_host::LogLevel::Warn, + &format!( + "Unknown Discord interaction type: {}", + interaction.interaction_type + ), + ); + json_response(200, serde_json::json!({"type": 6})) + } + } + } + + fn on_poll() {} + + fn on_respond(response: AgentResponse) -> Result<(), String> { + let metadata: DiscordMessageMetadata = serde_json::from_str(&response.metadata_json) + .map_err(|e| format!("Failed to parse metadata: {}", e))?; + + // Use webhook endpoint for followup + let url = format!( + "https://discord.com/api/v10/webhooks/{}/{}", + metadata.application_id, metadata.token + ); + + // Truncate content to 2000 characters to comply with Discord limits + let content = truncate_message(&response.content); + + let mut payload = serde_json::json!({ + "content": content, + }); + + // Check for embeds in metadata + if let Ok(meta_json) = serde_json::from_str::(&response.metadata_json) { + if let Some(embeds) = meta_json.get("embeds") { + payload["embeds"] = embeds.clone(); + } + } + + let payload_bytes = + serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; + + let headers = serde_json::json!({ + "Content-Type": "application/json" + }); + + let result = channel_host::http_request( + "POST", + &url, + &headers.to_string(), + Some(&payload_bytes), + None, + ); + + match result { + Ok(http_response) => { + if http_response.status >= 200 && http_response.status < 300 { + channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord"); + Ok(()) + } else { + let body_str = String::from_utf8_lossy(&http_response.body); + Err(format!( + "Discord API error: {} - {}", + http_response.status, body_str + )) + } + } + Err(e) => Err(format!("HTTP request failed: {}", e)), + } + } + + fn on_status(_update: StatusUpdate) {} + + fn on_shutdown() { + channel_host::log( + channel_host::LogLevel::Info, + "Discord channel shutting down", + ); + } +} + +fn handle_slash_command(interaction: &DiscordInteraction) { + let user = interaction + .member + .as_ref() + .map(|m| &m.user) + .or(interaction.user.as_ref()); + let user_id = user.map(|u| u.id.clone()).unwrap_or_default(); + let user_name = user + .map(|u| { + u.global_name + .as_ref() + .filter(|s| !s.is_empty()) + .unwrap_or(&u.username) + .clone() + }) + .unwrap_or_default(); + + let channel_id = interaction.channel_id.clone().unwrap_or_default(); + + let command_name = interaction + .data + .as_ref() + .map(|d| d.name.clone()) + .unwrap_or_default(); + let options = interaction.data.as_ref().and_then(|d| d.options.clone()); + + let content = if let Some(opts) = options { + let opt_str = opts + .iter() + .map(|o| format!("{}: {}", o.name, o.value)) + .collect::>() + .join(", "); + format!("/{} {}", command_name, opt_str) + } else { + format!("/{}", command_name) + }; + + let metadata = DiscordMessageMetadata { + channel_id: channel_id.clone(), + interaction_id: interaction.id.clone(), + token: interaction.token.clone(), + application_id: interaction.application_id.clone(), + thread_id: None, + }; + + let metadata_json = match serde_json::to_string(&metadata) { + Ok(json) => json, + Err(e) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Failed to serialize metadata: {}", e), + ); + // Attempt to notify user of internal error + let url = format!( + "https://discord.com/api/v10/webhooks/{}/{}", + interaction.application_id, interaction.token + ); + let payload = serde_json::json!({ + "content": "❌ Internal Error: Failed to process command metadata.", + "flags": 64 // Ephemeral + }); + let _ = channel_host::http_request( + "POST", + &url, + &serde_json::json!({"Content-Type": "application/json"}).to_string(), + Some(&serde_json::to_vec(&payload).unwrap_or_default()), + None, + ); + return; + } + }; + + channel_host::emit_message(&EmittedMessage { + user_id, + user_name: Some(user_name), + content, + thread_id: None, + metadata_json, + }); +} + +fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) { + // Check member first (for server contexts), then user (for DMs) + let user = interaction + .member + .as_ref() + .map(|m| &m.user) + .or(interaction.user.as_ref()); + let user_id = user.map(|u| u.id.clone()).unwrap_or_default(); + let user_name = user + .map(|u| { + u.global_name + .as_ref() + .filter(|s| !s.is_empty()) + .unwrap_or(&u.username) + .clone() + }) + .unwrap_or_default(); + + let channel_id = message.channel_id.clone(); + + let metadata = DiscordMessageMetadata { + channel_id: channel_id.clone(), + interaction_id: interaction.id.clone(), + token: interaction.token.clone(), + application_id: interaction.application_id.clone(), + thread_id: None, + }; + + let metadata_json = match serde_json::to_string(&metadata) { + Ok(json) => json, + Err(e) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Failed to serialize metadata: {}", e), + ); + return; // Don't emit message if metadata can't be serialized + } + }; + + channel_host::emit_message(&EmittedMessage { + user_id, + user_name: Some(user_name), + content: format!("[Button clicked] {}", message.content), + thread_id: None, + metadata_json, + }); +} + +fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse { + let body = serde_json::to_vec(&value).unwrap_or_default(); + let headers = serde_json::json!({"Content-Type": "application/json"}); + + OutgoingHttpResponse { + status, + headers_json: headers.to_string(), + body, + } +} + +export!(DiscordChannel); + +fn truncate_message(content: &str) -> String { + if content.len() <= 2000 { + content.to_string() + } else { + let max_bytes = 1990; + let cutoff = content + .char_indices() + .map(|(i, c)| i + c.len_utf8()) + .take_while(|&end| end <= max_bytes) + .last() + .unwrap_or(0); + let mut truncated = content[..cutoff].to_string(); + truncated.push_str("\n... (truncated)"); + truncated + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_message() { + let short = "Hello world"; + assert_eq!(truncate_message(short), short); + + let long = "a".repeat(2005); + let truncated = truncate_message(&long); + assert_eq!(truncated.len(), 2006); // 1990 + 16 chars suffix + assert!(truncated.ends_with("\n... (truncated)")); + + // Test with multibyte characters (Euro sign is 3 bytes) + // 1000 chars * 3 bytes = 3000 bytes + let multi = "€".repeat(1000); + let truncated_multi = truncate_message(&multi); + + // 1990 bytes limit. 1990 / 3 = 663 with remainder 1. + // Should truncate at 663 chars (1989 bytes). + // Suffix is 16 bytes. Total: 1989 + 16 = 2005 bytes. + assert!(truncated_multi.len() <= 2006); + assert!(truncated_multi.len() >= 2006 - 4); // Allow for max utf8 char width variance + assert!(truncated_multi.ends_with("\n... (truncated)")); + + let content_part = &truncated_multi[..truncated_multi.len() - 16]; + assert!(content_part.chars().all(|c| c == '€')); + } + + #[test] + fn test_metadata_serialization() { + let metadata = DiscordMessageMetadata { + channel_id: "123".into(), + interaction_id: "456".into(), + token: "abc".into(), + application_id: "789".into(), + thread_id: None, + }; + let json = serde_json::to_string(&metadata).unwrap(); + let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.channel_id, "123"); + assert_eq!(parsed.interaction_id, "456"); + } +} diff --git a/tools-src/github/Cargo.toml b/tools-src/github/Cargo.toml new file mode 100644 index 00000000..585e2679 --- /dev/null +++ b/tools-src/github/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "github-tool" +version = "0.1.0" +edition = "2021" +description = "GitHub integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +wit-bindgen = "0.41.0" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + diff --git a/tools-src/github/README.md b/tools-src/github/README.md new file mode 100644 index 00000000..fbde6c61 --- /dev/null +++ b/tools-src/github/README.md @@ -0,0 +1,189 @@ +# GitHub Tool for IronClaw + +WASM tool for GitHub integration - manage repos, issues, PRs, and workflows. + +## Features + +- **Repository Info** - Get repo details, list user repos +- **Issues** - List, create, and get issue details +- **Pull Requests** - List PRs, get PR details, review files, create reviews +- **File Content** - Read files from repos +- **Workflows** - Trigger GitHub Actions, check run status + +## Setup + +1. Create a GitHub Personal Access Token at +2. Required scopes: `repo`, `workflow`, `read:org` +3. Store the token: + + ``` + ironclaw secret set github_token YOUR_TOKEN + ``` + +## Usage Examples + +### Get Repository Info + +```json +{ + "action": "get_repo", + "owner": "nearai", + "repo": "ironclaw" +} +``` + +### List Open Issues + +```json +{ + "action": "list_issues", + "owner": "nearai", + "repo": "ironclaw", + "state": "open", + "limit": 10 +} +``` + +### Create Issue + +```json +{ + "action": "create_issue", + "owner": "nearai", + "repo": "ironclaw", + "title": "Bug: Something is broken", + "body": "Detailed description...", + "labels": ["bug", "help wanted"] +} +``` + +### List Pull Requests + +```json +{ + "action": "list_pull_requests", + "owner": "nearai", + "repo": "ironclaw", + "state": "open", + "limit": 5 +} +``` + +### Review PR + +```json +{ + "action": "create_pr_review", + "owner": "nearai", + "repo": "ironclaw", + "pr_number": 42, + "body": "LGTM! Great work.", + "event": "APPROVE" +} +``` + +### Get File Content + +```json +{ + "action": "get_file_content", + "owner": "nearai", + "repo": "ironclaw", + "path": "README.md", + "ref": "main" +} +``` + +### Trigger Workflow + +```json +{ + "action": "trigger_workflow", + "owner": "nearai", + "repo": "ironclaw", + "workflow_id": "ci.yml", + "ref": "main", + "inputs": { + "environment": "staging" + } +} +``` + +### Check Workflow Runs + +```json +{ + "action": "get_workflow_runs", + "owner": "nearai", + "repo": "ironclaw", + "limit": 5 +} +``` + +### List Workflow Runs (Pagination) + +```json +{ + "action": "get_workflow_runs", + "owner": "nearai", + "repo": "ironclaw", + "limit": 5, + "page": 2 +} +``` + +## Error Handling + +Errors are returned as strings in the `error` field of the response. + +### Rate Limit Exceeded + +When the GitHub API rate limit is exceeded (and retries fail), you might see: + +```text +GitHub API error 429: { "message": "API rate limit exceeded for user ID ...", ... } +``` + +The tool automatically logs warnings when the rate limit is low (<10 remaining) and retries on 429/5xx errors. + +### Invalid Parameters + +```text +Invalid event: 'INVALID'. Must be one of: APPROVE, REQUEST_CHANGES, COMMENT +``` + +### Missing Token + +```text +GitHub token not found in secret store. Set it with: ironclaw secret set github_token ... +``` + +## Troubleshooting + +### "GitHub API error 404: Not Found" + +- Check that the `owner` and `repo` are correct. +- Ensure the `github_token` has access to the repository (especially for private repos). +- Verify the token scopes include `repo` and `read:org`. + +### "GitHub API error 401: Bad credentials" + +- The token might be invalid or expired. +- Update the token: `ironclaw secret set github_token NEW_TOKEN`. + +### Rate Limiting + +- The tool logs a warning when remaining requests drop below 10. +- Check logs for "GitHub API rate limit low". +- If you hit the limit, wait for the reset time (usually 1 hour). + +## Building + +```bash +cd tools-src/github +cargo build --target wasm32-wasi --release +``` + +## License + +MIT/Apache-2.0 diff --git a/tools-src/github/github-tool.capabilities.json b/tools-src/github/github-tool.capabilities.json new file mode 100644 index 00000000..1195d575 --- /dev/null +++ b/tools-src/github/github-tool.capabilities.json @@ -0,0 +1,41 @@ +{ + "capabilities": { + "http": { + "allowlist": [ + { + "host": "api.github.com", + "path_prefix": "/", + "methods": [ + "GET", + "POST" + ] + } + ], + "credentials": { + "github_token": { + "secret_name": "github_token", + "location": { + "type": "bearer" + }, + "host_patterns": [ + "api.github.com" + ] + } + }, + "rate_limit": { + "requests_per_minute": 60, + "requests_per_hour": 3600 + } + }, + "secrets": { + "allowed_names": [ + "github_token", + "github_*" + ] + } + }, + "config": { + "default_limit": 30, + "max_limit": 100 + } +} \ No newline at end of file diff --git a/tools-src/github/src/lib.rs b/tools-src/github/src/lib.rs new file mode 100644 index 00000000..c8c780cb --- /dev/null +++ b/tools-src/github/src/lib.rs @@ -0,0 +1,845 @@ +//! GitHub WASM Tool for IronClaw. +//! +//! Provides GitHub integration for reading repos, managing issues, +//! reviewing PRs, and triggering workflows. +//! +//! # Authentication +//! +//! Store your GitHub Personal Access Token: +//! `ironclaw secret set github_token ` +//! +//! Token needs these permissions: +//! - repo (for private repos) +//! - workflow (for triggering actions) +//! - read:org (for org repos) + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +use serde::Deserialize; + +const MAX_TEXT_LENGTH: usize = 65536; + +/// Validate input length to prevent oversized payloads. +fn validate_input_length(s: &str, field_name: &str) -> Result<(), String> { + if s.len() > MAX_TEXT_LENGTH { + return Err(format!( + "Input '{}' exceeds maximum length of {} characters", + field_name, MAX_TEXT_LENGTH + )); + } + Ok(()) +} + +/// Percent-encode a string for safe use in URL path segments. +/// Encodes everything except alphanumeric, hyphen, underscore, and dot. +fn url_encode_path(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 2); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize])); + out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize])); + } + } + } + out +} + +/// Percent-encode a string for use as a URL query parameter value. +/// Currently identical to `url_encode_path`. +fn url_encode_query(s: &str) -> String { + url_encode_path(s) +} + +/// Validate that a path segment doesn't contain dangerous characters. +/// Returns true if the segment is safe to use. +fn validate_path_segment(s: &str) -> bool { + !s.is_empty() && !s.contains('/') && !s.contains("..") && !s.contains('?') && !s.contains('#') +} + +struct GitHubTool; + +#[derive(Debug, Deserialize)] +#[serde(tag = "action")] +enum GitHubAction { + #[serde(rename = "get_repo")] + GetRepo { owner: String, repo: String }, + #[serde(rename = "list_issues")] + ListIssues { + owner: String, + repo: String, + state: Option, + page: Option, + limit: Option, + }, + #[serde(rename = "create_issue")] + CreateIssue { + owner: String, + repo: String, + title: String, + body: Option, + labels: Option>, + }, + #[serde(rename = "get_issue")] + GetIssue { + owner: String, + repo: String, + issue_number: u32, + }, + #[serde(rename = "list_pull_requests")] + ListPullRequests { + owner: String, + repo: String, + state: Option, + page: Option, + limit: Option, + }, + #[serde(rename = "get_pull_request")] + GetPullRequest { + owner: String, + repo: String, + pr_number: u32, + }, + #[serde(rename = "get_pull_request_files")] + GetPullRequestFiles { + owner: String, + repo: String, + pr_number: u32, + }, + #[serde(rename = "create_pr_review")] + CreatePrReview { + owner: String, + repo: String, + pr_number: u32, + body: String, + event: String, + }, + #[serde(rename = "list_repos")] + ListRepos { + username: String, + page: Option, + limit: Option, + }, + #[serde(rename = "get_file_content")] + GetFileContent { + owner: String, + repo: String, + path: String, + r#ref: Option, + }, + #[serde(rename = "trigger_workflow")] + TriggerWorkflow { + owner: String, + repo: String, + workflow_id: String, + r#ref: String, + inputs: Option, + }, + #[serde(rename = "get_workflow_runs")] + GetWorkflowRuns { + owner: String, + repo: String, + workflow_id: Option, + page: Option, + limit: Option, + }, +} + +impl exports::near::agent::tool::Guest for GitHubTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + SCHEMA.to_string() + } + + fn description() -> String { + "GitHub integration for managing repositories, issues, pull requests, \ + and workflows. Supports reading repo info, listing/creating issues, \ + reviewing PRs, and triggering GitHub Actions. \ + Authentication is handled via the 'github_token' secret injected by the host." + .to_string() + } +} + +fn execute_inner(params: &str) -> Result { + let action: GitHubAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {e}"))?; + + // Pre-flight check: ensure token exists in secret store. + // We don't use the returned value because the host injects it into the request. + let _ = get_github_token()?; + + match action { + GitHubAction::GetRepo { owner, repo } => get_repo(&owner, &repo), + GitHubAction::ListIssues { + owner, + repo, + state, + page, + limit, + } => list_issues(&owner, &repo, state.as_deref(), page, limit), + GitHubAction::CreateIssue { + owner, + repo, + title, + body, + labels, + } => create_issue(&owner, &repo, &title, body.as_deref(), labels), + GitHubAction::GetIssue { + owner, + repo, + issue_number, + } => get_issue(&owner, &repo, issue_number), + GitHubAction::ListPullRequests { + owner, + repo, + state, + page, + limit, + } => list_pull_requests(&owner, &repo, state.as_deref(), page, limit), + GitHubAction::GetPullRequest { + owner, + repo, + pr_number, + } => get_pull_request(&owner, &repo, pr_number), + GitHubAction::GetPullRequestFiles { + owner, + repo, + pr_number, + } => get_pull_request_files(&owner, &repo, pr_number), + GitHubAction::CreatePrReview { + owner, + repo, + pr_number, + body, + event, + } => create_pr_review(&owner, &repo, pr_number, &body, &event), + GitHubAction::ListRepos { + username, + page, + limit, + } => list_repos(&username, page, limit), + GitHubAction::GetFileContent { + owner, + repo, + path, + r#ref, + } => get_file_content(&owner, &repo, &path, r#ref.as_deref()), + GitHubAction::TriggerWorkflow { + owner, + repo, + workflow_id, + r#ref, + inputs, + } => trigger_workflow(&owner, &repo, &workflow_id, &r#ref, inputs), + GitHubAction::GetWorkflowRuns { + owner, + repo, + workflow_id, + page, + limit, + } => get_workflow_runs(&owner, &repo, workflow_id.as_deref(), page, limit), + } +} + +fn get_github_token() -> Result { + if near::agent::host::secret_exists("github_token") { + // Return dummy value since we only need to verify existence. + // The actual token is injected by the host. + return Ok("present".to_string()); + } + + Err("GitHub token not found in secret store. Set it with: ironclaw secret set github_token . \ + Token needs 'repo', 'workflow', and 'read:org' scopes.".into()) +} + +fn github_request(method: &str, path: &str, body: Option) -> Result { + let url = format!("https://api.github.com{}", path); + + // Authorization header (Bearer ) is injected automatically by the host + // via the `http-wrapper` proxy based on the `github_token` secret. + let headers = serde_json::json!({ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "IronClaw-GitHub-Tool" + }); + + let body_bytes = body.map(|b| b.into_bytes()); + + // Simple retry logic for transient errors (max 3 attempts) + let max_retries = 3; + let mut attempt = 0; + + loop { + attempt += 1; + + let response = near::agent::host::http_request( + method, + &url, + &headers.to_string(), + body_bytes.as_deref(), + None, + ); + + match response { + Ok(resp) => { + // Log warning if rate limit is low + if let Ok(headers_json) = + serde_json::from_str::(&resp.headers_json) + { + // Header keys are often lowercase in http libs, check case-insensitively if needed, + // but usually standard is lowercase/case-insensitive. Let's try lowercase. + if let Some(remaining) = headers_json + .get("x-ratelimit-remaining") + .and_then(|v| v.as_str()) + { + if let Ok(count) = remaining.parse::() { + if count < 10 { + near::agent::host::log( + near::agent::host::LogLevel::Warn, + &format!("GitHub API rate limit low: {} remaining", count), + ); + } + } + } + } + + if resp.status >= 200 && resp.status < 300 { + return String::from_utf8(resp.body) + .map_err(|e| format!("Invalid UTF-8: {}", e)); + } else if attempt < max_retries && (resp.status == 429 || resp.status >= 500) { + near::agent::host::log( + near::agent::host::LogLevel::Warn, + &format!( + "GitHub API error {} (attempt {}/{}). Retrying...", + resp.status, attempt, max_retries + ), + ); + // Minimal backoff simulation since we can't block easily in WASM without consuming generic budget? + // actually std::thread::sleep works in WASMtime if configured, but here we might just spin. + // ideally host exposes sleep. For now just retry immediately or rely on host timeout logic? + // Let's assume immediate retry for now as simple strategy. + continue; + } else { + let body_str = String::from_utf8_lossy(&resp.body); + return Err(format!("GitHub API error {}: {}", resp.status, body_str)); + } + } + Err(e) => { + if attempt < max_retries { + near::agent::host::log( + near::agent::host::LogLevel::Warn, + &format!( + "HTTP request failed: {} (attempt {}/{}). Retrying...", + e, attempt, max_retries + ), + ); + continue; + } + return Err(format!( + "HTTP request failed after {} attempts: {}", + max_retries, e + )); + } + } + } +} + +// === API Functions === + +fn get_repo(owner: &str, repo: &str) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + github_request( + "GET", + &format!("/repos/{}/{}", encoded_owner, encoded_repo), + None, + ) +} + +fn list_issues( + owner: &str, + repo: &str, + state: Option<&str>, + page: Option, + limit: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let state = state.unwrap_or("open"); + let limit = limit.unwrap_or(30).min(100); // Cap at 100 + let encoded_state = url_encode_query(state); + + let mut path = format!( + "/repos/{}/{}/issues?state={}&per_page={}", + encoded_owner, encoded_repo, encoded_state, limit + ); + if let Some(p) = page { + path.push_str(&format!("&page={}", p)); + } + + github_request("GET", &path, None) +} + +fn create_issue( + owner: &str, + repo: &str, + title: &str, + body: Option<&str>, + labels: Option>, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_input_length(title, "title")?; + if let Some(b) = body { + validate_input_length(b, "body")?; + } + + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let path = format!("/repos/{}/{}/issues", encoded_owner, encoded_repo); + let mut req_body = serde_json::json!({ + "title": title, + }); + if let Some(body) = body { + req_body["body"] = serde_json::json!(body); + } + if let Some(labels) = labels { + req_body["labels"] = serde_json::json!(labels); + } + github_request("POST", &path, Some(req_body.to_string())) +} + +fn get_issue(owner: &str, repo: &str, issue_number: u32) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + github_request( + "GET", + &format!( + "/repos/{}/{}/issues/{}", + encoded_owner, encoded_repo, issue_number + ), + None, + ) +} + +fn list_pull_requests( + owner: &str, + repo: &str, + state: Option<&str>, + page: Option, + limit: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let state = state.unwrap_or("open"); + let limit = limit.unwrap_or(30).min(100); // Cap at 100 + let encoded_state = url_encode_query(state); + + let mut path = format!( + "/repos/{}/{}/pulls?state={}&per_page={}", + encoded_owner, encoded_repo, encoded_state, limit + ); + if let Some(p) = page { + path.push_str(&format!("&page={}", p)); + } + + github_request("GET", &path, None) +} + +fn get_pull_request(owner: &str, repo: &str, pr_number: u32) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + github_request( + "GET", + &format!( + "/repos/{}/{}/pulls/{}", + encoded_owner, encoded_repo, pr_number + ), + None, + ) +} + +fn get_pull_request_files(owner: &str, repo: &str, pr_number: u32) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + github_request( + "GET", + &format!( + "/repos/{}/{}/pulls/{}/files", + encoded_owner, encoded_repo, pr_number + ), + None, + ) +} + +fn create_pr_review( + owner: &str, + repo: &str, + pr_number: u32, + body: &str, + event: &str, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + validate_input_length(body, "body")?; + + let valid_events = ["APPROVE", "REQUEST_CHANGES", "COMMENT"]; + if !valid_events.contains(&event) { + return Err(format!( + "Invalid event: '{}'. Must be one of: {}", + event, + valid_events.join(", ") + )); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let path = format!( + "/repos/{}/{}/pulls/{}/reviews", + encoded_owner, encoded_repo, pr_number + ); + let req_body = serde_json::json!({ + "body": body, + "event": event, + }); + github_request("POST", &path, Some(req_body.to_string())) +} + +fn list_repos(username: &str, page: Option, limit: Option) -> Result { + if !validate_path_segment(username) { + return Err("Invalid username".into()); + } + let encoded_username = url_encode_path(username); + let limit = limit.unwrap_or(30).min(100); // Cap at 100 + let mut path = format!("/users/{}/repos?per_page={}", encoded_username, limit); + if let Some(p) = page { + path.push_str(&format!("&page={}", p)); + } + github_request("GET", &path, None) +} + +fn get_file_content( + owner: &str, + repo: &str, + path: &str, + r#ref: Option<&str>, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + // Validate path segments - reject path traversal attempts and empty segments + for segment in path.split('/') { + if segment == ".." { + return Err("Invalid path: path traversal not allowed".into()); + } + if segment.is_empty() { + return Err("Invalid path: empty segment not allowed".into()); + } + } + // Validate ref if provided + if let Some(r#ref) = r#ref { + if r#ref.contains("..") || r#ref.contains(':') { + return Err("Invalid ref: must be a valid branch, tag, or commit SHA".into()); + } + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + // Path can contain slashes, so we encode each segment separately + let encoded_path = path + .split('/') + .map(url_encode_path) + .collect::>() + .join("/"); + + let url_path = if let Some(r#ref) = r#ref { + let encoded_ref = url_encode_query(r#ref); + format!( + "/repos/{}/{}/contents/{}?ref={}", + encoded_owner, encoded_repo, encoded_path, encoded_ref + ) + } else { + format!( + "/repos/{}/{}/contents/{}", + encoded_owner, encoded_repo, encoded_path + ) + }; + github_request("GET", &url_path, None) +} + +fn trigger_workflow( + owner: &str, + repo: &str, + workflow_id: &str, + r#ref: &str, + inputs: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + // Validate inputs size if present + if let Some(valid_inputs) = &inputs { + let inputs_str = valid_inputs.to_string(); + validate_input_length(&inputs_str, "inputs")?; + } + + // Validate workflow_id - must be a safe filename + if workflow_id.contains('/') || workflow_id.contains("..") || workflow_id.contains(':') { + return Err("Invalid workflow_id: must be a filename or numeric ID".into()); + } + // Validate ref - must be a valid git ref + if r#ref.contains("..") || r#ref.contains(':') { + return Err("Invalid ref: must be a valid branch, tag, or commit SHA".into()); + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let encoded_workflow_id = url_encode_path(workflow_id); + let path = format!( + "/repos/{}/{}/actions/workflows/{}/dispatches", + encoded_owner, encoded_repo, encoded_workflow_id + ); + let mut req_body = serde_json::json!({ + "ref": r#ref, + }); + if let Some(inputs) = inputs { + req_body["inputs"] = inputs; + } + github_request("POST", &path, Some(req_body.to_string())) +} + +fn get_workflow_runs( + owner: &str, + repo: &str, + workflow_id: Option<&str>, + page: Option, + limit: Option, +) -> Result { + if !validate_path_segment(owner) || !validate_path_segment(repo) { + return Err("Invalid owner or repo name".into()); + } + // Validate workflow_id if provided + if let Some(wid) = workflow_id { + if wid.contains('/') || wid.contains("..") || wid.contains(':') { + return Err("Invalid workflow_id: must be a filename or numeric ID".into()); + } + } + let encoded_owner = url_encode_path(owner); + let encoded_repo = url_encode_path(repo); + let limit = limit.unwrap_or(30).min(100); // Cap at 100 + let mut path = if let Some(workflow_id) = workflow_id { + let encoded_workflow_id = url_encode_path(workflow_id); + format!( + "/repos/{}/{}/actions/workflows/{}/runs?per_page={}", + encoded_owner, encoded_repo, encoded_workflow_id, limit + ) + } else { + format!( + "/repos/{}/{}/actions/runs?per_page={}", + encoded_owner, encoded_repo, limit + ) + }; + if let Some(p) = page { + path.push_str(&format!("&page={}", p)); + } + github_request("GET", &path, None) +} + +const SCHEMA: &str = r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "get_repo" }, + "owner": { "type": "string", "description": "Repository owner (user or org)" }, + "repo": { "type": "string", "description": "Repository name" } + }, + "required": ["action", "owner", "repo"] + }, + { + "properties": { + "action": { "const": "list_issues" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "state": { "type": "string", "enum": ["open", "closed", "all"], "default": "open" }, + "limit": { "type": "integer", "default": 30 } + }, + "required": ["action", "owner", "repo"] + }, + { + "properties": { + "action": { "const": "create_issue" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "title": { "type": "string" }, + "body": { "type": "string" }, + "labels": { "type": "array", "items": { "type": "string" } } + }, + "required": ["action", "owner", "repo", "title"] + }, + { + "properties": { + "action": { "const": "get_issue" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "issue_number": { "type": "integer" } + }, + "required": ["action", "owner", "repo", "issue_number"] + }, + { + "properties": { + "action": { "const": "list_pull_requests" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "state": { "type": "string", "enum": ["open", "closed", "all"], "default": "open" }, + "limit": { "type": "integer", "default": 30 } + }, + "required": ["action", "owner", "repo"] + }, + { + "properties": { + "action": { "const": "get_pull_request" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "pr_number": { "type": "integer" } + }, + "required": ["action", "owner", "repo", "pr_number"] + }, + { + "properties": { + "action": { "const": "get_pull_request_files" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "pr_number": { "type": "integer" } + }, + "required": ["action", "owner", "repo", "pr_number"] + }, + { + "properties": { + "action": { "const": "create_pr_review" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "pr_number": { "type": "integer" }, + "body": { "type": "string", "description": "Review comment" }, + "event": { "type": "string", "enum": ["APPROVE", "REQUEST_CHANGES", "COMMENT"] } + }, + "required": ["action", "owner", "repo", "pr_number", "body", "event"] + }, + { + "properties": { + "action": { "const": "list_repos" }, + "username": { "type": "string" }, + "limit": { "type": "integer", "default": 30 } + }, + "required": ["action", "username"] + }, + { + "properties": { + "action": { "const": "get_file_content" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "path": { "type": "string", "description": "File path in repo" }, + "ref": { "type": "string", "description": "Branch/commit (default: default branch)" } + }, + "required": ["action", "owner", "repo", "path"] + }, + { + "properties": { + "action": { "const": "trigger_workflow" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "workflow_id": { "type": "string", "description": "Workflow filename or ID" }, + "ref": { "type": "string", "description": "Branch to run on" }, + "inputs": { "type": "object" } + }, + "required": ["action", "owner", "repo", "workflow_id", "ref"] + }, + { + "properties": { + "action": { "const": "get_workflow_runs" }, + "owner": { "type": "string" }, + "repo": { "type": "string" }, + "workflow_id": { "type": "string" }, + "limit": { "type": "integer", "default": 30 } + }, + "required": ["action", "owner", "repo"] + } + ] +}"#; + +export!(GitHubTool); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_encode_path() { + assert_eq!(url_encode_path("foo-bar_123.baz"), "foo-bar_123.baz"); + assert_eq!(url_encode_path("foo bar"), "foo%20bar"); + assert_eq!(url_encode_path("foo/bar"), "foo%2Fbar"); + } + + #[test] + fn test_validate_path_segment() { + assert!(validate_path_segment("foo")); + assert!(!validate_path_segment("")); + assert!(!validate_path_segment("foo/bar")); + assert!(!validate_path_segment("..")); + // Empty segments are handled in get_file_content logic, not here + } + + #[test] + fn test_validate_event_in_create_pr_review() { + let valid = ["APPROVE", "REQUEST_CHANGES", "COMMENT"]; + // Ensure valid inputs are accepted + for event in valid { + assert!(valid.contains(&event)); + } + } + + #[test] + fn test_input_length_validation() { + assert!(validate_input_length("short", "test").is_ok()); + + let long = "a".repeat(MAX_TEXT_LENGTH + 1); + assert!(validate_input_length(&long, "test").is_err()); + } +}