From 2486065fa787a554750bd5b5e78b8d9a8384069b Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 5 Feb 2026 00:59:44 -0800 Subject: [PATCH 1/3] Fix build_software tool stuck in planning mode loop The builder would get stuck when the LLM returned JSON specs or planning text instead of tool calls. The loop would continue for all iterations without making progress, eventually timing out. Changes: - Make initial prompt directive: "Use write_file NOW" instead of passive "Start by creating the project structure" - Add consecutive_text_responses counter to detect stuck state - Fail fast after 2 consecutive text-only responses with clear error - Send strong nudge on first text response: "STOP. Call write_file..." - Reset counter once tools have been executed (completion phase) Co-Authored-By: Claude Opus 4.5 --- src/tools/builder/core.rs | 62 ++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs index 839ff23f..cb441e2a 100644 --- a/src/tools/builder/core.rs +++ b/src/tools/builder/core.rs @@ -532,9 +532,12 @@ Create alongside the .wasm file to grant capabilities: .messages .push(ChatMessage::system(self.build_system_prompt(requirement))); - // Add initial user message + // Add initial user message - directive to force immediate tool use reason_ctx.messages.push(ChatMessage::user(format!( - "Build the {} in directory: {}\n\nRequirements:\n- {}\n\nStart by creating the project structure.", + "Build the {} in directory: {}\n\n\ + Requirements:\n- {}\n\n\ + IMPORTANT: Use the write_file tool NOW to create Cargo.toml. \ + Do not explain, plan, or output JSON—immediately call write_file.", requirement.name, project_dir.display(), requirement.description @@ -551,6 +554,7 @@ Create alongside the .wasm file to grant capabilities: let mut current_phase = BuildPhase::Scaffolding; let mut last_error: Option = None; let mut tools_executed = false; + let mut consecutive_text_responses = 0; loop { iteration += 1; @@ -593,27 +597,65 @@ Create alongside the .wasm file to grant capabilities: match result { RespondResult::Text(response) => { - // If no tools have been executed, prompt for tool use - if !tools_executed && iteration < 3 { + reason_ctx.messages.push(ChatMessage::assistant(&response)); + + // If tools haven't been executed yet, we're stuck in planning mode + if !tools_executed { + consecutive_text_responses += 1; + + // Fail fast after 2 consecutive text-only responses + if consecutive_text_responses >= 2 { + logs.push(BuildLog { + timestamp: Utc::now(), + phase: BuildPhase::Failed, + message: "Builder stuck in planning mode".into(), + details: Some(format!( + "LLM returned {} consecutive text responses without calling tools. \ + Try a more specific requirement.", + consecutive_text_responses + )), + }); + + return Ok(BuildResult { + build_id, + requirement: requirement.clone(), + artifact_path: project_dir.to_path_buf(), + logs, + success: false, + error: Some( + "LLM not executing tools - stuck in planning mode".into(), + ), + started_at, + completed_at: Utc::now(), + iterations: iteration, + validation_warnings: Vec::new(), + tests_passed: 0, + tests_failed: 0, + registered: false, + }); + } + tracing::debug!( - "Builder: no tools executed yet (iteration {}), prompting for action", - iteration + "Builder: no tools executed (text response #{}/2), forcing tool use", + consecutive_text_responses ); - reason_ctx.messages.push(ChatMessage::assistant(&response)); reason_ctx.messages.push(ChatMessage::user( - "Please use the available tools to implement this. Start by creating the necessary files.", + "STOP. Do NOT output text, JSON specs, or explanations. \ + Call the write_file tool RIGHT NOW to create Cargo.toml. \ + Just call the tool—no commentary.", )); continue; } - reason_ctx.messages.push(ChatMessage::assistant(&response)); + // Reset counter when tools have been executed (we're in completion phase) + consecutive_text_responses = 0; // Check for completion signals let response_lower = response.to_lowercase(); if response_lower.contains("build complete") || response_lower.contains("successfully built") || response_lower.contains("all tests pass") - || (tools_executed && response_lower.contains("complete")) + || response_lower.contains("complete") { logs.push(BuildLog { timestamp: Utc::now(), From 598dd43b1c1a93b4587c354338c6c245f757625a Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 5 Feb 2026 01:10:40 -0800 Subject: [PATCH 2/3] Rebrand to IronClaw with security-first mission Renamed project from "near-agent" to "ironclaw" throughout the codebase. Updated documentation to emphasize the core philosophy: - Your data stays yours (local, encrypted, no telemetry) - Self-expanding capabilities (build tools on the fly) - Defense in depth (WASM sandbox, prompt injection defense) - Always on user's side Key changes: - Package name: near-agent -> ironclaw - Config paths: ~/.near-agent/ -> ~/.ironclaw/ - Database name in docs: near_agent -> ironclaw - CLI binary: near-agent -> ironclaw - Log filters: RUST_LOG=near_agent -> RUST_LOG=ironclaw - All user-facing strings (welcome messages, help text, etc.) Preserved for compatibility: - HKDF salt "near-agent-secrets-v1" (changing would break existing secrets) - WIT interface names (near::agent::*) - NEAR AI provider config (NEARAI_* env vars) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 26 +++-- Cargo.lock | 112 +++++++++--------- Cargo.toml | 4 +- FEATURE_PARITY.md | 4 +- README.md | 167 +++++++++++++++------------ channels-src/slack/Cargo.toml | 2 +- channels-src/slack/src/lib.rs | 2 +- channels-src/telegram/Cargo.toml | 2 +- channels-src/telegram/src/lib.rs | 2 +- channels/whatsapp/Cargo.toml | 2 +- channels/whatsapp/src/lib.rs | 2 +- docs/BUILDING_CHANNELS.md | 12 +- examples/wasm-tools/slack/Cargo.toml | 2 +- examples/wasm-tools/slack/README.md | 16 +-- examples/wasm-tools/slack/src/lib.rs | 2 +- src/channels/cli/app.rs | 2 +- src/channels/repl.rs | 4 +- src/channels/wasm/loader.rs | 6 +- src/channels/wasm/mod.rs | 4 +- src/cli/mod.rs | 6 +- src/cli/tool.rs | 16 +-- src/config.rs | 22 ++-- src/error.rs | 2 +- src/llm/session.rs | 10 +- src/main.rs | 12 +- src/sandbox/mod.rs | 2 +- src/secrets/mod.rs | 2 +- src/settings.rs | 6 +- src/setup/mod.rs | 4 +- src/setup/prompts.rs | 2 +- src/setup/wizard.rs | 10 +- src/tools/builder/core.rs | 2 +- src/tools/wasm/loader.rs | 4 +- src/tools/wasm/mod.rs | 4 +- tests/wasm_channel_integration.rs | 14 +-- tests/workspace_integration.rs | 6 +- 36 files changed, 266 insertions(+), 231 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ae73405..1c5d81eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,17 @@ -# NEAR Agent Development Guide +# IronClaw Development Guide ## Project Overview -LLM-powered autonomous agent for the NEAR AI marketplace. Features: -- **Multi-channel input**: Full TUI (Ratatui), HTTP webhook with secret auth (Slack/Telegram stubs) +**IronClaw** is a secure personal AI assistant that protects your data and expands its capabilities on the fly. + +### Core Philosophy +- **User-first security** - Your data stays yours, encrypted and local +- **Self-expanding** - Build new tools dynamically without vendor dependency +- **Defense in depth** - Multiple security layers against prompt injection and data exfiltration +- **Always available** - Multi-channel access with proactive background execution + +### Features +- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, Telegram, WhatsApp, Slack (WASM channels) - **Parallel job execution** with state machine and self-repair for stuck jobs - **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder - **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF) @@ -26,7 +34,7 @@ cargo test cargo test test_name # Run with logging -RUST_LOG=near_agent=debug cargo run +RUST_LOG=ironclaw=debug cargo run ``` ## Project Structure @@ -201,7 +209,7 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted Environment variables (see `.env.example`): ```bash -DATABASE_URL=postgres://user:pass@localhost/near_agent +DATABASE_URL=postgres://user:pass@localhost/ironclaw # NEAR AI (required) NEARAI_SESSION_TOKEN=sess_... @@ -209,7 +217,7 @@ NEARAI_MODEL=claude-3-5-sonnet-20241022 NEARAI_BASE_URL=https://private.near.ai # Agent settings -AGENT_NAME=near-agent +AGENT_NAME=ironclaw MAX_PARALLEL_JOBS=5 # Embeddings (for semantic memory search) @@ -328,13 +336,13 @@ Key test patterns: ```bash # Verbose logging -RUST_LOG=near_agent=trace cargo run +RUST_LOG=ironclaw=trace cargo run # Just the agent module -RUST_LOG=near_agent::agent=debug cargo run +RUST_LOG=ironclaw::agent=debug cargo run # With HTTP request logging -RUST_LOG=near_agent=debug,tower_http=debug cargo run +RUST_LOG=ironclaw=debug,tower_http=debug cargo run ``` ## Code Style diff --git a/Cargo.lock b/Cargo.lock index 3f545e36..9230cda0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1876,6 +1876,62 @@ dependencies = [ "serde", ] +[[package]] +name = "ironclaw" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "aho-corasick", + "anyhow", + "async-trait", + "axum", + "blake3", + "bollard", + "bytes", + "chrono", + "clap", + "crossterm", + "deadpool-postgres", + "dirs 6.0.0", + "dotenvy", + "futures", + "hkdf", + "http-body-util", + "hyper", + "hyper-util", + "open", + "pgvector", + "postgres-types", + "pretty_assertions", + "rand 0.8.5", + "ratatui", + "refinery", + "regex", + "reqwest", + "rust_decimal", + "rust_decimal_macros", + "secrecy", + "serde", + "serde_json", + "sha2", + "tempfile", + "testcontainers-modules", + "thiserror 2.0.18", + "tokio", + "tokio-postgres", + "tokio-stream", + "tokio-test", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", + "wasmparser 0.220.1", + "wasmtime", + "wasmtime-wasi", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2127,62 +2183,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "near-agent" -version = "0.1.0" -dependencies = [ - "aes-gcm", - "aho-corasick", - "anyhow", - "async-trait", - "axum", - "blake3", - "bollard", - "bytes", - "chrono", - "clap", - "crossterm", - "deadpool-postgres", - "dirs 6.0.0", - "dotenvy", - "futures", - "hkdf", - "http-body-util", - "hyper", - "hyper-util", - "open", - "pgvector", - "postgres-types", - "pretty_assertions", - "rand 0.8.5", - "ratatui", - "refinery", - "regex", - "reqwest", - "rust_decimal", - "rust_decimal_macros", - "secrecy", - "serde", - "serde_json", - "sha2", - "tempfile", - "testcontainers-modules", - "thiserror 2.0.18", - "tokio", - "tokio-postgres", - "tokio-stream", - "tokio-test", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "urlencoding", - "uuid", - "wasmparser 0.220.1", - "wasmtime", - "wasmtime-wasi", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 8af7ba00..9f8f6e50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "near-agent" +name = "ironclaw" version = "0.1.0" edition = "2024" rust-version = "1.85" -description = "LLM-powered autonomous agent for the NEAR AI marketplace" +description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" license = "MIT OR Apache-2.0" [dependencies] diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 3d44f86f..6f791052 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -208,7 +208,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Dynamic loading | ✅ | ✅ | WASM modules | | Manifest validation | ✅ | ✅ | WASM metadata | | HTTP path registration | ✅ | ❌ | Plugin routes | -| Workspace-relative install | ✅ | ✅ | ~/.near-agent/tools/ | +| Workspace-relative install | ✅ | ✅ | ~/.ironclaw/tools/ | | Channel plugins | ✅ | ✅ | WASM channels | | Auth plugins | ✅ | ❌ | | | Memory plugins | ✅ | ❌ | Custom backends | @@ -233,7 +233,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Config validation/schema | ✅ | ✅ | Type-safe Config struct | | Hot-reload | ✅ | ❌ | | | Legacy migration | ✅ | ➖ | | -| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.near-agent/` | | +| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | | | Credentials directory | ✅ | ✅ | Session files | ### Owner: _Unassigned_ diff --git a/README.md b/README.md index 675b051a..d815c833 100644 --- a/README.md +++ b/README.md @@ -5,58 +5,60 @@

IronClaw

- LLM-powered autonomous agent for the NEAR AI marketplace + Your secure personal AI assistant, always on your side

+ PhilosophyFeatures • - ParityInstallationConfiguration • - Architecture • - Security + Security • + Architecture

--- +## Philosophy + +IronClaw is built on a simple principle: **your AI assistant should work for you, not against you**. + +In a world where AI systems are increasingly opaque about data handling and aligned with corporate interests, IronClaw takes a different approach: + +- **Your data stays yours** - All information is stored locally, encrypted, and never leaves your control +- **Transparency by design** - Open source, auditable, no hidden telemetry or data harvesting +- **Self-expanding capabilities** - Build new tools on the fly without waiting for vendor updates +- **Defense in depth** - Multiple security layers protect against prompt injection and data exfiltration + +IronClaw is the AI assistant you can actually trust with your personal and professional life. + ## Features -- **Multi-channel input** - CLI, HTTP webhooks, Slack, Telegram -- **Parallel job execution** - Concurrent task processing with isolated contexts -- **Extensible tools** - Built-in tools + MCP protocol + WASM sandbox -- **Persistent memory** - Hybrid search (FTS + vector) with chunked documents -- **Prompt injection defense** - Pattern detection, content sanitization, policy enforcement -- **Self-repair** - Automatic detection and recovery of stuck jobs -- **Heartbeat system** - Proactive periodic execution for background tasks +### Security First -## OpenClaw Feature Parity +- **WASM Sandbox** - Untrusted tools run in isolated WebAssembly containers with capability-based permissions +- **Credential Protection** - Secrets are never exposed to tools; injected at the host boundary with leak detection +- **Prompt Injection Defense** - Pattern detection, content sanitization, and policy enforcement +- **Endpoint Allowlisting** - HTTP requests only to explicitly approved hosts and paths -IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix. +### Always Available -### Status Summary +- **Multi-channel** - Reach your assistant via CLI, Telegram, WhatsApp, Slack, or HTTP webhooks +- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks +- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts +- **Self-repair** - Automatic detection and recovery of stuck operations -| Category | Status | Notes | -|----------|--------|-------| -| **Core Agent** | ✅ Complete | Sessions, workers, routing, context compaction | -| **Channels** | 🚧 Partial | TUI, HTTP, REPL, WASM channels done; messaging platforms pending | -| **Tools** | ✅ Complete | Built-in, MCP, WASM sandbox, dynamic builder | -| **Memory** | ✅ Complete | Hybrid search, embeddings, workspace filesystem | -| **Security** | ✅ Complete | WASM sandbox, prompt injection, leak detection | -| **Automation** | 🚧 Partial | Heartbeat done; cron, hooks pending | -| **Gateway** | ❌ Pending | WebSocket control plane, service management | -| **Web UI** | ❌ Pending | Control dashboard, WebChat | -| **Mobile/Desktop** | 🚫 Out of scope | Focus on server-side initially | +### Self-Expanding -### Key Differences from OpenClaw +- **Dynamic Tool Building** - Describe what you need, and IronClaw builds it as a WASM tool +- **MCP Protocol** - Connect to Model Context Protocol servers for additional capabilities +- **Plugin Architecture** - Drop in new WASM tools and channels without restarting -- **Rust vs TypeScript** - Native performance, single binary -- **WASM sandbox vs Docker** - Lightweight, capability-based security -- **PostgreSQL vs SQLite** - Production-ready persistence -- **NEAR AI primary** - Session-based auth with model proxy +### Persistent Memory -### Contributing - -Pick an unassigned feature area in [FEATURE_PARITY.md](FEATURE_PARITY.md) and claim it. +- **Hybrid Search** - Full-text + vector search using Reciprocal Rank Fusion +- **Workspace Filesystem** - Flexible path-based storage for notes, logs, and context +- **Identity Files** - Maintain consistent personality and preferences across sessions ## Installation @@ -64,14 +66,14 @@ Pick an unassigned feature area in [FEATURE_PARITY.md](FEATURE_PARITY.md) and cl - Rust 1.85+ - PostgreSQL 15+ with pgvector extension -- NEAR AI session token +- NEAR AI session token (or other LLM provider) ### Build ```bash # Clone the repository -git clone https://github.com/nearai/near-agent.git -cd near-agent +git clone https://github.com/nearai/ironclaw.git +cd ironclaw # Build cargo build --release @@ -84,10 +86,10 @@ cargo test ```bash # Create database -createdb near_agent +createdb ironclaw # Enable pgvector -psql near_agent -c "CREATE EXTENSION IF NOT EXISTS vector;" +psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;" # Run migrations refinery migrate -c refinery.toml @@ -99,12 +101,13 @@ Copy `.env.example` to `.env` and configure: ```bash # Required -DATABASE_URL=postgres://user:pass@localhost/near_agent +DATABASE_URL=postgres://user:pass@localhost/ironclaw NEARAI_SESSION_TOKEN=sess_... # Optional: Enable channels -SLACK_BOT_TOKEN=xoxb-... TELEGRAM_BOT_TOKEN=... +WHATSAPP_ACCESS_TOKEN=... +SLACK_BOT_TOKEN=xoxb-... HTTP_PORT=8080 ``` @@ -118,15 +121,51 @@ HTTP_PORT=8080 | `AGENT_MAX_PARALLEL_JOBS` | Max concurrent jobs (default: 5) | No | | `SECRETS_MASTER_KEY` | 32+ byte key for secret encryption | For secrets | +## Security + +IronClaw implements defense in depth to protect your data and prevent misuse. + +### WASM Sandbox + +All untrusted tools run in isolated WebAssembly containers: + +- **Capability-based permissions** - Explicit opt-in for HTTP, secrets, tool invocation +- **Endpoint allowlisting** - HTTP requests only to approved hosts/paths +- **Credential injection** - Secrets injected at host boundary, never exposed to WASM code +- **Leak detection** - Scans requests and responses for secret exfiltration attempts +- **Rate limiting** - Per-tool request limits to prevent abuse +- **Resource limits** - Memory, CPU, and execution time constraints + +``` +WASM ──► Allowlist ──► Leak Scan ──► Credential ──► Execute ──► Leak Scan ──► WASM + Validator (request) Injector Request (response) +``` + +### Prompt Injection Defense + +External content passes through multiple security layers: + +- Pattern-based detection of injection attempts +- Content sanitization and escaping +- Policy rules with severity levels (Block/Warn/Review/Sanitize) +- Tool output wrapping for safe LLM context injection + +### Data Protection + +- All data stored locally in your PostgreSQL database +- Secrets encrypted with AES-256-GCM +- No telemetry, analytics, or data sharing +- Full audit log of all tool executions + ## Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ Channels │ -│ ┌─────┐ ┌──────┐ ┌───────┐ ┌──────────┐ │ -│ │ CLI │ │ HTTP │ │ Slack │ │ Telegram │ │ -│ └──┬──┘ └──┬───┘ └───┬───┘ └────┬─────┘ │ -│ └────────┴──────────┴───────────┘ │ +│ ┌─────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │ +│ │ CLI │ │ Telegram │ │ WhatsApp │ │ Slack │ │ +│ └──┬──┘ └────┬─────┘ └────┬─────┘ └───┬───┘ │ +│ └──────────┴─────────────┴────────────┘ │ │ │ │ │ ┌────▼────┐ │ │ │ Router │ Intent classification │ @@ -165,31 +204,6 @@ HTTP_PORT=8080 | **Workspace** | Persistent memory with hybrid search | | **Safety Layer** | Prompt injection defense and content sanitization | -## Security - -### WASM Sandbox - -Untrusted tools run in a sandboxed WASM environment with: - -- **Capability-based permissions** - Explicit opt-in for HTTP, secrets, tool invocation -- **Endpoint allowlisting** - HTTP requests only to approved hosts/paths -- **Credential injection** - Secrets injected at host boundary, never exposed to WASM -- **Leak detection** - Scans requests and responses for secret exfiltration -- **Rate limiting** - Per-tool request limits (per-minute and per-hour) -- **Resource limits** - Memory, CPU, and execution time constraints - -``` -WASM ──► Allowlist ──► Leak Scan ──► Credential ──► Execute ──► Leak Scan ──► WASM - Validator (request) Injector Request (response) -``` - -### Prompt Injection Defense - -- Pattern-based detection of injection attempts -- Content sanitization and escaping -- Policy rules with severity levels (Block/Warn/Review/Sanitize) -- Tool output wrapping for LLM context - ## Usage ### CLI Mode @@ -199,7 +213,7 @@ WASM ──► Allowlist ──► Leak Scan ──► Credential ──► Exec cargo run # With debug logging -RUST_LOG=near_agent=debug cargo run +RUST_LOG=ironclaw=debug cargo run ``` ### HTTP Server @@ -211,7 +225,7 @@ HTTP_PORT=8080 cargo run # Send a request curl -X POST http://localhost:8080/webhook \ -H "Content-Type: application/json" \ - -d '{"message": "Hello, agent!"}' + -d '{"message": "Hello, IronClaw!"}' ``` ## Development @@ -230,6 +244,17 @@ cargo test cargo test test_name ``` +## OpenClaw Heritage + +IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix. + +Key differences: + +- **Rust vs TypeScript** - Native performance, memory safety, single binary +- **WASM sandbox vs Docker** - Lightweight, capability-based security +- **PostgreSQL vs SQLite** - Production-ready persistence +- **Security-first design** - Multiple defense layers, credential protection + ## License Licensed under either of: diff --git a/channels-src/slack/Cargo.toml b/channels-src/slack/Cargo.toml index c434519e..18d2fd39 100644 --- a/channels-src/slack/Cargo.toml +++ b/channels-src/slack/Cargo.toml @@ -2,7 +2,7 @@ name = "slack-channel" version = "0.1.0" edition = "2021" -description = "Slack Events API channel for NEAR Agent" +description = "Slack Events API channel for IronClaw" license = "MIT OR Apache-2.0" [lib] diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs index 18748833..adb2c5aa 100644 --- a/channels-src/slack/src/lib.rs +++ b/channels-src/slack/src/lib.rs @@ -1,4 +1,4 @@ -//! Slack Events API channel for NEAR Agent. +//! Slack Events API channel for IronClaw. //! //! This WASM component implements the channel interface for handling Slack //! webhooks and sending messages back to Slack. diff --git a/channels-src/telegram/Cargo.toml b/channels-src/telegram/Cargo.toml index d33266e5..855aa8fa 100644 --- a/channels-src/telegram/Cargo.toml +++ b/channels-src/telegram/Cargo.toml @@ -2,7 +2,7 @@ name = "telegram-channel" version = "0.1.0" edition = "2021" -description = "Telegram Bot API channel for NEAR Agent" +description = "Telegram Bot API channel for IronClaw" license = "MIT OR Apache-2.0" [lib] diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index 8b5c7517..146b1e11 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -1,7 +1,7 @@ // Telegram API types have fields reserved for future use (entities, reply threading, etc.) #![allow(dead_code)] -//! Telegram Bot API channel for NEAR Agent. +//! Telegram Bot API channel for IronClaw. //! //! This WASM component implements the channel interface for handling Telegram //! webhooks and sending messages back via the Bot API. diff --git a/channels/whatsapp/Cargo.toml b/channels/whatsapp/Cargo.toml index 6e91d1f3..8dd03499 100644 --- a/channels/whatsapp/Cargo.toml +++ b/channels/whatsapp/Cargo.toml @@ -2,7 +2,7 @@ name = "whatsapp-channel" version = "0.1.0" edition = "2021" -description = "WhatsApp channel for near-agent using the Cloud API" +description = "WhatsApp Cloud API channel for IronClaw" [lib] crate-type = ["cdylib"] diff --git a/channels/whatsapp/src/lib.rs b/channels/whatsapp/src/lib.rs index 6633ba4a..346c0f23 100644 --- a/channels/whatsapp/src/lib.rs +++ b/channels/whatsapp/src/lib.rs @@ -1,7 +1,7 @@ // WhatsApp API types have fields reserved for future use (contacts, statuses, etc.) #![allow(dead_code)] -//! WhatsApp Cloud API channel for NEAR Agent. +//! WhatsApp Cloud API channel for IronClaw. //! //! This WASM component implements the channel interface for handling WhatsApp //! webhooks and sending messages back via the Cloud API. diff --git a/docs/BUILDING_CHANNELS.md b/docs/BUILDING_CHANNELS.md index 7032bda3..a819bc01 100644 --- a/docs/BUILDING_CHANNELS.md +++ b/docs/BUILDING_CHANNELS.md @@ -1,6 +1,6 @@ # Building WASM Channels -This guide covers how to build WASM channel modules for the NEAR Agent. +This guide covers how to build WASM channel modules for IronClaw. ## Overview @@ -19,7 +19,7 @@ channels/ # Or channels-src/ After building, deploy to: ``` -~/.near-agent/channels/ +~/.ironclaw/channels/ ├── my-channel.wasm └── my-channel.capabilities.json ``` @@ -31,7 +31,7 @@ After building, deploy to: name = "my-channel" version = "0.1.0" edition = "2021" -description = "My messaging platform channel for NEAR Agent" +description = "My messaging platform channel for IronClaw" [lib] crate-type = ["cdylib"] @@ -251,9 +251,9 @@ Create `my-channel.capabilities.json`: cd channels/my-channel cargo component build --release -# Deploy to ~/.near-agent/channels/ -cp target/wasm32-wasip1/release/my_channel.wasm ~/.near-agent/channels/my-channel.wasm -cp my-channel.capabilities.json ~/.near-agent/channels/ +# Deploy to ~/.ironclaw/channels/ +cp target/wasm32-wasip1/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm +cp my-channel.capabilities.json ~/.ironclaw/channels/ ``` ## Host Functions Available diff --git a/examples/wasm-tools/slack/Cargo.toml b/examples/wasm-tools/slack/Cargo.toml index 92e34820..83425bc8 100644 --- a/examples/wasm-tools/slack/Cargo.toml +++ b/examples/wasm-tools/slack/Cargo.toml @@ -2,7 +2,7 @@ name = "slack-tool" version = "0.1.0" edition = "2021" -description = "Slack integration tool for NEAR Agent (WASM component)" +description = "Slack integration tool for IronClaw (WASM component)" license = "MIT OR Apache-2.0" publish = false diff --git a/examples/wasm-tools/slack/README.md b/examples/wasm-tools/slack/README.md index 2836643a..aee92f18 100644 --- a/examples/wasm-tools/slack/README.md +++ b/examples/wasm-tools/slack/README.md @@ -1,6 +1,6 @@ # Slack WASM Tool -A standalone WASM component that provides Slack integration for NEAR Agent. This serves as both a functional tool and a template for building custom WASM tools. +A standalone WASM component that provides Slack integration for IronClaw. This serves as both a functional tool and a template for building custom WASM tools. ## Features @@ -50,9 +50,9 @@ target/wasm32-wasip2/release/slack_tool.wasm Copy the WASM and capabilities files to the agent's tools directory: ```bash -mkdir -p ~/.near-agent/tools -cp target/wasm32-wasip2/release/slack_tool.wasm ~/.near-agent/tools/slack.wasm -cp slack.capabilities.json ~/.near-agent/tools/ +mkdir -p ~/.ironclaw/tools +cp target/wasm32-wasip2/release/slack_tool.wasm ~/.ironclaw/tools/slack.wasm +cp slack.capabilities.json ~/.ironclaw/tools/ ``` ### Option B: Database Storage (Production) @@ -60,7 +60,7 @@ cp slack.capabilities.json ~/.near-agent/tools/ Use the agent CLI or API to store the tool: ```bash -near-agent tool install \ +ironclaw tool install \ --name slack \ --wasm target/wasm32-wasip2/release/slack_tool.wasm \ --capabilities slack.capabilities.json @@ -71,7 +71,7 @@ near-agent tool install \ Store your Slack bot token as a secret: ```bash -near-agent secret set slack_bot_token "xoxb-your-token-here" +ironclaw secret set slack_bot_token "xoxb-your-token-here" ``` Or via SQL: @@ -88,7 +88,7 @@ VALUES ('your_user_id', 'slack_bot_token', ...); { "action": "send_message", "channel": "#general", - "text": "Hello from the NEAR Agent!" + "text": "Hello from IronClaw!" } ``` @@ -214,7 +214,7 @@ world sandboxed-tool { Ensure you've stored the secret: ```bash -near-agent secret set slack_bot_token "xoxb-..." +ironclaw secret set slack_bot_token "xoxb-..." ``` ### "Endpoint not in allowlist" diff --git a/examples/wasm-tools/slack/src/lib.rs b/examples/wasm-tools/slack/src/lib.rs index 432ba31f..04cc2b70 100644 --- a/examples/wasm-tools/slack/src/lib.rs +++ b/examples/wasm-tools/slack/src/lib.rs @@ -1,4 +1,4 @@ -//! Slack WASM Tool for NEAR Agent. +//! Slack WASM Tool for IronClaw. //! //! This is a standalone WASM component that provides Slack integration. //! It demonstrates how to build external tools that can be dynamically diff --git a/src/channels/cli/app.rs b/src/channels/cli/app.rs index a0e76113..8bae51bb 100644 --- a/src/channels/cli/app.rs +++ b/src/channels/cli/app.rs @@ -147,7 +147,7 @@ impl AppState { Self { mode: InputMode::Editing, messages: vec![ChatMessage::system( - "Welcome to NEAR Agent. Type a message or /help for commands.", + "Welcome to IronClaw. Type a message or /help for commands.", )], composer: ChatComposer::new(), approval: None, diff --git a/src/channels/repl.rs b/src/channels/repl.rs index c9c3f452..1046460e 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -64,7 +64,7 @@ impl Default for ReplChannel { fn print_help() { println!( r#" -NEAR Agent REPL - Interactive debugging mode +IronClaw REPL - Interactive debugging mode Commands: /help Show this help message @@ -116,7 +116,7 @@ impl Channel for ReplChannel { let stdin = io::stdin(); let mut stdout = io::stdout(); - println!("NEAR Agent REPL - Type /help for commands, /quit to exit"); + println!("IronClaw REPL - Type /help for commands, /quit to exit"); println!(); loop { diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 5e94d38c..6ab145f2 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -1,6 +1,6 @@ //! WASM channel loader for loading channels from files or directories. //! -//! Loads WASM channel modules from the filesystem (default: ~/.near-agent/channels/). +//! Loads WASM channel modules from the filesystem (default: ~/.ironclaw/channels/). //! Each channel consists of: //! - `.wasm` - The compiled WASM component //! - `.capabilities.json` - Channel capabilities and configuration @@ -329,12 +329,12 @@ pub struct DiscoveredChannel { /// Get the default channels directory path. /// -/// Returns ~/.near-agent/channels/ +/// Returns ~/.ironclaw/channels/ #[allow(dead_code)] pub fn default_channels_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("channels") } diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 24905fc0..7535d033 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -63,14 +63,14 @@ //! # Example Usage //! //! ```ignore -//! use near_agent::channels::wasm::{WasmChannelLoader, WasmChannelRuntime}; +//! use ironclaw::channels::wasm::{WasmChannelLoader, WasmChannelRuntime}; //! //! // Create runtime (can share engine with tool runtime) //! let runtime = WasmChannelRuntime::new(config)?; //! //! // Load channels from directory //! let loader = WasmChannelLoader::new(runtime); -//! let channels = loader.load_from_dir(Path::new("~/.near-agent/channels/")).await?; +//! let channels = loader.load_from_dir(Path::new("~/.ironclaw/channels/")).await?; //! //! // Add to channel manager //! for channel in channels { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index adc0a009..7fdcb3fe 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -13,8 +13,10 @@ pub use tool::{ToolCommand, run_tool_command}; use clap::{Parser, Subcommand}; #[derive(Parser, Debug)] -#[command(name = "near-agent")] -#[command(about = "LLM-powered autonomous agent for the NEAR AI marketplace")] +#[command(name = "ironclaw")] +#[command( + about = "Secure personal AI assistant that protects your data and expands its capabilities" +)] #[command(version)] pub struct Cli { #[command(subcommand)] diff --git a/src/cli/tool.rs b/src/cli/tool.rs index bee90001..1fa2df89 100644 --- a/src/cli/tool.rs +++ b/src/cli/tool.rs @@ -13,8 +13,8 @@ use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash}; /// Default tools directory. fn default_tools_dir() -> PathBuf { dirs::home_dir() - .map(|h| h.join(".near-agent").join("tools")) - .unwrap_or_else(|| PathBuf::from(".near-agent/tools")) + .map(|h| h.join(".ironclaw").join("tools")) + .unwrap_or_else(|| PathBuf::from(".ironclaw/tools")) } #[derive(Subcommand, Debug, Clone)] @@ -32,7 +32,7 @@ pub enum ToolCommand { #[arg(long)] capabilities: Option, - /// Target directory for installation (default: ~/.near-agent/tools/) + /// Target directory for installation (default: ~/.ironclaw/tools/) #[arg(short, long)] target: Option, @@ -51,7 +51,7 @@ pub enum ToolCommand { /// List installed tools List { - /// Directory to list tools from (default: ~/.near-agent/tools/) + /// Directory to list tools from (default: ~/.ironclaw/tools/) #[arg(short, long)] dir: Option, @@ -65,7 +65,7 @@ pub enum ToolCommand { /// Name of the tool to remove name: String, - /// Directory to remove tool from (default: ~/.near-agent/tools/) + /// Directory to remove tool from (default: ~/.ironclaw/tools/) #[arg(short, long)] dir: Option, }, @@ -75,7 +75,7 @@ pub enum ToolCommand { /// Name of the tool or path to .wasm file name_or_path: String, - /// Directory to look for tool (default: ~/.near-agent/tools/) + /// Directory to look for tool (default: ~/.ironclaw/tools/) #[arg(short, long)] dir: Option, }, @@ -420,7 +420,7 @@ async fn list_tools(dir: Option, verbose: bool) -> anyhow::Result<()> { if !tools_dir.exists() { println!("No tools directory found at {}", tools_dir.display()); - println!("Install a tool with: near-agent tool install "); + println!("Install a tool with: ironclaw tool install "); return Ok(()); } @@ -674,7 +674,7 @@ mod tests { #[test] fn test_default_tools_dir() { let dir = default_tools_dir(); - assert!(dir.to_string_lossy().contains(".near-agent")); + assert!(dir.to_string_lossy().contains(".ironclaw")); assert!(dir.to_string_lossy().contains("tools")); } } diff --git a/src/config.rs b/src/config.rs index ee5d6668..5038fad8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -//! Configuration for the NEAR Agent. +//! Configuration for IronClaw. use std::path::PathBuf; use std::time::Duration; @@ -192,7 +192,7 @@ pub struct NearAiConfig { pub base_url: String, /// Base URL for auth/refresh endpoints (default: https://private.near.ai) pub auth_base_url: String, - /// Path to session file (default: ~/.near-agent/session.json) + /// Path to session file (default: ~/.ironclaw/session.json) pub session_path: PathBuf, /// API mode: "responses" (chat-api) or "chat_completions" (cloud-api) pub api_mode: NearAiApiMode, @@ -297,11 +297,11 @@ impl EmbeddingsConfig { } } -/// Get the default session file path (~/.near-agent/session.json). +/// Get the default session file path (~/.ironclaw/session.json). fn default_session_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("session.json") } @@ -310,7 +310,7 @@ fn default_session_path() -> PathBuf { pub struct ChannelsConfig { pub cli: CliConfig, pub http: Option, - /// Directory containing WASM channel modules (default: ~/.near-agent/channels/). + /// Directory containing WASM channel modules (default: ~/.ironclaw/channels/). pub wasm_channels_dir: std::path::PathBuf, /// Whether WASM channels are enabled. pub wasm_channels_enabled: bool, @@ -371,11 +371,11 @@ impl ChannelsConfig { } } -/// Get the default channels directory (~/.near-agent/channels/). +/// Get the default channels directory (~/.ironclaw/channels/). fn default_channels_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("channels") } @@ -395,7 +395,7 @@ pub struct AgentConfig { impl AgentConfig { fn from_env() -> Result { Ok(Self { - name: optional_env("AGENT_NAME")?.unwrap_or_else(|| "near-agent".to_string()), + name: optional_env("AGENT_NAME")?.unwrap_or_else(|| "ironclaw".to_string()), max_parallel_jobs: parse_optional_env("AGENT_MAX_PARALLEL_JOBS", 5)?, job_timeout: Duration::from_secs(parse_optional_env("AGENT_JOB_TIMEOUT_SECS", 3600)?), stuck_threshold: Duration::from_secs(parse_optional_env( @@ -447,7 +447,7 @@ impl SafetyConfig { pub struct WasmConfig { /// Whether WASM tool execution is enabled. pub enabled: bool, - /// Directory containing installed WASM tools (default: ~/.near-agent/tools/). + /// Directory containing installed WASM tools (default: ~/.ironclaw/tools/). pub tools_dir: PathBuf, /// Default memory limit in bytes (default: 10 MB). pub default_memory_limit: u64, @@ -521,11 +521,11 @@ impl Default for WasmConfig { } } -/// Get the default tools directory (~/.near-agent/tools/). +/// Get the default tools directory (~/.ironclaw/tools/). fn default_tools_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("tools") } diff --git a/src/error.rs b/src/error.rs index 0f0d304a..a8fbced0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,4 @@ -//! Error types for the NEAR Agent. +//! Error types for IronClaw. use std::time::Duration; diff --git a/src/llm/session.rs b/src/llm/session.rs index 05006e30..5a136171 100644 --- a/src/llm/session.rs +++ b/src/llm/session.rs @@ -1,7 +1,7 @@ //! Session management for NEAR AI authentication. //! //! Handles session token persistence, expiration detection, and renewal via -//! OAuth flow. Tokens are stored in `~/.near-agent/session.json` and refreshed +//! OAuth flow. Tokens are stored in `~/.ironclaw/session.json` and refreshed //! automatically when expired. use std::path::PathBuf; @@ -29,7 +29,7 @@ pub struct SessionData { pub struct SessionConfig { /// Base URL for auth endpoints (e.g., https://private.near.ai). pub auth_base_url: String, - /// Path to session file (e.g., ~/.near-agent/session.json). + /// 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), @@ -45,11 +45,11 @@ impl Default for SessionConfig { } } -/// Get the default session file path (~/.near-agent/session.json). +/// Get the default session file path (~/.ironclaw/session.json). pub fn default_session_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("session.json") } @@ -631,6 +631,6 @@ mod tests { fn test_default_session_path() { let path = default_session_path(); assert!(path.ends_with("session.json")); - assert!(path.to_string_lossy().contains(".near-agent")); + assert!(path.to_string_lossy().contains(".ironclaw")); } } diff --git a/src/main.rs b/src/main.rs index 78944fe2..89eeaed8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -//! NEAR Agent - Main entry point. +//! IronClaw - Main entry point. use std::sync::Arc; use clap::Parser; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; -use near_agent::{ +use ironclaw::{ agent::{Agent, AgentDeps}, channels::{ AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel, @@ -74,7 +74,7 @@ async fn main() -> anyhow::Result<()> { // automatically run the setup wizard if !cli.no_setup { let settings = Settings::load(); - let session_path = near_agent::llm::session::default_session_path(); + let session_path = ironclaw::llm::session::default_session_path(); if !settings.setup_completed && !session_path.exists() { println!("First run detected. Starting setup wizard..."); @@ -102,7 +102,7 @@ async fn main() -> anyhow::Result<()> { // Initialize tracing and channels based on mode let env_filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("near_agent=info,tower_http=debug")); + .unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=debug")); // Determine which mode to use: REPL, single message, or TUI let use_repl = cli.repl || cli.message.is_some(); @@ -150,7 +150,7 @@ async fn main() -> anyhow::Result<()> { (None, None, None) }; - tracing::info!("Starting NEAR Agent..."); + tracing::info!("Starting IronClaw..."); tracing::info!("Loaded configuration for agent: {}", config.agent.name); tracing::info!("NEAR AI session authenticated"); @@ -578,7 +578,7 @@ async fn main() -> anyhow::Result<()> { /// /// Returns the number of credentials injected. async fn inject_channel_credentials( - channel: &Arc, + channel: &Arc, secrets: &dyn SecretsStore, channel_name: &str, ) -> anyhow::Result { diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index ca6e9f9f..5a7aed0f 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -49,7 +49,7 @@ //! # Example //! //! ```rust,no_run -//! use near_agent::sandbox::{SandboxManager, SandboxManagerBuilder, SandboxPolicy}; +//! use ironclaw::sandbox::{SandboxManager, SandboxManagerBuilder, SandboxPolicy}; //! use std::collections::HashMap; //! use std::path::Path; //! diff --git a/src/secrets/mod.rs b/src/secrets/mod.rs index 7335ec82..06bdfc2d 100644 --- a/src/secrets/mod.rs +++ b/src/secrets/mod.rs @@ -31,7 +31,7 @@ //! # Example //! //! ```ignore -//! use near_agent::secrets::{SecretsStore, PostgresSecretsStore, SecretsCrypto, CreateSecretParams}; +//! use ironclaw::secrets::{SecretsStore, PostgresSecretsStore, SecretsCrypto, CreateSecretParams}; //! use secrecy::SecretString; //! //! // Initialize crypto with master key from environment diff --git a/src/settings.rs b/src/settings.rs index de2aea02..b5276d9b 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,6 +1,6 @@ //! User settings persistence. //! -//! Stores user preferences like selected model in ~/.near-agent/settings.json. +//! Stores user preferences like selected model in ~/.ironclaw/settings.json. use std::path::PathBuf; @@ -55,11 +55,11 @@ pub struct ChannelSettings { } impl Settings { - /// Get the default settings file path (~/.near-agent/settings.json). + /// Get the default settings file path (~/.ironclaw/settings.json). pub fn default_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".near-agent") + .join(".ironclaw") .join("settings.json") } diff --git a/src/setup/mod.rs b/src/setup/mod.rs index c082c56c..50711ffa 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,4 +1,4 @@ -//! Interactive setup wizard for NEAR Agent. +//! Interactive setup wizard for IronClaw. //! //! Provides a guided setup experience for: //! - NEAR AI authentication @@ -8,7 +8,7 @@ //! # Example //! //! ```ignore -//! use near_agent::setup::SetupWizard; +//! use ironclaw::setup::SetupWizard; //! //! let mut wizard = SetupWizard::new(); //! wizard.run().await?; diff --git a/src/setup/prompts.rs b/src/setup/prompts.rs index 7a581cef..b07ae090 100644 --- a/src/setup/prompts.rs +++ b/src/setup/prompts.rs @@ -259,7 +259,7 @@ pub fn confirm(prompt: &str, default: bool) -> io::Result { /// # Example /// /// ```ignore -/// print_header("NEAR Agent Setup Wizard"); +/// print_header("IronClaw Setup Wizard"); /// ``` pub fn print_header(text: &str) { let width = text.len() + 4; diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index d63cf741..6ad22295 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -53,7 +53,7 @@ pub struct SetupConfig { pub channels_only: bool, } -/// Interactive setup wizard for NEAR Agent. +/// Interactive setup wizard for IronClaw. pub struct SetupWizard { config: SetupConfig, settings: Settings, @@ -87,7 +87,7 @@ impl SetupWizard { /// Run the setup wizard. pub async fn run(&mut self) -> Result<(), SetupError> { - print_header("NEAR Agent Setup Wizard"); + print_header("IronClaw Setup Wizard"); let total_steps = if self.config.channels_only { 1 } else { 3 }; let mut current_step = 1; @@ -336,7 +336,7 @@ impl SetupWizard { // Discover available WASM channels let channels_dir = dirs::home_dir() .unwrap_or_default() - .join(".near-agent/channels"); + .join(".ironclaw/channels"); let discovered_channels = discover_wasm_channels(&channels_dir).await; @@ -438,7 +438,7 @@ impl SetupWizard { })?; println!(); - print_success("Configuration saved to ~/.near-agent/"); + print_success("Configuration saved to ~/.ironclaw/"); println!(); // Print summary @@ -476,7 +476,7 @@ impl SetupWizard { println!(); println!("To start the agent, run:"); - println!(" near-agent"); + println!(" ironclaw"); println!(); Ok(()) diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs index cb441e2a..e46c8aaa 100644 --- a/src/tools/builder/core.rs +++ b/src/tools/builder/core.rs @@ -218,7 +218,7 @@ pub struct BuilderConfig { impl Default for BuilderConfig { fn default() -> Self { Self { - build_dir: std::env::temp_dir().join("near-agent-builds"), + build_dir: std::env::temp_dir().join("ironclaw-builds"), max_iterations: 10, timeout: Duration::from_secs(600), // 10 minutes cleanup_on_failure: false, // Keep for debugging diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index 7fad73f9..0029b4c3 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -7,7 +7,7 @@ //! # Example: Loading from Directory //! //! ```text -//! ~/.near-agent/tools/ +//! ~/.ironclaw/tools/ //! ├── slack.wasm //! ├── slack.capabilities.json //! ├── github.wasm @@ -16,7 +16,7 @@ //! //! ```ignore //! let loader = WasmToolLoader::new(runtime, registry); -//! loader.load_from_dir(Path::new("~/.near-agent/tools/")).await?; +//! loader.load_from_dir(Path::new("~/.ironclaw/tools/")).await?; //! ``` //! //! # Security diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index c496acbe..378aa6ba 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -51,8 +51,8 @@ //! # Example //! //! ```ignore -//! use near_agent::tools::wasm::{WasmToolRuntime, WasmRuntimeConfig, WasmToolWrapper}; -//! use near_agent::tools::wasm::Capabilities; +//! use ironclaw::tools::wasm::{WasmToolRuntime, WasmRuntimeConfig, WasmToolWrapper}; +//! use ironclaw::tools::wasm::Capabilities; //! use std::sync::Arc; //! //! // Create runtime diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index 388ccc52..7ac0b909 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -9,8 +9,8 @@ use std::collections::HashMap; use std::sync::Arc; -use near_agent::channels::Channel; -use near_agent::channels::wasm::{ +use ironclaw::channels::Channel; +use ironclaw::channels::wasm::{ ChannelCapabilities, EmitRateLimitConfig, PreparedChannelModule, RegisteredEndpoint, WasmChannel, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, }; @@ -238,7 +238,7 @@ mod loader_tests { async fn test_discover_channels_empty_dir() { let dir = TempDir::new().expect("Failed to create temp dir"); - let channels = near_agent::channels::wasm::discover_channels(dir.path()) + let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); @@ -253,7 +253,7 @@ mod loader_tests { std::fs::File::create(dir.path().join("slack.wasm")).expect("Failed to create file"); std::fs::File::create(dir.path().join("telegram.wasm")).expect("Failed to create file"); - let channels = near_agent::channels::wasm::discover_channels(dir.path()) + let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); @@ -284,7 +284,7 @@ mod loader_tests { ) .expect("Failed to write capabilities"); - let channels = near_agent::channels::wasm::discover_channels(dir.path()) + let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); @@ -301,7 +301,7 @@ mod loader_tests { std::fs::File::create(dir.path().join("config.json")).expect("Failed to create file"); std::fs::File::create(dir.path().join("channel.wasm")).expect("Failed to create file"); - let channels = near_agent::channels::wasm::discover_channels(dir.path()) + let channels = ironclaw::channels::wasm::discover_channels(dir.path()) .await .expect("Discovery failed"); @@ -388,7 +388,7 @@ mod capabilities_tests { mod message_emission_tests { use super::*; - use near_agent::channels::wasm::{ChannelHostState, EmittedMessage}; + use ironclaw::channels::wasm::{ChannelHostState, EmittedMessage}; #[test] fn test_emit_message_basic() { diff --git a/tests/workspace_integration.rs b/tests/workspace_integration.rs index 63519abb..568d00b9 100644 --- a/tests/workspace_integration.rs +++ b/tests/workspace_integration.rs @@ -1,15 +1,15 @@ //! Integration tests for the workspace module. //! //! Requires a running PostgreSQL with pgvector extension. -//! Set DATABASE_URL=postgres://localhost/near_agent_test +//! Set DATABASE_URL=postgres://localhost/ironclaw_test use std::sync::Arc; -use near_agent::workspace::{MockEmbeddings, SearchConfig, Workspace, paths}; +use ironclaw::workspace::{MockEmbeddings, SearchConfig, Workspace, paths}; fn get_pool() -> deadpool_postgres::Pool { let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://localhost/near_agent_test".to_string()); + .unwrap_or_else(|_| "postgres://localhost/ironclaw_test".to_string()); let config: tokio_postgres::Config = database_url.parse().expect("Invalid DATABASE_URL"); From 0ab964384395b2285532db1c546efbc7192caf74 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 5 Feb 2026 09:50:52 -0800 Subject: [PATCH 3/3] Add interactive setup wizard and persistent settings - Add 7-step setup wizard: database, security, auth, model, embeddings, channels, heartbeat - Store settings in ~/.ironclaw/settings.json with env var > settings > default priority - Add OS keychain integration for secrets master key (macOS/Linux) - Add `ironclaw config` CLI subcommand (list/get/set/reset/path) - Expand Settings struct with all configuration fields - Enhanced setup detection to auto-trigger wizard when needed Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 515 +++++++++++++++++++++++++++++++ Cargo.toml | 9 + src/cli/config.rs | 195 ++++++++++++ src/cli/mod.rs | 11 +- src/config.rs | 188 +++++++++--- src/error.rs | 3 + src/main.rs | 58 +++- src/secrets/keychain.rs | 346 +++++++++++++++++++++ src/secrets/mod.rs | 8 + src/secrets/types.rs | 3 + src/settings.rs | 655 +++++++++++++++++++++++++++++++++++++++- src/setup/mod.rs | 15 +- src/setup/wizard.rs | 568 ++++++++++++++++++++++++++++------ 13 files changed, 2417 insertions(+), 157 deletions(-) create mode 100644 src/cli/config.rs create mode 100644 src/secrets/keychain.rs diff --git a/Cargo.lock b/Cargo.lock index 9230cda0..d8635375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.3", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.3", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -316,6 +447,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bollard" version = "0.18.1" @@ -525,6 +678,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.55" @@ -642,6 +804,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1133,6 +1304,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1160,6 +1358,27 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -1292,6 +1511,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1828,6 +2060,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1911,6 +2144,8 @@ dependencies = [ "rust_decimal", "rust_decimal_macros", "secrecy", + "secret-service", + "security-framework", "serde", "serde_json", "sha2", @@ -1930,6 +2165,7 @@ dependencies = [ "wasmparser 0.220.1", "wasmtime", "wasmtime-wasi", + "zbus", ] [[package]] @@ -2165,6 +2401,15 @@ dependencies = [ "rustix 1.1.3", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2183,6 +2428,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2192,12 +2450,76 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2279,6 +2601,22 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2386,12 +2724,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3172,6 +3535,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -3322,6 +3704,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3760,6 +4153,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4065,6 +4459,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5182,6 +5587,16 @@ dependencies = [ "rustix 1.1.3", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yansi" version = "1.0.1" @@ -5211,6 +5626,69 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.37" @@ -5324,3 +5802,40 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] diff --git a/Cargo.toml b/Cargo.toml index 9f8f6e50..2dc13f28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,15 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"] http-body-util = "0.1" bytes = "1" +# macOS keychain +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + +# Linux secret-service (GNOME Keyring, KWallet) +[target.'cfg(target_os = "linux")'.dependencies] +secret-service = { version = "4", features = ["rt-tokio-crypto-rust"] } +zbus = "4" + [dev-dependencies] tokio-test = "0.4" testcontainers-modules = { version = "0.11", features = ["postgres"] } diff --git a/src/cli/config.rs b/src/cli/config.rs new file mode 100644 index 00000000..080cccf1 --- /dev/null +++ b/src/cli/config.rs @@ -0,0 +1,195 @@ +//! Configuration management CLI commands. +//! +//! Commands for viewing and modifying settings. + +use clap::Subcommand; + +use crate::settings::Settings; + +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigCommand { + /// List all settings and their current values + List { + /// Show only settings matching this prefix (e.g., "agent", "heartbeat") + #[arg(short, long)] + filter: Option, + }, + + /// Get a specific setting value + Get { + /// Setting path (e.g., "agent.max_parallel_jobs") + path: String, + }, + + /// Set a setting value + Set { + /// Setting path (e.g., "agent.max_parallel_jobs") + path: String, + + /// Value to set + value: String, + }, + + /// Reset a setting to its default value + Reset { + /// Setting path (e.g., "agent.max_parallel_jobs") + path: String, + }, + + /// Show the settings file path + Path, +} + +/// Run a config command. +pub fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { + match cmd { + ConfigCommand::List { filter } => list_settings(filter), + ConfigCommand::Get { path } => get_setting(&path), + ConfigCommand::Set { path, value } => set_setting(&path, &value), + ConfigCommand::Reset { path } => reset_setting(&path), + ConfigCommand::Path => show_path(), + } +} + +/// List all settings. +fn list_settings(filter: Option) -> anyhow::Result<()> { + let settings = Settings::load(); + let all = settings.list(); + + // Find the longest key for alignment + let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + + println!("Settings:"); + println!(); + + for (key, value) in all { + // Skip if filter is set and doesn't match + if let Some(ref f) = filter { + if !key.starts_with(f) { + continue; + } + } + + // Truncate long values for display + let display_value = if value.len() > 60 { + format!("{}...", &value[..57]) + } else { + value + }; + + println!(" {:width$} {}", key, display_value, width = max_key_len); + } + + Ok(()) +} + +/// Get a specific setting. +fn get_setting(path: &str) -> anyhow::Result<()> { + let settings = Settings::load(); + + match settings.get(path) { + Some(value) => { + println!("{}", value); + Ok(()) + } + None => { + anyhow::bail!("Setting not found: {}", path); + } + } +} + +/// Set a setting value. +fn set_setting(path: &str, value: &str) -> anyhow::Result<()> { + let mut settings = Settings::load(); + + // Try to set the value + settings + .set(path, value) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + // Save to disk + settings.save()?; + + println!("Set {} = {}", path, value); + Ok(()) +} + +/// Reset a setting to default. +fn reset_setting(path: &str) -> anyhow::Result<()> { + let mut settings = Settings::load(); + + // Get the default value for display + let default = Settings::default(); + let default_value = default + .get(path) + .ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?; + + // Reset it + settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?; + + // Save to disk + settings.save()?; + + println!("Reset {} to default: {}", path, default_value); + Ok(()) +} + +/// Show the settings file path. +fn show_path() -> anyhow::Result<()> { + let path = Settings::default_path(); + println!("{}", 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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_list_settings() { + // Just verify it doesn't panic + let settings = Settings::default(); + let list = settings.list(); + assert!(!list.is_empty()); + } + + #[test] + fn test_get_set_reset() { + let _dir = tempdir().unwrap(); + + let mut settings = Settings::default(); + + // Set a value + settings.set("agent.name", "testbot").unwrap(); + assert_eq!(settings.agent.name, "testbot"); + + // Reset to default + settings.reset("agent.name").unwrap(); + assert_eq!(settings.agent.name, "ironclaw"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7fdcb3fe..631a4770 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,11 +3,13 @@ //! Provides subcommands for: //! - Running the agent (`run`) //! - Interactive setup wizard (`setup`) +//! - Managing configuration (`config list`, `config get`, `config set`) //! - Managing WASM tools (`tool install`, `tool list`, `tool remove`) -//! - Managing secrets (`secret set`, `secret list`, `secret remove`) +mod config; mod tool; +pub use config::{ConfigCommand, run_config_command}; pub use tool::{ToolCommand, run_tool_command}; use clap::{Parser, Subcommand}; @@ -63,12 +65,13 @@ pub enum Command { channels_only: bool, }, + /// Manage configuration settings + #[command(subcommand)] + Config(ConfigCommand), + /// Manage WASM tools #[command(subcommand)] Tool(ToolCommand), - // Future: Secret management - // #[command(subcommand)] - // Secret(SecretCommand), } impl Cli { diff --git a/src/config.rs b/src/config.rs index 5038fad8..0ead0078 100644 --- a/src/config.rs +++ b/src/config.rs @@ -131,16 +131,30 @@ pub struct DatabaseConfig { impl DatabaseConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); + + // Priority: env var > settings > error (required) + let url = optional_env("DATABASE_URL")? + .or(settings.database_url.clone()) + .ok_or_else(|| ConfigError::MissingRequired { + key: "database_url".to_string(), + hint: "Run 'ironclaw setup' or set DATABASE_URL environment variable".to_string(), + })?; + + // Priority: env var > settings > default + 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(settings.database_pool_size) + .unwrap_or(10); + Ok(Self { - url: SecretString::from(required_env("DATABASE_URL")?), - 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}"), - })? - .unwrap_or(10), + url: SecretString::from(url), + pool_size, }) } @@ -269,10 +283,17 @@ impl Default for EmbeddingsConfig { impl EmbeddingsConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from); - let provider = optional_env("EMBEDDING_PROVIDER")?.unwrap_or_else(|| "openai".to_string()); - // Auto-enable if we have an API key + // Priority: env var > settings > default + let provider = optional_env("EMBEDDING_PROVIDER")? + .unwrap_or_else(|| settings.embeddings.provider.clone()); + + let model = + optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone()); + + // Priority: env var > settings > auto-detect from API key let enabled = optional_env("EMBEDDING_ENABLED")? .map(|s| s.parse()) .transpose() @@ -280,14 +301,16 @@ impl EmbeddingsConfig { key: "EMBEDDING_ENABLED".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or(openai_api_key.is_some()); + .unwrap_or_else(|| { + // Check settings, or auto-enable if API key present + settings.embeddings.enabled || openai_api_key.is_some() + }); Ok(Self { enabled, provider, openai_api_key, - model: optional_env("EMBEDDING_MODEL")? - .unwrap_or_else(|| "text-embedding-3-small".to_string()), + model, }) } @@ -394,19 +417,57 @@ pub struct AgentConfig { impl AgentConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); + Ok(Self { - name: optional_env("AGENT_NAME")?.unwrap_or_else(|| "ironclaw".to_string()), - max_parallel_jobs: parse_optional_env("AGENT_MAX_PARALLEL_JOBS", 5)?, - job_timeout: Duration::from_secs(parse_optional_env("AGENT_JOB_TIMEOUT_SECS", 3600)?), - stuck_threshold: Duration::from_secs(parse_optional_env( - "AGENT_STUCK_THRESHOLD_SECS", - 300, - )?), - repair_check_interval: Duration::from_secs(parse_optional_env( - "SELF_REPAIR_CHECK_INTERVAL_SECS", - 60, - )?), - max_repair_attempts: parse_optional_env("SELF_REPAIR_MAX_ATTEMPTS", 3)?, + // Priority: env var > settings > default + name: optional_env("AGENT_NAME")?.unwrap_or_else(|| settings.agent.name.clone()), + max_parallel_jobs: optional_env("AGENT_MAX_PARALLEL_JOBS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "AGENT_MAX_PARALLEL_JOBS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.agent.max_parallel_jobs as usize), + job_timeout: Duration::from_secs( + optional_env("AGENT_JOB_TIMEOUT_SECS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "AGENT_JOB_TIMEOUT_SECS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.agent.job_timeout_secs), + ), + stuck_threshold: Duration::from_secs( + optional_env("AGENT_STUCK_THRESHOLD_SECS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "AGENT_STUCK_THRESHOLD_SECS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.agent.stuck_threshold_secs), + ), + repair_check_interval: Duration::from_secs( + optional_env("SELF_REPAIR_CHECK_INTERVAL_SECS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "SELF_REPAIR_CHECK_INTERVAL_SECS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.agent.repair_check_interval_secs), + ), + max_repair_attempts: optional_env("SELF_REPAIR_MAX_ATTEMPTS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "SELF_REPAIR_MAX_ATTEMPTS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.agent.max_repair_attempts), use_planning: optional_env("AGENT_USE_PLANNING")? .map(|s| s.parse()) .transpose() @@ -414,7 +475,7 @@ impl AgentConfig { key: "AGENT_USE_PLANNING".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or(true), // Default to planning enabled + .unwrap_or(settings.agent.use_planning), }) } } @@ -464,11 +525,13 @@ pub struct WasmConfig { /// Secrets management configuration. #[derive(Clone, Default)] pub struct SecretsConfig { - /// Master key for encrypting secrets (loaded from SECRETS_MASTER_KEY env var). - /// Must be at least 32 bytes for AES-256-GCM. + /// Master key for encrypting secrets. + /// Source determined by KeySource in settings. pub master_key: Option, /// Whether secrets management is enabled. pub enabled: bool, + /// Source of the master key. + pub source: crate::settings::KeySource, } impl std::fmt::Debug for SecretsConfig { @@ -476,13 +539,53 @@ impl std::fmt::Debug for SecretsConfig { f.debug_struct("SecretsConfig") .field("master_key", &self.master_key.is_some()) .field("enabled", &self.enabled) + .field("source", &self.source) .finish() } } impl SecretsConfig { fn from_env() -> Result { - let master_key = optional_env("SECRETS_MASTER_KEY")?.map(SecretString::from); + use crate::settings::KeySource; + + let settings = crate::settings::Settings::load(); + + // Priority: env var > keychain (based on settings) > disabled + let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? { + // Env var takes priority (for CI/Docker) + (Some(SecretString::from(env_key)), KeySource::Env) + } else { + match settings.secrets_master_key_source { + KeySource::Keychain => { + // Try to load from OS keychain + match crate::secrets::keychain::get_master_key() { + Ok(key_bytes) => { + 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 setup' to reconfigure." + ); + (None, KeySource::None) + } + } + } + KeySource::Env => { + // Settings say env, but no env var found + tracing::warn!( + "Secrets configured for env var but SECRETS_MASTER_KEY not set." + ); + (None, KeySource::None) + } + KeySource::None => (None, KeySource::None), + } + }; + let enabled = master_key.is_some(); // Validate master key length if provided @@ -498,6 +601,7 @@ impl SecretsConfig { Ok(Self { master_key, enabled, + source, }) } @@ -676,7 +780,10 @@ impl Default for HeartbeatConfig { impl HeartbeatConfig { fn from_env() -> Result { + let settings = crate::settings::Settings::load(); + Ok(Self { + // Priority: env var > settings > default enabled: optional_env("HEARTBEAT_ENABLED")? .map(|s| s.parse()) .transpose() @@ -684,10 +791,19 @@ impl HeartbeatConfig { key: "HEARTBEAT_ENABLED".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or(false), - interval_secs: parse_optional_env("HEARTBEAT_INTERVAL_SECS", 1800)?, - notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")?, - notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?, + .unwrap_or(settings.heartbeat.enabled), + interval_secs: optional_env("HEARTBEAT_INTERVAL_SECS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "HEARTBEAT_INTERVAL_SECS".to_string(), + message: format!("must be a positive integer: {e}"), + })? + .unwrap_or(settings.heartbeat.interval_secs), + notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")? + .or(settings.heartbeat.notify_channel.clone()), + notify_user: optional_env("HEARTBEAT_NOTIFY_USER")? + .or(settings.heartbeat.notify_user.clone()), }) } } @@ -787,10 +903,6 @@ impl SandboxModeConfig { // Helper functions -fn required_env(key: &str) -> Result { - std::env::var(key).map_err(|_| ConfigError::MissingEnvVar(key.to_string())) -} - fn optional_env(key: &str) -> Result, ConfigError> { match std::env::var(key) { Ok(val) if val.is_empty() => Ok(None), diff --git a/src/error.rs b/src/error.rs index a8fbced0..10da699f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -47,6 +47,9 @@ pub enum ConfigError { #[error("Missing required environment variable: {0}")] MissingEnvVar(String), + #[error("Missing required configuration: {key}. {hint}")] + MissingRequired { key: String, hint: String }, + #[error("Invalid configuration value for {key}: {message}")] InvalidValue { key: String, message: String }, diff --git a/src/main.rs b/src/main.rs index 89eeaed8..38b2da19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,11 @@ async fn main() -> anyhow::Result<()> { return run_tool_command(tool_cmd.clone()).await; } + Some(Command::Config(config_cmd)) => { + // Config commands don't need logging setup + return ironclaw::cli::run_config_command(config_cmd.clone()) + .map_err(|e| anyhow::anyhow!("{}", e)); + } Some(Command::Setup { skip_auth, channels_only, @@ -70,14 +75,10 @@ async fn main() -> anyhow::Result<()> { // Load .env if present let _ = dotenvy::dotenv(); - // First-run detection: if setup hasn't been completed and user didn't skip it, - // automatically run the setup wizard + // Enhanced first-run detection if !cli.no_setup { - let settings = Settings::load(); - let session_path = ironclaw::llm::session::default_session_path(); - - if !settings.setup_completed && !session_path.exists() { - println!("First run detected. Starting setup wizard..."); + if let Some(reason) = check_setup_needed() { + println!("Setup needed: {}", reason); println!(); let mut wizard = SetupWizard::new(); wizard.run().await?; @@ -85,7 +86,19 @@ async fn main() -> anyhow::Result<()> { } // Load configuration (after potential setup) - let config = Config::from_env()?; + let config = match Config::from_env() { + Ok(c) => c, + Err(ironclaw::error::ConfigError::MissingRequired { key, hint }) => { + eprintln!("Configuration error: Missing required setting '{}'", key); + eprintln!(" {}", hint); + eprintln!(); + eprintln!( + "Run 'ironclaw setup' to configure, or set the required environment variables." + ); + std::process::exit(1); + } + Err(e) => return Err(e.into()), + }; // Initialize session manager and authenticate BEFORE TUI setup // This allows the auth menu to display cleanly without TUI interference @@ -571,6 +584,35 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// Check if setup is needed and return the reason. +/// +/// Returns `Some(reason)` if setup should be triggered, `None` otherwise. +fn check_setup_needed() -> Option<&'static str> { + let settings = Settings::load(); + + // Database not configured (and not in env) + if settings.database_url.is_none() && std::env::var("DATABASE_URL").is_err() { + return Some("Database not configured"); + } + + // Secrets not configured (and not in env) + if settings.secrets_master_key_source == ironclaw::settings::KeySource::None + && std::env::var("SECRETS_MASTER_KEY").is_err() + && !ironclaw::secrets::keychain::has_master_key() + { + // Only require secrets setup if user hasn't explicitly disabled it + // For now, we don't require it for first run + } + + // First run (setup never completed and no session) + let session_path = ironclaw::llm::session::default_session_path(); + if !settings.setup_completed && !session_path.exists() { + return Some("First run"); + } + + None +} + /// Inject credentials for a channel based on naming convention. /// /// Looks for secrets matching the pattern `{channel_name}_*` and injects them diff --git a/src/secrets/keychain.rs b/src/secrets/keychain.rs new file mode 100644 index 00000000..e81efcd0 --- /dev/null +++ b/src/secrets/keychain.rs @@ -0,0 +1,346 @@ +//! OS keychain integration for secrets master key storage. +//! +//! Provides platform-specific keychain support: +//! - macOS: security-framework (Keychain Services) +//! - Linux: secret-service (GNOME Keyring, KWallet) +//! +//! # Example +//! +//! ```ignore +//! use ironclaw::secrets::keychain::{store_master_key, get_master_key, delete_master_key}; +//! +//! // Generate and store a new master key +//! let key = generate_master_key(); +//! store_master_key(&key)?; +//! +//! // Later, retrieve it +//! let key = get_master_key()?; +//! ``` + +use crate::secrets::SecretError; + +/// Service name for keychain entries. +const SERVICE_NAME: &str = "ironclaw"; + +/// Account name for the master key. +const MASTER_KEY_ACCOUNT: &str = "master_key"; + +/// Generate a random 32-byte master key. +pub fn generate_master_key() -> Vec { + use rand::RngCore; + let mut key = vec![0u8; 32]; + rand::thread_rng().fill_bytes(&mut key); + key +} + +/// Generate a master key as a hex string. +pub fn generate_master_key_hex() -> String { + let bytes = generate_master_key(); + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +// ============================================================================ +// macOS implementation using security-framework +// ============================================================================ + +#[cfg(target_os = "macos")] +mod platform { + use security_framework::passwords::{ + delete_generic_password, get_generic_password, set_generic_password, + }; + + use super::*; + + /// Store the master key in the macOS Keychain. + pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + // Convert to hex for storage (keychain prefers strings) + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + + set_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT, key_hex.as_bytes()) + .map_err(|e| SecretError::KeychainError(format!("Failed to store in keychain: {}", e))) + } + + /// Retrieve the master key from the macOS Keychain. + pub fn get_master_key() -> Result, SecretError> { + let password = get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { + SecretError::KeychainError(format!("Failed to get from keychain: {}", e)) + })?; + + // Parse hex string back to bytes + let hex_str = String::from_utf8(password) + .map_err(|_| SecretError::KeychainError("Invalid UTF-8 in keychain".to_string()))?; + + hex_to_bytes(&hex_str) + } + + /// Delete the master key from the macOS Keychain. + pub fn delete_master_key() -> Result<(), SecretError> { + delete_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).map_err(|e| { + SecretError::KeychainError(format!("Failed to delete from keychain: {}", e)) + }) + } + + /// Check if a master key exists in the keychain. + pub fn has_master_key() -> bool { + get_generic_password(SERVICE_NAME, MASTER_KEY_ACCOUNT).is_ok() + } +} + +// ============================================================================ +// Linux implementation using secret-service +// ============================================================================ + +#[cfg(target_os = "linux")] +mod platform { + use secret_service::{EncryptionType, SecretService}; + + use super::*; + + /// Store the master key in the Linux secret service (GNOME Keyring, KWallet). + pub fn store_master_key(key: &[u8]) -> Result<(), SecretError> { + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + + rt.block_on(async { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; + + let collection = ss.get_default_collection().await.map_err(|e| { + SecretError::KeychainError(format!("Failed to get collection: {}", e)) + })?; + + // Unlock if needed + if collection.is_locked().await.unwrap_or(true) { + collection.unlock().await.map_err(|e| { + SecretError::KeychainError(format!("Failed to unlock collection: {}", e)) + })?; + } + + // Convert to hex for storage + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + + collection + .create_item( + &format!("{} master key", SERVICE_NAME), + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + key_hex.as_bytes(), + true, // Replace if exists + "text/plain", + ) + .await + .map_err(|e| { + SecretError::KeychainError(format!("Failed to create secret: {}", e)) + })?; + + Ok(()) + }) + } + + /// Retrieve the master key from the Linux secret service. + pub fn get_master_key() -> Result, SecretError> { + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + + rt.block_on(async { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; + + let items = ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + + let item = items + .unlocked + .first() + .or(items.locked.first()) + .ok_or_else(|| SecretError::KeychainError("Master key not found".to_string()))?; + + // Unlock if needed + if item.is_locked().await.unwrap_or(true) { + item.unlock() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to unlock: {}", e)))?; + } + + let secret = item + .get_secret() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to get secret: {}", e)))?; + + let hex_str = String::from_utf8(secret) + .map_err(|_| SecretError::KeychainError("Invalid UTF-8 in secret".to_string()))?; + + hex_to_bytes(&hex_str) + }) + } + + /// Delete the master key from the Linux secret service. + pub fn delete_master_key() -> Result<(), SecretError> { + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| SecretError::KeychainError("No tokio runtime available".to_string()))?; + + rt.block_on(async { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(|e| { + SecretError::KeychainError(format!( + "Failed to connect to secret service: {}", + e + )) + })?; + + let items = ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to search: {}", e)))?; + + for item in items.unlocked.iter().chain(items.locked.iter()) { + item.delete() + .await + .map_err(|e| SecretError::KeychainError(format!("Failed to delete: {}", e)))?; + } + + Ok(()) + }) + } + + /// Check if a master key exists in the secret service. + pub fn has_master_key() -> bool { + let rt = match tokio::runtime::Handle::try_current() { + Ok(rt) => rt, + Err(_) => return false, + }; + + rt.block_on(async { + let ss = match SecretService::connect(EncryptionType::Dh).await { + Ok(ss) => ss, + Err(_) => return false, + }; + + let items = match ss + .search_items( + [("service", SERVICE_NAME), ("account", MASTER_KEY_ACCOUNT)] + .into_iter() + .collect(), + ) + .await + { + Ok(items) => items, + Err(_) => return false, + }; + + !items.unlocked.is_empty() || !items.locked.is_empty() + }) + } +} + +// ============================================================================ +// Fallback for unsupported platforms +// ============================================================================ + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +mod platform { + use super::*; + + pub fn store_master_key(_key: &[u8]) -> Result<(), SecretError> { + Err(SecretError::KeychainError( + "Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(), + )) + } + + pub fn get_master_key() -> Result, SecretError> { + Err(SecretError::KeychainError( + "Keychain not supported on this platform. Use SECRETS_MASTER_KEY env var.".to_string(), + )) + } + + pub fn delete_master_key() -> Result<(), SecretError> { + Err(SecretError::KeychainError( + "Keychain not supported on this platform".to_string(), + )) + } + + pub fn has_master_key() -> bool { + false + } +} + +// Re-export platform-specific functions +pub use platform::{delete_master_key, get_master_key, has_master_key, store_master_key}; + +/// Parse a hex string to bytes. +fn hex_to_bytes(hex: &str) -> Result, SecretError> { + if hex.len() % 2 != 0 { + return Err(SecretError::KeychainError( + "Invalid hex string length".to_string(), + )); + } + + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&hex[i..i + 2], 16) + .map_err(|_| SecretError::KeychainError("Invalid hex character".to_string())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_master_key() { + let key = generate_master_key(); + assert_eq!(key.len(), 32); + + // Should be different each time + let key2 = generate_master_key(); + assert_ne!(key, key2); + } + + #[test] + fn test_generate_master_key_hex() { + let hex = generate_master_key_hex(); + assert_eq!(hex.len(), 64); // 32 bytes * 2 hex chars + assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_hex_to_bytes() { + let result = hex_to_bytes("deadbeef").unwrap(); + assert_eq!(result, vec![0xde, 0xad, 0xbe, 0xef]); + + let result = hex_to_bytes("00ff").unwrap(); + assert_eq!(result, vec![0x00, 0xff]); + } + + #[test] + fn test_hex_to_bytes_invalid() { + assert!(hex_to_bytes("abc").is_err()); // Odd length + assert!(hex_to_bytes("gg").is_err()); // Invalid chars + } +} diff --git a/src/secrets/mod.rs b/src/secrets/mod.rs index 06bdfc2d..d2f8696d 100644 --- a/src/secrets/mod.rs +++ b/src/secrets/mod.rs @@ -4,6 +4,7 @@ //! - AES-256-GCM encrypted secret storage //! - Per-secret key derivation (HKDF-SHA256) //! - PostgreSQL persistence +//! - OS keychain integration for master key //! - Access control for WASM tools //! //! # Security Model @@ -28,6 +29,12 @@ //! └─────────────────────────────────────────────────────────────────────────────┘ //! ``` //! +//! # Master Key Storage +//! +//! The master key for encrypting secrets can come from: +//! - **OS Keychain** (recommended for local installs): Auto-generated and stored securely +//! - **Environment variable** (for CI/Docker): Set `SECRETS_MASTER_KEY` +//! //! # Example //! //! ```ignore @@ -52,6 +59,7 @@ //! ``` mod crypto; +pub mod keychain; mod store; mod types; diff --git a/src/secrets/types.rs b/src/secrets/types.rs index 0050c9fd..eb259a69 100644 --- a/src/secrets/types.rs +++ b/src/secrets/types.rs @@ -156,6 +156,9 @@ pub enum SecretError { #[error("Secret access denied for tool")] AccessDenied, + + #[error("Keychain error: {0}")] + KeychainError(String), } /// Parameters for creating a new secret. diff --git a/src/settings.rs b/src/settings.rs index b5276d9b..bb594a19 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,6 +1,7 @@ //! User settings persistence. //! -//! Stores user preferences like selected model in ~/.ironclaw/settings.json. +//! Stores user preferences in ~/.ironclaw/settings.json. +//! Settings are loaded with env var > settings.json > default priority. use std::path::PathBuf; @@ -9,21 +10,118 @@ use serde::{Deserialize, Serialize}; /// User settings persisted to disk. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { - /// Currently selected model. - #[serde(default)] - pub selected_model: Option, - /// Whether setup wizard has been completed. #[serde(default)] pub setup_completed: bool, - /// Tunnel configuration for exposing the agent to the internet. + // === Step 1: Database === + /// Database connection URL (postgres://...). + #[serde(default)] + pub database_url: Option, + + /// Database pool size. + #[serde(default)] + pub database_pool_size: Option, + + // === Step 2: Security === + /// Source for the secrets master key. + #[serde(default)] + pub secrets_master_key_source: KeySource, + + // === Step 3: NEAR AI Auth === + // Session stored separately in session.json + + // === Step 4: Model Selection === + /// Currently selected model. + #[serde(default)] + pub selected_model: Option, + + // === Step 5: Embeddings === + /// Embeddings configuration. + #[serde(default)] + pub embeddings: EmbeddingsSettings, + + // === Step 6: Channels === + /// Tunnel configuration for public webhook endpoints. #[serde(default)] pub tunnel: TunnelSettings, /// Channel configuration. #[serde(default)] pub channels: ChannelSettings, + + // === Step 7: Heartbeat === + /// Heartbeat configuration. + #[serde(default)] + pub heartbeat: HeartbeatSettings, + + // === Advanced Settings (not asked during setup, editable via CLI) === + /// Agent behavior configuration. + #[serde(default)] + pub agent: AgentSettings, + + /// WASM sandbox configuration. + #[serde(default)] + pub wasm: WasmSettings, + + /// Docker sandbox configuration. + #[serde(default)] + pub sandbox: SandboxSettings, + + /// Safety configuration. + #[serde(default)] + pub safety: SafetySettings, + + /// Builder configuration. + #[serde(default)] + pub builder: BuilderSettings, +} + +/// Source for the secrets master key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum KeySource { + /// Auto-generated key stored in OS keychain. + Keychain, + /// User provides via SECRETS_MASTER_KEY env var. + Env, + /// Not configured (secrets features disabled). + #[default] + None, +} + +/// Embeddings configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbeddingsSettings { + /// Whether embeddings are enabled. + #[serde(default)] + pub enabled: bool, + + /// Provider to use: "openai" or "nearai". + #[serde(default = "default_embeddings_provider")] + pub provider: String, + + /// Model to use for embeddings. + #[serde(default = "default_embeddings_model")] + pub model: String, +} + +fn default_embeddings_provider() -> String { + "nearai".to_string() +} + +fn default_embeddings_model() -> String { + "text-embedding-3-small".to_string() +} + +impl Default for EmbeddingsSettings { + fn default() -> Self { + Self { + enabled: false, + provider: default_embeddings_provider(), + model: default_embeddings_model(), + } + } } /// Tunnel settings for public webhook endpoints. @@ -47,11 +145,330 @@ pub struct ChannelSettings { #[serde(default)] pub http_port: Option, + /// HTTP webhook host. + #[serde(default)] + pub http_host: Option, + /// Enabled WASM channels by name. /// Channels not in this list but present in the channels directory will still load. /// This is primarily used by the setup wizard to track which channels were configured. #[serde(default)] pub wasm_channels: Vec, + + /// Whether WASM channels are enabled. + #[serde(default = "default_true")] + pub wasm_channels_enabled: bool, + + /// Directory containing WASM channel modules. + #[serde(default)] + pub wasm_channels_dir: Option, +} + +/// Heartbeat configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeartbeatSettings { + /// Whether heartbeat is enabled. + #[serde(default)] + pub enabled: bool, + + /// Interval between heartbeat checks in seconds. + #[serde(default = "default_heartbeat_interval")] + pub interval_secs: u64, + + /// Channel to notify on heartbeat findings. + #[serde(default)] + pub notify_channel: Option, + + /// User ID to notify on heartbeat findings. + #[serde(default)] + pub notify_user: Option, +} + +fn default_heartbeat_interval() -> u64 { + 1800 // 30 minutes +} + +impl Default for HeartbeatSettings { + fn default() -> Self { + Self { + enabled: false, + interval_secs: default_heartbeat_interval(), + notify_channel: None, + notify_user: None, + } + } +} + +/// Agent behavior configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentSettings { + /// Agent name. + #[serde(default = "default_agent_name")] + pub name: String, + + /// Maximum parallel jobs. + #[serde(default = "default_max_parallel_jobs")] + pub max_parallel_jobs: u32, + + /// Job timeout in seconds. + #[serde(default = "default_job_timeout")] + pub job_timeout_secs: u64, + + /// Stuck job threshold in seconds. + #[serde(default = "default_stuck_threshold")] + pub stuck_threshold_secs: u64, + + /// Whether to use planning before tool execution. + #[serde(default = "default_true")] + pub use_planning: bool, + + /// Self-repair check interval in seconds. + #[serde(default = "default_repair_interval")] + pub repair_check_interval_secs: u64, + + /// Maximum repair attempts. + #[serde(default = "default_max_repair_attempts")] + pub max_repair_attempts: u32, +} + +fn default_agent_name() -> String { + "ironclaw".to_string() +} + +fn default_max_parallel_jobs() -> u32 { + 5 +} + +fn default_job_timeout() -> u64 { + 3600 // 1 hour +} + +fn default_stuck_threshold() -> u64 { + 300 // 5 minutes +} + +fn default_repair_interval() -> u64 { + 60 // 1 minute +} + +fn default_max_repair_attempts() -> u32 { + 3 +} + +fn default_true() -> bool { + true +} + +impl Default for AgentSettings { + fn default() -> Self { + Self { + name: default_agent_name(), + max_parallel_jobs: default_max_parallel_jobs(), + job_timeout_secs: default_job_timeout(), + stuck_threshold_secs: default_stuck_threshold(), + use_planning: true, + repair_check_interval_secs: default_repair_interval(), + max_repair_attempts: default_max_repair_attempts(), + } + } +} + +/// WASM sandbox configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WasmSettings { + /// Whether WASM tool execution is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Directory containing installed WASM tools. + #[serde(default)] + pub tools_dir: Option, + + /// Default memory limit in bytes. + #[serde(default = "default_wasm_memory_limit")] + pub default_memory_limit: u64, + + /// Default execution timeout in seconds. + #[serde(default = "default_wasm_timeout")] + pub default_timeout_secs: u64, + + /// Default fuel limit for CPU metering. + #[serde(default = "default_wasm_fuel_limit")] + pub default_fuel_limit: u64, + + /// Whether to cache compiled modules. + #[serde(default = "default_true")] + pub cache_compiled: bool, + + /// Directory for compiled module cache. + #[serde(default)] + pub cache_dir: Option, +} + +fn default_wasm_memory_limit() -> u64 { + 10 * 1024 * 1024 // 10 MB +} + +fn default_wasm_timeout() -> u64 { + 60 +} + +fn default_wasm_fuel_limit() -> u64 { + 10_000_000 +} + +impl Default for WasmSettings { + fn default() -> Self { + Self { + enabled: true, + tools_dir: None, + default_memory_limit: default_wasm_memory_limit(), + default_timeout_secs: default_wasm_timeout(), + default_fuel_limit: default_wasm_fuel_limit(), + cache_compiled: true, + cache_dir: None, + } + } +} + +/// Docker sandbox configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxSettings { + /// Whether the Docker sandbox is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Sandbox policy: "readonly", "workspace_write", or "full_access". + #[serde(default = "default_sandbox_policy")] + pub policy: String, + + /// Command timeout in seconds. + #[serde(default = "default_sandbox_timeout")] + pub timeout_secs: u64, + + /// Memory limit in megabytes. + #[serde(default = "default_sandbox_memory")] + pub memory_limit_mb: u64, + + /// CPU shares (relative weight). + #[serde(default = "default_sandbox_cpu_shares")] + pub cpu_shares: u32, + + /// Docker image for the sandbox. + #[serde(default = "default_sandbox_image")] + pub image: String, + + /// Whether to auto-pull the image if not found. + #[serde(default = "default_true")] + pub auto_pull_image: bool, + + /// Additional domains to allow through the network proxy. + #[serde(default)] + pub extra_allowed_domains: Vec, +} + +fn default_sandbox_policy() -> String { + "readonly".to_string() +} + +fn default_sandbox_timeout() -> u64 { + 120 +} + +fn default_sandbox_memory() -> u64 { + 2048 +} + +fn default_sandbox_cpu_shares() -> u32 { + 1024 +} + +fn default_sandbox_image() -> String { + "ghcr.io/nearai/sandbox:latest".to_string() +} + +impl Default for SandboxSettings { + fn default() -> Self { + Self { + enabled: true, + policy: default_sandbox_policy(), + timeout_secs: default_sandbox_timeout(), + memory_limit_mb: default_sandbox_memory(), + cpu_shares: default_sandbox_cpu_shares(), + image: default_sandbox_image(), + auto_pull_image: true, + extra_allowed_domains: Vec::new(), + } + } +} + +/// Safety configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SafetySettings { + /// Maximum output length in bytes. + #[serde(default = "default_max_output_length")] + pub max_output_length: usize, + + /// Whether injection check is enabled. + #[serde(default = "default_true")] + pub injection_check_enabled: bool, +} + +fn default_max_output_length() -> usize { + 100_000 +} + +impl Default for SafetySettings { + fn default() -> Self { + Self { + max_output_length: default_max_output_length(), + injection_check_enabled: true, + } + } +} + +/// Builder configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuilderSettings { + /// Whether the software builder tool is enabled. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Directory for build artifacts. + #[serde(default)] + pub build_dir: Option, + + /// Maximum iterations for the build loop. + #[serde(default = "default_builder_max_iterations")] + pub max_iterations: u32, + + /// Build timeout in seconds. + #[serde(default = "default_builder_timeout")] + pub timeout_secs: u64, + + /// Whether to automatically register built WASM tools. + #[serde(default = "default_true")] + pub auto_register: bool, +} + +fn default_builder_max_iterations() -> u32 { + 20 +} + +fn default_builder_timeout() -> u64 { + 600 +} + +impl Default for BuilderSettings { + fn default() -> Self { + Self { + enabled: true, + build_dir: None, + max_iterations: default_builder_max_iterations(), + timeout_secs: default_builder_timeout(), + auto_register: true, + } + } } impl Settings { @@ -106,6 +523,163 @@ impl Settings { 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 { + let json = serde_json::to_value(self).ok()?; + let mut current = &json; + + for part in path.split('.') { + current = current.get(part)?; + } + + match current { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + serde_json::Value::Null => Some("null".to_string()), + serde_json::Value::Array(arr) => Some(serde_json::to_string(arr).unwrap_or_default()), + serde_json::Value::Object(obj) => Some(serde_json::to_string(obj).unwrap_or_default()), + } + } + + /// Set a setting value by dotted path. + /// + /// Returns error if path is invalid or value cannot be parsed. + pub fn set(&mut self, path: &str, value: &str) -> Result<(), String> { + let mut json = serde_json::to_value(&self) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + + let parts: Vec<&str> = path.split('.').collect(); + if parts.is_empty() { + return Err("Empty path".to_string()); + } + + // Navigate to parent and set the final key + let mut current = &mut json; + for part in &parts[..parts.len() - 1] { + current = current + .get_mut(*part) + .ok_or_else(|| format!("Path not found: {}", path))?; + } + + let final_key = parts.last().unwrap(); + let obj = current + .as_object_mut() + .ok_or_else(|| format!("Parent is not an object: {}", path))?; + + // Try to infer the type from the existing value + let new_value = if let Some(existing) = obj.get(*final_key) { + match existing { + serde_json::Value::Bool(_) => { + let b = value + .parse::() + .map_err(|_| format!("Expected boolean for {}, got '{}'", path, value))?; + serde_json::Value::Bool(b) + } + serde_json::Value::Number(n) => { + if n.is_u64() { + let n = value.parse::().map_err(|_| { + format!("Expected integer for {}, got '{}'", path, value) + })?; + serde_json::Value::Number(n.into()) + } else if n.is_i64() { + let n = value.parse::().map_err(|_| { + format!("Expected integer for {}, got '{}'", path, value) + })?; + serde_json::Value::Number(n.into()) + } else { + let n = value.parse::().map_err(|_| { + format!("Expected number for {}, got '{}'", path, value) + })?; + serde_json::Number::from_f64(n) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::String(value.to_string())) + } + } + serde_json::Value::Null => { + // Could be Option, try to parse as JSON or use string + serde_json::from_str(value) + .unwrap_or(serde_json::Value::String(value.to_string())) + } + serde_json::Value::Array(_) => serde_json::from_str(value) + .map_err(|e| format!("Invalid JSON array for {}: {}", path, e))?, + serde_json::Value::Object(_) => serde_json::from_str(value) + .map_err(|e| format!("Invalid JSON object for {}: {}", path, e))?, + serde_json::Value::String(_) => serde_json::Value::String(value.to_string()), + } + } else { + // Key doesn't exist, try to parse as JSON or use string + serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.to_string())) + }; + + obj.insert((*final_key).to_string(), new_value); + + // Deserialize back to Settings + *self = + serde_json::from_value(json).map_err(|e| format!("Failed to apply setting: {}", e))?; + + Ok(()) + } + + /// Reset a setting to its default value. + pub fn reset(&mut self, path: &str) -> Result<(), String> { + let default = Self::default(); + let default_value = default + .get(path) + .ok_or_else(|| format!("Unknown setting: {}", path))?; + + self.set(path, &default_value) + } + + /// List all settings as (path, value) pairs. + pub fn list(&self) -> Vec<(String, String)> { + let json = match serde_json::to_value(self) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + + let mut results = Vec::new(); + collect_settings(&json, String::new(), &mut results); + results.sort_by(|a, b| a.0.cmp(&b.0)); + results + } +} + +/// Recursively collect settings paths and values. +fn collect_settings( + value: &serde_json::Value, + prefix: String, + results: &mut Vec<(String, String)>, +) { + match value { + serde_json::Value::Object(obj) => { + for (key, val) in obj { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + collect_settings(val, path, results); + } + } + serde_json::Value::Array(arr) => { + let display = serde_json::to_string(arr).unwrap_or_default(); + results.push((prefix, display)); + } + serde_json::Value::String(s) => { + results.push((prefix, s.clone())); + } + serde_json::Value::Number(n) => { + results.push((prefix, n.to_string())); + } + serde_json::Value::Bool(b) => { + results.push((prefix, b.to_string())); + } + serde_json::Value::Null => { + results.push((prefix, "null".to_string())); + } + } } #[cfg(test)] @@ -146,4 +720,73 @@ mod tests { }; assert_eq!(settings.model_or("default-model"), "my-model".to_string()); } + + #[test] + fn test_get_setting() { + let settings = Settings::default(); + + assert_eq!(settings.get("agent.name"), Some("ironclaw".to_string())); + assert_eq!( + settings.get("agent.max_parallel_jobs"), + Some("5".to_string()) + ); + assert_eq!(settings.get("heartbeat.enabled"), Some("false".to_string())); + assert_eq!(settings.get("nonexistent"), None); + } + + #[test] + fn test_set_setting() { + let mut settings = Settings::default(); + + settings.set("agent.name", "mybot").unwrap(); + assert_eq!(settings.agent.name, "mybot"); + + settings.set("agent.max_parallel_jobs", "10").unwrap(); + assert_eq!(settings.agent.max_parallel_jobs, 10); + + settings.set("heartbeat.enabled", "true").unwrap(); + assert!(settings.heartbeat.enabled); + } + + #[test] + fn test_reset_setting() { + let mut settings = Settings::default(); + + settings.agent.name = "custom".to_string(); + settings.reset("agent.name").unwrap(); + assert_eq!(settings.agent.name, "ironclaw"); + } + + #[test] + fn test_list_settings() { + let settings = Settings::default(); + let list = settings.list(); + + // Check some expected entries + assert!(list.iter().any(|(k, _)| k == "agent.name")); + assert!(list.iter().any(|(k, _)| k == "heartbeat.enabled")); + assert!(list.iter().any(|(k, _)| k == "setup_completed")); + } + + #[test] + fn test_key_source_serialization() { + let settings = Settings { + secrets_master_key_source: KeySource::Keychain, + ..Default::default() + }; + + let json = serde_json::to_string(&settings).unwrap(); + assert!(json.contains("\"keychain\"")); + + let loaded: Settings = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain); + } + + #[test] + fn test_embeddings_defaults() { + let settings = Settings::default(); + assert!(!settings.embeddings.enabled); + assert_eq!(settings.embeddings.provider, "nearai"); + assert_eq!(settings.embeddings.model, "text-embedding-3-small"); + } } diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 50711ffa..71bda14a 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,9 +1,13 @@ //! Interactive setup wizard for IronClaw. //! //! Provides a guided setup experience for: -//! - NEAR AI authentication -//! - Model selection -//! - Channel configuration (HTTP, Telegram, etc.) +//! 1. Database connection +//! 2. Security (secrets master key) +//! 3. NEAR AI authentication +//! 4. Model selection +//! 5. Embeddings +//! 6. Channel configuration (HTTP, Telegram, etc.) +//! 7. Heartbeat (background tasks) //! //! # Example //! @@ -21,5 +25,8 @@ mod wizard; pub use channels::{ SecretsContext, setup_http, setup_telegram, setup_tunnel, validate_telegram_token, }; -pub use prompts::{confirm, print_header, print_step, secret_input, select_many, select_one}; +pub use prompts::{ + confirm, input, optional_input, print_error, print_header, print_info, print_step, + print_success, secret_input, select_many, select_one, +}; pub use wizard::{SetupConfig, SetupWizard}; diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 6ad22295..5ba4b6ac 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -1,9 +1,13 @@ //! Main setup wizard orchestration. //! //! The wizard guides users through: -//! 1. NEAR AI authentication -//! 2. Model selection -//! 3. Channel configuration +//! 1. Database connection +//! 2. Security (secrets master key) +//! 3. NEAR AI authentication +//! 4. Model selection +//! 5. Embeddings +//! 6. Channel configuration +//! 7. Heartbeat (background tasks) use std::sync::Arc; @@ -14,12 +18,13 @@ use tokio_postgres::NoTls; use crate::channels::wasm::ChannelCapabilitiesFile; use crate::llm::{SessionConfig, SessionManager}; use crate::secrets::SecretsCrypto; -use crate::settings::Settings; +use crate::settings::{KeySource, Settings}; use crate::setup::channels::{ SecretsContext, setup_http, setup_telegram, setup_tunnel, setup_wasm_channel, }; use crate::setup::prompts::{ - input, print_header, print_info, print_step, print_success, select_many, select_one, + confirm, input, optional_input, print_error, print_header, print_info, print_step, + print_success, select_many, select_one, }; /// Setup wizard error. @@ -58,6 +63,10 @@ pub struct SetupWizard { config: SetupConfig, settings: Settings, session_manager: Option>, + /// Database pool (created during setup). + db_pool: Option, + /// Secrets crypto (created during setup). + secrets_crypto: Option>, } impl SetupWizard { @@ -67,6 +76,8 @@ impl SetupWizard { config: SetupConfig::default(), settings: Settings::load(), session_manager: None, + db_pool: None, + secrets_crypto: None, } } @@ -76,6 +87,8 @@ impl SetupWizard { config, settings: Settings::load(), session_manager: None, + db_pool: None, + secrets_crypto: None, } } @@ -89,26 +102,45 @@ impl SetupWizard { pub async fn run(&mut self) -> Result<(), SetupError> { print_header("IronClaw Setup Wizard"); - let total_steps = if self.config.channels_only { 1 } else { 3 }; - let mut current_step = 1; + if self.config.channels_only { + // Channels-only mode: just step 6 + print_step(1, 1, "Channel Configuration"); + self.step_channels().await?; + } else { + let total_steps = 7; - // Step 1: Authentication (unless skipped or channels-only) - if !self.config.channels_only && !self.config.skip_auth { - print_step(current_step, total_steps, "NEAR AI Authentication"); - self.step_authentication().await?; - current_step += 1; - } + // Step 1: Database + print_step(1, total_steps, "Database Connection"); + self.step_database().await?; - // Step 2: Model selection (unless channels-only) - if !self.config.channels_only { - print_step(current_step, total_steps, "Model Selection"); + // Step 2: Security + print_step(2, total_steps, "Security"); + self.step_security().await?; + + // Step 3: Authentication (unless skipped) + if !self.config.skip_auth { + print_step(3, total_steps, "NEAR AI Authentication"); + self.step_authentication().await?; + } else { + print_info("Skipping authentication (using existing session)"); + } + + // Step 4: Model selection + print_step(4, total_steps, "Model Selection"); self.step_model_selection().await?; - current_step += 1; - } - // Step 3: Channel configuration - print_step(current_step, total_steps, "Channel Configuration"); - self.step_channels().await?; + // Step 5: Embeddings + print_step(5, total_steps, "Embeddings (Semantic Search)"); + self.step_embeddings()?; + + // Step 6: Channel configuration + print_step(6, total_steps, "Channel Configuration"); + self.step_channels().await?; + + // Step 7: Heartbeat + print_step(7, total_steps, "Background Tasks"); + self.step_heartbeat()?; + } // Save settings and print summary self.save_and_summarize()?; @@ -116,7 +148,195 @@ impl SetupWizard { Ok(()) } - /// Step 1: NEAR AI authentication. + /// Step 1: Database connection. + async fn step_database(&mut self) -> Result<(), SetupError> { + // Check if we have an existing URL in env or settings + let existing_url = std::env::var("DATABASE_URL") + .ok() + .or_else(|| self.settings.database_url.clone()); + + if let Some(ref url) = existing_url { + // Mask the password for display + let display_url = mask_password_in_url(url); + print_info(&format!("Existing database URL: {}", display_url)); + + if confirm("Use this database?", true).map_err(SetupError::Io)? { + // Test the connection + if let Err(e) = self.test_database_connection(url).await { + print_error(&format!("Connection failed: {}", e)); + print_info("Let's configure a new database URL."); + } else { + print_success("Database connection successful"); + self.settings.database_url = Some(url.clone()); + return Ok(()); + } + } + } + + // Prompt for new URL + println!(); + print_info("Enter your PostgreSQL connection URL."); + print_info("Format: postgres://user:password@host:port/database"); + println!(); + + loop { + let url = input("Database URL").map_err(SetupError::Io)?; + + if url.is_empty() { + print_error("Database URL is required."); + continue; + } + + // Test the connection + print_info("Testing connection..."); + match self.test_database_connection(&url).await { + Ok(()) => { + print_success("Database connection successful"); + + // Ask if we should run migrations + if confirm("Run database migrations?", true).map_err(SetupError::Io)? { + self.run_migrations().await?; + } + + self.settings.database_url = Some(url); + return Ok(()); + } + Err(e) => { + print_error(&format!("Connection failed: {}", e)); + if !confirm("Try again?", true).map_err(SetupError::Io)? { + return Err(SetupError::Database( + "Database connection failed".to_string(), + )); + } + } + } + } + } + + /// Test database connection and store the pool. + async fn test_database_connection(&mut self, url: &str) -> Result<(), SetupError> { + let mut cfg = PoolConfig::new(); + cfg.url = Some(url.to_string()); + cfg.pool = Some(deadpool_postgres::PoolConfig { + max_size: 5, + ..Default::default() + }); + + let pool = cfg + .create_pool(Some(Runtime::Tokio1), NoTls) + .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; + + // Test the connection + let _ = pool + .get() + .await + .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))?; + + self.db_pool = Some(pool); + Ok(()) + } + + /// Run database migrations. + async fn run_migrations(&self) -> Result<(), SetupError> { + if let Some(ref pool) = self.db_pool { + use refinery::embed_migrations; + embed_migrations!("migrations"); + + print_info("Running migrations..."); + + let mut client = pool + .get() + .await + .map_err(|e| SetupError::Database(format!("Pool error: {}", e)))?; + + migrations::runner() + .run_async(&mut **client) + .await + .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; + + print_success("Migrations applied"); + } + Ok(()) + } + + /// Step 2: Security (secrets master key). + async fn step_security(&mut self) -> Result<(), SetupError> { + // Check current configuration + let env_key_exists = std::env::var("SECRETS_MASTER_KEY").is_ok(); + let keychain_key_exists = crate::secrets::keychain::has_master_key(); + + if env_key_exists { + print_info("Secrets master key found in SECRETS_MASTER_KEY environment variable."); + self.settings.secrets_master_key_source = KeySource::Env; + print_success("Security configured (env var)"); + return Ok(()); + } + + if keychain_key_exists { + print_info("Existing master key found in OS keychain."); + if confirm("Use existing keychain key?", true).map_err(SetupError::Io)? { + self.settings.secrets_master_key_source = KeySource::Keychain; + print_success("Security configured (keychain)"); + return Ok(()); + } + } + + // Offer options + println!(); + print_info("The secrets master key encrypts sensitive data like API tokens."); + print_info("Choose where to store it:"); + println!(); + + let options = [ + "OS Keychain (recommended for local installs)", + "Environment variable (for CI/Docker)", + "Skip (disable secrets features)", + ]; + + let choice = select_one("Select storage method:", &options).map_err(SetupError::Io)?; + + match choice { + 0 => { + // Generate and store in keychain + print_info("Generating master key..."); + let key = crate::secrets::keychain::generate_master_key(); + + crate::secrets::keychain::store_master_key(&key).map_err(|e| { + SetupError::Config(format!("Failed to store in keychain: {}", e)) + })?; + + // Also create crypto instance + let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex)) + .map_err(|e| SetupError::Config(e.to_string()))?, + )); + + self.settings.secrets_master_key_source = KeySource::Keychain; + print_success("Master key generated and stored in OS keychain"); + } + 1 => { + // Env var mode + print_info("Generate a key and add it to your environment:"); + let key_hex = crate::secrets::keychain::generate_master_key_hex(); + println!(); + println!(" export SECRETS_MASTER_KEY={}", key_hex); + println!(); + print_info("Add this to your shell profile or .env file."); + + self.settings.secrets_master_key_source = KeySource::Env; + print_success("Configured for environment variable"); + } + _ => { + self.settings.secrets_master_key_source = KeySource::None; + print_info("Secrets features disabled. Channel tokens must be set via env vars."); + } + } + + Ok(()) + } + + /// Step 3: NEAR AI authentication. async fn step_authentication(&mut self) -> Result<(), SetupError> { // Check if we already have a session if let Some(ref session) = self.session_manager { @@ -152,7 +372,7 @@ impl SetupWizard { Ok(()) } - /// Step 2: Model selection. + /// Step 4: Model selection. async fn step_model_selection(&mut self) -> Result<(), SetupError> { // Show current model if already configured if let Some(ref current) = self.settings.selected_model { @@ -160,7 +380,8 @@ impl SetupWizard { println!(); let options = ["Keep current model", "Change model"]; - let choice = select_one("What would you like to do?", &options)?; + let choice = + select_one("What would you like to do?", &options).map_err(SetupError::Io)?; if choice == 0 { print_success(&format!("Keeping {}", current)); @@ -201,11 +422,11 @@ impl SetupWizard { let mut all_options = options.clone(); all_options.push("Custom model ID"); - let choice = select_one("Select a model:", &all_options)?; + let choice = select_one("Select a model:", &all_options).map_err(SetupError::Io)?; let selected_model = if choice == all_options.len() - 1 { // Custom model - input("Enter model ID")? + input("Enter model ID").map_err(SetupError::Io)? } else if models.is_empty() { default_models[choice].0.to_string() } else { @@ -220,11 +441,9 @@ impl SetupWizard { /// Fetch available models from the API. async fn fetch_available_models(&self, session: &Arc) -> Vec { - // Create a temporary LLM provider to fetch models use crate::config::LlmConfig; use crate::llm::create_llm_provider; - // Read base URL from env, fallback to cloud-api.near.ai let base_url = std::env::var("NEARAI_BASE_URL") .unwrap_or_else(|_| "https://cloud-api.near.ai".to_string()); let auth_base_url = std::env::var("NEARAI_AUTH_URL") @@ -232,7 +451,7 @@ impl SetupWizard { let config = LlmConfig { nearai: crate::config::NearAiConfig { - model: "dummy".to_string(), // Not used for listing + model: "dummy".to_string(), base_url, auth_base_url, session_path: crate::llm::session::default_session_path(), @@ -259,65 +478,91 @@ impl SetupWizard { } } - /// Initialize secrets context for channel setup. - async fn init_secrets_context(&self) -> Result { - // Get DATABASE_URL - let database_url = std::env::var("DATABASE_URL").map_err(|_| { - SetupError::Config( - "DATABASE_URL not set. Please set it in .env or environment.".to_string(), - ) - })?; + /// Step 5: Embeddings configuration. + fn step_embeddings(&mut self) -> Result<(), SetupError> { + print_info("Embeddings enable semantic search in your workspace memory."); + println!(); - // Get or generate SECRETS_MASTER_KEY - let master_key = match std::env::var("SECRETS_MASTER_KEY") { - Ok(key) => { - if key.len() < 32 { - return Err(SetupError::Config( - "SECRETS_MASTER_KEY must be at least 32 characters".to_string(), - )); + if !confirm("Enable semantic search?", true).map_err(SetupError::Io)? { + self.settings.embeddings.enabled = false; + print_info("Embeddings disabled. Workspace will use keyword search only."); + return Ok(()); + } + + let options = [ + "NEAR AI (uses same auth, no extra cost)", + "OpenAI (requires API key)", + ]; + + let choice = select_one("Select embeddings provider:", &options).map_err(SetupError::Io)?; + + match choice { + 0 => { + self.settings.embeddings.enabled = true; + self.settings.embeddings.provider = "nearai".to_string(); + self.settings.embeddings.model = "text-embedding-3-small".to_string(); + print_success("Embeddings enabled via NEAR AI"); + } + 1 => { + // Check if API key is set + if std::env::var("OPENAI_API_KEY").is_err() { + print_info("OPENAI_API_KEY not set in environment."); + print_info("Add it to your .env file or environment to enable embeddings."); } - key + self.settings.embeddings.enabled = true; + self.settings.embeddings.provider = "openai".to_string(); + self.settings.embeddings.model = "text-embedding-3-small".to_string(); + print_success("Embeddings configured for OpenAI"); } - Err(_) => { - // Generate a new master key - print_info("SECRETS_MASTER_KEY not set. Generating a new one..."); - let key = generate_master_key(); - print_info(&format!( - "Generated master key. Add to your .env file:\nSECRETS_MASTER_KEY={}", - key - )); - key - } - }; + _ => unreachable!(), + } - // Create database pool - let mut cfg = PoolConfig::new(); - cfg.url = Some(database_url); - cfg.pool = Some(deadpool_postgres::PoolConfig { - max_size: 5, - ..Default::default() - }); - - let pool = cfg - .create_pool(Some(Runtime::Tokio1), NoTls) - .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; - - // Test connection - let _ = pool - .get() - .await - .map_err(|e| SetupError::Database(format!("Failed to connect to database: {}", e)))?; - - print_success("Connected to database"); - - // Create crypto - let crypto = SecretsCrypto::new(SecretString::from(master_key)) - .map_err(|e| SetupError::Config(format!("Invalid master key: {}", e)))?; - - Ok(SecretsContext::new(pool, Arc::new(crypto), "default")) + Ok(()) } - /// Step 3: Channel configuration. + /// Initialize secrets context for channel setup. + async fn init_secrets_context(&mut self) -> Result { + // Get database pool (should be set from step 1) + let pool = if let Some(ref p) = self.db_pool { + p.clone() + } else { + // Fall back to creating one from settings/env + let url = self + .settings + .database_url + .clone() + .or_else(|| std::env::var("DATABASE_URL").ok()) + .ok_or_else(|| SetupError::Config("Database URL not configured".to_string()))?; + + self.test_database_connection(&url).await?; + self.db_pool.clone().unwrap() + }; + + // Get crypto (should be set from step 2, or load from keychain/env) + let crypto = if let Some(ref c) = self.secrets_crypto { + Arc::clone(c) + } else { + // Try to load master key from keychain or env + let key = if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY") { + env_key + } else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key() { + keychain_key.iter().map(|b| format!("{:02x}", b)).collect() + } else { + return Err(SetupError::Config( + "Secrets not configured. Run full setup or set SECRETS_MASTER_KEY.".to_string(), + )); + }; + + let crypto = SecretsCrypto::new(SecretString::from(key)) + .map_err(|e| SetupError::Config(e.to_string()))?; + self.secrets_crypto = Some(Arc::new(crypto)); + Arc::clone(self.secrets_crypto.as_ref().unwrap()) + }; + + Ok(SecretsContext::new(pool, crypto, "default")) + } + + /// 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() { @@ -359,12 +604,20 @@ impl SetupWizard { let options_refs: Vec<(&str, bool)> = options.iter().map(|(s, b)| (s.as_str(), *b)).collect(); - let selected = select_many("Which channels do you want to enable?", &options_refs)?; + let selected = select_many("Which channels do you want to enable?", &options_refs) + .map_err(SetupError::Io)?; // Determine if we need secrets context let needs_secrets = selected.iter().any(|&i| i >= 1); let secrets = if needs_secrets { - Some(self.init_secrets_context().await?) + match self.init_secrets_context().await { + Ok(ctx) => Some(ctx), + Err(e) => { + print_info(&format!("Secrets not available: {}", e)); + print_info("Channel tokens must be set via environment variables."); + None + } + } } else { None }; @@ -376,6 +629,10 @@ impl SetupWizard { let result = setup_http(ctx).await.map_err(SetupError::Channel)?; self.settings.channels.http_enabled = result.enabled; self.settings.channels.http_port = Some(result.port); + } else { + self.settings.channels.http_enabled = true; + self.settings.channels.http_port = Some(8080); + print_info("HTTP webhook enabled on port 8080 (set HTTP_WEBHOOK_SECRET in env)"); } } else { self.settings.channels.http_enabled = false; @@ -418,6 +675,13 @@ impl SetupWizard { if result.enabled { enabled_wasm_channels.push(result.channel_name); } + } else { + // No secrets context, just enable the channel + print_info(&format!( + "{} enabled (configure tokens via environment)", + capitalize_first(channel_name) + )); + enabled_wasm_channels.push(channel_name.clone()); } } } @@ -426,6 +690,45 @@ impl SetupWizard { Ok(()) } + /// Step 7: Heartbeat configuration. + fn step_heartbeat(&mut self) -> Result<(), SetupError> { + print_info("Heartbeat runs periodic background tasks (e.g., checking your calendar,"); + print_info("monitoring for notifications, running scheduled workflows)."); + println!(); + + if !confirm("Enable heartbeat?", false).map_err(SetupError::Io)? { + self.settings.heartbeat.enabled = false; + print_info("Heartbeat disabled."); + return Ok(()); + } + + self.settings.heartbeat.enabled = true; + + // Interval + let interval_str = optional_input("Check interval in minutes", Some("default: 30")) + .map_err(SetupError::Io)?; + + if let Some(s) = interval_str { + if let Ok(mins) = s.parse::() { + self.settings.heartbeat.interval_secs = mins * 60; + } + } else { + self.settings.heartbeat.interval_secs = 1800; // 30 minutes + } + + // Notify channel + let notify_channel = optional_input("Notify channel on findings", Some("e.g., telegram")) + .map_err(SetupError::Io)?; + self.settings.heartbeat.notify_channel = notify_channel; + + print_success(&format!( + "Heartbeat enabled (every {} minutes)", + self.settings.heartbeat.interval_secs / 60 + )); + + Ok(()) + } + /// Save settings and print summary. fn save_and_summarize(&mut self) -> Result<(), SetupError> { self.settings.setup_completed = true; @@ -445,8 +748,33 @@ impl SetupWizard { println!("Configuration Summary:"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + if self.settings.database_url.is_some() { + println!(" Database: configured"); + } + + match self.settings.secrets_master_key_source { + KeySource::Keychain => println!(" Security: OS keychain"), + KeySource::Env => println!(" Security: environment variable"), + KeySource::None => println!(" Security: disabled"), + } + if let Some(ref model) = self.settings.selected_model { - println!(" Model: {}", model); + // Truncate long model names + let display = if model.len() > 40 { + format!("{}...", &model[..37]) + } else { + model.clone() + }; + println!(" Model: {}", display); + } + + if self.settings.embeddings.enabled { + println!( + " Embeddings: {} ({})", + self.settings.embeddings.provider, self.settings.embeddings.model + ); + } else { + println!(" Embeddings: disabled"); } if let Some(ref tunnel_url) = self.settings.tunnel.public_url { @@ -474,30 +802,61 @@ impl SetupWizard { ); } + if self.settings.heartbeat.enabled { + println!( + " Heartbeat: every {} minutes", + self.settings.heartbeat.interval_secs / 60 + ); + } + println!(); println!("To start the agent, run:"); println!(" ironclaw"); println!(); + println!("To change settings later:"); + println!(" ironclaw config set "); + println!(" ironclaw setup"); + println!(); Ok(()) } } -/// Generate a random 32-byte master key as hex string. -fn generate_master_key() -> String { - use rand::RngCore; - let mut rng = rand::thread_rng(); - let mut bytes = [0u8; 32]; - rng.fill_bytes(&mut bytes); - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - impl Default for SetupWizard { fn default() -> Self { Self::new() } } +/// Mask password in a database URL for display. +fn mask_password_in_url(url: &str) -> String { + // URL format: scheme://user:password@host/database + // Find "://" to locate start of credentials + let Some(scheme_end) = url.find("://") else { + return url.to_string(); + }; + let credentials_start = scheme_end + 3; // After "://" + + // Find "@" to locate end of credentials + let Some(at_pos) = url[credentials_start..].find('@') else { + return url.to_string(); + }; + let at_abs = credentials_start + at_pos; + + // Find ":" in the credentials section (separates user from password) + let credentials = &url[credentials_start..at_abs]; + let Some(colon_pos) = credentials.find(':') else { + return url.to_string(); + }; + + // Build masked URL: scheme://user:****@host/database + let scheme = &url[..credentials_start]; // "postgres://" + let username = &credentials[..colon_pos]; // "user" + let after_at = &url[at_abs..]; // "@localhost/db" + + format!("{}{}:****{}", scheme, username, after_at) +} + /// Discover WASM channels in a directory. /// /// Returns a list of (channel_name, capabilities_file) pairs. @@ -595,8 +954,23 @@ mod tests { } #[test] - fn test_generate_master_key() { - let key = generate_master_key(); - assert_eq!(key.len(), 64); // 32 bytes = 64 hex chars + fn test_mask_password_in_url() { + assert_eq!( + mask_password_in_url("postgres://user:secret@localhost/db"), + "postgres://user:****@localhost/db" + ); + + // URL without password + assert_eq!( + mask_password_in_url("postgres://localhost/db"), + "postgres://localhost/db" + ); + } + + #[test] + fn test_capitalize_first() { + assert_eq!(capitalize_first("telegram"), "Telegram"); + assert_eq!(capitalize_first("CAPS"), "CAPS"); + assert_eq!(capitalize_first(""), ""); } }