Compare commits

..
Author SHA1 Message Date
Nick Pismenkov b01bfecb48 remove CLAUDE.md changes 2026-03-09 17:13:46 -07:00
Nick Pismenkov 940c182fea fix: remove accidentally committed binary 2026-03-09 17:13:04 -07:00
Nick Pismenkov 654bda9239 Merge main into fix/libsql-crash
Resolves conflict in CLAUDE.md by keeping our detailed Configuration section.

[skip-regression-check]
2026-03-09 16:40:16 -07:00
Nick Pismenkov 13cde37722 fix
[skip-regression-check]
2026-03-09 16:40:11 -07:00
Nick PismenkovandClaude Haiku 4.5 f5123055b5 docs: document libSQL CLI connection crash issue
Add detailed documentation for the libSQL CLI crash that blocks interactive
setup flows on libSQL deployments (ironclaw tool setup, ironclaw secret set,
ironclaw mcp auth).

- Add known issue comment to src/db/mod.rs create_secrets_store() documenting
  the "invalid connection string" crash on libSQL CLI subcommands
- Add related comments to src/cli/tool.rs init_secrets_store() and
  src/cli/mcp.rs get_secrets_store() functions
- Add to CLAUDE.md Current Limitations section as priority item #1
- Include workaround: manually read master key from /proc/PID/environ,
  encrypt secrets with AES-256-GCM, write directly to secrets table
- Root cause unknown: libSQL works fine in main process but fails when
  called from CLI subcommands (possibly path resolution, file permissions,
  or WAL mode conflicts)
- Related: #655 (libSQL backend gaps)

Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-03-09 16:01:07 -07:00
88 changed files with 1083 additions and 3607 deletions
-2
View File
@@ -115,8 +115,6 @@ AGENT_NAME=ironclaw
AGENT_MAX_PARALLEL_JOBS=5 AGENT_MAX_PARALLEL_JOBS=5
AGENT_JOB_TIMEOUT_SECS=3600 AGENT_JOB_TIMEOUT_SECS=3600
AGENT_STUCK_THRESHOLD_SECS=300 AGENT_STUCK_THRESHOLD_SECS=300
# Maximum tokens per job (0 = unlimited, also settable via settings.json agent.max_tokens_per_job)
# AGENT_MAX_TOKENS_PER_JOB=0
# Enable planning phase before tool execution (default: true) # Enable planning phase before tool execution (default: true)
AGENT_USE_PLANNING=true AGENT_USE_PLANNING=true
+1 -2
View File
@@ -2,7 +2,7 @@ name: Claude Code Review
on: on:
pull_request: pull_request:
types: [labeled] types: [opened, labeled]
permissions: permissions:
contents: read contents: read
@@ -28,7 +28,6 @@ jobs:
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@v1
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: "ironclaw-ci[bot]"
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'" claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
prompt: | prompt: |
Code review this pull request. Follow these steps precisely: Code review this pull request. Follow these steps precisely:
+1 -7
View File
@@ -44,7 +44,6 @@ jobs:
clippy-windows: clippy-windows:
name: Clippy Windows (${{ matrix.name }}) name: Clippy Windows (${{ matrix.name }})
if: github.base_ref == 'main'
runs-on: windows-latest runs-on: windows-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -77,12 +76,7 @@ jobs:
needs: [format, clippy, clippy-windows] needs: [format, clippy, clippy-windows]
steps: steps:
- run: | - run: |
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.clippy-windows.result }}" != "success" ]]; then
echo "One or more jobs failed" echo "One or more jobs failed"
exit 1 exit 1
fi fi
# clippy-windows only runs on main PRs, so skip/success are both acceptable
if [[ "${{ needs.clippy-windows.result }}" == "failure" ]]; then
echo "Windows clippy failed"
exit 1
fi
+2
View File
@@ -115,6 +115,7 @@ jobs:
- name: Generate GitHub App token - name: Generate GitHub App token
id: app-token id: app-token
if: ${{ secrets.GH_RELEASES_MANAGER_APP_ID != '' }}
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
@@ -229,6 +230,7 @@ jobs:
- name: Generate GitHub App token - name: Generate GitHub App token
id: app-token id: app-token
if: ${{ secrets.GH_RELEASES_MANAGER_APP_ID != '' }}
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v2
with: with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
-2
View File
@@ -2,8 +2,6 @@ name: Run Tests
on: on:
workflow_call: workflow_call:
pull_request: pull_request:
branches:
- main
push: push:
branches: branches:
- main - main
+1
View File
@@ -28,3 +28,4 @@ trace_*.json
# Local Claude Code settings (machine-specific, should not be committed) # Local Claude Code settings (machine-specific, should not be committed)
.claude/settings.local.json .claude/settings.local.json
rust_out
-75
View File
@@ -7,81 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ### Added
- AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`) - 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) │ ├── repl.rs # Simple REPL (for testing)
│ ├── web/ # Web gateway (browser UI) — see src/channels/web/CLAUDE.md │ ├── web/ # Web gateway (browser UI) — see src/channels/web/CLAUDE.md
│ └── wasm/ # WASM channel runtime │ └── 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) ├── cli/ # CLI subcommands (clap)
│ ├── mod.rs # Cli struct, Command enum (run/onboard/config/tool/registry/mcp/memory/pairing/service/doctor/status/completion) │ ├── 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) ├── hooks/ # Lifecycle hooks (6 points: BeforeInbound, BeforeToolCall, BeforeOutbound, OnSessionStart, OnSessionEnd, TransformResponse)
├── tunnel/ # Tunnel abstraction for public internet exposure ├── tunnel/ # Tunnel abstraction (cloudflare, ngrok, tailscale, custom, none)
│ ├── 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)
├── observability/ # Pluggable event/metric recording (noop, log, multi) ├── observability/ # Pluggable event/metric recording (noop, log, multi)
@@ -118,26 +105,8 @@ src/
│ ├── rate_limiter.rs # Shared sliding-window rate limiter │ ├── 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) │ ├── 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 │ ├── builder/ # Dynamic tool building
│ ├── core.rs # BuildRequirement, SoftwareType, Language │ ├── mcp/ # Model Context Protocol client
│ ├── templates.rs # Project scaffolding └── wasm/ # Full WASM sandbox (wasmtime) — runtime, host functions, fuel metering, allowlist, credential injection
│ │ ├── 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
├── db/ # Dual-backend persistence (PostgreSQL + libSQL) — see src/db/CLAUDE.md ├── 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. 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 | | Module | Spec |
|--------|------| |--------|------|
| `src/agent/` | `src/agent/CLAUDE.md` | | `src/agent/` | `src/agent/CLAUDE.md` |
Generated
+1 -1
View File
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]] [[package]]
name = "ironclaw" name = "ironclaw"
version = "0.17.0" version = "0.16.1"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"aho-corasick", "aho-corasick",
+1 -1
View File
@@ -18,7 +18,7 @@ exclude = [
[package] [package]
name = "ironclaw" name = "ironclaw"
version = "0.17.0" version = "0.16.1"
edition = "2024" edition = "2024"
rust-version = "1.92" rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
+44 -51
View File
@@ -10,8 +10,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- 🚫 Out of scope (intentionally skipped) - 🚫 Out of scope (intentionally skipped)
- N/A (not applicable to Rust implementation) - 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 ## 1. Architecture
@@ -41,11 +39,11 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only | | Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override | | OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
| Canvas hosting | ✅ | ❌ | Agent-driven UI | | 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 | ✅ | ❌ | | | launchd/systemd integration | ✅ | ❌ | |
| Bonjour/mDNS discovery | ✅ | ❌ | | | Bonjour/mDNS discovery | ✅ | ❌ | |
| Tailscale integration | ✅ | ❌ | | | 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 | ✅ | ❌ | | | `doctor` diagnostics | ✅ | ❌ | |
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired | | Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval | | 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 | | REPL (simple) | ✅ | ✅ | - | For testing |
| WASM channels | ❌ | ✅ | - | IronClaw innovation | | WASM channels | ❌ | ✅ | - | IronClaw innovation |
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection | | 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 | | Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing | | Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
| Slack | ✅ | ✅ | - | WASM tool | | Slack | ✅ | ✅ | - | WASM tool |
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended | | iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required | | 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 | | | LINE | ✅ | ❌ | P3 | |
| WebChat | ✅ | ✅ | - | Web gateway chat | | WebChat | ✅ | ✅ | - | Web gateway chat |
| Matrix | ✅ | ❌ | P3 | E2EE support | | Matrix | ✅ | ❌ | P3 | E2EE support |
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker | | Mattermost | ✅ | ❌ | P3 | Emoji reactions |
| Google Chat | ✅ | ❌ | P3 | | | Google Chat | ✅ | ❌ | P3 | |
| MS Teams | ✅ | ❌ | P3 | | | MS Teams | ✅ | ❌ | P3 | |
| Twitch | ✅ | ❌ | P3 | | | Twitch | ✅ | ❌ | P3 | |
@@ -94,8 +92,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| User message reactions | ✅ | ❌ | Surface inbound reactions | | User message reactions | ✅ | ❌ | Surface inbound reactions |
| sendPoll | ✅ | ❌ | Poll creation via agent | | sendPoll | ✅ | ❌ | Poll creation via agent |
| Cron/heartbeat topic targeting | ✅ | ❌ | Messages land in correct topic | | 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) ### 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 | | Streaming draft replies | ✅ | ❌ | Partial replies via draft message updates |
| Configurable stream modes | ✅ | ❌ | Per-channel stream behavior | | Configurable stream modes | ✅ | ❌ | Per-channel stream behavior |
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking plus reply participation memory | | Thread ownership | ✅ | ❌ | Thread-level ownership tracking |
| 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 |
### Channel Features ### Channel Features
| Feature | OpenClaw | IronClaw | Notes | | Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------| |---------|----------|----------|-------|
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs | | 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 | | Self-message bypass | ✅ | ❌ | Own messages skip pairing |
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages | | Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools | | Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
| Thread isolation | ✅ | ✅ | Separate sessions per thread/topic | | Thread isolation | ✅ | ✅ | Separate sessions per thread |
| Per-channel media limits | ✅ | 🚧 | Caption support plus `mediaMaxMb` enforcement for WhatsApp, Telegram, and Discord | | Per-channel media limits | ✅ | | Attachment type in WIT; max 10 per msg, 20MB total, MIME allowlist |
| Typing indicators | ✅ | 🚧 | TUI + channel typing, with configurable silence timeout; richer parity pending | | Typing indicators | ✅ | 🚧 | TUI + Telegram typing/actionable status prompts; richer parity pending |
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions/scopes | | Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions |
| Group session priming | ✅ | ❌ | Member roster injected for context | | Group session priming | ✅ | ❌ | Member roster injected for context |
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata | | 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 | | | `gateway start/stop` | ✅ | ❌ | P2 | |
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup | | `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
| `tui` | ✅ | ✅ | - | Ratatui TUI | | `tui` | ✅ | ✅ | - | Ratatui TUI |
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers | | `config` | ✅ | ✅ | - | Read/write config |
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
| `channels` | ✅ | ❌ | P2 | Channel management | | `channels` | ✅ | ❌ | P2 | Channel management |
| `models` | ✅ | 🚧 | - | Model selector in TUI | | `models` | ✅ | 🚧 | - | Model selector in TUI |
| `status` | ✅ | ✅ | - | System status (enriched session details) | | `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 | | Global sessions | ✅ | ❌ | Optional shared context |
| Session pruning | ✅ | ❌ | Auto cleanup old sessions | | Session pruning | ✅ | ❌ | Auto cleanup old sessions |
| Context compaction | ✅ | ✅ | Auto summarization | | 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 read audit | ✅ | ❌ | Layer 3: workspace rules appended to summaries |
| Post-compaction context injection | ✅ | ❌ | Workspace context as system event | | Post-compaction context injection | ✅ | ❌ | Workspace context as system event |
| Custom system prompts | ✅ | ✅ | Template variables, safety guardrails | | Custom system prompts | ✅ | ✅ | Template variables, safety guardrails |
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector | | 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 routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens | | Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | ❌ | Configurable reasoning depth | | Thinking modes (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model; Anthropic Claude 4.6 defaults to adaptive | | Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model |
| Block-level streaming | ✅ | ❌ | | | Block-level streaming | ✅ | ❌ | |
| Tool-level streaming | ✅ | ❌ | | | Tool-level streaming | ✅ | ❌ | |
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call 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 | | Provider | OpenClaw | IronClaw | Priority | Notes |
|----------|----------|----------|----------|-------| |----------|----------|----------|----------|-------|
| NEAR AI | ✅ | ✅ | - | Primary provider | | NEAR AI | ✅ | ✅ | - | Primary provider |
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6, adaptive thinking default | | Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6 |
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy; GPT-5.4 + Codex OAuth | | OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy |
| AWS Bedrock | ✅ | ❌ | P3 | | | AWS Bedrock | ✅ | ✅ | - | Native Converse API via aws-sdk-bedrockruntime (requires `--features bedrock`) |
| Google Gemini | ✅ | | P3 | | | Google Gemini | ✅ | | P3 | Via `gemini` adapter |
| NVIDIA API | ✅ | | P3 | New provider | | 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) | | OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) | | Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) | | 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 | | Per-session model override | ✅ | ✅ | Model selector in TUI |
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut | | Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
| Per-model thinkingDefault | ✅ | ❌ | Override thinking level per model in config | | 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_ ### Owner: _Unassigned_
@@ -269,20 +252,32 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Priority | Notes | | 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 | | Image processing (Sharp) | ✅ | ❌ | P2 | Resize, format convert |
| Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config | | Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config |
| Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images | | Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images |
| Audio transcription | ✅ | ❌ | P2 | | | Audio transcription | ✅ | ❌ | P2 | |
| Video support | ✅ | ❌ | P3 | | | Video support | ✅ | ❌ | P3 | |
| PDF analysis tool | ✅ | ❌ | P2 | Native Anthropic/Gemini path with text/image extraction fallback | | PDF parsing | ✅ | ❌ | P2 | pdfjs-dist |
| PDF parsing | ✅ | | P2 | `pdfjs-dist` fallback path | | MIME detection | ✅ | | P2 | MIME allowlist in host validates attachment types |
| MIME detection | ✅ | ❌ | P2 | |
| Media caching | ✅ | ❌ | P3 | | | Media caching | ✅ | ❌ | P3 | |
| Vision model integration | ✅ | ❌ | P2 | Image understanding | | Vision model integration | ✅ | ❌ | P2 | Image understanding |
| TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech | | TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech |
| TTS (OpenAI) | ✅ | ❌ | P3 | | | TTS (OpenAI) | ✅ | ❌ | P3 | |
| Incremental TTS playback | ✅ | ❌ | P3 | iOS progressive playback | | 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_ ### Owner: _Unassigned_
@@ -298,8 +293,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Workspace-relative install | ✅ | ✅ | ~/.ironclaw/tools/ | | Workspace-relative install | ✅ | ✅ | ~/.ironclaw/tools/ |
| Channel plugins | ✅ | ✅ | WASM channels | | Channel plugins | ✅ | ✅ | WASM channels |
| Auth plugins | ✅ | ❌ | | | Auth plugins | ✅ | ❌ | |
| Memory plugins | ✅ | ❌ | Custom backends + selectable memory slot | | Memory plugins | ✅ | ❌ | Custom backends |
| Context-engine plugins | ✅ | ❌ | Custom context management + subagent/context hooks |
| Tool plugins | ✅ | ✅ | WASM tools | | Tool plugins | ✅ | ✅ | WASM tools |
| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities | | Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities |
| Provider plugins | ✅ | ❌ | | | Provider plugins | ✅ | ❌ | |
@@ -321,7 +315,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| JSON5 support | ✅ | ❌ | Comments, trailing commas | | JSON5 support | ✅ | ❌ | Comments, trailing commas |
| YAML alternative | ✅ | ❌ | | | YAML alternative | ✅ | ❌ | |
| Environment variable interpolation | ✅ | ✅ | `${VAR}` | | Environment variable interpolation | ✅ | ✅ | `${VAR}` |
| Config validation/schema | ✅ | ✅ | Type-safe Config struct + `openclaw config validate` | | Config validation/schema | ✅ | ✅ | Type-safe Config struct |
| Hot-reload | ✅ | ❌ | | | Hot-reload | ✅ | ❌ | |
| Legacy migration | ✅ | | | | Legacy migration | ✅ | | |
| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | | | 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 | | Feature | OpenClaw | IronClaw | Priority | Notes |
|---------|----------|----------|----------|-------| |---------|----------|----------|----------|-------|
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger | | 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 stagger controls | ✅ | ❌ | P3 | Default stagger for scheduled jobs |
| Cron finished-run webhook | ✅ | ❌ | P3 | Webhook on job completion | | Cron finished-run webhook | ✅ | ❌ | P3 | Webhook on job completion |
| Timezone support | ✅ | ✅ | - | Via cron expressions | | Timezone support | ✅ | ✅ | - | Via cron expressions |
@@ -482,10 +475,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Elevated mode | ✅ | ❌ | | | Elevated mode | ✅ | ❌ | |
| Safe bins allowlist | ✅ | ❌ | Hardened path trust | | Safe bins allowlist | ✅ | ❌ | Hardened path trust |
| LD*/DYLD* validation | ✅ | ❌ | | | 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 | | 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 | | 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 | ✅ | ✅ | | | Webhook signature verification | ✅ | ✅ | |
| Media URL validation | ✅ | ❌ | | | Media URL validation | ✅ | ❌ | |
| Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization | | Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization |
+1 -3
View File
@@ -9,9 +9,8 @@
"api_key_required": true, "api_key_required": true,
"base_url_env": "OPENAI_BASE_URL", "base_url_env": "OPENAI_BASE_URL",
"model_env": "OPENAI_MODEL", "model_env": "OPENAI_MODEL",
"default_model": "gpt-5-mini", "default_model": "gpt-4o",
"description": "OpenAI GPT models (direct API)", "description": "OpenAI GPT models (direct API)",
"unsupported_params": ["temperature"],
"setup": { "setup": {
"kind": "api_key", "kind": "api_key",
"secret_name": "llm_openai_api_key", "secret_name": "llm_openai_api_key",
@@ -87,7 +86,6 @@
"model_env": "TINFOIL_MODEL", "model_env": "TINFOIL_MODEL",
"default_model": "kimi-k2-5", "default_model": "kimi-k2-5",
"description": "Tinfoil private inference (hardware-attested TEE)", "description": "Tinfoil private inference (hardware-attested TEE)",
"unsupported_params": ["temperature"],
"setup": { "setup": {
"kind": "api_key", "kind": "api_key",
"secret_name": "llm_tinfoil_api_key", "secret_name": "llm_tinfoil_api_key",
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
"sha256": "85b424604482da3fb9badb56a0360ff4c93670bc7be0ad7f57ef9d85ff972b6f" "sha256": "030707431717bca3411a48f311c6ab5f92a45c747de26cafe4f6e3e23a8b3b2d"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
"sha256": "9190b8250bd20c22a8c97b1ea19a6590624a69d6c63a5f5c240a7840a4966286" "sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
"sha256": "55f2a56e7afd129a48fd49b019f12f9638705defa53fa323ad3b8978d7c59664" "sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
"sha256": "06bcf315df93af9f683134f4055eb810c602863d8c4a632e3733a10217cc5a89" "sha256": "bd35cad18d87292ea8d2f52db9b514ed9f814a414de910f59073d475c26c4c14"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -20,7 +20,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
"sha256": "c443328a3f10b6a4cf4d3d62c9217aca204f6467ef753d986b58ca966ca53514" "sha256": "6fcd32719a4ff15641a4b50fff8984686550f0c491dce60518f4126857d0c544"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
"sha256": "e4f0095890d22e3de8e9d516f2e1e91964f8ff4acdaaa19f0a7094a1f2d7786b" "sha256": "023da7000b17568bf0e64b2e5013c8a042b2f323c85f1632339231c73d500e39"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
"sha256": "2d202bd838de94677c91ea6473c7155f021c0500cf91794d17639b1b27446b3d" "sha256": "fc42277b65881d6e9bcc5403dc54c7f5b3ddeaaaf04617fce2c5da05d76325f0"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
"sha256": "7a5e40fe58199e34f7625e11d22e5601cdfd2a94a10193a83f1925180bbb66df" "sha256": "385c04abd1e6b8011ccc330e1f4bd7ce58577e488959b51594aa04eb26cbe7cc"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
"sha256": "d19f856fde0ae0320fd3f636a34116af1df0b59698c3684b686e8412a60e887f" "sha256": "1b107d575a5d52cc8c76d9a681802190f4373fb485f7f54f445533f097fa37c0"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
"sha256": "e113c317f9fa21ea68d0ec8accbba4a62a8222ff3c4655ae85e1e58e01de3250" "sha256": "c4f6b1e8c5126ac2c8a4b98e4283a3afa32223d2488fc3c3a609758c0c9beb90"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
"sha256": "7875a5ae1283e57937e0618bf14465f4bb4ee7f49110312382670202f4c567a5" "sha256": "7110b8565340c888e51f99e9c013bf4de8f8a7f7b33bace00eb8fc47831ff20b"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
"sha256": "9190b8250bd20c22a8c97b1ea19a6590624a69d6c63a5f5c240a7840a4966286" "sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
"sha256": "55f2a56e7afd129a48fd49b019f12f9638705defa53fa323ad3b8978d7c59664" "sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
"sha256": "dd7e54956ee0b3037ca3506dbcbd20efcc4cd2749175ed511b3640b09f77506a" "sha256": "66cb2b9b00652385e9f30f17c74902b9222c17c53e9d3bd1ef42f5cab705bcf6"
} }
}, },
"auth_summary": { "auth_summary": {
+6 -12
View File
@@ -29,7 +29,6 @@ use crate::llm::LlmProvider;
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::skills::SkillRegistry; use crate::skills::SkillRegistry;
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::workspace::Workspace; use crate::workspace::Workspace;
/// Collapse a tool output string into a single-line preview for display. /// 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>>, pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc. /// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>, 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. /// The main agent that coordinates all components.
@@ -133,7 +130,6 @@ impl Agent {
deps.tools.clone(), deps.tools.clone(),
deps.store.clone(), deps.store.clone(),
deps.hooks.clone(), deps.hooks.clone(),
deps.idempotency_cache.clone(),
); );
if let Some(ref tx) = deps.sse_tx { if let Some(ref tx) = deps.sse_tx {
scheduler.set_sse_sender(tx.clone()); scheduler.set_sse_sender(tx.clone());
@@ -450,8 +446,6 @@ impl Agent {
Arc::clone(workspace), Arc::clone(workspace),
notify_tx, notify_tx,
Some(self.scheduler.clone()), Some(self.scheduler.clone()),
self.tools().clone(),
self.safety().clone(),
)); ));
// Register routine tools // Register routine tools
@@ -520,7 +514,7 @@ impl Agent {
*slot.write().await = Some(Arc::clone(&engine)); *slot.write().await = Some(Arc::clone(&engine));
} }
tracing::debug!( tracing::info!(
"Routines enabled: cron ticker every {}s, max {} concurrent", "Routines enabled: cron ticker every {}s, max {} concurrent",
rt_config.cron_check_interval_secs, rt_config.cron_check_interval_secs,
rt_config.max_concurrent_routines 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)); let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
// Main message loop // Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name); tracing::info!("Agent {} ready and listening", self.config.name);
loop { loop {
let message = tokio::select! { let message = tokio::select! {
biased; biased;
_ = tokio::signal::ctrl_c() => { _ = tokio::signal::ctrl_c() => {
tracing::debug!("Ctrl+C received, shutting down..."); tracing::info!("Ctrl+C received, shutting down...");
break; break;
} }
msg = message_stream.next() => { msg = message_stream.next() => {
match msg { match msg {
Some(m) => m, Some(m) => m,
None => { None => {
tracing::debug!("All channel streams ended, shutting down..."); tracing::info!("All channel streams ended, shutting down...");
break; break;
} }
} }
@@ -630,7 +624,7 @@ impl Agent {
} }
Ok(None) => { Ok(None) => {
// Shutdown signal received (/quit, /exit, /shutdown) // Shutdown signal received (/quit, /exit, /shutdown)
tracing::debug!("Shutdown command received, exiting..."); tracing::info!("Shutdown command received, exiting...");
break; break;
} }
Err(e) => { Err(e) => {
@@ -659,7 +653,7 @@ impl Agent {
} }
// Cleanup // Cleanup
tracing::debug!("Agent shutting down..."); tracing::info!("Agent shutting down...");
repair_handle.abort(); repair_handle.abort();
pruning_handle.abort(); pruning_handle.abort();
if let Some(handle) = heartbeat_handle { if let Some(handle) = heartbeat_handle {
+8 -152
View File
@@ -15,7 +15,6 @@ use crate::channels::{IncomingMessage, StatusUpdate};
use crate::context::JobContext; use crate::context::JobContext;
use crate::error::Error; use crate::error::Error;
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult}; use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::redact_params; use crate::tools::redact_params;
/// Result of the agentic loop execution. /// Result of the agentic loop execution.
@@ -570,7 +569,6 @@ impl Agent {
let pf_idx = *pf_idx; let pf_idx = *pf_idx;
let tools = self.tools().clone(); let tools = self.tools().clone();
let safety = self.safety().clone(); let safety = self.safety().clone();
let idempotency_cache = self.deps.idempotency_cache.clone();
let channels = self.channels.clone(); let channels = self.channels.clone();
let job_ctx = job_ctx.clone(); let job_ctx = job_ctx.clone();
let tc = tc.clone(); let tc = tc.clone();
@@ -591,7 +589,6 @@ impl Agent {
let result = execute_chat_tool_standalone( let result = execute_chat_tool_standalone(
&tools, &tools,
&safety, &safety,
&idempotency_cache,
&tc.name, &tc.name,
&tc.arguments, &tc.arguments,
&job_ctx, &job_ctx,
@@ -862,15 +859,7 @@ impl Agent {
params: &serde_json::Value, params: &serde_json::Value,
job_ctx: &JobContext, job_ctx: &JobContext,
) -> Result<String, Error> { ) -> Result<String, Error> {
execute_chat_tool_standalone( execute_chat_tool_standalone(self.tools(), self.safety(), tool_name, params, job_ctx).await
self.tools(),
self.safety(),
&self.deps.idempotency_cache,
tool_name,
params,
job_ctx,
)
.await
} }
} }
@@ -879,11 +868,9 @@ impl Agent {
/// This standalone function enables parallel invocation from spawned JoinSet /// This standalone function enables parallel invocation from spawned JoinSet
/// tasks, which cannot borrow `&self`. It replicates the logic from /// tasks, which cannot borrow `&self`. It replicates the logic from
/// `Agent::execute_chat_tool`. /// `Agent::execute_chat_tool`.
#[allow(clippy::too_many_arguments)]
pub(super) async fn execute_chat_tool_standalone( pub(super) async fn execute_chat_tool_standalone(
tools: &crate::tools::ToolRegistry, tools: &crate::tools::ToolRegistry,
safety: &crate::safety::SafetyLayer, safety: &crate::safety::SafetyLayer,
idempotency_cache: &ToolIdempotencyCache,
tool_name: &str, tool_name: &str,
params: &serde_json::Value, params: &serde_json::Value,
job_ctx: &crate::context::JobContext, job_ctx: &crate::context::JobContext,
@@ -895,15 +882,6 @@ pub(super) async fn execute_chat_tool_standalone(
name: tool_name.to_string(), 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 // Validate tool parameters
let validation = safety.validator().validate_tool_params(params); let validation = safety.validator().validate_tool_params(params);
if !validation.is_valid { if !validation.is_valid {
@@ -975,25 +953,13 @@ pub(super) async fn execute_chat_tool_standalone(
reason: e.to_string(), reason: e.to_string(),
})?; })?;
let result_str: Result<String, Error> = serde_json::to_string_pretty(&result.result).map_err(|e| {
serde_json::to_string_pretty(&result.result).map_err(|e| { crate::error::ToolError::ExecutionFailed {
crate::error::ToolError::ExecutionFailed { name: tool_name.to_string(),
name: tool_name.to_string(), reason: format!("Failed to serialize result: {}", e),
reason: format!("Failed to serialize result: {}", e), }
} .into()
.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
} }
/// Parsed auth result fields for emitting StatusUpdate::AuthRequired. /// Parsed auth result fields for emitting StatusUpdate::AuthRequired.
@@ -1221,9 +1187,6 @@ mod tests {
http_interceptor: None, http_interceptor: None,
transcription: None, transcription: None,
document_extraction: None, document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
}; };
Agent::new( Agent::new(
@@ -1242,7 +1205,6 @@ mod tests {
max_tool_iterations: 50, max_tool_iterations: 50,
auto_approve_tools: false, auto_approve_tools: false,
default_timezone: "UTC".to_string(), default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
}, },
deps, deps,
Arc::new(ChannelManager::new()), Arc::new(ChannelManager::new()),
@@ -1301,96 +1263,6 @@ mod tests {
} }
} }
#[test]
fn test_always_approval_requirement_bypasses_session_auto_approve() {
// Regression test: even if tool is auto-approved in session,
// ApprovalRequirement::Always must still trigger approval.
use crate::tools::ApprovalRequirement;
let mut session = Session::new("user-1");
let tool_name = "tool_remove";
// Manually auto-approve tool_remove in this session
session.auto_approve_tool(tool_name);
assert!(
session.is_tool_auto_approved(tool_name),
"tool should be auto-approved"
);
// However, ApprovalRequirement::Always should always require approval
// This is verified by the dispatcher logic: Always => true (ignores session state)
let always_req = ApprovalRequirement::Always;
let requires_approval = match always_req {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
ApprovalRequirement::Always => true,
};
assert!(
requires_approval,
"ApprovalRequirement::Always must require approval even when tool is auto-approved"
);
}
#[test]
fn test_always_approval_requirement_vs_unless_auto_approved() {
// Verify the two requirements behave differently
use crate::tools::ApprovalRequirement;
let mut session = Session::new("user-2");
let tool_name = "http";
// Scenario 1: Tool is auto-approved
session.auto_approve_tool(tool_name);
// UnlessAutoApproved → doesn't require approval if auto-approved
let unless_req = ApprovalRequirement::UnlessAutoApproved;
let unless_needs = match unless_req {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
ApprovalRequirement::Always => true,
};
assert!(
!unless_needs,
"UnlessAutoApproved should not need approval when auto-approved"
);
// Always → always requires approval
let always_req = ApprovalRequirement::Always;
let always_needs = match always_req {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
ApprovalRequirement::Always => true,
};
assert!(
always_needs,
"Always must always require approval, even when auto-approved"
);
// Scenario 2: Tool is NOT auto-approved
let new_tool = "new_tool";
assert!(!session.is_tool_auto_approved(new_tool));
// UnlessAutoApproved → requires approval
let unless_needs = match unless_req {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(new_tool),
ApprovalRequirement::Always => true,
};
assert!(
unless_needs,
"UnlessAutoApproved should need approval when not auto-approved"
);
// Always → always requires approval
let always_needs = match always_req {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(new_tool),
ApprovalRequirement::Always => true,
};
assert!(always_needs, "Always must always require approval");
}
#[test] #[test]
fn test_pending_approval_serialization_backcompat_without_deferred_calls() { fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
// PendingApproval from before the deferred_tool_calls field was added // PendingApproval from before the deferred_tool_calls field was added
@@ -1557,13 +1429,9 @@ mod tests {
let job_ctx = JobContext::with_user("test", "chat", "test session"); 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( let result = super::execute_chat_tool_standalone(
&registry, &registry,
&safety, &safety,
&cache,
"echo", "echo",
&serde_json::json!({"message": "hello"}), &serde_json::json!({"message": "hello"}),
&job_ctx, &job_ctx,
@@ -1589,13 +1457,9 @@ mod tests {
}); });
let job_ctx = JobContext::with_user("test", "chat", "test session"); 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( let result = super::execute_chat_tool_standalone(
&registry, &registry,
&safety, &safety,
&cache,
"nonexistent", "nonexistent",
&serde_json::json!({}), &serde_json::json!({}),
&job_ctx, &job_ctx,
@@ -2071,9 +1935,6 @@ mod tests {
http_interceptor: None, http_interceptor: None,
transcription: None, transcription: None,
document_extraction: None, document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
}; };
Agent::new( Agent::new(
@@ -2092,7 +1953,6 @@ mod tests {
max_tool_iterations, max_tool_iterations,
auto_approve_tools: true, auto_approve_tools: true,
default_timezone: "UTC".to_string(), default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
}, },
deps, deps,
Arc::new(ChannelManager::new()), Arc::new(ChannelManager::new()),
@@ -2191,9 +2051,6 @@ mod tests {
http_interceptor: None, http_interceptor: None,
transcription: None, transcription: None,
document_extraction: None, document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
}; };
Agent::new( Agent::new(
@@ -2212,7 +2069,6 @@ mod tests {
max_tool_iterations: max_iter, max_tool_iterations: max_iter,
auto_approve_tools: true, auto_approve_tools: true,
default_timezone: "UTC".to_string(), default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
}, },
deps, deps,
Arc::new(ChannelManager::new()), Arc::new(ChannelManager::new()),
+14 -459
View File
@@ -25,14 +25,10 @@ use crate::agent::routine::{
}; };
use crate::channels::{IncomingMessage, OutgoingResponse}; use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::config::RoutineConfig; use crate::config::RoutineConfig;
use crate::context::JobContext;
use crate::db::Database; use crate::db::Database;
use crate::error::RoutineError; use crate::error::RoutineError;
use crate::llm::{ use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest, use crate::tools::ApprovalContext;
};
use crate::safety::SafetyLayer;
use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, redact_params};
use crate::workspace::Workspace; use crate::workspace::Workspace;
/// The routine execution engine. /// The routine execution engine.
@@ -49,14 +45,9 @@ pub struct RoutineEngine {
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>, event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
/// Scheduler for dispatching jobs (FullJob mode). /// Scheduler for dispatching jobs (FullJob mode).
scheduler: Option<Arc<Scheduler>>, 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 { impl RoutineEngine {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
config: RoutineConfig, config: RoutineConfig,
store: Arc<dyn Database>, store: Arc<dyn Database>,
@@ -64,8 +55,6 @@ impl RoutineEngine {
workspace: Arc<Workspace>, workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>, notify_tx: mpsc::Sender<OutgoingResponse>,
scheduler: Option<Arc<Scheduler>>, scheduler: Option<Arc<Scheduler>>,
tools: Arc<ToolRegistry>,
safety: Arc<SafetyLayer>,
) -> Self { ) -> Self {
Self { Self {
config, config,
@@ -76,8 +65,6 @@ impl RoutineEngine {
running_count: Arc::new(AtomicUsize::new(0)), running_count: Arc::new(AtomicUsize::new(0)),
event_cache: Arc::new(RwLock::new(Vec::new())), event_cache: Arc::new(RwLock::new(Vec::new())),
scheduler, scheduler,
tools,
safety,
} }
} }
@@ -253,15 +240,12 @@ impl RoutineEngine {
// Execute inline for manual triggers (caller wants to wait) // Execute inline for manual triggers (caller wants to wait)
let engine = EngineContext { let engine = EngineContext {
config: self.config.clone(),
store: self.store.clone(), store: self.store.clone(),
llm: self.llm.clone(), llm: self.llm.clone(),
workspace: self.workspace.clone(), workspace: self.workspace.clone(),
notify_tx: self.notify_tx.clone(), notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(), running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(), scheduler: self.scheduler.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
}; };
tokio::spawn(async move { tokio::spawn(async move {
@@ -288,15 +272,12 @@ impl RoutineEngine {
}; };
let engine = EngineContext { let engine = EngineContext {
config: self.config.clone(),
store: self.store.clone(), store: self.store.clone(),
llm: self.llm.clone(), llm: self.llm.clone(),
workspace: self.workspace.clone(), workspace: self.workspace.clone(),
notify_tx: self.notify_tx.clone(), notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(), running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(), scheduler: self.scheduler.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
}; };
// Record the run in DB, then spawn execution // Record the run in DB, then spawn execution
@@ -338,15 +319,12 @@ impl RoutineEngine {
/// Shared context passed to the execution function. /// Shared context passed to the execution function.
struct EngineContext { struct EngineContext {
config: RoutineConfig,
store: Arc<dyn Database>, store: Arc<dyn Database>,
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
workspace: Arc<Workspace>, workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>, notify_tx: mpsc::Sender<OutgoingResponse>,
running_count: Arc<AtomicUsize>, running_count: Arc<AtomicUsize>,
scheduler: Option<Arc<Scheduler>>, scheduler: Option<Arc<Scheduler>>,
tools: Arc<ToolRegistry>,
safety: Arc<SafetyLayer>,
} }
/// Execute a routine run. Handles both lightweight and full_job modes. /// 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)) Ok((RunStatus::Ok, Some(summary), None))
} }
/// Execute a lightweight routine with optional tool support. /// Execute a lightweight routine (single LLM call).
///
/// 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).
async fn execute_lightweight( async fn execute_lightweight(
ctx: &EngineContext, ctx: &EngineContext,
routine: &Routine, routine: &Routine,
@@ -595,7 +570,7 @@ async fn execute_lightweight(
Err(_) => None, Err(_) => None,
}; };
// Build the user-facing prompt // Build the prompt
let mut full_prompt = String::new(); let mut full_prompt = String::new();
full_prompt.push_str(prompt); 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 // Determine max_tokens from model metadata with fallback
let effective_max_tokens = match ctx.llm.model_metadata().await { let effective_max_tokens = match ctx.llm.model_metadata().await {
Ok(meta) => { Ok(meta) => {
@@ -632,45 +616,6 @@ async fn execute_lightweight(
Err(_) => max_tokens, 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) let request = CompletionRequest::new(messages)
.with_max_tokens(effective_max_tokens) .with_max_tokens(effective_max_tokens)
.with_temperature(0.3); .with_temperature(0.3);
@@ -686,7 +631,7 @@ async fn execute_lightweight_no_tools(
let content = response.content.trim(); let content = response.content.trim();
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32); 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() { if content.is_empty() {
return if response.finish_reason == FinishReason::Length { return if response.finish_reason == FinishReason::Length {
Err(RoutineError::TruncatedResponse) Err(RoutineError::TruncatedResponse)
@@ -703,269 +648,6 @@ async fn execute_lightweight_no_tools(
Ok((RunStatus::Attention, Some(content.to_string()), tokens_used)) 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. /// Send a notification based on the routine's notify config and run status.
async fn send_notification( async fn send_notification(
tx: &mpsc::Sender<OutgoingResponse>, tx: &mpsc::Sender<OutgoingResponse>,
@@ -1045,7 +727,6 @@ fn truncate(s: &str, max: usize) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::agent::routine::{NotifyConfig, RunStatus}; use crate::agent::routine::{NotifyConfig, RunStatus};
use crate::config::RoutineConfig;
#[test] #[test]
fn test_notification_gating() { fn test_notification_gating() {
@@ -1074,130 +755,4 @@ mod tests {
let _ = status.to_string(); 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);
}
} }
-23
View File
@@ -18,7 +18,6 @@ use crate::error::{Error, JobError};
use crate::hooks::HookRegistry; use crate::hooks::HookRegistry;
use crate::llm::LlmProvider; use crate::llm::LlmProvider;
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::{ApprovalContext, ToolRegistry}; use crate::tools::{ApprovalContext, ToolRegistry};
/// Message to send to a worker. /// Message to send to a worker.
@@ -59,8 +58,6 @@ pub struct Scheduler {
sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>, sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
/// HTTP interceptor for trace recording/replay (propagated to workers). /// HTTP interceptor for trace recording/replay (propagated to workers).
http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>, 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). /// Running jobs (main LLM-driven jobs).
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>, jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
/// Running sub-tasks (tool executions, background tasks). /// Running sub-tasks (tool executions, background tasks).
@@ -69,7 +66,6 @@ pub struct Scheduler {
impl Scheduler { impl Scheduler {
/// Create a new scheduler. /// Create a new scheduler.
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
config: AgentConfig, config: AgentConfig,
context_manager: Arc<ContextManager>, context_manager: Arc<ContextManager>,
@@ -78,7 +74,6 @@ impl Scheduler {
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
store: Option<Arc<dyn Database>>, store: Option<Arc<dyn Database>>,
hooks: Arc<HookRegistry>, hooks: Arc<HookRegistry>,
idempotency_cache: ToolIdempotencyCache,
) -> Self { ) -> Self {
Self { Self {
config, config,
@@ -90,7 +85,6 @@ impl Scheduler {
hooks, hooks,
sse_tx: None, sse_tx: None,
http_interceptor: None, http_interceptor: None,
idempotency_cache,
jobs: Arc::new(RwLock::new(HashMap::new())), jobs: Arc::new(RwLock::new(HashMap::new())),
subtasks: Arc::new(RwLock::new(HashMap::new())), subtasks: Arc::new(RwLock::new(HashMap::new())),
} }
@@ -166,13 +160,6 @@ impl Scheduler {
.create_job_for_user(user_id, title, description) .create_job_for_user(user_id, title, description)
.await?; .await?;
// Apply token budget from config, allowing per-job metadata override.
let max_tokens = metadata
.as_ref()
.and_then(|m| m.get("max_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(self.config.max_tokens_per_job);
// Apply metadata if provided // Apply metadata if provided
if let Some(meta) = metadata { if let Some(meta) = metadata {
self.context_manager self.context_manager
@@ -182,15 +169,6 @@ impl Scheduler {
.await?; .await?;
} }
// Set token budget (separate update to avoid overwriting metadata)
if max_tokens > 0 {
self.context_manager
.update_context(job_id, |ctx| {
ctx.max_tokens = max_tokens;
})
.await?;
}
// Persist to DB before scheduling so the worker's FK references are valid // Persist to DB before scheduling so the worker's FK references are valid
if let Some(ref store) = self.store { if let Some(ref store) = self.store {
let ctx = self.context_manager.get_context(job_id).await?; let ctx = self.context_manager.get_context(job_id).await?;
@@ -260,7 +238,6 @@ impl Scheduler {
sse_tx: self.sse_tx.clone(), sse_tx: self.sse_tx.clone(),
approval_context, approval_context,
http_interceptor: self.http_interceptor.clone(), http_interceptor: self.http_interceptor.clone(),
idempotency_cache: self.idempotency_cache.clone(),
}; };
let worker = Worker::new(job_id, deps); let worker = Worker::new(job_id, deps);
-2
View File
@@ -959,7 +959,6 @@ impl Agent {
for (spawn_idx, tc) in runnable.iter().enumerate() { for (spawn_idx, tc) in runnable.iter().enumerate() {
let tools = self.tools().clone(); let tools = self.tools().clone();
let safety = self.safety().clone(); let safety = self.safety().clone();
let idempotency_cache = self.deps.idempotency_cache.clone();
let channels = self.channels.clone(); let channels = self.channels.clone();
let job_ctx = job_ctx.clone(); let job_ctx = job_ctx.clone();
let tc = tc.clone(); let tc = tc.clone();
@@ -980,7 +979,6 @@ impl Agent {
let result = execute_chat_tool_standalone( let result = execute_chat_tool_standalone(
&tools, &tools,
&safety, &safety,
&idempotency_cache,
&tc.name, &tc.name,
&tc.arguments, &tc.arguments,
&job_ctx, &job_ctx,
+10 -152
View File
@@ -19,7 +19,6 @@ use crate::llm::{
ToolSelection, ToolSelection,
}; };
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::rate_limiter::RateLimitResult; use crate::tools::rate_limiter::RateLimitResult;
use crate::tools::{ApprovalContext, ToolRegistry, redact_params}; use crate::tools::{ApprovalContext, ToolRegistry, redact_params};
@@ -45,8 +44,6 @@ pub struct WorkerDeps {
pub approval_context: Option<ApprovalContext>, pub approval_context: Option<ApprovalContext>,
/// HTTP interceptor for trace recording/replay (propagated to JobContext). /// HTTP interceptor for trace recording/replay (propagated to JobContext).
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>, 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. /// 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(()) Ok(())
} }
@@ -426,8 +417,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
iteration += 1; iteration += 1;
if iteration > max_iterations { if iteration > max_iterations {
self.mark_failed("Maximum iterations exceeded: job hit the iteration cap") self.mark_stuck("Maximum iterations exceeded").await?;
.await?;
return Ok(()); return Ok(());
} }
@@ -447,8 +437,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
"LLM rate limited during tool selection, backing off" "LLM rate limited during tool selection, backing off"
); );
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS { if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
self.mark_failed("Persistent rate limiting: exceeded retry limit") self.mark_stuck("Persistent rate limiting").await?;
.await?;
return Ok(()); return Ok(());
} }
self.log_event( self.log_event(
@@ -478,8 +467,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
"LLM rate limited during respond_with_tools, backing off" "LLM rate limited during respond_with_tools, backing off"
); );
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS { if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
self.mark_failed("Persistent rate limiting: exceeded retry limit") self.mark_stuck("Persistent rate limiting").await?;
.await?;
return Ok(()); return Ok(());
} }
self.log_event( self.log_event(
@@ -495,20 +483,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
}; };
// Track token usage from LLM call against the job budget.
// NOTE: select_tools() also makes LLM calls but doesn't expose
// TokenUsage; only respond_with_tools() usage is tracked here.
let total_tokens = respond_output.usage.total() as u64;
if total_tokens > 0
&& let Err(msg) = self
.context_manager()
.update_context(self.job_id, |ctx| ctx.add_tokens(total_tokens))
.await?
{
self.mark_failed(&msg).await?;
return Ok(());
}
match respond_output.result { match respond_output.result {
RespondResult::Text(response) => { RespondResult::Text(response) => {
// Check for explicit completion phrases. Use word-boundary // Check for explicit completion phrases. Use word-boundary
@@ -746,8 +720,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
name: tool_name.to_string(), 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 // Check approval: use context-aware check if available, else block all non-Never tools
let requirement = tool.requires_approval(params); let requirement = tool.requires_approval(params);
let blocked = let blocked =
@@ -843,22 +815,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.into()); .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 // Redact sensitive parameter values (e.g. secret_save's "value") before
// they touch any observability or audit path. // they touch any observability or audit path.
let safe_params = redact_params(&params, tool.sensitive_params()); let safe_params = redact_params(&params, tool.sensitive_params());
@@ -994,25 +950,13 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
})?; })?;
// Return result as string // Return result as string
let result_str: Result<String, Error> = serde_json::to_string_pretty(&output.result) serde_json::to_string_pretty(&output.result).map_err(|e| {
.map_err(|e| { crate::error::ToolError::ExecutionFailed {
crate::error::ToolError::ExecutionFailed { name: tool_name.to_string(),
name: tool_name.to_string(), reason: format!("Failed to serialize result: {}", e),
reason: format!("Failed to serialize result: {}", e), }
} .into()
.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
} }
/// Process a tool execution result and add it to the reasoning context. /// Process a tool execution result and add it to the reasoning context.
@@ -1425,9 +1369,6 @@ mod tests {
sse_tx: None, sse_tx: None,
approval_context: None, approval_context: None,
http_interceptor: None, http_interceptor: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
}; };
Worker::new(job_id, deps) Worker::new(job_id, deps)
@@ -1690,9 +1631,6 @@ mod tests {
sse_tx: None, sse_tx: None,
approval_context, approval_context,
http_interceptor: None, http_interceptor: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
}; };
Worker::new(job_id, deps) Worker::new(job_id, deps)
@@ -1824,84 +1762,4 @@ mod tests {
"Always tool should be allowed with permission" "Always tool should be allowed with permission"
); );
} }
#[tokio::test]
async fn test_token_budget_exceeded_fails_job() {
let worker = make_worker(vec![]).await;
// Transition to InProgress (required for mark_failed)
worker
.context_manager()
.update_context(worker.job_id, |ctx| {
ctx.transition_to(JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
// Set a token budget
worker
.context_manager()
.update_context(worker.job_id, |ctx| {
ctx.max_tokens = 100;
})
.await
.unwrap();
// Simulate adding tokens that exceed the budget
let budget_result = worker
.context_manager()
.update_context(worker.job_id, |ctx| ctx.add_tokens(200))
.await
.unwrap();
assert!(
budget_result.is_err(),
"Should return error when token budget exceeded"
);
// Verify that mark_failed transitions job to Failed
worker
.mark_failed(&budget_result.unwrap_err())
.await
.unwrap();
let ctx = worker
.context_manager()
.get_context(worker.job_id)
.await
.unwrap();
assert_eq!(ctx.state, JobState::Failed);
}
#[tokio::test]
async fn test_iteration_cap_marks_failed_not_stuck() {
let worker = make_worker(vec![]).await;
// Transition to InProgress (required for mark_failed)
worker
.context_manager()
.update_context(worker.job_id, |ctx| {
ctx.transition_to(JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
// Simulate what the execution loop does when max_iterations is exceeded
worker
.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
.await
.unwrap();
let ctx = worker
.context_manager()
.get_context(worker.job_id)
.await
.unwrap();
assert_eq!(
ctx.state,
JobState::Failed,
"Iteration cap should transition to Failed, not Stuck"
);
}
} }
+203 -45
View File
@@ -77,7 +77,10 @@ pub struct AppBuilder {
llm_override: Option<Arc<dyn LlmProvider>>, llm_override: Option<Arc<dyn LlmProvider>>,
// Backend-specific handles needed by secrets store // 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 { impl AppBuilder {
@@ -102,7 +105,10 @@ impl AppBuilder {
db: None, db: None,
secrets_store: None, secrets_store: None,
llm_override: 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(()); return Ok(());
} }
let (db, handles) = crate::db::connect_with_handles(&self.config.database) let db: Arc<dyn Database> = match self.config.database.backend {
.await #[cfg(feature = "libsql")]
.map_err(|e| anyhow::anyhow!("{}", e))?; crate::config::DatabaseBackend::LibSql => {
self.handles = Some(handles); 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 // 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 { 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 { match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await {
Ok(db_config) => { Ok(db_config) => {
self.config = db_config; self.config = db_config;
tracing::debug!("Configuration reloaded from database"); tracing::info!("Configuration reloaded from database");
} }
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
@@ -184,7 +251,10 @@ impl AppBuilder {
crate::config::inject_os_credentials(); crate::config::inject_os_credentials();
// Consume unused handles // Consume unused handles
self.handles.take(); #[cfg(feature = "libsql")]
{
self.libsql_db.take();
}
// Re-resolve only the LLM config with OS credentials. // Re-resolve only the LLM config with OS credentials.
let store: Option<&(dyn crate::db::SettingsStore + Sync)> = let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
@@ -208,16 +278,35 @@ impl AppBuilder {
Ok(c) => Arc::new(c), Ok(c) => Arc::new(c),
Err(e) => { Err(e) => {
tracing::warn!("Failed to initialize secrets crypto: {}", e); tracing::warn!("Failed to initialize secrets crypto: {}", e);
self.handles.take(); #[cfg(feature = "libsql")]
{
self.libsql_db.take();
}
return Ok(()); return Ok(());
} }
}; };
// Fallback covers the no-database path where `init_database` returned let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
// early before populating `self.handles`.
let empty_handles = crate::db::DatabaseHandles::default(); #[cfg(feature = "libsql")]
let handles = self.handles.as_ref().unwrap_or(&empty_handles); let store = store.or_else(|| {
let store = crate::secrets::create_secrets_store(crypto, handles); 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 { if let Some(ref secrets) = store {
// Inject LLM API keys from encrypted storage // Inject LLM API keys from encrypted storage
@@ -274,7 +363,7 @@ impl AppBuilder {
anyhow::Error, anyhow::Error,
> { > {
let safety = Arc::new(SafetyLayer::new(&self.config.safety)); 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 // Initialize tool registry with credential injection support
let credential_registry = Arc::new(SharedCredentialRegistry::new()); let credential_registry = Arc::new(SharedCredentialRegistry::new());
@@ -361,7 +450,7 @@ impl AppBuilder {
tools tools
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config())) .register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await; .await;
tracing::debug!("Builder mode enabled"); tracing::info!("Builder mode enabled");
} }
Ok((safety, tools, embeddings, workspace)) Ok((safety, tools, embeddings, workspace))
@@ -383,7 +472,9 @@ impl AppBuilder {
), ),
anyhow::Error, 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}; use crate::tools::wasm::{WasmToolLoader, load_dev_tools};
let mcp_session_manager = Arc::new(McpSessionManager::new()); let mcp_session_manager = Arc::new(McpSessionManager::new());
@@ -419,7 +510,7 @@ impl AppBuilder {
match loader.load_from_dir(&wasm_config.tools_dir).await { match loader.load_from_dir(&wasm_config.tools_dir).await {
Ok(results) => { Ok(results) => {
if !results.loaded.is_empty() { if !results.loaded.is_empty() {
tracing::debug!( tracing::info!(
"Loaded {} WASM tools from {}", "Loaded {} WASM tools from {}",
results.loaded.len(), results.loaded.len(),
wasm_config.tools_dir.display() wasm_config.tools_dir.display()
@@ -442,7 +533,7 @@ impl AppBuilder {
Ok(results) => { Ok(results) => {
dev_loaded_tool_names.extend(results.loaded.iter().cloned()); dev_loaded_tool_names.extend(results.loaded.iter().cloned());
if !dev_loaded_tool_names.is_empty() { if !dev_loaded_tool_names.is_empty() {
tracing::debug!( tracing::info!(
"Loaded {} dev WASM tools from build artifacts", "Loaded {} dev WASM tools from build artifacts",
dev_loaded_tool_names.len() dev_loaded_tool_names.len()
); );
@@ -474,10 +565,7 @@ impl AppBuilder {
Ok(servers) => { Ok(servers) => {
let enabled: Vec<_> = servers.enabled_servers().cloned().collect(); let enabled: Vec<_> = servers.enabled_servers().cloned().collect();
if !enabled.is_empty() { if !enabled.is_empty() {
tracing::debug!( tracing::info!("Loading {} configured MCP server(s)...", enabled.len());
"Loading {} configured MCP server(s)...",
enabled.len()
);
} }
let mut join_set = tokio::task::JoinSet::new(); let mut join_set = tokio::task::JoinSet::new();
@@ -490,24 +578,95 @@ impl AppBuilder {
join_set.spawn(async move { join_set.spawn(async move {
let server_name = server.name.clone(); let server_name = server.name.clone();
let client = match crate::tools::mcp::create_client_from_config( let client: McpClient = match server.effective_transport() {
server, crate::tools::mcp::config::EffectiveTransport::Stdio {
&mcp_sm, command,
&pm, args,
secrets, env,
"default", } => {
) match pm
.await .spawn_stdio(
{ &server_name,
Ok(c) => c, command,
Err(e) => { 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!( tracing::warn!(
"Failed to create MCP client for '{}': {}", "Unix socket transport is not supported on this platform (server '{}')",
server_name, server_name
e
); );
return; 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 { match client.list_tools().await {
@@ -518,7 +677,7 @@ impl AppBuilder {
for tool in tool_impls { for tool in tool_impls {
tools.register(tool).await; tools.register(tool).await;
} }
tracing::debug!( tracing::info!(
"Loaded {} tools from MCP server '{}'", "Loaded {} tools from MCP server '{}'",
tool_count, tool_count,
server_name server_name
@@ -579,7 +738,7 @@ impl AppBuilder {
.iter() .iter()
.map(|m| m.to_registry_entry()) .map(|m| m.to_registry_entry())
.collect(); .collect();
tracing::debug!( tracing::info!(
count = entries.len(), count = entries.len(),
"Loaded registry catalog entries for extension discovery" "Loaded registry catalog entries for extension discovery"
); );
@@ -608,7 +767,6 @@ impl AppBuilder {
let extension_manager = { let extension_manager = {
let manager = Arc::new(ExtensionManager::new( let manager = Arc::new(ExtensionManager::new(
Arc::clone(&mcp_session_manager), Arc::clone(&mcp_session_manager),
Arc::clone(&mcp_process_manager),
ext_secrets, ext_secrets,
Arc::clone(tools), Arc::clone(tools),
Some(Arc::clone(hooks)), Some(Arc::clone(hooks)),
@@ -621,7 +779,7 @@ impl AppBuilder {
catalog_entries.clone(), catalog_entries.clone(),
)); ));
tools.register_extension_tools(Arc::clone(&manager)); 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) Some(manager)
}; };
@@ -692,7 +850,7 @@ impl AppBuilder {
let import_path = std::path::Path::new(&import_dir); let import_path = std::path::Path::new(&import_dir);
match ws.import_from_directory(import_path).await { match ws.import_from_directory(import_path).await {
Ok(count) if count > 0 => { 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(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
@@ -717,7 +875,7 @@ impl AppBuilder {
tokio::spawn(async move { tokio::spawn(async move {
match ws_bg.backfill_embeddings().await { match ws_bg.backfill_embeddings().await {
Ok(count) if count > 0 => { Ok(count) if count > 0 => {
tracing::debug!("Backfilled embeddings for {} chunks", count); tracing::info!("Backfilled embeddings for {} chunks", count);
} }
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
@@ -734,7 +892,7 @@ impl AppBuilder {
.with_installed_dir(self.config.skills.installed_dir.clone()); .with_installed_dir(self.config.skills.installed_dir.clone());
let loaded = registry.discover_all().await; let loaded = registry.discover_all().await;
if !loaded.is_empty() { 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 registry = Arc::new(std::sync::RwLock::new(registry));
let catalog = crate::skills::catalog::shared_catalog(); let catalog = crate::skills::catalog::shared_catalog();
@@ -752,7 +910,7 @@ impl AppBuilder {
}, },
)); ));
tracing::debug!( tracing::info!(
"Tool registry initialized with {} total tools", "Tool registry initialized with {} total tools",
tools.count() tools.count()
); );
-156
View File
@@ -198,58 +198,6 @@ pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> s
Ok(()) 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. /// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content.
/// ///
/// Unlike `save_bootstrap_env` (which overwrites the entire file), this /// Unlike `save_bootstrap_env` (which overwrites the entire file), this
@@ -1289,108 +1237,4 @@ INJECTED="pwned"#;
let lock = PidLock::acquire_at(pid_path).unwrap(); let lock = PidLock::acquire_at(pid_path).unwrap();
drop(lock); 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; break;
} }
} }
tracing::debug!(channel = %name, "Hot-added channel stream ended"); tracing::info!(channel = %name, "Hot-added channel stream ended");
}); });
Ok(()) Ok(())
@@ -92,7 +92,7 @@ impl ChannelManager {
for (name, channel) in channels.iter() { for (name, channel) in channels.iter() {
match channel.start().await { match channel.start().await {
Ok(stream) => { Ok(stream) => {
tracing::debug!("Started channel: {}", name); tracing::info!("Started channel: {}", name);
streams.push(stream); streams.push(stream);
} }
Err(e) => { Err(e) => {
+6 -37
View File
@@ -184,32 +184,18 @@ impl WasmChannelLoader {
/// └── telegram.capabilities.json /// └── telegram.capabilities.json
/// ``` /// ```
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmChannelError> { pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmChannelError> {
match fs::metadata(dir).await { if !dir.is_dir() {
Ok(meta) if meta.is_dir() => {} return Err(WasmChannelError::Io(std::io::Error::new(
Ok(_) => { std::io::ErrorKind::NotADirectory,
return Err(WasmChannelError::Io(std::io::Error::new( format!("{} is not a directory", dir.display()),
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)),
} }
let mut results = LoadResults::default(); let mut results = LoadResults::default();
// Collect all .wasm entries first, then load in parallel // Collect all .wasm entries first, then load in parallel
let mut channel_entries = Vec::new(); let mut channel_entries = Vec::new();
// Handle TOCTOU: if read_dir fails with NotFound, treat as empty let mut entries = fs::read_dir(dir).await?;
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)),
};
while let Some(entry) = entries.next_entry().await? { while let Some(entry) = entries.next_entry().await? {
let path = entry.path(); let path = entry.path();
@@ -500,21 +486,4 @@ mod tests {
let result = loader.load_from_files("", &wasm_path, None).await; let result = loader.load_from_files("", &wasm_path, None).await;
assert!(result.is_err()); 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 router;
mod runtime; mod runtime;
mod schema; mod schema;
pub mod setup;
pub(crate) mod signature; pub(crate) mod signature;
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) mod storage; pub(crate) mod storage;
@@ -106,5 +105,4 @@ pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeC
pub use schema::{ pub use schema::{
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema, ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
}; };
pub use setup::{WasmChannelSetup, inject_channel_credentials, setup_wasm_channels};
pub use wrapper::{HttpResponse, SharedWasmChannel, WasmChannel}; 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 -15
View File
@@ -276,25 +276,11 @@ pub async fn jobs_cancel_handler(
}))); })));
} }
// Fall back to agent job cancellation: stop the worker via the scheduler // Fall back to agent job cancellation via DB status update.
// (which updates the in-memory ContextManager AND aborts the task handle),
// then persist the status to the DB as a fallback.
if let Some(ref store) = state.store if let Some(ref store) = state.store
&& let Ok(Some(job)) = store.get_job(job_id).await && let Ok(Some(job)) = store.get_job(job_id).await
{ {
if job.state.is_active() { if job.state.is_active() {
// Try to stop via scheduler (aborts the worker task + updates
// in-memory ContextManager). This is best-effort — the job may
// not be in the scheduler map if it already finished.
if let Some(ref slot) = state.scheduler
&& let Some(ref scheduler) = *slot.read().await
{
let _ = scheduler.stop(job_id).await;
}
// Always persist cancellation to the DB so the state is
// consistent even if the scheduler wasn't available or the
// job wasn't in its in-memory map.
store store
.update_job_status( .update_job_status(
job_id, job_id,
-2
View File
@@ -108,7 +108,6 @@ pub async fn routines_detail_handler(
status: format!("{:?}", run.status), status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(), result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used, tokens_used: run.tokens_used,
job_id: run.job_id,
}) })
.collect(); .collect();
@@ -253,7 +252,6 @@ pub async fn routines_runs_handler(
status: format!("{:?}", run.status), status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(), result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used, tokens_used: run.tokens_used,
job_id: run.job_id,
}) })
.collect(); .collect();
+1 -6
View File
@@ -370,7 +370,7 @@ pub async fn start_server(
if let Err(e) = axum::serve(listener, app) if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async { .with_graceful_shutdown(async {
let _ = shutdown_rx.await; let _ = shutdown_rx.await;
tracing::debug!("Web gateway shutting down"); tracing::info!("Web gateway shutting down");
}) })
.await .await
{ {
@@ -2017,7 +2017,6 @@ async fn routines_detail_handler(
status: format!("{:?}", run.status), status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(), result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used, tokens_used: run.tokens_used,
job_id: run.job_id,
}) })
.collect(); .collect();
@@ -2170,7 +2169,6 @@ async fn routines_runs_handler(
status: format!("{:?}", run.status), status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(), result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used, tokens_used: run.tokens_used,
job_id: run.job_id,
}) })
.collect(); .collect();
@@ -2609,7 +2607,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new( let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm, mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets, secrets,
tool_registry, tool_registry,
None, None,
@@ -2659,7 +2656,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new( let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm, mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets.clone(), secrets.clone(),
tool_registry, tool_registry,
None, None,
@@ -2765,7 +2761,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new( let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm, mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets.clone(), secrets.clone(),
tool_registry, tool_registry,
None, None,
-1
View File
@@ -776,7 +776,6 @@ pub struct RoutineRunInfo {
pub status: String, pub status: String,
pub result_summary: Option<String>, pub result_summary: Option<String>,
pub tokens_used: Option<i32>, pub tokens_used: Option<i32>,
pub job_id: Option<Uuid>,
} }
// --- Settings --- // --- Settings ---
+1 -1
View File
@@ -68,7 +68,7 @@ impl WebhookServer {
if let Err(e) = axum::serve(listener, app) if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async { .with_graceful_shutdown(async {
let _ = shutdown_rx.await; let _ = shutdown_rx.await;
tracing::debug!("Webhook server shutting down"); tracing::info!("Webhook server shutting down");
}) })
.await .await
{ {
+13 -3
View File
@@ -10,7 +10,7 @@ use clap::{Args, Subcommand};
use crate::config::Config; use crate::config::Config;
use crate::db::Database; use crate::db::Database;
use crate::secrets::SecretsStore; use crate::secrets::{SecretsCrypto, SecretsStore};
use crate::tools::mcp::{ use crate::tools::mcp::{
McpClient, McpServerConfig, McpSessionManager, OAuthConfig, McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
auth::{authorize_mcp_server, is_authenticated}, auth::{authorize_mcp_server, is_authenticated},
@@ -626,9 +626,19 @@ async fn save_servers(
} }
} }
/// Initialize and return the secrets store. /// Get the secrets store for MCP authentication operations.
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> { 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)] #[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 doctor::run_doctor_command;
pub use mcp::{McpCommand, run_mcp_command}; pub use mcp::{McpCommand, run_mcp_command};
pub use memory::MemoryCommand; pub use memory::MemoryCommand;
#[cfg(feature = "postgres")]
pub use memory::run_memory_command;
pub use memory::run_memory_command_with_db; pub use memory::run_memory_command_with_db;
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store}; pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
pub use registry::{RegistryCommand, run_registry_command}; 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 status::run_status_command;
pub use tool::{ToolCommand, run_tool_command}; pub use tool::{ToolCommand, run_tool_command};
use std::sync::Arc;
use clap::{ColorChoice, Parser, Subcommand}; use clap::{ColorChoice, Parser, Subcommand};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -94,16 +94,12 @@ pub enum Command {
skip_auth: bool, skip_auth: bool,
/// Reconfigure channels only /// Reconfigure channels only
#[arg(long, conflicts_with_all = ["provider_only", "quick"])] #[arg(long, conflicts_with = "provider_only")]
channels_only: bool, channels_only: bool,
/// Reconfigure LLM provider and model only /// Reconfigure LLM provider and model only
#[arg(long, conflicts_with_all = ["channels_only", "quick"])] #[arg(long, conflicts_with = "channels_only")]
provider_only: bool, 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 /// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+12 -2
View File
@@ -10,7 +10,8 @@ use clap::Subcommand;
use tokio::fs; use tokio::fs;
use crate::bootstrap::ironclaw_base_dir; 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}; use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
/// Default tools directory. /// Default tools directory.
@@ -551,7 +552,16 @@ fn validate_tool_name(name: &str) -> anyhow::Result<()> {
/// Initialize the secrets store from environment config. /// Initialize the secrets store from environment config.
async fn init_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> { 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. /// Configure authentication for a tool.
-7
View File
@@ -29,8 +29,6 @@ pub struct AgentConfig {
pub auto_approve_tools: bool, pub auto_approve_tools: bool,
/// Default timezone for new sessions (IANA name, e.g. "America/New_York"). /// Default timezone for new sessions (IANA name, e.g. "America/New_York").
pub default_timezone: String, pub default_timezone: String,
/// Maximum tokens per job (0 = unlimited).
pub max_tokens_per_job: u64,
} }
impl AgentConfig { impl AgentConfig {
@@ -52,7 +50,6 @@ impl AgentConfig {
max_tool_iterations: 10, max_tool_iterations: 10,
auto_approve_tools: true, auto_approve_tools: true,
default_timezone: "UTC".to_string(), default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
} }
} }
@@ -108,10 +105,6 @@ impl AgentConfig {
} }
tz tz
}, },
max_tokens_per_job: parse_optional_env(
"AGENT_MAX_TOKENS_PER_JOB",
settings.agent.max_tokens_per_job,
)?,
}) })
} }
} }
+4 -4
View File
@@ -100,13 +100,13 @@ impl EmbeddingsConfig {
session: Arc<SessionManager>, session: Arc<SessionManager>,
) -> Option<Arc<dyn EmbeddingProvider>> { ) -> Option<Arc<dyn EmbeddingProvider>> {
if !self.enabled { 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; return None;
} }
match self.provider.as_str() { match self.provider.as_str() {
"nearai" => { "nearai" => {
tracing::debug!( tracing::info!(
"Embeddings enabled via NEAR AI (model: {}, dim: {})", "Embeddings enabled via NEAR AI (model: {}, dim: {})",
self.model, self.model,
self.dimension, self.dimension,
@@ -117,7 +117,7 @@ impl EmbeddingsConfig {
)) ))
} }
"ollama" => { "ollama" => {
tracing::debug!( tracing::info!(
"Embeddings enabled via Ollama (model: {}, url: {}, dim: {})", "Embeddings enabled via Ollama (model: {}, url: {}, dim: {})",
self.model, self.model,
self.ollama_base_url, self.ollama_base_url,
@@ -130,7 +130,7 @@ impl EmbeddingsConfig {
} }
_ => { _ => {
if let Some(api_key) = self.openai_api_key() { if let Some(api_key) = self.openai_api_key() {
tracing::debug!( tracing::info!(
"Embeddings enabled via OpenAI (model: {}, dim: {})", "Embeddings enabled via OpenAI (model: {}, dim: {})",
self.model, self.model,
self.dimension, self.dimension,
-10
View File
@@ -209,7 +209,6 @@ impl LlmConfig {
extra_headers_env, extra_headers_env,
api_key_required, api_key_required,
base_url_required, base_url_required,
unsupported_params,
) = if let Some(def) = def { ) = if let Some(def) = def {
( (
def.id.as_str(), def.id.as_str(),
@@ -222,7 +221,6 @@ impl LlmConfig {
def.extra_headers_env.as_deref(), def.extra_headers_env.as_deref(),
def.api_key_required, def.api_key_required,
def.base_url_required, def.base_url_required,
def.unsupported_params.clone(),
) )
} else { } else {
// Absolute fallback: treat as generic openai_completions // Absolute fallback: treat as generic openai_completions
@@ -237,7 +235,6 @@ impl LlmConfig {
Some("LLM_EXTRA_HEADERS"), Some("LLM_EXTRA_HEADERS"),
false, false,
true, true,
Vec::new(),
) )
}; };
@@ -341,7 +338,6 @@ impl LlmConfig {
extra_headers, extra_headers,
oauth_token, oauth_token,
cache_retention, cache_retention,
unsupported_params,
}) })
} }
} }
@@ -628,12 +624,6 @@ mod tests {
let provider = cfg.provider.expect("provider config should be present"); let provider = cfg.provider.expect("provider config should be present");
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1"); assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
assert_eq!(provider.model, "kimi-k2-5"); assert_eq!(provider.model, "kimi-k2-5");
assert!(
provider
.unsupported_params
.contains(&"temperature".to_string()),
"tinfoil should propagate unsupported_params from registry"
);
} }
#[test] #[test]
-9
View File
@@ -14,10 +14,6 @@ pub struct RoutineConfig {
pub default_cooldown_secs: u64, pub default_cooldown_secs: u64,
/// Max output tokens for lightweight routine LLM calls. /// Max output tokens for lightweight routine LLM calls.
pub max_lightweight_tokens: u32, 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 { impl Default for RoutineConfig {
@@ -28,23 +24,18 @@ impl Default for RoutineConfig {
max_concurrent_routines: 10, max_concurrent_routines: 10,
default_cooldown_secs: 300, default_cooldown_secs: 300,
max_lightweight_tokens: 4096, max_lightweight_tokens: 4096,
lightweight_tools_enabled: true,
lightweight_max_iterations: 3,
} }
} }
} }
impl RoutineConfig { impl RoutineConfig {
pub(crate) fn resolve() -> Result<Self, ConfigError> { pub(crate) fn resolve() -> Result<Self, ConfigError> {
let max_iterations: u32 = parse_optional_env("ROUTINES_LIGHTWEIGHT_MAX_ITERATIONS", 3)?;
Ok(Self { Ok(Self {
enabled: parse_bool_env("ROUTINES_ENABLED", true)?, enabled: parse_bool_env("ROUTINES_ENABLED", true)?,
cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?, cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?,
max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?, max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?,
default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?, default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?,
max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?, 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
}) })
} }
} }
+1 -4
View File
@@ -28,16 +28,14 @@ impl JobStore for LibSqlBackend {
r#" r#"
INSERT INTO agent_jobs ( INSERT INTO agent_jobs (
id, conversation_id, title, description, category, status, source, id, conversation_id, title, description, category, status, source,
user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs, budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, created_at, started_at, completed_at actual_cost, repair_attempts, created_at, started_at, completed_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
title = excluded.title, title = excluded.title,
description = excluded.description, description = excluded.description,
category = excluded.category, category = excluded.category,
status = excluded.status, status = excluded.status,
user_id = excluded.user_id,
estimated_cost = excluded.estimated_cost, estimated_cost = excluded.estimated_cost,
estimated_time_secs = excluded.estimated_time_secs, estimated_time_secs = excluded.estimated_time_secs,
actual_cost = excluded.actual_cost, actual_cost = excluded.actual_cost,
@@ -53,7 +51,6 @@ impl JobStore for LibSqlBackend {
opt_text(ctx.category.as_deref()), opt_text(ctx.category.as_deref()),
status, status,
"direct", "direct",
ctx.user_id.as_str(),
opt_text_owned(ctx.budget.map(|d| d.to_string())), opt_text_owned(ctx.budget.map(|d| d.to_string())),
opt_text(ctx.budget_token.as_deref()), opt_text(ctx.budget_token.as_deref()),
opt_text_owned(ctx.bid_amount.map(|d| d.to_string())), opt_text_owned(ctx.bid_amount.map(|d| d.to_string())),
-18
View File
@@ -482,24 +482,6 @@ mod tests {
assert_eq!(timeout, 5000); assert_eq!(timeout, 5000);
} }
/// Regression test: save_job must persist user_id and get_job must return it.
#[tokio::test]
async fn test_save_job_persists_user_id() {
use crate::context::JobContext;
use crate::db::JobStore;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test_user_id.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();
let ctx = JobContext::with_user("test-user-42", "Test Job", "A test job");
backend.save_job(&ctx).await.unwrap();
let loaded = backend.get_job(ctx.job_id).await.unwrap().unwrap();
assert_eq!(loaded.user_id, "test-user-42");
}
#[tokio::test] #[tokio::test]
async fn test_concurrent_writes_succeed() { async fn test_concurrent_writes_succeed() {
// Use a temp file so connections share state (in-memory DBs are connection-local) // Use a temp file so connections share state (in-memory DBs are connection-local)
+22 -40
View File
@@ -51,29 +51,6 @@ use crate::workspace::{SearchConfig, SearchResult};
pub async fn connect_from_config( pub async fn connect_from_config(
config: &crate::config::DatabaseConfig, config: &crate::config::DatabaseConfig,
) -> Result<Arc<dyn Database>, DatabaseError> { ) -> 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 { match config.backend {
#[cfg(feature = "libsql")] #[cfg(feature = "libsql")]
crate::config::DatabaseBackend::LibSql => { crate::config::DatabaseBackend::LibSql => {
@@ -97,28 +74,27 @@ pub async fn connect_with_handles(
.map_err(|e| DatabaseError::Pool(e.to_string()))? .map_err(|e| DatabaseError::Pool(e.to_string()))?
}; };
backend.run_migrations().await?; backend.run_migrations().await?;
tracing::info!("libSQL database connected and migrations applied"); Ok(Arc::new(backend))
handles.libsql_db = Some(backend.shared_db());
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
} }
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
_ => { crate::config::DatabaseBackend::Postgres => {
let pg = postgres::PgBackend::new(config) let pg = postgres::PgBackend::new(config)
.await .await
.map_err(|e| DatabaseError::Pool(e.to_string()))?; .map_err(|e| DatabaseError::Pool(e.to_string()))?;
pg.run_migrations().await?; pg.run_migrations().await?;
tracing::info!("PostgreSQL database connected and migrations applied"); Ok(Arc::new(pg))
handles.pg_pool = Some(pg.pool());
Ok((Arc::new(pg) as Arc<dyn Database>, handles))
} }
#[cfg(not(feature = "postgres"))] #[cfg(not(feature = "postgres"))]
_ => Err(DatabaseError::Pool( crate::config::DatabaseBackend::Postgres => Err(DatabaseError::Pool(
"No database backend available. Enable 'postgres' or 'libsql' feature.".to_string(), "No postgres backend available. Rebuild with --features postgres.".to_string(),
)), )),
// Catches LibSql in postgres-only builds (libsql arm compiled out)
#[allow(unreachable_patterns)]
_ => Err(DatabaseError::Pool(format!(
"Database backend {:?} not available in this build. \
Set DATABASE_BACKEND to a compiled-in backend, or rebuild with the matching feature flag.",
config.backend
))),
} }
} }
@@ -161,7 +137,7 @@ pub async fn create_secrets_store(
))) )))
} }
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
_ => { crate::config::DatabaseBackend::Postgres => {
let pg = postgres::PgBackend::new(config) let pg = postgres::PgBackend::new(config)
.await .await
.map_err(|e| DatabaseError::Pool(e.to_string()))?; .map_err(|e| DatabaseError::Pool(e.to_string()))?;
@@ -173,10 +149,16 @@ pub async fn create_secrets_store(
))) )))
} }
#[cfg(not(feature = "postgres"))] #[cfg(not(feature = "postgres"))]
_ => Err(DatabaseError::Pool( crate::config::DatabaseBackend::Postgres => Err(DatabaseError::Pool(
"No database backend available for secrets. Enable 'postgres' or 'libsql' feature." "No postgres backend available. Rebuild with --features postgres.".to_string(),
.to_string(),
)), )),
// Catches LibSql in postgres-only builds (libsql arm compiled out)
#[allow(unreachable_patterns)]
_ => Err(DatabaseError::Pool(format!(
"Database backend {:?} not available in this build. \
Set DATABASE_BACKEND to a compiled-in backend, or rebuild with the matching feature flag.",
config.backend
))),
} }
} }
+12 -16
View File
@@ -73,7 +73,6 @@ pub struct ExtensionManager {
// MCP infrastructure // MCP infrastructure
mcp_session_manager: Arc<McpSessionManager>, mcp_session_manager: Arc<McpSessionManager>,
mcp_process_manager: Arc<crate::tools::mcp::process::McpProcessManager>,
/// Active MCP clients keyed by server name. /// Active MCP clients keyed by server name.
mcp_clients: RwLock<HashMap<String, Arc<McpClient>>>, mcp_clients: RwLock<HashMap<String, Arc<McpClient>>>,
@@ -117,7 +116,6 @@ impl ExtensionManager {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
mcp_session_manager: Arc<McpSessionManager>, mcp_session_manager: Arc<McpSessionManager>,
mcp_process_manager: Arc<crate::tools::mcp::process::McpProcessManager>,
secrets: Arc<dyn SecretsStore + Send + Sync>, secrets: Arc<dyn SecretsStore + Send + Sync>,
tool_registry: Arc<ToolRegistry>, tool_registry: Arc<ToolRegistry>,
hooks: Option<Arc<HookRegistry>>, hooks: Option<Arc<HookRegistry>>,
@@ -138,7 +136,6 @@ impl ExtensionManager {
registry, registry,
discovery: OnlineDiscovery::new(), discovery: OnlineDiscovery::new(),
mcp_session_manager, mcp_session_manager,
mcp_process_manager,
mcp_clients: RwLock::new(HashMap::new()), mcp_clients: RwLock::new(HashMap::new()),
wasm_tool_runtime, wasm_tool_runtime,
wasm_tools_dir, wasm_tools_dir,
@@ -2470,15 +2467,18 @@ impl ExtensionManager {
.await .await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?; .map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
let client = crate::tools::mcp::create_client_from_config( let has_tokens = is_authenticated(&server, &self.secrets, &self.user_id).await;
server.clone(),
&self.mcp_session_manager, let client = if has_tokens || server.requires_auth() {
&self.mcp_process_manager, McpClient::new_authenticated(
Some(Arc::clone(&self.secrets)), server.clone(),
&self.user_id, Arc::clone(&self.mcp_session_manager),
) Arc::clone(&self.secrets),
.await &self.user_id,
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?; )
} else {
McpClient::new_with_config(server.clone())
};
// Try to list and create tools // Try to list and create tools
let mcp_tools = client let mcp_tools = client
@@ -3736,7 +3736,6 @@ mod tests {
tools_dir: std::path::PathBuf, tools_dir: std::path::PathBuf,
) -> crate::extensions::manager::ExtensionManager { ) -> crate::extensions::manager::ExtensionManager {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto}; use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
use crate::tools::mcp::process::McpProcessManager;
use crate::tools::mcp::session::McpSessionManager; use crate::tools::mcp::session::McpSessionManager;
let key = secrecy::SecretString::from(crate::secrets::keychain::generate_master_key_hex()); let key = secrecy::SecretString::from(crate::secrets::keychain::generate_master_key_hex());
@@ -3748,7 +3747,6 @@ mod tests {
crate::extensions::manager::ExtensionManager::new( crate::extensions::manager::ExtensionManager::new(
mcp, mcp,
Arc::new(McpProcessManager::new()),
secrets, secrets,
tools, tools,
None, // hooks None, // hooks
@@ -3908,7 +3906,6 @@ mod tests {
) -> ExtensionManager { ) -> ExtensionManager {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto}; use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
use crate::tools::mcp::process::McpProcessManager;
use crate::tools::mcp::session::McpSessionManager; use crate::tools::mcp::session::McpSessionManager;
std::fs::create_dir_all(&tools_dir).ok(); std::fs::create_dir_all(&tools_dir).ok();
@@ -3920,7 +3917,6 @@ mod tests {
ExtensionManager::new( ExtensionManager::new(
Arc::new(McpSessionManager::new()), Arc::new(McpSessionManager::new()),
Arc::new(McpProcessManager::new()),
Arc::new(InMemorySecretsStore::new(crypto)), Arc::new(InMemorySecretsStore::new(crypto)),
Arc::new(ToolRegistry::new()), Arc::new(ToolRegistry::new()),
None, None,
+1 -36
View File
@@ -149,16 +149,14 @@ impl Store {
r#" r#"
INSERT INTO agent_jobs ( INSERT INTO agent_jobs (
id, conversation_id, title, description, category, status, source, id, conversation_id, title, description, category, status, source,
user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs, budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, created_at, started_at, completed_at actual_cost, repair_attempts, created_at, started_at, completed_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title, title = EXCLUDED.title,
description = EXCLUDED.description, description = EXCLUDED.description,
category = EXCLUDED.category, category = EXCLUDED.category,
status = EXCLUDED.status, status = EXCLUDED.status,
user_id = EXCLUDED.user_id,
estimated_cost = EXCLUDED.estimated_cost, estimated_cost = EXCLUDED.estimated_cost,
estimated_time_secs = EXCLUDED.estimated_time_secs, estimated_time_secs = EXCLUDED.estimated_time_secs,
actual_cost = EXCLUDED.actual_cost, actual_cost = EXCLUDED.actual_cost,
@@ -174,7 +172,6 @@ impl Store {
&ctx.category, &ctx.category,
&status, &status,
&"direct", // source &"direct", // source
&ctx.user_id,
&ctx.budget, &ctx.budget,
&ctx.budget_token, &ctx.budget_token,
&ctx.bid_amount, &ctx.bid_amount,
@@ -2136,36 +2133,4 @@ mod tests {
assert_eq!(summary.channel, ch); assert_eq!(summary.channel, ch);
} }
} }
/// Regression test: save_job must persist user_id and get_job must return it.
/// Requires a running PostgreSQL instance (integration tier).
#[cfg(feature = "postgres")]
#[tokio::test]
#[ignore]
async fn test_save_job_persists_user_id() {
use crate::config::Config;
use crate::context::JobContext;
let _ = dotenvy::dotenv();
let config = Config::from_env().await.expect("Failed to load config");
let store = Store::new(&config.database)
.await
.expect("Failed to connect to database");
store
.run_migrations()
.await
.expect("Failed to run migrations");
let ctx = JobContext::with_user("test-user-42", "PG user_id test", "regression test");
store.save_job(&ctx).await.unwrap();
let loaded = store.get_job(ctx.job_id).await.unwrap().unwrap();
assert_eq!(loaded.user_id, "test-user-42");
// Clean up
let conn = store.conn().await.unwrap();
conn.execute("DELETE FROM agent_jobs WHERE id = $1", &[&ctx.job_id])
.await
.unwrap();
}
} }
+4 -40
View File
@@ -6,8 +6,6 @@
//! //!
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`. //! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
use std::collections::HashSet;
use async_trait::async_trait; use async_trait::async_trait;
use reqwest::Client; use reqwest::Client;
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -37,8 +35,6 @@ pub struct AnthropicOAuthProvider {
model: String, model: String,
base_url: Option<String>, base_url: Option<String>,
active_model: std::sync::RwLock<String>, active_model: std::sync::RwLock<String>,
/// Parameter names that this provider does not support.
unsupported_params: HashSet<String>,
} }
impl AnthropicOAuthProvider { impl AnthropicOAuthProvider {
@@ -65,45 +61,15 @@ impl AnthropicOAuthProvider {
Some(config.base_url.clone()) Some(config.base_url.clone())
}; };
let unsupported_params: HashSet<String> =
config.unsupported_params.iter().cloned().collect();
Ok(Self { Ok(Self {
client, client,
token, token,
model: config.model.clone(), model: config.model.clone(),
base_url, base_url,
active_model, 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 { fn api_url(&self) -> String {
if let Some(ref base) = self.base_url { if let Some(ref base) = self.base_url {
let base = base.trim_end_matches('/'); let base = base.trim_end_matches('/');
@@ -231,9 +197,8 @@ impl AnthropicOAuthProvider {
#[async_trait] #[async_trait]
impl LlmProvider for AnthropicOAuthProvider { impl LlmProvider for AnthropicOAuthProvider {
async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> { async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let model = req.model.take().unwrap_or_else(|| self.active_model_name()); let model = req.model.unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_completion_params(&mut req);
let (system, messages) = convert_messages(req.messages); let (system, messages) = convert_messages(req.messages);
let request = AnthropicRequest { let request = AnthropicRequest {
@@ -268,10 +233,9 @@ impl LlmProvider for AnthropicOAuthProvider {
async fn complete_with_tools( async fn complete_with_tools(
&self, &self,
mut req: ToolCompletionRequest, req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> { ) -> Result<ToolCompletionResponse, LlmError> {
let model = req.model.take().unwrap_or_else(|| self.active_model_name()); let model = req.model.unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_tool_params(&mut req);
let (system, messages) = convert_messages(req.messages); let (system, messages) = convert_messages(req.messages);
let tools: Vec<AnthropicTool> = req let tools: Vec<AnthropicTool> = req
-4
View File
@@ -87,10 +87,6 @@ pub struct RegistryProviderConfig {
pub oauth_token: Option<SecretString>, pub oauth_token: Option<SecretString>,
/// Prompt cache retention (Anthropic-specific). /// Prompt cache retention (Anthropic-specific).
pub cache_retention: CacheRetention, 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). /// Configuration for AWS Bedrock (native Converse API).
+17 -23
View File
@@ -117,7 +117,7 @@ pub fn create_llm_provider_with_config(
} else { } else {
"session token" "session token"
}; };
tracing::debug!( tracing::info!(
model = %config.model, model = %config.model,
base_url = %config.base_url, base_url = %config.base_url,
auth = auth_mode, 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?; let provider = bedrock::BedrockProvider::new(br).await?;
tracing::debug!( tracing::info!(
"Using AWS Bedrock (Converse API, region: {}, model: {})", "Using AWS Bedrock (Converse API, region: {}, model: {})",
br.region, br.region,
provider.active_model_name(), provider.active_model_name(),
@@ -221,16 +221,14 @@ fn create_openai_compat_from_registry(
let client = client.completions_api(); let client = client.completions_api();
let model = client.completion_model(&config.model); let model = client.completion_model(&config.model);
tracing::debug!( tracing::info!(
provider = %config.provider_id, provider = %config.provider_id,
model = %config.model, model = %config.model,
base_url = %config.base_url, base_url = %config.base_url,
"Using OpenAI-compatible provider" "Using OpenAI-compatible provider"
); );
let adapter = RigAdapter::new(model, &config.model) Ok(Arc::new(RigAdapter::new(model, &config.model)))
.with_unsupported_params(config.unsupported_params.clone());
Ok(Arc::new(adapter))
} }
fn create_anthropic_from_registry( fn create_anthropic_from_registry(
@@ -244,7 +242,7 @@ fn create_anthropic_from_registry(
.as_ref() .as_ref()
.is_some_and(|k| k.expose_secret() == crate::llm::config::OAUTH_PLACEHOLDER); .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) { if config.oauth_token.is_some() && (config.api_key.is_none() || api_key_is_placeholder) {
tracing::debug!( tracing::info!(
provider = %config.provider_id, provider = %config.provider_id,
model = %config.model, model = %config.model,
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url }, 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); let model = client.completion_model(&config.model);
if cache_retention != CacheRetention::None { if cache_retention != CacheRetention::None {
tracing::debug!( tracing::info!(
model = %config.model, model = %config.model,
retention = %cache_retention, retention = %cache_retention,
"Anthropic automatic prompt caching enabled" "Anthropic automatic prompt caching enabled"
); );
} }
tracing::debug!( tracing::info!(
provider = %config.provider_id, provider = %config.provider_id,
model = %config.model, model = %config.model,
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url }, 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( Ok(Arc::new(
RigAdapter::new(model, &config.model) RigAdapter::new(model, &config.model).with_cache_retention(cache_retention),
.with_cache_retention(cache_retention)
.with_unsupported_params(config.unsupported_params.clone()),
)) ))
} }
@@ -321,16 +317,14 @@ fn create_ollama_from_registry(
let model = client.completion_model(&config.model); let model = client.completion_model(&config.model);
tracing::debug!( tracing::info!(
provider = %config.provider_id, provider = %config.provider_id,
model = %config.model, model = %config.model,
base_url = %config.base_url, base_url = %config.base_url,
"Using Ollama provider" "Using Ollama provider"
); );
let adapter = RigAdapter::new(model, &config.model) Ok(Arc::new(RigAdapter::new(model, &config.model)))
.with_unsupported_params(config.unsupported_params.clone());
Ok(Arc::new(adapter))
} }
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation). /// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
@@ -391,14 +385,14 @@ pub async fn build_provider_chain(
LlmError, LlmError,
> { > {
let llm = create_llm_provider(config, session.clone()).await?; 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 // 1. Retry
let retry_config = RetryConfig { let retry_config = RetryConfig {
max_retries: config.nearai.max_retries, max_retries: config.nearai.max_retries,
}; };
let llm: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 { let llm: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
tracing::debug!( tracing::info!(
max_retries = retry_config.max_retries, max_retries = retry_config.max_retries,
"LLM retry wrapper enabled" "LLM retry wrapper enabled"
); );
@@ -421,7 +415,7 @@ pub async fn build_provider_chain(
} else { } else {
cheap cheap
}; };
tracing::debug!( tracing::info!(
primary = %llm.model_name(), primary = %llm.model_name(),
cheap = %cheap.model_name(), cheap = %cheap.model_name(),
"Smart routing enabled" "Smart routing enabled"
@@ -452,7 +446,7 @@ pub async fn build_provider_chain(
session.clone(), session.clone(),
config.request_timeout_secs, config.request_timeout_secs,
)?; )?;
tracing::debug!( tracing::info!(
primary = %llm.model_name(), primary = %llm.model_name(),
fallback = %fallback.model_name(), fallback = %fallback.model_name(),
"LLM failover enabled" "LLM failover enabled"
@@ -484,7 +478,7 @@ pub async fn build_provider_chain(
), ),
..CircuitBreakerConfig::default() ..CircuitBreakerConfig::default()
}; };
tracing::debug!( tracing::info!(
threshold, threshold,
recovery_secs = config.nearai.circuit_breaker_recovery_secs, recovery_secs = config.nearai.circuit_breaker_recovery_secs,
"LLM circuit breaker enabled" "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), ttl: std::time::Duration::from_secs(config.nearai.response_cache_ttl_secs),
max_entries: config.nearai.response_cache_max_entries, max_entries: config.nearai.response_cache_max_entries,
}; };
tracing::debug!( tracing::info!(
ttl_secs = config.nearai.response_cache_ttl_secs, ttl_secs = config.nearai.response_cache_ttl_secs,
max_entries = config.nearai.response_cache_max_entries, max_entries = config.nearai.response_cache_max_entries,
"LLM response cache enabled" "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) // Standalone cheap LLM for heartbeat/evaluation (not part of the chain)
let cheap_llm = create_cheap_llm_provider(config, session)?; let cheap_llm = create_cheap_llm_provider(config, session)?;
if let Some(ref cheap) = cheap_llm { 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)) Ok((llm, cheap_llm, recording_handle))
+1 -1
View File
@@ -110,7 +110,7 @@ impl NearAiChatProvider {
handle.spawn(async move { handle.spawn(async move {
match fetch_pricing(&client, &base_url, api_key.as_ref(), &session).await { match fetch_pricing(&client, &base_url, api_key.as_ref(), &session).await {
Ok(map) if !map.is_empty() => { 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() { match pricing.write() {
Ok(mut guard) => *guard = map, Ok(mut guard) => *guard = map,
Err(poisoned) => *poisoned.into_inner() = map, Err(poisoned) => *poisoned.into_inner() = map,
-56
View File
@@ -152,11 +152,6 @@ pub struct ProviderDefinition {
/// Setup wizard hints. /// Setup wizard hints.
#[serde(default)] #[serde(default)]
pub setup: Option<SetupHint>, 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. /// Registry of known LLM providers.
@@ -383,7 +378,6 @@ mod tests {
description: "Custom tinfoil".to_string(), description: "Custom tinfoil".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, setup: None,
unsupported_params: vec![],
}); });
let registry = ProviderRegistry::new(all); let registry = ProviderRegistry::new(all);
let tf = registry.find("tinfoil").expect("tinfoil should exist"); let tf = registry.find("tinfoil").expect("tinfoil should exist");
@@ -523,7 +517,6 @@ mod tests {
description: "No setup".to_string(), description: "No setup".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, // no setup hint setup: None, // no setup hint
unsupported_params: vec![],
}]; }];
let registry = ProviderRegistry::new(providers.clone()); let registry = ProviderRegistry::new(providers.clone());
@@ -553,7 +546,6 @@ mod tests {
can_list_models: false, can_list_models: false,
models_filter: None, models_filter: None,
}), }),
unsupported_params: vec![],
}); });
let registry = ProviderRegistry::new(providers); let registry = ProviderRegistry::new(providers);
@@ -595,7 +587,6 @@ mod tests {
can_list_models: false, can_list_models: false,
models_filter: None, models_filter: None,
}), }),
unsupported_params: vec![],
}, },
// User override removes setup // User override removes setup
ProviderDefinition { ProviderDefinition {
@@ -612,7 +603,6 @@ mod tests {
description: "No setup now".to_string(), description: "No setup now".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, setup: None,
unsupported_params: vec![],
}, },
]; ];
@@ -650,7 +640,6 @@ mod tests {
display_name: "A".to_string(), display_name: "A".to_string(),
can_list_models: false, can_list_models: false,
}), }),
unsupported_params: vec![],
}, },
ProviderDefinition { ProviderDefinition {
id: "bbb".to_string(), id: "bbb".to_string(),
@@ -669,7 +658,6 @@ mod tests {
display_name: "B".to_string(), display_name: "B".to_string(),
can_list_models: false, can_list_models: false,
}), }),
unsupported_params: vec![],
}, },
ProviderDefinition { ProviderDefinition {
id: "ccc".to_string(), id: "ccc".to_string(),
@@ -688,7 +676,6 @@ mod tests {
display_name: "C".to_string(), display_name: "C".to_string(),
can_list_models: false, can_list_models: false,
}), }),
unsupported_params: vec![],
}, },
// User override for B // User override for B
ProviderDefinition { ProviderDefinition {
@@ -708,7 +695,6 @@ mod tests {
display_name: "B".to_string(), display_name: "B".to_string(),
can_list_models: false, 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] #[test]
fn test_all_builtin_api_key_providers_have_api_key_env() { fn test_all_builtin_api_key_providers_have_api_key_env() {
// Every built-in provider with SetupHint::ApiKey must 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 /// via `additional_params` for Anthropic automatic caching. Also controls
/// the cost multiplier for cache-creation tokens. /// the cost multiplier for cache-creation tokens.
cache_retention: CacheRetention, 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> { impl<M: CompletionModel> RigAdapter<M> {
@@ -59,7 +56,6 @@ impl<M: CompletionModel> RigAdapter<M> {
input_cost, input_cost,
output_cost, output_cost,
cache_retention: CacheRetention::None, cache_retention: CacheRetention::None,
unsupported_params: HashSet::new(),
} }
} }
@@ -88,44 +84,6 @@ impl<M: CompletionModel> RigAdapter<M> {
} }
self 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 -- // -- Type conversion helpers --
@@ -581,10 +539,7 @@ where
} }
} }
async fn complete( async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
&self,
mut request: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
if let Some(requested_model) = request.model.as_deref() if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str() && requested_model != self.model_name.as_str()
{ {
@@ -595,8 +550,6 @@ where
); );
} }
self.strip_unsupported_completion_params(&mut request);
let mut messages = request.messages; let mut messages = request.messages;
crate::llm::provider::sanitize_tool_messages(&mut messages); crate::llm::provider::sanitize_tool_messages(&mut messages);
let (preamble, history) = convert_messages(&messages); let (preamble, history) = convert_messages(&messages);
@@ -646,7 +599,7 @@ where
async fn complete_with_tools( async fn complete_with_tools(
&self, &self,
mut request: ToolCompletionRequest, request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> { ) -> Result<ToolCompletionResponse, LlmError> {
if let Some(requested_model) = request.model.as_deref() if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str() && 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> = let known_tool_names: HashSet<String> =
request.tools.iter().map(|t| t.name.clone()).collect(); 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("gpt-4o"));
assert!(!supports_prompt_cache("llama3")); 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 std::time::Duration;
use clap::Parser; use clap::Parser;
use tracing_subscriber::EnvFilter;
use ironclaw::{ use ironclaw::{
agent::{Agent, AgentDeps}, agent::{Agent, AgentDeps},
@@ -11,7 +12,10 @@ use ironclaw::{
channels::{ channels::{
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, SignalChannel, WebhookServer, ChannelManager, GatewayChannel, HttpChannel, ReplChannel, SignalChannel, WebhookServer,
WebhookServerConfig, WebhookServerConfig,
wasm::{WasmChannelRouter, WasmChannelRuntime}, wasm::{
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
},
web::log_layer::LogBroadcaster, web::log_layer::LogBroadcaster,
}, },
cli::{ cli::{
@@ -21,14 +25,26 @@ use ironclaw::{
config::Config, config::Config,
hooks::bootstrap_hooks, hooks::bootstrap_hooks,
llm::create_session_manager, llm::create_session_manager,
orchestrator::{ReaperConfig, SandboxReaper}, orchestrator::{
ContainerJobConfig, ContainerJobManager, OrchestratorApi, ReaperConfig, SandboxReaper,
TokenStore, api::OrchestratorState,
},
pairing::PairingStore, pairing::PairingStore,
tracing_fmt::{init_cli_tracing, init_worker_tracing}, secrets::SecretsStore,
}; };
#[cfg(any(feature = "postgres", feature = "libsql"))] #[cfg(any(feature = "postgres", feature = "libsql"))]
use ironclaw::setup::{SetupConfig, SetupWizard}; 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 /// Synchronous entry point. Loads `.env` files before the Tokio runtime
/// starts so that `std::env::set_var` is safe (no worker threads yet). /// starts so that `std::env::set_var` is safe (no worker threads yet).
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
@@ -64,7 +80,7 @@ async fn async_main() -> anyhow::Result<()> {
} }
Some(Command::Memory(mem_cmd)) => { Some(Command::Memory(mem_cmd)) => {
init_cli_tracing(); init_cli_tracing();
return ironclaw::cli::run_memory_command(mem_cmd).await; return run_memory_command(mem_cmd).await;
} }
Some(Command::Pairing(pairing_cmd)) => { Some(Command::Pairing(pairing_cmd)) => {
init_cli_tracing(); init_cli_tracing();
@@ -92,7 +108,7 @@ async fn async_main() -> anyhow::Result<()> {
max_iterations, max_iterations,
}) => { }) => {
init_worker_tracing(); 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 { Some(Command::ClaudeBridge {
job_id, job_id,
@@ -101,19 +117,12 @@ async fn async_main() -> anyhow::Result<()> {
model, model,
}) => { }) => {
init_worker_tracing(); init_worker_tracing();
return ironclaw::worker::run_claude_bridge( return run_claude_bridge(*job_id, orchestrator_url, *max_turns, model).await;
*job_id,
orchestrator_url,
*max_turns,
model,
)
.await;
} }
Some(Command::Onboard { Some(Command::Onboard {
skip_auth, skip_auth,
channels_only, channels_only,
provider_only, provider_only,
quick,
}) => { }) => {
#[cfg(any(feature = "postgres", feature = "libsql"))] #[cfg(any(feature = "postgres", feature = "libsql"))]
{ {
@@ -121,14 +130,13 @@ async fn async_main() -> anyhow::Result<()> {
skip_auth: *skip_auth, skip_auth: *skip_auth,
channels_only: *channels_only, channels_only: *channels_only,
provider_only: *provider_only, provider_only: *provider_only,
quick: *quick,
}; };
let mut wizard = SetupWizard::with_config(config); let mut wizard = SetupWizard::with_config(config);
wizard.run().await?; wizard.run().await?;
} }
#[cfg(not(any(feature = "postgres", feature = "libsql")))] #[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."); eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
} }
return Ok(()); return Ok(());
@@ -161,14 +169,11 @@ async fn async_main() -> anyhow::Result<()> {
// Enhanced first-run detection // Enhanced first-run detection
#[cfg(any(feature = "postgres", feature = "libsql"))] #[cfg(any(feature = "postgres", feature = "libsql"))]
if !cli.no_onboard if !cli.no_onboard
&& let Some(reason) = ironclaw::setup::check_onboard_needed() && let Some(reason) = check_onboard_needed()
{ {
println!("Onboarding needed: {}", reason); println!("Onboarding needed: {}", reason);
println!(); println!();
let mut wizard = SetupWizard::with_config(SetupConfig { let mut wizard = SetupWizard::new();
quick: true,
..Default::default()
});
wizard.run().await?; wizard.run().await?;
} }
@@ -201,9 +206,9 @@ async fn async_main() -> anyhow::Result<()> {
let log_level_handle = let log_level_handle =
ironclaw::channels::web::log_layer::init_tracing(Arc::clone(&log_broadcaster)); ironclaw::channels::web::log_layer::init_tracing(Arc::clone(&log_broadcaster));
tracing::debug!("Starting IronClaw..."); tracing::info!("Starting IronClaw...");
tracing::debug!("Loaded configuration for agent: {}", config.agent.name); tracing::info!("Loaded configuration for agent: {}", config.agent.name);
tracing::debug!("LLM backend: {}", config.llm.backend); tracing::info!("LLM backend: {}", config.llm.backend);
// ── Phase 1-5: Build all core components via AppBuilder ──────────── // ── Phase 1-5: Build all core components via AppBuilder ────────────
@@ -222,21 +227,95 @@ async fn async_main() -> anyhow::Result<()> {
// ── Tunnel setup ─────────────────────────────────────────────────── // ── 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 ──────────────────────────── // ── Orchestrator / container job manager ────────────────────────────
let orch = ironclaw::orchestrator::setup_orchestrator( // Proactive Docker detection
&config, let docker_status = if config.sandbox.enabled {
&components.llm, let detection = ironclaw::sandbox::check_docker().await;
components.db.as_ref(), match detection.status {
components.secrets_store.as_ref(), ironclaw::sandbox::DockerStatus::Available => {
) tracing::info!("Docker is available");
.await; }
let container_job_manager = orch.container_job_manager; ironclaw::sandbox::DockerStatus::NotInstalled => {
let job_event_tx = orch.job_event_tx; tracing::warn!(
let prompt_queue = orch.prompt_queue; "Docker is not installed -- sandbox disabled for this session. {}",
let docker_status = orch.docker_status; 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 ────────────────────────────────────────────────── // ── Channel setup ──────────────────────────────────────────────────
@@ -264,10 +343,10 @@ async fn async_main() -> anyhow::Result<()> {
if let Some(repl) = repl_channel { if let Some(repl) = repl_channel {
channels.add(Box::new(repl)).await; channels.add(Box::new(repl)).await;
if cli.message.is_some() { if cli.message.is_some() {
tracing::debug!("Single message mode"); tracing::info!("Single message mode");
} else { } else {
channel_names.push("repl".to_string()); 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. // Load WASM channels and register their webhook routes.
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() { 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, &config,
&components.secrets_store, &components.secrets_store,
components.extension_manager.as_ref(), components.extension_manager.as_ref(),
@@ -309,7 +388,7 @@ async fn async_main() -> anyhow::Result<()> {
channel_names.push("signal".to_string()); channel_names.push("signal".to_string());
channels.add(Box::new(signal_channel)).await; channels.add(Box::new(signal_channel)).await;
let safe_url = SignalChannel::redact_url(&signal_config.http_url); let safe_url = SignalChannel::redact_url(&signal_config.http_url);
tracing::debug!( tracing::info!(
url = %safe_url, url = %safe_url,
"Signal channel enabled" "Signal channel enabled"
); );
@@ -335,7 +414,7 @@ async fn async_main() -> anyhow::Result<()> {
); );
channel_names.push("http".to_string()); channel_names.push("http".to_string());
channels.add(Box::new(http_channel)).await; channels.add(Box::new(http_channel)).await;
tracing::debug!( tracing::info!(
"HTTP channel enabled on {}:{}", "HTTP channel enabled on {}:{}",
http_config.host, http_config.host,
http_config.port http_config.port
@@ -376,7 +455,7 @@ async fn async_main() -> anyhow::Result<()> {
&components.dev_loaded_tool_names, &components.dev_loaded_tool_names,
) )
.await; .await;
tracing::debug!( tracing::info!(
bundled = hook_bootstrap.bundled_hooks, bundled = hook_bootstrap.bundled_hooks,
plugin = hook_bootstrap.plugin_hooks, plugin = hook_bootstrap.plugin_hooks,
workspace = hook_bootstrap.workspace_hooks, workspace = hook_bootstrap.workspace_hooks,
@@ -469,7 +548,7 @@ async fn async_main() -> anyhow::Result<()> {
gw.auth_token() 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. // Capture SSE sender and routine engine slot before moving gw into channels.
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state` // 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(), config.channels.wasm_channel_owner_ids.clone(),
) )
.await; .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. // Auto-activate channels that were active in a previous session.
let persisted = ext_mgr.load_persisted_active_channels().await; 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) { if !active_at_startup.contains(name) {
match ext_mgr.activate(name).await { match ext_mgr.activate(name).await {
Ok(result) => { Ok(result) => {
tracing::debug!( tracing::info!(
channel = %name, channel = %name,
message = %result.message, message = %result.message,
"Auto-activated persisted channel" "Auto-activated persisted channel"
@@ -623,9 +702,6 @@ async fn async_main() -> anyhow::Result<()> {
document_extraction: Some(Arc::new( document_extraction: Some(Arc::new(
ironclaw::document_extraction::DocumentExtractionMiddleware::new(), ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
)), )),
idempotency_cache: ironclaw::tools::idempotency::ToolIdempotencyCache::new(
ironclaw::tools::idempotency::IdempotencyCacheConfig::default(),
),
}; };
let mut agent = Agent::new( let mut agent = Agent::new(
@@ -683,13 +759,485 @@ async fn async_main() -> anyhow::Result<()> {
} }
if let Some(tunnel) = active_tunnel { 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 { if let Err(e) = tunnel.stop().await {
tracing::warn!("Failed to stop tunnel cleanly: {}", e); tracing::warn!("Failed to stop tunnel cleanly: {}", e);
} }
} }
tracing::debug!("Agent shutdown complete"); tracing::info!("Agent shutdown complete");
Ok(()) 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, CompletionResult, ContainerHandle, ContainerJobConfig, ContainerJobManager, JobMode,
}; };
pub use reaper::{ReaperConfig, SandboxReaper}; 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 self.initialized
.store(false, std::sync::atomic::Ordering::SeqCst); .store(false, std::sync::atomic::Ordering::SeqCst);
tracing::debug!("Sandbox shut down"); tracing::info!("Sandbox shut down");
} }
/// Execute a command in the sandbox. /// Execute a command in the sandbox.
+1 -1
View File
@@ -154,7 +154,7 @@ impl HttpProxy {
} }
} }
_ = &mut shutdown_rx => { _ = &mut shutdown_rx => {
tracing::debug!("Sandbox proxy shutting down"); tracing::info!("Sandbox proxy shutting down");
break; break;
} }
} }
-34
View File
@@ -75,37 +75,3 @@ pub use types::{
}; };
pub use store::in_memory::InMemorySecretsStore; 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
}
-5
View File
@@ -386,10 +386,6 @@ pub struct AgentSettings {
/// Default timezone for new sessions (IANA name, e.g. "America/New_York"). /// Default timezone for new sessions (IANA name, e.g. "America/New_York").
#[serde(default = "default_timezone")] #[serde(default = "default_timezone")]
pub default_timezone: String, pub default_timezone: String,
/// Maximum tokens per job (0 = unlimited).
#[serde(default)]
pub max_tokens_per_job: u64,
} }
fn default_agent_name() -> String { fn default_agent_name() -> String {
@@ -446,7 +442,6 @@ impl Default for AgentSettings {
max_tool_iterations: default_max_tool_iterations(), max_tool_iterations: default_max_tool_iterations(),
auto_approve_tools: false, auto_approve_tools: false,
default_timezone: default_timezone(), default_timezone: default_timezone(),
max_tokens_per_job: 0,
} }
} }
} }
+3 -40
View File
@@ -10,7 +10,7 @@ file first, then adjust the code to match.
## Entry Points ## 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. 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 - `LIBSQL_PATH` env var is set
- `~/.ironclaw/ironclaw.db` exists on disk - `~/.ironclaw/ironclaw.db` exists on disk
Auto-triggered onboarding uses **quick mode** by default.
The `--no-onboard` CLI flag suppresses auto-detection. The `--no-onboard` CLI flag suppresses auto-detection.
--- ---
@@ -52,41 +50,7 @@ The `--no-onboard` CLI flag suppresses auto-detection.
--- ---
## Quick Mode ## The 8-Step Wizard
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
### Overview ### Overview
@@ -98,8 +62,7 @@ Step 4: Model Selection
Step 5: Embeddings Step 5: Embeddings
Step 6: Channel Configuration Step 6: Channel Configuration
Step 7: Extensions (tools) Step 7: Extensions (tools)
Step 8: Docker Sandbox Step 8: Background Tasks (heartbeat)
Step 9: Background Tasks (heartbeat)
save_and_summarize() save_and_summarize()
``` ```
-32
View File
@@ -31,35 +31,3 @@ pub use prompts::{
}; };
#[cfg(any(feature = "postgres", feature = "libsql"))] #[cfg(any(feature = "postgres", feature = "libsql"))]
pub use wizard::{SetupConfig, SetupWizard}; 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
}
+37 -307
View File
@@ -76,8 +76,6 @@ pub struct SetupConfig {
pub channels_only: bool, pub channels_only: bool,
/// Only reconfigure LLM provider and model selection. /// Only reconfigure LLM provider and model selection.
pub provider_only: bool, pub provider_only: bool,
/// Quick setup: auto-defaults everything except LLM provider and model.
pub quick: bool,
} }
/// Interactive setup wizard for IronClaw. /// Interactive setup wizard for IronClaw.
@@ -156,26 +154,6 @@ impl SetupWizard {
print_step(1, 2, "Inference Provider"); print_step(1, 2, "Inference Provider");
self.step_inference_provider().await?; self.step_inference_provider().await?;
self.persist_after_step().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"); print_step(2, 2, "Model Selection");
self.step_model_selection().await?; self.step_model_selection().await?;
self.persist_after_step().await; self.persist_after_step().await;
@@ -681,10 +659,7 @@ impl SetupWizard {
use refinery::embed_migrations; use refinery::embed_migrations;
embed_migrations!("migrations"); embed_migrations!("migrations");
if !self.config.quick { print_info("Running migrations...");
print_info("Running migrations...");
}
tracing::debug!("Running PostgreSQL migrations...");
let mut client = pool let mut client = pool
.get() .get()
@@ -696,10 +671,7 @@ impl SetupWizard {
.await .await
.map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?;
if !self.config.quick { print_success("Migrations applied");
print_success("Migrations applied");
}
tracing::debug!("PostgreSQL migrations applied");
} }
Ok(()) Ok(())
} }
@@ -710,20 +682,14 @@ impl SetupWizard {
if let Some(ref backend) = self.db_backend { if let Some(ref backend) = self.db_backend {
use crate::db::Database; use crate::db::Database;
if !self.config.quick { print_info("Running migrations...");
print_info("Running migrations...");
}
tracing::debug!("Running libSQL migrations...");
backend backend
.run_migrations() .run_migrations()
.await .await
.map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?;
if !self.config.quick { print_success("Migrations applied");
print_success("Migrations applied");
}
tracing::debug!("libSQL migrations applied");
} }
Ok(()) Ok(())
} }
@@ -838,140 +804,6 @@ impl SetupWizard {
Ok(()) 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. /// Step 3: Inference provider selection.
/// ///
/// Uses the provider registry to dynamically build the selection menu. /// Uses the provider registry to dynamically build the selection menu.
@@ -1741,18 +1573,46 @@ impl SetupWizard {
} }
/// Fetch available models from the NEAR AI API. /// Fetch available models from the NEAR AI API.
///
/// Uses [`build_nearai_model_fetch_config`] to construct the provider config,
/// which reads `NEARAI_API_KEY` from the environment when present.
async fn fetch_nearai_models(&self) -> Vec<String> { async fn fetch_nearai_models(&self) -> Vec<String> {
let session = match self.session_manager { let session = match self.session_manager {
Some(ref s) => Arc::clone(s), Some(ref s) => Arc::clone(s),
None => return vec![], None => return vec![],
}; };
use crate::config::LlmConfig;
use crate::llm::create_llm_provider; use crate::llm::create_llm_provider;
let config = build_nearai_model_fetch_config(); 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());
let config = LlmConfig {
backend: "nearai".to_string(),
session: crate::llm::session::SessionConfig {
auth_base_url,
session_path: crate::config::llm::default_session_path(),
},
nearai: crate::config::NearAiConfig {
model: "dummy".to_string(),
cheap_model: None,
base_url,
api_key: None,
fallback_model: None,
max_retries: 3,
circuit_breaker_threshold: None,
circuit_breaker_recovery_secs: 30,
response_cache_enabled: false,
response_cache_ttl_secs: 3600,
response_cache_max_entries: 1000,
failover_cooldown_secs: 300,
failover_cooldown_threshold: 3,
smart_routing_cascade: true,
},
provider: None,
bedrock: None,
request_timeout_secs: 120,
};
match create_llm_provider(&config, session).await { match create_llm_provider(&config, session).await {
Ok(provider) => match provider.list_models().await { Ok(provider) => match provider.list_models().await {
@@ -2674,7 +2534,7 @@ impl SetupWizard {
.iter() .iter()
.map(|(k, v)| (k.as_str(), v.as_str())) .map(|(k, v)| (k.as_str(), v.as_str()))
.collect(); .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!( SetupError::Io(std::io::Error::other(format!(
"Failed to save bootstrap env to .env: {}", "Failed to save bootstrap env to .env: {}",
e e
@@ -2946,13 +2806,6 @@ impl SetupWizard {
println!(" ironclaw onboard"); println!(" ironclaw onboard");
println!(); println!();
if self.config.quick {
print_info(
"Tip: Run `ironclaw onboard` to configure channels, extensions, embeddings, and more.",
);
println!();
}
Ok(()) Ok(())
} }
} }
@@ -3387,58 +3240,6 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa
/// Mask an API key for display: show first 6 + last 4 chars. /// Mask an API key for display: show first 6 + last 4 chars.
/// ///
/// Uses char-based indexing to avoid panicking on multi-byte UTF-8. /// Uses char-based indexing to avoid panicking on multi-byte UTF-8.
/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models.
///
/// 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 {
// 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.
let api_key = std::env::var("NEARAI_API_KEY")
.ok()
.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 {
auth_base_url,
session_path: crate::config::llm::default_session_path(),
},
nearai: crate::config::NearAiConfig {
model: "dummy".to_string(),
cheap_model: None,
base_url,
api_key,
fallback_model: None,
max_retries: 3,
circuit_breaker_threshold: None,
circuit_breaker_recovery_secs: 30,
response_cache_enabled: false,
response_cache_ttl_secs: 3600,
response_cache_max_entries: 1000,
failover_cooldown_secs: 300,
failover_cooldown_threshold: 3,
smart_routing_cascade: true,
},
provider: None,
bedrock: None,
request_timeout_secs: 120,
}
}
fn mask_api_key(key: &str) -> String { fn mask_api_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect(); let chars: Vec<char> = key.chars().collect();
if chars.len() < 12 { if chars.len() < 12 {
@@ -3647,7 +3448,6 @@ mod tests {
use tempfile::tempdir; use tempfile::tempdir;
use super::*; use super::*;
use crate::config::helpers::ENV_MUTEX;
#[test] #[test]
fn test_wizard_creation() { fn test_wizard_creation() {
@@ -3662,7 +3462,6 @@ mod tests {
skip_auth: true, skip_auth: true,
channels_only: false, channels_only: false,
provider_only: false, provider_only: false,
quick: false,
}; };
let wizard = SetupWizard::with_config(config); let wizard = SetupWizard::with_config(config);
assert!(wizard.config.skip_auth); assert!(wizard.config.skip_auth);
@@ -3841,14 +3640,6 @@ mod tests {
} }
impl EnvGuard { impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let original = std::env::var(key).ok();
unsafe {
std::env::set_var(key, value);
}
Self { key, original }
}
fn clear(key: &'static str) -> Self { fn clear(key: &'static str) -> Self {
let original = std::env::var(key).ok(); let original = std::env::var(key).ok();
unsafe { unsafe {
@@ -3996,7 +3787,6 @@ mod tests {
description: "Custom provider with no setup wizard".to_string(), description: "Custom provider with no setup wizard".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, setup: None,
unsupported_params: vec![],
}); });
let registry = crate::llm::ProviderRegistry::new(providers); let registry = crate::llm::ProviderRegistry::new(providers);
@@ -4036,64 +3826,4 @@ mod tests {
}; };
assert!(settings.secrets_master_key_hex.is_some()); assert!(settings.secrets_master_key_hex.is_some());
} }
/// Regression test for #799: `fetch_nearai_models` hardcoded `api_key: None`,
/// causing the auth prompt to re-appear during model selection when the user
/// had authenticated via NEAR AI Cloud API key (option 4).
#[test]
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!(
config.nearai.api_key.is_some(),
"config should include NEARAI_API_KEY from env"
);
assert_eq!(
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();
assert!(
config.nearai.api_key.is_none(),
"config should have no api_key when env var is empty"
);
}
} }
-3
View File
@@ -453,9 +453,6 @@ impl TestHarnessBuilder {
http_interceptor: None, http_interceptor: None,
transcription: None, transcription: None,
document_extraction: None, document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
}; };
TestHarness { TestHarness {
-4
View File
@@ -46,8 +46,4 @@ impl Tool for EchoTool {
fn requires_sanitization(&self) -> bool { fn requires_sanitization(&self) -> bool {
false // Internal tool, no external data false // Internal tool, no external data
} }
fn is_idempotent(&self) -> bool {
true
}
} }
+4 -33
View File
@@ -451,8 +451,8 @@ impl Tool for ToolRemoveTool {
} }
fn description(&self) -> &str { fn description(&self) -> &str {
"Permanently remove an installed extension (channel, tool, or MCP server) from disk. \ "Remove an installed extension (channel, tool, or MCP server). \
This action cannot be undone the WASM binary and configuration files will be deleted." Unregisters tools and deletes configuration."
} }
fn parameters_schema(&self) -> serde_json::Value { fn parameters_schema(&self) -> serde_json::Value {
@@ -492,7 +492,7 @@ impl Tool for ToolRemoveTool {
} }
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement { fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::Always ApprovalRequirement::UnlessAutoApproved
} }
} }
@@ -701,38 +701,10 @@ mod tests {
assert_eq!(tool.name(), "tool_remove"); assert_eq!(tool.name(), "tool_remove");
assert_eq!( assert_eq!(
tool.requires_approval(&serde_json::json!({})), tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Always ApprovalRequirement::UnlessAutoApproved
); );
} }
#[test]
fn tool_remove_always_requires_approval_regardless_of_params() {
use crate::tools::tool::ApprovalRequirement;
let tool = ToolRemoveTool {
manager: test_manager_stub(),
};
let test_cases = vec![
("no params", serde_json::json!({})),
("empty name", serde_json::json!({"name": ""})),
("slack", serde_json::json!({"name": "slack"})),
("github-cli", serde_json::json!({"name": "github-cli"})),
(
"with extra fields",
serde_json::json!({"name": "tool", "extra": "field"}),
),
];
for (case_name, params) in test_cases {
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::Always,
"tool_remove must always require approval for case: {}",
case_name
);
}
}
#[test] #[test]
fn test_tool_upgrade_schema() { fn test_tool_upgrade_schema() {
use crate::tools::tool::ApprovalRequirement; use crate::tools::tool::ApprovalRequirement;
@@ -777,7 +749,6 @@ mod tests {
Arc::new(ExtensionManager::new( Arc::new(ExtensionManager::new(
Arc::new(McpSessionManager::new()), Arc::new(McpSessionManager::new()),
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
Arc::new(InMemorySecretsStore::new(crypto)), Arc::new(InMemorySecretsStore::new(crypto)),
Arc::new(ToolRegistry::new()), Arc::new(ToolRegistry::new()),
None, None,
-4
View File
@@ -132,10 +132,6 @@ impl Tool for JsonTool {
fn requires_sanitization(&self) -> bool { fn requires_sanitization(&self) -> bool {
false // Internal tool, no external data 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> { fn parse_json_input(data: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
+3 -33
View File
@@ -709,8 +709,7 @@ impl Tool for SkillRemoveTool {
} }
fn description(&self) -> &str { fn description(&self) -> &str {
"Permanently remove an installed skill from disk. This action cannot be undone — \ "Remove an installed skill by name. Only user-installed skills can be removed."
the skill files will be deleted."
} }
fn parameters_schema(&self) -> serde_json::Value { fn parameters_schema(&self) -> serde_json::Value {
@@ -771,7 +770,7 @@ impl Tool for SkillRemoveTool {
} }
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement { fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::Always ApprovalRequirement::UnlessAutoApproved
} }
} }
@@ -838,41 +837,12 @@ mod tests {
assert_eq!(tool.name(), "skill_remove"); assert_eq!(tool.name(), "skill_remove");
assert_eq!( assert_eq!(
tool.requires_approval(&serde_json::json!({})), tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Always ApprovalRequirement::UnlessAutoApproved
); );
let schema = tool.parameters_schema(); let schema = tool.parameters_schema();
assert!(schema["properties"].get("name").is_some()); assert!(schema["properties"].get("name").is_some());
} }
#[test]
fn skill_remove_always_requires_approval_regardless_of_params() {
use crate::tools::tool::ApprovalRequirement;
let tool = SkillRemoveTool::new(test_registry());
let test_cases = vec![
("no params", serde_json::json!({})),
("empty name", serde_json::json!({"name": ""})),
(
"deployment skill",
serde_json::json!({"name": "deployment"}),
),
("custom skill", serde_json::json!({"name": "custom-skill"})),
(
"with extra fields",
serde_json::json!({"name": "skill", "extra": "field"}),
),
];
for (case_name, params) in test_cases {
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::Always,
"skill_remove must always require approval for case: {}",
case_name
);
}
}
#[test] #[test]
fn test_validate_fetch_url_allows_https() { fn test_validate_fetch_url_allows_https() {
assert!(super::validate_fetch_url("https://clawhub.ai/api/v1/download?slug=foo").is_ok()); assert!(super::validate_fetch_url("https://clawhub.ai/api/v1/download?slug=foo").is_ok());
-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; pub mod auth;
mod client; mod client;
pub mod config; pub mod config;
pub mod factory;
pub(crate) mod http_transport; pub(crate) mod http_transport;
pub(crate) mod process; pub(crate) mod process;
mod protocol; mod protocol;
@@ -44,7 +43,6 @@ pub(crate) mod unix_transport;
pub use auth::{is_authenticated, refresh_access_token}; pub use auth::{is_authenticated, refresh_access_token};
pub use client::McpClient; pub use client::McpClient;
pub use config::{McpServerConfig, McpServersFile, OAuthConfig}; pub use config::{McpServerConfig, McpServersFile, OAuthConfig};
pub use factory::{McpFactoryError, create_client_from_config};
pub use process::McpProcessManager; pub use process::McpProcessManager;
pub use protocol::{InitializeResult, McpRequest, McpResponse, McpTool}; pub use protocol::{InitializeResult, McpRequest, McpResponse, McpTool};
pub use session::McpSessionManager; pub use session::McpSessionManager;
-1
View File
@@ -9,7 +9,6 @@
pub mod builder; pub mod builder;
pub mod builtin; pub mod builtin;
pub mod idempotency;
pub mod mcp; pub mod mcp;
pub mod rate_limiter; pub mod rate_limiter;
pub mod schema_validator; pub mod schema_validator;
+14 -14
View File
@@ -241,7 +241,7 @@ impl ToolRegistry {
} }
self.register_sync(Arc::new(http)); 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). /// 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(ListDirTool::new()));
self.register_sync(Arc::new(ApplyPatchTool::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. /// 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(MemoryReadTool::new(Arc::clone(&workspace))));
self.register_sync(Arc::new(MemoryTreeTool::new(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. /// Register job management tools.
@@ -364,7 +364,7 @@ impl ToolRegistry {
job_tool_count += 1; 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). /// Register secret management tools (list, delete).
@@ -378,7 +378,7 @@ impl ToolRegistry {
use crate::tools::builtin::{SecretDeleteTool, SecretListTool}; use crate::tools::builtin::{SecretDeleteTool, SecretListTool};
self.register_sync(Arc::new(SecretListTool::new(Arc::clone(&store)))); self.register_sync(Arc::new(SecretListTool::new(Arc::clone(&store))));
self.register_sync(Arc::new(SecretDeleteTool::new(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). /// 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(ToolRemoveTool::new(Arc::clone(&manager))));
self.register_sync(Arc::new(ToolUpgradeTool::new(Arc::clone(&manager)))); self.register_sync(Arc::new(ToolUpgradeTool::new(Arc::clone(&manager))));
self.register_sync(Arc::new(ExtensionInfoTool::new(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). /// Register skill management tools (list, search, install, remove).
@@ -414,7 +414,7 @@ impl ToolRegistry {
Arc::clone(&catalog), Arc::clone(&catalog),
))); )));
self.register_sync(Arc::new(SkillRemoveTool::new(registry))); 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. /// Register routine management tools.
@@ -448,7 +448,7 @@ impl ToolRegistry {
Arc::clone(&engine), Arc::clone(&engine),
))); )));
self.register_sync(Arc::new(RoutineHistoryTool::new(store))); 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. /// Register message tool for sending messages to channels.
@@ -467,7 +467,7 @@ impl ToolRegistry {
.write() .write()
.await .await
.insert("message".to_string()); .insert("message".to_string());
tracing::debug!("Registered message tool"); tracing::info!("Registered message tool");
} }
/// Set the default channel and target for the message tool. /// Set the default channel and target for the message tool.
@@ -501,7 +501,7 @@ impl ToolRegistry {
gen_model, gen_model,
base_dir, base_dir,
))); )));
tracing::debug!("Registered 2 image tools (generate, edit)"); tracing::info!("Registered 2 image tools (generate, edit)");
} }
/// Register vision/image analysis tools. /// Register vision/image analysis tools.
@@ -521,7 +521,7 @@ impl ToolRegistry {
vision_model, vision_model,
base_dir, base_dir,
))); )));
tracing::debug!("Registered 1 vision tool (analyze)"); tracing::info!("Registered 1 vision tool (analyze)");
} }
/// Register the software builder tool. /// Register the software builder tool.
@@ -549,7 +549,7 @@ impl ToolRegistry {
self.register(Arc::new(BuildSoftwareTool::new(builder))) self.register(Arc::new(BuildSoftwareTool::new(builder)))
.await; .await;
tracing::debug!("Registered software builder tool"); tracing::info!("Registered software builder tool");
} }
/// Register a WASM tool from bytes. /// 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(()) Ok(())
} }
@@ -676,7 +676,7 @@ impl ToolRegistry {
.await .await
.map_err(WasmRegistrationError::Wasm)?; .map_err(WasmRegistrationError::Wasm)?;
tracing::debug!( tracing::info!(
name = tool_with_binary.tool.name, name = tool_with_binary.tool.name,
user_id = user_id, user_id = user_id,
trust_level = %tool_with_binary.tool.trust_level, 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. /// Per-invocation rate limit for this tool.
/// ///
/// Return `Some(config)` to throttle how often this tool can be called per user. /// 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). /// Tools without a capabilities file get no permissions (default deny).
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmLoadError> { pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmLoadError> {
match fs::metadata(dir).await { if !dir.is_dir() {
Ok(meta) if meta.is_dir() => {} return Err(WasmLoadError::Io(std::io::Error::new(
Ok(_) => { std::io::ErrorKind::NotADirectory,
return Err(WasmLoadError::Io(std::io::Error::new( format!("{} is not a directory", dir.display()),
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)),
} }
// 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(); let mut results = LoadResults::default();
// Collect all .wasm entries first, then load in parallel
let mut tool_entries = Vec::new(); let mut tool_entries = Vec::new();
let mut entries = fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? { while let Some(entry) = entries.next_entry().await? {
let path = entry.path(); let path = entry.path();
@@ -1090,19 +1077,4 @@ mod tests {
"nested.wasm inside subdir should NOT be discovered" "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 std::io::{self, Write};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::MakeWriter; 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. /// Maximum bytes per tracing event written to the terminal.
const TERMINAL_MAX_EVENT_BYTES: usize = 500; 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 ──────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
-58
View File
@@ -33,61 +33,3 @@ pub use api::WorkerHttpClient;
pub use claude_bridge::ClaudeBridgeRuntime; pub use claude_bridge::ClaudeBridgeRuntime;
pub use proxy_llm::ProxyLlmProvider; pub use proxy_llm::ProxyLlmProvider;
pub use runtime::WorkerRuntime; 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::routine_engine::RoutineEngine;
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner}; use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner};
use ironclaw::channels::IncomingMessage; use ironclaw::channels::IncomingMessage;
use ironclaw::config::{RoutineConfig, SafetyConfig}; use ironclaw::config::RoutineConfig;
use ironclaw::db::Database; use ironclaw::db::Database;
use ironclaw::safety::SafetyLayer;
use ironclaw::tools::ToolRegistry;
use ironclaw::workspace::Workspace; use ironclaw::workspace::Workspace;
use ironclaw::workspace::hygiene::HygieneConfig; use ironclaw::workspace::hygiene::HygieneConfig;
@@ -105,14 +103,6 @@ mod tests {
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::channel(16); 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( let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(), RoutineConfig::default(),
db.clone(), db.clone(),
@@ -120,8 +110,6 @@ mod tests {
ws, ws,
notify_tx, notify_tx,
None, None,
tools,
safety,
)); ));
// Insert a cron routine with next_fire_at in the past. // 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 llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16); 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( let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(), RoutineConfig::default(),
db.clone(), db.clone(),
@@ -197,8 +177,6 @@ mod tests {
ws, ws,
notify_tx, notify_tx,
None, None,
tools,
safety,
)); ));
// Insert an event routine matching "deploy.*production". // Insert an event routine matching "deploy.*production".
@@ -280,14 +258,6 @@ mod tests {
let llm = Arc::new(TraceLlm::from_trace(trace)); let llm = Arc::new(TraceLlm::from_trace(trace));
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16); 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( let engine = Arc::new(RoutineEngine::new(
RoutineConfig::default(), RoutineConfig::default(),
db.clone(), db.clone(),
@@ -295,8 +265,6 @@ mod tests {
ws, ws,
notify_tx, notify_tx,
None, None,
tools,
safety,
)); ));
// Insert an event routine with 1-hour cooldown. // 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), Arc::clone(ws),
notify_tx, notify_tx,
None, None,
components.tools.clone(),
components.safety.clone(),
)); ));
components components
.tools .tools
@@ -624,9 +622,6 @@ impl TestRigBuilder {
}, },
transcription: None, transcription: None,
document_extraction: None, document_extraction: None,
idempotency_cache: ironclaw::tools::idempotency::ToolIdempotencyCache::new(
ironclaw::tools::idempotency::IdempotencyCacheConfig::default(),
),
}; };
// 7. Create TestChannel and ChannelManager. // 7. Create TestChannel and ChannelManager.
@@ -649,8 +644,6 @@ impl TestRigBuilder {
max_concurrent_routines: 3, max_concurrent_routines: 3,
default_cooldown_secs: 300, default_cooldown_secs: 300,
max_lightweight_tokens: 4096, max_lightweight_tokens: 4096,
lightweight_tools_enabled: true,
lightweight_max_iterations: 3,
}) })
} else { } else {
None None