Compare commits

..
Author SHA1 Message Date
Illia PolosukhinandClaude Opus 4.6 1aca76b1a7 style: fix rustfmt formatting in bootstrap test
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-15 00:22:18 -08:00
Illia PolosukhinandClaude Opus 4.6 414c1b28b9 fix: status command shows libSQL backend and skips keychain probe
The status command only checked DATABASE_URL (postgres), showing
"not configured" for libSQL users. Now detects the DATABASE_BACKEND
env var and reports libSQL path and Turso sync status.

Also remove the keychain probe from status. get_generic_password()
triggers macOS unlock+authorization dialogs which is terrible UX
for a read-only diagnostic command.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-15 00:13:33 -08:00
Illia PolosukhinandClaude Opus 4.6 d771f99f9e fix: persist DATABASE_BACKEND to ~/.ironclaw/.env for libSQL startup
The wizard saved database_backend only to the database, but
Config::from_env() needs it BEFORE connecting to any database (to
decide which backend to use). Without it, the backend defaults to
Postgres and then fails with "Missing required setting database_url".

Now save all database bootstrap vars (DATABASE_BACKEND, DATABASE_URL,
LIBSQL_PATH, LIBSQL_URL) to ~/.ironclaw/.env via save_bootstrap_env().

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-15 00:12:01 -08:00
Illia PolosukhinandClaude Opus 4.6 1facda4a75 fix: cache keychain key eagerly to avoid redundant macOS password dialogs
Replace has_master_key() with get_master_key() in step_security() and
immediately build SecretsCrypto from the result. This eliminates redundant
keychain accesses later in init_secrets_context(), each of which triggers
macOS system dialogs (keychain unlock + app authorization).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-15 00:07:02 -08:00
Illia PolosukhinandClaude Opus 4.6 e8caab1a12 fix: OAuth callback listener binds IPv4 first to match redirect URLs
The listener was binding to [::1] (IPv6) first, but NEAR AI and other
OAuth flows redirect to http://127.0.0.1:9876/... (IPv4 explicit).
On macOS and most systems, [::1] and 127.0.0.1 are separate addresses,
so the browser's connection to 127.0.0.1 was refused when the listener
was on [::1]. Reversed the bind order: try 127.0.0.1 first, fall back
to [::1] if IPv4 is unavailable.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 22:16:40 -08:00
Illia PolosukhinandClaude Opus 4.6 fac91aec3a fix: address latest PR review comments (SecretString, empty env, docs, embeddings)
- Change wizard llm_api_key from String to SecretString to prevent
  accidental logging of API keys
- Fix inject_llm_keys_from_secrets skipping when env var is set but
  empty, matching optional_env's treatment of empty as unset
- Fix inverted doc comment on INJECTED_VARS (env checked first, overlay
  is the fallback, not the other way around)
- Update stale "env vars" comments in main.rs to reflect overlay pattern
- Fix step_embeddings not seeing cached OpenAI key from wizard session

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 17:58:12 -08:00
Illia PolosukhinandClaude Opus 4.6 0aae66c9dc fix: address remaining PR review comments (clippy, TODO, secrets backend ordering)
- Fix empty line after doc comment (clippy: empty_line_after_doc_comments)
- Collapse nested if in optional_env overlay check (clippy: collapsible_if)
- Remove dangling TODO(#XX) placeholder issue ref in channels.rs
- Fix init_secrets_context to respect selected database_backend when both
  postgres and libsql features are compiled, preventing wrong-backend
  secrets storage when DATABASE_URL is set but libsql was chosen

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 17:19:30 -08:00
Illia PolosukhinandClaude Opus 4.6 aa808ca94e fix: remove unsafe set_var, use thread-safe overlay for injected secrets
Address PR #92 review comments:
- Replace all 5 unsafe `std::env::set_var()` calls with safe alternatives
- Add INJECTED_VARS OnceLock<HashMap> overlay in config.rs, checked by
  optional_env() before falling back to std::env::var()
- Cache wizard API key in SetupWizard.llm_api_key field instead of env
- Pass explicit key param to fetch_anthropic_models/fetch_openai_models
- Persist env-provided API keys to secrets store during onboarding

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 14:14:22 -08:00
Illia PolosukhinandClaude Opus 4.6 c7e6833d14 Merge remote-tracking branch 'origin/main' into fix/setup-audit-fixes
Resolve conflicts between main's simplified config (no bootstrap param,
env-only DatabaseConfig) and our branch's typed ChannelSetupError.

- config.rs: take main's simpler resolve() signatures (no bootstrap)
- main.rs: remove dead check_onboard_needed block and CACHED_KEYCHAIN_KEY ref
- channels.rs: keep ChannelSetupError types, restore settings params from main
- wizard.rs: pass &self.settings to setup_telegram, use ? with From impl
- settings.rs: fix test_llm_backend_round_trip (use std::fs::write, tempfile::tempdir)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 14:05:54 -08:00
Illia PolosukhinandClaude Opus 4.6 1885d61d46 fix: replace unreachable!() with error return in setup wizard
The provider match in step_inference_provider was guarded by
is_known but used unreachable!() as the catch-all. If a new
provider is added to the is_known check without a corresponding
match arm, this would panic at runtime. Return a typed error
instead.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 13:51:37 -08:00
Illia PolosukhinandClaude Opus 4.6 da47903108 fix: harden setup module error handling and secret safety
- Introduce ChannelSetupError typed enum replacing raw String errors
  across all channel setup functions (setup_telegram, setup_http,
  setup_tunnel, setup_wasm_channel, validate_telegram_token)
- Add From<ChannelSetupError> for SetupError to simplify call sites
- Convert setup_telegram retry from recursion to loop (unbounded stack)
- Stop printing HTTP webhook secret plaintext to terminal
- Use secret_input() for Turso auth token (was visible input())
- Replace dirs::home_dir().unwrap_or_default() with proper error
- Fix UTF-8 panic in model name truncation (byte-index to chars-based)
- Log warning in secret_exists() instead of silently swallowing errors
- Deduplicate generate_webhook_secret() to delegate to shared helper

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 13:43:25 -08:00
Illia PolosukhinandClaude Opus 4.6 85196cd527 fix: address second-round PR review feedback
- Validate custom model ID is non-empty (loop until valid input)
- Warn on unknown DATABASE_BACKEND env var before defaulting to Postgres
- Force re-selection when llm_backend contains unknown provider value
- Use ok_or_else for proper String error type in google-sheets

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 13:20:06 -08:00
Illia PolosukhinandClaude Opus 4.6 a0e01f04d3 fix: address critical/high audit findings across WASM sub-crates
- Telegram: remove .unwrap() panic on workspace_read (owner_id check)
- WhatsApp: use configured api_version instead of hardcoded v18.0
- WhatsApp: log config parse errors before falling back to defaults
- Slack: log serialization errors in emit_message and json_response
- Google Docs: safe array access for batch update replies
- Google Sheets: safe array access for add_sheet replies
- Google Calendar: fix doc comment secret name mismatch
- Gmail: avoid unnecessary String allocation in UNREAD check

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 12:54:20 -08:00
Illia PolosukhinandClaude Opus 4.6 e982699e09 fix: address PR review feedback (set_var safety, parse warnings, db_map efficiency)
1. Replace unsafe set_var keychain caching with OnceLock<String> in
   SecretsConfig::resolve(). Eliminates the env var write from main.rs
   entirely, using a process-wide OnceLock cache instead.

2. Log tracing::warn when database_backend or llm_backend settings
   fail to parse, instead of silently falling back to defaults.

3. Remove O(K*S) get() pre-check in from_db_map(). Instead, let set()
   run and match on "Path not found" errors to skip unknown keys,
   avoiding full Settings serialization per key.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 01:09:20 -08:00
Illia Polosukhin 5e73dbbdc8 Merge remote-tracking branch 'origin/main' into feat/onboarding-libsql-selection 2026-02-14 00:55:48 -08:00
Illia PolosukhinandClaude Opus 4.6 92863bf860 fix: resolve libSQL onboarding crash, keychain double-prompt, and setup audit findings
Three bugs fixed:

1. libSQL onboarding crash ("Missing required setting 'database_url'"):
   DatabaseConfig::resolve() only checked DATABASE_BACKEND env var, falling
   back to Postgres default. Now reads settings.database_backend, plus
   settings.libsql_path and settings.libsql_url as fallbacks.

2. OS keychain prompts twice during startup: Config::from_env() and
   Config::from_db() both called get_master_key(). Now caches the key in
   SECRETS_MASTER_KEY env var after first read so from_db() skips keychain.

3. "Path not found: nearai.session" warning: from_db_map() tried to apply
   app-specific DB keys (nearai.session_token) to the Settings struct.
   Now skips keys that don't map to known Settings fields. Also fixed
   bootstrap migration key mismatch (nearai.session -> nearai.session_token).

Setup module audit fixes (14 findings):
- Replace unreachable!() with proper error in provider match
- Extract setup_api_key_provider() to deduplicate setup_anthropic/setup_openai
- Add SAFETY comments to all unsafe std::env::set_var blocks
- Fix .unwrap() calls with proper error handling
- Remove incorrect #[allow(dead_code)] on used TelegramUpdate::update_id
- Log warnings instead of silently discarding HTTP errors in Telegram binding
- Guard select_many against empty options, fix mask_api_key for non-ASCII
- Update stale doc comment in mod.rs, rename misleading variable
- Add 7 new tests (model fetcher fallbacks, channel discovery, secret gen)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-13 21:46:19 -08:00
Illia PolosukhinandClaude Opus 4.6 46c1daca5e feat: add interactive database backend selection during onboarding
Previously the onboarding wizard silently defaulted to PostgreSQL because
libsql wasn't in the default feature set. Now both backends ship by default
and the wizard presents a selection prompt when both are available.

DATABASE_BACKEND env var still bypasses the prompt for headless/CI use.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-13 18:27:53 -08:00
41 changed files with 889 additions and 2656 deletions
+1
View File
@@ -39,6 +39,7 @@ permissions:
# If there's a prerelease-style suffix to the version, then the release(s) # If there's a prerelease-style suffix to the version, then the release(s)
# will be marked as a prerelease. # will be marked as a prerelease.
on: on:
pull_request:
push: push:
tags: tags:
- '**[0-9]+.[0-9]+.[0-9]+*' - '**[0-9]+.[0-9]+.[0-9]+*'
-1
View File
@@ -2,7 +2,6 @@
.env .env
.env.local .env.local
.env.* .env.*
!.env.example
target/ target/
-36
View File
@@ -7,42 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.3.0](https://github.com/nearai/ironclaw/compare/v0.2.0...v0.3.0) - 2026-02-17
### Added
- direct api key and cheap model ([#116](https://github.com/nearai/ironclaw/pull/116))
## [0.2.0](https://github.com/nearai/ironclaw/compare/v0.1.3...v0.2.0) - 2026-02-16
### Added
- mark Ollama + OpenAI-compatible as implemented ([#102](https://github.com/nearai/ironclaw/pull/102))
- multi-provider inference + libSQL onboarding selection ([#92](https://github.com/nearai/ironclaw/pull/92))
- add multi-provider LLM failover with retry backoff ([#28](https://github.com/nearai/ironclaw/pull/28))
- add libSQL/Turso embedded database backend ([#47](https://github.com/nearai/ironclaw/pull/47))
- Move debug log truncation from agent loop to REPL channel ([#65](https://github.com/nearai/ironclaw/pull/65))
### Fixed
- shell destructive-command check bypassed by Value::Object arguments ([#72](https://github.com/nearai/ironclaw/pull/72))
- propagate real tool_call_id instead of hardcoded placeholder ([#73](https://github.com/nearai/ironclaw/pull/73))
- Fix wasm tool schemas and runtime ([#42](https://github.com/nearai/ironclaw/pull/42))
- flatten tool messages for NEAR AI cloud-api compatibility ([#41](https://github.com/nearai/ironclaw/pull/41))
- security hardening across all layers ([#35](https://github.com/nearai/ironclaw/pull/35))
### Other
- Explicitly enable cargo-dist caching for binary artifacts building
- Skip building binary artifacts on every PR
- add module specification rules to CLAUDE.md
- add setup/onboarding specification (src/setup/README.md)
- deduplicate tool code and remove dead stubs ([#98](https://github.com/nearai/ironclaw/pull/98))
- Reformat architecture diagram in README ([#64](https://github.com/nearai/ironclaw/pull/64))
- Add review discipline guidelines to CLAUDE.md ([#68](https://github.com/nearai/ironclaw/pull/68))
- Bump MSRV to 1.92, add GCP deployment files ([#40](https://github.com/nearai/ironclaw/pull/40))
- Add OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models) ([#31](https://github.com/nearai/ironclaw/pull/31))
## [0.1.3](https://github.com/nearai/ironclaw/compare/v0.1.2...v0.1.3) - 2026-02-12 ## [0.1.3](https://github.com/nearai/ironclaw/compare/v0.1.2...v0.1.3) - 2026-02-12
### Other ### Other
-16
View File
@@ -630,22 +630,6 @@ RUST_LOG=ironclaw::agent=debug cargo run
RUST_LOG=ironclaw=debug,tower_http=debug cargo run RUST_LOG=ironclaw=debug,tower_http=debug cargo run
``` ```
## Module Specifications
Some modules have a `README.md` that serves as the authoritative specification
for that module's behavior. When modifying code in a module that has a spec:
1. **Read the spec first** before making changes
2. **Code follows spec**: if the spec says X, the code must do X
3. **Update both sides**: if you change behavior, update the spec to match;
if you're implementing a spec change, update the code to match
4. **Spec is the tiebreaker**: when code and spec disagree, the spec is correct
(unless the spec is clearly outdated, in which case fix the spec first)
| Module | Spec File |
|--------|-----------|
| `src/setup/` | `src/setup/README.md` |
## Code Style ## Code Style
- Use `crate::` imports, not `super::` - Use `crate::` imports, not `super::`
Generated
+1 -1
View File
@@ -2490,7 +2490,7 @@ dependencies = [
[[package]] [[package]]
name = "ironclaw" name = "ironclaw"
version = "0.3.0" version = "0.1.3"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"aho-corasick", "aho-corasick",
+2 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ironclaw" name = "ironclaw"
version = "0.3.0" version = "0.1.3"
edition = "2024" edition = "2024"
rust-version = "1.92" rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
@@ -183,13 +183,11 @@ windows-archive = ".tar.gz"
# The archive format to use for non-windows builds (defaults .tar.xz) # The archive format to use for non-windows builds (defaults .tar.xz)
unix-archive = ".tar.gz" unix-archive = ".tar.gz"
# Which actions to run on pull requests # Which actions to run on pull requests
pr-run-mode = "skip" pr-run-mode = "upload"
# Path that installers should place binaries in # Path that installers should place binaries in
install-path = "CARGO_HOME" install-path = "CARGO_HOME"
# Whether to install an updater program # Whether to install an updater program
install-updater = true install-updater = true
# Cache intermediate build artifacts to speed up the release pipelines
cache-builds = true
[workspace.metadata.dist.github-custom-runners] [workspace.metadata.dist.github-custom-runners]
aarch64-unknown-linux-gnu = "ubuntu-24.04-arm" aarch64-unknown-linux-gnu = "ubuntu-24.04-arm"
+1 -1
View File
@@ -164,7 +164,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| AWS Bedrock | ✅ | ❌ | P3 | | | AWS Bedrock | ✅ | ❌ | P3 | |
| Google Gemini | ✅ | ❌ | P3 | | | Google Gemini | ✅ | ❌ | P3 | |
| OpenRouter | ✅ | ❌ | P3 | | | OpenRouter | ✅ | ❌ | P3 | |
| Ollama (local) | ✅ | | - | via `rig::providers::ollama` (full support) | | Ollama (local) | ✅ | | P2 | Local models |
| node-llama-cpp | ✅ | | - | N/A for Rust | | node-llama-cpp | ✅ | | - | N/A for Rust |
| llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings | | llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings |
-23
View File
@@ -1,23 +0,0 @@
[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
-121
View File
@@ -1,121 +0,0 @@
# 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 <https://discord.com/developers/applications>
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
@@ -1,39 +0,0 @@
{
"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
}
}
-476
View File
@@ -1,476 +0,0 @@
//! 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<String>,
/// Channel ID
channel_id: Option<String>,
/// Member info (if in server)
member: Option<DiscordMember>,
/// User info (if DM)
user: Option<DiscordUser>,
/// Command data (for slash commands)
data: Option<DiscordCommandData>,
/// Message (for component interactions)
message: Option<DiscordMessage>,
/// Token for responding
token: String,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordMember {
user: DiscordUser,
#[allow(dead_code)] // Part of API payload, currently unused
nick: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordUser {
id: String,
username: String,
global_name: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordCommandData {
#[allow(dead_code)] // Part of API payload, currently unused
id: String,
name: String,
options: Option<Vec<DiscordCommandOption>>,
}
#[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<String>,
}
struct DiscordChannel;
impl Guest for DiscordChannel {
fn on_start(_config_json: String) -> Result<ChannelConfig, String> {
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::<serde_json::Value>(&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::<Vec<_>>()
.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");
}
}
+1 -9
View File
@@ -67,9 +67,6 @@ enum AgenticLoopResult {
pub struct AgentDeps { pub struct AgentDeps {
pub store: Option<Arc<dyn Database>>, pub store: Option<Arc<dyn Database>>,
pub llm: Arc<dyn LlmProvider>, pub llm: Arc<dyn LlmProvider>,
/// Cheap/fast LLM for lightweight tasks (heartbeat, routing, evaluation).
/// Falls back to the main `llm` if None.
pub cheap_llm: Option<Arc<dyn LlmProvider>>,
pub safety: Arc<SafetyLayer>, pub safety: Arc<SafetyLayer>,
pub tools: Arc<ToolRegistry>, pub tools: Arc<ToolRegistry>,
pub workspace: Option<Arc<Workspace>>, pub workspace: Option<Arc<Workspace>>,
@@ -141,11 +138,6 @@ impl Agent {
&self.deps.llm &self.deps.llm
} }
/// Get the cheap/fast LLM provider, falling back to the main one.
fn cheap_llm(&self) -> &Arc<dyn LlmProvider> {
self.deps.cheap_llm.as_ref().unwrap_or(&self.deps.llm)
}
fn safety(&self) -> &Arc<SafetyLayer> { fn safety(&self) -> &Arc<SafetyLayer> {
&self.deps.safety &self.deps.safety
} }
@@ -309,7 +301,7 @@ impl Agent {
Some(spawn_heartbeat( Some(spawn_heartbeat(
config, config,
workspace.clone(), workspace.clone(),
self.cheap_llm().clone(), self.llm().clone(),
Some(notify_tx), Some(notify_tx),
)) ))
} else { } else {
-4
View File
@@ -388,9 +388,6 @@ impl std::str::FromStr for NearAiApiMode {
pub struct NearAiConfig { pub struct NearAiConfig {
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o") /// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
pub model: String, pub model: String,
/// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation).
/// Falls back to the main model if not set.
pub cheap_model: Option<String>,
/// Base URL for the NEAR AI API (default: https://api.near.ai) /// Base URL for the NEAR AI API (default: https://api.near.ai)
pub base_url: String, pub base_url: String,
/// Base URL for auth/refresh endpoints (default: https://private.near.ai) /// Base URL for auth/refresh endpoints (default: https://private.near.ai)
@@ -457,7 +454,6 @@ impl LlmConfig {
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic" "fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
.to_string() .to_string()
}), }),
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
base_url: optional_env("NEARAI_BASE_URL")? base_url: optional_env("NEARAI_BASE_URL")?
.unwrap_or_else(|| "https://cloud-api.near.ai".to_string()), .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()),
auth_base_url: optional_env("NEARAI_AUTH_URL")? auth_base_url: optional_env("NEARAI_AUTH_URL")?
+5 -1
View File
@@ -20,6 +20,10 @@ impl CostEstimator {
// Default tool costs (in USD or equivalent) // Default tool costs (in USD or equivalent)
tool_costs.insert("http".to_string(), dec!(0.0001)); // API call tool_costs.insert("http".to_string(), dec!(0.0001)); // API call
tool_costs.insert("marketplace".to_string(), dec!(0.01)); // Gas costs
tool_costs.insert("ecommerce".to_string(), dec!(0.001)); // API call
tool_costs.insert("taskrabbit".to_string(), dec!(0.0)); // Cost comes from task itself
tool_costs.insert("restaurant".to_string(), dec!(0.001)); // API call
tool_costs.insert("echo".to_string(), dec!(0.0)); // Free tool_costs.insert("echo".to_string(), dec!(0.0)); // Free
tool_costs.insert("time".to_string(), dec!(0.0)); // Free tool_costs.insert("time".to_string(), dec!(0.0)); // Free
tool_costs.insert("json".to_string(), dec!(0.0)); // Free tool_costs.insert("json".to_string(), dec!(0.0)); // Free
@@ -70,7 +74,7 @@ mod tests {
let estimator = CostEstimator::new(); let estimator = CostEstimator::new();
assert_eq!(estimator.estimate_tool("echo"), dec!(0.0)); assert_eq!(estimator.estimate_tool("echo"), dec!(0.0));
assert_eq!(estimator.estimate_tool("http"), dec!(0.0001)); assert_eq!(estimator.estimate_tool("marketplace"), dec!(0.01));
assert!(estimator.estimate_tool("unknown") > dec!(0.0)); assert!(estimator.estimate_tool("unknown") > dec!(0.0));
} }
+4
View File
@@ -16,6 +16,10 @@ impl TimeEstimator {
// Default tool durations // Default tool durations
tool_durations.insert("http".to_string(), Duration::from_secs(5)); tool_durations.insert("http".to_string(), Duration::from_secs(5));
tool_durations.insert("marketplace".to_string(), Duration::from_secs(10));
tool_durations.insert("ecommerce".to_string(), Duration::from_secs(8));
tool_durations.insert("taskrabbit".to_string(), Duration::from_secs(30)); // Just API, not task itself
tool_durations.insert("restaurant".to_string(), Duration::from_secs(5));
tool_durations.insert("echo".to_string(), Duration::from_millis(10)); tool_durations.insert("echo".to_string(), Duration::from_millis(10));
tool_durations.insert("time".to_string(), Duration::from_millis(1)); tool_durations.insert("time".to_string(), Duration::from_millis(1));
tool_durations.insert("json".to_string(), Duration::from_millis(5)); tool_durations.insert("json".to_string(), Duration::from_millis(5));
-103
View File
@@ -183,106 +183,3 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmPr
); );
Ok(Arc::new(RigAdapter::new(model, &compat.model))) Ok(Arc::new(RigAdapter::new(model, &compat.model)))
} }
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
///
/// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider.
/// Currently only supports NEAR AI backends (Responses and ChatCompletions modes).
pub fn create_cheap_llm_provider(
config: &LlmConfig,
session: Arc<SessionManager>,
) -> Result<Option<Arc<dyn LlmProvider>>, LlmError> {
let Some(ref cheap_model) = config.nearai.cheap_model else {
return Ok(None);
};
if config.backend != LlmBackend::NearAi {
tracing::warn!(
"NEARAI_CHEAP_MODEL is set but LLM_BACKEND is {:?}, not NearAi. \
Cheap model setting will be ignored.",
config.backend
);
return Ok(None);
}
let mut cheap_config = config.nearai.clone();
cheap_config.model = cheap_model.clone();
tracing::info!("Cheap LLM provider: {}", cheap_model);
match cheap_config.api_mode {
NearAiApiMode::Responses => Ok(Some(Arc::new(NearAiProvider::new(cheap_config, session)))),
NearAiApiMode::ChatCompletions => {
Ok(Some(Arc::new(NearAiChatProvider::new(cheap_config)?)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{LlmBackend, NearAiApiMode, NearAiConfig};
use std::path::PathBuf;
fn test_nearai_config() -> NearAiConfig {
NearAiConfig {
model: "test-model".to_string(),
cheap_model: None,
base_url: "https://api.near.ai".to_string(),
auth_base_url: "https://private.near.ai".to_string(),
session_path: PathBuf::from("/tmp/test-session.json"),
api_mode: NearAiApiMode::Responses,
api_key: None,
fallback_model: None,
max_retries: 3,
}
}
fn test_llm_config() -> LlmConfig {
LlmConfig {
backend: LlmBackend::NearAi,
nearai: test_nearai_config(),
openai: None,
anthropic: None,
ollama: None,
openai_compatible: None,
}
}
#[test]
fn test_create_cheap_llm_provider_returns_none_when_not_configured() {
let config = test_llm_config();
let session = Arc::new(SessionManager::new(SessionConfig::default()));
let result = create_cheap_llm_provider(&config, session);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn test_create_cheap_llm_provider_creates_provider_when_configured() {
let mut config = test_llm_config();
config.nearai.cheap_model = Some("cheap-test-model".to_string());
let session = Arc::new(SessionManager::new(SessionConfig::default()));
let result = create_cheap_llm_provider(&config, session);
assert!(result.is_ok());
let provider = result.unwrap();
assert!(provider.is_some());
assert_eq!(provider.unwrap().model_name(), "cheap-test-model");
}
#[test]
fn test_create_cheap_llm_provider_ignored_for_non_nearai_backend() {
let mut config = test_llm_config();
config.backend = LlmBackend::OpenAi;
config.nearai.cheap_model = Some("cheap-test-model".to_string());
let session = Arc::new(SessionManager::new(SessionConfig::default()));
let result = create_cheap_llm_provider(&config, session);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
}
+4 -26
View File
@@ -23,8 +23,8 @@ use ironclaw::{
context::ContextManager, context::ContextManager,
extensions::ExtensionManager, extensions::ExtensionManager,
llm::{ llm::{
FailoverProvider, LlmProvider, SessionConfig, create_cheap_llm_provider, FailoverProvider, LlmProvider, SessionConfig, create_llm_provider,
create_llm_provider, create_llm_provider_with_config, create_session_manager, create_llm_provider_with_config, create_session_manager,
}, },
orchestrator::{ orchestrator::{
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore, ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
@@ -307,11 +307,8 @@ async fn main() -> anyhow::Result<()> {
}; };
let session = create_session_manager(session_config).await; let session = create_session_manager(session_config).await;
// Session-based auth is only needed for NEAR AI backend without an API key. // Ensure we're authenticated before proceeding (only needed for NEAR AI backend)
// ChatCompletions mode with an API key skips session auth entirely. if config.llm.backend == ironclaw::config::LlmBackend::NearAi {
if config.llm.backend == ironclaw::config::LlmBackend::NearAi
&& config.llm.nearai.api_key.is_none()
{
session.ensure_authenticated().await?; session.ensure_authenticated().await?;
} }
@@ -537,12 +534,6 @@ async fn main() -> anyhow::Result<()> {
llm llm
}; };
// Initialize cheap LLM provider for lightweight tasks (heartbeat, evaluation)
let cheap_llm = create_cheap_llm_provider(&config.llm, session.clone())?;
if let Some(ref cheap) = cheap_llm {
tracing::info!("Cheap LLM provider initialized: {}", cheap.model_name());
}
// Initialize safety layer // Initialize safety layer
let safety = Arc::new(SafetyLayer::new(&config.safety)); let safety = Arc::new(SafetyLayer::new(&config.safety));
tracing::info!("Safety layer initialized"); tracing::info!("Safety layer initialized");
@@ -1194,7 +1185,6 @@ async fn main() -> anyhow::Result<()> {
let deps = AgentDeps { let deps = AgentDeps {
store: db, store: db,
llm, llm,
cheap_llm,
safety, safety,
tools, tools,
workspace, workspace,
@@ -1239,18 +1229,6 @@ fn check_onboard_needed() -> Option<&'static str> {
return Some("Database not configured"); return Some("Database not configured");
} }
// First run (onboarding never completed and no session).
// Reads NEARAI_API_KEY env var directly because this function runs
// before Config is loaded -- Config::from_env() may fail without a
// database URL, which is what triggers onboarding in the first place.
if std::env::var("NEARAI_API_KEY").is_err() {
let settings = ironclaw::settings::Settings::load();
let session_path = ironclaw::llm::session::default_session_path();
if !settings.onboard_completed && !session_path.exists() {
return Some("First run");
}
}
None None
} }
+2 -2
View File
@@ -202,7 +202,7 @@ async fn report_complete(
State(state): State<OrchestratorState>, State(state): State<OrchestratorState>,
Path(job_id): Path<Uuid>, Path(job_id): Path<Uuid>,
Json(report): Json<CompletionReport>, Json(report): Json<CompletionReport>,
) -> Result<Json<serde_json::Value>, StatusCode> { ) -> Result<StatusCode, StatusCode> {
if report.success { if report.success {
tracing::info!( tracing::info!(
job_id = %job_id, job_id = %job_id,
@@ -223,7 +223,7 @@ async fn report_complete(
}; };
let _ = state.job_manager.complete_job(job_id, result).await; let _ = state.job_manager.complete_job(job_id, result).await;
Ok(Json(serde_json::json!({"status": "ok"}))) Ok(StatusCode::OK)
} }
// -- Sandbox job event handlers -- // -- Sandbox job event handlers --
-539
View File
@@ -1,539 +0,0 @@
# Setup / Onboarding Specification
This document is the authoritative specification for IronClaw's onboarding
wizard. Any code change to `src/setup/` **must** keep this document in sync.
If a future contributor or coding agent modifies setup behavior, update this
file first, then adjust the code to match.
---
## Entry Points
```
ironclaw onboard [--skip-auth] [--channels-only]
```
Explicit invocation. Loads `.env` files, runs the wizard, exits.
```
ironclaw (first run, no database configured)
```
Auto-detection via `check_onboard_needed()` in `main.rs`. Triggers when
none of these are true:
- `DATABASE_URL` env var is set
- `LIBSQL_PATH` env var is set
- `~/.ironclaw/ironclaw.db` exists on disk
The `--no-onboard` CLI flag suppresses auto-detection.
---
## Startup Sequence (main.rs)
```
1. Parse CLI args
2. If Command::Onboard → load .env, run wizard, exit
3. If Command::Run or no command:
a. Load .env files (dotenvy::dotenv() then load_ironclaw_env())
b. check_onboard_needed() → run wizard if needed
c. Config::from_env() → build config from env vars
d. Create SessionManager → load session token
e. ensure_authenticated() → validate session (NEAR AI only)
f. ... rest of agent startup
```
**Critical ordering:** `.env` files must be loaded (step 3a) before
`Config::from_env()` (step 3c) because bootstrap vars like
`DATABASE_BACKEND` live in `~/.ironclaw/.env`.
---
## The 7-Step Wizard
### Overview
```
Step 1: Database Connection
Step 2: Security (master key)
Step 3: Inference Provider ← skipped if --skip-auth
Step 4: Model Selection
Step 5: Embeddings
Step 6: Channel Configuration
Step 7: Background Tasks (heartbeat)
save_and_summarize()
```
`--channels-only` mode runs only Step 6, skipping everything else.
---
### Step 1: Database Connection
**Module:** `wizard.rs``step_database()`
**Goal:** Select backend, establish connection, run migrations.
**Decision tree:**
```
Both features compiled?
├─ Yes → DATABASE_BACKEND env var set?
│ ├─ Yes → use that backend
│ └─ No → interactive selection (PostgreSQL vs libSQL)
├─ Only postgres feature → step_database_postgres()
└─ Only libsql feature → step_database_libsql()
```
**PostgreSQL path** (`step_database_postgres`):
1. Check `DATABASE_URL` from env or settings
2. Test connection (creates `deadpool_postgres::Pool`)
3. Optionally run refinery migrations
4. Store pool in `self.db_pool`
**libSQL path** (`step_database_libsql`):
1. Offer local path (default: `~/.ironclaw/ironclaw.db`)
2. Optional Turso cloud sync (URL + auth token)
3. Test connection (creates `LibSqlBackend`)
4. Always run migrations (idempotent CREATE IF NOT EXISTS)
5. Store backend in `self.db_backend`
**Invariant:** After Step 1, exactly one of `self.db_pool` or
`self.db_backend` is `Some`. This is required for settings persistence
in `save_and_summarize()`.
---
### Step 2: Security (Master Key)
**Module:** `wizard.rs``step_security()`
**Goal:** Configure encryption for API tokens and secrets.
**Decision tree:**
```
SECRETS_MASTER_KEY env var set?
├─ Yes → use env var, done
└─ No → try get_master_key() from OS keychain
├─ Ok(bytes) → cache in self.secrets_crypto, ask "use existing?"
│ ├─ Yes → done (keychain)
│ └─ No → clear cache, fall through to options
└─ Err → fall through to options
├─ OS Keychain: generate + store + build SecretsCrypto
├─ Env variable: generate + print export command
└─ Skip: disable secrets features
```
**CRITICAL CAVEAT: macOS Keychain Dialogs**
On macOS, `security_framework::get_generic_password()` can trigger TWO
system dialogs:
1. "Enter your password to unlock the keychain" (keychain locked)
2. "Allow ironclaw to access this keychain item" (per-app authorization)
This is OS-level behavior we cannot prevent. To minimize pain:
- **Use `get_master_key()` not `has_master_key()`** in step 2. Both call
the same underlying API, but `get_master_key()` returns the key bytes
so we can cache them. `has_master_key()` throws them away, forcing a
second keychain access later.
- **Build `SecretsCrypto` eagerly.** When the keychain key is retrieved,
immediately construct `SecretsCrypto` and store in `self.secrets_crypto`.
Later calls to `init_secrets_context()` check this field first, avoiding
redundant keychain probes.
- **Never probe the keychain in read-only commands** (e.g., `ironclaw status`).
The status command reports "env not set (keychain may be configured)"
rather than triggering system dialogs.
**Invariant:** After Step 2, `self.secrets_crypto` is `Some` if the user
chose Keychain or generated a new key. It may be `None` if the user chose
env-var mode or skipped secrets.
---
### Step 3: Inference Provider
**Module:** `wizard.rs``step_inference_provider()`
**Goal:** Choose LLM backend and authenticate.
**Providers:**
| Provider | Auth Method | Secret Name | Env Var |
|----------|-------------|-------------|---------|
| NEAR AI | Browser OAuth | (session token) | `NEARAI_SESSION_TOKEN` |
| Anthropic | API key | `anthropic_api_key` | `ANTHROPIC_API_KEY` |
| OpenAI | API key | `openai_api_key` | `OPENAI_API_KEY` |
| Ollama | None | - | - |
| OpenAI-compatible | Optional API key | `llm_compatible_api_key` | `LLM_API_KEY` |
**API-key providers** (`setup_api_key_provider`):
1. Check env var → if set, ask to reuse, persist to secrets store
2. Otherwise prompt for key entry via `secret_input()`
3. Store encrypted in secrets via `init_secrets_context()`
4. **Cache key in `self.llm_api_key`** for model fetching in Step 4
**NEAR AI** (`setup_nearai`):
- Calls `session_manager.ensure_authenticated()` which opens browser
- Session token saved to `~/.ironclaw/session.json`
**`self.llm_api_key` caching:** The wizard caches the API key as
`Option<SecretString>` so that Step 4 (model fetching) and Step 5
(embeddings) can use it without re-reading from the secrets store or
mutating environment variables.
---
### Step 4: Model Selection
**Module:** `wizard.rs``step_model_selection()`
**Goal:** Choose which model to use.
**Flow:**
1. If model already set → offer to keep it
2. Fetch models from provider API (5-second timeout)
3. On timeout or error → use static fallback list
4. Present list + "Custom model ID" escape hatch
5. Store in `self.settings.selected_model`
**Model fetchers pass the cached API key explicitly:**
```rust
let cached = self.llm_api_key.as_ref().map(|k| k.expose_secret().to_string());
let models = fetch_anthropic_models(cached.as_deref()).await;
```
This avoids mutating environment variables. The fetcher checks the explicit
key first, then falls back to the standard env var.
---
### Step 5: Embeddings
**Module:** `wizard.rs``step_embeddings()`
**Goal:** Configure semantic search for workspace memory.
**Flow:**
1. Ask "Enable semantic search?" (default: yes)
2. Detect available providers:
- NEAR AI: if backend is `nearai` OR valid session exists
- OpenAI: if `OPENAI_API_KEY` in env OR (backend is `openai` AND cached key)
3. If both available → let user choose
4. If only one → use it
5. If neither → disable embeddings
**Default model:** `text-embedding-3-small` (for both providers)
---
### Step 6: Channel Configuration
**Module:** `wizard.rs``step_channels()`, delegating to `channels.rs`
**Goal:** Enable input channels (TUI, HTTP, Telegram, etc.).
**Sub-steps:**
```
6a. Tunnel setup (if webhook channels needed)
6b. Discover WASM channels from ~/.ironclaw/channels/
6c. Multi-select: CLI/TUI, HTTP, discovered channels, bundled channels
6d. Install missing bundled channels (copy WASM binaries)
6e. Initialize SecretsContext (for token storage)
6f. Setup HTTP webhook (if selected)
6g. Setup each WASM channel (secrets, owner binding)
```
**Tunnel setup** (`setup_tunnel`):
- Options: ngrok, Cloudflare Tunnel, localtunnel, custom URL
- Validates HTTPS requirement
- Stored in `self.settings.tunnel.public_url`
**WASM channel setup** (`setup_wasm_channel`):
- Reads `capabilities.json` for `setup.required_secrets`
- For each secret: check existing, prompt or auto-generate, validate regex
- Save each secret via `SecretsContext`
**Telegram special case** (`setup_telegram`):
- Validates bot token via Telegram `getMe` API
- Owner binding: polls `getUpdates` for 120s to capture sender's user ID
- Optional webhook secret generation
**SecretsContext creation** (`init_secrets_context`):
1. Check `self.secrets_crypto` (set in Step 2) → use if available
2. Else try `SECRETS_MASTER_KEY` env var
3. Else try `get_master_key()` from keychain (only in `channels_only` mode)
4. Create backend-appropriate secrets store (respects selected database backend)
---
### Step 7: Heartbeat
**Module:** `wizard.rs``step_heartbeat()`
**Goal:** Configure periodic background execution.
**Flow:**
1. Ask "Enable heartbeat?" (default: no)
2. If yes: interval in minutes (default: 30), notification channel
3. Store in `self.settings.heartbeat`
---
## Settings Persistence
### Two-Layer Architecture
Settings are persisted in two places:
**Layer 1: `~/.ironclaw/.env`** (bootstrap vars)
Contains only the settings needed BEFORE database connection. Written by
`save_bootstrap_env()` in `bootstrap.rs`.
```env
DATABASE_BACKEND="libsql"
LIBSQL_PATH="/Users/name/.ironclaw/ironclaw.db"
```
Or for PostgreSQL:
```env
DATABASE_BACKEND="postgres"
DATABASE_URL="postgres://user:pass@localhost/ironclaw"
```
**Why separate?** Chicken-and-egg: you need `DATABASE_BACKEND` to know
which database to connect to, so it can't be stored in the database.
**Layer 2: Database settings table** (everything else)
All other settings are stored as key-value pairs in the `settings` table,
keyed by `(user_id, key)`. Written by `set_all_settings()`.
Settings are serialized via `Settings::to_db_map()` as dotted paths:
```
database_backend = "libsql"
llm_backend = "nearai"
selected_model = "anthropic/claude-sonnet-4-5"
embeddings.enabled = "true"
embeddings.provider = "nearai"
channels.http_enabled = "true"
heartbeat.enabled = "true"
heartbeat.interval_secs = "300"
```
### save_and_summarize()
Final step of the wizard:
```
1. Mark onboard_completed = true
2. Write ALL settings to database (try postgres pool, then libSQL backend)
3. Write bootstrap vars to ~/.ironclaw/.env:
- DATABASE_BACKEND (always)
- DATABASE_URL (if postgres)
- LIBSQL_PATH (if libsql)
- LIBSQL_URL (if turso sync)
4. Print configuration summary
```
**Invariant:** Both Layer 1 and Layer 2 must be written. If the database
write fails, the wizard returns an error and the `.env` file is not written.
### Legacy Migration
`bootstrap.rs` handles one-time upgrades from older config formats:
- `bootstrap.json` → extracts `DATABASE_URL`, writes `.env`, renames to `.migrated`
- `settings.json` → migrated to database via `migrate_disk_to_db()`
---
## Settings Struct
**Module:** `settings.rs`
```rust
pub struct Settings {
// Meta
pub onboard_completed: bool,
// Step 1: Database
pub database_backend: Option<String>, // "postgres" | "libsql"
pub database_url: Option<String>,
pub libsql_path: Option<String>,
pub libsql_url: Option<String>,
// Step 2: Security
pub secrets_master_key_source: KeySource, // Keychain | Env | None
// Step 3: Inference
pub llm_backend: Option<String>, // "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible"
pub ollama_base_url: Option<String>,
pub openai_compatible_base_url: Option<String>,
// Step 4: Model
pub selected_model: Option<String>,
// Step 5: Embeddings
pub embeddings: EmbeddingsSettings, // enabled, provider, model
// Step 6: Channels
pub tunnel: TunnelSettings, // provider, public_url
pub channels: ChannelSettings, // http config, telegram owner, etc.
// Step 7: Heartbeat
pub heartbeat: HeartbeatSettings, // enabled, interval, notify
// Advanced (not in wizard, set via `ironclaw config set`)
pub agent: AgentSettings,
pub wasm: WasmSettings,
pub sandbox: SandboxSettings,
pub safety: SafetySettings,
pub builder: BuilderSettings,
}
```
**KeySource enum:** `Keychain | Env | None`
---
## Secrets Flow
### SecretsContext
Thin wrapper for setup-time secret operations:
```rust
pub struct SecretsContext {
store: Arc<dyn SecretsStore>,
user_id: String,
}
```
Created by `init_secrets_context()` which:
1. Gets `SecretsCrypto` from `self.secrets_crypto` or loads from keychain/env
2. Creates the appropriate backend store:
- If both features compiled: respects `self.settings.database_backend`
- Tries selected backend first, falls back to the other
3. Returns `SecretsContext` wrapping the store
### Secret Storage
Secrets are encrypted with AES-256-GCM using the master key, then stored
in the database `secrets` table. The wizard writes secrets like:
```
telegram_bot_token → encrypted bot token
telegram_webhook_secret → encrypted webhook HMAC secret
anthropic_api_key → encrypted API key
```
---
## Prompt Utilities
**Module:** `prompts.rs`
| Function | Description |
|----------|-------------|
| `select_one(label, options)` | Numbered single-choice menu |
| `select_many(label, options, defaults)` | Checkbox multi-select (raw terminal mode) |
| `input(label)` | Single line text input |
| `optional_input(label, hint)` | Text input that can be empty |
| `secret_input(label)` | Hidden input (shows `*` per char), returns `SecretString` |
| `confirm(label, default)` | `[Y/n]` or `[y/N]` prompt |
| `print_header(text)` | Bold section header with underline |
| `print_step(n, total, text)` | `[1/7] Step Name` |
| `print_success(text)` | Green checkmark prefix |
| `print_error(text)` | Red X prefix |
| `print_info(text)` | Blue info prefix |
`select_many` uses `crossterm` raw mode for arrow key navigation.
Must properly restore terminal state on all exit paths.
---
## Platform Caveats
### macOS Keychain
- `get_generic_password()` triggers system dialogs (unlock + authorize)
- Two dialogs per call is normal, not a bug
- Cache the result after first access to avoid repeat prompts
- Never probe keychain in read-only commands (`status`, `--help`)
- Service name: `"ironclaw"`, account: `"master_key"`
### Linux Secret Service
- Uses GNOME Keyring or KWallet via `secret-service` crate
- May need `gnome-keyring` daemon running
- Collection unlock may prompt for password
### URL Passwords
- `#` is common in URL-encoded passwords (`%23` decoded)
- `.env` values must be double-quoted to preserve `#`
- Display masked: `postgres://user:****@host/db`
### Telegram API
- Bot token format: `123456:ABC-DEF...`
- Token goes in URL path: `https://api.telegram.org/bot{TOKEN}/method`
- Webhook secret header: `X-Telegram-Bot-Api-Secret-Token`
- Owner binding polls `getUpdates` (must delete webhook first)
---
## Testing
Tests live in `mod tests {}` at the bottom of each file.
**What to test when modifying setup:**
- Settings round-trip: `to_db_map()` then `from_db_map()` preserves values
- Bootstrap `.env`: dotenvy can parse what `save_bootstrap_env()` writes
- Model fetchers: static fallback works when API is unreachable
- Channel discovery: handles missing dir, invalid JSON, deduplication
- Prompt functions: not tested (interactive I/O), but ensure error paths
don't panic
**Run setup tests:**
```bash
cargo test --lib -- setup
cargo test --lib -- bootstrap
```
---
## Modification Checklist
When changing the onboarding flow:
1. Update this README first with the intended behavior change
2. If adding a new wizard step:
- Add to the step enum in `run()`, adjust `total_steps`
- Add corresponding settings fields to `Settings`
- Add `to_db_map` / `from_db_map` serialization
- If the setting is needed before DB connection, add to `save_bootstrap_env()`
3. If adding a new provider or channel:
- Add to the selection menu in the appropriate step
- Add authentication flow (API key or OAuth)
- Add model fetcher with static fallback + 5s timeout
4. If touching keychain:
- Cache the result, never call `get_master_key()` twice
- Test on macOS (dialog behavior differs from Linux)
5. If touching secrets:
- Ensure `init_secrets_context()` respects the selected database backend
- Test with both postgres and libsql features
6. Run the full shipping checklist:
```bash
cargo fmt
cargo clippy --all --benches --tests --examples --all-features -- -D warnings
cargo test --lib -- setup bootstrap
```
7. Test a fresh onboarding: `rm -rf ~/.ironclaw && cargo run`
-1
View File
@@ -1014,7 +1014,6 @@ impl SetupWizard {
backend: crate::config::LlmBackend::NearAi, backend: crate::config::LlmBackend::NearAi,
nearai: crate::config::NearAiConfig { nearai: crate::config::NearAiConfig {
model: "dummy".to_string(), model: "dummy".to_string(),
cheap_model: None,
base_url, base_url,
auth_base_url, auth_base_url,
session_path: crate::llm::session::default_session_path(), session_path: crate::llm::session::default_session_path(),
+7 -2
View File
@@ -3,7 +3,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Simple echo tool for testing. /// Simple echo tool for testing.
pub struct EchoTool; pub struct EchoTool;
@@ -38,7 +38,12 @@ impl Tool for EchoTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let message = require_str(&params, "message")?; let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'message' parameter".to_string())
})?;
Ok(ToolOutput::text(message, start.elapsed())) Ok(ToolOutput::text(message, start.elapsed()))
} }
+136
View File
@@ -0,0 +1,136 @@
//! E-commerce tool for shopping and price comparison.
use async_trait::async_trait;
use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for e-commerce operations (Amazon, price comparison, etc.).
pub struct EcommerceTool {
// TODO: Add API clients
}
impl EcommerceTool {
/// Create a new e-commerce tool.
pub fn new() -> Self {
Self {}
}
}
impl Default for EcommerceTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for EcommerceTool {
fn name(&self) -> &str {
"ecommerce"
}
fn description(&self) -> &str {
"Search products, compare prices, and find deals across e-commerce platforms."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["search", "get_product", "compare_prices", "track_price"],
"description": "The e-commerce action to perform"
},
"query": {
"type": "string",
"description": "Search query (for search action)"
},
"product_id": {
"type": "string",
"description": "Product ID or ASIN (for get_product, compare_prices)"
},
"platform": {
"type": "string",
"enum": ["amazon", "ebay", "walmart", "all"],
"description": "E-commerce platform to search"
},
"max_price": {
"type": "number",
"description": "Maximum price filter"
},
"category": {
"type": "string",
"description": "Product category filter"
}
},
"required": ["action"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let action = params
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'action' parameter".to_string())
})?;
// TODO: Implement actual e-commerce API integrations
let result = match action {
"search" => {
let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
serde_json::json!({
"query": query,
"results": [],
"message": "E-commerce integration not yet implemented"
})
}
"get_product" => {
let product_id = params
.get("product_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'product_id' parameter".to_string())
})?;
serde_json::json!({
"product_id": product_id,
"found": false,
"message": "E-commerce integration not yet implemented"
})
}
"compare_prices" => {
serde_json::json!({
"prices": [],
"message": "E-commerce integration not yet implemented"
})
}
"track_price" => {
serde_json::json!({
"tracking": false,
"message": "E-commerce integration not yet implemented"
})
}
_ => {
return Err(ToolError::InvalidParameters(format!(
"unknown action: {}",
action
)));
}
};
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
true // External e-commerce data
}
}
+17 -5
View File
@@ -9,7 +9,7 @@ use async_trait::async_trait;
use crate::context::JobContext; use crate::context::JobContext;
use crate::extensions::{ExtensionKind, ExtensionManager}; use crate::extensions::{ExtensionKind, ExtensionManager};
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
// ── tool_search ────────────────────────────────────────────────────────── // ── tool_search ──────────────────────────────────────────────────────────
@@ -133,7 +133,10 @@ impl Tool for ToolInstallTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
let url = params.get("url").and_then(|v| v.as_str()); let url = params.get("url").and_then(|v| v.as_str());
@@ -207,7 +210,10 @@ impl Tool for ToolAuthTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
let result = self let result = self
.manager .manager
@@ -300,7 +306,10 @@ impl Tool for ToolActivateTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
match self.manager.activate(name).await { match self.manager.activate(name).await {
Ok(result) => { Ok(result) => {
@@ -462,7 +471,10 @@ impl Tool for ToolRemoveTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
let message = self let message = self
.manager .manager
+25 -7
View File
@@ -11,7 +11,7 @@ use async_trait::async_trait;
use tokio::fs; use tokio::fs;
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput};
use crate::workspace::paths as ws_paths; use crate::workspace::paths as ws_paths;
/// Well-known workspace filenames that must go through memory_write, not write_file. /// Well-known workspace filenames that must go through memory_write, not write_file.
@@ -203,7 +203,10 @@ impl Tool for ReadFileTool {
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, _ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let path_str = require_str(&params, "path")?; let path_str = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let limit = params.get("limit").and_then(|v| v.as_u64()); let limit = params.get("limit").and_then(|v| v.as_u64());
@@ -325,7 +328,10 @@ impl Tool for WriteFileTool {
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, _ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let path_str = require_str(&params, "path")?; let path_str = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
// Reject workspace paths: these live in the database, not on disk. // Reject workspace paths: these live in the database, not on disk.
if is_workspace_path(path_str) { if is_workspace_path(path_str) {
@@ -336,7 +342,10 @@ impl Tool for WriteFileTool {
))); )));
} }
let content = require_str(&params, "content")?; let content = params
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'content' parameter".into()))?;
let start = std::time::Instant::now(); let start = std::time::Instant::now();
@@ -641,11 +650,20 @@ impl Tool for ApplyPatchTool {
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, _ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let path_str = require_str(&params, "path")?; let path_str = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
let old_string = require_str(&params, "old_string")?; let old_string = params
.get("old_string")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'old_string' parameter".into()))?;
let new_string = require_str(&params, "new_string")?; let new_string = params
.get("new_string")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'new_string' parameter".into()))?;
let replace_all = params let replace_all = params
.get("replace_all") .get("replace_all")
+11 -3
View File
@@ -9,7 +9,7 @@ use reqwest::Client;
use crate::context::JobContext; use crate::context::JobContext;
use crate::safety::LeakDetector; use crate::safety::LeakDetector;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Maximum response body size (5 MB). Prevents OOM from unbounded responses. /// Maximum response body size (5 MB). Prevents OOM from unbounded responses.
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
@@ -154,9 +154,17 @@ impl Tool for HttpTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let method = require_str(&params, "method")?; let method = params
.get("method")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'method' parameter".to_string())
})?;
let url = require_str(&params, "url")?; let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'url' parameter".to_string()))?;
let parsed_url = validate_url(url)?; let parsed_url = validate_url(url)?;
// Parse headers // Parse headers
+19 -5
View File
@@ -18,7 +18,7 @@ use crate::context::{ContextManager, JobContext, JobState};
use crate::db::Database; use crate::db::Database;
use crate::history::SandboxJobRecord; use crate::history::SandboxJobRecord;
use crate::orchestrator::job_manager::{ContainerJobManager, JobMode}; use crate::orchestrator::job_manager::{ContainerJobManager, JobMode};
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for creating a new job. /// Tool for creating a new job.
/// ///
@@ -467,9 +467,17 @@ impl Tool for CreateJobTool {
params: serde_json::Value, params: serde_json::Value,
ctx: &JobContext, ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let title = require_str(&params, "title")?; let title = params
.get("title")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'title' parameter".into()))?;
let description = require_str(&params, "description")?; let description = params
.get("description")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'description' parameter".into())
})?;
if self.sandbox_enabled() { if self.sandbox_enabled() {
let wait = params.get("wait").and_then(|v| v.as_bool()).unwrap_or(true); let wait = params.get("wait").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -627,7 +635,10 @@ impl Tool for JobStatusTool {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let requester_id = ctx.user_id.clone(); let requester_id = ctx.user_id.clone();
let job_id_str = require_str(&params, "job_id")?; let job_id_str = params
.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;
let job_id = Uuid::parse_str(job_id_str).map_err(|_| { let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str)) ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
@@ -709,7 +720,10 @@ impl Tool for CancelJobTool {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let requester_id = ctx.user_id.clone(); let requester_id = ctx.user_id.clone();
let job_id_str = require_str(&params, "job_id")?; let job_id_str = params
.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;
let job_id = Uuid::parse_str(job_id_str).map_err(|_| { let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str)) ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
+10 -3
View File
@@ -3,7 +3,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_param, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for JSON manipulation (parse, query, transform). /// Tool for JSON manipulation (parse, query, transform).
pub struct JsonTool; pub struct JsonTool;
@@ -46,9 +46,16 @@ impl Tool for JsonTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let operation = require_str(&params, "operation")?; let operation = params
.get("operation")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'operation' parameter".to_string())
})?;
let data = require_param(&params, "data")?; let data = params
.get("data")
.ok_or_else(|| ToolError::InvalidParameters("missing 'data' parameter".to_string()))?;
let result = match operation { let result = match operation {
"parse" => { "parse" => {
+160
View File
@@ -0,0 +1,160 @@
//! NEAR AI Marketplace tool.
use async_trait::async_trait;
use rust_decimal::Decimal;
use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for interacting with the NEAR AI marketplace.
pub struct MarketplaceTool {
// TODO: Add marketplace client
}
impl MarketplaceTool {
/// Create a new marketplace tool.
pub fn new() -> Self {
Self {}
}
}
impl Default for MarketplaceTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for MarketplaceTool {
fn name(&self) -> &str {
"marketplace"
}
fn description(&self) -> &str {
"Interact with the NEAR AI marketplace: search jobs, submit bids, deliver work."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["search_jobs", "get_job", "submit_bid", "accept_job", "submit_work", "get_status"],
"description": "The marketplace action to perform"
},
"job_id": {
"type": "string",
"description": "Job ID (for get_job, submit_bid, accept_job, submit_work)"
},
"query": {
"type": "string",
"description": "Search query (for search_jobs)"
},
"category": {
"type": "string",
"description": "Job category filter (for search_jobs)"
},
"bid_amount": {
"type": "number",
"description": "Bid amount in NEAR (for submit_bid)"
},
"work_url": {
"type": "string",
"description": "URL to submitted work (for submit_work)"
},
"work_description": {
"type": "string",
"description": "Description of completed work (for submit_work)"
}
},
"required": ["action"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let action = params
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'action' parameter".to_string())
})?;
// TODO: Implement actual marketplace integration
let result = match action {
"search_jobs" => {
// Placeholder response
serde_json::json!({
"jobs": [],
"total": 0,
"message": "Marketplace integration not yet implemented"
})
}
"get_job" => {
let job_id = params
.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'job_id' parameter".to_string())
})?;
serde_json::json!({
"job_id": job_id,
"status": "not_found",
"message": "Marketplace integration not yet implemented"
})
}
"submit_bid" => {
serde_json::json!({
"success": false,
"message": "Marketplace integration not yet implemented"
})
}
"accept_job" => {
serde_json::json!({
"success": false,
"message": "Marketplace integration not yet implemented"
})
}
"submit_work" => {
serde_json::json!({
"success": false,
"message": "Marketplace integration not yet implemented"
})
}
"get_status" => {
serde_json::json!({
"connected": false,
"message": "Marketplace integration not yet implemented"
})
}
_ => {
return Err(ToolError::InvalidParameters(format!(
"unknown action: {}",
action
)));
}
};
Ok(ToolOutput::success(result, start.elapsed()))
}
fn estimated_cost(&self, params: &serde_json::Value) -> Option<Decimal> {
// Bidding has a cost
if params.get("action").and_then(|v| v.as_str()) == Some("submit_bid") {
Some(Decimal::new(1, 2)) // 0.01 NEAR gas cost
} else {
None
}
}
fn requires_sanitization(&self) -> bool {
true // External marketplace data
}
}
+15 -4
View File
@@ -17,7 +17,7 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
use crate::workspace::{Workspace, paths}; use crate::workspace::{Workspace, paths};
/// Identity files that the LLM must not overwrite via tool calls. /// Identity files that the LLM must not overwrite via tool calls.
@@ -81,7 +81,10 @@ impl Tool for MemorySearchTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let query = require_str(&params, "query")?; let query = params
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'query' parameter".to_string()))?;
let limit = params let limit = params
.get("limit") .get("limit")
@@ -173,7 +176,12 @@ impl Tool for MemoryWriteTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let content = require_str(&params, "content")?; let content = params
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'content' parameter".to_string())
})?;
if content.trim().is_empty() { if content.trim().is_empty() {
return Err(ToolError::InvalidParameters( return Err(ToolError::InvalidParameters(
@@ -329,7 +337,10 @@ impl Tool for MemoryReadTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let path = require_str(&params, "path")?; let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".to_string()))?;
let doc = self let doc = self
.workspace .workspace
+8
View File
@@ -1,17 +1,22 @@
//! Built-in tools that come with the agent. //! Built-in tools that come with the agent.
mod echo; mod echo;
mod ecommerce;
pub mod extension_tools; pub mod extension_tools;
mod file; mod file;
mod http; mod http;
mod job; mod job;
mod json; mod json;
mod marketplace;
mod memory; mod memory;
mod restaurant;
pub mod routine; pub mod routine;
pub(crate) mod shell; pub(crate) mod shell;
mod taskrabbit;
mod time; mod time;
pub use echo::EchoTool; pub use echo::EchoTool;
pub use ecommerce::EcommerceTool;
pub use extension_tools::{ pub use extension_tools::{
ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool,
}; };
@@ -19,9 +24,12 @@ pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
pub use http::HttpTool; pub use http::HttpTool;
pub use job::{CancelJobTool, CreateJobTool, JobStatusTool, ListJobsTool}; pub use job::{CancelJobTool, CreateJobTool, JobStatusTool, ListJobsTool};
pub use json::JsonTool; pub use json::JsonTool;
pub use marketplace::MarketplaceTool;
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool}; pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
pub use restaurant::RestaurantTool;
pub use routine::{ pub use routine::{
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool, RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
}; };
pub use shell::ShellTool; pub use shell::ShellTool;
pub use taskrabbit::TaskRabbitTool;
pub use time::TimeTool; pub use time::TimeTool;
+172
View File
@@ -0,0 +1,172 @@
//! Restaurant reservation tool.
use async_trait::async_trait;
use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for restaurant reservations (OpenTable, Resy, etc.).
pub struct RestaurantTool {
// TODO: Add reservation API clients
}
impl RestaurantTool {
/// Create a new restaurant tool.
pub fn new() -> Self {
Self {}
}
}
impl Default for RestaurantTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for RestaurantTool {
fn name(&self) -> &str {
"restaurant"
}
fn description(&self) -> &str {
"Search restaurants, check availability, and make reservations via OpenTable, Resy, etc."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["search", "check_availability", "make_reservation", "cancel_reservation", "get_reservation"],
"description": "The restaurant action to perform"
},
"query": {
"type": "string",
"description": "Search query (cuisine type, restaurant name, etc.)"
},
"location": {
"type": "object",
"properties": {
"city": { "type": "string" },
"neighborhood": { "type": "string" },
"latitude": { "type": "number" },
"longitude": { "type": "number" }
},
"description": "Location to search near"
},
"date": {
"type": "string",
"description": "Reservation date (YYYY-MM-DD)"
},
"time": {
"type": "string",
"description": "Preferred time (HH:MM)"
},
"party_size": {
"type": "integer",
"description": "Number of guests"
},
"restaurant_id": {
"type": "string",
"description": "Restaurant ID (for check_availability, make_reservation)"
},
"reservation_id": {
"type": "string",
"description": "Reservation ID (for cancel_reservation, get_reservation)"
},
"guest_name": {
"type": "string",
"description": "Name for the reservation"
},
"guest_phone": {
"type": "string",
"description": "Phone number for the reservation"
},
"guest_email": {
"type": "string",
"description": "Email for the reservation"
}
},
"required": ["action"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let action = params
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'action' parameter".to_string())
})?;
// TODO: Implement actual restaurant reservation API integrations
let result = match action {
"search" => {
let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
serde_json::json!({
"query": query,
"restaurants": [],
"message": "Restaurant integration not yet implemented"
})
}
"check_availability" => {
let restaurant_id = params
.get("restaurant_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters(
"missing 'restaurant_id' parameter".to_string(),
)
})?;
serde_json::json!({
"restaurant_id": restaurant_id,
"available_times": [],
"message": "Restaurant integration not yet implemented"
})
}
"make_reservation" => {
serde_json::json!({
"success": false,
"message": "Restaurant integration not yet implemented"
})
}
"cancel_reservation" => {
serde_json::json!({
"cancelled": false,
"message": "Restaurant integration not yet implemented"
})
}
"get_reservation" => {
let reservation_id = params.get("reservation_id").and_then(|v| v.as_str());
serde_json::json!({
"reservation_id": reservation_id,
"found": false,
"message": "Restaurant integration not yet implemented"
})
}
_ => {
return Err(ToolError::InvalidParameters(format!(
"unknown action: {}",
action
)));
}
};
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
true // External restaurant data
}
}
+25 -7
View File
@@ -20,7 +20,7 @@ use crate::agent::routine::{
use crate::agent::routine_engine::RoutineEngine; use crate::agent::routine_engine::RoutineEngine;
use crate::context::JobContext; use crate::context::JobContext;
use crate::db::Database; use crate::db::Database;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
// ==================== routine_create ==================== // ==================== routine_create ====================
@@ -106,16 +106,25 @@ impl Tool for RoutineCreateTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
let description = params let description = params
.get("description") .get("description")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
let trigger_type = require_str(&params, "trigger_type")?; let trigger_type = params
.get("trigger_type")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'trigger_type'".to_string()))?;
let prompt = require_str(&params, "prompt")?; let prompt = params
.get("prompt")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'prompt'".to_string()))?;
// Build trigger // Build trigger
let trigger = match trigger_type { let trigger = match trigger_type {
@@ -399,7 +408,10 @@ impl Tool for RoutineUpdateTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
let mut routine = self let mut routine = self
.store .store
@@ -502,7 +514,10 @@ impl Tool for RoutineDeleteTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
let routine = self let routine = self
.store .store
@@ -580,7 +595,10 @@ impl Tool for RoutineHistoryTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
let limit = params let limit = params
.get("limit") .get("limit")
+5 -2
View File
@@ -30,7 +30,7 @@ use tokio::process::Command;
use crate::context::JobContext; use crate::context::JobContext;
use crate::sandbox::{SandboxManager, SandboxPolicy}; use crate::sandbox::{SandboxManager, SandboxPolicy};
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput};
/// Maximum output size before truncation (64KB). /// Maximum output size before truncation (64KB).
const MAX_OUTPUT_SIZE: usize = 64 * 1024; const MAX_OUTPUT_SIZE: usize = 64 * 1024;
@@ -401,7 +401,10 @@ impl Tool for ShellTool {
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, _ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let command = require_str(&params, "command")?; let command = params
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'command' parameter".into()))?;
let workdir = params.get("workdir").and_then(|v| v.as_str()); let workdir = params.get("workdir").and_then(|v| v.as_str());
let timeout = params.get("timeout").and_then(|v| v.as_u64()); let timeout = params.get("timeout").and_then(|v| v.as_u64());
+157
View File
@@ -0,0 +1,157 @@
//! TaskRabbit tool for real-world task delegation.
use async_trait::async_trait;
use rust_decimal::Decimal;
use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for delegating real-world tasks via TaskRabbit.
pub struct TaskRabbitTool {
// TODO: Add TaskRabbit API client
}
impl TaskRabbitTool {
/// Create a new TaskRabbit tool.
pub fn new() -> Self {
Self {}
}
}
impl Default for TaskRabbitTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for TaskRabbitTool {
fn name(&self) -> &str {
"taskrabbit"
}
fn description(&self) -> &str {
"Delegate real-world tasks to TaskRabbit taskers (delivery, assembly, cleaning, etc.)."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["search_taskers", "get_quote", "book_task", "get_status", "cancel_task"],
"description": "The TaskRabbit action to perform"
},
"task_type": {
"type": "string",
"enum": ["delivery", "assembly", "moving", "cleaning", "handyman", "other"],
"description": "Type of task"
},
"description": {
"type": "string",
"description": "Detailed description of the task"
},
"location": {
"type": "object",
"properties": {
"address": { "type": "string" },
"city": { "type": "string" },
"state": { "type": "string" },
"zip": { "type": "string" }
},
"description": "Location for the task"
},
"scheduled_time": {
"type": "string",
"description": "ISO 8601 datetime for when the task should be performed"
},
"budget": {
"type": "number",
"description": "Maximum budget for the task in USD"
},
"task_id": {
"type": "string",
"description": "Task ID (for get_status, cancel_task)"
}
},
"required": ["action"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let action = params
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'action' parameter".to_string())
})?;
// TODO: Implement actual TaskRabbit API integration
let result = match action {
"search_taskers" => {
serde_json::json!({
"taskers": [],
"message": "TaskRabbit integration not yet implemented"
})
}
"get_quote" => {
serde_json::json!({
"quotes": [],
"message": "TaskRabbit integration not yet implemented"
})
}
"book_task" => {
serde_json::json!({
"booked": false,
"message": "TaskRabbit integration not yet implemented"
})
}
"get_status" => {
let task_id = params.get("task_id").and_then(|v| v.as_str());
serde_json::json!({
"task_id": task_id,
"status": "unknown",
"message": "TaskRabbit integration not yet implemented"
})
}
"cancel_task" => {
serde_json::json!({
"cancelled": false,
"message": "TaskRabbit integration not yet implemented"
})
}
_ => {
return Err(ToolError::InvalidParameters(format!(
"unknown action: {}",
action
)));
}
};
Ok(ToolOutput::success(result, start.elapsed()))
}
fn estimated_cost(&self, params: &serde_json::Value) -> Option<Decimal> {
// Booking a task has associated costs
if params.get("action").and_then(|v| v.as_str()) == Some("book_task") {
params
.get("budget")
.and_then(|v| v.as_f64())
.map(|b| Decimal::try_from(b).unwrap_or_default())
} else {
None
}
}
fn requires_sanitization(&self) -> bool {
true // External TaskRabbit data
}
}
+25 -5
View File
@@ -4,7 +4,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for getting current time and date operations. /// Tool for getting current time and date operations.
pub struct TimeTool; pub struct TimeTool;
@@ -52,7 +52,12 @@ impl Tool for TimeTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let operation = require_str(&params, "operation")?; let operation = params
.get("operation")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'operation' parameter".to_string())
})?;
let result = match operation { let result = match operation {
"now" => { "now" => {
@@ -64,7 +69,12 @@ impl Tool for TimeTool {
}) })
} }
"parse" => { "parse" => {
let timestamp = require_str(&params, "timestamp")?; let timestamp = params
.get("timestamp")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'timestamp' parameter".to_string())
})?;
let dt: DateTime<Utc> = timestamp.parse().map_err(|e| { let dt: DateTime<Utc> = timestamp.parse().map_err(|e| {
ToolError::InvalidParameters(format!("invalid timestamp: {}", e)) ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
@@ -77,9 +87,19 @@ impl Tool for TimeTool {
}) })
} }
"diff" => { "diff" => {
let ts1 = require_str(&params, "timestamp")?; let ts1 = params
.get("timestamp")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'timestamp' parameter".to_string())
})?;
let ts2 = require_str(&params, "timestamp2")?; let ts2 = params
.get("timestamp2")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'timestamp2' parameter".to_string())
})?;
let dt1: DateTime<Utc> = ts1.parse().map_err(|e| { let dt1: DateTime<Utc> = ts1.parse().map_err(|e| {
ToolError::InvalidParameters(format!("invalid timestamp: {}", e)) ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
+6 -59
View File
@@ -199,28 +199,6 @@ pub trait Tool: Send + Sync {
} }
} }
/// Extract a required string parameter from a JSON object.
///
/// Returns `ToolError::InvalidParameters` if the key is missing or not a string.
pub fn require_str<'a>(params: &'a serde_json::Value, name: &str) -> Result<&'a str, ToolError> {
params
.get(name)
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
}
/// Extract a required parameter of any type from a JSON object.
///
/// Returns `ToolError::InvalidParameters` if the key is missing.
pub fn require_param<'a>(
params: &'a serde_json::Value,
name: &str,
) -> Result<&'a serde_json::Value, ToolError> {
params
.get(name)
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -257,7 +235,12 @@ mod tests {
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, _ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let message = require_str(&params, "message")?; let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'message' parameter".to_string())
})?;
Ok(ToolOutput::text(message, Duration::from_millis(1))) Ok(ToolOutput::text(message, Duration::from_millis(1)))
} }
@@ -294,40 +277,4 @@ mod tests {
let tool = EchoTool; let tool = EchoTool;
assert_eq!(tool.execution_timeout(), Duration::from_secs(60)); assert_eq!(tool.execution_timeout(), Duration::from_secs(60));
} }
#[test]
fn test_require_str_present() {
let params = serde_json::json!({"name": "alice"});
assert_eq!(require_str(&params, "name").unwrap(), "alice");
}
#[test]
fn test_require_str_missing() {
let params = serde_json::json!({});
let err = require_str(&params, "name").unwrap_err();
assert!(err.to_string().contains("missing 'name'"));
}
#[test]
fn test_require_str_wrong_type() {
let params = serde_json::json!({"name": 42});
let err = require_str(&params, "name").unwrap_err();
assert!(err.to_string().contains("missing 'name'"));
}
#[test]
fn test_require_param_present() {
let params = serde_json::json!({"data": [1, 2, 3]});
assert_eq!(
require_param(&params, "data").unwrap(),
&serde_json::json!([1, 2, 3])
);
}
#[test]
fn test_require_param_missing() {
let params = serde_json::json!({});
let err = require_param(&params, "data").unwrap_err();
assert!(err.to_string().contains("missing 'data'"));
}
} }
+70 -54
View File
@@ -129,15 +129,11 @@ impl WorkerHttpClient {
format!("{}/worker/{}/{}", self.orchestrator_url, self.job_id, path) format!("{}/worker/{}/{}", self.orchestrator_url, self.job_id, path)
} }
/// Send a GET request, check the status, and deserialize the JSON body. /// Fetch the job description from the orchestrator.
async fn get_json<T: serde::de::DeserializeOwned>( pub async fn get_job(&self) -> Result<JobDescription, WorkerError> {
&self,
path: &str,
context: &str,
) -> Result<T, WorkerError> {
let resp = self let resp = self
.client .client
.get(self.url(path)) .get(self.url("job"))
.bearer_auth(&self.token) .bearer_auth(&self.token)
.send() .send()
.await .await
@@ -149,51 +145,15 @@ impl WorkerHttpClient {
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(WorkerError::OrchestratorRejected { return Err(WorkerError::OrchestratorRejected {
job_id: self.job_id, job_id: self.job_id,
reason: format!("{} returned {}", context, resp.status()), reason: format!("GET /job returned {}", resp.status()),
}); });
} }
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed { resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
reason: format!("{}: failed to parse response: {}", context, e), reason: format!("failed to parse job description: {}", e),
}) })
} }
/// Send a POST request with a JSON body, check the status, and deserialize the response.
async fn post_json<B: Serialize, T: serde::de::DeserializeOwned>(
&self,
path: &str,
body: &B,
context: &str,
) -> Result<T, WorkerError> {
let resp = self
.client
.post(self.url(path))
.bearer_auth(&self.token)
.json(body)
.send()
.await
.map_err(|e| WorkerError::LlmProxyFailed {
reason: format!("{}: {}", context, e),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(WorkerError::LlmProxyFailed {
reason: format!("{}: orchestrator returned {}: {}", context, status, body),
});
}
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
reason: format!("{}: failed to parse response: {}", context, e),
})
}
/// Fetch the job description from the orchestrator.
pub async fn get_job(&self) -> Result<JobDescription, WorkerError> {
self.get_json("job", "GET /job").await
}
/// Proxy an LLM completion request through the orchestrator. /// Proxy an LLM completion request through the orchestrator.
pub async fn llm_complete( pub async fn llm_complete(
&self, &self,
@@ -206,9 +166,29 @@ impl WorkerHttpClient {
stop_sequences: request.stop_sequences.clone(), stop_sequences: request.stop_sequences.clone(),
}; };
let proxy_resp: ProxyCompletionResponse = self let resp = self
.post_json("llm/complete", &proxy_req, "LLM complete") .client
.await?; .post(self.url("llm/complete"))
.bearer_auth(&self.token)
.json(&proxy_req)
.send()
.await
.map_err(|e| WorkerError::LlmProxyFailed {
reason: e.to_string(),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(WorkerError::LlmProxyFailed {
reason: format!("orchestrator returned {}: {}", status, body),
});
}
let proxy_resp: ProxyCompletionResponse =
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
reason: format!("failed to parse LLM response: {}", e),
})?;
Ok(CompletionResponse { Ok(CompletionResponse {
content: proxy_resp.content, content: proxy_resp.content,
@@ -232,9 +212,29 @@ impl WorkerHttpClient {
tool_choice: request.tool_choice.clone(), tool_choice: request.tool_choice.clone(),
}; };
let proxy_resp: ProxyToolCompletionResponse = self let resp = self
.post_json("llm/complete_with_tools", &proxy_req, "LLM tool complete") .client
.await?; .post(self.url("llm/complete_with_tools"))
.bearer_auth(&self.token)
.json(&proxy_req)
.send()
.await
.map_err(|e| WorkerError::LlmProxyFailed {
reason: e.to_string(),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(WorkerError::LlmProxyFailed {
reason: format!("orchestrator returned {}: {}", status, body),
});
}
let proxy_resp: ProxyToolCompletionResponse =
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
reason: format!("failed to parse tool completion response: {}", e),
})?;
Ok(ToolCompletionResponse { Ok(ToolCompletionResponse {
content: proxy_resp.content, content: proxy_resp.content,
@@ -337,9 +337,25 @@ impl WorkerHttpClient {
/// Signal job completion to the orchestrator. /// Signal job completion to the orchestrator.
pub async fn report_complete(&self, report: &CompletionReport) -> Result<(), WorkerError> { pub async fn report_complete(&self, report: &CompletionReport) -> Result<(), WorkerError> {
let _: serde_json::Value = self let resp = self
.post_json("complete", report, "report complete") .client
.await?; .post(self.url("complete"))
.bearer_auth(&self.token)
.json(report)
.send()
.await
.map_err(|e| WorkerError::ConnectionFailed {
url: self.orchestrator_url.clone(),
reason: e.to_string(),
})?;
if !resp.status().is_success() {
return Err(WorkerError::OrchestratorRejected {
job_id: self.job_id,
reason: format!("completion report rejected: {}", resp.status()),
});
}
Ok(()) Ok(())
} }
} }
-22
View File
@@ -1,22 +0,0 @@
[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
-189
View File
@@ -1,189 +0,0 @@
# 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 <https://github.com/settings/tokens>
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 <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
@@ -1,41 +0,0 @@
{
"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
}
}
-845
View File
@@ -1,845 +0,0 @@
//! 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>`
//!
//! 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<String>,
page: Option<u32>,
limit: Option<u32>,
},
#[serde(rename = "create_issue")]
CreateIssue {
owner: String,
repo: String,
title: String,
body: Option<String>,
labels: Option<Vec<String>>,
},
#[serde(rename = "get_issue")]
GetIssue {
owner: String,
repo: String,
issue_number: u32,
},
#[serde(rename = "list_pull_requests")]
ListPullRequests {
owner: String,
repo: String,
state: Option<String>,
page: Option<u32>,
limit: Option<u32>,
},
#[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<u32>,
limit: Option<u32>,
},
#[serde(rename = "get_file_content")]
GetFileContent {
owner: String,
repo: String,
path: String,
r#ref: Option<String>,
},
#[serde(rename = "trigger_workflow")]
TriggerWorkflow {
owner: String,
repo: String,
workflow_id: String,
r#ref: String,
inputs: Option<serde_json::Value>,
},
#[serde(rename = "get_workflow_runs")]
GetWorkflowRuns {
owner: String,
repo: String,
workflow_id: Option<String>,
page: Option<u32>,
limit: Option<u32>,
},
}
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<String, String> {
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<String, String> {
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>. \
Token needs 'repo', 'workflow', and 'read:org' scopes.".into())
}
fn github_request(method: &str, path: &str, body: Option<String>) -> Result<String, String> {
let url = format!("https://api.github.com{}", path);
// Authorization header (Bearer <token>) 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::<serde_json::Value>(&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::<u32>() {
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<String, String> {
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<u32>,
limit: Option<u32>,
) -> Result<String, String> {
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<Vec<String>>,
) -> Result<String, String> {
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<String, String> {
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<u32>,
limit: Option<u32>,
) -> Result<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<u32>, limit: Option<u32>) -> Result<String, String> {
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<String, String> {
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::<Vec<_>>()
.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<serde_json::Value>,
) -> Result<String, String> {
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<u32>,
limit: Option<u32>,
) -> Result<String, String> {
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());
}
}