mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e2e0f516 | ||
|
|
3d4ccd884e | ||
|
|
a268790b88 | ||
|
|
3a2989d009 | ||
|
|
94d101924e |
@@ -406,10 +406,6 @@ jobs:
|
|||||||
echo "passed=true" >> "$GITHUB_OUTPUT"
|
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Only merge PRs targeting main. Chained PRs (targeting another
|
|
||||||
# promotion branch) stay open — when the base PR merges into main,
|
|
||||||
# GitHub auto-retargets the chained PR. Merging chained PRs would
|
|
||||||
# trigger delete_branch_on_merge, auto-closing downstream PRs.
|
|
||||||
- name: Merge promotion PR
|
- name: Merge promotion PR
|
||||||
id: merge
|
id: merge
|
||||||
if: steps.evaluate.outputs.passed == 'true'
|
if: steps.evaluate.outputs.passed == 'true'
|
||||||
@@ -418,15 +414,12 @@ jobs:
|
|||||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||||
run: |
|
run: |
|
||||||
if [ -n "$PR_NUMBER" ]; then
|
if [ -n "$PR_NUMBER" ]; then
|
||||||
BASE=$(gh pr view "$PR_NUMBER" --json baseRefName --jq '.baseRefName')
|
echo "Merging promotion PR #${PR_NUMBER}"
|
||||||
if [ "$BASE" = "main" ]; then
|
# Do NOT use --delete-branch: deleting a promotion branch closes
|
||||||
echo "Merging promotion PR #${PR_NUMBER} (targets main)"
|
# any chained PRs that use it as their base (verified in ironclaw-ci-test).
|
||||||
gh pr merge "$PR_NUMBER" --merge
|
# Stale promotion branches are cleaned up separately.
|
||||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
gh pr merge "$PR_NUMBER" --merge
|
||||||
else
|
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "PR #${PR_NUMBER} targets '${BASE}' (not main) — leaving open for chain resolution"
|
|
||||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Update tested tag (always, so next batch covers only new commits) ──
|
# ── Update tested tag (always, so next batch covers only new commits) ──
|
||||||
@@ -444,7 +437,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: staging
|
ref: staging
|
||||||
fetch-depth: 0
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Update staging-tested tag
|
- name: Update staging-tested tag
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ 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)
|
||||||
@@ -76,7 +83,13 @@ 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 (cloudflare, ngrok, tailscale, custom, none)
|
├── tunnel/ # Tunnel abstraction for public internet exposure
|
||||||
|
│ ├── mod.rs # Tunnel trait, TunnelProviderConfig, create_tunnel(), start_managed_tunnel()
|
||||||
|
│ ├── cloudflare.rs # CloudflareTunnel (cloudflared binary)
|
||||||
|
│ ├── ngrok.rs # NgrokTunnel
|
||||||
|
│ ├── tailscale.rs # TailscaleTunnel (serve/funnel modes)
|
||||||
|
│ ├── custom.rs # CustomTunnel (arbitrary command with {host}/{port})
|
||||||
|
│ └── none.rs # NoneTunnel (local-only, no exposure)
|
||||||
│
|
│
|
||||||
├── observability/ # Pluggable event/metric recording (noop, log, multi)
|
├── observability/ # Pluggable event/metric recording (noop, log, multi)
|
||||||
│
|
│
|
||||||
@@ -105,8 +118,26 @@ 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
|
||||||
│ ├── mcp/ # Model Context Protocol client
|
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
||||||
│ └── wasm/ # Full WASM sandbox (wasmtime) — runtime, host functions, fuel metering, allowlist, credential injection
|
│ │ ├── templates.rs # Project scaffolding
|
||||||
|
│ │ ├── testing.rs # Test harness integration
|
||||||
|
│ │ └── validation.rs # WASM validation
|
||||||
|
│ ├── mcp/ # Model Context Protocol
|
||||||
|
│ │ ├── client.rs # MCP client over HTTP
|
||||||
|
│ │ ├── factory.rs # create_client_from_config() — transport dispatch factory
|
||||||
|
│ │ ├── protocol.rs # JSON-RPC types
|
||||||
|
│ │ └── session.rs # MCP session management (Mcp-Session-Id header, per-server state)
|
||||||
|
│ └── wasm/ # Full WASM sandbox (wasmtime)
|
||||||
|
│ ├── runtime.rs # Module compilation and caching
|
||||||
|
│ ├── wrapper.rs # Tool trait wrapper for WASM modules
|
||||||
|
│ ├── host.rs # Host functions (logging, time, workspace)
|
||||||
|
│ ├── limits.rs # Fuel metering and memory limiting
|
||||||
|
│ ├── allowlist.rs # Network endpoint allowlisting
|
||||||
|
│ ├── credential_injector.rs # Safe credential injection
|
||||||
|
│ ├── loader.rs # WASM tool discovery from filesystem
|
||||||
|
│ ├── rate_limiter.rs # Per-tool rate limiting
|
||||||
|
│ ├── error.rs # WASM-specific error types
|
||||||
|
│ └── storage.rs # Linear memory persistence
|
||||||
│
|
│
|
||||||
├── db/ # Dual-backend persistence (PostgreSQL + libSQL) — see src/db/CLAUDE.md
|
├── db/ # Dual-backend persistence (PostgreSQL + libSQL) — see src/db/CLAUDE.md
|
||||||
│
|
│
|
||||||
@@ -144,6 +175,8 @@ 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
@@ -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
@@ -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"
|
||||||
|
|||||||
+1
-3
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -516,7 +516,7 @@ impl Agent {
|
|||||||
*slot.write().await = Some(Arc::clone(&engine));
|
*slot.write().await = Some(Arc::clone(&engine));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
"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
|
||||||
@@ -538,20 +538,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::info!("Agent {} ready and listening", self.config.name);
|
tracing::debug!("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::info!("Ctrl+C received, shutting down...");
|
tracing::debug!("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::info!("All channel streams ended, shutting down...");
|
tracing::debug!("All channel streams ended, shutting down...");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -626,7 +626,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// Shutdown signal received (/quit, /exit, /shutdown)
|
// Shutdown signal received (/quit, /exit, /shutdown)
|
||||||
tracing::info!("Shutdown command received, exiting...");
|
tracing::debug!("Shutdown command received, exiting...");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -655,7 +655,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
tracing::info!("Agent shutting down...");
|
tracing::debug!("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 {
|
||||||
|
|||||||
+45
-203
@@ -77,10 +77,7 @@ 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
|
||||||
#[cfg(feature = "postgres")]
|
handles: Option<crate::db::DatabaseHandles>,
|
||||||
pg_pool: Option<deadpool_postgres::Pool>,
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
libsql_db: Option<Arc<libsql::Database>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppBuilder {
|
impl AppBuilder {
|
||||||
@@ -105,10 +102,7 @@ impl AppBuilder {
|
|||||||
db: None,
|
db: None,
|
||||||
secrets_store: None,
|
secrets_store: None,
|
||||||
llm_override: None,
|
llm_override: None,
|
||||||
#[cfg(feature = "postgres")]
|
handles: None,
|
||||||
pg_pool: None,
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
libsql_db: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,71 +131,10 @@ impl AppBuilder {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let db: Arc<dyn Database> = match self.config.database.backend {
|
let (db, handles) = crate::db::connect_with_handles(&self.config.database)
|
||||||
#[cfg(feature = "libsql")]
|
.await
|
||||||
crate::config::DatabaseBackend::LibSql => {
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
use crate::db::Database as _;
|
self.handles = Some(handles);
|
||||||
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 {
|
||||||
@@ -212,7 +145,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::info!("Configuration reloaded from database");
|
tracing::debug!("Configuration reloaded from database");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -251,10 +184,7 @@ impl AppBuilder {
|
|||||||
crate::config::inject_os_credentials();
|
crate::config::inject_os_credentials();
|
||||||
|
|
||||||
// Consume unused handles
|
// Consume unused handles
|
||||||
#[cfg(feature = "libsql")]
|
self.handles.take();
|
||||||
{
|
|
||||||
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)> =
|
||||||
@@ -278,35 +208,16 @@ 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);
|
||||||
#[cfg(feature = "libsql")]
|
self.handles.take();
|
||||||
{
|
|
||||||
self.libsql_db.take();
|
|
||||||
}
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
|
// Fallback covers the no-database path where `init_database` returned
|
||||||
|
// early before populating `self.handles`.
|
||||||
#[cfg(feature = "libsql")]
|
let empty_handles = crate::db::DatabaseHandles::default();
|
||||||
let store = store.or_else(|| {
|
let handles = self.handles.as_ref().unwrap_or(&empty_handles);
|
||||||
self.libsql_db.take().map(|db| {
|
let store = crate::secrets::create_secrets_store(crypto, handles);
|
||||||
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
|
||||||
@@ -363,7 +274,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::info!("Safety layer initialized");
|
tracing::debug!("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());
|
||||||
@@ -450,7 +361,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::info!("Builder mode enabled");
|
tracing::debug!("Builder mode enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((safety, tools, embeddings, workspace))
|
Ok((safety, tools, embeddings, workspace))
|
||||||
@@ -472,9 +383,7 @@ impl AppBuilder {
|
|||||||
),
|
),
|
||||||
anyhow::Error,
|
anyhow::Error,
|
||||||
> {
|
> {
|
||||||
use crate::tools::mcp::{
|
use crate::tools::mcp::config::load_mcp_servers_from_db;
|
||||||
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());
|
||||||
@@ -510,7 +419,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::info!(
|
tracing::debug!(
|
||||||
"Loaded {} WASM tools from {}",
|
"Loaded {} WASM tools from {}",
|
||||||
results.loaded.len(),
|
results.loaded.len(),
|
||||||
wasm_config.tools_dir.display()
|
wasm_config.tools_dir.display()
|
||||||
@@ -533,7 +442,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::info!(
|
tracing::debug!(
|
||||||
"Loaded {} dev WASM tools from build artifacts",
|
"Loaded {} dev WASM tools from build artifacts",
|
||||||
dev_loaded_tool_names.len()
|
dev_loaded_tool_names.len()
|
||||||
);
|
);
|
||||||
@@ -565,7 +474,10 @@ 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::info!("Loading {} configured MCP server(s)...", enabled.len());
|
tracing::debug!(
|
||||||
|
"Loading {} configured MCP server(s)...",
|
||||||
|
enabled.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut join_set = tokio::task::JoinSet::new();
|
let mut join_set = tokio::task::JoinSet::new();
|
||||||
@@ -578,95 +490,24 @@ 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: McpClient = match server.effective_transport() {
|
let client = match crate::tools::mcp::create_client_from_config(
|
||||||
crate::tools::mcp::config::EffectiveTransport::Stdio {
|
server,
|
||||||
command,
|
&mcp_sm,
|
||||||
args,
|
&pm,
|
||||||
env,
|
secrets,
|
||||||
} => {
|
"default",
|
||||||
match pm
|
)
|
||||||
.spawn_stdio(
|
.await
|
||||||
&server_name,
|
{
|
||||||
command,
|
Ok(c) => c,
|
||||||
args.to_vec(),
|
Err(e) => {
|
||||||
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!(
|
||||||
"Unix socket transport is not supported on this platform (server '{}')",
|
"Failed to create MCP client for '{}': {}",
|
||||||
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 {
|
||||||
@@ -677,7 +518,7 @@ impl AppBuilder {
|
|||||||
for tool in tool_impls {
|
for tool in tool_impls {
|
||||||
tools.register(tool).await;
|
tools.register(tool).await;
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
"Loaded {} tools from MCP server '{}'",
|
"Loaded {} tools from MCP server '{}'",
|
||||||
tool_count,
|
tool_count,
|
||||||
server_name
|
server_name
|
||||||
@@ -738,7 +579,7 @@ impl AppBuilder {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|m| m.to_registry_entry())
|
.map(|m| m.to_registry_entry())
|
||||||
.collect();
|
.collect();
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
count = entries.len(),
|
count = entries.len(),
|
||||||
"Loaded registry catalog entries for extension discovery"
|
"Loaded registry catalog entries for extension discovery"
|
||||||
);
|
);
|
||||||
@@ -767,6 +608,7 @@ 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)),
|
||||||
@@ -779,7 +621,7 @@ impl AppBuilder {
|
|||||||
catalog_entries.clone(),
|
catalog_entries.clone(),
|
||||||
));
|
));
|
||||||
tools.register_extension_tools(Arc::clone(&manager));
|
tools.register_extension_tools(Arc::clone(&manager));
|
||||||
tracing::info!("Extension manager initialized with in-chat discovery tools");
|
tracing::debug!("Extension manager initialized with in-chat discovery tools");
|
||||||
Some(manager)
|
Some(manager)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -850,7 +692,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::info!("Imported {} workspace file(s) from {}", count, import_dir);
|
tracing::debug!("Imported {} workspace file(s) from {}", count, import_dir);
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -875,7 +717,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::info!("Backfilled embeddings for {} chunks", count);
|
tracing::debug!("Backfilled embeddings for {} chunks", count);
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -892,7 +734,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::info!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
|
tracing::debug!("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();
|
||||||
@@ -910,7 +752,7 @@ impl AppBuilder {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
"Tool registry initialized with {} total tools",
|
"Tool registry initialized with {} total tools",
|
||||||
tools.count()
|
tools.count()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -198,6 +198,58 @@ 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
|
||||||
@@ -1237,4 +1289,108 @@ 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())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ pub trait Channel: Send + Sync {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::testing::credentials::TEST_REDACT_SECRET_123;
|
||||||
|
|
||||||
/// Stub tool that marks `"value"` as sensitive.
|
/// Stub tool that marks `"value"` as sensitive.
|
||||||
struct SecretTool;
|
struct SecretTool;
|
||||||
@@ -376,7 +377,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_completed_redacts_sensitive_params_on_failure() {
|
fn tool_completed_redacts_sensitive_params_on_failure() {
|
||||||
let params = serde_json::json!({"name": "api_key", "value": "sk-secret-123"});
|
let params = serde_json::json!({"name": "api_key", "value": TEST_REDACT_SECRET_123});
|
||||||
let err: Result<String, crate::error::Error> =
|
let err: Result<String, crate::error::Error> =
|
||||||
Err(crate::error::ToolError::ExecutionFailed {
|
Err(crate::error::ToolError::ExecutionFailed {
|
||||||
name: "secret_save".into(),
|
name: "secret_save".into(),
|
||||||
@@ -411,7 +412,7 @@ mod tests {
|
|||||||
param_str
|
param_str
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!param_str.contains("sk-secret-123"),
|
!param_str.contains(TEST_REDACT_SECRET_123),
|
||||||
"raw secret should not appear: {}",
|
"raw secret should not appear: {}",
|
||||||
param_str
|
param_str
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ impl ChannelManager {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::info!(channel = %name, "Hot-added channel stream ended");
|
tracing::debug!(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::info!("Started channel: {}", name);
|
tracing::debug!("Started channel: {}", name);
|
||||||
streams.push(stream);
|
streams.push(stream);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -184,18 +184,32 @@ 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> {
|
||||||
if !dir.is_dir() {
|
match fs::metadata(dir).await {
|
||||||
return Err(WasmChannelError::Io(std::io::Error::new(
|
Ok(meta) if meta.is_dir() => {}
|
||||||
std::io::ErrorKind::NotADirectory,
|
Ok(_) => {
|
||||||
format!("{} is not a directory", dir.display()),
|
return Err(WasmChannelError::Io(std::io::Error::new(
|
||||||
)));
|
std::io::ErrorKind::NotADirectory,
|
||||||
|
format!("{} is not a directory", dir.display()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(LoadResults::default());
|
||||||
|
}
|
||||||
|
Err(e) => return Err(WasmChannelError::Io(e)),
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
let mut entries = fs::read_dir(dir).await?;
|
// Handle TOCTOU: if read_dir fails with NotFound, treat as empty
|
||||||
|
let mut entries = match fs::read_dir(dir).await {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(LoadResults::default());
|
||||||
|
}
|
||||||
|
Err(e) => return Err(WasmChannelError::Io(e)),
|
||||||
|
};
|
||||||
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
@@ -486,4 +500,21 @@ 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ 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;
|
||||||
@@ -105,4 +106,5 @@ 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};
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
//! 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)
|
||||||
|
}
|
||||||
@@ -3059,6 +3059,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
|
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
|
||||||
use crate::pairing::PairingStore;
|
use crate::pairing::PairingStore;
|
||||||
|
use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN;
|
||||||
use crate::tools::wasm::ResourceLimits;
|
use crate::tools::wasm::ResourceLimits;
|
||||||
|
|
||||||
fn create_test_channel() -> WasmChannel {
|
fn create_test_channel() -> WasmChannel {
|
||||||
@@ -4009,7 +4010,7 @@ mod tests {
|
|||||||
let mut creds = std::collections::HashMap::new();
|
let mut creds = std::collections::HashMap::new();
|
||||||
creds.insert(
|
creds.insert(
|
||||||
"TELEGRAM_BOT_TOKEN".to_string(),
|
"TELEGRAM_BOT_TOKEN".to_string(),
|
||||||
"8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(),
|
TEST_TELEGRAM_BOT_TOKEN.to_string(),
|
||||||
);
|
);
|
||||||
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
|
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
|
||||||
|
|
||||||
@@ -4022,13 +4023,15 @@ mod tests {
|
|||||||
Arc::new(PairingStore::new()),
|
Arc::new(PairingStore::new()),
|
||||||
);
|
);
|
||||||
|
|
||||||
let error = "HTTP request failed: error sending request for url \
|
let error = format!(
|
||||||
(https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)";
|
"HTTP request failed: error sending request for url \
|
||||||
|
(https://api.telegram.org/bot{TEST_TELEGRAM_BOT_TOKEN}/getUpdates)"
|
||||||
|
);
|
||||||
|
|
||||||
let redacted = store.redact_credentials(error);
|
let redacted = store.redact_credentials(&error);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"),
|
!redacted.contains(TEST_TELEGRAM_BOT_TOKEN),
|
||||||
"credential value should be redacted"
|
"credential value should be redacted"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+27
-26
@@ -83,14 +83,15 @@ pub async fn auth_middleware(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::testing::credentials::{TEST_AUTH_SECRET_TOKEN, TEST_BEARER_TOKEN};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_auth_state_clone() {
|
fn test_auth_state_clone() {
|
||||||
let state = AuthState {
|
let state = AuthState {
|
||||||
token: "test-token".to_string(),
|
token: TEST_BEARER_TOKEN.to_string(),
|
||||||
};
|
};
|
||||||
let cloned = state.clone();
|
let cloned = state.clone();
|
||||||
assert_eq!(cloned.token, "test-token");
|
assert_eq!(cloned.token, TEST_BEARER_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -120,10 +121,10 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_valid_bearer_token_passes() {
|
async fn test_valid_bearer_token_passes() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/events")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "Bearer secret-token")
|
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -132,7 +133,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_invalid_bearer_token_rejected() {
|
async fn test_invalid_bearer_token_rejected() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/events")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "Bearer wrong-token")
|
.header("Authorization", "Bearer wrong-token")
|
||||||
@@ -144,9 +145,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_query_token_allowed_for_chat_events() {
|
async fn test_query_token_allowed_for_chat_events() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/events?token=secret-token")
|
.uri(format!("/api/chat/events?token={TEST_AUTH_SECRET_TOKEN}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -155,9 +156,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_query_token_allowed_for_logs_events() {
|
async fn test_query_token_allowed_for_logs_events() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/logs/events?token=secret-token")
|
.uri(format!("/api/logs/events?token={TEST_AUTH_SECRET_TOKEN}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -166,9 +167,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_query_token_allowed_for_ws_upgrade() {
|
async fn test_query_token_allowed_for_ws_upgrade() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/ws?token=secret-token")
|
.uri(format!("/api/chat/ws?token={TEST_AUTH_SECRET_TOKEN}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -202,9 +203,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_query_token_rejected_for_non_sse_get() {
|
async fn test_query_token_rejected_for_non_sse_get() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/history?token=secret-token")
|
.uri(format!("/api/chat/history?token={TEST_AUTH_SECRET_TOKEN}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -213,10 +214,10 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_query_token_rejected_for_post() {
|
async fn test_query_token_rejected_for_post() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
.uri("/api/chat/send?token=secret-token")
|
.uri(format!("/api/chat/send?token={TEST_AUTH_SECRET_TOKEN}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -225,7 +226,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_query_token_invalid_rejected() {
|
async fn test_query_token_invalid_rejected() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/events?token=wrong-token")
|
.uri("/api/chat/events?token=wrong-token")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
@@ -236,7 +237,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_no_auth_at_all_rejected() {
|
async fn test_no_auth_at_all_rejected() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/events")
|
.uri("/api/chat/events")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
@@ -247,11 +248,11 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_bearer_header_works_for_post() {
|
async fn test_bearer_header_works_for_post() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
.uri("/api/chat/send")
|
.uri("/api/chat/send")
|
||||||
.header("Authorization", "Bearer secret-token")
|
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -260,10 +261,10 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_bearer_prefix_case_insensitive() {
|
async fn test_bearer_prefix_case_insensitive() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/events")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "bearer secret-token")
|
.header("Authorization", format!("bearer {TEST_AUTH_SECRET_TOKEN}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -272,10 +273,10 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_bearer_prefix_mixed_case() {
|
async fn test_bearer_prefix_mixed_case() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/events")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "BEARER secret-token")
|
.header("Authorization", format!("BEARER {TEST_AUTH_SECRET_TOKEN}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -284,7 +285,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_empty_bearer_token_rejected() {
|
async fn test_empty_bearer_token_rejected() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/events")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "Bearer ")
|
.header("Authorization", "Bearer ")
|
||||||
@@ -296,10 +297,10 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_token_with_whitespace_rejected() {
|
async fn test_token_with_whitespace_rejected() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/api/chat/events")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "Bearer secret-token")
|
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
|
|||||||
@@ -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::info!("Web gateway shutting down");
|
tracing::debug!("Web gateway shutting down");
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -2427,6 +2427,7 @@ struct GatewayStatusResponse {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_turns_from_db_messages_complete() {
|
fn test_build_turns_from_db_messages_complete() {
|
||||||
@@ -2600,7 +2601,7 @@ mod tests {
|
|||||||
// Build an ExtensionManager so the handler can look up flows
|
// Build an ExtensionManager so the handler can look up flows
|
||||||
let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||||
"test-key-at-least-32-chars-long!!".to_string(),
|
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
||||||
))
|
))
|
||||||
.expect("crypto"),
|
.expect("crypto"),
|
||||||
)));
|
)));
|
||||||
@@ -2609,6 +2610,7 @@ 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,
|
||||||
@@ -2649,7 +2651,7 @@ mod tests {
|
|||||||
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
||||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||||
"test-key-at-least-32-chars-long!!".to_string(),
|
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
||||||
))
|
))
|
||||||
.expect("crypto"),
|
.expect("crypto"),
|
||||||
)));
|
)));
|
||||||
@@ -2658,6 +2660,7 @@ 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,
|
||||||
@@ -2754,7 +2757,7 @@ mod tests {
|
|||||||
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
||||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||||
"test-key-at-least-32-chars-long!!".to_string(),
|
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
||||||
))
|
))
|
||||||
.expect("crypto"),
|
.expect("crypto"),
|
||||||
)));
|
)));
|
||||||
@@ -2763,6 +2766,7 @@ 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,
|
||||||
|
|||||||
@@ -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::info!("Webhook server shutting down");
|
tracing::debug!("Webhook server shutting down");
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-12
@@ -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::{SecretsCrypto, SecretsStore};
|
use crate::secrets::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},
|
||||||
@@ -628,17 +628,7 @@ async fn save_servers(
|
|||||||
|
|
||||||
/// Initialize and return the secrets store.
|
/// Initialize and return the secrets store.
|
||||||
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
||||||
let config = Config::from_env().await?;
|
crate::cli::init_secrets_store().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)]
|
||||||
|
|||||||
+45
-4
@@ -28,8 +28,6 @@ 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};
|
||||||
@@ -37,6 +35,8 @@ 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,12 +94,16 @@ pub enum Command {
|
|||||||
skip_auth: bool,
|
skip_auth: bool,
|
||||||
|
|
||||||
/// Reconfigure channels only
|
/// Reconfigure channels only
|
||||||
#[arg(long, conflicts_with = "provider_only")]
|
#[arg(long, conflicts_with_all = ["provider_only", "quick"])]
|
||||||
channels_only: bool,
|
channels_only: bool,
|
||||||
|
|
||||||
/// Reconfigure LLM provider and model only
|
/// Reconfigure LLM provider and model only
|
||||||
#[arg(long, conflicts_with = "channels_only")]
|
#[arg(long, conflicts_with_all = ["channels_only", "quick"])]
|
||||||
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
|
||||||
@@ -225,6 +229,43 @@ 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::*;
|
||||||
|
|||||||
+2
-12
@@ -10,8 +10,7 @@ use clap::Subcommand;
|
|||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
use crate::config::Config;
|
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||||
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.
|
||||||
@@ -552,16 +551,7 @@ 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>> {
|
||||||
let config = Config::from_env().await?;
|
crate::cli::init_secrets_store().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.
|
||||||
|
|||||||
@@ -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::info!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)");
|
tracing::debug!("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::info!(
|
tracing::debug!(
|
||||||
"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::info!(
|
tracing::debug!(
|
||||||
"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::info!(
|
tracing::debug!(
|
||||||
"Embeddings enabled via OpenAI (model: {}, dim: {})",
|
"Embeddings enabled via OpenAI (model: {}, dim: {})",
|
||||||
self.model,
|
self.model,
|
||||||
self.dimension,
|
self.dimension,
|
||||||
@@ -154,6 +154,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::ENV_MUTEX;
|
||||||
use crate::settings::{EmbeddingsSettings, Settings};
|
use crate::settings::{EmbeddingsSettings, Settings};
|
||||||
|
use crate::testing::credentials::*;
|
||||||
|
|
||||||
/// Clear all embedding-related env vars.
|
/// Clear all embedding-related env vars.
|
||||||
fn clear_embedding_env() {
|
fn clear_embedding_env() {
|
||||||
@@ -173,7 +174,7 @@ mod tests {
|
|||||||
clear_embedding_env();
|
clear_embedding_env();
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("OPENAI_API_KEY", "sk-test-key-for-issue-129");
|
std::env::set_var("OPENAI_API_KEY", TEST_OPENAI_API_KEY_ISSUE_129);
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
|
|||||||
+8
-17
@@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,6 +385,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::helpers::ENV_MUTEX;
|
use crate::config::helpers::ENV_MUTEX;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
use crate::testing::credentials::*;
|
||||||
|
|
||||||
/// Clear all openai-compatible-related env vars.
|
/// Clear all openai-compatible-related env vars.
|
||||||
fn clear_openai_compatible_env() {
|
fn clear_openai_compatible_env() {
|
||||||
@@ -628,12 +625,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]
|
||||||
@@ -657,7 +648,7 @@ mod tests {
|
|||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("LLM_BACKEND", "open_ai");
|
std::env::set_var("LLM_BACKEND", "open_ai");
|
||||||
std::env::set_var("OPENAI_API_KEY", "test-key");
|
std::env::set_var("OPENAI_API_KEY", TEST_API_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = Settings::default();
|
let settings = Settings::default();
|
||||||
@@ -791,7 +782,7 @@ mod tests {
|
|||||||
clear_anthropic_env();
|
clear_anthropic_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
@@ -815,7 +806,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
provider.oauth_token.as_ref().unwrap().expose_secret(),
|
provider.oauth_token.as_ref().unwrap().expose_secret(),
|
||||||
"sk-ant-oat01-test-token"
|
TEST_ANTHROPIC_OAUTH_TOKEN
|
||||||
);
|
);
|
||||||
|
|
||||||
clear_anthropic_env();
|
clear_anthropic_env();
|
||||||
@@ -829,8 +820,8 @@ mod tests {
|
|||||||
clear_anthropic_env();
|
clear_anthropic_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-real-key");
|
std::env::set_var("ANTHROPIC_API_KEY", TEST_ANTHROPIC_API_KEY);
|
||||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
@@ -845,7 +836,7 @@ mod tests {
|
|||||||
.api_key
|
.api_key
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|k| k.expose_secret().to_string()),
|
.map(|k| k.expose_secret().to_string()),
|
||||||
Some("sk-ant-real-key".to_string()),
|
Some(TEST_ANTHROPIC_API_KEY.to_string()),
|
||||||
"real API key should take priority over OAuth placeholder"
|
"real API key should take priority over OAuth placeholder"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -862,7 +853,7 @@ mod tests {
|
|||||||
clear_anthropic_env();
|
clear_anthropic_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
|
|||||||
+17
-10
@@ -272,6 +272,7 @@ fn parse_oauth_access_token(json: &str) -> Option<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::config::sandbox::*;
|
use crate::config::sandbox::*;
|
||||||
|
use crate::testing::credentials::*;
|
||||||
|
|
||||||
// ── SandboxModeConfig defaults ──────────────────────────────────
|
// ── SandboxModeConfig defaults ──────────────────────────────────
|
||||||
|
|
||||||
@@ -405,9 +406,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_oauth_token_valid() {
|
fn parse_oauth_token_valid() {
|
||||||
let json = r#"{"claudeAiOauth": {"accessToken": "sk-ant-oat01-fake"}}"#;
|
let json = format!(
|
||||||
let token = parse_oauth_access_token(json);
|
r#"{{"claudeAiOauth": {{"accessToken": "{}"}}}}"#,
|
||||||
assert_eq!(token, Some("sk-ant-oat01-fake".to_string()));
|
TEST_ANTHROPIC_OAUTH_BASIC
|
||||||
|
);
|
||||||
|
let token = parse_oauth_access_token(&json);
|
||||||
|
assert_eq!(token, Some(TEST_ANTHROPIC_OAUTH_BASIC.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -434,16 +438,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_oauth_token_nested_extra_fields() {
|
fn parse_oauth_token_nested_extra_fields() {
|
||||||
let json = r#"{
|
let json = format!(
|
||||||
"claudeAiOauth": {
|
r#"{{
|
||||||
"accessToken": "sk-ant-oat01-real-token",
|
"claudeAiOauth": {{
|
||||||
|
"accessToken": "{}",
|
||||||
"refreshToken": "rt-abc",
|
"refreshToken": "rt-abc",
|
||||||
"expiresAt": 1700000000
|
"expiresAt": 1700000000
|
||||||
}
|
}}
|
||||||
}"#;
|
}}"#,
|
||||||
|
TEST_ANTHROPIC_OAUTH_NESTED
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_oauth_access_token(json),
|
parse_oauth_access_token(&json),
|
||||||
Some("sk-ant-oat01-real-token".to_string())
|
Some(TEST_ANTHROPIC_OAUTH_NESTED.to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-2
@@ -51,6 +51,29 @@ 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 => {
|
||||||
@@ -74,7 +97,11 @@ pub async fn connect_from_config(
|
|||||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
||||||
};
|
};
|
||||||
backend.run_migrations().await?;
|
backend.run_migrations().await?;
|
||||||
Ok(Arc::new(backend))
|
tracing::info!("libSQL database connected and migrations applied");
|
||||||
|
|
||||||
|
handles.libsql_db = Some(backend.shared_db());
|
||||||
|
|
||||||
|
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
|
||||||
}
|
}
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
_ => {
|
_ => {
|
||||||
@@ -82,7 +109,11 @@ pub async fn connect_from_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?;
|
||||||
Ok(Arc::new(pg))
|
tracing::info!("PostgreSQL database connected and migrations applied");
|
||||||
|
|
||||||
|
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(
|
_ => Err(DatabaseError::Pool(
|
||||||
|
|||||||
+18
-14
@@ -73,6 +73,7 @@ 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>>>,
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ 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>>,
|
||||||
@@ -136,6 +138,7 @@ 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,
|
||||||
@@ -2467,18 +2470,15 @@ impl ExtensionManager {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
||||||
|
|
||||||
let has_tokens = is_authenticated(&server, &self.secrets, &self.user_id).await;
|
let client = crate::tools::mcp::create_client_from_config(
|
||||||
|
server.clone(),
|
||||||
let client = if has_tokens || server.requires_auth() {
|
&self.mcp_session_manager,
|
||||||
McpClient::new_authenticated(
|
&self.mcp_process_manager,
|
||||||
server.clone(),
|
Some(Arc::clone(&self.secrets)),
|
||||||
Arc::clone(&self.mcp_session_manager),
|
&self.user_id,
|
||||||
Arc::clone(&self.secrets),
|
)
|
||||||
&self.user_id,
|
.await
|
||||||
)
|
.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,6 +3736,7 @@ 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());
|
||||||
@@ -3747,6 +3748,7 @@ 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
|
||||||
@@ -3905,18 +3907,20 @@ mod tests {
|
|||||||
channels_dir: std::path::PathBuf,
|
channels_dir: std::path::PathBuf,
|
||||||
) -> ExtensionManager {
|
) -> ExtensionManager {
|
||||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||||
|
use crate::testing::credentials::TEST_CRYPTO_KEY;
|
||||||
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();
|
||||||
std::fs::create_dir_all(&channels_dir).ok();
|
std::fs::create_dir_all(&channels_dir).ok();
|
||||||
|
|
||||||
let master_key =
|
let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string());
|
||||||
secrecy::SecretString::from("0123456789abcdef0123456789abcdef".to_string());
|
|
||||||
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
|
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -117,7 +117,7 @@ pub fn create_llm_provider_with_config(
|
|||||||
} else {
|
} else {
|
||||||
"session token"
|
"session token"
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
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::info!(
|
tracing::debug!(
|
||||||
"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::info!(
|
tracing::debug!(
|
||||||
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::info!(
|
tracing::debug!(
|
||||||
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::info!(
|
tracing::debug!(
|
||||||
model = %config.model,
|
model = %config.model,
|
||||||
retention = %cache_retention,
|
retention = %cache_retention,
|
||||||
"Anthropic automatic prompt caching enabled"
|
"Anthropic automatic prompt caching enabled"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
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::info!(
|
tracing::debug!(
|
||||||
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::info!("LLM provider initialized: {}", llm.model_name());
|
tracing::debug!("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::info!(
|
tracing::debug!(
|
||||||
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::info!(
|
tracing::debug!(
|
||||||
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::info!(
|
tracing::debug!(
|
||||||
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::info!(
|
tracing::debug!(
|
||||||
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::info!(
|
tracing::debug!(
|
||||||
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::info!("Cheap LLM provider initialized: {}", cheap.model_name());
|
tracing::debug!("Cheap LLM provider initialized: {}", cheap.model_name());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((llm, cheap_llm, recording_handle))
|
Ok((llm, cheap_llm, recording_handle))
|
||||||
|
|||||||
@@ -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::info!("Loaded NEAR AI pricing for {} model(s)", map.len());
|
tracing::debug!("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,
|
||||||
|
|||||||
@@ -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
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-7
@@ -627,6 +627,9 @@ pub async fn create_session_manager(config: SessionConfig) -> Arc<SessionManager
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::testing::credentials::{
|
||||||
|
TEST_SESSION_NEARAI_ABC, TEST_SESSION_NEARAI_XYZ, TEST_SESSION_TOKEN,
|
||||||
|
};
|
||||||
use secrecy::ExposeSecret;
|
use secrecy::ExposeSecret;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
@@ -647,28 +650,28 @@ mod tests {
|
|||||||
|
|
||||||
// Save a token
|
// Save a token
|
||||||
manager
|
manager
|
||||||
.save_session("test_token_123", Some("near"))
|
.save_session(TEST_SESSION_TOKEN, Some("near"))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
manager
|
manager
|
||||||
.set_token(SecretString::from("test_token_123"))
|
.set_token(SecretString::from(TEST_SESSION_TOKEN))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Verify it's set
|
// Verify it's set
|
||||||
assert!(manager.has_token().await);
|
assert!(manager.has_token().await);
|
||||||
let token = manager.get_token().await.unwrap();
|
let token = manager.get_token().await.unwrap();
|
||||||
assert_eq!(token.expose_secret(), "test_token_123");
|
assert_eq!(token.expose_secret(), TEST_SESSION_TOKEN);
|
||||||
|
|
||||||
// Create new manager and verify it loads the token
|
// Create new manager and verify it loads the token
|
||||||
let manager2 = SessionManager::new_async(config).await;
|
let manager2 = SessionManager::new_async(config).await;
|
||||||
assert!(manager2.has_token().await);
|
assert!(manager2.has_token().await);
|
||||||
let token2 = manager2.get_token().await.unwrap();
|
let token2 = manager2.get_token().await.unwrap();
|
||||||
assert_eq!(token2.expose_secret(), "test_token_123");
|
assert_eq!(token2.expose_secret(), TEST_SESSION_TOKEN);
|
||||||
|
|
||||||
// Verify file contents
|
// Verify file contents
|
||||||
let data: SessionData =
|
let data: SessionData =
|
||||||
serde_json::from_str(&std::fs::read_to_string(&session_path).unwrap()).unwrap();
|
serde_json::from_str(&std::fs::read_to_string(&session_path).unwrap()).unwrap();
|
||||||
assert_eq!(data.session_token, "test_token_123");
|
assert_eq!(data.session_token, TEST_SESSION_TOKEN);
|
||||||
assert_eq!(data.auth_provider, Some("near".to_string()));
|
assert_eq!(data.auth_provider, Some("near".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,7 +692,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_session_data_serde_roundtrip_with_auth_provider() {
|
fn test_session_data_serde_roundtrip_with_auth_provider() {
|
||||||
let original = SessionData {
|
let original = SessionData {
|
||||||
session_token: "sess_abc123".to_string(),
|
session_token: TEST_SESSION_NEARAI_ABC.to_string(),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
auth_provider: Some("github".to_string()),
|
auth_provider: Some("github".to_string()),
|
||||||
};
|
};
|
||||||
@@ -703,7 +706,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_session_data_serde_roundtrip_without_auth_provider() {
|
fn test_session_data_serde_roundtrip_without_auth_provider() {
|
||||||
let original = SessionData {
|
let original = SessionData {
|
||||||
session_token: "sess_xyz789".to_string(),
|
session_token: TEST_SESSION_NEARAI_XYZ.to_string(),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
auth_provider: None,
|
auth_provider: None,
|
||||||
};
|
};
|
||||||
|
|||||||
+46
-597
@@ -4,7 +4,6 @@ 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},
|
||||||
@@ -12,10 +11,7 @@ use ironclaw::{
|
|||||||
channels::{
|
channels::{
|
||||||
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, SignalChannel, WebhookServer,
|
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, SignalChannel, WebhookServer,
|
||||||
WebhookServerConfig,
|
WebhookServerConfig,
|
||||||
wasm::{
|
wasm::{WasmChannelRouter, WasmChannelRuntime},
|
||||||
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
|
|
||||||
WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
|
|
||||||
},
|
|
||||||
web::log_layer::LogBroadcaster,
|
web::log_layer::LogBroadcaster,
|
||||||
},
|
},
|
||||||
cli::{
|
cli::{
|
||||||
@@ -25,26 +21,14 @@ use ironclaw::{
|
|||||||
config::Config,
|
config::Config,
|
||||||
hooks::bootstrap_hooks,
|
hooks::bootstrap_hooks,
|
||||||
llm::create_session_manager,
|
llm::create_session_manager,
|
||||||
orchestrator::{
|
orchestrator::{ReaperConfig, SandboxReaper},
|
||||||
ContainerJobConfig, ContainerJobManager, OrchestratorApi, ReaperConfig, SandboxReaper,
|
|
||||||
TokenStore, api::OrchestratorState,
|
|
||||||
},
|
|
||||||
pairing::PairingStore,
|
pairing::PairingStore,
|
||||||
secrets::SecretsStore,
|
tracing_fmt::{init_cli_tracing, init_worker_tracing},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[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<()> {
|
||||||
@@ -80,7 +64,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
Some(Command::Memory(mem_cmd)) => {
|
Some(Command::Memory(mem_cmd)) => {
|
||||||
init_cli_tracing();
|
init_cli_tracing();
|
||||||
return run_memory_command(mem_cmd).await;
|
return ironclaw::cli::run_memory_command(mem_cmd).await;
|
||||||
}
|
}
|
||||||
Some(Command::Pairing(pairing_cmd)) => {
|
Some(Command::Pairing(pairing_cmd)) => {
|
||||||
init_cli_tracing();
|
init_cli_tracing();
|
||||||
@@ -108,7 +92,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
max_iterations,
|
max_iterations,
|
||||||
}) => {
|
}) => {
|
||||||
init_worker_tracing();
|
init_worker_tracing();
|
||||||
return run_worker(*job_id, orchestrator_url, *max_iterations).await;
|
return ironclaw::worker::run_worker(*job_id, orchestrator_url, *max_iterations).await;
|
||||||
}
|
}
|
||||||
Some(Command::ClaudeBridge {
|
Some(Command::ClaudeBridge {
|
||||||
job_id,
|
job_id,
|
||||||
@@ -117,12 +101,19 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
model,
|
model,
|
||||||
}) => {
|
}) => {
|
||||||
init_worker_tracing();
|
init_worker_tracing();
|
||||||
return run_claude_bridge(*job_id, orchestrator_url, *max_turns, model).await;
|
return ironclaw::worker::run_claude_bridge(
|
||||||
|
*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"))]
|
||||||
{
|
{
|
||||||
@@ -130,13 +121,14 @@ 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);
|
let _ = (skip_auth, channels_only, provider_only, quick);
|
||||||
eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
|
eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -169,11 +161,14 @@ 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) = check_onboard_needed()
|
&& let Some(reason) = ironclaw::setup::check_onboard_needed()
|
||||||
{
|
{
|
||||||
println!("Onboarding needed: {}", reason);
|
println!("Onboarding needed: {}", reason);
|
||||||
println!();
|
println!();
|
||||||
let mut wizard = SetupWizard::new();
|
let mut wizard = SetupWizard::with_config(SetupConfig {
|
||||||
|
quick: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
wizard.run().await?;
|
wizard.run().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,9 +201,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::info!("Starting IronClaw...");
|
tracing::debug!("Starting IronClaw...");
|
||||||
tracing::info!("Loaded configuration for agent: {}", config.agent.name);
|
tracing::debug!("Loaded configuration for agent: {}", config.agent.name);
|
||||||
tracing::info!("LLM backend: {}", config.llm.backend);
|
tracing::debug!("LLM backend: {}", config.llm.backend);
|
||||||
|
|
||||||
// ── Phase 1-5: Build all core components via AppBuilder ────────────
|
// ── Phase 1-5: Build all core components via AppBuilder ────────────
|
||||||
|
|
||||||
@@ -227,95 +222,21 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// ── Tunnel setup ───────────────────────────────────────────────────
|
// ── Tunnel setup ───────────────────────────────────────────────────
|
||||||
|
|
||||||
let (config, active_tunnel) = start_tunnel(config).await;
|
let (config, active_tunnel) = ironclaw::tunnel::start_managed_tunnel(config).await;
|
||||||
|
|
||||||
// ── Orchestrator / container job manager ────────────────────────────
|
// ── Orchestrator / container job manager ────────────────────────────
|
||||||
|
|
||||||
// Proactive Docker detection
|
let orch = ironclaw::orchestrator::setup_orchestrator(
|
||||||
let docker_status = if config.sandbox.enabled {
|
&config,
|
||||||
let detection = ironclaw::sandbox::check_docker().await;
|
&components.llm,
|
||||||
match detection.status {
|
components.db.as_ref(),
|
||||||
ironclaw::sandbox::DockerStatus::Available => {
|
components.secrets_store.as_ref(),
|
||||||
tracing::info!("Docker is available");
|
)
|
||||||
}
|
.await;
|
||||||
ironclaw::sandbox::DockerStatus::NotInstalled => {
|
let container_job_manager = orch.container_job_manager;
|
||||||
tracing::warn!(
|
let job_event_tx = orch.job_event_tx;
|
||||||
"Docker is not installed -- sandbox disabled for this session. {}",
|
let prompt_queue = orch.prompt_queue;
|
||||||
detection.platform.install_hint()
|
let docker_status = orch.docker_status;
|
||||||
);
|
|
||||||
}
|
|
||||||
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 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -343,10 +264,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::info!("Single message mode");
|
tracing::debug!("Single message mode");
|
||||||
} else {
|
} else {
|
||||||
channel_names.push("repl".to_string());
|
channel_names.push("repl".to_string());
|
||||||
tracing::info!("REPL mode enabled");
|
tracing::debug!("REPL mode enabled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +276,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 = setup_wasm_channels(
|
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
|
||||||
&config,
|
&config,
|
||||||
&components.secrets_store,
|
&components.secrets_store,
|
||||||
components.extension_manager.as_ref(),
|
components.extension_manager.as_ref(),
|
||||||
@@ -388,7 +309,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::info!(
|
tracing::debug!(
|
||||||
url = %safe_url,
|
url = %safe_url,
|
||||||
"Signal channel enabled"
|
"Signal channel enabled"
|
||||||
);
|
);
|
||||||
@@ -414,7 +335,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::info!(
|
tracing::debug!(
|
||||||
"HTTP channel enabled on {}:{}",
|
"HTTP channel enabled on {}:{}",
|
||||||
http_config.host,
|
http_config.host,
|
||||||
http_config.port
|
http_config.port
|
||||||
@@ -455,7 +376,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
&components.dev_loaded_tool_names,
|
&components.dev_loaded_tool_names,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
tracing::info!(
|
tracing::debug!(
|
||||||
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,
|
||||||
@@ -548,7 +469,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
gw.auth_token()
|
gw.auth_token()
|
||||||
));
|
));
|
||||||
|
|
||||||
tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port);
|
tracing::debug!("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`
|
||||||
@@ -633,7 +554,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
config.channels.wasm_channel_owner_ids.clone(),
|
config.channels.wasm_channel_owner_ids.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
tracing::info!("Channel runtime wired into extension manager for hot-activation");
|
tracing::debug!("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;
|
||||||
@@ -641,7 +562,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::info!(
|
tracing::debug!(
|
||||||
channel = %name,
|
channel = %name,
|
||||||
message = %result.message,
|
message = %result.message,
|
||||||
"Auto-activated persisted channel"
|
"Auto-activated persisted channel"
|
||||||
@@ -759,485 +680,13 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(tunnel) = active_tunnel {
|
if let Some(tunnel) = active_tunnel {
|
||||||
tracing::info!("Stopping {} tunnel...", tunnel.name());
|
tracing::debug!("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::info!("Agent shutdown complete");
|
tracing::debug!("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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -661,12 +661,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn credentials_returns_secrets_when_store_configured() {
|
async fn credentials_returns_secrets_when_store_configured() {
|
||||||
|
use crate::testing::credentials::test_secrets_store;
|
||||||
use secrecy::SecretString;
|
use secrecy::SecretString;
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
let secrets_store = Arc::new(test_secrets_store());
|
||||||
let crypto = Arc::new(
|
|
||||||
crate::secrets::SecretsCrypto::new(SecretString::from(key.to_string())).unwrap(),
|
|
||||||
);
|
|
||||||
let secrets_store = Arc::new(crate::secrets::InMemorySecretsStore::new(crypto));
|
|
||||||
|
|
||||||
// Create a secret
|
// Create a secret
|
||||||
secrets_store
|
secrets_store
|
||||||
|
|||||||
@@ -39,3 +39,115 @@ 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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::info!("Sandbox shut down");
|
tracing::debug!("Sandbox shut down");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a command in the sandbox.
|
/// Execute a command in the sandbox.
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ impl HttpProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = &mut shutdown_rx => {
|
_ = &mut shutdown_rx => {
|
||||||
tracing::info!("Sandbox proxy shutting down");
|
tracing::debug!("Sandbox proxy shutting down");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,11 +153,11 @@ mod tests {
|
|||||||
use secrecy::SecretString;
|
use secrecy::SecretString;
|
||||||
|
|
||||||
use crate::secrets::crypto::SecretsCrypto;
|
use crate::secrets::crypto::SecretsCrypto;
|
||||||
|
use crate::testing::credentials::TEST_CRYPTO_KEY;
|
||||||
|
|
||||||
fn test_crypto() -> SecretsCrypto {
|
fn test_crypto() -> SecretsCrypto {
|
||||||
// 32-byte test key
|
// 32-byte test key
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
SecretsCrypto::new(SecretString::from(TEST_CRYPTO_KEY.to_string())).unwrap()
|
||||||
SecretsCrypto::new(SecretString::from(key.to_string())).unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -75,3 +75,37 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
+15
-14
@@ -802,30 +802,25 @@ pub mod in_memory {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
use crate::secrets::crypto::SecretsCrypto;
|
|
||||||
use crate::secrets::store::SecretsStore;
|
use crate::secrets::store::SecretsStore;
|
||||||
use crate::secrets::store::in_memory::InMemorySecretsStore;
|
|
||||||
use crate::secrets::types::CreateSecretParams;
|
use crate::secrets::types::CreateSecretParams;
|
||||||
|
use crate::testing::credentials::{
|
||||||
|
TEST_OPENAI_API_KEY_SHORT, TEST_SECRET_VALUE, TEST_STRIPE_KEY, test_secrets_store,
|
||||||
|
};
|
||||||
|
|
||||||
fn test_store() -> InMemorySecretsStore {
|
fn test_store() -> crate::secrets::store::in_memory::InMemorySecretsStore {
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
test_secrets_store()
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
InMemorySecretsStore::new(crypto)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_and_get() {
|
async fn test_create_and_get() {
|
||||||
let store = test_store();
|
let store = test_store();
|
||||||
let params = CreateSecretParams::new("api_key", "sk-test-12345");
|
let params = CreateSecretParams::new("api_key", TEST_SECRET_VALUE);
|
||||||
|
|
||||||
store.create("user1", params).await.unwrap();
|
store.create("user1", params).await.unwrap();
|
||||||
|
|
||||||
let decrypted = store.get_decrypted("user1", "api_key").await.unwrap();
|
let decrypted = store.get_decrypted("user1", "api_key").await.unwrap();
|
||||||
assert_eq!(decrypted.expose(), "sk-test-12345");
|
assert_eq!(decrypted.expose(), TEST_SECRET_VALUE);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -878,11 +873,17 @@ mod tests {
|
|||||||
async fn test_is_accessible() {
|
async fn test_is_accessible() {
|
||||||
let store = test_store();
|
let store = test_store();
|
||||||
store
|
store
|
||||||
.create("user1", CreateSecretParams::new("openai_key", "sk-test"))
|
.create(
|
||||||
|
"user1",
|
||||||
|
CreateSecretParams::new("openai_key", TEST_OPENAI_API_KEY_SHORT),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
.create("user1", CreateSecretParams::new("stripe_key", "sk-live"))
|
.create(
|
||||||
|
"user1",
|
||||||
|
CreateSecretParams::new("stripe_key", TEST_STRIPE_KEY),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
+40
-3
@@ -10,7 +10,7 @@ file first, then adjust the code to match.
|
|||||||
## Entry Points
|
## Entry Points
|
||||||
|
|
||||||
```
|
```
|
||||||
ironclaw onboard [--skip-auth] [--channels-only]
|
ironclaw onboard [--skip-auth] [--channels-only] [--provider-only] [--quick]
|
||||||
```
|
```
|
||||||
|
|
||||||
Explicit invocation. Loads `.env` files, runs the wizard, exits.
|
Explicit invocation. Loads `.env` files, runs the wizard, exits.
|
||||||
@@ -26,6 +26,8 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -50,7 +52,41 @@ The `--no-onboard` CLI flag suppresses auto-detection.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The 8-Step Wizard
|
## Quick Mode
|
||||||
|
|
||||||
|
Quick mode (`--quick` flag, or auto-triggered on first run) provides a
|
||||||
|
near-instant onboarding experience by auto-defaulting everything except
|
||||||
|
the LLM provider and model selection.
|
||||||
|
|
||||||
|
```
|
||||||
|
auto_setup_database() → libsql at ~/.ironclaw/ironclaw.db (zero prompts)
|
||||||
|
auto_setup_security() → keychain or env var (zero prompts)
|
||||||
|
Step 1/2: Inference Provider ← only interactive step
|
||||||
|
Step 2/2: Model Selection ← only interactive step
|
||||||
|
↓
|
||||||
|
save_and_summarize() → includes tip to run `ironclaw onboard`
|
||||||
|
```
|
||||||
|
|
||||||
|
**`auto_setup_database()`:** Uses existing env vars if set (`DATABASE_URL`
|
||||||
|
for postgres, `LIBSQL_PATH` for libsql) without prompting. Otherwise
|
||||||
|
defaults to libsql at `~/.ironclaw/ironclaw.db`, creates the database,
|
||||||
|
and runs migrations silently. Falls back to interactive mode only when
|
||||||
|
just the postgres feature is compiled and no `DATABASE_URL` is set.
|
||||||
|
|
||||||
|
**`auto_setup_security()`:** Checks for existing `SECRETS_MASTER_KEY`
|
||||||
|
env var or OS keychain key. If neither exists, generates a new key and
|
||||||
|
stores it in the keychain (macOS) or env var (Linux/other). Zero prompts
|
||||||
|
except unavoidable macOS keychain dialogs.
|
||||||
|
|
||||||
|
**`.env` preservation (fix for #751):** `write_bootstrap_env()` now uses
|
||||||
|
`upsert_bootstrap_vars()` instead of `save_bootstrap_env()`, preserving
|
||||||
|
user-added variables like `HTTP_HOST` across re-onboarding.
|
||||||
|
|
||||||
|
The full 9-step wizard remains available via `ironclaw onboard`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The 9-Step Wizard
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|
||||||
@@ -62,7 +98,8 @@ 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: Background Tasks (heartbeat)
|
Step 8: Docker Sandbox
|
||||||
|
Step 9: Background Tasks (heartbeat)
|
||||||
↓
|
↓
|
||||||
save_and_summarize()
|
save_and_summarize()
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -31,3 +31,35 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
+208
-11
@@ -76,6 +76,8 @@ 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.
|
||||||
@@ -154,6 +156,26 @@ 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;
|
||||||
@@ -659,7 +681,10 @@ impl SetupWizard {
|
|||||||
use refinery::embed_migrations;
|
use refinery::embed_migrations;
|
||||||
embed_migrations!("migrations");
|
embed_migrations!("migrations");
|
||||||
|
|
||||||
print_info("Running migrations...");
|
if !self.config.quick {
|
||||||
|
print_info("Running migrations...");
|
||||||
|
}
|
||||||
|
tracing::debug!("Running PostgreSQL migrations...");
|
||||||
|
|
||||||
let mut client = pool
|
let mut client = pool
|
||||||
.get()
|
.get()
|
||||||
@@ -671,7 +696,10 @@ impl SetupWizard {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?;
|
.map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?;
|
||||||
|
|
||||||
print_success("Migrations applied");
|
if !self.config.quick {
|
||||||
|
print_success("Migrations applied");
|
||||||
|
}
|
||||||
|
tracing::debug!("PostgreSQL migrations applied");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -682,14 +710,20 @@ 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;
|
||||||
|
|
||||||
print_info("Running migrations...");
|
if !self.config.quick {
|
||||||
|
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)))?;
|
||||||
|
|
||||||
print_success("Migrations applied");
|
if !self.config.quick {
|
||||||
|
print_success("Migrations applied");
|
||||||
|
}
|
||||||
|
tracing::debug!("libSQL migrations applied");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -804,6 +838,140 @@ 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.
|
||||||
@@ -2506,7 +2674,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::save_bootstrap_env(&pairs).map_err(|e| {
|
crate::bootstrap::upsert_bootstrap_vars(&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
|
||||||
@@ -2778,6 +2946,13 @@ 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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3217,11 +3392,6 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa
|
|||||||
/// Reads `NEARAI_API_KEY` from the environment so that users who authenticated
|
/// 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.
|
/// via Cloud API key (option 4) don't get re-prompted during model selection.
|
||||||
fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
||||||
let base_url =
|
|
||||||
std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
|
|
||||||
let auth_base_url =
|
|
||||||
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
|
|
||||||
|
|
||||||
// If the user authenticated via API key (option 4), the key is stored
|
// 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
|
// as an env var. Pass it through so `resolve_bearer_token()` doesn't
|
||||||
// re-trigger the interactive auth prompt.
|
// re-trigger the interactive auth prompt.
|
||||||
@@ -3230,6 +3400,17 @@ fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
|||||||
.filter(|k| !k.is_empty())
|
.filter(|k| !k.is_empty())
|
||||||
.map(secrecy::SecretString::from);
|
.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 {
|
crate::config::LlmConfig {
|
||||||
backend: "nearai".to_string(),
|
backend: "nearai".to_string(),
|
||||||
session: crate::llm::session::SessionConfig {
|
session: crate::llm::session::SessionConfig {
|
||||||
@@ -3466,6 +3647,7 @@ 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() {
|
||||||
@@ -3480,6 +3662,7 @@ 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);
|
||||||
@@ -3813,7 +3996,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);
|
||||||
|
|
||||||
@@ -3861,7 +4043,9 @@ mod tests {
|
|||||||
fn test_build_nearai_model_fetch_config_picks_up_api_key_env() {
|
fn test_build_nearai_model_fetch_config_picks_up_api_key_env() {
|
||||||
use secrecy::ExposeSecret;
|
use secrecy::ExposeSecret;
|
||||||
|
|
||||||
|
let _lock = ENV_MUTEX.lock().unwrap();
|
||||||
let _guard = EnvGuard::set("NEARAI_API_KEY", "test-cloud-api-key-12345");
|
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();
|
let config = build_nearai_model_fetch_config();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -3872,24 +4056,37 @@ mod tests {
|
|||||||
config.nearai.api_key.as_ref().unwrap().expose_secret(),
|
config.nearai.api_key.as_ref().unwrap().expose_secret(),
|
||||||
"test-cloud-api-key-12345"
|
"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,
|
/// Regression test for #799: when NEARAI_API_KEY is absent or empty,
|
||||||
/// the config should have `api_key: None` (session token path).
|
/// the config should have `api_key: None` (session token path).
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_nearai_model_fetch_config_none_when_no_api_key() {
|
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 _guard = EnvGuard::clear("NEARAI_API_KEY");
|
||||||
|
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
|
||||||
|
|
||||||
let config = build_nearai_model_fetch_config();
|
let config = build_nearai_model_fetch_config();
|
||||||
assert!(
|
assert!(
|
||||||
config.nearai.api_key.is_none(),
|
config.nearai.api_key.is_none(),
|
||||||
"config should have no api_key when env var is absent"
|
"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.
|
/// Regression test for #799: empty NEARAI_API_KEY should be treated as absent.
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_nearai_model_fetch_config_none_when_empty_api_key() {
|
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 _guard = EnvGuard::set("NEARAI_API_KEY", "");
|
||||||
|
|
||||||
let config = build_nearai_model_fetch_config();
|
let config = build_nearai_model_fetch_config();
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! Centralized fake credential constants for tests.
|
||||||
|
//!
|
||||||
|
//! All values here are intentionally fake. Centralizing them makes security
|
||||||
|
//! audits trivial (one file to verify) and eliminates duplication across
|
||||||
|
//! the test suite.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use secrecy::SecretString;
|
||||||
|
|
||||||
|
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||||
|
|
||||||
|
// ── Encryption keys ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 32-character key string for `SecretsCrypto::new()` in tests.
|
||||||
|
pub const TEST_CRYPTO_KEY: &str = "0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
|
/// 32+ char key for web gateway `SecretsCrypto` in tests.
|
||||||
|
pub const TEST_GATEWAY_CRYPTO_KEY: &str = "test-key-at-least-32-chars-long!!";
|
||||||
|
|
||||||
|
// ── OpenAI-style API keys ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Generic OpenAI-style test API key.
|
||||||
|
pub const TEST_OPENAI_API_KEY: &str = "sk-test123";
|
||||||
|
|
||||||
|
/// OpenAI API key with longer format (config round-trip tests).
|
||||||
|
pub const TEST_OPENAI_API_KEY_LONG: &str = "sk-test-key-1234567890";
|
||||||
|
|
||||||
|
/// Short OpenAI-style key for secrets store accessibility tests.
|
||||||
|
pub const TEST_OPENAI_API_KEY_SHORT: &str = "sk-test";
|
||||||
|
|
||||||
|
/// OpenAI API key used in embeddings config issue-129 test.
|
||||||
|
pub const TEST_OPENAI_API_KEY_ISSUE_129: &str = "sk-test-key-for-issue-129";
|
||||||
|
|
||||||
|
// ── Anthropic keys ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Anthropic OAuth token for config tests.
|
||||||
|
pub const TEST_ANTHROPIC_OAUTH_TOKEN: &str = "sk-ant-oat01-test-token";
|
||||||
|
|
||||||
|
/// Anthropic API key for priority tests.
|
||||||
|
pub const TEST_ANTHROPIC_API_KEY: &str = "sk-ant-priority-key";
|
||||||
|
|
||||||
|
/// Anthropic OAuth token for sandbox config parse tests.
|
||||||
|
pub const TEST_ANTHROPIC_OAUTH_BASIC: &str = "sk-ant-oat01-basic";
|
||||||
|
|
||||||
|
/// Anthropic OAuth token in nested JSON parse test.
|
||||||
|
pub const TEST_ANTHROPIC_OAUTH_NESTED: &str = "sk-ant-oat01-primary-token";
|
||||||
|
|
||||||
|
// ── Google OAuth ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Google OAuth access token (standard test).
|
||||||
|
pub const TEST_GOOGLE_OAUTH_TOKEN: &str = "ya29.test-token";
|
||||||
|
|
||||||
|
/// Google OAuth access token (fresh/non-expired variant).
|
||||||
|
pub const TEST_GOOGLE_OAUTH_FRESH: &str = "ya29.fresh-token";
|
||||||
|
|
||||||
|
/// Google OAuth access token (legacy/no-expiry variant).
|
||||||
|
pub const TEST_GOOGLE_OAUTH_LEGACY: &str = "ya29.legacy-token";
|
||||||
|
|
||||||
|
// ── GitHub ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// GitHub personal access token (test).
|
||||||
|
pub const TEST_GITHUB_TOKEN: &str = "ghp_test123";
|
||||||
|
|
||||||
|
// ── Telegram ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Telegram bot token for credential redaction tests.
|
||||||
|
pub const TEST_TELEGRAM_BOT_TOKEN: &str = "0000000000:AAFakeTestTokenForTestingPurposesOnly";
|
||||||
|
|
||||||
|
// ── OAuth client credentials ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// OAuth client ID for token refresh tests.
|
||||||
|
pub const TEST_OAUTH_CLIENT_ID: &str = "test-client-id";
|
||||||
|
|
||||||
|
/// OAuth client secret for token refresh tests.
|
||||||
|
pub const TEST_OAUTH_CLIENT_SECRET: &str = "test-client-secret";
|
||||||
|
|
||||||
|
// ── Bearer/auth tokens ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Generic test bearer token.
|
||||||
|
pub const TEST_BEARER_TOKEN: &str = "test-token";
|
||||||
|
|
||||||
|
/// Bearer token with suffix (wasm wrapper credential injection).
|
||||||
|
pub const TEST_BEARER_TOKEN_123: &str = "test-token-123";
|
||||||
|
|
||||||
|
/// Auth token used by web gateway middleware tests.
|
||||||
|
pub const TEST_AUTH_SECRET_TOKEN: &str = "secret-token";
|
||||||
|
|
||||||
|
// ── Stripe ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Stripe-style test key.
|
||||||
|
pub const TEST_STRIPE_KEY: &str = "sk_test_fake123";
|
||||||
|
|
||||||
|
// ── Redaction test values ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Secret-prefixed key for redaction/sanitization tests.
|
||||||
|
pub const TEST_REDACT_SECRET: &str = "sk-secret";
|
||||||
|
|
||||||
|
/// Secret-prefixed key with suffix for redaction tests.
|
||||||
|
pub const TEST_REDACT_SECRET_123: &str = "sk-secret-123";
|
||||||
|
|
||||||
|
// ── Session tokens ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Generic session token for persistence tests.
|
||||||
|
pub const TEST_SESSION_TOKEN: &str = "test_token_123";
|
||||||
|
|
||||||
|
/// NEAR AI session token variant A.
|
||||||
|
pub const TEST_SESSION_NEARAI_ABC: &str = "sess_abc123";
|
||||||
|
|
||||||
|
/// NEAR AI session token variant B.
|
||||||
|
pub const TEST_SESSION_NEARAI_XYZ: &str = "sess_xyz789";
|
||||||
|
|
||||||
|
// ── Generic ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Generic test API key for LLM config, embedding config, nearai tests.
|
||||||
|
pub const TEST_API_KEY: &str = "test-key";
|
||||||
|
|
||||||
|
/// Stored secret value for create-and-get tests.
|
||||||
|
pub const TEST_SECRET_VALUE: &str = "sk-test-12345";
|
||||||
|
|
||||||
|
/// HTTP webhook secret for channel tests.
|
||||||
|
pub const TEST_HTTP_SECRET: &str = "test-secret-123";
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Create an `InMemorySecretsStore` backed by [`TEST_CRYPTO_KEY`].
|
||||||
|
///
|
||||||
|
/// Replaces the duplicated `test_store()` pattern found across multiple
|
||||||
|
/// test modules.
|
||||||
|
pub fn test_secrets_store() -> InMemorySecretsStore {
|
||||||
|
let crypto =
|
||||||
|
Arc::new(SecretsCrypto::new(SecretString::from(TEST_CRYPTO_KEY.to_string())).unwrap());
|
||||||
|
InMemorySecretsStore::new(crypto)
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
pub mod credentials;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
@@ -768,15 +768,16 @@ mod tests {
|
|||||||
/// Create a stub manager for schema tests (these don't call execute).
|
/// Create a stub manager for schema tests (these don't call execute).
|
||||||
fn test_manager_stub() -> Arc<ExtensionManager> {
|
fn test_manager_stub() -> Arc<ExtensionManager> {
|
||||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||||
|
use crate::testing::credentials::TEST_CRYPTO_KEY;
|
||||||
use crate::tools::ToolRegistry;
|
use crate::tools::ToolRegistry;
|
||||||
use crate::tools::mcp::session::McpSessionManager;
|
use crate::tools::mcp::session::McpSessionManager;
|
||||||
|
|
||||||
let master_key =
|
let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string());
|
||||||
secrecy::SecretString::from("0123456789abcdef0123456789abcdef".to_string());
|
|
||||||
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
|
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -609,6 +609,7 @@ impl Tool for HttpTool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::testing::credentials::{TEST_OPENAI_API_KEY, test_secrets_store};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_http_tool_schema_headers_is_array() {
|
fn test_http_tool_schema_headers_is_array() {
|
||||||
@@ -868,12 +869,7 @@ mod tests {
|
|||||||
let tool = HttpTool::new().with_credentials(
|
let tool = HttpTool::new().with_credentials(
|
||||||
registry,
|
registry,
|
||||||
// secrets_store is not used in requires_approval, just needs to be present
|
// secrets_store is not used in requires_approval, just needs to be present
|
||||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
Arc::new(test_secrets_store()),
|
||||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
|
||||||
"0123456789abcdef0123456789abcdef".to_string(),
|
|
||||||
))
|
|
||||||
.unwrap(),
|
|
||||||
))),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
@@ -890,15 +886,7 @@ mod tests {
|
|||||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||||
// Empty registry - no credential mappings
|
// Empty registry - no credential mappings
|
||||||
|
|
||||||
let tool = HttpTool::new().with_credentials(
|
let tool = HttpTool::new().with_credentials(registry, Arc::new(test_secrets_store()));
|
||||||
registry,
|
|
||||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
|
||||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
|
||||||
"0123456789abcdef0123456789abcdef".to_string(),
|
|
||||||
))
|
|
||||||
.unwrap(),
|
|
||||||
))),
|
|
||||||
);
|
|
||||||
|
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
@@ -926,7 +914,7 @@ mod tests {
|
|||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"headers": {"X-Custom": "Bearer sk-test123"}
|
"headers": {"X-Custom": format!("Bearer {TEST_OPENAI_API_KEY}")}
|
||||||
});
|
});
|
||||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Always);
|
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Always);
|
||||||
}
|
}
|
||||||
@@ -957,15 +945,7 @@ mod tests {
|
|||||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||||
registry.add_mappings(vec![CredentialMapping::bearer("test_key", "api.test.com")]);
|
registry.add_mappings(vec![CredentialMapping::bearer("test_key", "api.test.com")]);
|
||||||
|
|
||||||
let tool = HttpTool::new().with_credentials(
|
let tool = HttpTool::new().with_credentials(registry, Arc::new(test_secrets_store()));
|
||||||
registry,
|
|
||||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
|
||||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
|
||||||
"0123456789abcdef0123456789abcdef".to_string(),
|
|
||||||
))
|
|
||||||
.unwrap(),
|
|
||||||
))),
|
|
||||||
);
|
|
||||||
|
|
||||||
// These calls should not panic in multi-thread runtime
|
// These calls should not panic in multi-thread runtime
|
||||||
let params_no_auth = serde_json::json!({
|
let params_no_auth = serde_json::json!({
|
||||||
|
|||||||
@@ -1748,14 +1748,10 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_parse_credentials_missing_secret() {
|
async fn test_parse_credentials_missing_secret() {
|
||||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
use crate::testing::credentials::test_secrets_store;
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
let manager = Arc::new(ContextManager::new(5));
|
let manager = Arc::new(ContextManager::new(5));
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
let secrets: Arc<dyn SecretsStore + Send + Sync> = Arc::new(test_secrets_store());
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
let secrets: Arc<dyn SecretsStore + Send + Sync> =
|
|
||||||
Arc::new(InMemorySecretsStore::new(crypto));
|
|
||||||
|
|
||||||
let tool = CreateJobTool::new(manager).with_secrets(Arc::clone(&secrets));
|
let tool = CreateJobTool::new(manager).with_secrets(Arc::clone(&secrets));
|
||||||
|
|
||||||
@@ -1772,20 +1768,17 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_parse_credentials_valid() {
|
async fn test_parse_credentials_valid() {
|
||||||
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
|
use crate::secrets::CreateSecretParams;
|
||||||
use secrecy::SecretString;
|
use crate::testing::credentials::{TEST_GITHUB_TOKEN, test_secrets_store};
|
||||||
|
|
||||||
let manager = Arc::new(ContextManager::new(5));
|
let manager = Arc::new(ContextManager::new(5));
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
let secrets: Arc<dyn SecretsStore + Send + Sync> = Arc::new(test_secrets_store());
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
let secrets: Arc<dyn SecretsStore + Send + Sync> =
|
|
||||||
Arc::new(InMemorySecretsStore::new(Arc::clone(&crypto)));
|
|
||||||
|
|
||||||
// Store a secret
|
// Store a secret
|
||||||
secrets
|
secrets
|
||||||
.create(
|
.create(
|
||||||
"user1",
|
"user1",
|
||||||
CreateSecretParams::new("github_token", "ghp_test123"),
|
CreateSecretParams::new("github_token", TEST_GITHUB_TOKEN),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -158,16 +158,13 @@ impl Tool for SecretDeleteTool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
|
use crate::secrets::CreateSecretParams;
|
||||||
|
use crate::testing::credentials::{TEST_OPENAI_API_KEY_SHORT, test_secrets_store};
|
||||||
|
|
||||||
fn test_store() -> Arc<InMemorySecretsStore> {
|
fn test_store() -> Arc<crate::secrets::InMemorySecretsStore> {
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
Arc::new(test_secrets_store())
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
Arc::new(InMemorySecretsStore::new(crypto))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_ctx() -> JobContext {
|
fn test_ctx() -> JobContext {
|
||||||
@@ -183,7 +180,7 @@ mod tests {
|
|||||||
store
|
store
|
||||||
.create(
|
.create(
|
||||||
&ctx.user_id,
|
&ctx.user_id,
|
||||||
CreateSecretParams::new("openai_key", "sk-test"),
|
CreateSecretParams::new("openai_key", TEST_OPENAI_API_KEY_SHORT),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
//! 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
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;
|
||||||
@@ -43,6 +44,7 @@ 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;
|
||||||
|
|||||||
+14
-14
@@ -241,7 +241,7 @@ impl ToolRegistry {
|
|||||||
}
|
}
|
||||||
self.register_sync(Arc::new(http));
|
self.register_sync(Arc::new(http));
|
||||||
|
|
||||||
tracing::info!("Registered {} built-in tools", self.count());
|
tracing::debug!("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::info!("Registered 5 development tools");
|
tracing::debug!("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::info!("Registered 4 memory tools");
|
tracing::debug!("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::info!("Registered {} job management tools", job_tool_count);
|
tracing::debug!("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::info!("Registered 2 secret management tools (list, delete)");
|
tracing::debug!("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::info!("Registered 8 extension management tools");
|
tracing::debug!("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::info!("Registered 4 skill management tools");
|
tracing::debug!("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::info!("Registered 6 routine management tools");
|
tracing::debug!("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::info!("Registered message tool");
|
tracing::debug!("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::info!("Registered 2 image tools (generate, edit)");
|
tracing::debug!("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::info!("Registered 1 vision tool (analyze)");
|
tracing::debug!("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::info!("Registered software builder tool");
|
tracing::debug!("Registered software builder tool");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a WASM tool from bytes.
|
/// Register a WASM tool from bytes.
|
||||||
@@ -619,7 +619,7 @@ impl ToolRegistry {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(name = reg.name, "Registered WASM tool");
|
tracing::debug!(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::info!(
|
tracing::debug!(
|
||||||
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,
|
||||||
|
|||||||
+3
-2
@@ -480,6 +480,7 @@ pub fn validate_tool_schema(schema: &serde_json::Value, path: &str) -> Vec<Strin
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::testing::credentials::TEST_REDACT_SECRET;
|
||||||
|
|
||||||
/// A simple no-op tool for testing.
|
/// A simple no-op tool for testing.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -602,12 +603,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_redact_params_replaces_sensitive_key() {
|
fn test_redact_params_replaces_sensitive_key() {
|
||||||
let params = serde_json::json!({"name": "openai_key", "value": "sk-secret"});
|
let params = serde_json::json!({"name": "openai_key", "value": TEST_REDACT_SECRET});
|
||||||
let redacted = redact_params(¶ms, &["value"]);
|
let redacted = redact_params(¶ms, &["value"]);
|
||||||
assert_eq!(redacted["name"], "openai_key");
|
assert_eq!(redacted["name"], "openai_key");
|
||||||
assert_eq!(redacted["value"], "[REDACTED]");
|
assert_eq!(redacted["value"], "[REDACTED]");
|
||||||
// Original unchanged
|
// Original unchanged
|
||||||
assert_eq!(params["value"], "sk-secret");
|
assert_eq!(params["value"], TEST_REDACT_SECRET);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -365,22 +365,18 @@ fn base64_encode(input: &[u8]) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
use crate::secrets::{
|
use crate::secrets::{
|
||||||
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
||||||
SecretsCrypto, SecretsStore,
|
SecretsStore,
|
||||||
};
|
};
|
||||||
|
use crate::testing::credentials::{TEST_OPENAI_API_KEY, test_secrets_store};
|
||||||
use crate::tools::wasm::credential_injector::{
|
use crate::tools::wasm::credential_injector::{
|
||||||
CredentialInjector, base64_encode, host_matches_pattern,
|
CredentialInjector, base64_encode, host_matches_pattern,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn test_store() -> InMemorySecretsStore {
|
fn test_store() -> InMemorySecretsStore {
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
test_secrets_store()
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
InMemorySecretsStore::new(crypto)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -406,7 +402,10 @@ mod tests {
|
|||||||
async fn test_inject_bearer() {
|
async fn test_inject_bearer() {
|
||||||
let store = test_store();
|
let store = test_store();
|
||||||
store
|
store
|
||||||
.create("user1", CreateSecretParams::new("openai_key", "sk-test123"))
|
.create(
|
||||||
|
"user1",
|
||||||
|
CreateSecretParams::new("openai_key", TEST_OPENAI_API_KEY),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -428,7 +427,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.headers.get("Authorization"),
|
result.headers.get("Authorization"),
|
||||||
Some(&"Bearer sk-test123".to_string())
|
Some(&format!("Bearer {TEST_OPENAI_API_KEY}"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-12
@@ -193,18 +193,31 @@ 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> {
|
||||||
if !dir.is_dir() {
|
match fs::metadata(dir).await {
|
||||||
return Err(WasmLoadError::Io(std::io::Error::new(
|
Ok(meta) if meta.is_dir() => {}
|
||||||
std::io::ErrorKind::NotADirectory,
|
Ok(_) => {
|
||||||
format!("{} is not a directory", dir.display()),
|
return Err(WasmLoadError::Io(std::io::Error::new(
|
||||||
)));
|
std::io::ErrorKind::NotADirectory,
|
||||||
|
format!("{} is not a directory", dir.display()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(LoadResults::default());
|
||||||
|
}
|
||||||
|
Err(e) => return Err(WasmLoadError::Io(e)),
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut results = LoadResults::default();
|
// 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)),
|
||||||
|
};
|
||||||
|
|
||||||
// Collect all .wasm entries first, then load in parallel
|
let mut results = LoadResults::default();
|
||||||
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();
|
||||||
@@ -681,6 +694,7 @@ mod tests {
|
|||||||
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::testing::credentials::{TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET};
|
||||||
use crate::tools::wasm::loader::{WasmLoadError, check_wit_version_compat, discover_tools};
|
use crate::tools::wasm::loader::{WasmLoadError, check_wit_version_compat, discover_tools};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -821,8 +835,8 @@ mod tests {
|
|||||||
oauth: Some(OAuthConfigSchema {
|
oauth: Some(OAuthConfigSchema {
|
||||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||||
client_id: Some("test-client-id".to_string()),
|
client_id: Some(TEST_OAUTH_CLIENT_ID.to_string()),
|
||||||
client_secret: Some("test-client-secret".to_string()),
|
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -835,8 +849,11 @@ mod tests {
|
|||||||
|
|
||||||
let config = config.unwrap();
|
let config = config.unwrap();
|
||||||
assert_eq!(config.token_url, "https://oauth2.googleapis.com/token");
|
assert_eq!(config.token_url, "https://oauth2.googleapis.com/token");
|
||||||
assert_eq!(config.client_id, "test-client-id");
|
assert_eq!(config.client_id, TEST_OAUTH_CLIENT_ID);
|
||||||
assert_eq!(config.client_secret, Some("test-client-secret".to_string()));
|
assert_eq!(
|
||||||
|
config.client_secret,
|
||||||
|
Some(TEST_OAUTH_CLIENT_SECRET.to_string())
|
||||||
|
);
|
||||||
assert_eq!(config.secret_name, "google_oauth_token");
|
assert_eq!(config.secret_name, "google_oauth_token");
|
||||||
assert_eq!(config.provider, Some("google".to_string()));
|
assert_eq!(config.provider, Some("google".to_string()));
|
||||||
}
|
}
|
||||||
@@ -1077,4 +1094,19 @@ 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-49
@@ -1212,6 +1212,11 @@ fn coerce_params_to_schema(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::testing::credentials::{
|
||||||
|
TEST_BEARER_TOKEN_123, TEST_GOOGLE_OAUTH_FRESH, TEST_GOOGLE_OAUTH_LEGACY,
|
||||||
|
TEST_GOOGLE_OAUTH_TOKEN, TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET,
|
||||||
|
test_secrets_store,
|
||||||
|
};
|
||||||
use crate::tools::wasm::capabilities::Capabilities;
|
use crate::tools::wasm::capabilities::Capabilities;
|
||||||
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
|
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
|
||||||
|
|
||||||
@@ -1279,12 +1284,12 @@ mod tests {
|
|||||||
let mut h = HashMap::new();
|
let mut h = HashMap::new();
|
||||||
h.insert(
|
h.insert(
|
||||||
"Authorization".to_string(),
|
"Authorization".to_string(),
|
||||||
"Bearer test-token-123".to_string(),
|
format!("Bearer {TEST_BEARER_TOKEN_123}"),
|
||||||
);
|
);
|
||||||
h
|
h
|
||||||
},
|
},
|
||||||
query_params: HashMap::new(),
|
query_params: HashMap::new(),
|
||||||
secret_value: "test-token-123".to_string(),
|
secret_value: TEST_BEARER_TOKEN_123.to_string(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let store_data = StoreData::new(
|
let store_data = StoreData::new(
|
||||||
@@ -1300,7 +1305,7 @@ mod tests {
|
|||||||
store_data.inject_host_credentials("www.googleapis.com", &mut headers, &mut url);
|
store_data.inject_host_credentials("www.googleapis.com", &mut headers, &mut url);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
headers.get("Authorization"),
|
headers.get("Authorization"),
|
||||||
Some(&"Bearer test-token-123".to_string())
|
Some(&format!("Bearer {TEST_BEARER_TOKEN_123}"))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should not inject for non-matching host
|
// Should not inject for non-matching host
|
||||||
@@ -1376,13 +1381,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_resolve_host_credentials_no_http_cap() {
|
async fn test_resolve_host_credentials_no_http_cap() {
|
||||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
|
||||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
let store = test_secrets_store();
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
let store = InMemorySecretsStore::new(crypto);
|
|
||||||
|
|
||||||
let caps = Capabilities::default();
|
let caps = Capabilities::default();
|
||||||
let result = resolve_host_credentials(&caps, Some(&store), "user1", None).await;
|
let result = resolve_host_credentials(&caps, Some(&store), "user1", None).await;
|
||||||
@@ -1394,21 +1395,17 @@ mod tests {
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::secrets::{
|
use crate::secrets::{
|
||||||
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||||
SecretsCrypto, SecretsStore,
|
|
||||||
};
|
};
|
||||||
use crate::tools::wasm::capabilities::HttpCapability;
|
use crate::tools::wasm::capabilities::HttpCapability;
|
||||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
let store = test_secrets_store();
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
let store = InMemorySecretsStore::new(crypto);
|
|
||||||
|
|
||||||
store
|
store
|
||||||
.create(
|
.create(
|
||||||
"user1",
|
"user1",
|
||||||
CreateSecretParams::new("google_oauth_token", "ya29.test-token"),
|
CreateSecretParams::new("google_oauth_token", TEST_GOOGLE_OAUTH_TOKEN),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1436,7 +1433,7 @@ mod tests {
|
|||||||
assert_eq!(result[0].host_patterns, vec!["www.googleapis.com"]);
|
assert_eq!(result[0].host_patterns, vec!["www.googleapis.com"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result[0].headers.get("Authorization"),
|
result[0].headers.get("Authorization"),
|
||||||
Some(&"Bearer ya29.test-token".to_string())
|
Some(&format!("Bearer {TEST_GOOGLE_OAUTH_TOKEN}"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,16 +1441,11 @@ mod tests {
|
|||||||
async fn test_resolve_host_credentials_missing_secret() {
|
async fn test_resolve_host_credentials_missing_secret() {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::secrets::{
|
use crate::secrets::{CredentialLocation, CredentialMapping};
|
||||||
CredentialLocation, CredentialMapping, InMemorySecretsStore, SecretsCrypto,
|
|
||||||
};
|
|
||||||
use crate::tools::wasm::capabilities::HttpCapability;
|
use crate::tools::wasm::capabilities::HttpCapability;
|
||||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
let store = test_secrets_store();
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
let store = InMemorySecretsStore::new(crypto);
|
|
||||||
|
|
||||||
// No secret stored, should silently skip
|
// No secret stored, should silently skip
|
||||||
let mut credentials = HashMap::new();
|
let mut credentials = HashMap::new();
|
||||||
@@ -1483,23 +1475,19 @@ mod tests {
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::secrets::{
|
use crate::secrets::{
|
||||||
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||||
SecretsCrypto, SecretsStore,
|
|
||||||
};
|
};
|
||||||
use crate::tools::wasm::capabilities::HttpCapability;
|
use crate::tools::wasm::capabilities::HttpCapability;
|
||||||
use crate::tools::wasm::wrapper::{OAuthRefreshConfig, resolve_host_credentials};
|
use crate::tools::wasm::wrapper::{OAuthRefreshConfig, resolve_host_credentials};
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
let store = test_secrets_store();
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
let store = InMemorySecretsStore::new(crypto);
|
|
||||||
|
|
||||||
// Store a token that expires 2 hours from now (well within buffer)
|
// Store a token that expires 2 hours from now (well within buffer)
|
||||||
let expires_at = chrono::Utc::now() + chrono::Duration::hours(2);
|
let expires_at = chrono::Utc::now() + chrono::Duration::hours(2);
|
||||||
store
|
store
|
||||||
.create(
|
.create(
|
||||||
"user1",
|
"user1",
|
||||||
CreateSecretParams::new("google_oauth_token", "ya29.fresh-token")
|
CreateSecretParams::new("google_oauth_token", TEST_GOOGLE_OAUTH_FRESH)
|
||||||
.with_expiry(expires_at),
|
.with_expiry(expires_at),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1525,8 +1513,8 @@ mod tests {
|
|||||||
|
|
||||||
let oauth_config = OAuthRefreshConfig {
|
let oauth_config = OAuthRefreshConfig {
|
||||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||||
client_id: "test-client-id".to_string(),
|
client_id: TEST_OAUTH_CLIENT_ID.to_string(),
|
||||||
client_secret: Some("test-client-secret".to_string()),
|
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
|
||||||
secret_name: "google_oauth_token".to_string(),
|
secret_name: "google_oauth_token".to_string(),
|
||||||
provider: Some("google".to_string()),
|
provider: Some("google".to_string()),
|
||||||
};
|
};
|
||||||
@@ -1537,7 +1525,7 @@ mod tests {
|
|||||||
assert_eq!(result.len(), 1);
|
assert_eq!(result.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result[0].headers.get("Authorization"),
|
result[0].headers.get("Authorization"),
|
||||||
Some(&"Bearer ya29.fresh-token".to_string())
|
Some(&format!("Bearer {TEST_GOOGLE_OAUTH_FRESH}"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1546,16 +1534,12 @@ mod tests {
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::secrets::{
|
use crate::secrets::{
|
||||||
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||||
SecretsCrypto, SecretsStore,
|
|
||||||
};
|
};
|
||||||
use crate::tools::wasm::capabilities::HttpCapability;
|
use crate::tools::wasm::capabilities::HttpCapability;
|
||||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
let store = test_secrets_store();
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
let store = InMemorySecretsStore::new(crypto);
|
|
||||||
|
|
||||||
// Store an expired token
|
// Store an expired token
|
||||||
let expires_at = chrono::Utc::now() - chrono::Duration::hours(1);
|
let expires_at = chrono::Utc::now() - chrono::Duration::hours(1);
|
||||||
@@ -1595,22 +1579,18 @@ mod tests {
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::secrets::{
|
use crate::secrets::{
|
||||||
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||||
SecretsCrypto, SecretsStore,
|
|
||||||
};
|
};
|
||||||
use crate::tools::wasm::capabilities::HttpCapability;
|
use crate::tools::wasm::capabilities::HttpCapability;
|
||||||
use crate::tools::wasm::wrapper::{OAuthRefreshConfig, resolve_host_credentials};
|
use crate::tools::wasm::wrapper::{OAuthRefreshConfig, resolve_host_credentials};
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
let key = "0123456789abcdef0123456789abcdef";
|
let store = test_secrets_store();
|
||||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
|
||||||
let store = InMemorySecretsStore::new(crypto);
|
|
||||||
|
|
||||||
// Legacy token: no expires_at set
|
// Legacy token: no expires_at set
|
||||||
store
|
store
|
||||||
.create(
|
.create(
|
||||||
"user1",
|
"user1",
|
||||||
CreateSecretParams::new("google_oauth_token", "ya29.legacy-token"),
|
CreateSecretParams::new("google_oauth_token", TEST_GOOGLE_OAUTH_LEGACY),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1635,8 +1615,8 @@ mod tests {
|
|||||||
|
|
||||||
let oauth_config = OAuthRefreshConfig {
|
let oauth_config = OAuthRefreshConfig {
|
||||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||||
client_id: "test-client-id".to_string(),
|
client_id: TEST_OAUTH_CLIENT_ID.to_string(),
|
||||||
client_secret: Some("test-client-secret".to_string()),
|
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
|
||||||
secret_name: "google_oauth_token".to_string(),
|
secret_name: "google_oauth_token".to_string(),
|
||||||
provider: Some("google".to_string()),
|
provider: Some("google".to_string()),
|
||||||
};
|
};
|
||||||
@@ -1647,7 +1627,7 @@ mod tests {
|
|||||||
assert_eq!(result.len(), 1);
|
assert_eq!(result.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result[0].headers.get("Authorization"),
|
result[0].headers.get("Authorization"),
|
||||||
Some(&"Bearer ya29.legacy-token".to_string())
|
Some(&format!("Bearer {TEST_GOOGLE_OAUTH_LEGACY}"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,27 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
|||||||
+64
-1
@@ -180,6 +180,68 @@ 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)]
|
||||||
@@ -232,10 +294,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn factory_cloudflare_with_config_ok() {
|
fn factory_cloudflare_with_config_ok() {
|
||||||
|
use crate::testing::credentials::TEST_BEARER_TOKEN;
|
||||||
let cfg = TunnelProviderConfig {
|
let cfg = TunnelProviderConfig {
|
||||||
provider: "cloudflare".into(),
|
provider: "cloudflare".into(),
|
||||||
cloudflare: Some(CloudflareTunnelConfig {
|
cloudflare: Some(CloudflareTunnelConfig {
|
||||||
token: "test-token".into(),
|
token: TEST_BEARER_TOKEN.into(),
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-2
@@ -419,13 +419,14 @@ fn parse_finish_reason(s: &str) -> FinishReason {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::testing::credentials::TEST_BEARER_TOKEN;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_url_construction() {
|
fn test_url_construction() {
|
||||||
let client = WorkerHttpClient::new(
|
let client = WorkerHttpClient::new(
|
||||||
"http://host.docker.internal:50051".to_string(),
|
"http://host.docker.internal:50051".to_string(),
|
||||||
Uuid::nil(),
|
Uuid::nil(),
|
||||||
"test-token".to_string(),
|
TEST_BEARER_TOKEN.to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -449,7 +450,7 @@ mod tests {
|
|||||||
let client = WorkerHttpClient::new(
|
let client = WorkerHttpClient::new(
|
||||||
"http://host.docker.internal:50051".to_string(),
|
"http://host.docker.internal:50051".to_string(),
|
||||||
Uuid::nil(),
|
Uuid::nil(),
|
||||||
"test-token".to_string(),
|
TEST_BEARER_TOKEN.to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -33,3 +33,61 @@ 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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ use tempfile::tempdir;
|
|||||||
|
|
||||||
use ironclaw::bootstrap::{save_bootstrap_env_to, upsert_bootstrap_var_to};
|
use ironclaw::bootstrap::{save_bootstrap_env_to, upsert_bootstrap_var_to};
|
||||||
|
|
||||||
|
/// Fake OpenAI API key for test use only. Mirrors the internal
|
||||||
|
/// `TEST_OPENAI_API_KEY_LONG` constant from the main crate, which is not
|
||||||
|
/// directly available to integration tests due to `#[cfg(test)]`.
|
||||||
|
const TEST_OPENAI_API_KEY_LONG: &str = "sk-test-key-1234567890";
|
||||||
|
|
||||||
/// Parse a .env file into a HashMap using dotenvy.
|
/// Parse a .env file into a HashMap using dotenvy.
|
||||||
fn read_env_map(path: &std::path::Path) -> HashMap<String, String> {
|
fn read_env_map(path: &std::path::Path) -> HashMap<String, String> {
|
||||||
dotenvy::from_path_iter(path)
|
dotenvy::from_path_iter(path)
|
||||||
@@ -77,7 +82,7 @@ fn bootstrap_env_round_trips_embedding_disabled() {
|
|||||||
&[
|
&[
|
||||||
("DATABASE_BACKEND", "libsql"),
|
("DATABASE_BACKEND", "libsql"),
|
||||||
("EMBEDDING_ENABLED", "false"),
|
("EMBEDDING_ENABLED", "false"),
|
||||||
("OPENAI_API_KEY", "sk-test-key-1234567890"),
|
("OPENAI_API_KEY", TEST_OPENAI_API_KEY_LONG),
|
||||||
("ONBOARD_COMPLETED", "true"),
|
("ONBOARD_COMPLETED", "true"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -92,7 +97,7 @@ fn bootstrap_env_round_trips_embedding_disabled() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
map.get("OPENAI_API_KEY").map(String::as_str),
|
map.get("OPENAI_API_KEY").map(String::as_str),
|
||||||
Some("sk-test-key-1234567890"),
|
Some(TEST_OPENAI_API_KEY_LONG),
|
||||||
"OPENAI_API_KEY must be preserved alongside EMBEDDING_ENABLED"
|
"OPENAI_API_KEY must be preserved alongside EMBEDDING_ENABLED"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user