mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
fix: Fix wasm tool schemas and runtime (#42)
* feat: Move debug log truncation from agent loop to REPL channel Full tool output now flows through StatusUpdate so the web gateway gets untruncated content. The REPL channel truncates at display time (200 chars for tool results, thinking, and status messages). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Flatten WASM tool schemas and fix host HTTP runtime contention LLMs can't reliably follow oneOf + const discriminator patterns in JSON Schema, causing tools like Google Calendar to receive malformed params (e.g., {"operation":"list_events","data":{"calendarId":"primary"}} instead of {"action":"list_events","calendar_id":"primary"}). Replace all 9 WASM tool schemas with flat action enum + top-level properties. The serde #[serde(tag = "action")] deserialization works identically. Also fixes WASM host HTTP requests (channels and tools) stalling during startup by replacing Handle::current().block_on() with a dedicated single-threaded runtime per request, avoiding I/O driver contention. Reduces verbose LLM debug logging (full request/response payloads) and changes tower_http default from debug to warn. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Built-in OAuth credentials and combined Google scopes Add infrastructure for shipping default OAuth credentials with the binary, similar to how gcloud/rclone bake in their client_id. Credentials are set at compile time via IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET env vars, or can be hardcoded in src/cli/oauth_defaults.rs. The fallback chain is: capabilities file > runtime env var > built-in defaults. Also, when authing any Google tool, scopes from ALL installed Google tools are now combined into a single OAuth request (they all share the same google_oauth_token secret). One login covers Gmail, Calendar, Drive, etc. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Ship default Google OAuth credentials for zero-config auth Google Desktop App credentials are not secret (per Google's own docs). Hardcode them so `ironclaw tool auth <google-tool>` works out of the box without requiring users to register their own OAuth app. Credentials can still be overridden at compile time (IRONCLAW_GOOGLE_CLIENT_ID) or runtime (GOOGLE_OAUTH_CLIENT_ID). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Consistent OAuth callback port and polished landing page - Use fixed port 9876 instead of scanning 9876-9886 (one redirect URI to register in provider OAuth apps, deterministic behavior) - Replace broken unicode checkmark with SVG icons (charset was missing, rendered as mojibake) - Dark themed landing page with proper card layout for both success and error states - Add charset=utf-8 to Content-Type headers Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: Unify OAuth callback server across all auth flows All three OAuth flows (WASM tool auth, MCP server auth, NEAR AI login) now share the same code from cli::oauth_defaults: - Fixed port 9876 (one redirect URI to register per provider) - Shared landing page HTML (dark card with SVG icons, proper charset) - Parameterized wait_for_callback(listener, path, param, display_name) Removes ~120 lines of duplicated callback/HTML code. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Support for oauth token refresh * refactor: Replace bootstrap.json with ~/.ironclaw/.env for DATABASE_URL Kill the 4-field BootstrapConfig JSON file. Only DATABASE_URL actually needs disk persistence (chicken-and-egg before DB connect). The other three fields are now derived: pool_size defaults to 10 via env var, secrets master key is auto-detected (env then keychain probe), and onboard_completed is inferred from DATABASE_URL presence. The new format is a standard .env file loaded via dotenvy early in main, so DATABASE_URL is available as a regular env var everywhere. Handles three upgrade paths: - Clean start: wizard writes .env, reload after wizard completes - Returning user: .env loaded at startup, business as usual - Legacy upgrade: bootstrap.json auto-migrated to .env on first run Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR review findings - Fix UTF-8 panic in truncate_for_preview (byte-slice on char boundary) - Cap WASM guest timeout_ms at 5 minutes to prevent resource exhaustion - Fix localhost detection in requires_auth() to avoid substring matches (e.g. "notlocalhost.com" no longer matches) - Fix query param injection to insert before URL fragment - Fix extract_host_from_url for IPv6 bracket notation - Remove misleading schema defaults: Slack limit, Slides insertion_index, Docs index (per-action defaults documented in descriptions instead) Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: Fix cargo fmt formatting Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: IPv6 loopback support for OAuth listener and localhost detection - bind_callback_listener: try [::1] first, fall back to 127.0.0.1, so OAuth redirects work on systems where localhost resolves to ::1 - is_localhost_url: replace manual string parsing with url::Url for correct handling of IPv6 brackets, ports, userinfo, etc. - Add url crate as direct dependency (already a transitive dep) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR review feedback on runtime reuse, onboard check, and OAuth binding - Remove session file check from check_onboard_needed(); DATABASE_URL is sufficient - Detect AddrInUse on IPv6 bind and fail immediately instead of falling through to IPv4 - Reuse dedicated tokio runtime across HTTP calls in both tool and channel WASM wrappers Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: HTML-escape provider name in OAuth landing page, simplify Slack limit description - Add html_escape() to prevent XSS in landing_html() where provider_name was interpolated directly into HTML (defense-in-depth, source is trusted but escaping costs nothing) - Remove per-action default numbers from Slack limit field description to avoid confusing LLMs with conflicting defaults Addresses review feedback from zmanian on PR #42. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Save all bootstrap fields from wizard, fix config module comment - Wizard now saves secrets_master_key_source and database_pool_size to bootstrap.json (was only saving database_url and onboard_completed, which broke secrets after fresh onboard since SecretsConfig::resolve reads key source from bootstrap) - Update config.rs module doc to reflect bootstrap.json priority chain instead of the removed ~/.ironclaw/.env approach Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: Replace BootstrapConfig with .env-based bootstrap DATABASE_URL is the only setting that needs disk persistence before the database is available. Instead of a custom bootstrap.json with 4 fields, use a standard ~/.ironclaw/.env file loaded via dotenvy. - Remove BootstrapConfig struct entirely - Restore ironclaw_env_path(), load_ironclaw_env(), save_database_url() - SecretsConfig::resolve() now auto-detects (env var then keychain probe) instead of reading a saved source from bootstrap.json - DatabaseConfig::resolve() reads DATABASE_URL from env only (dotenvy loads ~/.ironclaw/.env into the environment early in startup) - check_onboard_needed() is now sync (just checks env vars) - Wizard save_and_summarize() works for both postgres and libsql backends - One-time migration from bootstrap.json to .env preserved Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Ensure load_ironclaw_env() runs in all Config paths, fix .env priority - Config::from_env() and Config::from_db() now call load_ironclaw_env() internally (after dotenvy::dotenv()), so CLI commands like `memory` and `config` correctly load DATABASE_URL from ~/.ironclaw/.env - Fix load order: standard ./.env first (higher priority), then ~/.ironclaw/.env, matching the documented priority chain - Collapse nested if/if-let into let-chains (clippy::collapsible_if) in oauth_defaults.rs, tool.rs, and secrets/store.rs - Fix rename_to_migrated to take &Path instead of &PathBuf Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR review comments (quoting, SSRF, error mapping) - Quote DATABASE_URL in .env writes so `#` in passwords isn't treated as a dotenv comment (e.g., `DATABASE_URL="postgres://..."`) - Add SSRF defenses to refresh_oauth_token(): require HTTPS, reject private/loopback IPs (with DNS resolution), disable redirects. token_url comes from tool capabilities JSON, so a malicious tool could otherwise exfiltrate refresh tokens. - Fix IPv4 bind error mapping: only map AddrInUse to PortInUse, use generic Io variant for other bind failures Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
408ae8a29a
commit
a53b2c10b5
Generated
+3
-11
@@ -2210,11 +2210,11 @@ dependencies = [
|
||||
"hyper 1.8.1",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2548,6 +2548,7 @@ dependencies = [
|
||||
"tower-http 0.6.8",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"wasmparser 0.220.1",
|
||||
@@ -4033,6 +4034,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4050,7 +4052,6 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6237,15 +6238,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "4.4.2"
|
||||
|
||||
+3
-2
@@ -22,7 +22,7 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures = "0.3"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -84,7 +84,8 @@ fs4 = "0.6"
|
||||
# Secrecy for sensitive values
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
|
||||
# URL encoding for OAuth flow
|
||||
# URL parsing and encoding
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
|
||||
# Open URLs in browser
|
||||
|
||||
@@ -81,7 +81,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
let session = create_session_manager(SessionConfig {
|
||||
auth_base_url: config.llm.nearai.auth_base_url.clone(),
|
||||
session_path: config.llm.nearai.session_path.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let llm = create_llm_provider(&config.llm, session)?;
|
||||
|
||||
@@ -1236,7 +1236,7 @@ impl Agent {
|
||||
&message.channel,
|
||||
StatusUpdate::ToolResult {
|
||||
name: tc.name.clone(),
|
||||
preview: truncate_for_preview(output, 200),
|
||||
preview: output.clone(),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
@@ -1710,7 +1710,7 @@ impl Agent {
|
||||
&message.channel,
|
||||
StatusUpdate::ToolResult {
|
||||
name: pending.tool_name.clone(),
|
||||
preview: truncate_for_preview(output, 200),
|
||||
preview: output.clone(),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
|
||||
+219
-156
@@ -1,147 +1,128 @@
|
||||
//! Bootstrap configuration for IronClaw.
|
||||
//! Bootstrap helpers for IronClaw.
|
||||
//!
|
||||
//! These are the only settings that MUST live on disk because they're needed
|
||||
//! before the database connection is established. Everything else lives in the
|
||||
//! `settings` table in PostgreSQL.
|
||||
//! The only setting that truly needs disk persistence before the database is
|
||||
//! available is `DATABASE_URL` (chicken-and-egg: can't connect to DB without
|
||||
//! it). Everything else is auto-detected or read from env vars.
|
||||
//!
|
||||
//! File: `~/.ironclaw/bootstrap.json`
|
||||
//! File: `~/.ironclaw/.env` (standard dotenvy format)
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
/// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`.
|
||||
pub fn ironclaw_env_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join(".env")
|
||||
}
|
||||
|
||||
use crate::settings::KeySource;
|
||||
|
||||
/// Minimal config needed to connect to the database and decrypt secrets.
|
||||
/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`).
|
||||
///
|
||||
/// This is the only JSON file IronClaw reads from disk at startup.
|
||||
/// All other configuration lives in the `settings` table in PostgreSQL.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BootstrapConfig {
|
||||
/// Database connection URL (postgres://...).
|
||||
#[serde(default)]
|
||||
pub database_url: Option<String>,
|
||||
/// Call this **after** `dotenvy::dotenv()` so that the standard `./.env`
|
||||
/// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites
|
||||
/// existing env vars, so the effective priority is:
|
||||
///
|
||||
/// explicit env vars > `./.env` > `~/.ironclaw/.env`
|
||||
///
|
||||
/// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does,
|
||||
/// extracts `DATABASE_URL` from it and writes the `.env` file (one-time
|
||||
/// upgrade from the old config format).
|
||||
pub fn load_ironclaw_env() {
|
||||
let path = ironclaw_env_path();
|
||||
|
||||
/// Database connection pool size.
|
||||
#[serde(default)]
|
||||
pub database_pool_size: Option<usize>,
|
||||
|
||||
/// Source for the secrets master key.
|
||||
#[serde(default)]
|
||||
pub secrets_master_key_source: KeySource,
|
||||
|
||||
/// Whether onboarding wizard has been completed.
|
||||
#[serde(default)]
|
||||
pub onboard_completed: bool,
|
||||
if !path.exists() {
|
||||
// One-time upgrade: extract DATABASE_URL from legacy bootstrap.json
|
||||
migrate_bootstrap_json_to_env(&path);
|
||||
}
|
||||
|
||||
impl Default for BootstrapConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: None,
|
||||
database_pool_size: None,
|
||||
secrets_master_key_source: KeySource::None,
|
||||
onboard_completed: false,
|
||||
}
|
||||
if path.exists() {
|
||||
let _ = dotenvy::from_path(&path);
|
||||
}
|
||||
}
|
||||
|
||||
impl BootstrapConfig {
|
||||
/// Default bootstrap file path: `~/.ironclaw/bootstrap.json`.
|
||||
pub fn default_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("bootstrap.json")
|
||||
/// If `bootstrap.json` exists, pull `database_url` out of it and write `.env`.
|
||||
fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
|
||||
let ironclaw_dir = env_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."));
|
||||
let bootstrap_path = ironclaw_dir.join("bootstrap.json");
|
||||
|
||||
if !bootstrap_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
/// Legacy settings.json path (for migration detection).
|
||||
pub fn legacy_settings_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("settings.json")
|
||||
}
|
||||
let content = match std::fs::read_to_string(&bootstrap_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
/// Load from the default path, falling back to legacy settings.json,
|
||||
/// then to defaults if neither exists.
|
||||
pub fn load() -> Self {
|
||||
let bootstrap_path = Self::default_path();
|
||||
if bootstrap_path.exists() {
|
||||
return Self::load_from(&bootstrap_path);
|
||||
}
|
||||
// Minimal parse: just grab database_url from the JSON
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Fall back to legacy settings.json (extract just the 4 bootstrap fields)
|
||||
let legacy_path = Self::legacy_settings_path();
|
||||
if legacy_path.exists() {
|
||||
return Self::load_from_legacy(&legacy_path);
|
||||
if let Some(url) = parsed.get("database_url").and_then(|v| v.as_str()) {
|
||||
if let Some(parent) = env_path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
eprintln!("Warning: failed to create {}: {}", parent.display(), e);
|
||||
return;
|
||||
}
|
||||
|
||||
Self::default()
|
||||
if let Err(e) = std::fs::write(env_path, format!("DATABASE_URL=\"{}\"\n", url)) {
|
||||
eprintln!("Warning: failed to migrate bootstrap.json to .env: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Load from a specific path.
|
||||
pub fn load_from(path: &PathBuf) -> Self {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||
Err(_) => Self::default(),
|
||||
rename_to_migrated(&bootstrap_path);
|
||||
eprintln!(
|
||||
"Migrated DATABASE_URL from bootstrap.json to {}",
|
||||
env_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract bootstrap fields from a legacy settings.json.
|
||||
fn load_from_legacy(path: &PathBuf) -> Self {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(data) => {
|
||||
// The legacy Settings struct is a superset; serde will ignore extra fields.
|
||||
serde_json::from_str(&data).unwrap_or_default()
|
||||
}
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save to the default path.
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
self.save_to(&Self::default_path())
|
||||
}
|
||||
|
||||
/// Save to a specific path.
|
||||
pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> {
|
||||
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
|
||||
///
|
||||
/// Creates the parent directory if it doesn't exist.
|
||||
/// The value is double-quoted so that `#` (common in URL-encoded passwords)
|
||||
/// and other shell-special characters are preserved by dotenvy.
|
||||
pub fn save_database_url(url: &str) -> std::io::Result<()> {
|
||||
let path = ironclaw_env_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
|
||||
std::fs::write(path, json)
|
||||
}
|
||||
std::fs::write(&path, format!("DATABASE_URL=\"{}\"\n", url))
|
||||
}
|
||||
|
||||
/// One-time migration from disk config files to the database settings table.
|
||||
/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
|
||||
///
|
||||
/// On first boot after upgrade, checks if:
|
||||
/// 1. `~/.ironclaw/settings.json` exists
|
||||
/// 2. The DB settings table is empty for this user
|
||||
/// Only runs when a `settings.json` exists on disk AND the DB has no settings
|
||||
/// yet. After the wizard writes directly to the DB, this path is only hit by
|
||||
/// users upgrading from the old disk-only configuration.
|
||||
///
|
||||
/// If both conditions hold, migrates settings, MCP servers, and session data
|
||||
/// to the database, writes `bootstrap.json`, and renames old files to `.migrated`.
|
||||
/// After syncing, renames `settings.json` to `.migrated` so it won't trigger again.
|
||||
pub async fn migrate_disk_to_db(
|
||||
store: &dyn crate::db::Database,
|
||||
user_id: &str,
|
||||
) -> Result<(), MigrationError> {
|
||||
let legacy_settings_path = BootstrapConfig::legacy_settings_path();
|
||||
let ironclaw_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw");
|
||||
let legacy_settings_path = ironclaw_dir.join("settings.json");
|
||||
|
||||
if !legacy_settings_path.exists() {
|
||||
tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Only migrate if DB is empty for this user
|
||||
// If DB already has settings, this is not a first boot, the wizard already
|
||||
// wrote directly to the DB. Just clean up the stale file.
|
||||
let has_settings = store.has_settings(user_id).await.map_err(|e| {
|
||||
MigrationError::Database(format!("Failed to check existing settings: {}", e))
|
||||
})?;
|
||||
if has_settings {
|
||||
tracing::debug!(
|
||||
"DB already has settings for user '{}', skipping migration",
|
||||
user_id
|
||||
);
|
||||
tracing::info!("DB already has settings, renaming stale settings.json");
|
||||
rename_to_migrated(&legacy_settings_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -160,22 +141,14 @@ pub async fn migrate_disk_to_db(
|
||||
tracing::info!("Migrated {} settings to database", db_map.len());
|
||||
}
|
||||
|
||||
// 2. Write bootstrap.json with the 4 essential fields
|
||||
let bootstrap = BootstrapConfig {
|
||||
database_url: settings.database_url.clone(),
|
||||
database_pool_size: settings.database_pool_size,
|
||||
secrets_master_key_source: settings.secrets_master_key_source,
|
||||
onboard_completed: settings.onboard_completed,
|
||||
};
|
||||
bootstrap
|
||||
.save()
|
||||
.map_err(|e| MigrationError::Io(format!("Failed to write bootstrap.json: {}", e)))?;
|
||||
tracing::info!("Wrote bootstrap.json");
|
||||
// 2. Write DATABASE_URL to ~/.ironclaw/.env
|
||||
if let Some(ref url) = settings.database_url {
|
||||
save_database_url(url)
|
||||
.map_err(|e| MigrationError::Io(format!("Failed to write .env: {}", e)))?;
|
||||
tracing::info!("Wrote DATABASE_URL to {}", ironclaw_env_path().display());
|
||||
}
|
||||
|
||||
// 3. Migrate mcp-servers.json if it exists
|
||||
let ironclaw_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw");
|
||||
let mcp_path = ironclaw_dir.join("mcp-servers.json");
|
||||
if mcp_path.exists() {
|
||||
match std::fs::read_to_string(&mcp_path) {
|
||||
@@ -236,12 +209,19 @@ pub async fn migrate_disk_to_db(
|
||||
// 5. Rename settings.json to .migrated (don't delete, safety net)
|
||||
rename_to_migrated(&legacy_settings_path);
|
||||
|
||||
// 6. Clean up old bootstrap.json if it exists (superseded by .env)
|
||||
let old_bootstrap = ironclaw_dir.join("bootstrap.json");
|
||||
if old_bootstrap.exists() {
|
||||
rename_to_migrated(&old_bootstrap);
|
||||
tracing::info!("Renamed old bootstrap.json to .migrated");
|
||||
}
|
||||
|
||||
tracing::info!("Disk-to-DB migration complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a file to `<name>.migrated` as a safety net.
|
||||
fn rename_to_migrated(path: &PathBuf) {
|
||||
fn rename_to_migrated(path: &std::path::Path) {
|
||||
let mut migrated = path.as_os_str().to_owned();
|
||||
migrated.push(".migrated");
|
||||
if let Err(e) = std::fs::rename(path, &migrated) {
|
||||
@@ -264,62 +244,145 @@ mod tests {
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_bootstrap_save_load() {
|
||||
fn test_save_and_load_database_url() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("bootstrap.json");
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
let config = BootstrapConfig {
|
||||
database_url: Some("postgres://localhost/test".to_string()),
|
||||
database_pool_size: Some(5),
|
||||
secrets_master_key_source: KeySource::Keychain,
|
||||
onboard_completed: true,
|
||||
};
|
||||
// Write in the quoted format that save_database_url uses
|
||||
let url = "postgres://localhost:5432/ironclaw_test";
|
||||
std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();
|
||||
|
||||
config.save_to(&path).unwrap();
|
||||
|
||||
let loaded = BootstrapConfig::load_from(&path);
|
||||
// Verify the content is a valid dotenv line (quoted)
|
||||
let content = std::fs::read_to_string(&env_path).unwrap();
|
||||
assert_eq!(
|
||||
loaded.database_url,
|
||||
Some("postgres://localhost/test".to_string())
|
||||
content,
|
||||
"DATABASE_URL=\"postgres://localhost:5432/ironclaw_test\"\n"
|
||||
);
|
||||
assert_eq!(loaded.database_pool_size, Some(5));
|
||||
assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain);
|
||||
assert!(loaded.onboard_completed);
|
||||
|
||||
// Verify dotenvy can parse it (strips quotes automatically)
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].0, "DATABASE_URL");
|
||||
assert_eq!(parsed[0].1, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bootstrap_from_legacy_settings() {
|
||||
fn test_save_database_url_with_hash_in_password() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// Write a legacy settings.json with many extra fields
|
||||
let legacy = serde_json::json!({
|
||||
"database_url": "postgres://localhost/ironclaw",
|
||||
"database_pool_size": 10,
|
||||
// URLs with # in the password are common (URL-encoded special chars).
|
||||
// Without quoting, dotenvy treats # as a comment delimiter.
|
||||
let url = "postgres://user:p%23ss@localhost:5432/ironclaw";
|
||||
std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].0, "DATABASE_URL");
|
||||
assert_eq!(parsed[0].1, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_database_url_creates_parent_dirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let nested = dir.path().join("deep").join("nested");
|
||||
let env_path = nested.join(".env");
|
||||
|
||||
// Parent doesn't exist yet
|
||||
assert!(!nested.exists());
|
||||
|
||||
// The global function uses a fixed path, so we test the logic directly
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(&env_path, "DATABASE_URL=postgres://test\n").unwrap();
|
||||
|
||||
assert!(env_path.exists());
|
||||
let content = std::fs::read_to_string(&env_path).unwrap();
|
||||
assert!(content.contains("DATABASE_URL=postgres://test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ironclaw_env_path() {
|
||||
let path = ironclaw_env_path();
|
||||
assert!(path.ends_with(".ironclaw/.env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_bootstrap_json_to_env() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
let bootstrap_path = dir.path().join("bootstrap.json");
|
||||
|
||||
// Write a legacy bootstrap.json
|
||||
let bootstrap_json = serde_json::json!({
|
||||
"database_url": "postgres://localhost/ironclaw_upgrade",
|
||||
"database_pool_size": 5,
|
||||
"secrets_master_key_source": "keychain",
|
||||
"onboard_completed": true,
|
||||
"selected_model": "claude-3-5-sonnet",
|
||||
"agent": { "name": "testbot", "max_parallel_jobs": 3 },
|
||||
"heartbeat": { "enabled": true }
|
||||
"onboard_completed": true
|
||||
});
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&bootstrap_path,
|
||||
serde_json::to_string_pretty(&bootstrap_json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = BootstrapConfig::load_from_legacy(&path);
|
||||
assert!(!env_path.exists());
|
||||
assert!(bootstrap_path.exists());
|
||||
|
||||
// Run the migration
|
||||
migrate_bootstrap_json_to_env(&env_path);
|
||||
|
||||
// .env should now exist with DATABASE_URL
|
||||
assert!(env_path.exists());
|
||||
let content = std::fs::read_to_string(&env_path).unwrap();
|
||||
assert_eq!(
|
||||
config.database_url,
|
||||
Some("postgres://localhost/ironclaw".to_string())
|
||||
content,
|
||||
"DATABASE_URL=\"postgres://localhost/ironclaw_upgrade\"\n"
|
||||
);
|
||||
assert_eq!(config.database_pool_size, Some(10));
|
||||
assert_eq!(config.secrets_master_key_source, KeySource::Keychain);
|
||||
assert!(config.onboard_completed);
|
||||
|
||||
// bootstrap.json should be renamed to .migrated
|
||||
assert!(!bootstrap_path.exists());
|
||||
assert!(dir.path().join("bootstrap.json.migrated").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bootstrap_defaults() {
|
||||
let config = BootstrapConfig::default();
|
||||
assert!(config.database_url.is_none());
|
||||
assert!(config.database_pool_size.is_none());
|
||||
assert_eq!(config.secrets_master_key_source, KeySource::None);
|
||||
assert!(!config.onboard_completed);
|
||||
fn test_migrate_bootstrap_json_no_database_url() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
let bootstrap_path = dir.path().join("bootstrap.json");
|
||||
|
||||
// bootstrap.json with no database_url
|
||||
let bootstrap_json = serde_json::json!({
|
||||
"onboard_completed": false
|
||||
});
|
||||
std::fs::write(
|
||||
&bootstrap_path,
|
||||
serde_json::to_string_pretty(&bootstrap_json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_bootstrap_json_to_env(&env_path);
|
||||
|
||||
// .env should NOT be created
|
||||
assert!(!env_path.exists());
|
||||
// bootstrap.json should remain (no migration happened)
|
||||
assert!(bootstrap_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_bootstrap_json_missing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// No bootstrap.json at all
|
||||
migrate_bootstrap_json_to_env(&env_path);
|
||||
|
||||
// Nothing should happen
|
||||
assert!(!env_path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ use crate::agent::truncate_for_preview;
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
/// Max characters for tool result previews in the terminal.
|
||||
const CLI_TOOL_RESULT_MAX: usize = 200;
|
||||
|
||||
/// Max characters for thinking/status messages in the terminal.
|
||||
const CLI_STATUS_MAX: usize = 200;
|
||||
|
||||
@@ -265,7 +268,7 @@ impl Channel for ReplChannel {
|
||||
std::thread::spawn(move || {
|
||||
// Single message mode: send it and return
|
||||
if let Some(msg) = single_message {
|
||||
let incoming = IncomingMessage::new("repl", "user", &msg);
|
||||
let incoming = IncomingMessage::new("repl", "default", &msg);
|
||||
let _ = tx.blocking_send(incoming);
|
||||
return;
|
||||
}
|
||||
@@ -333,21 +336,21 @@ impl Channel for ReplChannel {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let msg = IncomingMessage::new("repl", "user", line);
|
||||
let msg = IncomingMessage::new("repl", "default", line);
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
// Ctrl+C: send /interrupt
|
||||
let msg = IncomingMessage::new("repl", "user", "/interrupt");
|
||||
let msg = IncomingMessage::new("repl", "default", "/interrupt");
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Eof) => {
|
||||
// Ctrl+D: send /quit so the agent loop runs graceful shutdown
|
||||
let msg = IncomingMessage::new("repl", "user", "/quit");
|
||||
let msg = IncomingMessage::new("repl", "default", "/quit");
|
||||
let _ = tx.blocking_send(msg);
|
||||
break;
|
||||
}
|
||||
@@ -418,7 +421,8 @@ impl Channel for ReplChannel {
|
||||
}
|
||||
}
|
||||
StatusUpdate::ToolResult { name: _, preview } => {
|
||||
eprintln!(" \x1b[90m{preview}\x1b[0m");
|
||||
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
|
||||
eprintln!(" \x1b[90m{display}\x1b[0m");
|
||||
}
|
||||
StatusUpdate::StreamChunk(chunk) => {
|
||||
// Print separator on the false-to-true transition
|
||||
|
||||
@@ -76,6 +76,9 @@ struct ChannelStoreData {
|
||||
credentials: HashMap<String, String>,
|
||||
/// Pairing store for DM pairing (guest access control).
|
||||
pairing_store: Arc<PairingStore>,
|
||||
/// Dedicated tokio runtime for HTTP requests, lazily initialized.
|
||||
/// Reused across multiple `http_request` calls within one execution.
|
||||
http_runtime: Option<tokio::runtime::Runtime>,
|
||||
}
|
||||
|
||||
impl ChannelStoreData {
|
||||
@@ -96,6 +99,7 @@ impl ChannelStoreData {
|
||||
table: ResourceTable::new(),
|
||||
credentials,
|
||||
pairing_store,
|
||||
http_runtime: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,10 +287,25 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
.map(|h| h.max_response_bytes)
|
||||
.unwrap_or(10 * 1024 * 1024);
|
||||
|
||||
// Make the HTTP request using blocking I/O
|
||||
// We're already in a spawn_blocking context, so we can use block_on
|
||||
let result = tokio::runtime::Handle::current().block_on(async {
|
||||
let client = reqwest::Client::new();
|
||||
// Make the HTTP request using a dedicated single-threaded runtime.
|
||||
// We're inside spawn_blocking, so we can't rely on the main runtime's
|
||||
// I/O driver (it may be busy with WASM compilation or other startup work).
|
||||
// A dedicated runtime gives us our own I/O driver and avoids contention.
|
||||
// The runtime is lazily created and reused across calls within one execution.
|
||||
if self.http_runtime.is_none() {
|
||||
self.http_runtime = Some(
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP runtime: {e}"))?,
|
||||
);
|
||||
}
|
||||
let rt = self.http_runtime.as_ref().expect("just initialized");
|
||||
let result = rt.block_on(async {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
let mut request = match method.to_uppercase().as_str() {
|
||||
"GET" => client.get(&url),
|
||||
@@ -308,9 +327,9 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
request = request.body(body_bytes);
|
||||
}
|
||||
|
||||
// Send request with caller-specified timeout (default 30s).
|
||||
// Cap at callback_timeout to prevent outliving the host wrapper.
|
||||
let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64);
|
||||
// Send request with caller-specified timeout (default 30s, max 5min).
|
||||
let timeout_ms = timeout_ms.unwrap_or(30_000).min(300_000) as u64;
|
||||
let timeout = std::time::Duration::from_millis(timeout_ms);
|
||||
let response = request.timeout(timeout).send().await.map_err(|e| {
|
||||
// Walk the full error chain so we get the actual root cause
|
||||
// (DNS, TLS, connection refused, etc.) instead of just
|
||||
@@ -795,7 +814,21 @@ impl WasmChannel {
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok((config, _host_state))) => {
|
||||
Ok(Ok((config, mut host_state))) => {
|
||||
// Surface WASM guest logs (errors/warnings from webhook setup, etc.)
|
||||
for entry in host_state.take_logs() {
|
||||
match entry.level {
|
||||
crate::tools::wasm::LogLevel::Error => {
|
||||
tracing::error!(channel = %self.name, "{}", entry.message);
|
||||
}
|
||||
crate::tools::wasm::LogLevel::Warn => {
|
||||
tracing::warn!(channel = %self.name, "{}", entry.message);
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!(channel = %self.name, "{}", entry.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
channel = %self.name,
|
||||
display_name = %config.display_name,
|
||||
@@ -2615,15 +2648,52 @@ mod tests {
|
||||
assert_eq!(store.redact_credentials(input), input);
|
||||
}
|
||||
|
||||
/// Verify that the block_on-inside-spawn_blocking pattern used by the WASM
|
||||
/// channel HTTP host function doesn't deadlock or panic.
|
||||
/// Verify that WASM HTTP host functions work using a dedicated
|
||||
/// current-thread runtime inside spawn_blocking.
|
||||
#[tokio::test]
|
||||
async fn test_block_on_inside_spawn_blocking_does_not_deadlock() {
|
||||
async fn test_dedicated_runtime_inside_spawn_blocking() {
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
tokio::runtime::Handle::current().block_on(async { 42 })
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build runtime");
|
||||
rt.block_on(async { 42 })
|
||||
})
|
||||
.await
|
||||
.expect("spawn_blocking panicked");
|
||||
assert_eq!(result, 42);
|
||||
}
|
||||
|
||||
/// Verify a real HTTP request works using the dedicated-runtime pattern.
|
||||
/// This catches DNS, TLS, and I/O driver issues that trivial tests miss.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires network
|
||||
async fn test_dedicated_runtime_real_http() {
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build runtime");
|
||||
rt.block_on(async {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("failed to build client");
|
||||
let resp = client
|
||||
.get("https://api.telegram.org/bot000/getMe")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
match resp {
|
||||
Ok(r) => r.status().as_u16(),
|
||||
Err(e) if e.is_timeout() => panic!("request timed out: {e}"),
|
||||
Err(e) => panic!("unexpected error: {e}"),
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.expect("spawn_blocking panicked");
|
||||
// 404 because "000" is not a valid bot token
|
||||
assert_eq!(result, 404);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-44
@@ -48,8 +48,6 @@ pub enum ConfigCommand {
|
||||
/// Connects to the database to read/write settings. Falls back to disk
|
||||
/// if the database is not available.
|
||||
pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
// Try to connect to the DB for settings access
|
||||
let db: Option<Arc<dyn crate::db::Database>> = match connect_db().await {
|
||||
Ok(d) => Some(d),
|
||||
@@ -92,7 +90,7 @@ async fn load_settings(store: Option<&dyn crate::db::Database>) -> Settings {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Settings::load()
|
||||
Settings::default()
|
||||
}
|
||||
|
||||
/// List all settings.
|
||||
@@ -155,8 +153,9 @@ async fn set_setting(
|
||||
.set(path, value)
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
// Save to DB if available, otherwise disk
|
||||
if let Some(store) = store {
|
||||
let store = store.ok_or_else(|| {
|
||||
anyhow::anyhow!("Database connection required to save settings. Check DATABASE_URL.")
|
||||
})?;
|
||||
let json_value = match serde_json::from_str::<serde_json::Value>(value) {
|
||||
Ok(v) => v,
|
||||
Err(_) => serde_json::Value::String(value.to_string()),
|
||||
@@ -165,9 +164,6 @@ async fn set_setting(
|
||||
.set_setting(DEFAULT_USER_ID, path, &json_value)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save to database: {}", e))?;
|
||||
} else {
|
||||
settings.save()?;
|
||||
}
|
||||
|
||||
println!("Set {} = {}", path, value);
|
||||
Ok(())
|
||||
@@ -180,17 +176,13 @@ async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> a
|
||||
.get(path)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?;
|
||||
|
||||
// Delete from DB (falling back to default) or reset on disk
|
||||
if let Some(store) = store {
|
||||
let store = store.ok_or_else(|| {
|
||||
anyhow::anyhow!("Database connection required to reset settings. Check DATABASE_URL.")
|
||||
})?;
|
||||
store
|
||||
.delete_setting(DEFAULT_USER_ID, path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?;
|
||||
} else {
|
||||
let mut settings = Settings::load();
|
||||
settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
settings.save()?;
|
||||
}
|
||||
|
||||
println!("Reset {} to default: {}", path, default_value);
|
||||
Ok(())
|
||||
@@ -200,37 +192,13 @@ async fn reset_setting(store: Option<&dyn crate::db::Database>, path: &str) -> a
|
||||
fn show_path(has_db: bool) -> anyhow::Result<()> {
|
||||
if has_db {
|
||||
println!("Settings stored in: database (settings table)");
|
||||
} else {
|
||||
println!("Settings stored in: PostgreSQL (not connected, using defaults)");
|
||||
}
|
||||
println!(
|
||||
"Bootstrap config: {}",
|
||||
crate::bootstrap::BootstrapConfig::default_path().display()
|
||||
"Env config: {}",
|
||||
crate::bootstrap::ironclaw_env_path().display()
|
||||
);
|
||||
} else {
|
||||
let path = Settings::default_path();
|
||||
println!("Settings stored in: {} (disk fallback)", path.display());
|
||||
|
||||
if path.exists() {
|
||||
let metadata = std::fs::metadata(&path)?;
|
||||
println!(" Size: {} bytes", metadata.len());
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
use std::time::SystemTime;
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or_default();
|
||||
let secs = duration.as_secs();
|
||||
if secs < 60 {
|
||||
println!(" Modified: {} seconds ago", secs);
|
||||
} else if secs < 3600 {
|
||||
println!(" Modified: {} minutes ago", secs / 60);
|
||||
} else if secs < 86400 {
|
||||
println!(" Modified: {} hours ago", secs / 3600);
|
||||
} else {
|
||||
println!(" Modified: {} days ago", secs / 86400);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" (does not exist, using defaults)");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
mod config;
|
||||
mod mcp;
|
||||
pub mod memory;
|
||||
pub mod oauth_defaults;
|
||||
mod pairing;
|
||||
pub mod status;
|
||||
mod tool;
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Shared OAuth infrastructure: built-in credentials, callback server, landing pages.
|
||||
//!
|
||||
//! Every OAuth flow in the codebase (WASM tool auth, MCP server auth, NEAR AI login)
|
||||
//! uses the same callback port, landing page, and listener logic from this module.
|
||||
//!
|
||||
//! # Built-in Credentials
|
||||
//!
|
||||
//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials
|
||||
//! so users don't need to register their own OAuth app. Google explicitly
|
||||
//! documents that client_secret for "Desktop App" / "Installed App" types
|
||||
//! is NOT actually secret.
|
||||
//!
|
||||
//! Default credentials are hardcoded below. They can be overridden at:
|
||||
//!
|
||||
//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
|
||||
//! env vars before building to replace the hardcoded defaults.
|
||||
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
|
||||
//! env vars, which take priority over built-in defaults.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
// ── Built-in credentials ────────────────────────────────────────────────
|
||||
|
||||
pub struct OAuthCredentials {
|
||||
pub client_id: &'static str,
|
||||
pub client_secret: &'static str,
|
||||
}
|
||||
|
||||
/// Google OAuth "Desktop App" credentials, shared across all Google tools.
|
||||
/// Compile-time env vars override the hardcoded defaults below.
|
||||
const GOOGLE_CLIENT_ID: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_ID") {
|
||||
Some(v) => v,
|
||||
None => "564604149681-efo25d43rs85v0tibdepsmdv5dsrhhr0.apps.googleusercontent.com",
|
||||
};
|
||||
const GOOGLE_CLIENT_SECRET: &str = match option_env!("IRONCLAW_GOOGLE_CLIENT_SECRET") {
|
||||
Some(v) => v,
|
||||
None => "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",
|
||||
};
|
||||
|
||||
/// Returns built-in OAuth credentials for a provider, keyed by secret_name.
|
||||
///
|
||||
/// The secret_name comes from the tool's capabilities.json `auth.secret_name` field.
|
||||
/// Returns `None` if no built-in credentials are configured for that provider.
|
||||
pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
|
||||
match secret_name {
|
||||
"google_oauth_token" => Some(OAuthCredentials {
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared callback server ──────────────────────────────────────────────
|
||||
|
||||
/// Fixed port for all OAuth callbacks.
|
||||
///
|
||||
/// Every redirect URI registered with providers must use this port:
|
||||
/// `http://localhost:9876/callback` (or `/auth/callback` for NEAR AI).
|
||||
pub const OAUTH_CALLBACK_PORT: u16 = 9876;
|
||||
|
||||
/// Error from the OAuth callback listener.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OAuthCallbackError {
|
||||
#[error("Port {0} is in use (another auth flow running?): {1}")]
|
||||
PortInUse(u16, String),
|
||||
|
||||
#[error("Authorization denied by user")]
|
||||
Denied,
|
||||
|
||||
#[error("Timed out waiting for authorization")]
|
||||
Timeout,
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Bind the OAuth callback listener on the fixed port.
|
||||
///
|
||||
/// Tries IPv6 loopback (`[::1]`) first so that `http://localhost:…` redirects
|
||||
/// work on systems where `localhost` resolves to `::1`. Falls back to IPv4
|
||||
/// (`127.0.0.1`) only if IPv6 fails for a reason other than `AddrInUse`
|
||||
/// (e.g., IPv6 not supported on the host). If the port is already occupied
|
||||
/// on IPv6, the port is occupied period, so we fail immediately.
|
||||
pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError> {
|
||||
let ipv6_addr = format!("[::1]:{}", OAUTH_CALLBACK_PORT);
|
||||
match TcpListener::bind(&ipv6_addr).await {
|
||||
Ok(listener) => return Ok(listener),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
|
||||
return Err(OAuthCallbackError::PortInUse(
|
||||
OAUTH_CALLBACK_PORT,
|
||||
e.to_string(),
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
// IPv6 not available on this host, fall back to IPv4
|
||||
}
|
||||
}
|
||||
TcpListener::bind(format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||
OAuthCallbackError::PortInUse(OAUTH_CALLBACK_PORT, e.to_string())
|
||||
} else {
|
||||
OAuthCallbackError::Io(e.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait for an OAuth callback and extract a query parameter value.
|
||||
///
|
||||
/// Listens for a GET request matching `path_prefix` (e.g., "/callback" or "/auth/callback"),
|
||||
/// extracts the value of `param_name` (e.g., "code" or "token"), and shows a branded
|
||||
/// landing page using `display_name` (e.g., "Google", "Notion", "NEAR AI").
|
||||
///
|
||||
/// Times out after 5 minutes.
|
||||
pub async fn wait_for_callback(
|
||||
listener: TcpListener,
|
||||
path_prefix: &str,
|
||||
param_name: &str,
|
||||
display_name: &str,
|
||||
) -> Result<String, OAuthCallbackError> {
|
||||
let path_prefix = path_prefix.to_string();
|
||||
let param_name = param_name.to_string();
|
||||
let display_name = display_name.to_string();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(300), async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(e.to_string()))?;
|
||||
|
||||
let mut reader = BufReader::new(&mut socket);
|
||||
let mut request_line = String::new();
|
||||
reader
|
||||
.read_line(&mut request_line)
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(e.to_string()))?;
|
||||
|
||||
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||
&& path.starts_with(&path_prefix)
|
||||
&& let Some(query) = path.split('?').nth(1)
|
||||
{
|
||||
// Check for error first
|
||||
if query.contains("error=") {
|
||||
let html = landing_html(&display_name, false);
|
||||
let response = format!(
|
||||
"HTTP/1.1 400 Bad Request\r\n\
|
||||
Content-Type: text/html; charset=utf-8\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n\
|
||||
{}",
|
||||
html
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
return Err(OAuthCallbackError::Denied);
|
||||
}
|
||||
|
||||
// Look for the target parameter
|
||||
for param in query.split('&') {
|
||||
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
||||
if parts.len() == 2 && parts[0] == param_name {
|
||||
let value = urlencoding::decode(parts[1])
|
||||
.unwrap_or_else(|_| parts[1].into())
|
||||
.into_owned();
|
||||
|
||||
let html = landing_html(&display_name, true);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: text/html; charset=utf-8\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n\
|
||||
{}",
|
||||
html
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not the callback we're looking for
|
||||
let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| OAuthCallbackError::Timeout)?
|
||||
}
|
||||
|
||||
/// Escape a string for safe interpolation into HTML content.
|
||||
fn html_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// HTML landing page shown in the browser after an OAuth redirect.
|
||||
pub fn landing_html(provider_name: &str, success: bool) -> String {
|
||||
let safe_name = html_escape(provider_name);
|
||||
let (icon, heading, subtitle, accent) = if success {
|
||||
(
|
||||
r##"<div style="width:64px;height:64px;border-radius:50%;background:#22c55e;display:flex;align-items:center;justify-content:center;margin:0 auto 24px">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>"##,
|
||||
format!("{} Connected", safe_name),
|
||||
"You can close this window and return to your terminal.",
|
||||
"#22c55e",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
r##"<div style="width:64px;height:64px;border-radius:50%;background:#ef4444;display:flex;align-items:center;justify-content:center;margin:0 auto 24px">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</div>"##,
|
||||
"Authorization Failed".to_string(),
|
||||
"The request was denied. You can close this window and try again.",
|
||||
"#ef4444",
|
||||
)
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>IronClaw - {heading}</title>
|
||||
<style>
|
||||
* {{ margin:0; padding:0; box-sizing:border-box }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}}
|
||||
.card {{
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
max-width: 420px;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
background: #141414;
|
||||
}}
|
||||
h1 {{
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #fafafa;
|
||||
}}
|
||||
p {{
|
||||
font-size: 14px;
|
||||
color: #a3a3a3;
|
||||
line-height: 1.5;
|
||||
}}
|
||||
.accent {{ color: {accent}; }}
|
||||
.brand {{
|
||||
margin-top: 32px;
|
||||
font-size: 12px;
|
||||
color: #525252;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{icon}
|
||||
<h1>{heading}</h1>
|
||||
<p>{subtitle}</p>
|
||||
<div class="brand">IronClaw</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
heading = heading,
|
||||
icon = icon,
|
||||
subtitle = subtitle,
|
||||
accent = accent,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::cli::oauth_defaults::{builtin_credentials, landing_html};
|
||||
|
||||
#[test]
|
||||
fn test_unknown_provider_returns_none() {
|
||||
assert!(builtin_credentials("unknown_token").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_google_returns_based_on_compile_env() {
|
||||
let creds = builtin_credentials("google_oauth_token");
|
||||
assert!(creds.is_some());
|
||||
let creds = creds.unwrap();
|
||||
assert!(!creds.client_id.is_empty());
|
||||
assert!(!creds.client_secret.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_landing_html_success_contains_key_elements() {
|
||||
let html = landing_html("Google", true);
|
||||
assert!(html.contains("Google Connected"));
|
||||
assert!(html.contains("charset"));
|
||||
assert!(html.contains("IronClaw"));
|
||||
assert!(html.contains("#22c55e")); // green accent
|
||||
assert!(!html.contains("Failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_landing_html_escapes_provider_name() {
|
||||
let html = landing_html("<script>alert(1)</script>", true);
|
||||
assert!(!html.contains("<script>"));
|
||||
assert!(html.contains("<script>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_landing_html_error_contains_key_elements() {
|
||||
let html = landing_html("Notion", false);
|
||||
assert!(html.contains("Authorization Failed"));
|
||||
assert!(html.contains("charset"));
|
||||
assert!(html.contains("IronClaw"));
|
||||
assert!(html.contains("#ef4444")); // red accent
|
||||
assert!(!html.contains("Connected"));
|
||||
}
|
||||
}
|
||||
+15
-17
@@ -9,7 +9,7 @@ use crate::settings::Settings;
|
||||
|
||||
/// Run the status command, printing system health info.
|
||||
pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
let settings = Settings::load();
|
||||
let settings = Settings::default();
|
||||
|
||||
println!("IronClaw Status");
|
||||
println!("===============\n");
|
||||
@@ -22,10 +22,9 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// Database
|
||||
let db_url_set = settings.database_url.is_some() || std::env::var("DATABASE_URL").is_ok();
|
||||
let db_url_set = std::env::var("DATABASE_URL").is_ok();
|
||||
print!(" Database: ");
|
||||
if db_url_set {
|
||||
// Try to connect
|
||||
match check_database().await {
|
||||
Ok(()) => println!("connected"),
|
||||
Err(e) => println!("error ({})", e),
|
||||
@@ -43,13 +42,14 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
println!("not found (run `ironclaw onboard`)");
|
||||
}
|
||||
|
||||
// Secrets
|
||||
// Secrets (auto-detect: env var or keychain)
|
||||
print!(" Secrets: ");
|
||||
let secrets_configured = settings.secrets_master_key_source != crate::settings::KeySource::None
|
||||
|| std::env::var("SECRETS_MASTER_KEY").is_ok()
|
||||
|| crate::secrets::keychain::has_master_key().await;
|
||||
if secrets_configured {
|
||||
println!("configured ({:?})", settings.secrets_master_key_source);
|
||||
let has_env_key = std::env::var("SECRETS_MASTER_KEY").is_ok();
|
||||
let has_keychain = crate::secrets::keychain::has_master_key().await;
|
||||
if has_env_key {
|
||||
println!("configured (env)");
|
||||
} else if has_keychain {
|
||||
println!("configured (keychain)");
|
||||
} else {
|
||||
println!("not configured");
|
||||
}
|
||||
@@ -129,20 +129,18 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
Err(_) => println!("none configured"),
|
||||
}
|
||||
|
||||
// Settings path
|
||||
println!("\n Settings: {}", Settings::default_path().display());
|
||||
// Config path
|
||||
println!(
|
||||
"\n Config: {}",
|
||||
crate::bootstrap::ironclaw_env_path().display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
async fn check_database() -> anyhow::Result<()> {
|
||||
let _ = dotenvy::dotenv();
|
||||
let settings = Settings::load();
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.ok()
|
||||
.or(settings.database_url)
|
||||
.ok_or_else(|| anyhow::anyhow!("no URL"))?;
|
||||
let url = std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL not set"))?;
|
||||
|
||||
let config: deadpool_postgres::Config = deadpool_postgres::Config {
|
||||
url: Some(url),
|
||||
|
||||
+112
-87
@@ -829,20 +829,73 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
}
|
||||
|
||||
// Save the token
|
||||
save_token(secrets_store.as_ref(), &user_id, &auth, &token).await?;
|
||||
save_token(secrets_store.as_ref(), &user_id, &auth, &token, None, None).await?;
|
||||
print_success(display_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check for OAuth configuration
|
||||
if let Some(ref oauth) = auth.oauth {
|
||||
return auth_tool_oauth(secrets_store.as_ref(), &user_id, &auth, oauth).await;
|
||||
// For providers with shared tokens (e.g., all Google tools share google_oauth_token),
|
||||
// combine scopes from all installed tools so one auth covers everything.
|
||||
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
|
||||
if combined.scopes.len() > oauth.scopes.len() {
|
||||
let extra = combined.scopes.len() - oauth.scopes.len();
|
||||
println!(
|
||||
" Including scopes from {} other installed tool(s) sharing this credential.",
|
||||
extra
|
||||
);
|
||||
println!();
|
||||
}
|
||||
return auth_tool_oauth(secrets_store.as_ref(), &user_id, &auth, &combined).await;
|
||||
}
|
||||
|
||||
// Fall back to manual entry
|
||||
auth_tool_manual(secrets_store.as_ref(), &user_id, &auth).await
|
||||
}
|
||||
|
||||
/// Scan the tools directory for all capabilities files sharing the same secret_name
|
||||
/// and combine their OAuth scopes. This way, authing any Google tool requests scopes
|
||||
/// for ALL installed Google tools, so one login covers everything.
|
||||
async fn combine_provider_scopes(
|
||||
tools_dir: &Path,
|
||||
secret_name: &str,
|
||||
base_oauth: &crate::tools::wasm::OAuthConfigSchema,
|
||||
) -> crate::tools::wasm::OAuthConfigSchema {
|
||||
let mut all_scopes: std::collections::HashSet<String> =
|
||||
base_oauth.scopes.iter().cloned().collect();
|
||||
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(tools_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or_default();
|
||||
if !name.ends_with(".capabilities.json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(content) = tokio::fs::read_to_string(&path).await
|
||||
&& let Ok(caps) = CapabilitiesFile::from_json(&content)
|
||||
&& let Some(auth) = &caps.auth
|
||||
&& auth.secret_name == secret_name
|
||||
&& let Some(oauth) = &auth.oauth
|
||||
{
|
||||
all_scopes.extend(oauth.scopes.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut combined = base_oauth.clone();
|
||||
combined.scopes = all_scopes.into_iter().collect();
|
||||
combined.scopes.sort(); // deterministic ordering
|
||||
combined
|
||||
}
|
||||
|
||||
/// OAuth browser-based login flow.
|
||||
async fn auth_tool_oauth(
|
||||
store: &(dyn SecretsStore + Send + Sync),
|
||||
@@ -853,12 +906,14 @@ async fn auth_tool_oauth(
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
|
||||
|
||||
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
|
||||
|
||||
// Get client_id from config or env
|
||||
// Get client_id: capabilities file > runtime env var > built-in defaults
|
||||
let builtin = oauth_defaults::builtin_credentials(&auth.secret_name);
|
||||
|
||||
let client_id = oauth
|
||||
.client_id
|
||||
.clone()
|
||||
@@ -868,41 +923,32 @@ async fn auth_tool_oauth(
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
})
|
||||
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"OAuth client_id not configured.\n\
|
||||
Set it in the capabilities file or via environment variable."
|
||||
Set {} env var, or build with IRONCLAW_GOOGLE_CLIENT_ID.",
|
||||
oauth.client_id_env.as_deref().unwrap_or("the client_id")
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get client_secret if provided
|
||||
let client_secret = oauth.client_secret.clone().or_else(|| {
|
||||
// Get client_secret: capabilities file > runtime env var > built-in defaults
|
||||
let client_secret = oauth
|
||||
.client_secret
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
oauth
|
||||
.client_secret_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
});
|
||||
})
|
||||
.or_else(|| builtin.as_ref().map(|c| c.client_secret.to_string()));
|
||||
|
||||
println!(" Starting OAuth authentication...");
|
||||
println!();
|
||||
|
||||
// Find an available port for the callback
|
||||
let mut listener = None;
|
||||
let mut port = 0;
|
||||
|
||||
for p in 9876..=9886 {
|
||||
match TcpListener::bind(format!("127.0.0.1:{}", p)).await {
|
||||
Ok(l) => {
|
||||
listener = Some(l);
|
||||
port = p;
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let listener = listener.ok_or_else(|| anyhow::anyhow!("Could not find available port"))?;
|
||||
let redirect_uri = format!("http://localhost:{}/callback", port);
|
||||
let listener = oauth_defaults::bind_callback_listener().await?;
|
||||
let redirect_uri = format!("http://localhost:{}/callback", OAUTH_CALLBACK_PORT);
|
||||
|
||||
// Generate PKCE verifier and challenge
|
||||
let (code_verifier, code_challenge) = if oauth.use_pkce {
|
||||
@@ -961,63 +1007,8 @@ async fn auth_tool_oauth(
|
||||
|
||||
println!(" Waiting for authorization...");
|
||||
|
||||
// Wait for callback with timeout
|
||||
let timeout = std::time::Duration::from_secs(300);
|
||||
let code = tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await?;
|
||||
|
||||
let mut reader = BufReader::new(&mut socket);
|
||||
let mut request_line = String::new();
|
||||
reader.read_line(&mut request_line).await?;
|
||||
|
||||
// Parse GET /callback?code=xxx HTTP/1.1
|
||||
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||
&& path.starts_with("/callback")
|
||||
&& let Some(query) = path.split('?').nth(1) {
|
||||
for param in query.split('&') {
|
||||
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
||||
if parts.len() == 2 && parts[0] == "code" {
|
||||
let code = urlencoding::decode(parts[1])
|
||||
.unwrap_or_else(|_| parts[1].into())
|
||||
.into_owned();
|
||||
|
||||
// Send success response
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: text/html\r\n\
|
||||
\r\n\
|
||||
<!DOCTYPE html><html><body style=\"font-family: sans-serif; \
|
||||
display: flex; justify-content: center; align-items: center; \
|
||||
height: 100vh; margin: 0; background: #191919; color: white;\">\
|
||||
<div style=\"text-align: center;\">\
|
||||
<h1>✓ {} Connected!</h1>\
|
||||
<p>You can close this window.</p>\
|
||||
</div></body></html>",
|
||||
display_name
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
|
||||
return Ok::<_, anyhow::Error>(code);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for error
|
||||
if query.contains("error=") {
|
||||
let response =
|
||||
"HTTP/1.1 400 Bad Request\r\n\r\nAuthorization denied";
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
return Err(anyhow::anyhow!("Authorization denied by user"));
|
||||
}
|
||||
}
|
||||
|
||||
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Timed out waiting for authorization"))??;
|
||||
let code =
|
||||
oauth_defaults::wait_for_callback(listener, "/callback", "code", display_name).await?;
|
||||
|
||||
println!();
|
||||
println!(" Exchanging code for token...");
|
||||
@@ -1068,8 +1059,19 @@ async fn auth_tool_oauth(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Save the token
|
||||
save_token(store, user_id, auth, access_token).await?;
|
||||
let refresh_token = token_data.get("refresh_token").and_then(|v| v.as_str());
|
||||
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
|
||||
|
||||
// Save the token (with refresh token and expiry if provided)
|
||||
save_token(
|
||||
store,
|
||||
user_id,
|
||||
auth,
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Extract any additional info for display
|
||||
let workspace_name = token_data
|
||||
@@ -1171,8 +1173,8 @@ async fn auth_tool_manual(
|
||||
}
|
||||
}
|
||||
|
||||
// Save the token
|
||||
save_token(store, user_id, auth, &token).await?;
|
||||
// Save the token (manual path: no refresh token or expiry)
|
||||
save_token(store, user_id, auth, &token, None, None).await?;
|
||||
print_success(display_name);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1263,11 +1265,16 @@ async fn validate_token(
|
||||
}
|
||||
|
||||
/// Save token to secrets store.
|
||||
///
|
||||
/// Optionally stores a refresh token (as `{secret_name}_refresh_token`) and
|
||||
/// sets `expires_at` on the access token so the runtime can auto-refresh.
|
||||
async fn save_token(
|
||||
store: &(dyn SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
||||
token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
expires_in: Option<u64>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut params = CreateSecretParams::new(&auth.secret_name, token);
|
||||
|
||||
@@ -1275,11 +1282,29 @@ async fn save_token(
|
||||
params = params.with_provider(provider);
|
||||
}
|
||||
|
||||
if let Some(secs) = expires_in {
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(secs as i64);
|
||||
params = params.with_expiry(expires_at);
|
||||
}
|
||||
|
||||
store
|
||||
.create(user_id, params)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save token: {}", e))?;
|
||||
|
||||
// Store refresh token separately (no expiry, it's long-lived)
|
||||
if let Some(rt) = refresh_token {
|
||||
let refresh_name = format!("{}_refresh_token", auth.secret_name);
|
||||
let mut refresh_params = CreateSecretParams::new(&refresh_name, rt);
|
||||
if let Some(ref provider) = auth.provider {
|
||||
refresh_params = refresh_params.with_provider(provider);
|
||||
}
|
||||
store
|
||||
.create(user_id, refresh_params)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save refresh token: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+25
-49
@@ -1,9 +1,9 @@
|
||||
//! Configuration for IronClaw.
|
||||
//!
|
||||
//! Settings are loaded with priority: env var > database > default.
|
||||
//! The database replaces the old `settings.json` file for all settings
|
||||
//! except the 4 bootstrap fields (database_url, pool_size, secrets key
|
||||
//! source, onboard_completed) which live in `~/.ironclaw/bootstrap.json`.
|
||||
//! `DATABASE_URL` lives in `~/.ironclaw/.env` (loaded via dotenvy early
|
||||
//! in startup). Everything else comes from env vars, the DB settings
|
||||
//! table, or auto-detection.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
@@ -40,9 +40,9 @@ impl Config {
|
||||
pub async fn from_db(
|
||||
store: &dyn crate::db::Database,
|
||||
user_id: &str,
|
||||
bootstrap: &crate::bootstrap::BootstrapConfig,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let _ = dotenvy::dotenv();
|
||||
crate::bootstrap::load_ironclaw_env();
|
||||
|
||||
// Load all settings from DB into a Settings struct
|
||||
let db_settings = match store.get_all_settings(user_id).await {
|
||||
@@ -53,7 +53,7 @@ impl Config {
|
||||
}
|
||||
};
|
||||
|
||||
Self::build(bootstrap, &db_settings).await
|
||||
Self::build(&db_settings).await
|
||||
}
|
||||
|
||||
/// Load configuration from environment variables only (no database).
|
||||
@@ -61,20 +61,20 @@ impl Config {
|
||||
/// Used during early startup before the database is connected,
|
||||
/// and by CLI commands that don't have DB access.
|
||||
/// Falls back to legacy `settings.json` on disk if present.
|
||||
///
|
||||
/// Loads both `./.env` (standard, higher priority) and `~/.ironclaw/.env`
|
||||
/// (lower priority) via dotenvy, which never overwrites existing vars.
|
||||
pub async fn from_env() -> Result<Self, ConfigError> {
|
||||
let _ = dotenvy::dotenv();
|
||||
let bootstrap = crate::bootstrap::BootstrapConfig::load();
|
||||
crate::bootstrap::load_ironclaw_env();
|
||||
let settings = Settings::load();
|
||||
Self::build(&bootstrap, &settings).await
|
||||
Self::build(&settings).await
|
||||
}
|
||||
|
||||
/// Build config from bootstrap + settings (shared by from_env and from_db).
|
||||
async fn build(
|
||||
bootstrap: &crate::bootstrap::BootstrapConfig,
|
||||
settings: &Settings,
|
||||
) -> Result<Self, ConfigError> {
|
||||
/// Build config from settings (shared by from_env and from_db).
|
||||
async fn build(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
database: DatabaseConfig::resolve(bootstrap)?,
|
||||
database: DatabaseConfig::resolve()?,
|
||||
llm: LlmConfig::resolve(settings)?,
|
||||
embeddings: EmbeddingsConfig::resolve(settings)?,
|
||||
tunnel: TunnelConfig::resolve(settings)?,
|
||||
@@ -82,7 +82,7 @@ impl Config {
|
||||
agent: AgentConfig::resolve(settings)?,
|
||||
safety: SafetyConfig::resolve()?,
|
||||
wasm: WasmConfig::resolve()?,
|
||||
secrets: SecretsConfig::resolve(bootstrap).await?,
|
||||
secrets: SecretsConfig::resolve().await?,
|
||||
builder: BuilderModeConfig::resolve()?,
|
||||
heartbeat: HeartbeatConfig::resolve(settings)?,
|
||||
routines: RoutineConfig::resolve()?,
|
||||
@@ -179,7 +179,7 @@ pub struct DatabaseConfig {
|
||||
}
|
||||
|
||||
impl DatabaseConfig {
|
||||
fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result<Self, ConfigError> {
|
||||
fn resolve() -> Result<Self, ConfigError> {
|
||||
let backend: DatabaseBackend = if let Some(b) = optional_env("DATABASE_BACKEND")? {
|
||||
b.parse().map_err(|e| ConfigError::InvalidValue {
|
||||
key: "DATABASE_BACKEND".to_string(),
|
||||
@@ -191,8 +191,8 @@ impl DatabaseConfig {
|
||||
|
||||
// PostgreSQL URL is required only when using the postgres backend.
|
||||
// For libsql backend, default to an empty placeholder.
|
||||
// DATABASE_URL is loaded from ~/.ironclaw/.env via dotenvy early in startup.
|
||||
let url = optional_env("DATABASE_URL")?
|
||||
.or_else(|| bootstrap.database_url.clone())
|
||||
.or_else(|| {
|
||||
if backend == DatabaseBackend::LibSql {
|
||||
Some("unused://libsql".to_string())
|
||||
@@ -205,15 +205,7 @@ impl DatabaseConfig {
|
||||
hint: "Run 'ironclaw onboard' or set DATABASE_URL environment variable".to_string(),
|
||||
})?;
|
||||
|
||||
let pool_size = optional_env("DATABASE_POOL_SIZE")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e| ConfigError::InvalidValue {
|
||||
key: "DATABASE_POOL_SIZE".to_string(),
|
||||
message: format!("must be a positive integer: {e}"),
|
||||
})?
|
||||
.or(bootstrap.database_pool_size)
|
||||
.unwrap_or(10);
|
||||
let pool_size = parse_optional_env("DATABASE_POOL_SIZE", 10)?;
|
||||
|
||||
let libsql_path = optional_env("LIBSQL_PATH")?.map(PathBuf::from).or_else(|| {
|
||||
if backend == DatabaseBackend::LibSql {
|
||||
@@ -864,39 +856,23 @@ impl std::fmt::Debug for SecretsConfig {
|
||||
}
|
||||
|
||||
impl SecretsConfig {
|
||||
async fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result<Self, ConfigError> {
|
||||
/// Auto-detect secrets master key from env var, then OS keychain.
|
||||
///
|
||||
/// Sequential probe: SECRETS_MASTER_KEY env var first, then OS keychain.
|
||||
/// No saved "source" needed; just try each source in order.
|
||||
async fn resolve() -> Result<Self, ConfigError> {
|
||||
use crate::settings::KeySource;
|
||||
|
||||
let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? {
|
||||
(Some(SecretString::from(env_key)), KeySource::Env)
|
||||
} else {
|
||||
match bootstrap.secrets_master_key_source {
|
||||
KeySource::Keychain => {
|
||||
// Try to load from OS keychain (async on Linux)
|
||||
// Probe the OS keychain; if a key is stored, use it
|
||||
match crate::secrets::keychain::get_master_key().await {
|
||||
Ok(key_bytes) => {
|
||||
let key_hex: String =
|
||||
key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
let key_hex: String = key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
(Some(SecretString::from(key_hex)), KeySource::Keychain)
|
||||
}
|
||||
Err(_) => {
|
||||
// Keychain configured but key not found
|
||||
// This might happen if keychain was cleared
|
||||
tracing::warn!(
|
||||
"Secrets configured for keychain but key not found. \
|
||||
Run 'ironclaw onboard' to reconfigure."
|
||||
);
|
||||
(None, KeySource::None)
|
||||
}
|
||||
}
|
||||
}
|
||||
KeySource::Env => {
|
||||
tracing::warn!(
|
||||
"Secrets configured for env var but SECRETS_MASTER_KEY not set."
|
||||
);
|
||||
(None, KeySource::None)
|
||||
}
|
||||
KeySource::None => (None, KeySource::None),
|
||||
Err(_) => (None, KeySource::None),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ pub struct Store {
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
impl Store {
|
||||
/// Wrap an existing pool (useful when the caller already has a connection).
|
||||
pub fn from_pool(pool: Pool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Create a new store and connect to the database.
|
||||
pub async fn new(config: &DatabaseConfig) -> Result<Self, DatabaseError> {
|
||||
let mut cfg = Config::new();
|
||||
|
||||
+1
-11
@@ -508,7 +508,7 @@ impl LlmProvider for NearAiProvider {
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
tracing::debug!("NEAR AI response: {:?}", response);
|
||||
tracing::debug!("NEAR AI response: output_items={}", response.output.len());
|
||||
|
||||
// Extract text from response output
|
||||
// Try multiple formats since API response shape may vary
|
||||
@@ -516,11 +516,6 @@ impl LlmProvider for NearAiProvider {
|
||||
.output
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
tracing::debug!(
|
||||
"Processing output item: type={}, text={:?}",
|
||||
item.item_type,
|
||||
item.text
|
||||
);
|
||||
if item.item_type == "message" {
|
||||
// First check for direct text field on item
|
||||
if let Some(ref text) = item.text {
|
||||
@@ -531,11 +526,6 @@ impl LlmProvider for NearAiProvider {
|
||||
contents
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
tracing::debug!(
|
||||
"Content item: type={}, text={:?}",
|
||||
c.content_type,
|
||||
c.text
|
||||
);
|
||||
// Accept various content types that might contain text
|
||||
match c.content_type.as_str() {
|
||||
"output_text" | "text" => c.text.clone(),
|
||||
|
||||
+15
-158
@@ -31,8 +31,6 @@ pub struct SessionConfig {
|
||||
pub auth_base_url: String,
|
||||
/// Path to session file (e.g., ~/.ironclaw/session.json).
|
||||
pub session_path: PathBuf,
|
||||
/// Port range for OAuth callback server.
|
||||
pub callback_port_range: (u16, u16),
|
||||
}
|
||||
|
||||
impl Default for SessionConfig {
|
||||
@@ -40,7 +38,6 @@ impl Default for SessionConfig {
|
||||
Self {
|
||||
auth_base_url: "https://private.near.ai".to_string(),
|
||||
session_path: default_session_path(),
|
||||
callback_port_range: (9876, 9886),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,38 +219,21 @@ impl SessionManager {
|
||||
|
||||
/// Start the OAuth login flow.
|
||||
///
|
||||
/// 1. Find an available port for the callback server
|
||||
/// 1. Bind the fixed callback port
|
||||
/// 2. Print the auth URL and attempt to open browser
|
||||
/// 3. Wait for OAuth callback with session token
|
||||
/// 4. Save and return the token
|
||||
async fn initiate_login(&self) -> Result<(), LlmError> {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
|
||||
|
||||
// Find an available port
|
||||
let mut listener = None;
|
||||
let mut port = 0;
|
||||
|
||||
for p in self.config.callback_port_range.0..=self.config.callback_port_range.1 {
|
||||
match TcpListener::bind(format!("127.0.0.1:{}", p)).await {
|
||||
Ok(l) => {
|
||||
listener = Some(l);
|
||||
port = p;
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let listener = listener.ok_or_else(|| LlmError::SessionRenewalFailed {
|
||||
let listener = oauth_defaults::bind_callback_listener()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "nearai".to_string(),
|
||||
reason: format!(
|
||||
"Could not find available port in range {}-{}",
|
||||
self.config.callback_port_range.0, self.config.callback_port_range.1
|
||||
),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let callback_url = format!("http://127.0.0.1:{}", port);
|
||||
let callback_url = format!("http://127.0.0.1:{}", OAUTH_CALLBACK_PORT);
|
||||
|
||||
// Show auth provider menu
|
||||
println!();
|
||||
@@ -333,137 +313,16 @@ impl SessionManager {
|
||||
println!();
|
||||
println!("Waiting for authentication...");
|
||||
|
||||
// Wait for callback with timeout
|
||||
// The API redirects to: {frontend_callback}/auth/callback?token=X&session_id=X&expires_at=X&is_new_user=X
|
||||
let timeout = std::time::Duration::from_secs(300); // 5 minutes
|
||||
let selected_provider = auth_provider.to_string();
|
||||
let (session_token, auth_provider) = tokio::time::timeout(timeout, async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.map_err(|e| {
|
||||
LlmError::SessionRenewalFailed {
|
||||
provider: "nearai".to_string(),
|
||||
reason: format!("Failed to accept connection: {}", e),
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut reader = BufReader::new(&mut socket);
|
||||
let mut request_line = String::new();
|
||||
reader.read_line(&mut request_line).await.map_err(|e| {
|
||||
LlmError::SessionRenewalFailed {
|
||||
provider: "nearai".to_string(),
|
||||
reason: format!("Failed to read request: {}", e),
|
||||
}
|
||||
})?;
|
||||
|
||||
// Parse GET /auth/callback?token=xxx&session_id=xxx&expires_at=xxx&is_new_user=xxx HTTP/1.1
|
||||
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||
&& path.starts_with("/auth/callback") {
|
||||
// Parse query parameters
|
||||
if let Some(query) = path.split('?').nth(1) {
|
||||
let mut token = None;
|
||||
|
||||
for param in query.split('&') {
|
||||
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
||||
if parts.len() == 2 && parts[0] == "token" {
|
||||
token = Some(
|
||||
urlencoding::decode(parts[1])
|
||||
.unwrap_or_else(|_| parts[1].into())
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(token) = token {
|
||||
// Send success response with nice styling
|
||||
let response = concat!(
|
||||
"HTTP/1.1 200 OK\r\n",
|
||||
"Content-Type: text/html; charset=utf-8\r\n",
|
||||
"Connection: close\r\n",
|
||||
"\r\n",
|
||||
"<!DOCTYPE html>\n",
|
||||
"<html>\n",
|
||||
"<head>\n",
|
||||
" <meta charset=\"utf-8\">\n",
|
||||
" <title>NEAR AI - Authentication Successful</title>\n",
|
||||
" <style>\n",
|
||||
" * { margin: 0; padding: 0; box-sizing: border-box; }\n",
|
||||
" body {\n",
|
||||
" font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n",
|
||||
" background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);\n",
|
||||
" min-height: 100vh;\n",
|
||||
" display: flex;\n",
|
||||
" align-items: center;\n",
|
||||
" justify-content: center;\n",
|
||||
" color: #fff;\n",
|
||||
" }\n",
|
||||
" .container {\n",
|
||||
" text-align: center;\n",
|
||||
" padding: 3rem;\n",
|
||||
" background: rgba(255,255,255,0.05);\n",
|
||||
" border-radius: 16px;\n",
|
||||
" backdrop-filter: blur(10px);\n",
|
||||
" border: 1px solid rgba(255,255,255,0.1);\n",
|
||||
" max-width: 400px;\n",
|
||||
" }\n",
|
||||
" .checkmark {\n",
|
||||
" width: 80px;\n",
|
||||
" height: 80px;\n",
|
||||
" background: linear-gradient(135deg, #00d9a5 0%, #00b386 100%);\n",
|
||||
" border-radius: 50%;\n",
|
||||
" display: flex;\n",
|
||||
" align-items: center;\n",
|
||||
" justify-content: center;\n",
|
||||
" margin: 0 auto 1.5rem;\n",
|
||||
" font-size: 40px;\n",
|
||||
" }\n",
|
||||
" h1 {\n",
|
||||
" font-size: 1.5rem;\n",
|
||||
" font-weight: 600;\n",
|
||||
" margin-bottom: 0.75rem;\n",
|
||||
" }\n",
|
||||
" p {\n",
|
||||
" color: rgba(255,255,255,0.7);\n",
|
||||
" font-size: 0.95rem;\n",
|
||||
" line-height: 1.5;\n",
|
||||
" }\n",
|
||||
" .brand {\n",
|
||||
" margin-top: 2rem;\n",
|
||||
" padding-top: 1.5rem;\n",
|
||||
" border-top: 1px solid rgba(255,255,255,0.1);\n",
|
||||
" font-size: 0.8rem;\n",
|
||||
" color: rgba(255,255,255,0.4);\n",
|
||||
" }\n",
|
||||
" </style>\n",
|
||||
"</head>\n",
|
||||
"<body>\n",
|
||||
" <div class=\"container\">\n",
|
||||
" <div class=\"checkmark\">✓</div>\n",
|
||||
" <h1>Authentication Successful</h1>\n",
|
||||
" <p>You can close this window and return to the terminal.</p>\n",
|
||||
" <div class=\"brand\">NEAR AI Agent</div>\n",
|
||||
" </div>\n",
|
||||
"</body>\n",
|
||||
"</html>"
|
||||
);
|
||||
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
|
||||
return Ok::<_, LlmError>((token, Some(selected_provider.clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not the callback we're looking for, send 404
|
||||
let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
}
|
||||
})
|
||||
// The NEAR AI API redirects to: {frontend_callback}/auth/callback?token=X&...
|
||||
let session_token =
|
||||
oauth_defaults::wait_for_callback(listener, "/auth/callback", "token", "NEAR AI")
|
||||
.await
|
||||
.map_err(|_| LlmError::SessionRenewalFailed {
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "nearai".to_string(),
|
||||
reason: "Authentication timed out after 5 minutes".to_string(),
|
||||
})??;
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let auth_provider = Some(auth_provider.to_string());
|
||||
|
||||
// Save the token
|
||||
self.save_session(&session_token, auth_provider.as_deref())
|
||||
@@ -669,7 +528,6 @@ mod tests {
|
||||
let config = SessionConfig {
|
||||
auth_base_url: "https://example.com".to_string(),
|
||||
session_path: session_path.clone(),
|
||||
callback_port_range: (9900, 9910),
|
||||
};
|
||||
|
||||
let manager = SessionManager::new_async(config.clone()).await;
|
||||
@@ -710,7 +568,6 @@ mod tests {
|
||||
let config = SessionConfig {
|
||||
auth_base_url: "https://example.com".to_string(),
|
||||
session_path: dir.path().join("nonexistent.json"),
|
||||
callback_port_range: (9900, 9910),
|
||||
};
|
||||
|
||||
let manager = SessionManager::new_async(config).await;
|
||||
|
||||
+18
-34
@@ -93,7 +93,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
.init();
|
||||
|
||||
// Memory commands need database (and optionally embeddings)
|
||||
let _ = dotenvy::dotenv();
|
||||
let config = Config::from_env()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
@@ -102,7 +101,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
let session = ironclaw::llm::create_session_manager(ironclaw::llm::SessionConfig {
|
||||
auth_base_url: config.llm.nearai.auth_base_url.clone(),
|
||||
session_path: config.llm.nearai.session_path.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -155,7 +153,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
return run_pairing_command(pairing_cmd.clone()).map_err(|e| anyhow::anyhow!("{}", e));
|
||||
}
|
||||
Some(Command::Status) => {
|
||||
let _ = dotenvy::dotenv();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
||||
@@ -246,8 +243,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
skip_auth,
|
||||
channels_only,
|
||||
}) => {
|
||||
// Load .env before running onboarding wizard
|
||||
// Load .env files before running onboarding wizard.
|
||||
// Standard ./.env first (higher priority), then ~/.ironclaw/.env.
|
||||
let _ = dotenvy::dotenv();
|
||||
ironclaw::bootstrap::load_ironclaw_env();
|
||||
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
{
|
||||
@@ -270,13 +269,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Load .env if present
|
||||
// Load .env files early so DATABASE_URL (and any other vars) are
|
||||
// available to all subsequent env-based config resolution.
|
||||
// Standard ./.env first (higher priority), then ~/.ironclaw/.env.
|
||||
let _ = dotenvy::dotenv();
|
||||
ironclaw::bootstrap::load_ironclaw_env();
|
||||
|
||||
// Enhanced first-run detection
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
if !cli.no_onboard
|
||||
&& let Some(reason) = check_onboard_needed().await
|
||||
&& let Some(reason) = check_onboard_needed()
|
||||
{
|
||||
println!("Onboarding needed: {}", reason);
|
||||
println!();
|
||||
@@ -284,9 +286,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
wizard.run().await?;
|
||||
}
|
||||
|
||||
// Load bootstrap config (4 fields that must live on disk)
|
||||
let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
|
||||
|
||||
// Load initial config from env + disk (before DB is available)
|
||||
let mut config = match Config::from_env().await {
|
||||
Ok(c) => c,
|
||||
@@ -306,7 +305,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
let session_config = SessionConfig {
|
||||
auth_base_url: config.llm.nearai.auth_base_url.clone(),
|
||||
session_path: config.llm.nearai.session_path.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let session = create_session_manager(session_config).await;
|
||||
|
||||
@@ -317,7 +315,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Initialize tracing
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=debug"));
|
||||
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=warn"));
|
||||
|
||||
// Create log broadcaster before tracing init so the WebLogLayer can capture all events.
|
||||
// This gets wired to the gateway's /api/logs/events SSE endpoint later.
|
||||
@@ -425,7 +423,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Reload config from DB now that we have a connection.
|
||||
match Config::from_db(db.as_ref(), "default", &bootstrap).await {
|
||||
match Config::from_db(db.as_ref(), "default").await {
|
||||
Ok(db_config) => {
|
||||
config = db_config;
|
||||
tracing::info!("Configuration reloaded from database");
|
||||
@@ -607,7 +605,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Both register into the shared ToolRegistry (RwLock-based) so concurrent writes are safe.
|
||||
let wasm_tools_future = async {
|
||||
if let Some(ref runtime) = wasm_tool_runtime {
|
||||
let loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&tools));
|
||||
let mut loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&tools));
|
||||
if let Some(ref secrets) = secrets_store {
|
||||
loader = loader.with_secrets_store(Arc::clone(secrets));
|
||||
}
|
||||
|
||||
// Load installed tools from ~/.ironclaw/tools/
|
||||
match loader.load_from_dir(&config.wasm.tools_dir).await {
|
||||
@@ -1194,13 +1195,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
/// Check if onboarding is needed and return the reason.
|
||||
///
|
||||
/// Returns `Some(reason)` if onboarding should be triggered, `None` otherwise.
|
||||
/// Called after `load_ironclaw_env()`, so DATABASE_URL from `~/.ironclaw/.env`
|
||||
/// is already in the environment.
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
async fn check_onboard_needed() -> Option<&'static str> {
|
||||
let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
|
||||
|
||||
// Database not configured (and not in env)
|
||||
let has_db = bootstrap.database_url.is_some()
|
||||
|| std::env::var("DATABASE_URL").is_ok()
|
||||
fn check_onboard_needed() -> Option<&'static str> {
|
||||
let has_db = std::env::var("DATABASE_URL").is_ok()
|
||||
|| std::env::var("LIBSQL_PATH").is_ok()
|
||||
|| ironclaw::config::default_libsql_path().exists();
|
||||
|
||||
@@ -1208,21 +1207,6 @@ async fn check_onboard_needed() -> Option<&'static str> {
|
||||
return Some("Database not configured");
|
||||
}
|
||||
|
||||
// Secrets not configured (and not in env)
|
||||
if bootstrap.secrets_master_key_source == ironclaw::settings::KeySource::None
|
||||
&& std::env::var("SECRETS_MASTER_KEY").is_err()
|
||||
&& !ironclaw::secrets::keychain::has_master_key().await
|
||||
{
|
||||
// Only require secrets setup if user hasn't explicitly disabled it
|
||||
// For now, we don't require it for first run
|
||||
}
|
||||
|
||||
// First run (onboarding never completed and no session)
|
||||
let session_path = ironclaw::llm::session::default_session_path();
|
||||
if !bootstrap.onboard_completed && !session_path.exists() {
|
||||
return Some("First run");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
+39
-2
@@ -695,12 +695,21 @@ pub mod testing {
|
||||
}
|
||||
|
||||
async fn get(&self, user_id: &str, name: &str) -> Result<Secret, SecretError> {
|
||||
self.secrets
|
||||
let secret = self
|
||||
.secrets
|
||||
.read()
|
||||
.await
|
||||
.get(&(user_id.to_string(), name.to_string()))
|
||||
.cloned()
|
||||
.ok_or_else(|| SecretError::NotFound(name.to_string()))
|
||||
.ok_or_else(|| SecretError::NotFound(name.to_string()))?;
|
||||
|
||||
if let Some(expires_at) = secret.expires_at
|
||||
&& expires_at < Utc::now()
|
||||
{
|
||||
return Err(SecretError::Expired);
|
||||
}
|
||||
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
async fn get_decrypted(
|
||||
@@ -889,6 +898,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_expired_secret_returns_error() {
|
||||
let store = test_store();
|
||||
let expires_at = chrono::Utc::now() - chrono::Duration::hours(1);
|
||||
let params = CreateSecretParams::new("expired_key", "value").with_expiry(expires_at);
|
||||
|
||||
store.create("user1", params).await.unwrap();
|
||||
|
||||
let result = store.get("user1", "expired_key").await;
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
crate::secrets::SecretError::Expired
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_non_expired_secret_succeeds() {
|
||||
let store = test_store();
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
let params = CreateSecretParams::new("fresh_key", "value").with_expiry(expires_at);
|
||||
|
||||
store.create("user1", params).await.unwrap();
|
||||
|
||||
let result = store.get("user1", "fresh_key").await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_isolation() {
|
||||
let store = test_store();
|
||||
|
||||
+18
-72
@@ -499,14 +499,6 @@ impl Default for BuilderSettings {
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Get the default settings file path (~/.ironclaw/settings.json).
|
||||
pub fn default_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("settings.json")
|
||||
}
|
||||
|
||||
/// Reconstruct Settings from a flat key-value map (as stored in the DB).
|
||||
///
|
||||
/// Each key is a dotted path (e.g., "agent.name"), value is a JSONB value.
|
||||
@@ -552,50 +544,27 @@ impl Settings {
|
||||
map
|
||||
}
|
||||
|
||||
/// Get the default settings file path (~/.ironclaw/settings.json).
|
||||
pub fn default_path() -> std::path::PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("settings.json")
|
||||
}
|
||||
|
||||
/// Load settings from disk, returning default if not found.
|
||||
pub fn load() -> Self {
|
||||
Self::load_from(&Self::default_path())
|
||||
}
|
||||
|
||||
/// Load settings from a specific path.
|
||||
pub fn load_from(path: &PathBuf) -> Self {
|
||||
/// Load settings from a specific path (used by bootstrap legacy migration).
|
||||
pub fn load_from(path: &std::path::Path) -> Self {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save settings to disk.
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
self.save_to(&Self::default_path())
|
||||
}
|
||||
|
||||
/// Save settings to a specific path.
|
||||
pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
|
||||
|
||||
std::fs::write(path, json)
|
||||
}
|
||||
|
||||
/// Get the selected model, falling back to the provided default.
|
||||
pub fn model_or(&self, default: &str) -> String {
|
||||
self.selected_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
/// Set the selected model and save.
|
||||
pub fn set_model(&mut self, model: &str) -> std::io::Result<()> {
|
||||
self.selected_model = Some(model.to_string());
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Get a setting value by dotted path (e.g., "agent.max_parallel_jobs").
|
||||
pub fn get(&self, path: &str) -> Option<String> {
|
||||
let json = serde_json::to_value(self).ok()?;
|
||||
@@ -780,42 +749,22 @@ fn collect_settings(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_settings_save_load() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
|
||||
fn test_db_map_round_trip() {
|
||||
let settings = Settings {
|
||||
selected_model: Some("claude-3-5-sonnet-20241022".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
settings.save_to(&path).unwrap();
|
||||
|
||||
let loaded = Settings::load_from(&path);
|
||||
let map = settings.to_db_map();
|
||||
let restored = Settings::from_db_map(&map);
|
||||
assert_eq!(
|
||||
loaded.selected_model,
|
||||
restored.selected_model,
|
||||
Some("claude-3-5-sonnet-20241022".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_or_default() {
|
||||
let settings = Settings::default();
|
||||
assert_eq!(
|
||||
settings.model_or("default-model"),
|
||||
"default-model".to_string()
|
||||
);
|
||||
|
||||
let settings = Settings {
|
||||
selected_model: Some("my-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(settings.model_or("default-model"), "my-model".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_setting() {
|
||||
let settings = Settings::default();
|
||||
@@ -886,16 +835,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_owner_id_round_trip() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
|
||||
fn test_telegram_owner_id_db_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
settings.channels.telegram_owner_id = Some(123456789);
|
||||
settings.save_to(&path).unwrap();
|
||||
|
||||
let loaded = Settings::load_from(&path);
|
||||
assert_eq!(loaded.channels.telegram_owner_id, Some(123456789));
|
||||
let map = settings.to_db_map();
|
||||
let restored = Settings::from_db_map(&map);
|
||||
assert_eq!(restored.channels.telegram_owner_id, Some(123456789));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+20
-24
@@ -15,7 +15,7 @@ use serde::Deserialize;
|
||||
#[cfg(feature = "postgres")]
|
||||
use crate::secrets::SecretsCrypto;
|
||||
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||
use crate::settings::Settings;
|
||||
use crate::settings::{Settings, TunnelSettings};
|
||||
use crate::setup::prompts::{
|
||||
confirm, input, optional_input, print_error, print_info, print_success, secret_input,
|
||||
};
|
||||
@@ -131,7 +131,10 @@ struct TelegramUpdateUser {
|
||||
/// 2. Entering the bot token
|
||||
/// 3. Validating the token
|
||||
/// 4. Saving the token to the database
|
||||
pub async fn setup_telegram(secrets: &SecretsContext) -> Result<TelegramSetupResult, String> {
|
||||
pub async fn setup_telegram(
|
||||
secrets: &SecretsContext,
|
||||
settings: &Settings,
|
||||
) -> Result<TelegramSetupResult, String> {
|
||||
println!("Telegram Setup:");
|
||||
println!();
|
||||
print_info("To create a Telegram bot:");
|
||||
@@ -145,8 +148,8 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result<TelegramSetupRes
|
||||
print_info("Existing Telegram token found in database.");
|
||||
if !confirm("Replace existing token?", false).map_err(|e| e.to_string())? {
|
||||
// Still offer to configure webhook secret and owner binding
|
||||
let webhook_secret = setup_telegram_webhook_secret(secrets).await?;
|
||||
let owner_id = bind_telegram_owner_flow(secrets).await?;
|
||||
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
||||
let owner_id = bind_telegram_owner_flow(secrets, settings).await?;
|
||||
return Ok(TelegramSetupResult {
|
||||
enabled: true,
|
||||
bot_username: None,
|
||||
@@ -176,7 +179,7 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result<TelegramSetupRes
|
||||
let owner_id = bind_telegram_owner(&token).await?;
|
||||
|
||||
// Offer webhook secret configuration
|
||||
let webhook_secret = setup_telegram_webhook_secret(secrets).await?;
|
||||
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
||||
|
||||
Ok(TelegramSetupResult {
|
||||
enabled: true,
|
||||
@@ -189,7 +192,7 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result<TelegramSetupRes
|
||||
print_error(&format!("Token validation failed: {}", e));
|
||||
|
||||
if confirm("Try again?", true).map_err(|e| e.to_string())? {
|
||||
Box::pin(setup_telegram(secrets)).await
|
||||
Box::pin(setup_telegram(secrets, settings)).await
|
||||
} else {
|
||||
Ok(TelegramSetupResult {
|
||||
enabled: false,
|
||||
@@ -301,9 +304,10 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
|
||||
/// Bind flow when the token already exists (reads from secrets store).
|
||||
///
|
||||
/// Retrieves the saved bot token and delegates to `bind_telegram_owner`.
|
||||
async fn bind_telegram_owner_flow(secrets: &SecretsContext) -> Result<Option<i64>, String> {
|
||||
// Check current settings first
|
||||
let settings = Settings::load();
|
||||
async fn bind_telegram_owner_flow(
|
||||
secrets: &SecretsContext,
|
||||
settings: &Settings,
|
||||
) -> Result<Option<i64>, String> {
|
||||
if settings.channels.telegram_owner_id.is_some() {
|
||||
print_info("Bot is already bound to a Telegram account.");
|
||||
if !confirm("Re-bind to a different account?", false).map_err(|e| e.to_string())? {
|
||||
@@ -321,9 +325,7 @@ async fn bind_telegram_owner_flow(secrets: &SecretsContext) -> Result<Option<i64
|
||||
///
|
||||
/// This is shared across all channels that need webhook endpoints.
|
||||
/// Returns the tunnel URL if configured.
|
||||
pub fn setup_tunnel() -> Result<Option<String>, String> {
|
||||
// Check if already configured
|
||||
let settings = Settings::load();
|
||||
pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, String> {
|
||||
if let Some(ref url) = settings.tunnel.public_url {
|
||||
print_info(&format!("Existing tunnel configured: {}", url));
|
||||
if !confirm("Change tunnel configuration?", false).map_err(|e| e.to_string())? {
|
||||
@@ -362,14 +364,7 @@ pub fn setup_tunnel() -> Result<Option<String>, String> {
|
||||
// Remove trailing slash if present
|
||||
let tunnel_url = tunnel_url.trim_end_matches('/').to_string();
|
||||
|
||||
// Save to settings
|
||||
let mut settings = Settings::load();
|
||||
settings.tunnel.public_url = Some(tunnel_url.clone());
|
||||
settings
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
|
||||
print_success(&format!("Tunnel URL saved: {}", tunnel_url));
|
||||
print_success(&format!("Tunnel URL configured: {}", tunnel_url));
|
||||
print_info("");
|
||||
print_info("Make sure your tunnel is running before starting the agent.");
|
||||
print_info("You can also set TUNNEL_URL environment variable to override.");
|
||||
@@ -380,10 +375,11 @@ pub fn setup_tunnel() -> Result<Option<String>, String> {
|
||||
/// Set up Telegram webhook secret for signature validation.
|
||||
///
|
||||
/// Returns the webhook secret if configured.
|
||||
async fn setup_telegram_webhook_secret(secrets: &SecretsContext) -> Result<Option<String>, String> {
|
||||
// Check if tunnel is configured
|
||||
let settings = Settings::load();
|
||||
if settings.tunnel.public_url.is_none() {
|
||||
async fn setup_telegram_webhook_secret(
|
||||
secrets: &SecretsContext,
|
||||
tunnel: &TunnelSettings,
|
||||
) -> Result<Option<String>, String> {
|
||||
if tunnel.public_url.is_none() {
|
||||
print_info("");
|
||||
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
|
||||
print_info("Run setup again to configure a tunnel for instant delivery.");
|
||||
|
||||
+74
-12
@@ -83,7 +83,7 @@ impl SetupWizard {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: SetupConfig::default(),
|
||||
settings: Settings::load(),
|
||||
settings: Settings::default(),
|
||||
session_manager: None,
|
||||
#[cfg(feature = "postgres")]
|
||||
db_pool: None,
|
||||
@@ -97,7 +97,7 @@ impl SetupWizard {
|
||||
pub fn with_config(config: SetupConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
settings: Settings::load(),
|
||||
settings: Settings::default(),
|
||||
session_manager: None,
|
||||
#[cfg(feature = "postgres")]
|
||||
db_pool: None,
|
||||
@@ -158,7 +158,7 @@ impl SetupWizard {
|
||||
}
|
||||
|
||||
// Save settings and print summary
|
||||
self.save_and_summarize()?;
|
||||
self.save_and_summarize().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -818,7 +818,7 @@ impl SetupWizard {
|
||||
/// Step 6: Channel configuration.
|
||||
async fn step_channels(&mut self) -> Result<(), SetupError> {
|
||||
// First, configure tunnel (shared across all channels that need webhooks)
|
||||
match setup_tunnel() {
|
||||
match setup_tunnel(&self.settings) {
|
||||
Ok(Some(url)) => {
|
||||
self.settings.tunnel.public_url = Some(url);
|
||||
}
|
||||
@@ -934,8 +934,9 @@ impl SetupWizard {
|
||||
.await
|
||||
.map_err(SetupError::Channel)?
|
||||
} else if channel_name == "telegram" {
|
||||
let telegram_result =
|
||||
setup_telegram(ctx).await.map_err(SetupError::Channel)?;
|
||||
let telegram_result = setup_telegram(ctx, &self.settings)
|
||||
.await
|
||||
.map_err(SetupError::Channel)?;
|
||||
if let Some(owner_id) = telegram_result.owner_id {
|
||||
self.settings.channels.telegram_owner_id = Some(owner_id);
|
||||
}
|
||||
@@ -1018,16 +1019,77 @@ impl SetupWizard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save settings and print summary.
|
||||
fn save_and_summarize(&mut self) -> Result<(), SetupError> {
|
||||
/// Save settings to the database and `~/.ironclaw/.env`, then print summary.
|
||||
async fn save_and_summarize(&mut self) -> Result<(), SetupError> {
|
||||
self.settings.onboard_completed = true;
|
||||
|
||||
self.settings
|
||||
.save()
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to save settings: {}", e)))?;
|
||||
// Write all settings to the database (whichever backend is active).
|
||||
{
|
||||
let db_map = self.settings.to_db_map();
|
||||
let saved = false;
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
let saved = if !saved {
|
||||
if let Some(ref pool) = self.db_pool {
|
||||
let store = crate::history::Store::from_pool(pool.clone());
|
||||
store
|
||||
.set_all_settings("default", &db_map)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
SetupError::Database(format!(
|
||||
"Failed to save settings to database: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
saved
|
||||
};
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
let saved = if !saved {
|
||||
if let Some(ref backend) = self.db_backend {
|
||||
use crate::db::Database as _;
|
||||
backend
|
||||
.set_all_settings("default", &db_map)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
SetupError::Database(format!(
|
||||
"Failed to save settings to database: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
saved
|
||||
};
|
||||
|
||||
if !saved {
|
||||
return Err(SetupError::Database(
|
||||
"No database connection, cannot save settings".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Save DATABASE_URL to ~/.ironclaw/.env (the only field that needs
|
||||
// disk persistence before the DB is available).
|
||||
if let Some(ref url) = self.settings.database_url {
|
||||
crate::bootstrap::save_database_url(url).map_err(|e| {
|
||||
SetupError::Io(std::io::Error::other(format!(
|
||||
"Failed to save DATABASE_URL to .env: {}",
|
||||
e
|
||||
)))
|
||||
})?;
|
||||
}
|
||||
|
||||
println!();
|
||||
print_success("Configuration saved to ~/.ironclaw/");
|
||||
print_success("Configuration saved to database");
|
||||
println!();
|
||||
|
||||
// Print summary
|
||||
|
||||
+13
-68
@@ -11,9 +11,9 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
|
||||
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||
use crate::tools::mcp::config::McpServerConfig;
|
||||
|
||||
@@ -466,14 +466,12 @@ pub async fn authorize_mcp_server(
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Find an available port for the OAuth callback.
|
||||
/// Bind the OAuth callback listener on the shared fixed port.
|
||||
pub async fn find_available_port() -> Result<(TcpListener, u16), AuthError> {
|
||||
for port in 9876..=9886 {
|
||||
if let Ok(listener) = TcpListener::bind(format!("127.0.0.1:{}", port)).await {
|
||||
return Ok((listener, port));
|
||||
}
|
||||
}
|
||||
Err(AuthError::PortUnavailable)
|
||||
let listener = oauth_defaults::bind_callback_listener()
|
||||
.await
|
||||
.map_err(|_| AuthError::PortUnavailable)?;
|
||||
Ok((listener, OAUTH_CALLBACK_PORT))
|
||||
}
|
||||
|
||||
/// Build the authorization URL with all required parameters.
|
||||
@@ -522,69 +520,16 @@ pub async fn wait_for_authorization_callback(
|
||||
listener: TcpListener,
|
||||
server_name: &str,
|
||||
) -> Result<String, AuthError> {
|
||||
let timeout = Duration::from_secs(300);
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
let (mut socket, _) = listener
|
||||
.accept()
|
||||
oauth_defaults::wait_for_callback(listener, "/callback", "code", server_name)
|
||||
.await
|
||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||
|
||||
let mut reader = BufReader::new(&mut socket);
|
||||
let mut request_line = String::new();
|
||||
reader
|
||||
.read_line(&mut request_line)
|
||||
.await
|
||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||
|
||||
// Parse GET /callback?code=xxx HTTP/1.1
|
||||
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||
&& path.starts_with("/callback")
|
||||
&& let Some(query) = path.split('?').nth(1) {
|
||||
// Check for error first
|
||||
if query.contains("error=") {
|
||||
let response = "HTTP/1.1 400 Bad Request\r\n\r\nAuthorization denied";
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
return Err(AuthError::AuthorizationDenied);
|
||||
}
|
||||
|
||||
// Look for code
|
||||
for param in query.split('&') {
|
||||
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
||||
if parts.len() == 2 && parts[0] == "code" {
|
||||
let code = urlencoding::decode(parts[1])
|
||||
.unwrap_or_else(|_| parts[1].into())
|
||||
.into_owned();
|
||||
|
||||
// Send success response
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: text/html\r\n\
|
||||
\r\n\
|
||||
<!DOCTYPE html><html><body style=\"font-family: sans-serif; \
|
||||
display: flex; justify-content: center; align-items: center; \
|
||||
height: 100vh; margin: 0; background: #191919; color: white;\">\
|
||||
<div style=\"text-align: center;\">\
|
||||
<h1>✓ {} Connected!</h1>\
|
||||
<p>You can close this window.</p>\
|
||||
</div></body></html>",
|
||||
server_name
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
|
||||
return Ok(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
.map_err(|e| match e {
|
||||
oauth_defaults::OAuthCallbackError::Denied => AuthError::AuthorizationDenied,
|
||||
oauth_defaults::OAuthCallbackError::Timeout => AuthError::Timeout,
|
||||
oauth_defaults::OAuthCallbackError::PortInUse(_, msg) => {
|
||||
AuthError::Http(format!("Port error: {}", msg))
|
||||
}
|
||||
oauth_defaults::OAuthCallbackError::Io(msg) => AuthError::Http(msg),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AuthError::Timeout)?
|
||||
}
|
||||
|
||||
/// Exchange the authorization code for an access token.
|
||||
|
||||
@@ -190,10 +190,15 @@ impl McpClient {
|
||||
req_builder = req_builder.header("Mcp-Session-Id", session_id);
|
||||
}
|
||||
|
||||
let response = req_builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExternalService(format!("MCP request failed: {}", e)))?;
|
||||
let response = req_builder.send().await.map_err(|e| {
|
||||
let mut chain = format!("MCP request failed: {}", e);
|
||||
let mut source = std::error::Error::source(&e);
|
||||
while let Some(cause) = source {
|
||||
chain.push_str(&format!(" -> {}", cause));
|
||||
source = cause.source();
|
||||
}
|
||||
ToolError::ExternalService(chain)
|
||||
})?;
|
||||
|
||||
// Check for 401 Unauthorized - try to refresh token on first attempt
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
|
||||
+82
-1
@@ -88,8 +88,18 @@ impl McpServerConfig {
|
||||
}
|
||||
|
||||
/// Check if this server requires authentication.
|
||||
///
|
||||
/// Returns true if OAuth is pre-configured OR if this is a remote HTTPS server
|
||||
/// (which likely supports Dynamic Client Registration even without pre-configured OAuth).
|
||||
pub fn requires_auth(&self) -> bool {
|
||||
self.oauth.is_some()
|
||||
if self.oauth.is_some() {
|
||||
return true;
|
||||
}
|
||||
// Remote HTTPS servers need auth handling (DCR, token refresh, 401 detection).
|
||||
// Localhost/127.0.0.1 servers are assumed to be dev servers without auth.
|
||||
let url_lower = self.url.to_lowercase();
|
||||
let is_localhost = is_localhost_url(&url_lower);
|
||||
url_lower.starts_with("https://") && !is_localhost
|
||||
}
|
||||
|
||||
/// Get the secret name used to store the access token.
|
||||
@@ -402,11 +412,43 @@ pub async fn remove_mcp_server_db(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a URL points to a loopback address (localhost, 127.0.0.1, [::1]).
|
||||
///
|
||||
/// Uses `url::Url` for proper parsing so edge cases (IPv6, userinfo, ports)
|
||||
/// are handled correctly without manual string splitting.
|
||||
fn is_localhost_url(url: &str) -> bool {
|
||||
let Ok(parsed) = url::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
match parsed.host() {
|
||||
Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
|
||||
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
|
||||
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_is_localhost_url() {
|
||||
assert!(is_localhost_url("http://localhost:3000/path"));
|
||||
assert!(is_localhost_url("https://localhost/path"));
|
||||
assert!(is_localhost_url("http://127.0.0.1:8080"));
|
||||
assert!(is_localhost_url("http://127.0.0.1"));
|
||||
assert!(!is_localhost_url("https://notlocalhost.com/path"));
|
||||
assert!(!is_localhost_url("https://example-localhost.io"));
|
||||
assert!(!is_localhost_url("https://mcp.notion.com"));
|
||||
assert!(is_localhost_url("http://user:pass@localhost:3000/path"));
|
||||
// IPv6 loopback
|
||||
assert!(is_localhost_url("http://[::1]:8080/path"));
|
||||
assert!(is_localhost_url("http://[::1]/path"));
|
||||
assert!(!is_localhost_url("http://[::2]:8080/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_config_validation() {
|
||||
// Valid HTTPS server
|
||||
@@ -514,4 +556,43 @@ mod tests {
|
||||
"mcp_notion_refresh_token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requires_auth_with_oauth() {
|
||||
let config = McpServerConfig::new("notion", "https://mcp.notion.com")
|
||||
.with_oauth(OAuthConfig::new("client-123"));
|
||||
assert!(config.requires_auth());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requires_auth_remote_https_without_oauth() {
|
||||
// Remote HTTPS servers need auth even without pre-configured OAuth (DCR)
|
||||
let config = McpServerConfig::new("github-copilot", "https://api.githubcopilot.com/mcp/");
|
||||
assert!(config.requires_auth());
|
||||
|
||||
let config = McpServerConfig::new("notion", "https://mcp.notion.com");
|
||||
assert!(config.requires_auth());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requires_auth_localhost_no_auth() {
|
||||
// Localhost servers are dev servers, no auth needed
|
||||
let config = McpServerConfig::new("local", "http://localhost:8080");
|
||||
assert!(!config.requires_auth());
|
||||
|
||||
let config = McpServerConfig::new("local", "http://127.0.0.1:3000/mcp");
|
||||
assert!(!config.requires_auth());
|
||||
|
||||
// Even HTTPS localhost doesn't require auth
|
||||
let config = McpServerConfig::new("local", "https://localhost:8443");
|
||||
assert!(!config.requires_auth());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requires_auth_http_remote_no_auth() {
|
||||
// HTTP remote servers won't pass validation, but if they existed
|
||||
// they wouldn't trigger HTTPS auth detection
|
||||
let config = McpServerConfig::new("bad", "http://mcp.example.com");
|
||||
assert!(!config.requires_auth());
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -11,6 +11,7 @@ use crate::extensions::ExtensionManager;
|
||||
use crate::llm::{LlmProvider, ToolDefinition};
|
||||
use crate::orchestrator::job_manager::ContainerJobManager;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
|
||||
use crate::tools::builtin::{
|
||||
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, HttpTool, JobStatusTool, JsonTool,
|
||||
@@ -20,8 +21,8 @@ use crate::tools::builtin::{
|
||||
};
|
||||
use crate::tools::tool::{Tool, ToolDomain};
|
||||
use crate::tools::wasm::{
|
||||
Capabilities, ResourceLimits, WasmError, WasmStorageError, WasmToolRuntime, WasmToolStore,
|
||||
WasmToolWrapper,
|
||||
Capabilities, OAuthRefreshConfig, ResourceLimits, WasmError, WasmStorageError, WasmToolRuntime,
|
||||
WasmToolStore, WasmToolWrapper,
|
||||
};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
@@ -366,6 +367,12 @@ impl ToolRegistry {
|
||||
if let Some(s) = reg.schema {
|
||||
wrapper = wrapper.with_schema(s);
|
||||
}
|
||||
if let Some(store) = reg.secrets_store {
|
||||
wrapper = wrapper.with_secrets_store(store);
|
||||
}
|
||||
if let Some(oauth) = reg.oauth_refresh {
|
||||
wrapper = wrapper.with_oauth_refresh(oauth);
|
||||
}
|
||||
|
||||
// Register the tool
|
||||
self.register(Arc::new(wrapper)).await;
|
||||
@@ -421,6 +428,8 @@ impl ToolRegistry {
|
||||
limits: None,
|
||||
description: Some(&tool_with_binary.tool.description),
|
||||
schema: Some(tool_with_binary.tool.parameters_schema.clone()),
|
||||
secrets_store: None,
|
||||
oauth_refresh: None,
|
||||
})
|
||||
.await
|
||||
.map_err(WasmRegistrationError::Wasm)?;
|
||||
@@ -462,6 +471,10 @@ pub struct WasmToolRegistration<'a> {
|
||||
pub description: Option<&'a str>,
|
||||
/// Optional parameter schema override.
|
||||
pub schema: Option<serde_json::Value>,
|
||||
/// Secrets store for credential injection at request time.
|
||||
pub secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
/// OAuth refresh configuration for auto-refreshing expired tokens.
|
||||
pub oauth_refresh: Option<OAuthRefreshConfig>,
|
||||
}
|
||||
|
||||
impl Default for ToolRegistry {
|
||||
|
||||
@@ -169,7 +169,7 @@ impl CredentialInjector {
|
||||
}
|
||||
|
||||
/// Inject a single credential into the result.
|
||||
fn inject_credential(
|
||||
pub(crate) fn inject_credential(
|
||||
result: &mut InjectedCredentials,
|
||||
location: &CredentialLocation,
|
||||
secret: &DecryptedSecret,
|
||||
@@ -208,7 +208,7 @@ fn inject_credential(
|
||||
}
|
||||
|
||||
/// Check if a host matches a pattern (supports wildcards).
|
||||
fn host_matches_pattern(host: &str, pattern: &str) -> bool {
|
||||
pub(crate) fn host_matches_pattern(host: &str, pattern: &str) -> bool {
|
||||
if pattern == host {
|
||||
return true;
|
||||
}
|
||||
|
||||
+179
-7
@@ -39,10 +39,11 @@ use std::sync::Arc;
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::registry::{ToolRegistry, WasmRegistrationError, WasmToolRegistration};
|
||||
use crate::tools::wasm::capabilities_schema::CapabilitiesFile;
|
||||
use crate::tools::wasm::{
|
||||
Capabilities, WasmError, WasmStorageError, WasmToolRuntime, WasmToolStore,
|
||||
Capabilities, OAuthRefreshConfig, WasmError, WasmStorageError, WasmToolRuntime, WasmToolStore,
|
||||
};
|
||||
|
||||
/// Error during WASM tool loading.
|
||||
@@ -77,12 +78,23 @@ pub enum WasmLoadError {
|
||||
pub struct WasmToolLoader {
|
||||
runtime: Arc<WasmToolRuntime>,
|
||||
registry: Arc<ToolRegistry>,
|
||||
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl WasmToolLoader {
|
||||
/// Create a new loader with the given runtime and registry.
|
||||
pub fn new(runtime: Arc<WasmToolRuntime>, registry: Arc<ToolRegistry>) -> Self {
|
||||
Self { runtime, registry }
|
||||
Self {
|
||||
runtime,
|
||||
registry,
|
||||
secrets_store: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the secrets store for credential injection in WASM tools.
|
||||
pub fn with_secrets_store(mut self, store: Arc<dyn SecretsStore + Send + Sync>) -> Self {
|
||||
self.secrets_store = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
/// Load a single WASM tool from a file pair.
|
||||
@@ -108,22 +120,24 @@ impl WasmToolLoader {
|
||||
}
|
||||
let wasm_bytes = fs::read(wasm_path).await?;
|
||||
|
||||
// Read capabilities (optional)
|
||||
let capabilities = if let Some(cap_path) = capabilities_path {
|
||||
// Read capabilities (optional) and extract OAuth refresh config
|
||||
let (capabilities, oauth_refresh) = if let Some(cap_path) = capabilities_path {
|
||||
if cap_path.exists() {
|
||||
let cap_bytes = fs::read(cap_path).await?;
|
||||
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
|
||||
cap_file.to_capabilities()
|
||||
let caps = cap_file.to_capabilities();
|
||||
let oauth = resolve_oauth_refresh_config(&cap_file);
|
||||
(caps, oauth)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file not found, using default (no permissions)"
|
||||
);
|
||||
Capabilities::default()
|
||||
(Capabilities::default(), None)
|
||||
}
|
||||
} else {
|
||||
Capabilities::default()
|
||||
(Capabilities::default(), None)
|
||||
};
|
||||
|
||||
// Register the tool
|
||||
@@ -136,6 +150,8 @@ impl WasmToolLoader {
|
||||
limits: None,
|
||||
description: None,
|
||||
schema: None,
|
||||
secrets_store: self.secrets_store.clone(),
|
||||
oauth_refresh,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -293,6 +309,50 @@ impl WasmToolLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract OAuth refresh configuration from a parsed capabilities file.
|
||||
///
|
||||
/// Returns `None` if there's no `auth.oauth` section or if the client_id
|
||||
/// can't be resolved from any source (inline, env var, or built-in defaults).
|
||||
///
|
||||
/// Fallback chain for client_id:
|
||||
/// `oauth.client_id` > env var (`oauth.client_id_env`) > `builtin_credentials()`
|
||||
fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefreshConfig> {
|
||||
let auth = cap_file.auth.as_ref()?;
|
||||
let oauth = auth.oauth.as_ref()?;
|
||||
|
||||
let builtin = crate::cli::oauth_defaults::builtin_credentials(&auth.secret_name);
|
||||
|
||||
let client_id = oauth
|
||||
.client_id
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
oauth
|
||||
.client_id_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
})
|
||||
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))?;
|
||||
|
||||
let client_secret = oauth
|
||||
.client_secret
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
oauth
|
||||
.client_secret_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
})
|
||||
.or_else(|| builtin.as_ref().map(|c| c.client_secret.to_string()));
|
||||
|
||||
Some(OAuthRefreshConfig {
|
||||
token_url: oauth.token_url.clone(),
|
||||
client_id,
|
||||
client_secret,
|
||||
secret_name: auth.secret_name.clone(),
|
||||
provider: auth.provider.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Results from loading multiple tools.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LoadResults {
|
||||
@@ -618,4 +678,116 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oauth_refresh_config_with_oauth() {
|
||||
use crate::tools::wasm::capabilities_schema::{
|
||||
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
||||
};
|
||||
|
||||
let caps = CapabilitiesFile {
|
||||
auth: Some(AuthCapabilitySchema {
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
oauth: Some(OAuthConfigSchema {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||
client_id: Some("test-client-id".to_string()),
|
||||
client_secret: Some("test-client-secret".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = super::resolve_oauth_refresh_config(&caps);
|
||||
assert!(config.is_some());
|
||||
|
||||
let config = config.unwrap();
|
||||
assert_eq!(config.token_url, "https://oauth2.googleapis.com/token");
|
||||
assert_eq!(config.client_id, "test-client-id");
|
||||
assert_eq!(config.client_secret, Some("test-client-secret".to_string()));
|
||||
assert_eq!(config.secret_name, "google_oauth_token");
|
||||
assert_eq!(config.provider, Some("google".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oauth_refresh_config_no_auth() {
|
||||
use crate::tools::wasm::capabilities_schema::CapabilitiesFile;
|
||||
|
||||
let caps = CapabilitiesFile::default();
|
||||
let config = super::resolve_oauth_refresh_config(&caps);
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oauth_refresh_config_no_oauth() {
|
||||
use crate::tools::wasm::capabilities_schema::{AuthCapabilitySchema, CapabilitiesFile};
|
||||
|
||||
let caps = CapabilitiesFile {
|
||||
auth: Some(AuthCapabilitySchema {
|
||||
secret_name: "manual_token".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = super::resolve_oauth_refresh_config(&caps);
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oauth_refresh_config_no_client_id() {
|
||||
use crate::tools::wasm::capabilities_schema::{
|
||||
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
||||
};
|
||||
|
||||
// A non-Google provider with no client_id anywhere should return None
|
||||
let caps = CapabilitiesFile {
|
||||
auth: Some(AuthCapabilitySchema {
|
||||
secret_name: "unknown_provider_token".to_string(),
|
||||
oauth: Some(OAuthConfigSchema {
|
||||
authorization_url: "https://example.com/auth".to_string(),
|
||||
token_url: "https://example.com/token".to_string(),
|
||||
// No client_id, no client_id_env, no builtin
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = super::resolve_oauth_refresh_config(&caps);
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oauth_refresh_config_builtin_google() {
|
||||
use crate::tools::wasm::capabilities_schema::{
|
||||
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
||||
};
|
||||
|
||||
// google_oauth_token should fall back to built-in credentials
|
||||
let caps = CapabilitiesFile {
|
||||
auth: Some(AuthCapabilitySchema {
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
oauth: Some(OAuthConfigSchema {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||
// No inline client_id, should fall back to builtin
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = super::resolve_oauth_refresh_config(&caps);
|
||||
assert!(config.is_some());
|
||||
let config = config.unwrap();
|
||||
assert!(!config.client_id.is_empty());
|
||||
assert!(config.client_secret.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ pub use limits::{
|
||||
WasmResourceLimiter,
|
||||
};
|
||||
pub use runtime::{PreparedModule, WasmRuntimeConfig, WasmToolRuntime};
|
||||
pub use wrapper::WasmToolWrapper;
|
||||
pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper};
|
||||
|
||||
// Capabilities (V2)
|
||||
pub use capabilities::{
|
||||
|
||||
+875
-12
File diff suppressed because it is too large
Load Diff
+15
-78
@@ -53,119 +53,56 @@ impl exports::near::agent::tool::Guest for GmailTool {
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_messages" },
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list_messages", "get_message", "send_message", "create_draft", "reply_to_message", "trash_message"],
|
||||
"description": "The Gmail operation to perform"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Gmail search query (same syntax as Gmail search box). Examples: 'is:unread', 'from:[email protected]', 'subject:meeting after:2025/01/01'"
|
||||
"description": "Gmail search query (same syntax as Gmail search box, e.g., 'is:unread', 'from:[email protected]'). Used by: list_messages"
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages to return (default: 20)",
|
||||
"description": "Maximum number of messages to return (default: 20). Used by: list_messages",
|
||||
"default": 20
|
||||
},
|
||||
"label_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Label IDs to filter by (e.g., 'INBOX', 'SENT', 'DRAFT')"
|
||||
}
|
||||
"description": "Label IDs to filter by (e.g., 'INBOX', 'SENT', 'DRAFT'). Used by: list_messages"
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_message" },
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "The message ID to retrieve"
|
||||
}
|
||||
"description": "Message ID. Required for: get_message, reply_to_message, trash_message"
|
||||
},
|
||||
"required": ["action", "message_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "send_message" },
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Recipient email address(es), comma-separated"
|
||||
"description": "Recipient email address(es), comma-separated. Required for: send_message, create_draft"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Email subject"
|
||||
"description": "Email subject. Required for: send_message, create_draft"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Email body (plain text)"
|
||||
"description": "Email body (plain text). Required for: send_message, create_draft, reply_to_message"
|
||||
},
|
||||
"cc": {
|
||||
"type": "string",
|
||||
"description": "CC recipients, comma-separated"
|
||||
"description": "CC recipients, comma-separated. Used by: send_message, create_draft"
|
||||
},
|
||||
"bcc": {
|
||||
"type": "string",
|
||||
"description": "BCC recipients, comma-separated"
|
||||
}
|
||||
},
|
||||
"required": ["action", "to", "subject", "body"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_draft" },
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Recipient email address(es), comma-separated"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Email subject"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Email body (plain text)"
|
||||
},
|
||||
"cc": {
|
||||
"type": "string",
|
||||
"description": "CC recipients, comma-separated"
|
||||
},
|
||||
"bcc": {
|
||||
"type": "string",
|
||||
"description": "BCC recipients, comma-separated"
|
||||
}
|
||||
},
|
||||
"required": ["action", "to", "subject", "body"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "reply_to_message" },
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "The message ID to reply to"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Reply body (plain text)"
|
||||
"description": "BCC recipients, comma-separated. Used by: send_message, create_draft"
|
||||
},
|
||||
"reply_all": {
|
||||
"type": "boolean",
|
||||
"description": "If true, reply to all recipients (default: false)",
|
||||
"description": "If true, reply to all recipients (default: false). Used by: reply_to_message",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["action", "message_id", "body"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "trash_message" },
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "The message ID to move to trash"
|
||||
}
|
||||
},
|
||||
"required": ["action", "message_id"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -52,166 +52,76 @@ impl exports::near::agent::tool::Guest for GoogleCalendarTool {
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_events" },
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list_events", "get_event", "create_event", "update_event", "delete_event"],
|
||||
"description": "The calendar operation to perform"
|
||||
},
|
||||
"calendar_id": {
|
||||
"type": "string",
|
||||
"description": "Calendar ID (default: 'primary')",
|
||||
"default": "primary"
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "Event ID. Required for: get_event, update_event, delete_event"
|
||||
},
|
||||
"time_min": {
|
||||
"type": "string",
|
||||
"description": "Lower bound for event start time (RFC3339, e.g., '2025-01-15T00:00:00Z')"
|
||||
"description": "Lower bound for event start time (RFC3339, e.g., '2025-01-15T00:00:00Z'). Used by: list_events"
|
||||
},
|
||||
"time_max": {
|
||||
"type": "string",
|
||||
"description": "Upper bound for event end time (RFC3339)"
|
||||
"description": "Upper bound for event end time (RFC3339). Used by: list_events"
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of events to return (default: 25)",
|
||||
"description": "Maximum number of events to return (default: 25). Used by: list_events",
|
||||
"default": 25
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Free text search terms to filter events"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_event" },
|
||||
"calendar_id": {
|
||||
"type": "string",
|
||||
"description": "Calendar ID (default: 'primary')",
|
||||
"default": "primary"
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "The event ID to retrieve"
|
||||
}
|
||||
},
|
||||
"required": ["action", "event_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_event" },
|
||||
"calendar_id": {
|
||||
"type": "string",
|
||||
"description": "Calendar ID (default: 'primary')",
|
||||
"default": "primary"
|
||||
"description": "Free text search terms to filter events. Used by: list_events"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "Event title"
|
||||
"description": "Event title. Required for: create_event. Optional for: update_event"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Event description"
|
||||
"description": "Event description. Used by: create_event, update_event"
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "Event location"
|
||||
"description": "Event location. Used by: create_event, update_event"
|
||||
},
|
||||
"start_datetime": {
|
||||
"type": "string",
|
||||
"description": "Start time as RFC3339 (e.g., '2025-01-15T09:00:00-05:00'). Use start_date for all-day events."
|
||||
"description": "Start time (RFC3339, e.g., '2025-01-15T09:00:00-05:00'). For all-day events use start_date. Used by: create_event, update_event"
|
||||
},
|
||||
"end_datetime": {
|
||||
"type": "string",
|
||||
"description": "End time as RFC3339. Use end_date for all-day events."
|
||||
"description": "End time (RFC3339). For all-day events use end_date. Used by: create_event, update_event"
|
||||
},
|
||||
"start_date": {
|
||||
"type": "string",
|
||||
"description": "Start date for all-day events (e.g., '2025-01-15')"
|
||||
"description": "Start date for all-day events (e.g., '2025-01-15'). Used by: create_event, update_event"
|
||||
},
|
||||
"end_date": {
|
||||
"type": "string",
|
||||
"description": "End date for all-day events (exclusive, e.g., '2025-01-16' for a single day)"
|
||||
"description": "End date for all-day events (exclusive, e.g., '2025-01-16'). Used by: create_event, update_event"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "Timezone (e.g., 'America/New_York')"
|
||||
"description": "Timezone (e.g., 'America/New_York'). Used by: create_event, update_event"
|
||||
},
|
||||
"attendees": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Attendee email addresses"
|
||||
"description": "Attendee email addresses. Used by: create_event, update_event"
|
||||
}
|
||||
},
|
||||
"required": ["action", "summary"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "update_event" },
|
||||
"calendar_id": {
|
||||
"type": "string",
|
||||
"description": "Calendar ID (default: 'primary')",
|
||||
"default": "primary"
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "The event ID to update"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "New event title"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "New event description"
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "New event location"
|
||||
},
|
||||
"start_datetime": {
|
||||
"type": "string",
|
||||
"description": "New start time (RFC3339)"
|
||||
},
|
||||
"end_datetime": {
|
||||
"type": "string",
|
||||
"description": "New end time (RFC3339)"
|
||||
},
|
||||
"start_date": {
|
||||
"type": "string",
|
||||
"description": "New start date for all-day events"
|
||||
},
|
||||
"end_date": {
|
||||
"type": "string",
|
||||
"description": "New end date for all-day events"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "Timezone for datetime fields"
|
||||
},
|
||||
"attendees": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Replace attendees with these email addresses"
|
||||
}
|
||||
},
|
||||
"required": ["action", "event_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "delete_event" },
|
||||
"calendar_id": {
|
||||
"type": "string",
|
||||
"description": "Calendar ID (default: 'primary')",
|
||||
"default": "primary"
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "The event ID to delete"
|
||||
}
|
||||
},
|
||||
"required": ["action", "event_id"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -74,251 +74,120 @@ impl exports::near::agent::tool::Guest for GoogleDocsTool {
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_document" },
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["create_document", "get_document", "read_content", "insert_text", "delete_content", "replace_text", "format_text", "format_paragraph", "insert_table", "create_list", "batch_update"],
|
||||
"description": "The Google Docs operation to perform"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Document title"
|
||||
}
|
||||
"description": "Document title. Required for: create_document"
|
||||
},
|
||||
"required": ["action", "title"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_document" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID (same as Google Drive file ID)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "read_content" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "insert_text" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
"description": "The document ID (same as Google Drive file ID). Required for all actions except create_document"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text to insert"
|
||||
"description": "Text to insert. Required for: insert_text"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Character index to insert at (1 for start of body). Use -1 to append at end.",
|
||||
"default": -1
|
||||
"description": "Character index (1 for start of body, -1 to append at end). Required for: insert_table. Used by: insert_text (default: -1)"
|
||||
},
|
||||
"segment_id": {
|
||||
"type": "string",
|
||||
"description": "Segment ID (empty string for body, or a header/footer ID)",
|
||||
"description": "Segment ID (empty for body, or a header/footer ID). Used by: insert_text, delete_content",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "text"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "delete_content" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive)"
|
||||
"description": "Start index (inclusive). Required for: delete_content, format_text, format_paragraph, create_list"
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive)"
|
||||
},
|
||||
"segment_id": {
|
||||
"type": "string",
|
||||
"description": "Segment ID (empty for body)",
|
||||
"default": ""
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "start_index", "end_index"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "replace_text" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
"description": "End index (exclusive). Required for: delete_content, format_text, format_paragraph, create_list"
|
||||
},
|
||||
"find": {
|
||||
"type": "string",
|
||||
"description": "Text to search for"
|
||||
"description": "Text to search for. Required for: replace_text"
|
||||
},
|
||||
"replace": {
|
||||
"type": "string",
|
||||
"description": "Replacement text"
|
||||
"description": "Replacement text. Required for: replace_text"
|
||||
},
|
||||
"match_case": {
|
||||
"type": "boolean",
|
||||
"description": "Case-sensitive match (default: true)",
|
||||
"description": "Case-sensitive match (default: true). Used by: replace_text",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "find", "replace"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "format_text" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive)"
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive)"
|
||||
},
|
||||
"bold": {
|
||||
"type": "boolean",
|
||||
"description": "Make text bold"
|
||||
"description": "Make text bold. Used by: format_text"
|
||||
},
|
||||
"italic": {
|
||||
"type": "boolean",
|
||||
"description": "Make text italic"
|
||||
"description": "Make text italic. Used by: format_text"
|
||||
},
|
||||
"underline": {
|
||||
"type": "boolean",
|
||||
"description": "Underline text"
|
||||
"description": "Underline text. Used by: format_text"
|
||||
},
|
||||
"strikethrough": {
|
||||
"type": "boolean",
|
||||
"description": "Strikethrough text"
|
||||
"description": "Strikethrough text. Used by: format_text"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number",
|
||||
"description": "Font size in points (e.g., 12, 14, 18)"
|
||||
"description": "Font size in points (e.g., 12, 14, 18). Used by: format_text"
|
||||
},
|
||||
"font_family": {
|
||||
"type": "string",
|
||||
"description": "Font family (e.g., 'Arial', 'Times New Roman', 'Courier New')"
|
||||
"description": "Font family (e.g., 'Arial', 'Times New Roman'). Used by: format_text"
|
||||
},
|
||||
"foreground_color": {
|
||||
"type": "string",
|
||||
"description": "Text color as hex (e.g., '#FF0000' for red)"
|
||||
"description": "Text color as hex (e.g., '#FF0000'). Used by: format_text"
|
||||
},
|
||||
"background_color": {
|
||||
"type": "string",
|
||||
"description": "Text background/highlight color as hex"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "start_index", "end_index"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "format_paragraph" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive)"
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive)"
|
||||
"description": "Text background/highlight color as hex. Used by: format_text"
|
||||
},
|
||||
"named_style": {
|
||||
"type": "string",
|
||||
"enum": ["NORMAL_TEXT", "TITLE", "SUBTITLE", "HEADING_1", "HEADING_2", "HEADING_3", "HEADING_4", "HEADING_5", "HEADING_6"],
|
||||
"description": "Paragraph style (heading level)"
|
||||
"description": "Paragraph style (heading level). Used by: format_paragraph"
|
||||
},
|
||||
"alignment": {
|
||||
"type": "string",
|
||||
"enum": ["START", "CENTER", "END", "JUSTIFIED"],
|
||||
"description": "Text alignment"
|
||||
"description": "Text alignment. Used by: format_paragraph"
|
||||
},
|
||||
"line_spacing": {
|
||||
"type": "number",
|
||||
"description": "Line spacing as percentage (e.g., 100 for single, 150 for 1.5x, 200 for double)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "start_index", "end_index"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "insert_table" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
"description": "Line spacing as percentage (100=single, 150=1.5x, 200=double). Used by: format_paragraph"
|
||||
},
|
||||
"rows": {
|
||||
"type": "integer",
|
||||
"description": "Number of rows"
|
||||
"description": "Number of rows. Required for: insert_table"
|
||||
},
|
||||
"columns": {
|
||||
"type": "integer",
|
||||
"description": "Number of columns"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Character index to insert the table at"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "rows", "columns", "index"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_list" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive)"
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive)"
|
||||
"description": "Number of columns. Required for: insert_table"
|
||||
},
|
||||
"bullet_preset": {
|
||||
"type": "string",
|
||||
"enum": ["BULLET_DISC_CIRCLE_SQUARE", "BULLET_CHECKBOX", "BULLET_ARROW_DIAMOND_DISC", "NUMBERED_DECIMAL_ALPHA_ROMAN", "NUMBERED_DECIMAL_NESTED", "NUMBERED_UPPERALPHA_ALPHA_ROMAN"],
|
||||
"description": "Bullet style preset (default: BULLET_DISC_CIRCLE_SQUARE)",
|
||||
"description": "Bullet style preset (default: BULLET_DISC_CIRCLE_SQUARE). Used by: create_list",
|
||||
"default": "BULLET_DISC_CIRCLE_SQUARE"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "start_index", "end_index"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "batch_update" },
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "The document ID"
|
||||
},
|
||||
"requests": {
|
||||
"type": "array",
|
||||
"items": { "type": "object" },
|
||||
"description": "Array of raw Docs API batchUpdate request objects"
|
||||
"description": "Array of raw Docs API batchUpdate request objects. Required for: batch_update"
|
||||
}
|
||||
},
|
||||
"required": ["action", "document_id", "requests"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -62,215 +62,95 @@ impl exports::near::agent::tool::Guest for GoogleDriveTool {
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_files" },
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list_files", "get_file", "download_file", "upload_file", "update_file", "create_folder", "delete_file", "trash_file", "share_file", "list_permissions", "remove_permission", "list_shared_drives"],
|
||||
"description": "The Google Drive operation to perform"
|
||||
},
|
||||
"file_id": {
|
||||
"type": "string",
|
||||
"description": "File ID. Required for: get_file, download_file, update_file, delete_file, trash_file, share_file, list_permissions, remove_permission"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Drive search query. Examples: \"name contains 'report'\", \"mimeType = 'application/pdf'\", \"'folderId' in parents\", \"sharedWithMe = true\""
|
||||
"description": "Drive search query (e.g., \"name contains 'report'\", \"mimeType = 'application/pdf'\"). Used by: list_files"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer",
|
||||
"description": "Max results (default: 25, max: 1000)",
|
||||
"description": "Max results (default: 25, max: 1000). Used by: list_files, list_shared_drives",
|
||||
"default": 25
|
||||
},
|
||||
"order_by": {
|
||||
"type": "string",
|
||||
"description": "Sort order (e.g., 'modifiedTime desc', 'name')"
|
||||
"description": "Sort order (e.g., 'modifiedTime desc', 'name'). Used by: list_files"
|
||||
},
|
||||
"corpora": {
|
||||
"type": "string",
|
||||
"enum": ["user", "drive", "domain", "allDrives"],
|
||||
"description": "Search scope: 'user' (personal, default), 'drive' (specific shared drive), 'domain' (org-wide), 'allDrives' (everything)",
|
||||
"description": "Search scope: 'user' (default), 'drive' (shared drive), 'domain', 'allDrives'. Used by: list_files",
|
||||
"default": "user"
|
||||
},
|
||||
"drive_id": {
|
||||
"type": "string",
|
||||
"description": "Shared drive ID (required when corpora is 'drive')"
|
||||
"description": "Shared drive ID (required when corpora is 'drive'). Used by: list_files"
|
||||
},
|
||||
"page_token": {
|
||||
"type": "string",
|
||||
"description": "Token for next page of results"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_file" },
|
||||
"file_id": {
|
||||
"type": "string",
|
||||
"description": "The file ID"
|
||||
}
|
||||
},
|
||||
"required": ["action", "file_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "download_file" },
|
||||
"file_id": {
|
||||
"type": "string",
|
||||
"description": "The file ID to download"
|
||||
"description": "Token for next page of results. Used by: list_files"
|
||||
},
|
||||
"export_mime_type": {
|
||||
"type": "string",
|
||||
"description": "Export format for Google Workspace files (e.g., 'text/plain', 'text/csv', 'application/pdf')"
|
||||
}
|
||||
"description": "Export format for Google Workspace files (e.g., 'text/plain', 'text/csv'). Used by: download_file"
|
||||
},
|
||||
"required": ["action", "file_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "upload_file" },
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "File name"
|
||||
"description": "File/folder name. Required for: upload_file, create_folder. Optional for: update_file"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "File content (text)"
|
||||
"description": "File content (text). Required for: upload_file"
|
||||
},
|
||||
"mime_type": {
|
||||
"type": "string",
|
||||
"description": "MIME type (default: 'text/plain')",
|
||||
"description": "MIME type (default: 'text/plain'). Used by: upload_file",
|
||||
"default": "text/plain"
|
||||
},
|
||||
"parent_id": {
|
||||
"type": "string",
|
||||
"description": "Parent folder ID (omit for root)"
|
||||
"description": "Parent folder ID (omit for root). Used by: upload_file, create_folder"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "File description"
|
||||
}
|
||||
},
|
||||
"required": ["action", "name", "content"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "update_file" },
|
||||
"file_id": {
|
||||
"type": "string",
|
||||
"description": "The file ID to update"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "New file name"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "New description"
|
||||
"description": "File/folder description. Used by: upload_file, update_file, create_folder"
|
||||
},
|
||||
"move_to_parent": {
|
||||
"type": "string",
|
||||
"description": "Move file to this folder ID"
|
||||
"description": "Move file to this folder ID. Used by: update_file"
|
||||
},
|
||||
"starred": {
|
||||
"type": "boolean",
|
||||
"description": "Star or unstar the file"
|
||||
}
|
||||
},
|
||||
"required": ["action", "file_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_folder" },
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Folder name"
|
||||
},
|
||||
"parent_id": {
|
||||
"type": "string",
|
||||
"description": "Parent folder ID (omit for root)"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Folder description"
|
||||
}
|
||||
},
|
||||
"required": ["action", "name"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "delete_file" },
|
||||
"file_id": {
|
||||
"type": "string",
|
||||
"description": "The file ID to permanently delete"
|
||||
}
|
||||
},
|
||||
"required": ["action", "file_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "trash_file" },
|
||||
"file_id": {
|
||||
"type": "string",
|
||||
"description": "The file ID to move to trash"
|
||||
}
|
||||
},
|
||||
"required": ["action", "file_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "share_file" },
|
||||
"file_id": {
|
||||
"type": "string",
|
||||
"description": "The file ID to share"
|
||||
"description": "Star or unstar the file. Used by: update_file"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Recipient email address"
|
||||
"description": "Recipient email address. Required for: share_file"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["reader", "commenter", "writer", "organizer"],
|
||||
"description": "Permission level (default: 'reader')",
|
||||
"description": "Permission level (default: 'reader'). Used by: share_file",
|
||||
"default": "reader"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Optional message in sharing notification"
|
||||
}
|
||||
},
|
||||
"required": ["action", "file_id", "email"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_permissions" },
|
||||
"file_id": {
|
||||
"type": "string",
|
||||
"description": "The file ID to check permissions for"
|
||||
}
|
||||
},
|
||||
"required": ["action", "file_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "remove_permission" },
|
||||
"file_id": {
|
||||
"type": "string",
|
||||
"description": "The file ID"
|
||||
"description": "Optional message in sharing notification. Used by: share_file"
|
||||
},
|
||||
"permission_id": {
|
||||
"type": "string",
|
||||
"description": "The permission ID to remove (get from list_permissions)"
|
||||
"description": "Permission ID to remove (from list_permissions). Required for: remove_permission"
|
||||
}
|
||||
},
|
||||
"required": ["action", "file_id", "permission_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_shared_drives" },
|
||||
"page_size": {
|
||||
"type": "integer",
|
||||
"description": "Max results (default: 25)",
|
||||
"default": 25
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -70,236 +70,100 @@ impl exports::near::agent::tool::Guest for GoogleSheetsTool {
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_spreadsheet" },
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["create_spreadsheet", "get_spreadsheet", "read_values", "batch_read_values", "write_values", "append_values", "clear_values", "add_sheet", "delete_sheet", "rename_sheet", "format_cells"],
|
||||
"description": "The Google Sheets operation to perform"
|
||||
},
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "Spreadsheet ID (same as Google Drive file ID). Required for all actions except create_spreadsheet"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Spreadsheet title"
|
||||
"description": "Title/name. Required for: create_spreadsheet, add_sheet, rename_sheet"
|
||||
},
|
||||
"sheet_names": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Names for sheets (tabs). Defaults to ['Sheet1'] if omitted."
|
||||
}
|
||||
},
|
||||
"required": ["action", "title"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_spreadsheet" },
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "The spreadsheet ID (same as Google Drive file ID)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "spreadsheet_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "read_values" },
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "The spreadsheet ID"
|
||||
"description": "Names for sheets (tabs, defaults to ['Sheet1']). Used by: create_spreadsheet"
|
||||
},
|
||||
"range": {
|
||||
"type": "string",
|
||||
"description": "A1 notation range (e.g., 'Sheet1!A1:D10', 'A1:B5')"
|
||||
}
|
||||
},
|
||||
"required": ["action", "spreadsheet_id", "range"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "batch_read_values" },
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "The spreadsheet ID"
|
||||
"description": "A1 notation range (e.g., 'Sheet1!A1:D10'). Required for: read_values, write_values, append_values, clear_values"
|
||||
},
|
||||
"ranges": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "List of A1 notation ranges to read"
|
||||
}
|
||||
},
|
||||
"required": ["action", "spreadsheet_id", "ranges"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "write_values" },
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "The spreadsheet ID"
|
||||
},
|
||||
"range": {
|
||||
"type": "string",
|
||||
"description": "A1 notation range (e.g., 'Sheet1!A1')"
|
||||
"description": "List of A1 notation ranges. Required for: batch_read_values"
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": { "type": "array" },
|
||||
"description": "2D array of values (rows of columns)"
|
||||
"description": "2D array of values (rows of columns). Required for: write_values, append_values"
|
||||
},
|
||||
"value_input_option": {
|
||||
"type": "string",
|
||||
"enum": ["RAW", "USER_ENTERED"],
|
||||
"description": "How to interpret input. USER_ENTERED (default) parses like typing in the UI. RAW stores as-is.",
|
||||
"description": "How to interpret input (USER_ENTERED parses like the UI, RAW stores as-is, default: USER_ENTERED). Used by: write_values, append_values",
|
||||
"default": "USER_ENTERED"
|
||||
}
|
||||
},
|
||||
"required": ["action", "spreadsheet_id", "range", "values"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "append_values" },
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "The spreadsheet ID"
|
||||
},
|
||||
"range": {
|
||||
"type": "string",
|
||||
"description": "A1 notation range to find the table (e.g., 'Sheet1!A:E')"
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": { "type": "array" },
|
||||
"description": "Rows to append (2D array)"
|
||||
},
|
||||
"value_input_option": {
|
||||
"type": "string",
|
||||
"enum": ["RAW", "USER_ENTERED"],
|
||||
"description": "How to interpret input (default: USER_ENTERED)",
|
||||
"default": "USER_ENTERED"
|
||||
}
|
||||
},
|
||||
"required": ["action", "spreadsheet_id", "range", "values"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "clear_values" },
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "The spreadsheet ID"
|
||||
},
|
||||
"range": {
|
||||
"type": "string",
|
||||
"description": "A1 notation range to clear"
|
||||
}
|
||||
},
|
||||
"required": ["action", "spreadsheet_id", "range"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "add_sheet" },
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "The spreadsheet ID"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Name for the new sheet (tab)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "spreadsheet_id", "title"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "delete_sheet" },
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "The spreadsheet ID"
|
||||
},
|
||||
"sheet_id": {
|
||||
"type": "integer",
|
||||
"description": "Numeric sheet ID (get from get_spreadsheet, NOT the sheet name)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "spreadsheet_id", "sheet_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "rename_sheet" },
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "The spreadsheet ID"
|
||||
},
|
||||
"sheet_id": {
|
||||
"type": "integer",
|
||||
"description": "Numeric sheet ID"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "New name for the sheet"
|
||||
}
|
||||
},
|
||||
"required": ["action", "spreadsheet_id", "sheet_id", "title"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "format_cells" },
|
||||
"spreadsheet_id": {
|
||||
"type": "string",
|
||||
"description": "The spreadsheet ID"
|
||||
},
|
||||
"sheet_id": {
|
||||
"type": "integer",
|
||||
"description": "Numeric sheet ID"
|
||||
"description": "Numeric sheet ID (from get_spreadsheet, NOT the sheet name). Required for: delete_sheet, rename_sheet, format_cells"
|
||||
},
|
||||
"start_row": {
|
||||
"type": "integer",
|
||||
"description": "Start row (0-indexed, inclusive)"
|
||||
"description": "Start row (0-indexed, inclusive). Required for: format_cells"
|
||||
},
|
||||
"end_row": {
|
||||
"type": "integer",
|
||||
"description": "End row (0-indexed, exclusive)"
|
||||
"description": "End row (0-indexed, exclusive). Required for: format_cells"
|
||||
},
|
||||
"start_column": {
|
||||
"type": "integer",
|
||||
"description": "Start column (0-indexed, inclusive)"
|
||||
"description": "Start column (0-indexed, inclusive). Required for: format_cells"
|
||||
},
|
||||
"end_column": {
|
||||
"type": "integer",
|
||||
"description": "End column (0-indexed, exclusive)"
|
||||
"description": "End column (0-indexed, exclusive). Required for: format_cells"
|
||||
},
|
||||
"bold": {
|
||||
"type": "boolean",
|
||||
"description": "Make text bold"
|
||||
"description": "Make text bold. Used by: format_cells"
|
||||
},
|
||||
"italic": {
|
||||
"type": "boolean",
|
||||
"description": "Make text italic"
|
||||
"description": "Make text italic. Used by: format_cells"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "integer",
|
||||
"description": "Font size in points"
|
||||
"description": "Font size in points. Used by: format_cells"
|
||||
},
|
||||
"text_color": {
|
||||
"type": "string",
|
||||
"description": "Text color as hex (e.g., '#FF0000' for red)"
|
||||
"description": "Text color as hex (e.g., '#FF0000'). Used by: format_cells"
|
||||
},
|
||||
"background_color": {
|
||||
"type": "string",
|
||||
"description": "Cell background color as hex (e.g., '#FFFF00' for yellow)"
|
||||
"description": "Cell background color as hex (e.g., '#FFFF00'). Used by: format_cells"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"type": "string",
|
||||
"enum": ["LEFT", "CENTER", "RIGHT"],
|
||||
"description": "Horizontal text alignment"
|
||||
"description": "Horizontal text alignment. Used by: format_cells"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string",
|
||||
"description": "Number format pattern (e.g., '#,##0.00', 'yyyy-mm-dd', '$#,##0')"
|
||||
"description": "Number format pattern (e.g., '#,##0.00', 'yyyy-mm-dd'). Used by: format_cells"
|
||||
},
|
||||
"number_format_type": {
|
||||
"type": "string",
|
||||
"enum": ["NUMBER", "CURRENCY", "PERCENT", "DATE", "TIME", "TEXT"],
|
||||
"description": "Type of number format (default: NUMBER)"
|
||||
"description": "Type of number format (default: NUMBER). Used by: format_cells"
|
||||
}
|
||||
},
|
||||
"required": ["action", "spreadsheet_id", "sheet_id", "start_row", "end_row", "start_column", "end_column"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -79,326 +79,124 @@ impl exports::near::agent::tool::Guest for GoogleSlidesTool {
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_presentation" },
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["create_presentation", "get_presentation", "get_thumbnail", "create_slide", "delete_object", "insert_text", "delete_text", "replace_all_text", "create_shape", "insert_image", "format_text", "format_paragraph", "replace_shapes_with_image", "batch_update"],
|
||||
"description": "The Google Slides operation to perform"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Presentation title"
|
||||
}
|
||||
"description": "Presentation title. Required for: create_presentation"
|
||||
},
|
||||
"required": ["action", "title"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_presentation" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID (same as Google Drive file ID)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_thumbnail" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
"description": "Presentation ID (same as Google Drive file ID). Required for all actions except create_presentation"
|
||||
},
|
||||
"slide_object_id": {
|
||||
"type": "string",
|
||||
"description": "The slide's object ID"
|
||||
}
|
||||
"description": "Slide object ID. Required for: get_thumbnail, create_shape, insert_image"
|
||||
},
|
||||
"required": ["action", "presentation_id", "slide_object_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_slide" },
|
||||
"presentation_id": {
|
||||
"object_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
"description": "Object ID of a slide element. Required for: delete_object, insert_text, delete_text, format_text, format_paragraph"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text to insert. Required for: insert_text"
|
||||
},
|
||||
"insertion_index": {
|
||||
"type": "integer",
|
||||
"description": "Position to insert (0-based). Omit to append at end."
|
||||
"description": "Position to insert at (0-based). Used by: create_slide (omit to append at end), insert_text (default: 0)"
|
||||
},
|
||||
"layout": {
|
||||
"type": "string",
|
||||
"enum": ["BLANK", "TITLE", "TITLE_AND_BODY", "TITLE_AND_TWO_COLUMNS", "TITLE_ONLY", "SECTION_HEADER", "CAPTION_ONLY", "BIG_NUMBER", "ONE_COLUMN_TEXT", "MAIN_POINT"],
|
||||
"description": "Predefined layout (default: BLANK)",
|
||||
"description": "Predefined slide layout (default: BLANK). Used by: create_slide",
|
||||
"default": "BLANK"
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "delete_object" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
},
|
||||
"object_id": {
|
||||
"type": "string",
|
||||
"description": "Object ID of the slide or element to delete"
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id", "object_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "insert_text" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
},
|
||||
"object_id": {
|
||||
"type": "string",
|
||||
"description": "Object ID of the shape or text box"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text to insert"
|
||||
},
|
||||
"insertion_index": {
|
||||
"type": "integer",
|
||||
"description": "Character index to insert at (0-based). Default: 0.",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id", "object_id", "text"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "delete_text" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
},
|
||||
"object_id": {
|
||||
"type": "string",
|
||||
"description": "Object ID of the shape"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive, 0-based)",
|
||||
"default": 0
|
||||
"description": "Start index (inclusive, 0-based). Used by: delete_text, format_text, format_paragraph"
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive). Omit to delete from start_index to end."
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id", "object_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "replace_all_text" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
"description": "End index (exclusive). Used by: delete_text, format_text, format_paragraph"
|
||||
},
|
||||
"find": {
|
||||
"type": "string",
|
||||
"description": "Text to search for"
|
||||
"description": "Text to search for. Required for: replace_all_text, replace_shapes_with_image"
|
||||
},
|
||||
"replace": {
|
||||
"type": "string",
|
||||
"description": "Replacement text"
|
||||
"description": "Replacement text. Required for: replace_all_text"
|
||||
},
|
||||
"match_case": {
|
||||
"type": "boolean",
|
||||
"description": "Case-sensitive match (default: true)",
|
||||
"description": "Case-sensitive match (default: true). Used by: replace_all_text, replace_shapes_with_image",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id", "find", "replace"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_shape" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
},
|
||||
"slide_object_id": {
|
||||
"type": "string",
|
||||
"description": "Slide object ID to place the shape on"
|
||||
},
|
||||
"shape_type": {
|
||||
"type": "string",
|
||||
"enum": ["TEXT_BOX", "RECTANGLE", "ROUND_RECTANGLE", "ELLIPSE"],
|
||||
"description": "Shape type (default: TEXT_BOX)",
|
||||
"description": "Shape type (default: TEXT_BOX). Used by: create_shape",
|
||||
"default": "TEXT_BOX"
|
||||
},
|
||||
"x": {
|
||||
"type": "number",
|
||||
"description": "X position in points from left edge"
|
||||
"description": "X position in points from left edge. Required for: create_shape, insert_image"
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"description": "Y position in points from top edge"
|
||||
"description": "Y position in points from top edge. Required for: create_shape, insert_image"
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"description": "Width in points"
|
||||
"description": "Width in points. Required for: create_shape, insert_image"
|
||||
},
|
||||
"height": {
|
||||
"type": "number",
|
||||
"description": "Height in points"
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id", "slide_object_id", "x", "y", "width", "height"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "insert_image" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
},
|
||||
"slide_object_id": {
|
||||
"type": "string",
|
||||
"description": "Slide object ID to place the image on"
|
||||
"description": "Height in points. Required for: create_shape, insert_image"
|
||||
},
|
||||
"image_url": {
|
||||
"type": "string",
|
||||
"description": "Publicly accessible image URL"
|
||||
},
|
||||
"x": {
|
||||
"type": "number",
|
||||
"description": "X position in points"
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"description": "Y position in points"
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"description": "Width in points"
|
||||
},
|
||||
"height": {
|
||||
"type": "number",
|
||||
"description": "Height in points"
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id", "slide_object_id", "image_url", "x", "y", "width", "height"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "format_text" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
},
|
||||
"object_id": {
|
||||
"type": "string",
|
||||
"description": "Object ID of the shape"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive). Omit to format all text."
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive). Omit to format to end."
|
||||
"description": "Publicly accessible image URL. Required for: insert_image, replace_shapes_with_image"
|
||||
},
|
||||
"bold": {
|
||||
"type": "boolean",
|
||||
"description": "Make text bold"
|
||||
"description": "Make text bold. Used by: format_text"
|
||||
},
|
||||
"italic": {
|
||||
"type": "boolean",
|
||||
"description": "Make text italic"
|
||||
"description": "Make text italic. Used by: format_text"
|
||||
},
|
||||
"underline": {
|
||||
"type": "boolean",
|
||||
"description": "Underline text"
|
||||
"description": "Underline text. Used by: format_text"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number",
|
||||
"description": "Font size in points (e.g., 12, 18, 24)"
|
||||
"description": "Font size in points (e.g., 12, 18, 24). Used by: format_text"
|
||||
},
|
||||
"font_family": {
|
||||
"type": "string",
|
||||
"description": "Font family (e.g., 'Arial', 'Roboto', 'Times New Roman')"
|
||||
"description": "Font family (e.g., 'Arial', 'Roboto'). Used by: format_text"
|
||||
},
|
||||
"foreground_color": {
|
||||
"type": "string",
|
||||
"description": "Text color as hex (e.g., '#FF0000' for red)"
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id", "object_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "format_paragraph" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
},
|
||||
"object_id": {
|
||||
"type": "string",
|
||||
"description": "Object ID of the shape"
|
||||
"description": "Text color as hex (e.g., '#FF0000'). Used by: format_text"
|
||||
},
|
||||
"alignment": {
|
||||
"type": "string",
|
||||
"enum": ["START", "CENTER", "END", "JUSTIFIED"],
|
||||
"description": "Paragraph alignment"
|
||||
},
|
||||
"start_index": {
|
||||
"type": "integer",
|
||||
"description": "Start index (inclusive). Omit to format all."
|
||||
},
|
||||
"end_index": {
|
||||
"type": "integer",
|
||||
"description": "End index (exclusive). Omit to format to end."
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id", "object_id", "alignment"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "replace_shapes_with_image" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
},
|
||||
"find": {
|
||||
"type": "string",
|
||||
"description": "Text to match in shapes"
|
||||
},
|
||||
"image_url": {
|
||||
"type": "string",
|
||||
"description": "Image URL to replace matched shapes with"
|
||||
},
|
||||
"match_case": {
|
||||
"type": "boolean",
|
||||
"description": "Case-sensitive match (default: true)",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id", "find", "image_url"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "batch_update" },
|
||||
"presentation_id": {
|
||||
"type": "string",
|
||||
"description": "The presentation ID"
|
||||
"description": "Paragraph alignment. Required for: format_paragraph"
|
||||
},
|
||||
"requests": {
|
||||
"type": "array",
|
||||
"items": { "type": "object" },
|
||||
"description": "Array of raw Slides API batchUpdate request objects"
|
||||
"description": "Array of raw Slides API batchUpdate request objects. Required for: batch_update"
|
||||
}
|
||||
},
|
||||
"required": ["action", "presentation_id", "requests"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -54,56 +54,25 @@ impl exports::near::agent::tool::Guest for OktaTool {
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_profile" }
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get_profile", "update_profile", "list_apps", "search_apps", "get_app_sso_link", "get_org_info"],
|
||||
"description": "The Okta operation to perform"
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "update_profile" },
|
||||
"fields": {
|
||||
"type": "object",
|
||||
"description": "Profile fields to update. Common: firstName, lastName, email, mobilePhone, displayName, nickName, title, department, organization"
|
||||
}
|
||||
"description": "Profile fields to update (e.g., firstName, lastName, email, mobilePhone, displayName, title, department). Required for: update_profile"
|
||||
},
|
||||
"required": ["action", "fields"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_apps" }
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "search_apps" },
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Case-insensitive search query to match against app labels and names"
|
||||
}
|
||||
"description": "Case-insensitive search query to match against app labels and names. Required for: search_apps"
|
||||
},
|
||||
"required": ["action", "query"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_app_sso_link" },
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "App instance ID (e.g., '0oa1xxx') or app label (e.g., 'Google Workspace')"
|
||||
"description": "App instance ID (e.g., '0oa1xxx') or app label (e.g., 'Google Workspace'). Required for: get_app_sso_link"
|
||||
}
|
||||
},
|
||||
"required": ["action", "app"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_org_info" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
+12
-52
@@ -53,84 +53,44 @@ impl exports::near::agent::tool::Guest for SlackTool {
|
||||
}
|
||||
|
||||
fn schema() -> String {
|
||||
// JSON Schema for the tool's parameters
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "send_message" },
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["send_message", "list_channels", "get_channel_history", "post_reaction", "get_user_info"],
|
||||
"description": "The Slack operation to perform"
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Channel ID or name (e.g., '#general' or 'C1234567890')"
|
||||
"description": "Channel ID or name (e.g., '#general' or 'C1234567890'). Required for: send_message, get_channel_history, post_reaction"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Message text (supports Slack mrkdwn formatting)"
|
||||
"description": "Message text (supports Slack mrkdwn formatting). Required for: send_message"
|
||||
},
|
||||
"thread_ts": {
|
||||
"type": "string",
|
||||
"description": "Optional thread timestamp to reply in a thread"
|
||||
}
|
||||
},
|
||||
"required": ["action", "channel", "text"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_channels" },
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of channels to return (default: 100)",
|
||||
"default": 100
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_channel_history" },
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Channel ID (e.g., 'C1234567890')"
|
||||
"description": "Thread timestamp to reply in a thread. Used by: send_message"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages to return (default: 20)",
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
"required": ["action", "channel"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "post_reaction" },
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Channel ID containing the message"
|
||||
"description": "Maximum number of results to return. Used by: list_channels, get_channel_history"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"description": "Timestamp of the message to react to"
|
||||
"description": "Timestamp of the message to react to. Required for: post_reaction"
|
||||
},
|
||||
"emoji": {
|
||||
"type": "string",
|
||||
"description": "Emoji name without colons (e.g., 'thumbsup')"
|
||||
}
|
||||
"description": "Emoji name without colons (e.g., 'thumbsup'). Required for: post_reaction"
|
||||
},
|
||||
"required": ["action", "channel", "timestamp", "emoji"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_user_info" },
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"description": "User ID (e.g., 'U1234567890')"
|
||||
"description": "User ID (e.g., 'U1234567890'). Required for: get_user_info"
|
||||
}
|
||||
},
|
||||
"required": ["action", "user_id"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
+17
-107
@@ -248,154 +248,64 @@ fn get_api_hash() -> Result<String, String> {
|
||||
const SCHEMA: &str = r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "login" },
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["login", "submit_auth_code", "submit_2fa_password", "get_me", "get_contacts", "get_chats", "get_messages", "send_message", "forward_message", "delete_message", "search_messages", "get_updates"],
|
||||
"description": "The Telegram operation to perform"
|
||||
},
|
||||
"phone_number": {
|
||||
"type": "string",
|
||||
"description": "Phone number in international format (e.g., '+1234567890')"
|
||||
}
|
||||
"description": "Phone number in international format (e.g., '+1234567890'). Required for: login"
|
||||
},
|
||||
"required": ["action", "phone_number"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "submit_auth_code" },
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Verification code received via SMS or Telegram"
|
||||
}
|
||||
"description": "Verification code received via SMS or Telegram. Required for: submit_auth_code"
|
||||
},
|
||||
"required": ["action", "code"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "submit_2fa_password" },
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Two-factor authentication password"
|
||||
}
|
||||
"description": "Two-factor authentication password. Required for: submit_2fa_password"
|
||||
},
|
||||
"required": ["action", "password"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_me" }
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_contacts" }
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_chats" },
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of chats to return (default: 20)",
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_messages" },
|
||||
"chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Chat ID (negative for groups/channels)"
|
||||
"description": "Chat ID (negative for groups/channels). Required for: get_messages, send_message. Optional for: search_messages"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages (default: 20)",
|
||||
"description": "Maximum number of results (default: 20). Used by: get_chats, get_messages, search_messages",
|
||||
"default": 20
|
||||
},
|
||||
"from_message_id": {
|
||||
"type": "integer",
|
||||
"description": "Start from this message ID for pagination"
|
||||
}
|
||||
},
|
||||
"required": ["action", "chat_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "send_message" },
|
||||
"chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Chat ID to send the message to"
|
||||
"description": "Start from this message ID for pagination. Used by: get_messages"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Message text"
|
||||
}
|
||||
"description": "Message text. Required for: send_message"
|
||||
},
|
||||
"required": ["action", "chat_id", "text"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "forward_message" },
|
||||
"from_chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Source chat ID"
|
||||
"description": "Source chat ID. Required for: forward_message"
|
||||
},
|
||||
"to_chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Destination chat ID"
|
||||
"description": "Destination chat ID. Required for: forward_message"
|
||||
},
|
||||
"message_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" },
|
||||
"description": "Message IDs to forward"
|
||||
}
|
||||
},
|
||||
"required": ["action", "from_chat_id", "to_chat_id", "message_ids"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "delete_message" },
|
||||
"message_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" },
|
||||
"description": "Message IDs to delete"
|
||||
"description": "Message IDs. Required for: forward_message, delete_message"
|
||||
},
|
||||
"revoke": {
|
||||
"type": "boolean",
|
||||
"description": "Also delete for other participants (default: false)",
|
||||
"description": "Also delete for other participants (default: false). Used by: delete_message",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["action", "message_ids"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "search_messages" },
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query"
|
||||
},
|
||||
"chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Chat ID to search within (omit for global search)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results (default: 20)",
|
||||
"default": 20
|
||||
"description": "Search query. Required for: search_messages"
|
||||
}
|
||||
},
|
||||
"required": ["action", "query"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_updates" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
export!(TelegramTool);
|
||||
|
||||
Reference in New Issue
Block a user