Compare commits

..
Author SHA1 Message Date
Henry ParkandClaude Opus 4.6 f714b0a3dd fix(ci): run all test jobs when invoked via workflow_call
When staging-ci.yml calls test.yml as a reusable workflow, the event
name is the caller's event (workflow_dispatch/schedule), not push or
pull_request. This caused telegram-tests, windows-build, wasm-wit-compat,
and docker-build to be skipped during staging promotion.

Add github.event_name == 'workflow_call' to each gated job's condition.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-09 19:38:17 -07:00
74 changed files with 1021 additions and 3124 deletions
+4
View File
@@ -43,6 +43,7 @@ jobs:
name: Telegram Channel Tests
if: >
github.event_name == 'push' ||
github.event_name == 'workflow_call' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: ubuntu-latest
steps:
@@ -58,6 +59,7 @@ jobs:
name: Windows Build (${{ matrix.name }})
if: >
github.event_name == 'push' ||
github.event_name == 'workflow_call' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: windows-latest
strategy:
@@ -85,6 +87,7 @@ jobs:
name: WASM WIT Compatibility
if: >
github.event_name == 'push' ||
github.event_name == 'workflow_call' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: ubuntu-latest
steps:
@@ -108,6 +111,7 @@ jobs:
name: Docker Build
if: >
github.event_name == 'push' ||
github.event_name == 'workflow_call' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: ubuntu-latest
steps:
-75
View File
@@ -7,81 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.17.0](https://github.com/nearai/ironclaw/compare/v0.16.1...v0.17.0) - 2026-03-10
### Added
- *(llm)* per-provider unsupported parameter filtering (#749, #728) ([#809](https://github.com/nearai/ironclaw/pull/809))
- persist user_id in save_job and expose job_id on routine runs ([#709](https://github.com/nearai/ironclaw/pull/709))
- *(ci)* chained promotion PRs with multi-agent Claude review ([#776](https://github.com/nearai/ironclaw/pull/776))
- add background sandbox reaper for orphaned Docker containers ([#634](https://github.com/nearai/ironclaw/pull/634))
- *(wasm)* lazy schema injection on WASM tool errors ([#638](https://github.com/nearai/ironclaw/pull/638))
- add AWS Bedrock LLM provider via native Converse API ([#713](https://github.com/nearai/ironclaw/pull/713))
- full image support across all channels ([#725](https://github.com/nearai/ironclaw/pull/725))
- *(skills)* exclude_keywords veto in skill activation scoring ([#688](https://github.com/nearai/ironclaw/pull/688))
- *(mcp)* transport abstraction, stdio/UDS transports, and OAuth fixes ([#721](https://github.com/nearai/ironclaw/pull/721))
- add PID-based gateway lock to prevent multiple instances ([#717](https://github.com/nearai/ironclaw/pull/717))
- configurable LLM request timeout via LLM_REQUEST_TIMEOUT_SECS ([#615](https://github.com/nearai/ironclaw/pull/615)) ([#630](https://github.com/nearai/ironclaw/pull/630))
- *(timezone)* add timezone-aware session context ([#671](https://github.com/nearai/ironclaw/pull/671))
- *(setup)* Anthropic OAuth onboarding with setup-token support ([#384](https://github.com/nearai/ironclaw/pull/384))
- *(llm)* add Google Gemini, AWS Bedrock, io.net, Mistral, Yandex, and Cloudflare WS AI providers ([#676](https://github.com/nearai/ironclaw/pull/676))
- unified thread model for web gateway ([#607](https://github.com/nearai/ironclaw/pull/607))
- WASM channel attachments with LLM pipeline integration ([#596](https://github.com/nearai/ironclaw/pull/596))
- enable Anthropic prompt caching via automatic cache_control injection ([#660](https://github.com/nearai/ironclaw/pull/660))
- *(routines)* approval context for autonomous job execution ([#577](https://github.com/nearai/ironclaw/pull/577))
- *(llm)* declarative provider registry ([#618](https://github.com/nearai/ironclaw/pull/618))
- *(gateway)* show IronClaw version in status popover [skip-regression-check] ([#636](https://github.com/nearai/ironclaw/pull/636))
- Wire memory hygiene retention policy into heartbeat loop ([#629](https://github.com/nearai/ironclaw/pull/629))
### Fixed
- *(ci)* run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] ([#802](https://github.com/nearai/ironclaw/pull/802))
- *(ci)* clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] ([#794](https://github.com/nearai/ironclaw/pull/794))
- *(ci)* secrets can't be used in step if conditions [skip-regression-check] ([#787](https://github.com/nearai/ironclaw/pull/787))
- prevent irreversible context loss when compaction archive write fails ([#754](https://github.com/nearai/ironclaw/pull/754))
- button styles ([#637](https://github.com/nearai/ironclaw/pull/637))
- *(mcp)* JSON-RPC spec compliance — flexible id, correct notification format ([#685](https://github.com/nearai/ironclaw/pull/685))
- preserve tool-call history across thread hydration ([#568](https://github.com/nearai/ironclaw/pull/568)) ([#670](https://github.com/nearai/ironclaw/pull/670))
- CLI commands ignore runtime DATABASE_BACKEND when both features compiled ([#740](https://github.com/nearai/ironclaw/pull/740))
- *(web)* prevent fetch error when hostname is an IP address in TEE check ([#672](https://github.com/nearai/ironclaw/pull/672))
- add timezone conversion support to time tool ([#687](https://github.com/nearai/ironclaw/pull/687))
- standardize libSQL timestamps as RFC 3339 UTC ([#683](https://github.com/nearai/ironclaw/pull/683))
- *(docker)* bind postgres to localhost only ([#686](https://github.com/nearai/ironclaw/pull/686))
- *(repl)* skip /quit on EOF when stdin is not a TTY ([#724](https://github.com/nearai/ironclaw/pull/724))
- *(web)* prevent Enter key from sending message during IME composition ([#715](https://github.com/nearai/ironclaw/pull/715))
- *(config)* init_secrets no longer overwrites entire config ([#726](https://github.com/nearai/ironclaw/pull/726))
- *(cli)* status command ignores config.toml and settings.json ([#354](https://github.com/nearai/ironclaw/pull/354)) ([#734](https://github.com/nearai/ironclaw/pull/734))
- *(setup)* preserve model name when re-running onboarding with same provider ([#600](https://github.com/nearai/ironclaw/pull/600)) ([#694](https://github.com/nearai/ironclaw/pull/694))
- *(setup)* initialize secrets crypto for env-var security option ([#666](https://github.com/nearai/ironclaw/pull/666)) ([#706](https://github.com/nearai/ironclaw/pull/706))
- persist /model selection across restarts ([#707](https://github.com/nearai/ironclaw/pull/707))
- *(routines)* resolve message tool channel/target from per-job metadata ([#708](https://github.com/nearai/ironclaw/pull/708))
- sanitize HTML error bodies from MCP servers to prevent web UI white screen ([#263](https://github.com/nearai/ironclaw/pull/263)) ([#656](https://github.com/nearai/ironclaw/pull/656))
- prevent Instant duration overflow on Windows ([#657](https://github.com/nearai/ironclaw/pull/657)) ([#664](https://github.com/nearai/ironclaw/pull/664))
- enable libsql remote + tls features for Turso cloud sync ([#587](https://github.com/nearai/ironclaw/pull/587))
- *(tests)* replace hardcoded /tmp paths with tempdir + add 300 unit tests ([#659](https://github.com/nearai/ironclaw/pull/659))
- *(llm)* nudge LLM when it expresses tool intent without calling tools ([#653](https://github.com/nearai/ironclaw/pull/653))
- *(llm)* report zero cost for OpenRouter free-tier models ([#463](https://github.com/nearai/ironclaw/pull/463)) ([#613](https://github.com/nearai/ironclaw/pull/613))
- reliable network tests and improved tool error messages ([#626](https://github.com/nearai/ironclaw/pull/626))
- *(wasm)* use per-engine cache dirs on Windows to avoid file lock error ([#624](https://github.com/nearai/ironclaw/pull/624))
- *(libsql)* support flexible embedding dimensions ([#534](https://github.com/nearai/ironclaw/pull/534))
### Other
- Restructure CLAUDE.md into modular rules + add pr-shepherd command ([#750](https://github.com/nearai/ironclaw/pull/750))
- make src/llm/ self-contained for crate extraction ([#767](https://github.com/nearai/ironclaw/pull/767))
- add simplified Chinese (zh-CN) README translation ([#488](https://github.com/nearai/ironclaw/pull/488))
- *(job)* cover job tool validation and state transitions ([#681](https://github.com/nearai/ironclaw/pull/681))
- *(agent)* wire TestRig job tools through the scheduler ([#716](https://github.com/nearai/ironclaw/pull/716))
- Fix single-message mode to exit after one turn when background channels are enabled ([#719](https://github.com/nearai/ironclaw/pull/719))
- remove dead code ([#648](https://github.com/nearai/ironclaw/pull/648)) ([#703](https://github.com/nearai/ironclaw/pull/703))
- add reviewer-feedback guardrails (CLAUDE.md, pre-commit hook, skill) ([#665](https://github.com/nearai/ironclaw/pull/665))
- update WASM artifact SHA256 checksums [skip ci] ([#631](https://github.com/nearai/ironclaw/pull/631))
- add explanatory comments to coverage workflow ([#610](https://github.com/nearai/ironclaw/pull/610))
- build system prompt once per turn, skip tools on force-text ([#583](https://github.com/nearai/ironclaw/pull/583))
- add comprehensive subdirectory CLAUDE.md files and update root ([#589](https://github.com/nearai/ironclaw/pull/589))
- Improve test infrastructure: StubChannel, gateway helpers, security tests, search edge cases ([#623](https://github.com/nearai/ironclaw/pull/623))
- *(workspace)* regression test for document_path in search results ([#509](https://github.com/nearai/ironclaw/pull/509))
### Added
- AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`)
+3 -36
View File
@@ -64,13 +64,6 @@ src/
│ ├── repl.rs # Simple REPL (for testing)
│ ├── web/ # Web gateway (browser UI) — see src/channels/web/CLAUDE.md
│ └── wasm/ # WASM channel runtime
│ ├── mod.rs
│ ├── bundled.rs # Bundled channel discovery
│ ├── capabilities.rs # Channel-specific capabilities (HTTP endpoint, emit rate)
│ ├── error.rs # WASM channel error types
│ ├── runtime.rs # WASM channel execution runtime
│ ├── setup.rs # WasmChannelSetup, setup_wasm_channels(), inject_channel_credentials()
│ └── wrapper.rs # Channel trait wrapper for WASM modules
├── cli/ # CLI subcommands (clap)
│ ├── mod.rs # Cli struct, Command enum (run/onboard/config/tool/registry/mcp/memory/pairing/service/doctor/status/completion)
@@ -83,13 +76,7 @@ src/
├── hooks/ # Lifecycle hooks (6 points: BeforeInbound, BeforeToolCall, BeforeOutbound, OnSessionStart, OnSessionEnd, TransformResponse)
├── tunnel/ # Tunnel abstraction for public internet exposure
│ ├── mod.rs # Tunnel trait, TunnelProviderConfig, create_tunnel(), start_managed_tunnel()
│ ├── cloudflare.rs # CloudflareTunnel (cloudflared binary)
│ ├── ngrok.rs # NgrokTunnel
│ ├── tailscale.rs # TailscaleTunnel (serve/funnel modes)
│ ├── custom.rs # CustomTunnel (arbitrary command with {host}/{port})
│ └── none.rs # NoneTunnel (local-only, no exposure)
├── tunnel/ # Tunnel abstraction (cloudflare, ngrok, tailscale, custom, none)
├── observability/ # Pluggable event/metric recording (noop, log, multi)
@@ -118,26 +105,8 @@ src/
│ ├── rate_limiter.rs # Shared sliding-window rate limiter
│ ├── builtin/ # Built-in tools (echo, time, json, http, web_fetch, file, shell, memory, message, job, routine, extension_tools, skill_tools, secrets_tools)
│ ├── builder/ # Dynamic tool building
│ ├── core.rs # BuildRequirement, SoftwareType, Language
│ ├── templates.rs # Project scaffolding
│ │ ├── testing.rs # Test harness integration
│ │ └── validation.rs # WASM validation
│ ├── mcp/ # Model Context Protocol
│ │ ├── client.rs # MCP client over HTTP
│ │ ├── factory.rs # create_client_from_config() — transport dispatch factory
│ │ ├── protocol.rs # JSON-RPC types
│ │ └── session.rs # MCP session management (Mcp-Session-Id header, per-server state)
│ └── wasm/ # Full WASM sandbox (wasmtime)
│ ├── runtime.rs # Module compilation and caching
│ ├── wrapper.rs # Tool trait wrapper for WASM modules
│ ├── host.rs # Host functions (logging, time, workspace)
│ ├── limits.rs # Fuel metering and memory limiting
│ ├── allowlist.rs # Network endpoint allowlisting
│ ├── credential_injector.rs # Safe credential injection
│ ├── loader.rs # WASM tool discovery from filesystem
│ ├── rate_limiter.rs # Per-tool rate limiting
│ ├── error.rs # WASM-specific error types
│ └── storage.rs # Linear memory persistence
│ ├── mcp/ # Model Context Protocol client
└── wasm/ # Full WASM sandbox (wasmtime) — runtime, host functions, fuel metering, allowlist, credential injection
├── db/ # Dual-backend persistence (PostgreSQL + libSQL) — see src/db/CLAUDE.md
@@ -175,8 +144,6 @@ Dual-backend: PostgreSQL + libSQL/Turso. **All new persistence features must sup
When modifying a module with a spec, read the spec first. Code follows spec; spec is the tiebreaker.
**Module-owned initialization:** Module-specific initialization logic (database connection, transport creation, channel setup) must live in the owning module as a public factory function — not in `main.rs` or `app.rs`. These entry-point files orchestrate calls to module factories. Feature-flag branching (`#[cfg(feature = ...)]`) must be confined to the module that owns the abstraction.
| Module | Spec |
|--------|------|
| `src/agent/` | `src/agent/CLAUDE.md` |
Generated
+1 -1
View File
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.17.0"
version = "0.16.1"
dependencies = [
"aes-gcm",
"aho-corasick",
+1 -1
View File
@@ -18,7 +18,7 @@ exclude = [
[package]
name = "ironclaw"
version = "0.17.0"
version = "0.16.1"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
+44 -51
View File
@@ -10,8 +10,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- 🚫 Out of scope (intentionally skipped)
- N/A (not applicable to Rust implementation)
**Last reviewed against OpenClaw PRs:** 2026-03-10 (merged 2026-02-24 through 2026-03-10)
---
## 1. Architecture
@@ -41,11 +39,11 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
| Gateway lock (PID-based) | ✅ | | |
| Gateway lock (PID-based) | ✅ | | `fs4` flock-based, acquired in `main.rs` before agent startup |
| launchd/systemd integration | ✅ | ❌ | |
| Bonjour/mDNS discovery | ✅ | ❌ | |
| Tailscale integration | ✅ | ❌ | |
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status + /healthz + /readyz, with channel-backed readiness probes |
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
| `doctor` diagnostics | ✅ | ❌ | |
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval |
@@ -68,17 +66,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| REPL (simple) | ✅ | ✅ | - | For testing |
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics |
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
| Slack | ✅ | ✅ | - | WASM tool |
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools, Docx table/image/file actions, rich-text media extraction |
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools |
| LINE | ✅ | ❌ | P3 | |
| WebChat | ✅ | ✅ | - | Web gateway chat |
| Matrix | ✅ | ❌ | P3 | E2EE support |
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
| Mattermost | ✅ | ❌ | P3 | Emoji reactions |
| Google Chat | ✅ | ❌ | P3 | |
| MS Teams | ✅ | ❌ | P3 | |
| Twitch | ✅ | ❌ | P3 | |
@@ -94,8 +92,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| User message reactions | ✅ | ❌ | Surface inbound reactions |
| sendPoll | ✅ | ❌ | Poll creation via agent |
| Cron/heartbeat topic targeting | ✅ | ❌ | Messages land in correct topic |
| DM topics support | ✅ | ❌ | Agent/topic bindings in DMs and agent-scoped SessionKeys |
| Persistent ACP topic binding | ✅ | ❌ | ACP harness sessions can pin to Telegram forum or DM topics |
### Discord-Specific Features (since Feb 2025)
@@ -111,36 +107,21 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|---------|----------|----------|-------|
| Streaming draft replies | ✅ | ❌ | Partial replies via draft message updates |
| Configurable stream modes | ✅ | ❌ | Per-channel stream behavior |
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking plus reply participation memory |
| Download-file action | ✅ | ❌ | On-demand attachment downloads via message actions |
### Mattermost-Specific Features (since Mar 2026)
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| Interactive buttons | ✅ | ❌ | Clickable message buttons with signed callback flow |
| Interactive model picker | ✅ | ❌ | In-channel provider/model chooser |
### Feishu/Lark-Specific Features (since Mar 2026)
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| Doc/table actions | ✅ | ❌ | `feishu_doc` supports tables, positional insert, color_text, image upload, and file upload |
| Rich-text embedded media extraction | ✅ | ❌ | Pull video/media attachments from post messages |
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking |
### Channel Features
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | `allow_from` + pairing store + hardened command/group allowlists |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
| Thread isolation | ✅ | ✅ | Separate sessions per thread/topic |
| Per-channel media limits | ✅ | 🚧 | Caption support plus `mediaMaxMb` enforcement for WhatsApp, Telegram, and Discord |
| Typing indicators | ✅ | 🚧 | TUI + channel typing, with configurable silence timeout; richer parity pending |
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions/scopes |
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
| Per-channel media limits | ✅ | | Attachment type in WIT; max 10 per msg, 20MB total, MIME allowlist |
| Typing indicators | ✅ | 🚧 | TUI + Telegram typing/actionable status prompts; richer parity pending |
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions |
| Group session priming | ✅ | ❌ | Member roster injected for context |
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata |
@@ -157,8 +138,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `gateway start/stop` | ✅ | ❌ | P2 | |
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
| `tui` | ✅ | ✅ | - | Ratatui TUI |
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
| `config` | ✅ | ✅ | - | Read/write config |
| `channels` | ✅ | ❌ | P2 | Channel management |
| `models` | ✅ | 🚧 | - | Model selector in TUI |
| `status` | ✅ | ✅ | - | System status (enriched session details) |
@@ -197,15 +177,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Global sessions | ✅ | ❌ | Optional shared context |
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
| Context compaction | ✅ | ✅ | Auto summarization |
| Compaction model override | ✅ | ❌ | Use a dedicated provider/model for summarization only |
| Post-compaction read audit | ✅ | ❌ | Layer 3: workspace rules appended to summaries |
| Post-compaction context injection | ✅ | ❌ | Workspace context as system event |
| Custom system prompts | ✅ | ✅ | Template variables, safety guardrails |
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | ❌ | Configurable reasoning depth |
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model; Anthropic Claude 4.6 defaults to adaptive |
| Thinking modes (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model |
| Block-level streaming | ✅ | ❌ | |
| Tool-level streaming | ✅ | ❌ | |
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
@@ -234,11 +213,15 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Provider | OpenClaw | IronClaw | Priority | Notes |
|----------|----------|----------|----------|-------|
| NEAR AI | ✅ | ✅ | - | Primary provider |
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6, adaptive thinking default |
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy; GPT-5.4 + Codex OAuth |
| AWS Bedrock | ✅ | ❌ | P3 | |
| Google Gemini | ✅ | | P3 | |
| NVIDIA API | ✅ | | P3 | New provider |
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6 |
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy |
| AWS Bedrock | ✅ | ✅ | - | Native Converse API via aws-sdk-bedrockruntime (requires `--features bedrock`) |
| Google Gemini | ✅ | | P3 | Via `gemini` adapter |
| io.net | ✅ | | P3 | Via `ionet` adapter |
| Mistral | ✅ | ✅ | P3 | Via `mistral` adapter |
| Yandex AI Studio | ✅ | ✅ | P3 | Via `yandex` adapter |
| Cloudflare Workers AI | ✅ | ✅ | P3 | Via `cloudflare` adapter |
| NVIDIA API | ✅ | ✅ | P3 | Via `nvidia` adapter and `providers.json` |
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
@@ -259,7 +242,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Per-session model override | ✅ | ✅ | Model selector in TUI |
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
| Per-model thinkingDefault | ✅ | ❌ | Override thinking level per model in config |
| 1M context support | ✅ | ❌ | Anthropic extended context beta + OpenAI Codex GPT-5.4 1M context |
| 1M context beta header | ✅ | ❌ | Anthropic extended context support |
### Owner: _Unassigned_
@@ -269,20 +252,32 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Priority | Notes |
|---------|----------|----------|----------|-------|
| WIT inbound-attachment type | N/A | ✅ | P1 | `inbound-attachment` record in channel-host (id, mime_type, filename, size_bytes, source_url, storage_key, extracted_text) |
| WIT outbound attachment type | N/A | ✅ | P1 | `attachment` record in channel (filename, mime_type, data) on `agent-response` |
| WIT on-broadcast export | N/A | ✅ | P1 | Proactive message sending without prior incoming message |
| IncomingMessage attachments | N/A | ✅ | P1 | `IncomingAttachment` struct on `IncomingMessage`, populated from WASM channels |
| OutgoingResponse attachments | N/A | ✅ | P1 | File paths on `OutgoingResponse`, read from disk and sent as WIT attachments |
| Attachment security (size/MIME) | N/A | ✅ | P1 | Inbound: max 10, 20MB total, MIME allowlist. Outbound: 50MB total |
| Telegram media parsing | ✅ | ✅ | P1 | Photo, document, audio, video, voice, sticker parsed and emitted as attachments |
| Telegram media sending | ✅ | ✅ | P1 | sendPhoto/sendDocument multipart upload, auto photo→document fallback >10MB |
| Slack file parsing | ✅ | ✅ | P1 | `files` array from Events API parsed into attachments |
| WhatsApp media parsing | ✅ | ✅ | P1 | Image, audio, video, document parsed with caption as extracted_text |
| Discord attachment parsing | ✅ | ❌ | P2 | Discord interaction payloads don't include file attachments (needs message events) |
| HTTP tool save_to | N/A | ✅ | P1 | Download binary files to /tmp/ for attachment sending (50MB limit, path traversal protection) |
| Credential env var fallback | N/A | ✅ | P2 | Channels can use env vars (e.g., TELEGRAM_BOT_TOKEN) when secrets store not configured |
| Image processing (Sharp) | ✅ | ❌ | P2 | Resize, format convert |
| Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config |
| Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images |
| Audio transcription | ✅ | ❌ | P2 | |
| Video support | ✅ | ❌ | P3 | |
| PDF analysis tool | ✅ | ❌ | P2 | Native Anthropic/Gemini path with text/image extraction fallback |
| PDF parsing | ✅ | | P2 | `pdfjs-dist` fallback path |
| MIME detection | ✅ | ❌ | P2 | |
| PDF parsing | ✅ | ❌ | P2 | pdfjs-dist |
| MIME detection | ✅ | | P2 | MIME allowlist in host validates attachment types |
| Media caching | ✅ | ❌ | P3 | |
| Vision model integration | ✅ | ❌ | P2 | Image understanding |
| TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech |
| TTS (OpenAI) | ✅ | ❌ | P3 | |
| Incremental TTS playback | ✅ | ❌ | P3 | iOS progressive playback |
| Sticker-to-image | ✅ | | P3 | Telegram stickers |
| Sticker-to-image | ✅ | | P3 | Telegram stickers emitted as image/webp attachments |
### Owner: _Unassigned_
@@ -298,8 +293,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Workspace-relative install | ✅ | ✅ | ~/.ironclaw/tools/ |
| Channel plugins | ✅ | ✅ | WASM channels |
| Auth plugins | ✅ | ❌ | |
| Memory plugins | ✅ | ❌ | Custom backends + selectable memory slot |
| Context-engine plugins | ✅ | ❌ | Custom context management + subagent/context hooks |
| Memory plugins | ✅ | ❌ | Custom backends |
| Tool plugins | ✅ | ✅ | WASM tools |
| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities |
| Provider plugins | ✅ | ❌ | |
@@ -321,7 +315,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| JSON5 support | ✅ | ❌ | Comments, trailing commas |
| YAML alternative | ✅ | ❌ | |
| Environment variable interpolation | ✅ | ✅ | `${VAR}` |
| Config validation/schema | ✅ | ✅ | Type-safe Config struct + `openclaw config validate` |
| Config validation/schema | ✅ | ✅ | Type-safe Config struct |
| Hot-reload | ✅ | ❌ | |
| Legacy migration | ✅ | | |
| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | |
@@ -428,7 +422,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Priority | Notes |
|---------|----------|----------|----------|-------|
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
| Per-job model fallback override | ✅ | ❌ | P2 | `payload.fallbacks` overrides agent-level fallbacks |
| Cron stagger controls | ✅ | ❌ | P3 | Default stagger for scheduled jobs |
| Cron finished-run webhook | ✅ | ❌ | P3 | Webhook on job completion |
| Timezone support | ✅ | ✅ | - | Via cron expressions |
@@ -482,10 +475,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Elevated mode | ✅ | ❌ | |
| Safe bins allowlist | ✅ | ❌ | Hardened path trust |
| LD*/DYLD* validation | ✅ | ❌ | |
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) + workspace-only tool mounts |
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) |
| Credential theft via env injection | ✅ | 🚧 | Shell env scrubbing + command injection detection; no full OC-09 defense |
| Session file permissions (0o600) | ✅ | ✅ | Session token file set to 0o600 in llm/session.rs |
| Skill download path restriction | ✅ | ❌ | Validated download roots prevent arbitrary write targets |
| Skill download path restriction | ✅ | ❌ | Prevent arbitrary write targets |
| Webhook signature verification | ✅ | ✅ | |
| Media URL validation | ✅ | ❌ | |
| Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization |
+1 -3
View File
@@ -9,9 +9,8 @@
"api_key_required": true,
"base_url_env": "OPENAI_BASE_URL",
"model_env": "OPENAI_MODEL",
"default_model": "gpt-5-mini",
"default_model": "gpt-4o",
"description": "OpenAI GPT models (direct API)",
"unsupported_params": ["temperature"],
"setup": {
"kind": "api_key",
"secret_name": "llm_openai_api_key",
@@ -87,7 +86,6 @@
"model_env": "TINFOIL_MODEL",
"default_model": "kimi-k2-5",
"description": "Tinfoil private inference (hardware-attested TEE)",
"unsupported_params": ["temperature"],
"setup": {
"kind": "api_key",
"secret_name": "llm_tinfoil_api_key",
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
"sha256": "85b424604482da3fb9badb56a0360ff4c93670bc7be0ad7f57ef9d85ff972b6f"
"sha256": "030707431717bca3411a48f311c6ab5f92a45c747de26cafe4f6e3e23a8b3b2d"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
"sha256": "9190b8250bd20c22a8c97b1ea19a6590624a69d6c63a5f5c240a7840a4966286"
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
"sha256": "55f2a56e7afd129a48fd49b019f12f9638705defa53fa323ad3b8978d7c59664"
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
"sha256": "06bcf315df93af9f683134f4055eb810c602863d8c4a632e3733a10217cc5a89"
"sha256": "bd35cad18d87292ea8d2f52db9b514ed9f814a414de910f59073d475c26c4c14"
}
},
"auth_summary": {
+1 -1
View File
@@ -20,7 +20,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
"sha256": "c443328a3f10b6a4cf4d3d62c9217aca204f6467ef753d986b58ca966ca53514"
"sha256": "6fcd32719a4ff15641a4b50fff8984686550f0c491dce60518f4126857d0c544"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
"sha256": "e4f0095890d22e3de8e9d516f2e1e91964f8ff4acdaaa19f0a7094a1f2d7786b"
"sha256": "023da7000b17568bf0e64b2e5013c8a042b2f323c85f1632339231c73d500e39"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
"sha256": "2d202bd838de94677c91ea6473c7155f021c0500cf91794d17639b1b27446b3d"
"sha256": "fc42277b65881d6e9bcc5403dc54c7f5b3ddeaaaf04617fce2c5da05d76325f0"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
"sha256": "7a5e40fe58199e34f7625e11d22e5601cdfd2a94a10193a83f1925180bbb66df"
"sha256": "385c04abd1e6b8011ccc330e1f4bd7ce58577e488959b51594aa04eb26cbe7cc"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
"sha256": "d19f856fde0ae0320fd3f636a34116af1df0b59698c3684b686e8412a60e887f"
"sha256": "1b107d575a5d52cc8c76d9a681802190f4373fb485f7f54f445533f097fa37c0"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
"sha256": "e113c317f9fa21ea68d0ec8accbba4a62a8222ff3c4655ae85e1e58e01de3250"
"sha256": "c4f6b1e8c5126ac2c8a4b98e4283a3afa32223d2488fc3c3a609758c0c9beb90"
}
},
"auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
"sha256": "7875a5ae1283e57937e0618bf14465f4bb4ee7f49110312382670202f4c567a5"
"sha256": "7110b8565340c888e51f99e9c013bf4de8f8a7f7b33bace00eb8fc47831ff20b"
}
},
"auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
"sha256": "9190b8250bd20c22a8c97b1ea19a6590624a69d6c63a5f5c240a7840a4966286"
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
"sha256": "55f2a56e7afd129a48fd49b019f12f9638705defa53fa323ad3b8978d7c59664"
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
"sha256": "dd7e54956ee0b3037ca3506dbcbd20efcc4cd2749175ed511b3640b09f77506a"
"sha256": "66cb2b9b00652385e9f30f17c74902b9222c17c53e9d3bd1ef42f5cab705bcf6"
}
},
"auth_summary": {
+6 -12
View File
@@ -29,7 +29,6 @@ use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
use crate::skills::SkillRegistry;
use crate::tools::ToolRegistry;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::workspace::Workspace;
/// Collapse a tool output string into a single-line preview for display.
@@ -82,8 +81,6 @@ pub struct AgentDeps {
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
/// Idempotency cache for tool executions.
pub idempotency_cache: ToolIdempotencyCache,
}
/// The main agent that coordinates all components.
@@ -133,7 +130,6 @@ impl Agent {
deps.tools.clone(),
deps.store.clone(),
deps.hooks.clone(),
deps.idempotency_cache.clone(),
);
if let Some(ref tx) = deps.sse_tx {
scheduler.set_sse_sender(tx.clone());
@@ -450,8 +446,6 @@ impl Agent {
Arc::clone(workspace),
notify_tx,
Some(self.scheduler.clone()),
self.tools().clone(),
self.safety().clone(),
));
// Register routine tools
@@ -520,7 +514,7 @@ impl Agent {
*slot.write().await = Some(Arc::clone(&engine));
}
tracing::debug!(
tracing::info!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
rt_config.cron_check_interval_secs,
rt_config.max_concurrent_routines
@@ -542,20 +536,20 @@ impl Agent {
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
tracing::info!("Agent {} ready and listening", self.config.name);
loop {
let message = tokio::select! {
biased;
_ = tokio::signal::ctrl_c() => {
tracing::debug!("Ctrl+C received, shutting down...");
tracing::info!("Ctrl+C received, shutting down...");
break;
}
msg = message_stream.next() => {
match msg {
Some(m) => m,
None => {
tracing::debug!("All channel streams ended, shutting down...");
tracing::info!("All channel streams ended, shutting down...");
break;
}
}
@@ -630,7 +624,7 @@ impl Agent {
}
Ok(None) => {
// Shutdown signal received (/quit, /exit, /shutdown)
tracing::debug!("Shutdown command received, exiting...");
tracing::info!("Shutdown command received, exiting...");
break;
}
Err(e) => {
@@ -659,7 +653,7 @@ impl Agent {
}
// Cleanup
tracing::debug!("Agent shutting down...");
tracing::info!("Agent shutting down...");
repair_handle.abort();
pruning_handle.abort();
if let Some(handle) = heartbeat_handle {
+8 -59
View File
@@ -15,7 +15,6 @@ use crate::channels::{IncomingMessage, StatusUpdate};
use crate::context::JobContext;
use crate::error::Error;
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::redact_params;
/// Result of the agentic loop execution.
@@ -570,7 +569,6 @@ impl Agent {
let pf_idx = *pf_idx;
let tools = self.tools().clone();
let safety = self.safety().clone();
let idempotency_cache = self.deps.idempotency_cache.clone();
let channels = self.channels.clone();
let job_ctx = job_ctx.clone();
let tc = tc.clone();
@@ -591,7 +589,6 @@ impl Agent {
let result = execute_chat_tool_standalone(
&tools,
&safety,
&idempotency_cache,
&tc.name,
&tc.arguments,
&job_ctx,
@@ -862,15 +859,7 @@ impl Agent {
params: &serde_json::Value,
job_ctx: &JobContext,
) -> Result<String, Error> {
execute_chat_tool_standalone(
self.tools(),
self.safety(),
&self.deps.idempotency_cache,
tool_name,
params,
job_ctx,
)
.await
execute_chat_tool_standalone(self.tools(), self.safety(), tool_name, params, job_ctx).await
}
}
@@ -879,11 +868,9 @@ impl Agent {
/// This standalone function enables parallel invocation from spawned JoinSet
/// tasks, which cannot borrow `&self`. It replicates the logic from
/// `Agent::execute_chat_tool`.
#[allow(clippy::too_many_arguments)]
pub(super) async fn execute_chat_tool_standalone(
tools: &crate::tools::ToolRegistry,
safety: &crate::safety::SafetyLayer,
idempotency_cache: &ToolIdempotencyCache,
tool_name: &str,
params: &serde_json::Value,
job_ctx: &crate::context::JobContext,
@@ -895,15 +882,6 @@ pub(super) async fn execute_chat_tool_standalone(
name: tool_name.to_string(),
})?;
// Check idempotency cache
let job_id_str = job_ctx.job_id.to_string();
if tool.is_idempotent()
&& let Some(cached) = idempotency_cache.get(&job_id_str, tool_name, params).await
{
tracing::debug!(tool = %tool_name, "Idempotency cache hit (chat)");
return Ok(cached);
}
// Validate tool parameters
let validation = safety.validator().validate_tool_params(params);
if !validation.is_valid {
@@ -975,25 +953,13 @@ pub(super) async fn execute_chat_tool_standalone(
reason: e.to_string(),
})?;
let result_str: Result<String, Error> =
serde_json::to_string_pretty(&result.result).map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
});
// Cache successful results for idempotent tools
if let Ok(ref output_str) = result_str
&& tool.is_idempotent()
{
idempotency_cache
.put(&job_id_str, tool_name, params, output_str.clone())
.await;
}
result_str
serde_json::to_string_pretty(&result.result).map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
})
}
/// Parsed auth result fields for emitting StatusUpdate::AuthRequired.
@@ -1221,9 +1187,6 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Agent::new(
@@ -1557,13 +1520,9 @@ mod tests {
let job_ctx = JobContext::with_user("test", "chat", "test session");
let cache = crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
);
let result = super::execute_chat_tool_standalone(
&registry,
&safety,
&cache,
"echo",
&serde_json::json!({"message": "hello"}),
&job_ctx,
@@ -1589,13 +1548,9 @@ mod tests {
});
let job_ctx = JobContext::with_user("test", "chat", "test session");
let cache = crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
);
let result = super::execute_chat_tool_standalone(
&registry,
&safety,
&cache,
"nonexistent",
&serde_json::json!({}),
&job_ctx,
@@ -2071,9 +2026,6 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Agent::new(
@@ -2191,9 +2143,6 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Agent::new(
+14 -459
View File
@@ -25,14 +25,10 @@ use crate::agent::routine::{
};
use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::config::RoutineConfig;
use crate::context::JobContext;
use crate::db::Database;
use crate::error::RoutineError;
use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
};
use crate::safety::SafetyLayer;
use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, redact_params};
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
use crate::tools::ApprovalContext;
use crate::workspace::Workspace;
/// The routine execution engine.
@@ -49,14 +45,9 @@ pub struct RoutineEngine {
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
/// Scheduler for dispatching jobs (FullJob mode).
scheduler: Option<Arc<Scheduler>>,
/// Tool registry for lightweight routine tool execution.
tools: Arc<ToolRegistry>,
/// Safety layer for tool output sanitization.
safety: Arc<SafetyLayer>,
}
impl RoutineEngine {
#[allow(clippy::too_many_arguments)]
pub fn new(
config: RoutineConfig,
store: Arc<dyn Database>,
@@ -64,8 +55,6 @@ impl RoutineEngine {
workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>,
scheduler: Option<Arc<Scheduler>>,
tools: Arc<ToolRegistry>,
safety: Arc<SafetyLayer>,
) -> Self {
Self {
config,
@@ -76,8 +65,6 @@ impl RoutineEngine {
running_count: Arc::new(AtomicUsize::new(0)),
event_cache: Arc::new(RwLock::new(Vec::new())),
scheduler,
tools,
safety,
}
}
@@ -253,15 +240,12 @@ impl RoutineEngine {
// Execute inline for manual triggers (caller wants to wait)
let engine = EngineContext {
config: self.config.clone(),
store: self.store.clone(),
llm: self.llm.clone(),
workspace: self.workspace.clone(),
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
};
tokio::spawn(async move {
@@ -288,15 +272,12 @@ impl RoutineEngine {
};
let engine = EngineContext {
config: self.config.clone(),
store: self.store.clone(),
llm: self.llm.clone(),
workspace: self.workspace.clone(),
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
};
// Record the run in DB, then spawn execution
@@ -338,15 +319,12 @@ impl RoutineEngine {
/// Shared context passed to the execution function.
struct EngineContext {
config: RoutineConfig,
store: Arc<dyn Database>,
llm: Arc<dyn LlmProvider>,
workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>,
running_count: Arc<AtomicUsize>,
scheduler: Option<Arc<Scheduler>>,
tools: Arc<ToolRegistry>,
safety: Arc<SafetyLayer>,
}
/// Execute a routine run. Handles both lightweight and full_job modes.
@@ -560,10 +538,7 @@ async fn execute_full_job(
Ok((RunStatus::Ok, Some(summary), None))
}
/// Execute a lightweight routine with optional tool support.
///
/// If tools are enabled, this runs a simplified agentic loop (max 3-5 iterations).
/// If tools are disabled, this does a single LLM call (original behavior).
/// Execute a lightweight routine (single LLM call).
async fn execute_lightweight(
ctx: &EngineContext,
routine: &Routine,
@@ -595,7 +570,7 @@ async fn execute_lightweight(
Err(_) => None,
};
// Build the user-facing prompt
// Build the prompt
let mut full_prompt = String::new();
full_prompt.push_str(prompt);
@@ -623,6 +598,15 @@ async fn execute_lightweight(
}
};
let messages = if system_prompt.is_empty() {
vec![ChatMessage::user(&full_prompt)]
} else {
vec![
ChatMessage::system(&system_prompt),
ChatMessage::user(&full_prompt),
]
};
// Determine max_tokens from model metadata with fallback
let effective_max_tokens = match ctx.llm.model_metadata().await {
Ok(meta) => {
@@ -632,45 +616,6 @@ async fn execute_lightweight(
Err(_) => max_tokens,
};
// If tools are enabled, use the tool execution loop; otherwise, single LLM call
if ctx.config.lightweight_tools_enabled {
execute_lightweight_with_tools(
ctx,
routine,
&system_prompt,
&full_prompt,
effective_max_tokens,
)
.await
} else {
execute_lightweight_no_tools(
ctx,
routine,
&system_prompt,
&full_prompt,
effective_max_tokens,
)
.await
}
}
/// Execute a lightweight routine without tool support (original single-call behavior).
async fn execute_lightweight_no_tools(
ctx: &EngineContext,
_routine: &Routine,
system_prompt: &str,
full_prompt: &str,
effective_max_tokens: u32,
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
let messages = if system_prompt.is_empty() {
vec![ChatMessage::user(full_prompt)]
} else {
vec![
ChatMessage::system(system_prompt),
ChatMessage::user(full_prompt),
]
};
let request = CompletionRequest::new(messages)
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);
@@ -686,7 +631,7 @@ async fn execute_lightweight_no_tools(
let content = response.content.trim();
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
// Empty content guard
// Empty content guard (same as heartbeat)
if content.is_empty() {
return if response.finish_reason == FinishReason::Length {
Err(RoutineError::TruncatedResponse)
@@ -703,269 +648,6 @@ async fn execute_lightweight_no_tools(
Ok((RunStatus::Attention, Some(content.to_string()), tokens_used))
}
/// Handle a text-only LLM response in lightweight routine execution.
///
/// Checks for the ROUTINE_OK sentinel, validates content, and returns appropriate status.
fn handle_text_response(
content: &str,
finish_reason: FinishReason,
total_input_tokens: u32,
total_output_tokens: u32,
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
let content = content.trim();
// Empty content guard
if content.is_empty() {
return if finish_reason == FinishReason::Length {
Err(RoutineError::TruncatedResponse)
} else {
Err(RoutineError::EmptyResponse)
};
}
// Check for the "nothing to do" sentinel
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
return Ok((RunStatus::Ok, None, total_tokens));
}
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
Ok((
RunStatus::Attention,
Some(content.to_string()),
total_tokens,
))
}
/// Execute a lightweight routine with tool execution support (agentic loop).
///
/// This is a simplified version of the full dispatcher loop:
/// - Max 3-5 iterations (configurable)
/// - Sequential tool execution (not parallel)
/// - Auto-approval of non-Always tools
/// - No hooks or approval dialogs
async fn execute_lightweight_with_tools(
ctx: &EngineContext,
routine: &Routine,
system_prompt: &str,
full_prompt: &str,
effective_max_tokens: u32,
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
let mut messages = if system_prompt.is_empty() {
vec![ChatMessage::user(full_prompt)]
} else {
vec![
ChatMessage::system(system_prompt),
ChatMessage::user(full_prompt),
]
};
let max_iterations = ctx.config.lightweight_max_iterations.min(5);
let mut iteration = 0;
let mut total_input_tokens = 0;
let mut total_output_tokens = 0;
// Create a minimal job context for tool execution with unique run ID
let run_id = Uuid::new_v4();
let job_ctx = JobContext {
job_id: run_id,
user_id: routine.user_id.clone(),
title: "Lightweight Routine".to_string(),
description: routine.name.clone(),
..Default::default()
};
loop {
iteration += 1;
// Force text-only response at iteration limit
let force_text = iteration >= max_iterations;
if force_text {
// Final iteration: no tools, just get text response
let request = CompletionRequest::new(messages)
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);
let response =
ctx.llm
.complete(request)
.await
.map_err(|e| RoutineError::LlmFailed {
reason: e.to_string(),
})?;
total_input_tokens += response.input_tokens;
total_output_tokens += response.output_tokens;
return handle_text_response(
&response.content,
response.finish_reason,
total_input_tokens,
total_output_tokens,
);
} else {
// Tool-enabled iteration
let tool_defs = ctx.tools.tool_definitions().await;
let request = ToolCompletionRequest::new(messages.clone(), tool_defs)
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);
let response = ctx.llm.complete_with_tools(request).await.map_err(|e| {
RoutineError::LlmFailed {
reason: e.to_string(),
}
})?;
total_input_tokens += response.input_tokens;
total_output_tokens += response.output_tokens;
// Check if LLM returned text (no tool calls)
if response.tool_calls.is_empty() {
let content = response.content.unwrap_or_default();
return handle_text_response(
&content,
response.finish_reason,
total_input_tokens,
total_output_tokens,
);
}
// LLM returned tool calls: add assistant message and execute tools
messages.push(ChatMessage::assistant_with_tool_calls(
response.content.clone(),
response.tool_calls.clone(),
));
// Execute tools sequentially
for tc in response.tool_calls {
let result = execute_routine_tool(ctx, &job_ctx, &tc).await;
// Sanitize and wrap result (including errors)
let result_content = match result {
Ok(output) => {
let sanitized = ctx.safety.sanitize_tool_output(&tc.name, &output);
ctx.safety.wrap_for_llm(
&tc.name,
&sanitized.content,
sanitized.was_modified,
)
}
Err(e) => {
let error_msg = format!("Tool '{}' failed: {}", tc.name, e);
let sanitized = ctx.safety.sanitize_tool_output(&tc.name, &error_msg);
ctx.safety.wrap_for_llm(
&tc.name,
&sanitized.content,
sanitized.was_modified,
)
}
};
// Add tool result to context
messages.push(ChatMessage::tool_result(&tc.id, &tc.name, &result_content));
}
// Continue loop to next LLM call
}
}
}
/// Execute a single tool for a lightweight routine.
async fn execute_routine_tool(
ctx: &EngineContext,
job_ctx: &JobContext,
tc: &ToolCall,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Check if tool exists
let tool = ctx
.tools
.get(&tc.name)
.await
.ok_or_else(|| format!("Tool '{}' not found", tc.name))?;
// Check approval requirement: only allow Never tools in lightweight routines.
// UnlessAutoApproved and Always tools are blocked to prevent prompt injection attacks.
// Lightweight routines can be triggered by external events and may process untrusted data,
// making them vulnerable to prompt injection that could trick the LLM into calling
// sensitive tools. Blocking these tools entirely is the safest approach.
match tool.requires_approval(&tc.arguments) {
ApprovalRequirement::Never => {}
ApprovalRequirement::UnlessAutoApproved | ApprovalRequirement::Always => {
return Err(format!(
"Tool '{}' requires manual approval and cannot be used in lightweight routines",
tc.name
)
.into());
}
}
// Validate tool parameters
let validation = ctx.safety.validator().validate_tool_params(&tc.arguments);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Err(format!("Invalid tool parameters: {}", details).into());
}
let safe_params = redact_params(&tc.arguments, tool.sensitive_params());
tracing::debug!(
tool = %tc.name,
params = %safe_params,
"Lightweight routine tool call started"
);
// Execute with per-tool timeout
let timeout = tool.execution_timeout();
let start = std::time::Instant::now();
let result = tokio::time::timeout(timeout, async {
tool.execute(tc.arguments.clone(), job_ctx).await
})
.await;
let elapsed = start.elapsed();
match &result {
Ok(Ok(_)) => {
tracing::debug!(
tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64,
"Lightweight routine tool call succeeded"
);
}
Ok(Err(e)) => {
tracing::debug!(
tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64,
error = %e,
"Lightweight routine tool call failed"
);
}
Err(_) => {
tracing::debug!(
tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64,
timeout_secs = timeout.as_secs(),
"Lightweight routine tool call timed out"
);
}
}
let result = result
.map_err(|_| ToolError::Timeout(timeout))
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
// Serialize result to JSON string
let result_str =
serde_json::to_string(&result.result).unwrap_or_else(|_| "<serialize error>".to_string());
Ok(result_str)
}
/// Send a notification based on the routine's notify config and run status.
async fn send_notification(
tx: &mpsc::Sender<OutgoingResponse>,
@@ -1045,7 +727,6 @@ fn truncate(s: &str, max: usize) -> String {
#[cfg(test)]
mod tests {
use crate::agent::routine::{NotifyConfig, RunStatus};
use crate::config::RoutineConfig;
#[test]
fn test_notification_gating() {
@@ -1074,130 +755,4 @@ mod tests {
let _ = status.to_string();
}
}
#[test]
fn test_routine_config_lightweight_tools_enabled_default() {
let config = RoutineConfig::default();
assert!(
config.lightweight_tools_enabled,
"Tools should be enabled by default"
);
}
#[test]
fn test_routine_config_lightweight_max_iterations_default() {
let config = RoutineConfig::default();
assert_eq!(
config.lightweight_max_iterations, 3,
"Default should be 3 iterations"
);
}
#[test]
fn test_routine_config_can_hold_uncapped_max_iterations() {
// The `RoutineConfig` struct can hold a value greater than the safety cap.
let config = RoutineConfig {
lightweight_max_iterations: 10, // Set a value higher than the cap.
..RoutineConfig::default()
};
// The actual capping to a maximum of 5 is handled at runtime in
// `execute_lightweight_with_tools` and during config resolution from env vars.
assert_eq!(
config.lightweight_max_iterations, 10,
"Config struct should store the provided value"
);
}
#[test]
fn test_sanitize_routine_name_replaces_special_chars() {
let test_cases = vec![
("valid-routine", "valid-routine"),
("routine_with_underscore", "routine_with_underscore"),
("Routine With Spaces", "Routine_With_Spaces"),
("routine/with/slashes", "routine_with_slashes"),
("routine@with#symbols", "routine_with_symbols"),
];
for (input, expected) in test_cases {
let result = super::sanitize_routine_name(input);
assert_eq!(
result, expected,
"sanitize_routine_name({}) should be {}",
input, expected
);
}
}
#[test]
fn test_sanitize_routine_name_preserves_alphanumeric_dash_underscore() {
let names = vec!["routine123", "routine-name", "routine_name", "ROUTINE"];
for name in names {
let result = super::sanitize_routine_name(name);
assert_eq!(result, name, "Should preserve {}", name);
}
}
#[test]
fn test_routine_sentinel_detection_exact_match() {
// The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK")
// After trim(), whitespace is removed
let test_cases = vec![
("ROUTINE_OK", true),
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
("something ROUTINE_OK something", true),
("ROUTINE_OK is done", true),
("done ROUTINE_OK", true),
("no sentinel here", false),
];
for (content, should_match) in test_cases {
let trimmed = content.trim();
let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK");
assert_eq!(
matches, should_match,
"Content '{}' sentinel detection should be {}, got {}",
content, should_match, matches
);
}
}
#[test]
fn test_approval_requirement_pattern_matching() {
// Test the approval requirement logic (Never, UnlessAutoApproved, Always)
use crate::tools::ApprovalRequirement;
let requirements = vec![
(ApprovalRequirement::Never, "auto-approved"),
(ApprovalRequirement::UnlessAutoApproved, "auto-approved"),
(ApprovalRequirement::Always, "blocks"),
];
for (req, expected) in requirements {
let can_auto_approve = matches!(
req,
ApprovalRequirement::Never | ApprovalRequirement::UnlessAutoApproved
);
let label = if can_auto_approve {
"auto-approved"
} else {
"blocks"
};
assert_eq!(label, expected, "Approval pattern should match");
}
}
#[test]
fn test_empty_response_handling() {
// Simulate the empty content guard logic
let empty_content = "";
let finish_reason_length = crate::llm::FinishReason::Length;
let finish_reason_stop = crate::llm::FinishReason::Stop;
assert!(
empty_content.trim().is_empty(),
"Should detect empty content"
);
assert_eq!(finish_reason_length, crate::llm::FinishReason::Length);
assert_eq!(finish_reason_stop, crate::llm::FinishReason::Stop);
}
}
-7
View File
@@ -18,7 +18,6 @@ use crate::error::{Error, JobError};
use crate::hooks::HookRegistry;
use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::{ApprovalContext, ToolRegistry};
/// Message to send to a worker.
@@ -59,8 +58,6 @@ pub struct Scheduler {
sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
/// HTTP interceptor for trace recording/replay (propagated to workers).
http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
/// Idempotency cache for tool executions (shared across all workers).
idempotency_cache: ToolIdempotencyCache,
/// Running jobs (main LLM-driven jobs).
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
/// Running sub-tasks (tool executions, background tasks).
@@ -69,7 +66,6 @@ pub struct Scheduler {
impl Scheduler {
/// Create a new scheduler.
#[allow(clippy::too_many_arguments)]
pub fn new(
config: AgentConfig,
context_manager: Arc<ContextManager>,
@@ -78,7 +74,6 @@ impl Scheduler {
tools: Arc<ToolRegistry>,
store: Option<Arc<dyn Database>>,
hooks: Arc<HookRegistry>,
idempotency_cache: ToolIdempotencyCache,
) -> Self {
Self {
config,
@@ -90,7 +85,6 @@ impl Scheduler {
hooks,
sse_tx: None,
http_interceptor: None,
idempotency_cache,
jobs: Arc::new(RwLock::new(HashMap::new())),
subtasks: Arc::new(RwLock::new(HashMap::new())),
}
@@ -260,7 +254,6 @@ impl Scheduler {
sse_tx: self.sse_tx.clone(),
approval_context,
http_interceptor: self.http_interceptor.clone(),
idempotency_cache: self.idempotency_cache.clone(),
};
let worker = Worker::new(job_id, deps);
-2
View File
@@ -959,7 +959,6 @@ impl Agent {
for (spawn_idx, tc) in runnable.iter().enumerate() {
let tools = self.tools().clone();
let safety = self.safety().clone();
let idempotency_cache = self.deps.idempotency_cache.clone();
let channels = self.channels.clone();
let job_ctx = job_ctx.clone();
let tc = tc.clone();
@@ -980,7 +979,6 @@ impl Agent {
let result = execute_chat_tool_standalone(
&tools,
&safety,
&idempotency_cache,
&tc.name,
&tc.arguments,
&job_ctx,
+7 -52
View File
@@ -19,7 +19,6 @@ use crate::llm::{
ToolSelection,
};
use crate::safety::SafetyLayer;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::rate_limiter::RateLimitResult;
use crate::tools::{ApprovalContext, ToolRegistry, redact_params};
@@ -45,8 +44,6 @@ pub struct WorkerDeps {
pub approval_context: Option<ApprovalContext>,
/// HTTP interceptor for trace recording/replay (propagated to JobContext).
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
/// Idempotency cache for tool executions.
pub idempotency_cache: ToolIdempotencyCache,
}
/// Worker that executes a single job.
@@ -288,12 +285,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
}
// Clean up idempotency cache entries for this job
self.deps
.idempotency_cache
.invalidate_job(&self.job_id.to_string())
.await;
Ok(())
}
@@ -746,8 +737,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
name: tool_name.to_string(),
})?;
let job_id_str = job_id.to_string();
// Check approval: use context-aware check if available, else block all non-Never tools
let requirement = tool.requires_approval(params);
let blocked =
@@ -843,22 +832,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.into());
}
// Check idempotency cache after approval/hooks/validation so those
// checks always run. Uses post-hook params for consistency with put().
if tool.is_idempotent()
&& let Some(cached) = deps
.idempotency_cache
.get(&job_id_str, tool_name, &params)
.await
{
tracing::debug!(
tool = %tool_name,
job = %job_id,
"Idempotency cache hit"
);
return Ok(cached);
}
// Redact sensitive parameter values (e.g. secret_save's "value") before
// they touch any observability or audit path.
let safe_params = redact_params(&params, tool.sensitive_params());
@@ -994,25 +967,13 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
})?;
// Return result as string
let result_str: Result<String, Error> = serde_json::to_string_pretty(&output.result)
.map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
});
// Cache successful results for idempotent tools
if let Ok(ref output_str) = result_str
&& tool.is_idempotent()
{
deps.idempotency_cache
.put(&job_id_str, tool_name, &params, output_str.clone())
.await;
}
result_str
serde_json::to_string_pretty(&output.result).map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
})
}
/// Process a tool execution result and add it to the reasoning context.
@@ -1425,9 +1386,6 @@ mod tests {
sse_tx: None,
approval_context: None,
http_interceptor: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Worker::new(job_id, deps)
@@ -1690,9 +1648,6 @@ mod tests {
sse_tx: None,
approval_context,
http_interceptor: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Worker::new(job_id, deps)
+203 -45
View File
@@ -77,7 +77,10 @@ pub struct AppBuilder {
llm_override: Option<Arc<dyn LlmProvider>>,
// Backend-specific handles needed by secrets store
handles: Option<crate::db::DatabaseHandles>,
#[cfg(feature = "postgres")]
pg_pool: Option<deadpool_postgres::Pool>,
#[cfg(feature = "libsql")]
libsql_db: Option<Arc<libsql::Database>>,
}
impl AppBuilder {
@@ -102,7 +105,10 @@ impl AppBuilder {
db: None,
secrets_store: None,
llm_override: None,
handles: None,
#[cfg(feature = "postgres")]
pg_pool: None,
#[cfg(feature = "libsql")]
libsql_db: None,
}
}
@@ -131,10 +137,71 @@ impl AppBuilder {
return Ok(());
}
let (db, handles) = crate::db::connect_with_handles(&self.config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
self.handles = Some(handles);
let db: Arc<dyn Database> = match self.config.database.backend {
#[cfg(feature = "libsql")]
crate::config::DatabaseBackend::LibSql => {
use crate::db::Database as _;
use crate::db::libsql::LibSqlBackend;
use secrecy::ExposeSecret as _;
let default_path = crate::config::default_libsql_path();
let db_path = self
.config
.database
.libsql_path
.as_deref()
.unwrap_or(&default_path);
let backend = if let Some(ref url) = self.config.database.libsql_url {
let token =
self.config
.database
.libsql_auth_token
.as_ref()
.ok_or_else(|| {
anyhow::anyhow!(
"LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set"
)
})?;
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret()).await?
} else {
LibSqlBackend::new_local(db_path).await?
};
backend.run_migrations().await?;
tracing::info!("libSQL database connected and migrations applied");
#[cfg(feature = "libsql")]
{
self.libsql_db = Some(backend.shared_db());
}
Arc::new(backend) as Arc<dyn Database>
}
#[cfg(feature = "postgres")]
_ => {
use crate::db::Database as _;
let pg = crate::db::postgres::PgBackend::new(&self.config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
pg.run_migrations()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
tracing::info!("PostgreSQL database connected and migrations applied");
#[cfg(feature = "postgres")]
{
self.pg_pool = Some(pg.pool());
}
Arc::new(pg) as Arc<dyn Database>
}
#[cfg(not(feature = "postgres"))]
_ => {
anyhow::bail!(
"No database backend available. Enable 'postgres' or 'libsql' feature."
);
}
};
// Post-init: migrate disk config, reload config from DB, attach session, cleanup
if let Err(e) = crate::bootstrap::migrate_disk_to_db(db.as_ref(), "default").await {
@@ -145,7 +212,7 @@ impl AppBuilder {
match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await {
Ok(db_config) => {
self.config = db_config;
tracing::debug!("Configuration reloaded from database");
tracing::info!("Configuration reloaded from database");
}
Err(e) => {
tracing::warn!(
@@ -184,7 +251,10 @@ impl AppBuilder {
crate::config::inject_os_credentials();
// Consume unused handles
self.handles.take();
#[cfg(feature = "libsql")]
{
self.libsql_db.take();
}
// Re-resolve only the LLM config with OS credentials.
let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
@@ -208,16 +278,35 @@ impl AppBuilder {
Ok(c) => Arc::new(c),
Err(e) => {
tracing::warn!("Failed to initialize secrets crypto: {}", e);
self.handles.take();
#[cfg(feature = "libsql")]
{
self.libsql_db.take();
}
return Ok(());
}
};
// Fallback covers the no-database path where `init_database` returned
// early before populating `self.handles`.
let empty_handles = crate::db::DatabaseHandles::default();
let handles = self.handles.as_ref().unwrap_or(&empty_handles);
let store = crate::secrets::create_secrets_store(crypto, handles);
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
#[cfg(feature = "libsql")]
let store = store.or_else(|| {
self.libsql_db.take().map(|db| {
Arc::new(crate::secrets::LibSqlSecretsStore::new(
db,
Arc::clone(&crypto),
)) as Arc<dyn SecretsStore + Send + Sync>
})
});
#[cfg(feature = "postgres")]
let store = store.or_else(|| {
self.pg_pool.as_ref().map(|pool| {
Arc::new(crate::secrets::PostgresSecretsStore::new(
pool.clone(),
Arc::clone(&crypto),
)) as Arc<dyn SecretsStore + Send + Sync>
})
});
if let Some(ref secrets) = store {
// Inject LLM API keys from encrypted storage
@@ -274,7 +363,7 @@ impl AppBuilder {
anyhow::Error,
> {
let safety = Arc::new(SafetyLayer::new(&self.config.safety));
tracing::debug!("Safety layer initialized");
tracing::info!("Safety layer initialized");
// Initialize tool registry with credential injection support
let credential_registry = Arc::new(SharedCredentialRegistry::new());
@@ -361,7 +450,7 @@ impl AppBuilder {
tools
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await;
tracing::debug!("Builder mode enabled");
tracing::info!("Builder mode enabled");
}
Ok((safety, tools, embeddings, workspace))
@@ -383,7 +472,9 @@ impl AppBuilder {
),
anyhow::Error,
> {
use crate::tools::mcp::config::load_mcp_servers_from_db;
use crate::tools::mcp::{
McpClient, McpTransport, config::load_mcp_servers_from_db, is_authenticated,
};
use crate::tools::wasm::{WasmToolLoader, load_dev_tools};
let mcp_session_manager = Arc::new(McpSessionManager::new());
@@ -419,7 +510,7 @@ impl AppBuilder {
match loader.load_from_dir(&wasm_config.tools_dir).await {
Ok(results) => {
if !results.loaded.is_empty() {
tracing::debug!(
tracing::info!(
"Loaded {} WASM tools from {}",
results.loaded.len(),
wasm_config.tools_dir.display()
@@ -442,7 +533,7 @@ impl AppBuilder {
Ok(results) => {
dev_loaded_tool_names.extend(results.loaded.iter().cloned());
if !dev_loaded_tool_names.is_empty() {
tracing::debug!(
tracing::info!(
"Loaded {} dev WASM tools from build artifacts",
dev_loaded_tool_names.len()
);
@@ -474,10 +565,7 @@ impl AppBuilder {
Ok(servers) => {
let enabled: Vec<_> = servers.enabled_servers().cloned().collect();
if !enabled.is_empty() {
tracing::debug!(
"Loading {} configured MCP server(s)...",
enabled.len()
);
tracing::info!("Loading {} configured MCP server(s)...", enabled.len());
}
let mut join_set = tokio::task::JoinSet::new();
@@ -490,24 +578,95 @@ impl AppBuilder {
join_set.spawn(async move {
let server_name = server.name.clone();
let client = match crate::tools::mcp::create_client_from_config(
server,
&mcp_sm,
&pm,
secrets,
"default",
)
.await
{
Ok(c) => c,
Err(e) => {
let client: McpClient = match server.effective_transport() {
crate::tools::mcp::config::EffectiveTransport::Stdio {
command,
args,
env,
} => {
match pm
.spawn_stdio(
&server_name,
command,
args.to_vec(),
env.clone(),
)
.await
{
Ok(transport) => McpClient::new_with_transport(
&server_name,
transport as Arc<dyn McpTransport>,
None,
secrets,
"default",
Some(server),
),
Err(e) => {
tracing::warn!(
"Failed to spawn stdio MCP server '{}': {}",
server_name,
e
);
return;
}
}
}
#[cfg(unix)]
crate::tools::mcp::config::EffectiveTransport::Unix {
socket_path,
} => {
match crate::tools::mcp::unix_transport::UnixMcpTransport::connect(
&server_name,
socket_path,
)
.await
{
Ok(transport) => McpClient::new_with_transport(
&server_name,
Arc::new(transport) as Arc<dyn McpTransport>,
None,
secrets,
"default",
Some(server),
),
Err(e) => {
tracing::warn!(
"Failed to connect to Unix MCP server '{}': {}",
server_name,
e
);
return;
}
}
}
#[cfg(not(unix))]
crate::tools::mcp::config::EffectiveTransport::Unix { .. } => {
tracing::warn!(
"Failed to create MCP client for '{}': {}",
server_name,
e
"Unix socket transport is not supported on this platform (server '{}')",
server_name
);
return;
}
crate::tools::mcp::config::EffectiveTransport::Http => {
if let Some(ref secrets) = secrets {
let has_tokens =
is_authenticated(&server, secrets, "default")
.await;
if has_tokens || server.requires_auth() {
McpClient::new_authenticated(
server,
Arc::clone(&mcp_sm),
Arc::clone(secrets),
"default",
)
} else {
McpClient::new_with_config(server)
}
} else {
McpClient::new_with_config(server)
}
}
};
match client.list_tools().await {
@@ -518,7 +677,7 @@ impl AppBuilder {
for tool in tool_impls {
tools.register(tool).await;
}
tracing::debug!(
tracing::info!(
"Loaded {} tools from MCP server '{}'",
tool_count,
server_name
@@ -579,7 +738,7 @@ impl AppBuilder {
.iter()
.map(|m| m.to_registry_entry())
.collect();
tracing::debug!(
tracing::info!(
count = entries.len(),
"Loaded registry catalog entries for extension discovery"
);
@@ -608,7 +767,6 @@ impl AppBuilder {
let extension_manager = {
let manager = Arc::new(ExtensionManager::new(
Arc::clone(&mcp_session_manager),
Arc::clone(&mcp_process_manager),
ext_secrets,
Arc::clone(tools),
Some(Arc::clone(hooks)),
@@ -621,7 +779,7 @@ impl AppBuilder {
catalog_entries.clone(),
));
tools.register_extension_tools(Arc::clone(&manager));
tracing::debug!("Extension manager initialized with in-chat discovery tools");
tracing::info!("Extension manager initialized with in-chat discovery tools");
Some(manager)
};
@@ -692,7 +850,7 @@ impl AppBuilder {
let import_path = std::path::Path::new(&import_dir);
match ws.import_from_directory(import_path).await {
Ok(count) if count > 0 => {
tracing::debug!("Imported {} workspace file(s) from {}", count, import_dir);
tracing::info!("Imported {} workspace file(s) from {}", count, import_dir);
}
Ok(_) => {}
Err(e) => {
@@ -717,7 +875,7 @@ impl AppBuilder {
tokio::spawn(async move {
match ws_bg.backfill_embeddings().await {
Ok(count) if count > 0 => {
tracing::debug!("Backfilled embeddings for {} chunks", count);
tracing::info!("Backfilled embeddings for {} chunks", count);
}
Ok(_) => {}
Err(e) => {
@@ -734,7 +892,7 @@ impl AppBuilder {
.with_installed_dir(self.config.skills.installed_dir.clone());
let loaded = registry.discover_all().await;
if !loaded.is_empty() {
tracing::debug!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
tracing::info!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
}
let registry = Arc::new(std::sync::RwLock::new(registry));
let catalog = crate::skills::catalog::shared_catalog();
@@ -752,7 +910,7 @@ impl AppBuilder {
},
));
tracing::debug!(
tracing::info!(
"Tool registry initialized with {} total tools",
tools.count()
);
-156
View File
@@ -198,58 +198,6 @@ pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> s
Ok(())
}
/// Update or add multiple variables in `~/.ironclaw/.env`, preserving existing content.
///
/// Like `upsert_bootstrap_var` but batched — replaces lines for any key in `vars`
/// and preserves all other existing lines. Use this instead of `save_bootstrap_env`
/// when you want to update specific keys without destroying user-added variables.
pub fn upsert_bootstrap_vars(vars: &[(&str, &str)]) -> std::io::Result<()> {
upsert_bootstrap_vars_to(&ironclaw_env_path(), vars)
}
/// Update or add multiple variables at an arbitrary path (testable variant).
pub fn upsert_bootstrap_vars_to(
path: &std::path::Path,
vars: &[(&str, &str)],
) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let keys_being_written: std::collections::HashSet<&str> =
vars.iter().map(|(k, _)| *k).collect();
let existing = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
let mut result = String::new();
for line in existing.lines() {
// Extract key from lines matching `KEY=...`
let is_overwritten = line
.split_once('=')
.map(|(k, _)| keys_being_written.contains(k.trim()))
.unwrap_or(false);
if !is_overwritten {
result.push_str(line);
result.push('\n');
}
}
// Append all new key=value pairs
for (key, value) in vars {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
result.push_str(&format!("{}=\"{}\"\n", key, escaped));
}
std::fs::write(path, &result)?;
restrict_file_permissions(path)?;
Ok(())
}
/// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content.
///
/// Unlike `save_bootstrap_env` (which overwrites the entire file), this
@@ -1289,108 +1237,4 @@ INJECTED="pwned"#;
let lock = PidLock::acquire_at(pid_path).unwrap();
drop(lock);
}
#[test]
fn upsert_bootstrap_vars_preserves_unknown_keys() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
// Simulate a user-edited .env with custom vars
let initial =
"HTTP_HOST=\"0.0.0.0\"\nDATABASE_BACKEND=\"postgres\"\nCUSTOM_VAR=\"keep_me\"\n";
std::fs::write(&env_path, initial).unwrap();
// Upsert wizard vars — should preserve HTTP_HOST and CUSTOM_VAR
let vars = [("DATABASE_BACKEND", "libsql"), ("LLM_BACKEND", "openai")];
upsert_bootstrap_vars_to(&env_path, &vars).unwrap();
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
parsed.len(),
4,
"should have 4 vars (2 preserved + 2 upserted)"
);
// User-added vars must be preserved
assert!(
parsed
.iter()
.any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"),
"HTTP_HOST must be preserved"
);
assert!(
parsed
.iter()
.any(|(k, v)| k == "CUSTOM_VAR" && v == "keep_me"),
"CUSTOM_VAR must be preserved"
);
// Wizard vars must be updated/added
assert!(
parsed
.iter()
.any(|(k, v)| k == "DATABASE_BACKEND" && v == "libsql"),
"DATABASE_BACKEND must be updated to libsql"
);
assert!(
parsed
.iter()
.any(|(k, v)| k == "LLM_BACKEND" && v == "openai"),
"LLM_BACKEND must be added"
);
// Now update LLM_BACKEND and verify HTTP_HOST still preserved
let vars2 = [("LLM_BACKEND", "anthropic")];
upsert_bootstrap_vars_to(&env_path, &vars2).unwrap();
let parsed2: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
parsed2.len(),
4,
"should still have 4 vars after second upsert"
);
assert!(
parsed2
.iter()
.any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"),
"HTTP_HOST must still be preserved after second upsert"
);
assert!(
parsed2
.iter()
.any(|(k, v)| k == "LLM_BACKEND" && v == "anthropic"),
"LLM_BACKEND must be updated to anthropic"
);
}
#[test]
fn upsert_bootstrap_vars_creates_file_if_missing() {
let dir = tempdir().unwrap();
let env_path = dir.path().join("subdir").join(".env");
// File doesn't exist yet
assert!(!env_path.exists());
let vars = [("DATABASE_BACKEND", "libsql")];
upsert_bootstrap_vars_to(&env_path, &vars).unwrap();
assert!(env_path.exists());
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0],
("DATABASE_BACKEND".to_string(), "libsql".to_string())
);
}
}
+2 -2
View File
@@ -75,7 +75,7 @@ impl ChannelManager {
break;
}
}
tracing::debug!(channel = %name, "Hot-added channel stream ended");
tracing::info!(channel = %name, "Hot-added channel stream ended");
});
Ok(())
@@ -92,7 +92,7 @@ impl ChannelManager {
for (name, channel) in channels.iter() {
match channel.start().await {
Ok(stream) => {
tracing::debug!("Started channel: {}", name);
tracing::info!("Started channel: {}", name);
streams.push(stream);
}
Err(e) => {
+6 -37
View File
@@ -184,32 +184,18 @@ impl WasmChannelLoader {
/// └── telegram.capabilities.json
/// ```
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmChannelError> {
match fs::metadata(dir).await {
Ok(meta) if meta.is_dir() => {}
Ok(_) => {
return Err(WasmChannelError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("{} is not a directory", dir.display()),
)));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadResults::default());
}
Err(e) => return Err(WasmChannelError::Io(e)),
if !dir.is_dir() {
return Err(WasmChannelError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("{} is not a directory", dir.display()),
)));
}
let mut results = LoadResults::default();
// Collect all .wasm entries first, then load in parallel
let mut channel_entries = Vec::new();
// Handle TOCTOU: if read_dir fails with NotFound, treat as empty
let mut entries = match fs::read_dir(dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadResults::default());
}
Err(e) => return Err(WasmChannelError::Io(e)),
};
let mut entries = fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
@@ -500,21 +486,4 @@ mod tests {
let result = loader.load_from_files("", &wasm_path, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn load_from_dir_returns_empty_when_dir_missing() {
let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None);
let dir = TempDir::new().unwrap();
let missing = dir.path().join("nonexistent_channels_dir");
let results = loader.load_from_dir(&missing).await;
// Must succeed with empty results, not error
let results = results.expect("missing dir should return Ok, not Err");
assert!(results.loaded.is_empty());
assert!(results.errors.is_empty());
}
}
-2
View File
@@ -86,7 +86,6 @@ mod loader;
mod router;
mod runtime;
mod schema;
pub mod setup;
pub(crate) mod signature;
#[allow(dead_code)]
pub(crate) mod storage;
@@ -106,5 +105,4 @@ pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeC
pub use schema::{
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
};
pub use setup::{WasmChannelSetup, inject_channel_credentials, setup_wasm_channels};
pub use wrapper::{HttpResponse, SharedWasmChannel, WasmChannel};
-324
View File
@@ -1,324 +0,0 @@
//! WASM channel setup and credential injection.
//!
//! Encapsulates the logic for loading WASM channels, registering their
//! webhook routes, and injecting credentials from the secrets store.
use std::collections::HashSet;
use std::sync::Arc;
use crate::channels::wasm::{
LoadedChannel, RegisteredEndpoint, SharedWasmChannel, WasmChannel, WasmChannelLoader,
WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
};
use crate::config::Config;
use crate::db::Database;
use crate::extensions::ExtensionManager;
use crate::pairing::PairingStore;
use crate::secrets::SecretsStore;
/// Result of WASM channel setup.
pub struct WasmChannelSetup {
pub channels: Vec<(String, Box<dyn crate::channels::Channel>)>,
pub channel_names: Vec<String>,
pub webhook_routes: Option<axum::Router>,
/// Runtime objects needed for hot-activation via ExtensionManager.
pub wasm_channel_runtime: Arc<WasmChannelRuntime>,
pub pairing_store: Arc<PairingStore>,
pub wasm_channel_router: Arc<WasmChannelRouter>,
}
/// Load WASM channels and register their webhook routes.
pub async fn setup_wasm_channels(
config: &Config,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
extension_manager: Option<&Arc<ExtensionManager>>,
database: Option<&Arc<dyn Database>>,
) -> Option<WasmChannelSetup> {
let runtime = match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
Ok(r) => Arc::new(r),
Err(e) => {
tracing::warn!("Failed to initialize WASM channel runtime: {}", e);
return None;
}
};
let pairing_store = Arc::new(PairingStore::new());
let settings_store: Option<Arc<dyn crate::db::SettingsStore>> =
database.map(|db| Arc::clone(db) as Arc<dyn crate::db::SettingsStore>);
let mut loader = WasmChannelLoader::new(
Arc::clone(&runtime),
Arc::clone(&pairing_store),
settings_store,
);
if let Some(secrets) = secrets_store {
loader = loader.with_secrets_store(Arc::clone(secrets));
}
let results = match loader
.load_from_dir(&config.channels.wasm_channels_dir)
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("Failed to scan WASM channels directory: {}", e);
return None;
}
};
let wasm_router = Arc::new(WasmChannelRouter::new());
let mut channels: Vec<(String, Box<dyn crate::channels::Channel>)> = Vec::new();
let mut channel_names: Vec<String> = Vec::new();
for loaded in results.loaded {
let (name, channel) = register_channel(loaded, config, secrets_store, &wasm_router).await;
channel_names.push(name.clone());
channels.push((name, channel));
}
for (path, err) in &results.errors {
tracing::warn!("Failed to load WASM channel {}: {}", path.display(), err);
}
// Always create webhook routes (even with no channels loaded) so that
// channels hot-added at runtime can receive webhooks without a restart.
let webhook_routes = {
Some(create_wasm_channel_router(
Arc::clone(&wasm_router),
extension_manager.map(Arc::clone),
))
};
Some(WasmChannelSetup {
channels,
channel_names,
webhook_routes,
wasm_channel_runtime: runtime,
pairing_store,
wasm_channel_router: wasm_router,
})
}
/// Process a single loaded WASM channel: retrieve secrets, inject config,
/// register with the router, and set up signing keys and credentials.
async fn register_channel(
loaded: LoadedChannel,
config: &Config,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
wasm_router: &Arc<WasmChannelRouter>,
) -> (String, Box<dyn crate::channels::Channel>) {
let channel_name = loaded.name().to_string();
tracing::info!("Loaded WASM channel: {}", channel_name);
let secret_name = loaded.webhook_secret_name();
let sig_key_secret_name = loaded.signature_key_secret_name();
let hmac_secret_name = loaded.hmac_secret_name();
let webhook_secret = if let Some(secrets) = secrets_store {
secrets
.get_decrypted("default", &secret_name)
.await
.ok()
.map(|s| s.expose().to_string())
} else {
None
};
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
let webhook_path = format!("/webhook/{}", channel_name);
let endpoints = vec![RegisteredEndpoint {
channel_name: channel_name.clone(),
path: webhook_path,
methods: vec!["POST".to_string()],
require_secret: webhook_secret.is_some(),
}];
let channel_arc = Arc::new(loaded.channel);
// Inject runtime config (tunnel URL, webhook secret, owner_id).
{
let mut config_updates = std::collections::HashMap::new();
if let Some(ref tunnel_url) = config.tunnel.public_url {
config_updates.insert(
"tunnel_url".to_string(),
serde_json::Value::String(tunnel_url.clone()),
);
}
if let Some(ref secret) = webhook_secret {
config_updates.insert(
"webhook_secret".to_string(),
serde_json::Value::String(secret.clone()),
);
}
if let Some(&owner_id) = config
.channels
.wasm_channel_owner_ids
.get(channel_name.as_str())
{
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
}
if !config_updates.is_empty() {
channel_arc.update_config(config_updates).await;
tracing::info!(
channel = %channel_name,
has_tunnel = config.tunnel.public_url.is_some(),
has_webhook_secret = webhook_secret.is_some(),
"Injected runtime config into channel"
);
}
}
tracing::info!(
channel = %channel_name,
has_webhook_secret = webhook_secret.is_some(),
secret_header = ?secret_header,
"Registering channel with router"
);
wasm_router
.register(
Arc::clone(&channel_arc),
endpoints,
webhook_secret.clone(),
secret_header,
)
.await;
// Register Ed25519 signature key if declared in capabilities.
if let Some(ref sig_key_name) = sig_key_secret_name
&& let Some(secrets) = secrets_store
&& let Ok(key_secret) = secrets.get_decrypted("default", sig_key_name).await
{
match wasm_router
.register_signature_key(&channel_name, key_secret.expose())
.await
{
Ok(()) => {
tracing::info!(channel = %channel_name, "Registered Ed25519 signature key")
}
Err(e) => {
tracing::error!(channel = %channel_name, error = %e, "Invalid signature key in secrets store")
}
}
}
// Register HMAC signing secret if declared in capabilities.
if let Some(ref hmac_secret_name) = hmac_secret_name
&& let Some(secrets) = secrets_store
&& let Ok(secret) = secrets.get_decrypted("default", hmac_secret_name).await
{
wasm_router
.register_hmac_secret(&channel_name, secret.expose())
.await;
tracing::info!(channel = %channel_name, "Registered HMAC signing secret");
}
// Inject credentials from secrets store / environment.
if let Some(secrets) = secrets_store {
match inject_channel_credentials(&channel_arc, secrets.as_ref(), &channel_name).await {
Ok(count) => {
if count > 0 {
tracing::info!(
channel = %channel_name,
credentials_injected = count,
"Channel credentials injected"
);
}
}
Err(e) => {
tracing::error!(
channel = %channel_name,
error = %e,
"Failed to inject channel credentials"
);
}
}
}
(channel_name, Box::new(SharedWasmChannel::new(channel_arc)))
}
/// Inject credentials for a channel based on naming convention.
///
/// Looks for secrets matching the pattern `{channel_name}_*` and injects them
/// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`).
///
/// Falls back to environment variables with the uppercase name if not found
/// in the secrets store (e.g., `TELEGRAM_BOT_TOKEN`).
pub async fn inject_channel_credentials(
channel: &Arc<WasmChannel>,
secrets: &dyn SecretsStore,
channel_name: &str,
) -> anyhow::Result<usize> {
let all_secrets = secrets
.list("default")
.await
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
let prefix = format!("{}_", channel_name);
let mut count = 0;
let mut injected_placeholders = HashSet::new();
for secret_meta in all_secrets {
if !secret_meta.name.starts_with(&prefix) {
continue;
}
let decrypted = match secrets.get_decrypted("default", &secret_meta.name).await {
Ok(d) => d,
Err(e) => {
tracing::warn!(
secret = %secret_meta.name,
error = %e,
"Failed to decrypt secret for channel credential injection"
);
continue;
}
};
let placeholder = secret_meta.name.to_uppercase();
tracing::debug!(
channel = %channel_name,
secret = %secret_meta.name,
placeholder = %placeholder,
"Injecting credential"
);
channel
.set_credential(&placeholder, decrypted.expose().to_string())
.await;
injected_placeholders.insert(placeholder);
count += 1;
}
// Fall back to environment variables for required secrets not found in the store.
// This allows channels to work when configured via env vars (e.g., TELEGRAM_BOT_TOKEN)
// without requiring the setup wizard to have run.
let caps = channel.capabilities();
if let Some(ref http_cap) = caps.tool_capabilities.http {
for cred_mapping in http_cap.credentials.values() {
let placeholder = cred_mapping.secret_name.to_uppercase();
if injected_placeholders.contains(&placeholder) {
continue;
}
if let Ok(env_value) = std::env::var(&placeholder)
&& !env_value.is_empty()
{
tracing::debug!(
channel = %channel_name,
placeholder = %placeholder,
"Injecting credential from environment variable"
);
channel.set_credential(&placeholder, env_value).await;
count += 1;
}
}
}
Ok(count)
}
+1 -4
View File
@@ -370,7 +370,7 @@ pub async fn start_server(
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::debug!("Web gateway shutting down");
tracing::info!("Web gateway shutting down");
})
.await
{
@@ -2609,7 +2609,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets,
tool_registry,
None,
@@ -2659,7 +2658,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets.clone(),
tool_registry,
None,
@@ -2765,7 +2763,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets.clone(),
tool_registry,
None,
+1 -1
View File
@@ -68,7 +68,7 @@ impl WebhookServer {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::debug!("Webhook server shutting down");
tracing::info!("Webhook server shutting down");
})
.await
{
+12 -2
View File
@@ -10,7 +10,7 @@ use clap::{Args, Subcommand};
use crate::config::Config;
use crate::db::Database;
use crate::secrets::SecretsStore;
use crate::secrets::{SecretsCrypto, SecretsStore};
use crate::tools::mcp::{
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
auth::{authorize_mcp_server, is_authenticated},
@@ -628,7 +628,17 @@ async fn save_servers(
/// Initialize and return the secrets store.
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
crate::cli::init_secrets_store().await
let config = Config::from_env().await?;
let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!(
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
)
})?;
let crypto = Arc::new(SecretsCrypto::new(master_key.clone())?);
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
}
#[cfg(test)]
+4 -45
View File
@@ -28,6 +28,8 @@ pub use config::{ConfigCommand, run_config_command};
pub use doctor::run_doctor_command;
pub use mcp::{McpCommand, run_mcp_command};
pub use memory::MemoryCommand;
#[cfg(feature = "postgres")]
pub use memory::run_memory_command;
pub use memory::run_memory_command_with_db;
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
pub use registry::{RegistryCommand, run_registry_command};
@@ -35,8 +37,6 @@ pub use service::{ServiceCommand, run_service_command};
pub use status::run_status_command;
pub use tool::{ToolCommand, run_tool_command};
use std::sync::Arc;
use clap::{ColorChoice, Parser, Subcommand};
#[derive(Parser, Debug)]
@@ -94,16 +94,12 @@ pub enum Command {
skip_auth: bool,
/// Reconfigure channels only
#[arg(long, conflicts_with_all = ["provider_only", "quick"])]
#[arg(long, conflicts_with = "provider_only")]
channels_only: bool,
/// Reconfigure LLM provider and model only
#[arg(long, conflicts_with_all = ["channels_only", "quick"])]
#[arg(long, conflicts_with = "channels_only")]
provider_only: bool,
/// Quick setup: auto-defaults everything except LLM provider and model
#[arg(long, conflicts_with_all = ["channels_only", "provider_only"])]
quick: bool,
},
/// Manage configuration settings
@@ -229,43 +225,6 @@ impl Cli {
}
}
/// Initialize a secrets store from environment config.
///
/// Shared helper for CLI subcommands (`mcp auth`, `tool auth`, etc.) that need
/// access to encrypted secrets without spinning up the full AppBuilder.
pub async fn init_secrets_store()
-> anyhow::Result<Arc<dyn crate::secrets::SecretsStore + Send + Sync>> {
let config = crate::config::Config::from_env().await?;
let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!(
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
)
})?;
let crypto = Arc::new(crate::secrets::SecretsCrypto::new(master_key.clone())?);
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
}
/// Run the Memory CLI subcommand.
pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
let config = crate::config::Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let session = crate::llm::create_session_manager(config.llm.session.clone()).await;
let embeddings = config
.embeddings
.create_provider(&config.llm.nearai.base_url, session);
let db: Arc<dyn crate::db::Database> = crate::db::connect_from_config(&config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await
}
#[cfg(test)]
mod tests {
use super::*;
+12 -2
View File
@@ -10,7 +10,8 @@ use clap::Subcommand;
use tokio::fs;
use crate::bootstrap::ironclaw_base_dir;
use crate::secrets::{CreateSecretParams, SecretsStore};
use crate::config::Config;
use crate::secrets::{CreateSecretParams, SecretsCrypto, SecretsStore};
use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
/// Default tools directory.
@@ -551,7 +552,16 @@ fn validate_tool_name(name: &str) -> anyhow::Result<()> {
/// Initialize the secrets store from environment config.
async fn init_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
crate::cli::init_secrets_store().await
let config = Config::from_env().await?;
let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!(
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
)
})?;
let crypto = Arc::new(SecretsCrypto::new(master_key.clone())?);
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
}
/// Configure authentication for a tool.
+4 -4
View File
@@ -100,13 +100,13 @@ impl EmbeddingsConfig {
session: Arc<SessionManager>,
) -> Option<Arc<dyn EmbeddingProvider>> {
if !self.enabled {
tracing::debug!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)");
tracing::info!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)");
return None;
}
match self.provider.as_str() {
"nearai" => {
tracing::debug!(
tracing::info!(
"Embeddings enabled via NEAR AI (model: {}, dim: {})",
self.model,
self.dimension,
@@ -117,7 +117,7 @@ impl EmbeddingsConfig {
))
}
"ollama" => {
tracing::debug!(
tracing::info!(
"Embeddings enabled via Ollama (model: {}, url: {}, dim: {})",
self.model,
self.ollama_base_url,
@@ -130,7 +130,7 @@ impl EmbeddingsConfig {
}
_ => {
if let Some(api_key) = self.openai_api_key() {
tracing::debug!(
tracing::info!(
"Embeddings enabled via OpenAI (model: {}, dim: {})",
self.model,
self.dimension,
-10
View File
@@ -209,7 +209,6 @@ impl LlmConfig {
extra_headers_env,
api_key_required,
base_url_required,
unsupported_params,
) = if let Some(def) = def {
(
def.id.as_str(),
@@ -222,7 +221,6 @@ impl LlmConfig {
def.extra_headers_env.as_deref(),
def.api_key_required,
def.base_url_required,
def.unsupported_params.clone(),
)
} else {
// Absolute fallback: treat as generic openai_completions
@@ -237,7 +235,6 @@ impl LlmConfig {
Some("LLM_EXTRA_HEADERS"),
false,
true,
Vec::new(),
)
};
@@ -341,7 +338,6 @@ impl LlmConfig {
extra_headers,
oauth_token,
cache_retention,
unsupported_params,
})
}
}
@@ -628,12 +624,6 @@ mod tests {
let provider = cfg.provider.expect("provider config should be present");
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
assert_eq!(provider.model, "kimi-k2-5");
assert!(
provider
.unsupported_params
.contains(&"temperature".to_string()),
"tinfoil should propagate unsupported_params from registry"
);
}
#[test]
-9
View File
@@ -14,10 +14,6 @@ pub struct RoutineConfig {
pub default_cooldown_secs: u64,
/// Max output tokens for lightweight routine LLM calls.
pub max_lightweight_tokens: u32,
/// Enable tool execution in lightweight routines (default: true).
pub lightweight_tools_enabled: bool,
/// Max tool iterations for lightweight routines (default: 3, max: 5).
pub lightweight_max_iterations: u32,
}
impl Default for RoutineConfig {
@@ -28,23 +24,18 @@ impl Default for RoutineConfig {
max_concurrent_routines: 10,
default_cooldown_secs: 300,
max_lightweight_tokens: 4096,
lightweight_tools_enabled: true,
lightweight_max_iterations: 3,
}
}
}
impl RoutineConfig {
pub(crate) fn resolve() -> Result<Self, ConfigError> {
let max_iterations: u32 = parse_optional_env("ROUTINES_LIGHTWEIGHT_MAX_ITERATIONS", 3)?;
Ok(Self {
enabled: parse_bool_env("ROUTINES_ENABLED", true)?,
cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?,
max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?,
default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?,
max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?,
lightweight_tools_enabled: parse_bool_env("ROUTINES_LIGHTWEIGHT_TOOLS", true)?,
lightweight_max_iterations: max_iterations.min(5), // cap at 5
})
}
}
+2 -33
View File
@@ -51,29 +51,6 @@ use crate::workspace::{SearchConfig, SearchResult};
pub async fn connect_from_config(
config: &crate::config::DatabaseConfig,
) -> Result<Arc<dyn Database>, DatabaseError> {
let (db, _handles) = connect_with_handles(config).await?;
Ok(db)
}
/// Backend-specific handles retained after database connection.
///
/// These are needed by satellite stores (e.g., `SecretsStore`) that require
/// a backend-specific handle rather than the generic `Arc<dyn Database>`.
#[derive(Default)]
pub struct DatabaseHandles {
#[cfg(feature = "postgres")]
pub pg_pool: Option<deadpool_postgres::Pool>,
#[cfg(feature = "libsql")]
pub libsql_db: Option<Arc<::libsql::Database>>,
}
/// Connect to the database, run migrations, and return both the generic
/// `Database` trait object and the backend-specific handles.
pub async fn connect_with_handles(
config: &crate::config::DatabaseConfig,
) -> Result<(Arc<dyn Database>, DatabaseHandles), DatabaseError> {
let mut handles = DatabaseHandles::default();
match config.backend {
#[cfg(feature = "libsql")]
crate::config::DatabaseBackend::LibSql => {
@@ -97,11 +74,7 @@ pub async fn connect_with_handles(
.map_err(|e| DatabaseError::Pool(e.to_string()))?
};
backend.run_migrations().await?;
tracing::info!("libSQL database connected and migrations applied");
handles.libsql_db = Some(backend.shared_db());
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
Ok(Arc::new(backend))
}
#[cfg(feature = "postgres")]
_ => {
@@ -109,11 +82,7 @@ pub async fn connect_with_handles(
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
pg.run_migrations().await?;
tracing::info!("PostgreSQL database connected and migrations applied");
handles.pg_pool = Some(pg.pool());
Ok((Arc::new(pg) as Arc<dyn Database>, handles))
Ok(Arc::new(pg))
}
#[cfg(not(feature = "postgres"))]
_ => Err(DatabaseError::Pool(
+12 -16
View File
@@ -73,7 +73,6 @@ pub struct ExtensionManager {
// MCP infrastructure
mcp_session_manager: Arc<McpSessionManager>,
mcp_process_manager: Arc<crate::tools::mcp::process::McpProcessManager>,
/// Active MCP clients keyed by server name.
mcp_clients: RwLock<HashMap<String, Arc<McpClient>>>,
@@ -117,7 +116,6 @@ impl ExtensionManager {
#[allow(clippy::too_many_arguments)]
pub fn new(
mcp_session_manager: Arc<McpSessionManager>,
mcp_process_manager: Arc<crate::tools::mcp::process::McpProcessManager>,
secrets: Arc<dyn SecretsStore + Send + Sync>,
tool_registry: Arc<ToolRegistry>,
hooks: Option<Arc<HookRegistry>>,
@@ -138,7 +136,6 @@ impl ExtensionManager {
registry,
discovery: OnlineDiscovery::new(),
mcp_session_manager,
mcp_process_manager,
mcp_clients: RwLock::new(HashMap::new()),
wasm_tool_runtime,
wasm_tools_dir,
@@ -2470,15 +2467,18 @@ impl ExtensionManager {
.await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
let client = crate::tools::mcp::create_client_from_config(
server.clone(),
&self.mcp_session_manager,
&self.mcp_process_manager,
Some(Arc::clone(&self.secrets)),
&self.user_id,
)
.await
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
let has_tokens = is_authenticated(&server, &self.secrets, &self.user_id).await;
let client = if has_tokens || server.requires_auth() {
McpClient::new_authenticated(
server.clone(),
Arc::clone(&self.mcp_session_manager),
Arc::clone(&self.secrets),
&self.user_id,
)
} else {
McpClient::new_with_config(server.clone())
};
// Try to list and create tools
let mcp_tools = client
@@ -3736,7 +3736,6 @@ mod tests {
tools_dir: std::path::PathBuf,
) -> crate::extensions::manager::ExtensionManager {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
use crate::tools::mcp::process::McpProcessManager;
use crate::tools::mcp::session::McpSessionManager;
let key = secrecy::SecretString::from(crate::secrets::keychain::generate_master_key_hex());
@@ -3748,7 +3747,6 @@ mod tests {
crate::extensions::manager::ExtensionManager::new(
mcp,
Arc::new(McpProcessManager::new()),
secrets,
tools,
None, // hooks
@@ -3908,7 +3906,6 @@ mod tests {
) -> ExtensionManager {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
use crate::tools::ToolRegistry;
use crate::tools::mcp::process::McpProcessManager;
use crate::tools::mcp::session::McpSessionManager;
std::fs::create_dir_all(&tools_dir).ok();
@@ -3920,7 +3917,6 @@ mod tests {
ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(McpProcessManager::new()),
Arc::new(InMemorySecretsStore::new(crypto)),
Arc::new(ToolRegistry::new()),
None,
+4 -40
View File
@@ -6,8 +6,6 @@
//!
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
use std::collections::HashSet;
use async_trait::async_trait;
use reqwest::Client;
use rust_decimal::Decimal;
@@ -37,8 +35,6 @@ pub struct AnthropicOAuthProvider {
model: String,
base_url: Option<String>,
active_model: std::sync::RwLock<String>,
/// Parameter names that this provider does not support.
unsupported_params: HashSet<String>,
}
impl AnthropicOAuthProvider {
@@ -65,45 +61,15 @@ impl AnthropicOAuthProvider {
Some(config.base_url.clone())
};
let unsupported_params: HashSet<String> =
config.unsupported_params.iter().cloned().collect();
Ok(Self {
client,
token,
model: config.model.clone(),
base_url,
active_model,
unsupported_params,
})
}
/// Strip unsupported fields from a `CompletionRequest` in place.
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
if self.unsupported_params.is_empty() {
return;
}
if self.unsupported_params.contains("temperature") {
req.temperature = None;
}
if self.unsupported_params.contains("max_tokens") {
req.max_tokens = None;
}
}
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
if self.unsupported_params.is_empty() {
return;
}
if self.unsupported_params.contains("temperature") {
req.temperature = None;
}
if self.unsupported_params.contains("max_tokens") {
req.max_tokens = None;
}
}
fn api_url(&self) -> String {
if let Some(ref base) = self.base_url {
let base = base.trim_end_matches('/');
@@ -231,9 +197,8 @@ impl AnthropicOAuthProvider {
#[async_trait]
impl LlmProvider for AnthropicOAuthProvider {
async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_completion_params(&mut req);
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let model = req.model.unwrap_or_else(|| self.active_model_name());
let (system, messages) = convert_messages(req.messages);
let request = AnthropicRequest {
@@ -268,10 +233,9 @@ impl LlmProvider for AnthropicOAuthProvider {
async fn complete_with_tools(
&self,
mut req: ToolCompletionRequest,
req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_tool_params(&mut req);
let model = req.model.unwrap_or_else(|| self.active_model_name());
let (system, messages) = convert_messages(req.messages);
let tools: Vec<AnthropicTool> = req
-4
View File
@@ -87,10 +87,6 @@ pub struct RegistryProviderConfig {
pub oauth_token: Option<SecretString>,
/// Prompt cache retention (Anthropic-specific).
pub cache_retention: CacheRetention,
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
/// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
/// Listed parameters are stripped from requests before sending to avoid 400 errors.
pub unsupported_params: Vec<String>,
}
/// Configuration for AWS Bedrock (native Converse API).
+17 -23
View File
@@ -117,7 +117,7 @@ pub fn create_llm_provider_with_config(
} else {
"session token"
};
tracing::debug!(
tracing::info!(
model = %config.model,
base_url = %config.base_url,
auth = auth_mode,
@@ -156,7 +156,7 @@ async fn create_bedrock_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvid
})?;
let provider = bedrock::BedrockProvider::new(br).await?;
tracing::debug!(
tracing::info!(
"Using AWS Bedrock (Converse API, region: {}, model: {})",
br.region,
provider.active_model_name(),
@@ -221,16 +221,14 @@ fn create_openai_compat_from_registry(
let client = client.completions_api();
let model = client.completion_model(&config.model);
tracing::debug!(
tracing::info!(
provider = %config.provider_id,
model = %config.model,
base_url = %config.base_url,
"Using OpenAI-compatible provider"
);
let adapter = RigAdapter::new(model, &config.model)
.with_unsupported_params(config.unsupported_params.clone());
Ok(Arc::new(adapter))
Ok(Arc::new(RigAdapter::new(model, &config.model)))
}
fn create_anthropic_from_registry(
@@ -244,7 +242,7 @@ fn create_anthropic_from_registry(
.as_ref()
.is_some_and(|k| k.expose_secret() == crate::llm::config::OAUTH_PLACEHOLDER);
if config.oauth_token.is_some() && (config.api_key.is_none() || api_key_is_placeholder) {
tracing::debug!(
tracing::info!(
provider = %config.provider_id,
model = %config.model,
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
@@ -283,14 +281,14 @@ fn create_anthropic_from_registry(
let model = client.completion_model(&config.model);
if cache_retention != CacheRetention::None {
tracing::debug!(
tracing::info!(
model = %config.model,
retention = %cache_retention,
"Anthropic automatic prompt caching enabled"
);
}
tracing::debug!(
tracing::info!(
provider = %config.provider_id,
model = %config.model,
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
@@ -298,9 +296,7 @@ fn create_anthropic_from_registry(
);
Ok(Arc::new(
RigAdapter::new(model, &config.model)
.with_cache_retention(cache_retention)
.with_unsupported_params(config.unsupported_params.clone()),
RigAdapter::new(model, &config.model).with_cache_retention(cache_retention),
))
}
@@ -321,16 +317,14 @@ fn create_ollama_from_registry(
let model = client.completion_model(&config.model);
tracing::debug!(
tracing::info!(
provider = %config.provider_id,
model = %config.model,
base_url = %config.base_url,
"Using Ollama provider"
);
let adapter = RigAdapter::new(model, &config.model)
.with_unsupported_params(config.unsupported_params.clone());
Ok(Arc::new(adapter))
Ok(Arc::new(RigAdapter::new(model, &config.model)))
}
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
@@ -391,14 +385,14 @@ pub async fn build_provider_chain(
LlmError,
> {
let llm = create_llm_provider(config, session.clone()).await?;
tracing::debug!("LLM provider initialized: {}", llm.model_name());
tracing::info!("LLM provider initialized: {}", llm.model_name());
// 1. Retry
let retry_config = RetryConfig {
max_retries: config.nearai.max_retries,
};
let llm: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
tracing::debug!(
tracing::info!(
max_retries = retry_config.max_retries,
"LLM retry wrapper enabled"
);
@@ -421,7 +415,7 @@ pub async fn build_provider_chain(
} else {
cheap
};
tracing::debug!(
tracing::info!(
primary = %llm.model_name(),
cheap = %cheap.model_name(),
"Smart routing enabled"
@@ -452,7 +446,7 @@ pub async fn build_provider_chain(
session.clone(),
config.request_timeout_secs,
)?;
tracing::debug!(
tracing::info!(
primary = %llm.model_name(),
fallback = %fallback.model_name(),
"LLM failover enabled"
@@ -484,7 +478,7 @@ pub async fn build_provider_chain(
),
..CircuitBreakerConfig::default()
};
tracing::debug!(
tracing::info!(
threshold,
recovery_secs = config.nearai.circuit_breaker_recovery_secs,
"LLM circuit breaker enabled"
@@ -500,7 +494,7 @@ pub async fn build_provider_chain(
ttl: std::time::Duration::from_secs(config.nearai.response_cache_ttl_secs),
max_entries: config.nearai.response_cache_max_entries,
};
tracing::debug!(
tracing::info!(
ttl_secs = config.nearai.response_cache_ttl_secs,
max_entries = config.nearai.response_cache_max_entries,
"LLM response cache enabled"
@@ -521,7 +515,7 @@ pub async fn build_provider_chain(
// Standalone cheap LLM for heartbeat/evaluation (not part of the chain)
let cheap_llm = create_cheap_llm_provider(config, session)?;
if let Some(ref cheap) = cheap_llm {
tracing::debug!("Cheap LLM provider initialized: {}", cheap.model_name());
tracing::info!("Cheap LLM provider initialized: {}", cheap.model_name());
}
Ok((llm, cheap_llm, recording_handle))
+1 -1
View File
@@ -110,7 +110,7 @@ impl NearAiChatProvider {
handle.spawn(async move {
match fetch_pricing(&client, &base_url, api_key.as_ref(), &session).await {
Ok(map) if !map.is_empty() => {
tracing::debug!("Loaded NEAR AI pricing for {} model(s)", map.len());
tracing::info!("Loaded NEAR AI pricing for {} model(s)", map.len());
match pricing.write() {
Ok(mut guard) => *guard = map,
Err(poisoned) => *poisoned.into_inner() = map,
-56
View File
@@ -152,11 +152,6 @@ pub struct ProviderDefinition {
/// Setup wizard hints.
#[serde(default)]
pub setup: Option<SetupHint>,
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
/// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
/// Listed parameters are stripped from requests before sending to avoid 400 errors.
#[serde(default)]
pub unsupported_params: Vec<String>,
}
/// Registry of known LLM providers.
@@ -383,7 +378,6 @@ mod tests {
description: "Custom tinfoil".to_string(),
extra_headers_env: None,
setup: None,
unsupported_params: vec![],
});
let registry = ProviderRegistry::new(all);
let tf = registry.find("tinfoil").expect("tinfoil should exist");
@@ -523,7 +517,6 @@ mod tests {
description: "No setup".to_string(),
extra_headers_env: None,
setup: None, // no setup hint
unsupported_params: vec![],
}];
let registry = ProviderRegistry::new(providers.clone());
@@ -553,7 +546,6 @@ mod tests {
can_list_models: false,
models_filter: None,
}),
unsupported_params: vec![],
});
let registry = ProviderRegistry::new(providers);
@@ -595,7 +587,6 @@ mod tests {
can_list_models: false,
models_filter: None,
}),
unsupported_params: vec![],
},
// User override removes setup
ProviderDefinition {
@@ -612,7 +603,6 @@ mod tests {
description: "No setup now".to_string(),
extra_headers_env: None,
setup: None,
unsupported_params: vec![],
},
];
@@ -650,7 +640,6 @@ mod tests {
display_name: "A".to_string(),
can_list_models: false,
}),
unsupported_params: vec![],
},
ProviderDefinition {
id: "bbb".to_string(),
@@ -669,7 +658,6 @@ mod tests {
display_name: "B".to_string(),
can_list_models: false,
}),
unsupported_params: vec![],
},
ProviderDefinition {
id: "ccc".to_string(),
@@ -688,7 +676,6 @@ mod tests {
display_name: "C".to_string(),
can_list_models: false,
}),
unsupported_params: vec![],
},
// User override for B
ProviderDefinition {
@@ -708,7 +695,6 @@ mod tests {
display_name: "B".to_string(),
can_list_models: false,
}),
unsupported_params: vec![],
},
];
@@ -722,48 +708,6 @@ mod tests {
);
}
#[test]
fn test_unsupported_params_deserialized() {
let providers: Vec<ProviderDefinition> =
serde_json::from_str(include_str!("../../providers.json")).unwrap();
// Tinfoil should have temperature in unsupported_params
let tinfoil = providers.iter().find(|p| p.id == "tinfoil").unwrap();
assert!(
tinfoil
.unsupported_params
.contains(&"temperature".to_string()),
"tinfoil should have 'temperature' in unsupported_params"
);
// OpenAI should also have temperature in unsupported_params
let openai = providers.iter().find(|p| p.id == "openai").unwrap();
assert!(
openai
.unsupported_params
.contains(&"temperature".to_string()),
"openai should have 'temperature' in unsupported_params"
);
// Providers without the field in JSON should deserialize to empty vec
let groq = providers.iter().find(|p| p.id == "groq").unwrap();
assert!(
groq.unsupported_params.is_empty(),
"groq should have empty unsupported_params (field absent in JSON)"
);
// Every non-empty entry should contain valid param names
for def in &providers {
for param in &def.unsupported_params {
assert!(
!param.is_empty(),
"{}: unsupported_params contains empty string",
def.id
);
}
}
}
#[test]
fn test_all_builtin_api_key_providers_have_api_key_env() {
// Every built-in provider with SetupHint::ApiKey must have api_key_env
+2 -144
View File
@@ -42,9 +42,6 @@ pub struct RigAdapter<M: CompletionModel> {
/// via `additional_params` for Anthropic automatic caching. Also controls
/// the cost multiplier for cache-creation tokens.
cache_retention: CacheRetention,
/// Parameter names that this provider does not support (e.g., `"temperature"`).
/// These are stripped from requests before sending to avoid 400 errors.
unsupported_params: HashSet<String>,
}
impl<M: CompletionModel> RigAdapter<M> {
@@ -59,7 +56,6 @@ impl<M: CompletionModel> RigAdapter<M> {
input_cost,
output_cost,
cache_retention: CacheRetention::None,
unsupported_params: HashSet::new(),
}
}
@@ -88,44 +84,6 @@ impl<M: CompletionModel> RigAdapter<M> {
}
self
}
/// Set the list of unsupported parameter names for this provider.
///
/// Parameters in this set are stripped from requests before sending.
/// Supported parameter names: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
pub fn with_unsupported_params(mut self, params: Vec<String>) -> Self {
self.unsupported_params = params.into_iter().collect();
self
}
/// Strip unsupported fields from a `CompletionRequest` in place.
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
if self.unsupported_params.is_empty() {
return;
}
if self.unsupported_params.contains("temperature") {
req.temperature = None;
}
if self.unsupported_params.contains("max_tokens") {
req.max_tokens = None;
}
if self.unsupported_params.contains("stop_sequences") {
req.stop_sequences = None;
}
}
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
if self.unsupported_params.is_empty() {
return;
}
if self.unsupported_params.contains("temperature") {
req.temperature = None;
}
if self.unsupported_params.contains("max_tokens") {
req.max_tokens = None;
}
}
}
// -- Type conversion helpers --
@@ -581,10 +539,7 @@ where
}
}
async fn complete(
&self,
mut request: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str()
{
@@ -595,8 +550,6 @@ where
);
}
self.strip_unsupported_completion_params(&mut request);
let mut messages = request.messages;
crate::llm::provider::sanitize_tool_messages(&mut messages);
let (preamble, history) = convert_messages(&messages);
@@ -646,7 +599,7 @@ where
async fn complete_with_tools(
&self,
mut request: ToolCompletionRequest,
request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str()
@@ -658,8 +611,6 @@ where
);
}
self.strip_unsupported_tool_params(&mut request);
let known_tool_names: HashSet<String> =
request.tools.iter().map(|t| t.name.clone()).collect();
@@ -1205,97 +1156,4 @@ mod tests {
assert!(!supports_prompt_cache("gpt-4o"));
assert!(!supports_prompt_cache("llama3"));
}
#[test]
fn test_with_unsupported_params_populates_set() {
use rig::client::CompletionClient;
use rig::providers::openai;
let client: openai::Client = openai::Client::builder()
.api_key("test-key")
.base_url("http://localhost:0")
.build()
.unwrap();
let client = client.completions_api();
let model = client.completion_model("test-model");
let adapter = RigAdapter::new(model, "test-model")
.with_unsupported_params(vec!["temperature".to_string()]);
assert!(adapter.unsupported_params.contains("temperature"));
assert!(!adapter.unsupported_params.contains("max_tokens"));
}
#[test]
fn test_strip_unsupported_completion_params() {
use rig::client::CompletionClient;
use rig::providers::openai;
let client: openai::Client = openai::Client::builder()
.api_key("test-key")
.base_url("http://localhost:0")
.build()
.unwrap();
let client = client.completions_api();
let model = client.completion_model("test-model");
let adapter = RigAdapter::new(model, "test-model").with_unsupported_params(vec![
"temperature".to_string(),
"stop_sequences".to_string(),
]);
let mut req = CompletionRequest::new(vec![ChatMessage::user("hi")]);
req.temperature = Some(0.7);
req.max_tokens = Some(100);
req.stop_sequences = Some(vec!["STOP".to_string()]);
adapter.strip_unsupported_completion_params(&mut req);
assert!(req.temperature.is_none(), "temperature should be stripped");
assert_eq!(req.max_tokens, Some(100), "max_tokens should be preserved");
assert!(
req.stop_sequences.is_none(),
"stop_sequences should be stripped"
);
}
#[test]
fn test_strip_unsupported_tool_params() {
use rig::client::CompletionClient;
use rig::providers::openai;
let client: openai::Client = openai::Client::builder()
.api_key("test-key")
.base_url("http://localhost:0")
.build()
.unwrap();
let client = client.completions_api();
let model = client.completion_model("test-model");
let adapter = RigAdapter::new(model, "test-model")
.with_unsupported_params(vec!["temperature".to_string(), "max_tokens".to_string()]);
let mut req = ToolCompletionRequest::new(vec![ChatMessage::user("hi")], vec![]);
req.temperature = Some(0.5);
req.max_tokens = Some(200);
adapter.strip_unsupported_tool_params(&mut req);
assert!(req.temperature.is_none(), "temperature should be stripped");
assert!(req.max_tokens.is_none(), "max_tokens should be stripped");
}
#[test]
fn test_unsupported_params_empty_by_default() {
use rig::client::CompletionClient;
use rig::providers::openai;
let client: openai::Client = openai::Client::builder()
.api_key("test-key")
.base_url("http://localhost:0")
.build()
.unwrap();
let client = client.completions_api();
let model = client.completion_model("test-model");
let adapter = RigAdapter::new(model, "test-model");
assert!(adapter.unsupported_params.is_empty());
}
}
+597 -49
View File
@@ -4,6 +4,7 @@ use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use tracing_subscriber::EnvFilter;
use ironclaw::{
agent::{Agent, AgentDeps},
@@ -11,7 +12,10 @@ use ironclaw::{
channels::{
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, SignalChannel, WebhookServer,
WebhookServerConfig,
wasm::{WasmChannelRouter, WasmChannelRuntime},
wasm::{
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
},
web::log_layer::LogBroadcaster,
},
cli::{
@@ -21,14 +25,26 @@ use ironclaw::{
config::Config,
hooks::bootstrap_hooks,
llm::create_session_manager,
orchestrator::{ReaperConfig, SandboxReaper},
orchestrator::{
ContainerJobConfig, ContainerJobManager, OrchestratorApi, ReaperConfig, SandboxReaper,
TokenStore, api::OrchestratorState,
},
pairing::PairingStore,
tracing_fmt::{init_cli_tracing, init_worker_tracing},
secrets::SecretsStore,
};
#[cfg(any(feature = "postgres", feature = "libsql"))]
use ironclaw::setup::{SetupConfig, SetupWizard};
/// Initialize tracing for simple CLI commands (warn level, no fancy layers).
fn init_cli_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
)
.init();
}
/// Synchronous entry point. Loads `.env` files before the Tokio runtime
/// starts so that `std::env::set_var` is safe (no worker threads yet).
fn main() -> anyhow::Result<()> {
@@ -64,7 +80,7 @@ async fn async_main() -> anyhow::Result<()> {
}
Some(Command::Memory(mem_cmd)) => {
init_cli_tracing();
return ironclaw::cli::run_memory_command(mem_cmd).await;
return run_memory_command(mem_cmd).await;
}
Some(Command::Pairing(pairing_cmd)) => {
init_cli_tracing();
@@ -92,7 +108,7 @@ async fn async_main() -> anyhow::Result<()> {
max_iterations,
}) => {
init_worker_tracing();
return ironclaw::worker::run_worker(*job_id, orchestrator_url, *max_iterations).await;
return run_worker(*job_id, orchestrator_url, *max_iterations).await;
}
Some(Command::ClaudeBridge {
job_id,
@@ -101,19 +117,12 @@ async fn async_main() -> anyhow::Result<()> {
model,
}) => {
init_worker_tracing();
return ironclaw::worker::run_claude_bridge(
*job_id,
orchestrator_url,
*max_turns,
model,
)
.await;
return run_claude_bridge(*job_id, orchestrator_url, *max_turns, model).await;
}
Some(Command::Onboard {
skip_auth,
channels_only,
provider_only,
quick,
}) => {
#[cfg(any(feature = "postgres", feature = "libsql"))]
{
@@ -121,14 +130,13 @@ async fn async_main() -> anyhow::Result<()> {
skip_auth: *skip_auth,
channels_only: *channels_only,
provider_only: *provider_only,
quick: *quick,
};
let mut wizard = SetupWizard::with_config(config);
wizard.run().await?;
}
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
{
let _ = (skip_auth, channels_only, provider_only, quick);
let _ = (skip_auth, channels_only, provider_only);
eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
}
return Ok(());
@@ -161,14 +169,11 @@ async fn async_main() -> anyhow::Result<()> {
// Enhanced first-run detection
#[cfg(any(feature = "postgres", feature = "libsql"))]
if !cli.no_onboard
&& let Some(reason) = ironclaw::setup::check_onboard_needed()
&& let Some(reason) = check_onboard_needed()
{
println!("Onboarding needed: {}", reason);
println!();
let mut wizard = SetupWizard::with_config(SetupConfig {
quick: true,
..Default::default()
});
let mut wizard = SetupWizard::new();
wizard.run().await?;
}
@@ -201,9 +206,9 @@ async fn async_main() -> anyhow::Result<()> {
let log_level_handle =
ironclaw::channels::web::log_layer::init_tracing(Arc::clone(&log_broadcaster));
tracing::debug!("Starting IronClaw...");
tracing::debug!("Loaded configuration for agent: {}", config.agent.name);
tracing::debug!("LLM backend: {}", config.llm.backend);
tracing::info!("Starting IronClaw...");
tracing::info!("Loaded configuration for agent: {}", config.agent.name);
tracing::info!("LLM backend: {}", config.llm.backend);
// ── Phase 1-5: Build all core components via AppBuilder ────────────
@@ -222,21 +227,95 @@ async fn async_main() -> anyhow::Result<()> {
// ── Tunnel setup ───────────────────────────────────────────────────
let (config, active_tunnel) = ironclaw::tunnel::start_managed_tunnel(config).await;
let (config, active_tunnel) = start_tunnel(config).await;
// ── Orchestrator / container job manager ────────────────────────────
let orch = ironclaw::orchestrator::setup_orchestrator(
&config,
&components.llm,
components.db.as_ref(),
components.secrets_store.as_ref(),
)
.await;
let container_job_manager = orch.container_job_manager;
let job_event_tx = orch.job_event_tx;
let prompt_queue = orch.prompt_queue;
let docker_status = orch.docker_status;
// Proactive Docker detection
let docker_status = if config.sandbox.enabled {
let detection = ironclaw::sandbox::check_docker().await;
match detection.status {
ironclaw::sandbox::DockerStatus::Available => {
tracing::info!("Docker is available");
}
ironclaw::sandbox::DockerStatus::NotInstalled => {
tracing::warn!(
"Docker is not installed -- sandbox disabled for this session. {}",
detection.platform.install_hint()
);
}
ironclaw::sandbox::DockerStatus::NotRunning => {
tracing::warn!(
"Docker is installed but not running -- sandbox disabled for this session. {}",
detection.platform.start_hint()
);
}
ironclaw::sandbox::DockerStatus::Disabled => {}
}
detection.status
} else {
ironclaw::sandbox::DockerStatus::Disabled
};
let job_event_tx: Option<
tokio::sync::broadcast::Sender<(uuid::Uuid, ironclaw::channels::web::types::SseEvent)>,
> = if config.sandbox.enabled && docker_status.is_ok() {
let (tx, _) = tokio::sync::broadcast::channel(256);
Some(tx)
} else {
None
};
let prompt_queue = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::<
uuid::Uuid,
std::collections::VecDeque<ironclaw::orchestrator::api::PendingPrompt>,
>::new()));
let container_job_manager: Option<Arc<ContainerJobManager>> =
if config.sandbox.enabled && docker_status.is_ok() {
let token_store = TokenStore::new();
let job_config = ContainerJobConfig {
image: config.sandbox.image.clone(),
memory_limit_mb: config.sandbox.memory_limit_mb,
cpu_shares: config.sandbox.cpu_shares,
orchestrator_port: 50051,
claude_code_api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
claude_code_oauth_token: ironclaw::config::ClaudeCodeConfig::extract_oauth_token(),
claude_code_model: config.claude_code.model.clone(),
claude_code_max_turns: config.claude_code.max_turns,
claude_code_memory_limit_mb: config.claude_code.memory_limit_mb,
claude_code_allowed_tools: config.claude_code.allowed_tools.clone(),
};
let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone()));
// Start the orchestrator internal API in the background
let orchestrator_state = OrchestratorState {
llm: components.llm.clone(),
job_manager: Arc::clone(&jm),
token_store,
job_event_tx: job_event_tx.clone(),
prompt_queue: Arc::clone(&prompt_queue),
store: components.db.clone(),
secrets_store: components.secrets_store.clone(),
user_id: "default".to_string(),
};
tokio::spawn(async move {
if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await {
tracing::error!("Orchestrator API failed: {}", e);
}
});
if config.claude_code.enabled {
tracing::info!(
"Claude Code sandbox mode available (model: {}, max_turns: {})",
config.claude_code.model,
config.claude_code.max_turns
);
}
Some(jm)
} else {
None
};
// ── Channel setup ──────────────────────────────────────────────────
@@ -264,10 +343,10 @@ async fn async_main() -> anyhow::Result<()> {
if let Some(repl) = repl_channel {
channels.add(Box::new(repl)).await;
if cli.message.is_some() {
tracing::debug!("Single message mode");
tracing::info!("Single message mode");
} else {
channel_names.push("repl".to_string());
tracing::debug!("REPL mode enabled");
tracing::info!("REPL mode enabled");
}
}
@@ -276,7 +355,7 @@ async fn async_main() -> anyhow::Result<()> {
// Load WASM channels and register their webhook routes.
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
let wasm_result = setup_wasm_channels(
&config,
&components.secrets_store,
components.extension_manager.as_ref(),
@@ -309,7 +388,7 @@ async fn async_main() -> anyhow::Result<()> {
channel_names.push("signal".to_string());
channels.add(Box::new(signal_channel)).await;
let safe_url = SignalChannel::redact_url(&signal_config.http_url);
tracing::debug!(
tracing::info!(
url = %safe_url,
"Signal channel enabled"
);
@@ -335,7 +414,7 @@ async fn async_main() -> anyhow::Result<()> {
);
channel_names.push("http".to_string());
channels.add(Box::new(http_channel)).await;
tracing::debug!(
tracing::info!(
"HTTP channel enabled on {}:{}",
http_config.host,
http_config.port
@@ -376,7 +455,7 @@ async fn async_main() -> anyhow::Result<()> {
&components.dev_loaded_tool_names,
)
.await;
tracing::debug!(
tracing::info!(
bundled = hook_bootstrap.bundled_hooks,
plugin = hook_bootstrap.plugin_hooks,
workspace = hook_bootstrap.workspace_hooks,
@@ -469,7 +548,7 @@ async fn async_main() -> anyhow::Result<()> {
gw.auth_token()
));
tracing::debug!("Web UI: http://{}:{}/", gw_config.host, gw_config.port);
tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port);
// Capture SSE sender and routine engine slot before moving gw into channels.
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
@@ -554,7 +633,7 @@ async fn async_main() -> anyhow::Result<()> {
config.channels.wasm_channel_owner_ids.clone(),
)
.await;
tracing::debug!("Channel runtime wired into extension manager for hot-activation");
tracing::info!("Channel runtime wired into extension manager for hot-activation");
// Auto-activate channels that were active in a previous session.
let persisted = ext_mgr.load_persisted_active_channels().await;
@@ -562,7 +641,7 @@ async fn async_main() -> anyhow::Result<()> {
if !active_at_startup.contains(name) {
match ext_mgr.activate(name).await {
Ok(result) => {
tracing::debug!(
tracing::info!(
channel = %name,
message = %result.message,
"Auto-activated persisted channel"
@@ -623,9 +702,6 @@ async fn async_main() -> anyhow::Result<()> {
document_extraction: Some(Arc::new(
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
)),
idempotency_cache: ironclaw::tools::idempotency::ToolIdempotencyCache::new(
ironclaw::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
let mut agent = Agent::new(
@@ -683,13 +759,485 @@ async fn async_main() -> anyhow::Result<()> {
}
if let Some(tunnel) = active_tunnel {
tracing::debug!("Stopping {} tunnel...", tunnel.name());
tracing::info!("Stopping {} tunnel...", tunnel.name());
if let Err(e) = tunnel.stop().await {
tracing::warn!("Failed to stop tunnel cleanly: {}", e);
}
}
tracing::debug!("Agent shutdown complete");
tracing::info!("Agent shutdown complete");
Ok(())
}
// ── Helper functions ────────────────────────────────────────────────────
/// Initialize tracing for worker/bridge processes (info level).
fn init_worker_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("ironclaw=info")),
)
.init();
}
/// Run the Memory CLI subcommand.
async fn run_memory_command(mem_cmd: &ironclaw::cli::MemoryCommand) -> anyhow::Result<()> {
let config = Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let session = create_session_manager(config.llm.session.clone()).await;
let embeddings = config
.embeddings
.create_provider(&config.llm.nearai.base_url, session);
let db: Arc<dyn ironclaw::db::Database> = ironclaw::db::connect_from_config(&config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
ironclaw::cli::run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await
}
/// Run the Worker subcommand (inside Docker containers).
async fn run_worker(
job_id: uuid::Uuid,
orchestrator_url: &str,
max_iterations: u32,
) -> anyhow::Result<()> {
tracing::info!(
"Starting worker for job {} (orchestrator: {})",
job_id,
orchestrator_url
);
let config = ironclaw::worker::runtime::WorkerConfig {
job_id,
orchestrator_url: orchestrator_url.to_string(),
max_iterations,
timeout: std::time::Duration::from_secs(600),
};
let runtime = ironclaw::worker::WorkerRuntime::new(config)
.map_err(|e| anyhow::anyhow!("Worker init failed: {}", e))?;
runtime
.run()
.await
.map_err(|e| anyhow::anyhow!("Worker failed: {}", e))
}
/// Run the Claude Code bridge subcommand (inside Docker containers).
async fn run_claude_bridge(
job_id: uuid::Uuid,
orchestrator_url: &str,
max_turns: u32,
model: &str,
) -> anyhow::Result<()> {
tracing::info!(
"Starting Claude Code bridge for job {} (orchestrator: {}, model: {})",
job_id,
orchestrator_url,
model
);
let config = ironclaw::worker::claude_bridge::ClaudeBridgeConfig {
job_id,
orchestrator_url: orchestrator_url.to_string(),
max_turns,
model: model.to_string(),
timeout: std::time::Duration::from_secs(1800),
allowed_tools: ironclaw::config::ClaudeCodeConfig::from_env().allowed_tools,
};
let runtime = ironclaw::worker::ClaudeBridgeRuntime::new(config)
.map_err(|e| anyhow::anyhow!("Claude bridge init failed: {}", e))?;
runtime
.run()
.await
.map_err(|e| anyhow::anyhow!("Claude bridge failed: {}", e))
}
/// Start managed tunnel if configured and no static URL is already set.
async fn start_tunnel(
mut config: ironclaw::config::Config,
) -> (
ironclaw::config::Config,
Option<Box<dyn ironclaw::tunnel::Tunnel>>,
) {
if config.tunnel.public_url.is_some() {
tracing::info!(
"Static tunnel URL in use: {}",
config.tunnel.public_url.as_deref().unwrap_or("?")
);
return (config, None);
}
let Some(ref provider_config) = config.tunnel.provider else {
return (config, None);
};
let gateway_port = config
.channels
.gateway
.as_ref()
.map(|g| g.port)
.unwrap_or(3000);
let gateway_host = config
.channels
.gateway
.as_ref()
.map(|g| g.host.as_str())
.unwrap_or("127.0.0.1");
match ironclaw::tunnel::create_tunnel(provider_config) {
Ok(Some(tunnel)) => {
tracing::info!(
"Starting {} tunnel on {}:{}...",
tunnel.name(),
gateway_host,
gateway_port
);
match tunnel.start(gateway_host, gateway_port).await {
Ok(url) => {
tracing::info!("Tunnel started: {}", url);
config.tunnel.public_url = Some(url);
(config, Some(tunnel))
}
Err(e) => {
tracing::error!("Failed to start tunnel: {}", e);
(config, None)
}
}
}
Ok(None) => (config, None),
Err(e) => {
tracing::error!("Failed to create tunnel: {}", e);
(config, None)
}
}
}
/// Result of WASM channel setup.
struct WasmChannelSetup {
channels: Vec<(String, Box<dyn ironclaw::channels::Channel>)>,
channel_names: Vec<String>,
webhook_routes: Option<axum::Router>,
/// Runtime objects needed for hot-activation via ExtensionManager.
wasm_channel_runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
wasm_channel_router: Arc<WasmChannelRouter>,
}
/// Load WASM channels and register their webhook routes.
async fn setup_wasm_channels(
config: &ironclaw::config::Config,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
extension_manager: Option<&Arc<ironclaw::extensions::ExtensionManager>>,
database: Option<&Arc<dyn ironclaw::db::Database>>,
) -> Option<WasmChannelSetup> {
let runtime = match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
Ok(r) => Arc::new(r),
Err(e) => {
tracing::warn!("Failed to initialize WASM channel runtime: {}", e);
return None;
}
};
let pairing_store = Arc::new(PairingStore::new());
let settings_store: Option<Arc<dyn ironclaw::db::SettingsStore>> =
database.map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>);
let mut loader = WasmChannelLoader::new(
Arc::clone(&runtime),
Arc::clone(&pairing_store),
settings_store,
);
if let Some(secrets) = secrets_store {
loader = loader.with_secrets_store(Arc::clone(secrets));
}
let results = match loader
.load_from_dir(&config.channels.wasm_channels_dir)
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("Failed to scan WASM channels directory: {}", e);
return None;
}
};
let wasm_router = Arc::new(WasmChannelRouter::new());
let mut channels: Vec<(String, Box<dyn ironclaw::channels::Channel>)> = Vec::new();
let mut channel_names: Vec<String> = Vec::new();
for loaded in results.loaded {
let channel_name = loaded.name().to_string();
channel_names.push(channel_name.clone());
tracing::info!("Loaded WASM channel: {}", channel_name);
let secret_name = loaded.webhook_secret_name();
let sig_key_secret_name = loaded.signature_key_secret_name();
let hmac_secret_name = loaded.hmac_secret_name();
let webhook_secret = if let Some(secrets) = secrets_store {
secrets
.get_decrypted("default", &secret_name)
.await
.ok()
.map(|s| s.expose().to_string())
} else {
None
};
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
let webhook_path = format!("/webhook/{}", channel_name);
let endpoints = vec![RegisteredEndpoint {
channel_name: channel_name.clone(),
path: webhook_path,
methods: vec!["POST".to_string()],
require_secret: webhook_secret.is_some(),
}];
let channel_arc = Arc::new(loaded.channel);
{
let mut config_updates = std::collections::HashMap::new();
if let Some(ref tunnel_url) = config.tunnel.public_url {
config_updates.insert(
"tunnel_url".to_string(),
serde_json::Value::String(tunnel_url.clone()),
);
}
if let Some(ref secret) = webhook_secret {
config_updates.insert(
"webhook_secret".to_string(),
serde_json::Value::String(secret.clone()),
);
}
// Inject owner_id if configured for this channel.
if let Some(&owner_id) = config
.channels
.wasm_channel_owner_ids
.get(channel_name.as_str())
{
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
}
if !config_updates.is_empty() {
channel_arc.update_config(config_updates).await;
tracing::info!(
channel = %channel_name,
has_tunnel = config.tunnel.public_url.is_some(),
has_webhook_secret = webhook_secret.is_some(),
"Injected runtime config into channel"
);
}
}
tracing::info!(
channel = %channel_name,
has_webhook_secret = webhook_secret.is_some(),
secret_header = ?secret_header,
"Registering channel with router"
);
wasm_router
.register(
Arc::clone(&channel_arc),
endpoints,
webhook_secret.clone(),
secret_header,
)
.await;
// Register Ed25519 signature key if declared in capabilities
if let Some(ref sig_key_name) = sig_key_secret_name
&& let Some(secrets) = secrets_store
&& let Ok(key_secret) = secrets.get_decrypted("default", sig_key_name).await
{
match wasm_router
.register_signature_key(&channel_name, key_secret.expose())
.await
{
Ok(()) => {
tracing::info!(channel = %channel_name, "Registered Ed25519 signature key")
}
Err(e) => {
tracing::error!(channel = %channel_name, error = %e, "Invalid signature key in secrets store")
}
}
}
// Register HMAC signing secret if declared in capabilities
if let Some(ref hmac_secret_name) = hmac_secret_name
&& let Some(secrets) = secrets_store
&& let Ok(secret) = secrets.get_decrypted("default", hmac_secret_name).await
{
wasm_router
.register_hmac_secret(&channel_name, secret.expose())
.await;
tracing::info!(channel = %channel_name, "Registered HMAC signing secret");
}
if let Some(secrets) = secrets_store {
match inject_channel_credentials(&channel_arc, secrets.as_ref(), &channel_name).await {
Ok(count) => {
if count > 0 {
tracing::info!(
channel = %channel_name,
credentials_injected = count,
"Channel credentials injected"
);
}
}
Err(e) => {
tracing::error!(
channel = %channel_name,
error = %e,
"Failed to inject channel credentials"
);
}
}
}
channels.push((channel_name, Box::new(SharedWasmChannel::new(channel_arc))));
}
for (path, err) in &results.errors {
tracing::warn!("Failed to load WASM channel {}: {}", path.display(), err);
}
// Always create webhook routes (even with no channels loaded) so that
// channels hot-added at runtime can receive webhooks without a restart.
let webhook_routes = {
Some(create_wasm_channel_router(
Arc::clone(&wasm_router),
extension_manager.map(Arc::clone),
))
};
Some(WasmChannelSetup {
channels,
channel_names,
webhook_routes,
wasm_channel_runtime: runtime,
pairing_store,
wasm_channel_router: wasm_router,
})
}
/// Check if onboarding is needed and return the reason.
#[cfg(any(feature = "postgres", feature = "libsql"))]
fn check_onboard_needed() -> Option<&'static str> {
let has_db = std::env::var("DATABASE_URL").is_ok()
|| std::env::var("LIBSQL_PATH").is_ok()
|| ironclaw::config::default_libsql_path().exists();
if !has_db {
return Some("Database not configured");
}
if std::env::var("ONBOARD_COMPLETED")
.map(|v| v == "true")
.unwrap_or(false)
{
return None;
}
if std::env::var("NEARAI_API_KEY").is_err() {
let session_path = ironclaw::config::default_session_path();
if !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
/// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`).
///
/// Falls back to environment variables with the uppercase name if not found
/// in the secrets store (e.g., `TELEGRAM_BOT_TOKEN`).
async fn inject_channel_credentials(
channel: &Arc<ironclaw::channels::wasm::WasmChannel>,
secrets: &dyn SecretsStore,
channel_name: &str,
) -> anyhow::Result<usize> {
let all_secrets = secrets
.list("default")
.await
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
let prefix = format!("{}_", channel_name);
let mut count = 0;
let mut injected_placeholders = std::collections::HashSet::new();
for secret_meta in all_secrets {
if !secret_meta.name.starts_with(&prefix) {
continue;
}
let decrypted = match secrets.get_decrypted("default", &secret_meta.name).await {
Ok(d) => d,
Err(e) => {
tracing::warn!(
secret = %secret_meta.name,
error = %e,
"Failed to decrypt secret for channel credential injection"
);
continue;
}
};
let placeholder = secret_meta.name.to_uppercase();
tracing::debug!(
channel = %channel_name,
secret = %secret_meta.name,
placeholder = %placeholder,
"Injecting credential"
);
channel
.set_credential(&placeholder, decrypted.expose().to_string())
.await;
injected_placeholders.insert(placeholder);
count += 1;
}
// Fall back to environment variables for required secrets not found in the store.
// This allows channels to work when configured via env vars (e.g., TELEGRAM_BOT_TOKEN)
// without requiring the setup wizard to have run.
let caps = channel.capabilities();
if let Some(ref http_cap) = caps.tool_capabilities.http {
for cred_mapping in http_cap.credentials.values() {
let placeholder = cred_mapping.secret_name.to_uppercase();
if injected_placeholders.contains(&placeholder) {
continue;
}
if let Ok(env_value) = std::env::var(&placeholder)
&& !env_value.is_empty()
{
tracing::debug!(
channel = %channel_name,
placeholder = %placeholder,
"Injecting credential from environment variable"
);
channel.set_credential(&placeholder, env_value).await;
count += 1;
}
}
}
Ok(count)
}
-112
View File
@@ -39,115 +39,3 @@ pub use job_manager::{
CompletionResult, ContainerHandle, ContainerJobConfig, ContainerJobManager, JobMode,
};
pub use reaper::{ReaperConfig, SandboxReaper};
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use tokio::sync::{Mutex, broadcast};
use uuid::Uuid;
use crate::channels::web::types::SseEvent;
use crate::db::Database;
use crate::llm::LlmProvider;
use crate::secrets::SecretsStore;
/// Result of orchestrator setup, containing all handles needed by the agent.
pub struct OrchestratorSetup {
pub container_job_manager: Option<Arc<ContainerJobManager>>,
pub job_event_tx: Option<broadcast::Sender<(Uuid, SseEvent)>>,
pub prompt_queue: Arc<Mutex<HashMap<Uuid, VecDeque<api::PendingPrompt>>>>,
pub docker_status: crate::sandbox::DockerStatus,
}
/// Detect Docker availability, create the container job manager, and start
/// the orchestrator internal API in the background.
pub async fn setup_orchestrator(
config: &crate::config::Config,
llm: &Arc<dyn LlmProvider>,
db: Option<&Arc<dyn Database>>,
secrets_store: Option<&Arc<dyn SecretsStore + Send + Sync>>,
) -> OrchestratorSetup {
let prompt_queue = Arc::new(Mutex::new(
HashMap::<Uuid, VecDeque<api::PendingPrompt>>::new(),
));
let docker_status = if config.sandbox.enabled {
let detection = crate::sandbox::check_docker().await;
match detection.status {
crate::sandbox::DockerStatus::Available => {
tracing::info!("Docker is available");
}
crate::sandbox::DockerStatus::NotInstalled => {
tracing::warn!(
"Docker is not installed -- sandbox disabled for this session. {}",
detection.platform.install_hint()
);
}
crate::sandbox::DockerStatus::NotRunning => {
tracing::warn!(
"Docker is installed but not running -- sandbox disabled for this session. {}",
detection.platform.start_hint()
);
}
crate::sandbox::DockerStatus::Disabled => {}
}
detection.status
} else {
crate::sandbox::DockerStatus::Disabled
};
let (job_event_tx, container_job_manager) = if config.sandbox.enabled && docker_status.is_ok() {
let (tx, _) = broadcast::channel(256);
let job_event_tx = Some(tx);
let token_store = TokenStore::new();
let job_config = ContainerJobConfig {
image: config.sandbox.image.clone(),
memory_limit_mb: config.sandbox.memory_limit_mb,
cpu_shares: config.sandbox.cpu_shares,
orchestrator_port: 50051,
claude_code_api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
claude_code_oauth_token: crate::config::ClaudeCodeConfig::extract_oauth_token(),
claude_code_model: config.claude_code.model.clone(),
claude_code_max_turns: config.claude_code.max_turns,
claude_code_memory_limit_mb: config.claude_code.memory_limit_mb,
claude_code_allowed_tools: config.claude_code.allowed_tools.clone(),
};
let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone()));
let orchestrator_state = api::OrchestratorState {
llm: Arc::clone(llm),
job_manager: Arc::clone(&jm),
token_store,
job_event_tx: job_event_tx.clone(),
prompt_queue: Arc::clone(&prompt_queue),
store: db.cloned(),
secrets_store: secrets_store.cloned(),
user_id: "default".to_string(),
};
tokio::spawn(async move {
if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await {
tracing::error!("Orchestrator API failed: {}", e);
}
});
if config.claude_code.enabled {
tracing::info!(
"Claude Code sandbox mode available (model: {}, max_turns: {})",
config.claude_code.model,
config.claude_code.max_turns
);
}
(job_event_tx, Some(jm))
} else {
(None, None)
};
OrchestratorSetup {
container_job_manager,
job_event_tx,
prompt_queue,
docker_status,
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ impl SandboxManager {
self.initialized
.store(false, std::sync::atomic::Ordering::SeqCst);
tracing::debug!("Sandbox shut down");
tracing::info!("Sandbox shut down");
}
/// Execute a command in the sandbox.
+1 -1
View File
@@ -154,7 +154,7 @@ impl HttpProxy {
}
}
_ = &mut shutdown_rx => {
tracing::debug!("Sandbox proxy shutting down");
tracing::info!("Sandbox proxy shutting down");
break;
}
}
-34
View File
@@ -75,37 +75,3 @@ pub use types::{
};
pub use store::in_memory::InMemorySecretsStore;
/// Create a secrets store from a master key and database handles.
///
/// Returns `None` if no matching backend handle is available (e.g. when
/// running without a database). This is a normal condition in no-db mode,
/// not an error — callers should treat `None` as "secrets unavailable".
pub fn create_secrets_store(
crypto: std::sync::Arc<SecretsCrypto>,
handles: &crate::db::DatabaseHandles,
) -> Option<std::sync::Arc<dyn SecretsStore + Send + Sync>> {
let store: Option<std::sync::Arc<dyn SecretsStore + Send + Sync>> = None;
#[cfg(feature = "libsql")]
let store = store.or_else(|| {
handles.libsql_db.as_ref().map(|db| {
std::sync::Arc::new(LibSqlSecretsStore::new(
std::sync::Arc::clone(db),
std::sync::Arc::clone(&crypto),
)) as std::sync::Arc<dyn SecretsStore + Send + Sync>
})
});
#[cfg(feature = "postgres")]
let store = store.or_else(|| {
handles.pg_pool.as_ref().map(|pool| {
std::sync::Arc::new(PostgresSecretsStore::new(
pool.clone(),
std::sync::Arc::clone(&crypto),
)) as std::sync::Arc<dyn SecretsStore + Send + Sync>
})
});
store
}
+3 -40
View File
@@ -10,7 +10,7 @@ file first, then adjust the code to match.
## Entry Points
```
ironclaw onboard [--skip-auth] [--channels-only] [--provider-only] [--quick]
ironclaw onboard [--skip-auth] [--channels-only]
```
Explicit invocation. Loads `.env` files, runs the wizard, exits.
@@ -26,8 +26,6 @@ the wizard). Otherwise triggers when no database is configured:
- `LIBSQL_PATH` env var is set
- `~/.ironclaw/ironclaw.db` exists on disk
Auto-triggered onboarding uses **quick mode** by default.
The `--no-onboard` CLI flag suppresses auto-detection.
---
@@ -52,41 +50,7 @@ The `--no-onboard` CLI flag suppresses auto-detection.
---
## Quick Mode
Quick mode (`--quick` flag, or auto-triggered on first run) provides a
near-instant onboarding experience by auto-defaulting everything except
the LLM provider and model selection.
```
auto_setup_database() → libsql at ~/.ironclaw/ironclaw.db (zero prompts)
auto_setup_security() → keychain or env var (zero prompts)
Step 1/2: Inference Provider ← only interactive step
Step 2/2: Model Selection ← only interactive step
save_and_summarize() → includes tip to run `ironclaw onboard`
```
**`auto_setup_database()`:** Uses existing env vars if set (`DATABASE_URL`
for postgres, `LIBSQL_PATH` for libsql) without prompting. Otherwise
defaults to libsql at `~/.ironclaw/ironclaw.db`, creates the database,
and runs migrations silently. Falls back to interactive mode only when
just the postgres feature is compiled and no `DATABASE_URL` is set.
**`auto_setup_security()`:** Checks for existing `SECRETS_MASTER_KEY`
env var or OS keychain key. If neither exists, generates a new key and
stores it in the keychain (macOS) or env var (Linux/other). Zero prompts
except unavoidable macOS keychain dialogs.
**`.env` preservation (fix for #751):** `write_bootstrap_env()` now uses
`upsert_bootstrap_vars()` instead of `save_bootstrap_env()`, preserving
user-added variables like `HTTP_HOST` across re-onboarding.
The full 9-step wizard remains available via `ironclaw onboard`.
---
## The 9-Step Wizard
## The 8-Step Wizard
### Overview
@@ -98,8 +62,7 @@ Step 4: Model Selection
Step 5: Embeddings
Step 6: Channel Configuration
Step 7: Extensions (tools)
Step 8: Docker Sandbox
Step 9: Background Tasks (heartbeat)
Step 8: Background Tasks (heartbeat)
save_and_summarize()
```
-32
View File
@@ -31,35 +31,3 @@ pub use prompts::{
};
#[cfg(any(feature = "postgres", feature = "libsql"))]
pub use wizard::{SetupConfig, SetupWizard};
/// Check if onboarding is needed and return the reason.
///
/// Reads environment variables (`DATABASE_URL`, `LIBSQL_PATH`,
/// `ONBOARD_COMPLETED`, `NEARAI_API_KEY`) and checks for the default
/// session file on disk. Not safe to call concurrently with `env::set_var`.
#[cfg(any(feature = "postgres", feature = "libsql"))]
pub fn check_onboard_needed() -> Option<&'static str> {
let has_db = std::env::var("DATABASE_URL").is_ok()
|| std::env::var("LIBSQL_PATH").is_ok()
|| crate::config::default_libsql_path().exists();
if !has_db {
return Some("Database not configured");
}
if std::env::var("ONBOARD_COMPLETED")
.map(|v| v == "true")
.unwrap_or(false)
{
return None;
}
if std::env::var("NEARAI_API_KEY").is_err() {
let session_path = crate::config::default_session_path();
if !session_path.exists() {
return Some("First run");
}
}
None
}
+10 -209
View File
@@ -76,8 +76,6 @@ pub struct SetupConfig {
pub channels_only: bool,
/// Only reconfigure LLM provider and model selection.
pub provider_only: bool,
/// Quick setup: auto-defaults everything except LLM provider and model.
pub quick: bool,
}
/// Interactive setup wizard for IronClaw.
@@ -156,26 +154,6 @@ impl SetupWizard {
print_step(1, 2, "Inference Provider");
self.step_inference_provider().await?;
self.persist_after_step().await;
print_step(2, 2, "Model Selection");
self.step_model_selection().await?;
self.persist_after_step().await;
} else if self.config.quick {
// Quick mode: auto-default database + security, only ask for
// LLM provider + model. Designed for first-run experience.
self.auto_setup_database().await?;
// Load existing settings from DB (if any prior partial run)
let step1_settings = self.settings.clone();
self.try_load_existing_settings().await;
self.settings.merge_from(&step1_settings);
self.auto_setup_security().await?;
self.persist_after_step().await;
print_step(1, 2, "Inference Provider");
self.step_inference_provider().await?;
self.persist_after_step().await;
print_step(2, 2, "Model Selection");
self.step_model_selection().await?;
self.persist_after_step().await;
@@ -681,10 +659,7 @@ impl SetupWizard {
use refinery::embed_migrations;
embed_migrations!("migrations");
if !self.config.quick {
print_info("Running migrations...");
}
tracing::debug!("Running PostgreSQL migrations...");
print_info("Running migrations...");
let mut client = pool
.get()
@@ -696,10 +671,7 @@ impl SetupWizard {
.await
.map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?;
if !self.config.quick {
print_success("Migrations applied");
}
tracing::debug!("PostgreSQL migrations applied");
print_success("Migrations applied");
}
Ok(())
}
@@ -710,20 +682,14 @@ impl SetupWizard {
if let Some(ref backend) = self.db_backend {
use crate::db::Database;
if !self.config.quick {
print_info("Running migrations...");
}
tracing::debug!("Running libSQL migrations...");
print_info("Running migrations...");
backend
.run_migrations()
.await
.map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?;
if !self.config.quick {
print_success("Migrations applied");
}
tracing::debug!("libSQL migrations applied");
print_success("Migrations applied");
}
Ok(())
}
@@ -838,140 +804,6 @@ impl SetupWizard {
Ok(())
}
/// Auto-setup database with zero prompts (quick mode).
///
/// Uses existing env vars if present, otherwise defaults to libsql at the
/// standard path. Falls back to the interactive `step_database()` only when
/// just the postgres feature is compiled (can't auto-default postgres).
async fn auto_setup_database(&mut self) -> Result<(), SetupError> {
// If DATABASE_URL or LIBSQL_PATH already set, respect existing config
#[cfg(feature = "postgres")]
let env_backend = std::env::var("DATABASE_BACKEND").ok();
#[cfg(feature = "postgres")]
if let Some(ref backend) = env_backend
&& (backend == "postgres" || backend == "postgresql")
{
if let Ok(url) = std::env::var("DATABASE_URL") {
print_info("Using existing PostgreSQL configuration");
self.settings.database_backend = Some("postgres".to_string());
self.settings.database_url = Some(url);
return Ok(());
}
// Postgres configured but no URL — fall through to interactive
return self.step_database().await;
}
#[cfg(feature = "postgres")]
if let Ok(url) = std::env::var("DATABASE_URL") {
print_info("Using existing PostgreSQL configuration");
self.settings.database_backend = Some("postgres".to_string());
self.settings.database_url = Some(url);
return Ok(());
}
// Auto-default to libsql if the feature is compiled
#[cfg(feature = "libsql")]
{
self.settings.database_backend = Some("libsql".to_string());
let existing_path = std::env::var("LIBSQL_PATH")
.ok()
.or_else(|| self.settings.libsql_path.clone());
let db_path = existing_path.unwrap_or_else(|| {
crate::config::default_libsql_path()
.to_string_lossy()
.to_string()
});
let turso_url = std::env::var("LIBSQL_URL").ok();
let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok();
self.test_database_connection_libsql(
&db_path,
turso_url.as_deref(),
turso_token.as_deref(),
)
.await?;
self.run_migrations_libsql().await?;
self.settings.libsql_path = Some(db_path.clone());
if let Some(url) = turso_url {
self.settings.libsql_url = Some(url);
}
print_success(&format!("Using embedded database at {}", db_path));
return Ok(());
}
// Only postgres feature compiled — can't auto-default, use interactive
#[allow(unreachable_code)]
{
self.step_database().await
}
}
/// Auto-setup security with zero prompts (quick mode).
///
/// Silently configures the master key: uses existing env var or keychain
/// key if available, otherwise generates and stores one automatically
/// (keychain on macOS, env var fallback).
async fn auto_setup_security(&mut self) -> Result<(), SetupError> {
// Check env var first
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
self.settings.secrets_master_key_source = KeySource::Env;
print_success("Security configured (env var)");
return Ok(());
}
// Try existing keychain key (no prompts — get_master_key may show
// OS dialogs on macOS, but that's unavoidable for keychain access)
if let Ok(keychain_key_bytes) = crate::secrets::keychain::get_master_key().await {
let key_hex: String = keychain_key_bytes
.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("Security configured (keychain)");
return Ok(());
}
// No existing key — generate one
// Try keychain first (preferred on macOS)
let key = crate::secrets::keychain::generate_master_key();
if crate::secrets::keychain::store_master_key(&key)
.await
.is_ok()
{
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 stored in OS keychain");
return Ok(());
}
// Keychain unavailable — fall back to env var mode
let key_hex = crate::secrets::keychain::generate_master_key_hex();
self.secrets_crypto = Some(Arc::new(
SecretsCrypto::new(SecretString::from(key_hex.clone()))
.map_err(|e| SetupError::Config(e.to_string()))?,
));
crate::config::inject_single_var("SECRETS_MASTER_KEY", &key_hex);
self.settings.secrets_master_key_hex = Some(key_hex);
self.settings.secrets_master_key_source = KeySource::Env;
print_success("Master key stored in ~/.ironclaw/.env");
Ok(())
}
/// Step 3: Inference provider selection.
///
/// Uses the provider registry to dynamically build the selection menu.
@@ -2674,7 +2506,7 @@ impl SetupWizard {
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
crate::bootstrap::upsert_bootstrap_vars(&pairs).map_err(|e| {
crate::bootstrap::save_bootstrap_env(&pairs).map_err(|e| {
SetupError::Io(std::io::Error::other(format!(
"Failed to save bootstrap env to .env: {}",
e
@@ -2946,13 +2778,6 @@ impl SetupWizard {
println!(" ironclaw onboard");
println!();
if self.config.quick {
print_info(
"Tip: Run `ironclaw onboard` to configure channels, extensions, embeddings, and more.",
);
println!();
}
Ok(())
}
}
@@ -3392,6 +3217,11 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa
/// Reads `NEARAI_API_KEY` from the environment so that users who authenticated
/// via Cloud API key (option 4) don't get re-prompted during model selection.
fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
let base_url =
std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
let auth_base_url =
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
// If the user authenticated via API key (option 4), the key is stored
// as an env var. Pass it through so `resolve_bearer_token()` doesn't
// re-trigger the interactive auth prompt.
@@ -3400,17 +3230,6 @@ fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
.filter(|k| !k.is_empty())
.map(secrecy::SecretString::from);
// Match the same base_url logic as LlmConfig::resolve(): use cloud-api
// when an API key is present, private.near.ai for session-token auth.
let default_base = if api_key.is_some() {
"https://cloud-api.near.ai"
} else {
"https://private.near.ai"
};
let base_url = std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string());
let auth_base_url =
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
crate::config::LlmConfig {
backend: "nearai".to_string(),
session: crate::llm::session::SessionConfig {
@@ -3647,7 +3466,6 @@ mod tests {
use tempfile::tempdir;
use super::*;
use crate::config::helpers::ENV_MUTEX;
#[test]
fn test_wizard_creation() {
@@ -3662,7 +3480,6 @@ mod tests {
skip_auth: true,
channels_only: false,
provider_only: false,
quick: false,
};
let wizard = SetupWizard::with_config(config);
assert!(wizard.config.skip_auth);
@@ -3996,7 +3813,6 @@ mod tests {
description: "Custom provider with no setup wizard".to_string(),
extra_headers_env: None,
setup: None,
unsupported_params: vec![],
});
let registry = crate::llm::ProviderRegistry::new(providers);
@@ -4044,9 +3860,7 @@ mod tests {
fn test_build_nearai_model_fetch_config_picks_up_api_key_env() {
use secrecy::ExposeSecret;
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::set("NEARAI_API_KEY", "test-cloud-api-key-12345");
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
let config = build_nearai_model_fetch_config();
assert!(
@@ -4057,37 +3871,24 @@ mod tests {
config.nearai.api_key.as_ref().unwrap().expose_secret(),
"test-cloud-api-key-12345"
);
// With API key, base_url must point to cloud-api (not private.near.ai)
assert_eq!(
config.nearai.base_url, "https://cloud-api.near.ai",
"API key auth must use cloud-api base URL for model fetching"
);
}
/// Regression test for #799: when NEARAI_API_KEY is absent or empty,
/// the config should have `api_key: None` (session token path).
#[test]
fn test_build_nearai_model_fetch_config_none_when_no_api_key() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::clear("NEARAI_API_KEY");
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
let config = build_nearai_model_fetch_config();
assert!(
config.nearai.api_key.is_none(),
"config should have no api_key when env var is absent"
);
// Without API key, base_url must point to private.near.ai (session token)
assert_eq!(
config.nearai.base_url, "https://private.near.ai",
"session-token auth must use private.near.ai base URL"
);
}
/// Regression test for #799: empty NEARAI_API_KEY should be treated as absent.
#[test]
fn test_build_nearai_model_fetch_config_none_when_empty_api_key() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::set("NEARAI_API_KEY", "");
let config = build_nearai_model_fetch_config();
-3
View File
@@ -453,9 +453,6 @@ impl TestHarnessBuilder {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
TestHarness {
-4
View File
@@ -46,8 +46,4 @@ impl Tool for EchoTool {
fn requires_sanitization(&self) -> bool {
false // Internal tool, no external data
}
fn is_idempotent(&self) -> bool {
true
}
}
-1
View File
@@ -777,7 +777,6 @@ mod tests {
Arc::new(ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
Arc::new(InMemorySecretsStore::new(crypto)),
Arc::new(ToolRegistry::new()),
None,
-4
View File
@@ -132,10 +132,6 @@ impl Tool for JsonTool {
fn requires_sanitization(&self) -> bool {
false // Internal tool, no external data
}
fn is_idempotent(&self) -> bool {
true
}
}
fn parse_json_input(data: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
-316
View File
@@ -1,316 +0,0 @@
//! Tool execution idempotency cache.
//!
//! Caches results of idempotent tool calls (same tool + same args = same result)
//! to avoid redundant re-execution during LLM retry loops, self-repair recovery,
//! and stuck job retries.
use std::sync::Arc;
use std::time::{Duration, Instant};
use lru::LruCache;
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
/// Cached tool result with expiration.
#[derive(Clone, Debug)]
struct CachedResult {
/// The serialized tool output string.
output: String,
/// When this entry was inserted.
inserted_at: Instant,
}
/// Configuration for the idempotency cache.
#[derive(Debug, Clone)]
pub struct IdempotencyCacheConfig {
/// Maximum total entries across all jobs. Default: 2000.
pub max_entries: usize,
/// Time-to-live for cached entries. Default: 30 minutes.
pub ttl: Duration,
}
impl Default for IdempotencyCacheConfig {
fn default() -> Self {
Self {
max_entries: 2000,
ttl: Duration::from_secs(30 * 60),
}
}
}
/// Global idempotency cache for tool executions.
///
/// Uses a single LRU cache with composite keys (job_id + tool_name + args_hash).
/// Entries expire after `ttl` and the total size is bounded by `max_entries`.
#[derive(Clone)]
pub struct ToolIdempotencyCache {
inner: Arc<Mutex<LruCache<String, CachedResult>>>,
config: IdempotencyCacheConfig,
}
impl ToolIdempotencyCache {
/// Create a new cache with the given configuration.
pub fn new(config: IdempotencyCacheConfig) -> Self {
let cap = std::num::NonZeroUsize::new(config.max_entries)
.unwrap_or(std::num::NonZeroUsize::new(1).expect("nonzero"));
Self {
inner: Arc::new(Mutex::new(LruCache::new(cap))),
config,
}
}
/// Look up a cached result. Returns `None` if absent or expired.
pub async fn get(
&self,
job_id: &str,
tool_name: &str,
params: &serde_json::Value,
) -> Option<String> {
let key = Self::cache_key(job_id, tool_name, params);
let mut cache = self.inner.lock().await;
if let Some(entry) = cache.get(&key) {
if entry.inserted_at.elapsed() < self.config.ttl {
return Some(entry.output.clone());
}
// Expired — remove it
cache.pop(&key);
}
None
}
/// Store a successful tool result in the cache.
pub async fn put(
&self,
job_id: &str,
tool_name: &str,
params: &serde_json::Value,
output: String,
) {
let key = Self::cache_key(job_id, tool_name, params);
let entry = CachedResult {
output,
inserted_at: Instant::now(),
};
let mut cache = self.inner.lock().await;
cache.put(key, entry);
}
/// Remove all cached entries for a specific job (call on job completion).
pub async fn invalidate_job(&self, job_id: &str) {
let prefix = format!("{}:", job_id);
let mut cache = self.inner.lock().await;
// Collect keys to remove (can't mutate while iterating)
let keys_to_remove: Vec<String> = cache
.iter()
.filter_map(|(k, _)| {
if k.starts_with(&prefix) {
Some(k.clone())
} else {
None
}
})
.collect();
for key in keys_to_remove {
cache.pop(&key);
}
}
/// Build a deterministic cache key from job_id, tool name, and params.
///
/// JSON object keys are sorted recursively to ensure order-independent
/// hashing (`{"a":1,"b":2}` and `{"b":2,"a":1}` produce the same key).
fn cache_key(job_id: &str, tool_name: &str, params: &serde_json::Value) -> String {
let mut hasher = Sha256::new();
hasher.update(tool_name.as_bytes());
hasher.update(b":");
let canonical = Self::canonicalize(params);
let params_str = serde_json::to_string(&canonical).unwrap_or_default();
hasher.update(params_str.as_bytes());
let hash = format!("{:x}", hasher.finalize());
format!("{}:{}:{}", job_id, tool_name, hash)
}
/// Recursively sort JSON object keys for canonical serialization.
fn canonicalize(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted: std::collections::BTreeMap<String, serde_json::Value> =
std::collections::BTreeMap::new();
for (k, v) in map {
sorted.insert(k.clone(), Self::canonicalize(v));
}
serde_json::Value::Object(sorted.into_iter().collect())
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(Self::canonicalize).collect())
}
other => other.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> IdempotencyCacheConfig {
IdempotencyCacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
}
}
#[tokio::test]
async fn test_cache_hit() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"path": "/etc/hosts"});
cache
.put("job1", "read_file", &params, "file contents".into())
.await;
let result = cache.get("job1", "read_file", &params).await;
assert_eq!(result, Some("file contents".into()));
}
#[tokio::test]
async fn test_cache_miss() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"path": "/etc/hosts"});
let result = cache.get("job1", "read_file", &params).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_different_params_miss() {
let cache = ToolIdempotencyCache::new(config());
let params1 = serde_json::json!({"path": "/etc/hosts"});
let params2 = serde_json::json!({"path": "/etc/passwd"});
cache
.put("job1", "read_file", &params1, "hosts".into())
.await;
let result = cache.get("job1", "read_file", &params2).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_different_jobs_isolated() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"path": "/etc/hosts"});
cache
.put("job1", "read_file", &params, "from job1".into())
.await;
let result = cache.get("job2", "read_file", &params).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_invalidate_job() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"q": "test"});
cache.put("job1", "echo", &params, "echo1".into()).await;
cache
.put("job1", "time", &serde_json::json!({}), "now".into())
.await;
cache.put("job2", "echo", &params, "echo2".into()).await;
cache.invalidate_job("job1").await;
assert_eq!(cache.get("job1", "echo", &params).await, None);
assert_eq!(
cache.get("job1", "time", &serde_json::json!({})).await,
None
);
// job2 unaffected
assert_eq!(
cache.get("job2", "echo", &params).await,
Some("echo2".into())
);
}
#[tokio::test]
async fn test_ttl_expiry() {
let cache = ToolIdempotencyCache::new(IdempotencyCacheConfig {
max_entries: 10,
ttl: Duration::from_millis(1),
});
let params = serde_json::json!({"x": 1});
cache.put("job1", "echo", &params, "val".into()).await;
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(cache.get("job1", "echo", &params).await, None);
}
#[tokio::test]
async fn test_lru_eviction() {
let cache = ToolIdempotencyCache::new(IdempotencyCacheConfig {
max_entries: 3,
ttl: Duration::from_secs(60),
});
// Fill to capacity
for i in 0..3 {
let params = serde_json::json!({"i": i});
cache.put("job1", "echo", &params, format!("val{i}")).await;
}
// Insert one more, evicting the oldest (i=0)
let params_new = serde_json::json!({"i": 99});
cache.put("job1", "echo", &params_new, "val99".into()).await;
assert_eq!(
cache
.get("job1", "echo", &serde_json::json!({"i": 0}))
.await,
None
);
assert_eq!(
cache
.get("job1", "echo", &serde_json::json!({"i": 2}))
.await,
Some("val2".into())
);
assert_eq!(
cache.get("job1", "echo", &params_new).await,
Some("val99".into())
);
}
#[tokio::test]
async fn test_cache_key_determinism() {
let key1 =
ToolIdempotencyCache::cache_key("j1", "echo", &serde_json::json!({"a": 1, "b": 2}));
let key2 =
ToolIdempotencyCache::cache_key("j1", "echo", &serde_json::json!({"a": 1, "b": 2}));
assert_eq!(key1, key2);
}
#[tokio::test]
async fn test_cache_key_order_independent() {
// JSON objects with different key insertion order must produce the same cache key
let key1 =
ToolIdempotencyCache::cache_key("j1", "echo", &serde_json::json!({"a": 1, "b": 2}));
let key2 =
ToolIdempotencyCache::cache_key("j1", "echo", &serde_json::json!({"b": 2, "a": 1}));
assert_eq!(key1, key2);
}
#[tokio::test]
async fn test_cache_key_nested_order_independent() {
let key1 = ToolIdempotencyCache::cache_key(
"j1",
"tool",
&serde_json::json!({"x": {"c": 3, "d": 4}, "y": 1}),
);
let key2 = ToolIdempotencyCache::cache_key(
"j1",
"tool",
&serde_json::json!({"y": 1, "x": {"d": 4, "c": 3}}),
);
assert_eq!(key1, key2);
}
#[tokio::test]
async fn test_overwrite_existing_entry() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"x": 1});
cache.put("job1", "echo", &params, "old".into()).await;
cache.put("job1", "echo", &params, "new".into()).await;
assert_eq!(cache.get("job1", "echo", &params).await, Some("new".into()));
}
}
-98
View File
@@ -1,98 +0,0 @@
//! Factory for creating MCP clients from server configuration.
//!
//! Encapsulates the transport dispatch logic (stdio, Unix socket, HTTP)
//! so that callers don't need to match on `EffectiveTransport` themselves.
use std::sync::Arc;
use crate::secrets::SecretsStore;
use crate::tools::mcp::config::{EffectiveTransport, McpServerConfig};
use crate::tools::mcp::{McpClient, McpProcessManager, McpSessionManager, McpTransport};
/// Error returned when MCP client creation fails.
#[derive(Debug, thiserror::Error)]
pub enum McpFactoryError {
#[error("Failed to spawn stdio MCP server '{name}': {reason}")]
StdioSpawn { name: String, reason: String },
#[error("Failed to connect to Unix MCP server '{name}': {reason}")]
UnixConnect { name: String, reason: String },
#[error("Unix socket transport is not supported on this platform (server '{name}')")]
UnixNotSupported { name: String },
}
/// Create an `McpClient` from a server configuration, dispatching on the
/// effective transport type.
pub async fn create_client_from_config(
server: McpServerConfig,
session_manager: &Arc<McpSessionManager>,
process_manager: &Arc<McpProcessManager>,
secrets: Option<Arc<dyn SecretsStore + Send + Sync>>,
user_id: &str,
) -> Result<McpClient, McpFactoryError> {
let server_name = server.name.clone();
match server.effective_transport() {
EffectiveTransport::Stdio { command, args, env } => {
let transport = process_manager
.spawn_stdio(&server_name, command, args.to_vec(), env.clone())
.await
.map_err(|e| McpFactoryError::StdioSpawn {
name: server_name.clone(),
reason: e.to_string(),
})?;
Ok(McpClient::new_with_transport(
&server_name,
transport as Arc<dyn McpTransport>,
None,
secrets,
user_id,
Some(server),
))
}
#[cfg(unix)]
EffectiveTransport::Unix { socket_path } => {
let transport = crate::tools::mcp::unix_transport::UnixMcpTransport::connect(
&server_name,
socket_path,
)
.await
.map_err(|e| McpFactoryError::UnixConnect {
name: server_name.clone(),
reason: e.to_string(),
})?;
Ok(McpClient::new_with_transport(
&server_name,
Arc::new(transport) as Arc<dyn McpTransport>,
None,
secrets,
user_id,
Some(server),
))
}
#[cfg(not(unix))]
EffectiveTransport::Unix { .. } => {
Err(McpFactoryError::UnixNotSupported { name: server_name })
}
EffectiveTransport::Http => {
if let Some(ref secrets) = secrets {
let has_tokens =
crate::tools::mcp::is_authenticated(&server, secrets, user_id).await;
if has_tokens || server.requires_auth() {
Ok(McpClient::new_authenticated(
server,
Arc::clone(session_manager),
Arc::clone(secrets),
user_id,
))
} else {
Ok(McpClient::new_with_config(server))
}
} else {
Ok(McpClient::new_with_config(server))
}
}
}
}
-2
View File
@@ -31,7 +31,6 @@
pub mod auth;
mod client;
pub mod config;
pub mod factory;
pub(crate) mod http_transport;
pub(crate) mod process;
mod protocol;
@@ -44,7 +43,6 @@ pub(crate) mod unix_transport;
pub use auth::{is_authenticated, refresh_access_token};
pub use client::McpClient;
pub use config::{McpServerConfig, McpServersFile, OAuthConfig};
pub use factory::{McpFactoryError, create_client_from_config};
pub use process::McpProcessManager;
pub use protocol::{InitializeResult, McpRequest, McpResponse, McpTool};
pub use session::McpSessionManager;
-1
View File
@@ -9,7 +9,6 @@
pub mod builder;
pub mod builtin;
pub mod idempotency;
pub mod mcp;
pub mod rate_limiter;
pub mod schema_validator;
+14 -14
View File
@@ -241,7 +241,7 @@ impl ToolRegistry {
}
self.register_sync(Arc::new(http));
tracing::debug!("Registered {} built-in tools", self.count());
tracing::info!("Registered {} built-in tools", self.count());
}
/// Register only orchestrator-domain tools (safe for the main process).
@@ -289,7 +289,7 @@ impl ToolRegistry {
self.register_sync(Arc::new(ListDirTool::new()));
self.register_sync(Arc::new(ApplyPatchTool::new()));
tracing::debug!("Registered 5 development tools");
tracing::info!("Registered 5 development tools");
}
/// Register memory tools with a workspace.
@@ -302,7 +302,7 @@ impl ToolRegistry {
self.register_sync(Arc::new(MemoryReadTool::new(Arc::clone(&workspace))));
self.register_sync(Arc::new(MemoryTreeTool::new(workspace)));
tracing::debug!("Registered 4 memory tools");
tracing::info!("Registered 4 memory tools");
}
/// Register job management tools.
@@ -364,7 +364,7 @@ impl ToolRegistry {
job_tool_count += 1;
}
tracing::debug!("Registered {} job management tools", job_tool_count);
tracing::info!("Registered {} job management tools", job_tool_count);
}
/// Register secret management tools (list, delete).
@@ -378,7 +378,7 @@ impl ToolRegistry {
use crate::tools::builtin::{SecretDeleteTool, SecretListTool};
self.register_sync(Arc::new(SecretListTool::new(Arc::clone(&store))));
self.register_sync(Arc::new(SecretDeleteTool::new(store)));
tracing::debug!("Registered 2 secret management tools (list, delete)");
tracing::info!("Registered 2 secret management tools (list, delete)");
}
/// Register extension management tools (search, install, auth, activate, list, remove).
@@ -393,7 +393,7 @@ impl ToolRegistry {
self.register_sync(Arc::new(ToolRemoveTool::new(Arc::clone(&manager))));
self.register_sync(Arc::new(ToolUpgradeTool::new(Arc::clone(&manager))));
self.register_sync(Arc::new(ExtensionInfoTool::new(manager)));
tracing::debug!("Registered 8 extension management tools");
tracing::info!("Registered 8 extension management tools");
}
/// Register skill management tools (list, search, install, remove).
@@ -414,7 +414,7 @@ impl ToolRegistry {
Arc::clone(&catalog),
)));
self.register_sync(Arc::new(SkillRemoveTool::new(registry)));
tracing::debug!("Registered 4 skill management tools");
tracing::info!("Registered 4 skill management tools");
}
/// Register routine management tools.
@@ -448,7 +448,7 @@ impl ToolRegistry {
Arc::clone(&engine),
)));
self.register_sync(Arc::new(RoutineHistoryTool::new(store)));
tracing::debug!("Registered 6 routine management tools");
tracing::info!("Registered 6 routine management tools");
}
/// Register message tool for sending messages to channels.
@@ -467,7 +467,7 @@ impl ToolRegistry {
.write()
.await
.insert("message".to_string());
tracing::debug!("Registered message tool");
tracing::info!("Registered message tool");
}
/// Set the default channel and target for the message tool.
@@ -501,7 +501,7 @@ impl ToolRegistry {
gen_model,
base_dir,
)));
tracing::debug!("Registered 2 image tools (generate, edit)");
tracing::info!("Registered 2 image tools (generate, edit)");
}
/// Register vision/image analysis tools.
@@ -521,7 +521,7 @@ impl ToolRegistry {
vision_model,
base_dir,
)));
tracing::debug!("Registered 1 vision tool (analyze)");
tracing::info!("Registered 1 vision tool (analyze)");
}
/// Register the software builder tool.
@@ -549,7 +549,7 @@ impl ToolRegistry {
self.register(Arc::new(BuildSoftwareTool::new(builder)))
.await;
tracing::debug!("Registered software builder tool");
tracing::info!("Registered software builder tool");
}
/// Register a WASM tool from bytes.
@@ -619,7 +619,7 @@ impl ToolRegistry {
);
}
tracing::debug!(name = reg.name, "Registered WASM tool");
tracing::info!(name = reg.name, "Registered WASM tool");
Ok(())
}
@@ -676,7 +676,7 @@ impl ToolRegistry {
.await
.map_err(WasmRegistrationError::Wasm)?;
tracing::debug!(
tracing::info!(
name = tool_with_binary.tool.name,
user_id = user_id,
trust_level = %tool_with_binary.tool.trust_level,
-19
View File
@@ -312,25 +312,6 @@ pub trait Tool: Send + Sync {
&[]
}
/// Whether this tool is a pure function of its input parameters.
///
/// A tool marked idempotent must produce the same output for the same input
/// regardless of when it is called — no dependency on external mutable state
/// (filesystem, time, database, network) and no side effects.
///
/// Results of idempotent tools are cached to avoid re-execution when the LLM
/// re-requests the same tool with identical arguments (common during
/// self-repair recovery or retry loops).
///
/// Examples: `echo` (returns input), `json` (parse/format).
/// Counter-examples: `read_file` (filesystem changes), `time` (clock),
/// `memory_search` (workspace mutations), `list_jobs` (job state changes).
///
/// Default: `false`. Override to return `true` only for pure functions.
fn is_idempotent(&self) -> bool {
false
}
/// Per-invocation rate limit for this tool.
///
/// Return `Some(config)` to throttle how often this tool can be called per user.
+8 -36
View File
@@ -193,31 +193,18 @@ impl WasmToolLoader {
///
/// Tools without a capabilities file get no permissions (default deny).
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmLoadError> {
match fs::metadata(dir).await {
Ok(meta) if meta.is_dir() => {}
Ok(_) => {
return Err(WasmLoadError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("{} is not a directory", dir.display()),
)));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadResults::default());
}
Err(e) => return Err(WasmLoadError::Io(e)),
if !dir.is_dir() {
return Err(WasmLoadError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("{} is not a directory", dir.display()),
)));
}
// Handle TOCTOU: if read_dir fails with NotFound, treat as empty
let mut entries = match fs::read_dir(dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadResults::default());
}
Err(e) => return Err(WasmLoadError::Io(e)),
};
let mut results = LoadResults::default();
// Collect all .wasm entries first, then load in parallel
let mut tool_entries = Vec::new();
let mut entries = fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
@@ -1090,19 +1077,4 @@ mod tests {
"nested.wasm inside subdir should NOT be discovered"
);
}
#[tokio::test]
async fn load_from_dir_returns_empty_when_dir_missing() {
let loader = make_loader();
let dir = TempDir::new().unwrap();
let missing = dir.path().join("nonexistent_tools_dir");
let results = loader.load_from_dir(&missing).await;
// Must succeed with empty results, not error
let results = results.expect("missing dir should return Ok, not Err");
assert!(results.loaded.is_empty());
assert!(results.errors.is_empty());
}
}
-19
View File
@@ -21,27 +21,8 @@
use std::io::{self, Write};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::MakeWriter;
/// Initialize tracing for simple CLI commands (warn level, no fancy layers).
pub fn init_cli_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
)
.init();
}
/// Initialize tracing for worker/bridge processes (info level).
pub fn init_worker_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("ironclaw=info")),
)
.init();
}
/// Maximum bytes per tracing event written to the terminal.
const TERMINAL_MAX_EVENT_BYTES: usize = 500;
-62
View File
@@ -180,68 +180,6 @@ pub fn create_tunnel(config: &TunnelProviderConfig) -> Result<Option<Box<dyn Tun
}
}
// ── Managed tunnel startup ───────────────────────────────────────
/// Start a managed tunnel if configured and no static URL is already set.
///
/// Returns the (potentially mutated) config with `tunnel.public_url` set,
/// plus the active tunnel handle (if one was started) for later shutdown.
pub async fn start_managed_tunnel(
mut config: crate::config::Config,
) -> (crate::config::Config, Option<Box<dyn Tunnel>>) {
if config.tunnel.public_url.is_some() {
tracing::info!(
"Static tunnel URL in use: {}",
config.tunnel.public_url.as_deref().unwrap_or("?")
);
return (config, None);
}
let Some(ref provider_config) = config.tunnel.provider else {
return (config, None);
};
let gateway_port = config
.channels
.gateway
.as_ref()
.map(|g| g.port)
.unwrap_or(3000);
let gateway_host = config
.channels
.gateway
.as_ref()
.map(|g| g.host.as_str())
.unwrap_or("127.0.0.1");
match create_tunnel(provider_config) {
Ok(Some(tunnel)) => {
tracing::info!(
"Starting {} tunnel on {}:{}...",
tunnel.name(),
gateway_host,
gateway_port
);
match tunnel.start(gateway_host, gateway_port).await {
Ok(url) => {
tracing::info!("Tunnel started: {}", url);
config.tunnel.public_url = Some(url);
(config, Some(tunnel))
}
Err(e) => {
tracing::error!("Failed to start tunnel: {}", e);
(config, None)
}
}
}
Ok(None) => (config, None),
Err(e) => {
tracing::error!("Failed to create tunnel: {}", e);
(config, None)
}
}
}
// ── Tests ────────────────────────────────────────────────────────
#[cfg(test)]
-58
View File
@@ -33,61 +33,3 @@ pub use api::WorkerHttpClient;
pub use claude_bridge::ClaudeBridgeRuntime;
pub use proxy_llm::ProxyLlmProvider;
pub use runtime::WorkerRuntime;
/// Run the Worker subcommand (inside Docker containers).
pub async fn run_worker(
job_id: uuid::Uuid,
orchestrator_url: &str,
max_iterations: u32,
) -> anyhow::Result<()> {
tracing::info!(
"Starting worker for job {} (orchestrator: {})",
job_id,
orchestrator_url
);
let config = runtime::WorkerConfig {
job_id,
orchestrator_url: orchestrator_url.to_string(),
max_iterations,
timeout: std::time::Duration::from_secs(600),
};
let rt =
WorkerRuntime::new(config).map_err(|e| anyhow::anyhow!("Worker init failed: {}", e))?;
rt.run()
.await
.map_err(|e| anyhow::anyhow!("Worker failed: {}", e))
}
/// Run the Claude Code bridge subcommand (inside Docker containers).
pub async fn run_claude_bridge(
job_id: uuid::Uuid,
orchestrator_url: &str,
max_turns: u32,
model: &str,
) -> anyhow::Result<()> {
tracing::info!(
"Starting Claude Code bridge for job {} (orchestrator: {}, model: {})",
job_id,
orchestrator_url,
model
);
let config = claude_bridge::ClaudeBridgeConfig {
job_id,
orchestrator_url: orchestrator_url.to_string(),
max_turns,
model: model.to_string(),
timeout: std::time::Duration::from_secs(1800),
allowed_tools: crate::config::ClaudeCodeConfig::from_env().allowed_tools,
};
let rt = ClaudeBridgeRuntime::new(config)
.map_err(|e| anyhow::anyhow!("Claude bridge init failed: {}", e))?;
rt.run()
.await
.map_err(|e| anyhow::anyhow!("Claude bridge failed: {}", e))
}
+1 -33
View File
@@ -20,10 +20,8 @@ mod tests {
use ironclaw::agent::routine_engine::RoutineEngine;
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner};
use ironclaw::channels::IncomingMessage;
use ironclaw::config::{RoutineConfig, SafetyConfig};
use ironclaw::config::RoutineConfig;
use ironclaw::db::Database;
use ironclaw::safety::SafetyLayer;
use ironclaw::tools::ToolRegistry;
use ironclaw::workspace::Workspace;
use ironclaw::workspace::hygiene::HygieneConfig;
@@ -105,14 +103,6 @@ mod tests {
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::channel(16);
// Create minimal ToolRegistry and SafetyLayer for test.
let tools = Arc::new(ToolRegistry::new());
let safety_config = SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: true,
};
let safety = Arc::new(SafetyLayer::new(&safety_config));
let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(),
db.clone(),
@@ -120,8 +110,6 @@ mod tests {
ws,
notify_tx,
None,
tools,
safety,
));
// Insert a cron routine with next_fire_at in the past.
@@ -182,14 +170,6 @@ mod tests {
let llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
// Create minimal ToolRegistry and SafetyLayer for test.
let tools = Arc::new(ToolRegistry::new());
let safety_config = SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: true,
};
let safety = Arc::new(SafetyLayer::new(&safety_config));
let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(),
db.clone(),
@@ -197,8 +177,6 @@ mod tests {
ws,
notify_tx,
None,
tools,
safety,
));
// Insert an event routine matching "deploy.*production".
@@ -280,14 +258,6 @@ mod tests {
let llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
// Create minimal ToolRegistry and SafetyLayer for test.
let tools = Arc::new(ToolRegistry::new());
let safety_config = SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: true,
};
let safety = Arc::new(SafetyLayer::new(&safety_config));
let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(),
db.clone(),
@@ -295,8 +265,6 @@ mod tests {
ws,
notify_tx,
None,
tools,
safety,
));
// Insert an event routine with 1-hour cooldown.
-237
View File
@@ -1,237 +0,0 @@
//! Integration test for module-owned initialization factories.
//!
//! Verifies that the refactored factory functions in `db`, `secrets`,
//! `orchestrator`, and `extensions` modules wire up correctly end-to-end,
//! ensuring nothing was lost when initialization logic was moved out of
//! `main.rs` and `app.rs` into owning modules.
use std::sync::Arc;
use ironclaw::db::DatabaseHandles;
use ironclaw::secrets::{CreateSecretParams, SecretsCrypto, SecretsStore};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Build a libsql DatabaseConfig pointing at a temp file.
#[cfg(feature = "libsql")]
fn libsql_config(path: &std::path::Path) -> ironclaw::config::DatabaseConfig {
ironclaw::config::DatabaseConfig {
backend: ironclaw::config::DatabaseBackend::LibSql,
url: secrecy::SecretString::from(String::new()),
pool_size: 1,
ssl_mode: ironclaw::config::SslMode::Prefer,
libsql_path: Some(path.to_path_buf()),
libsql_url: None,
libsql_auth_token: None,
}
}
/// Build a master-key crypto instance for tests.
fn test_crypto() -> Arc<SecretsCrypto> {
let key = secrecy::SecretString::from(ironclaw::secrets::keychain::generate_master_key_hex());
Arc::new(SecretsCrypto::new(key).expect("test crypto"))
}
// ---------------------------------------------------------------------------
// connect_with_handles: returns Database + populated handles
// ---------------------------------------------------------------------------
#[cfg(feature = "libsql")]
#[tokio::test]
async fn connect_with_handles_returns_db_and_libsql_handle() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.db");
let config = libsql_config(&db_path);
let (db, handles) = ironclaw::db::connect_with_handles(&config)
.await
.expect("connect_with_handles");
// Database trait object works — run a trivial operation.
db.run_migrations().await.expect("migrations");
// Handle is populated.
assert!(
handles.libsql_db.is_some(),
"libsql handle should be Some after connect_with_handles"
);
}
// ---------------------------------------------------------------------------
// connect_from_config delegates to connect_with_handles
// ---------------------------------------------------------------------------
#[cfg(feature = "libsql")]
#[tokio::test]
async fn connect_from_config_produces_working_db() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.db");
let config = libsql_config(&db_path);
// connect_from_config delegates to connect_with_handles internally.
let db = ironclaw::db::connect_from_config(&config)
.await
.expect("connect_from_config");
// Verify usable — migrations should be idempotent.
db.run_migrations().await.expect("migrations");
}
// ---------------------------------------------------------------------------
// secrets::create_secrets_store from DatabaseHandles
// ---------------------------------------------------------------------------
#[cfg(feature = "libsql")]
#[tokio::test]
async fn secrets_store_from_handles_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.db");
let config = libsql_config(&db_path);
let (_db, handles) = ironclaw::db::connect_with_handles(&config)
.await
.expect("connect");
let crypto = test_crypto();
let store = ironclaw::secrets::create_secrets_store(crypto, &handles)
.expect("create_secrets_store should return Some for libsql");
// Round-trip a secret to prove the store works.
store
.create("test", CreateSecretParams::new("test_key", "test_value"))
.await
.expect("create secret");
let decrypted = store
.get_decrypted("test", "test_key")
.await
.expect("get_decrypted");
assert_eq!(decrypted.expose(), "test_value");
}
// ---------------------------------------------------------------------------
// db::create_secrets_store (standalone CLI factory)
// ---------------------------------------------------------------------------
#[cfg(feature = "libsql")]
#[tokio::test]
async fn db_create_secrets_store_standalone_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.db");
let config = libsql_config(&db_path);
let crypto = test_crypto();
let store = ironclaw::db::create_secrets_store(&config, crypto)
.await
.expect("db::create_secrets_store");
store
.create(
"test",
CreateSecretParams::new("standalone_key", "standalone_value"),
)
.await
.expect("create secret");
let decrypted = store
.get_decrypted("test", "standalone_key")
.await
.expect("get_decrypted");
assert_eq!(decrypted.expose(), "standalone_value");
}
// ---------------------------------------------------------------------------
// Both secrets factories produce equivalent stores
// ---------------------------------------------------------------------------
#[cfg(feature = "libsql")]
#[tokio::test]
async fn both_secrets_factories_produce_compatible_stores() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.db");
let config = libsql_config(&db_path);
let crypto = test_crypto();
// Factory 1: connect_with_handles + secrets::create_secrets_store
let (_db, handles) = ironclaw::db::connect_with_handles(&config)
.await
.expect("connect");
let store_a = ironclaw::secrets::create_secrets_store(Arc::clone(&crypto), &handles)
.expect("store from handles");
// Factory 2: db::create_secrets_store (standalone)
let store_b = ironclaw::db::create_secrets_store(&config, crypto)
.await
.expect("standalone store");
// Write with factory 1, read with factory 2.
store_a
.create(
"test",
CreateSecretParams::new("cross_factory", "shared_secret"),
)
.await
.expect("create via store_a");
let decrypted = store_b
.get_decrypted("test", "cross_factory")
.await
.expect("read via store_b");
assert_eq!(decrypted.expose(), "shared_secret");
}
// ---------------------------------------------------------------------------
// ExtensionManager constructs with McpProcessManager
// ---------------------------------------------------------------------------
#[tokio::test]
async fn extension_manager_with_process_manager_constructs() {
use ironclaw::extensions::ExtensionManager;
use ironclaw::secrets::InMemorySecretsStore;
use ironclaw::tools::ToolRegistry;
use ironclaw::tools::mcp::McpProcessManager;
use ironclaw::tools::mcp::McpSessionManager;
let crypto = test_crypto();
let secrets: Arc<dyn SecretsStore + Send + Sync> = Arc::new(InMemorySecretsStore::new(crypto));
let tools = Arc::new(ToolRegistry::new());
let tools_dir = tempfile::tempdir().expect("tools_dir");
let channels_dir = tempfile::tempdir().expect("channels_dir");
let manager = ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(McpProcessManager::new()),
secrets,
tools,
None,
None,
tools_dir.path().to_path_buf(),
channels_dir.path().to_path_buf(),
None,
"test".to_string(),
None,
Vec::new(),
);
// Verify the manager is functional — list returns Ok.
let result = manager.list(None, false).await;
assert!(result.is_ok(), "list should succeed on empty manager");
assert!(result.unwrap().is_empty());
}
// ---------------------------------------------------------------------------
// DatabaseHandles: default is empty
// ---------------------------------------------------------------------------
#[test]
fn database_handles_default_is_empty() {
let handles = DatabaseHandles::default();
#[cfg(feature = "postgres")]
assert!(handles.pg_pool.is_none());
#[cfg(feature = "libsql")]
assert!(handles.libsql_db.is_none());
}
-7
View File
@@ -575,8 +575,6 @@ impl TestRigBuilder {
Arc::clone(ws),
notify_tx,
None,
components.tools.clone(),
components.safety.clone(),
));
components
.tools
@@ -624,9 +622,6 @@ impl TestRigBuilder {
},
transcription: None,
document_extraction: None,
idempotency_cache: ironclaw::tools::idempotency::ToolIdempotencyCache::new(
ironclaw::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
// 7. Create TestChannel and ChannelManager.
@@ -649,8 +644,6 @@ impl TestRigBuilder {
max_concurrent_routines: 3,
default_cooldown_secs: 300,
max_lightweight_tokens: 4096,
lightweight_tools_enabled: true,
lightweight_max_iterations: 3,
})
} else {
None