Compare commits

...
Author SHA1 Message Date
serrrfirat 0f347d1f63 Revert "fix: remove auto-proceed fake user message injection from agent loop (#255)"
This reverts commit 8a4f3b6f88.
2026-02-20 12:41:02 +04:00
3829d81269 fix: consolidate per-module ENV_MUTEX into crate-wide test lock (#246)
Each config test module (llm.rs, embeddings.rs) defined its own
ENV_MUTEX, which doesn't prevent cross-module env races since
cargo test runs in parallel. Move to a single shared mutex in
config/helpers.rs so all unsafe set_var/remove_var calls are
serialized crate-wide.

Closes #245

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 08:11:10 +00:00
8a4f3b6f88 fix: remove auto-proceed fake user message injection from agent loop (#255)
The agentic loop injected fake user messages ("Please proceed and use
the available tools to complete this task.") when the LLM responded
with text instead of tool calls. This caused hallucinated conversations
during casual chat, 3x wasted LLM calls, and trust issues.

Remove the `resume_after_tool` parameter and `tools_executed` tracking
entirely. Text responses now return immediately, trusting the LLM to
decide when tools are needed (consistent with ZeroClaw and OpenClaw).

Closes #145

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 08:09:52 +00:00
140f29decf ci: add automated PR labeling system (#253)
* ci: add automated PR labeling system

Add two independent workflows for PR auto-labeling:
- Scope labels via actions/labeler (path glob matching)
- Size, risk, and contributor tier via custom shell script

Includes idempotent label bootstrap script (create-labels.sh).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: temporarily use pull_request trigger for testing

Switch to pull_request so workflows run from the PR branch.
Will revert to pull_request_target before merge.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(ci): use absolute path for search/issues API call

gh api requires a leading slash for REST endpoints.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(ci): use gh pr list instead of search API for contributor count

The search/issues API returns 404 with the default GITHUB_TOKEN.
gh pr list --state merged works with standard permissions.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: revert to pull_request_target for fork PR support

Restore pull_request_target trigger and base branch checkout
now that testing is complete.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 12:02:41 +04:00
5725a62c83 fix: onboarding errors reset flow and remote server auth (#185, #186) (#248)
* fix: incremental settings persistence and remote server auth (#185, #186)

Persist settings after each wizard step so failures don't lose prior
progress. Load existing settings on re-run to recover from partial
onboarding. Add manual token paste option for remote/headless servers
where browser OAuth is unreachable, and support IRONCLAW_OAUTH_CALLBACK_URL
for custom callback URLs. Color prompt output (green/red/blue prefixes).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: replace session token paste with API key entry, address PR review

Replace option 4 in NEAR AI auth menu from session token paste to NEAR
AI Cloud API key entry (cloud.near.ai). Also address all PR review
feedback: restrict .env file permissions to 0o600, mask API key input
with secret_input, fix libsql loaded flag in try_load_existing_settings,
add ENV_MUTEX to oauth_defaults tests, and add NEARAI_API_KEY to secrets
injection.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: deduplicate keys in upsert_bootstrap_var

When the .env file contains duplicate keys (e.g. from manual editing),
only write the replacement once and skip subsequent duplicates.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: NEARAI_SESSION_TOKEN env var takes precedence over file-based tokens

Hosting providers inject session tokens via env var and expect them to
be used directly. Previously the env var was only picked up when no
session file existed and was treated as a legacy migration. Now the env
var always wins, without persisting to disk.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: distinguish NEAR AI Chat and NEAR AI Cloud providers

Split documentation into two clearly named modes:
- NEAR AI Chat: Responses API at private.near.ai, session token auth
- NEAR AI Cloud: Chat Completions API at cloud-api.near.ai, API key auth

Update default base URLs so each mode points to its correct endpoint.
Update .env.example, deploy/env.example, CLAUDE.md, setup spec, and
code comments across config/llm.rs, nearai.rs, nearai_chat.rs, mod.rs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: wizard recovery ordering — load DB before persist, fresh choices win

Previously, persist_after_step() ran after Step 1 but before
try_load_existing_settings(), bulk-upserting defaults that clobbered
prior settings. Additionally, merge_from gave stale DB values
precedence over fresh Step 1 choices.

Fix: snapshot Step 1 settings, load DB, then re-apply the snapshot.
This ensures prior progress (steps 2-7) is recovered while fresh
Step 1 choices override stale DB values.

Add two tests verifying wizard recovery merge ordering.

Addresses PR review comments from Copilot on wizard.rs:150,
wizard.rs:1607, and wizard.rs:1626.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix rustfmt formatting in config/llm.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: collapse nested if per clippy collapsible_if lint

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use print_success for API key confirmation, fix menu spacing

- Use print_success() for colored output consistency in api_key_login
- Fix box-drawing alignment: options 1-2 had an extra trailing space

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 08:02:22 +00:00
bfe393eb38 fix: parallelize tool call execution via JoinSet (#219) (#252)
* fix: parallelize tool call execution via JoinSet (#219)

When the LLM returns multiple tool_calls in a single response, they were
executed sequentially. This change makes both the worker and dispatcher
paths concurrent using tokio::task::JoinSet, so N independent tool calls
complete in ~max(latency) instead of sum(latency).

Worker path: migrate execute_tools_parallel from join_all to JoinSet and
route the respond_with_tools branch through the same parallel path.

Dispatcher path: restructure the while-idx loop into three phases —
preflight (sequential approval/hook checks), parallel execution via
JoinSet, and sequential post-flight processing (session recording,
auth detection, sanitization).

Also fixes a pre-existing infinite loop bug where hook rejection used
`continue` inside a `while idx` loop, skipping `idx += 1` and retrying
the same rejected tool forever.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review — ordered results, deferred auth, dedup standalone fn

- Fix auth early return skipping unrecorded tool results: defer auth
  response until after all results in the batch are recorded in session
  history and context_messages (both dispatcher and thread_ops paths)
- Fix tool results appearing out of order: collect Phase 1 hook
  rejections indexed by original position, merge with Phase 2 execution
  results, and emit all in Phase 3 in original tool_calls order
- Deduplicate execute_chat_tool: Agent method now delegates to the
  standalone function instead of duplicating 90 lines of logic
- Fix benchmark compilation: add missing session_manager arg to Agent::new

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: rustfmt alignment for CI compatibility

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address second round of PR review comments

- Distinguish JoinError panic vs cancellation in log messages and error
  reasons across all 3 files (dispatcher, thread_ops, worker)
- Simplify deferred_auth from Option<(String, String)> to Option<String>
  since only the instructions string is used
- Add single-tool short-circuit in worker execute_tools_parallel to
  avoid JoinSet overhead for the common single-tool case

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 06:07:30 +00:00
AI-Reviewer-QSandGitHub 9906190de7 fix: prevent pipe deadlock in shell command execution (#140)
Drain stdout and stderr concurrently with child.wait() using tokio::join
to prevent deadlocks when command output exceeds the OS pipe buffer
(64KB on Linux, 16KB on macOS).

Use AsyncReadExt::take() for memory-bounded reads and
tokio::io::copy to sink for draining excess output.

Add regression test that generates 128KB of output to verify the
fix prevents deadlocks.
2026-02-20 03:07:46 +00:00
Illia PolosukhinandClaude Opus 4.6 9349a3baca fix: add missing session_manager arg to Agent::new in benchmark runner
Agent::new gained an 8th parameter (session_manager) but the benchmark
runner was not updated, breaking compilation of the bench crate.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-19 18:32:56 -08:00
3f135bdde9 fix: persist turns after approval and add agent-level tests (#250)
* fix: persist turns after approval and add agent-level tests

Port relevant changes from PR #112 that were not carried over to #237:

- Add persist_turn calls in process_approval for the response, error,
  and auth-required paths. Previously, turns completed after tool
  approval were never persisted to DB — if the process crashed after
  approval the entire turn (user message + assistant response) was lost.

- Add agent-level unit tests: StaticLlmProvider mock, make_test_agent
  helper, tests for auto-approval logic, destructive shell command
  detection, and PendingApproval backward-compatible deserialization
  (without deferred_tool_calls field).

- Remove unused _thread_state binding in process_approval.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address 14 audit findings in src/agent/

Audit of the agent module found 2 High, 7 Medium, 3 Low, and 2 Nit
severity issues. This commit fixes all of them:

High:
- Remove 4 `.expect()` calls in session.rs (entry API, match, direct
  indexing, if-let) to eliminate panic paths in production
- Add typed RoutineError enum replacing Result<_, String> across
  routine.rs, routine_engine.rs, and callers in history/store.rs and
  db/libsql/mod.rs

Medium:
- Sanitize routine names in path construction to prevent directory
  traversal (routine_engine.rs)
- Log warnings for 5 silently-swallowed errors in scheduler.rs,
  compaction.rs, and worker.rs
- Extract shared handle_auth_intercept helper to deduplicate auth
  interception in thread_ops.rs
- Add session count warning threshold in session_manager.rs
- Make FullJob stub degradation visible via warn-level log and
  prepended warning in output

Low:
- Restrict dead code visibility with #[cfg(test)] on 19 unused items
  in submission.rs, task.rs, and undo.rs
- Narrow pub to pub(crate) on self_repair.rs builder methods
- Remove TaskStatus from mod.rs re-exports (test-only type)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review comments

- Reorder persist_turn before persist_response_chain so the
  conversation row exists before the metadata UPDATE runs
- Add persist_response_chain call to handle_auth_intercept so
  auth-required paths preserve the response chain
- Harden sanitize_routine_name to use allowlist (alphanumeric,
  dash, underscore) instead of denylist replacements
- Fix stale active_thread ID in get_or_create_thread: fall back
  to create_thread() when the stored ID is missing from the map
- Persist turn on approval rejection so user messages survive
  crashes after a tool is rejected

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 02:28:15 +00:00
97a7637f30 feat: extension registry with metadata catalog and onboarding integration (#238)
* feat: add extension registry with metadata catalog, CLI, and onboarding integration

Adds a central registry that catalogs all 14 available extensions (10 tools,
4 channels) with their capabilities, auth requirements, and artifact references.
The onboarding wizard now shows installable channels from the registry and
offers tool installation as a new Step 7.

- registry/ folder with per-extension JSON manifests and bundle definitions
- src/registry/ module: manifest structs, catalog loader, installer
- `ironclaw registry list|info|install|install-defaults` CLI commands
- Setup wizard enhanced: channels from registry, new extensions step (8 steps)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(setup): resolve workspace errors for tool crates and channels-only onboarding

Tool crates in tools-src/ and channels-src/ failed `cargo metadata` during
onboard install because Cargo resolved them as part of the root workspace.
Add `[workspace]` table to each standalone crate and extend the root
`workspace.exclude` list so they build independently.

Channels-only mode (`onboard --channels-only`) failed with "Secrets not
configured" and "No database connection" because it skipped database and
security setup. Add `reconnect_existing_db()` to establish the DB connection
and load saved settings before running channel configuration.

Also improve the tunnel "already configured" display to show full provider
details (domain, mode, command) instead of just the provider name.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(registry): address PR review feedback on installer and catalog

- Use manifest.name (not crate_name) for installed filenames so
  discovery, auth, and CLI commands all agree on the stem (#1)
- Add AlreadyInstalled error variant instead of misleading
  ExtensionNotFound (#2)
- Add DownloadFailed error variant with URL context instead of
  stuffing URLs into PathBuf (#3)
- Validate HTTP status with error_for_status() before reading
  response bytes in artifact downloads (#4)
- Switch build_wasm_component to tokio::process::Command with
  status() so build output streams to the terminal (#6)
- Find WASM artifact by crate_name specifically instead of picking
  the first .wasm file in the release directory (#7)
- Add is_file() guard in catalog loader to skip directories (#8)
- Detect ambiguous bare-name lookups when both tools/<name> and
  channels/<name> exist, with get_strict() returning an error (#9)
- Fix wizard step_extensions to check tool.name for installed
  detection, consistent with the new naming (#11, #12)
- Fix redundant closures and map_or clippy warnings in changed files

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(setup): restore DB connection fields after settings reload

reconnect_postgres() and reconnect_libsql() called Settings::from_db_map()
which overwrote database_url / libsql_path / libsql_url set from env vars.
Also use get_strict() in cmd_info to surface ambiguous bare-name errors.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix clippy collapsible_if and print_literal warnings

Collapse nested if-let chains and inline string literals in format
macros to satisfy CI clippy lint checks (deny warnings).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(registry): prefer artifacts for install-defaults and improve dir lookup

- InstallDefaults now defaults to downloading pre-built artifacts
  (matching `registry install` behavior), with --build flag for source builds.
- find_registry_dir() walks up 3 ancestor levels from the exe and adds
  a CARGO_MANIFEST_DIR fallback, matching load_registry_catalog() logic.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 01:17:44 +00:00
bigguybobbyandGitHub dae26d640e feat(models): add GPT-5.3 Codex, full GPT-5.x family, Claude 4.x series, o4-mini (#197)
Fixes #184 — updates model selection, priority sort, and cost table to
match current OpenAI and Anthropic model catalogs.

OpenAI: GPT-5.3 Codex, GPT-5.2 Codex/Pro, GPT-5.1 Codex/Mini/Max,
GPT-5/Mini/Nano, GPT-4.1/Mini/Nano, o4-mini, o3/Pro
Anthropic: Claude Opus 4.6/4.5/4.1/4.0, Claude Sonnet 4.6/4.5/4.0,
Claude Haiku 4.5, Claude 3.7 Sonnet, Claude 3.5 Haiku

Also resolves stale merge-conflict markers in http.rs and json.rs.
2026-02-20 01:16:13 +00:00
fa64df05ff feat: wire memory hygiene into the heartbeat loop (#195)
* feat: wire memory hygiene into heartbeat loop (#166)

* refactor: address PR review comments for hygiene wiring

* style: fix fmt import ordering and clippy too_many_arguments warning

* fix: update heartbeat integration test to pass HygieneConfig argument

HeartbeatRunner::new() now requires a HygieneConfig as its second
argument after the hygiene wiring refactor. Pass the default config
in the integration test.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-02-20 01:13:25 +00:00
356f56f77c docs: update CLAUDE.md for recently merged features (#183)
* docs: update CLAUDE.md for recently merged features

Document skills system, sandbox network proxy, leak detector,
Tinfoil private inference, setup wizard, and shell env scrubbing
that were merged but not reflected in CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: fix SKILL.md format example and scoring description

Align SKILL.md frontmatter example with actual SkillManifest struct:
activation block with patterns/keywords/max_context_tokens, requires
nested under metadata.openclaw. Fix scoring pipeline description to
mention keywords, tags, and regex patterns instead of triggers/intents.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: optimize CLAUDE.md structure and reduce from 959 to 671 lines

- Update llm/ directory tree (4 -> 12 files to match actual codebase)
- Fix "NEAR AI (required)" -> "NEAR AI (when LLM_BACKEND=nearai)"
- Remove 28-item Completed changelog list (no actionable value)
- Deduplicate 3 config blocks with cross-references
- Extract Workspace deep-dive to src/workspace/README.md
- Extract Tool Architecture deep-dive to src/tools/README.md
- Consolidate Code Style and Review Discipline under Key Patterns
- Add workspace and tools to Module Specifications table

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 01:04:39 +00:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
17434d6499 chore: release v0.7.0 (#239)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-20 00:37:58 +00:00
3f58ed6232 fix: persist onboard_completed to bootstrap .env so config survives restart (#241)
* fix: persist onboard_completed to bootstrap .env so config survives restart (#187)

The wizard saved settings to the database but check_onboard_needed() read
from the legacy settings.json on disk, causing re-onboarding on every run
for non-NEAR AI users. Write ONBOARD_COMPLETED=true to ~/.ironclaw/.env
and check that env var instead of the legacy file.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Copilot <[email protected]>
2026-02-20 00:33:53 +00:00
097a26ace6 fix: harden openai-compatible provider, approval replay, and embeddings defaults (#237)
* fix: harden openai-compatible tool flow and local defaults

* fix: close approval replay gaps and harden openai-compatible flow

* fix: address review feedback and code improvements (takeover #112)

- Make ChatCompletionResponse.id Optional<String> to handle providers
  that omit or null the field
- Propagate HTTP client builder errors instead of silently dropping
  timeout configuration (openai_compatible_chat, nearai_chat)
- Add EMBEDDING_DIMENSION env var with smart per-model defaults instead
  of hardcoding 768/1536 everywhere
- Remove duplicated dimension inference logic from main.rs

Co-Authored-By: panosAthDBX <[email protected]>
Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: harden src/llm/ module from crate audit findings

- Replace 9x .expect() on RwLock with graceful poison recovery
  (nearai.rs: 7, nearai_chat.rs: 2) — eliminates production panics
- Propagate HTTP client builder errors in nearai.rs instead of
  silently dropping timeout config (NearAiProvider::new now returns Result)
- Make nearai_chat ChatCompletionResponse.id Optional<String>
  (mirrors openai_compatible_chat.rs fix for providers that omit id)
- Make nearai_chat usage fields optional with defensive parse_usage()
  helper (was required u32 fields that crash on null/missing)
- Truncate error responses to 512 chars in nearai_chat.rs error
  messages to prevent log bloat and potential data leakage
- Delegate 4 missing LlmProvider methods in FailoverProvider
  (model_metadata, seed_response_chain, get_response_chain_id,
  calculate_cost) to last-used provider instead of trait defaults

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor(llm): add RetryProvider, remove openai_compatible_chat, harden decorators

- Add composable RetryProvider decorator wrapping any LlmProvider with
  exponential backoff + jitter, respecting RateLimited retry_after hints
- Remove openai_compatible_chat.rs — replaced by rig adapter + RetryProvider
- Remove internal retry loop from nearai.rs (was causing double-retry
  with external RetryProvider, up to 16 attempts instead of 4)
- Remove internal retry loop from nearai_chat.rs (same issue)
- Wire RetryProvider into main.rs composition chain: each provider gets
  its own retry wrapper before failover
- Move normalize_tool_name to rig_adapter.rs for all rig-based providers
- Reconcile is_retryable() vs is_transient() error classification:
  ModelNotAvailable no longer retryable, Json no longer transient
- Fix unchecked Duration subtraction panic in circuit_breaker.rs
- Make failover.rs use shared is_retryable() from retry.rs
- Remove stale #[allow(dead_code)] on NearAiResponse::id (field is used)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback — error handling, dimension validation, libSQL warning

- Replace response.text().await.unwrap_or_default() with proper error
  propagation in nearai.rs and nearai_chat.rs (4 call sites). Failures
  now return LlmError::RequestFailed with context instead of silently
  proceeding with an empty string.
- Add embedding dimension validation in OllamaEmbeddings::embed_batch():
  returns EmbeddingError if Ollama returns embeddings with a dimension
  that doesn't match the configured value.
- Add runtime warning when libSQL backend is used with non-1536 embedding
  dimension, since the libSQL schema uses F32_BLOB(1536) and cannot store
  different-dimension vectors.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Apply suggestions from code review

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: panosAthDbx <[email protected]>
Co-authored-by: panosAthDBX <[email protected]>
Co-authored-by: panosAthDBX <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Copilot <[email protected]>
2026-02-19 23:05:04 +00:00
e87d7bd066 feat: extend lifecycle hooks with declarative bundles (#176)
* feat: add bundled and declarative hook bundle loading

* fix: load plugin hooks only for active extensions

* fix: avoid duplicate plugin hook registration

* security: harden outbound webhook hooks

* fix: pin webhook DNS resolutions for outbound hooks

* fix: block IPv4-mapped local webhook targets

* style: format webhook hardening changes for CI

* fix: pass HookRegistry to ExtensionManager in AppBuilder

After merging main (which extracted AppBuilder from main.rs in #198),
the ExtensionManager::new() call in app.rs was missing the `hooks`
parameter that PR #176 added. This moves HookRegistry creation before
init_extensions() and threads it through, matching the existing pattern
in main.rs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-19 23:00:54 +00:00
e42b1e5ec1 fix: Network Security Findings (#201)
* docs(security): add network security reference for all listeners

Catalogs every network-facing surface (web gateway, webhook server,
orchestrator API, OAuth callback, sandbox proxy) with auth mechanisms,
bind addresses, egress controls, known findings, and a review checklist
for PRs that touch network-facing code.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(security): address three network security findings

- Use constant-time comparison (ct_eq) for webhook secret validation,
  matching the pattern in web gateway and orchestrator auth
- Add X-Content-Type-Options and X-Frame-Options security headers to
  the web gateway via SetResponseHeaderLayer
- Warn at startup when HTTP webhook server binds to 0.0.0.0
- Update NETWORK_SECURITY.md to mark findings 1, 4, 5 as resolved

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(security): address PR #201 review findings

- Reorder web gateway layers so security headers (X-Content-Type-Options,
  X-Frame-Options) are outermost and apply to all responses including
  DefaultBodyLimit 413 rejections
- Move 0.0.0.0 warning to final bind address resolution so it fires for
  WASM-only webhook servers that fall back to the default address
- Add webhook handler auth tests: correct secret -> 200, wrong secret
  -> 401, missing secret -> 401
- Rewrite NETWORK_SECURITY.md: replace brittle line-number references
  with function/struct name anchors, add threat model section, document
  graceful shutdown per listener, fill content gaps (health endpoint
  responses, content-type validation, CSRF analysis, WS auth flow, MCP
  trust boundary, orchestrator rate limiting), change findings F-4/F-5
  from "Resolved" to "Mitigated" with caveats

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix rustfmt and clippy warnings from main merge

Fix formatting in llm/mod.rs and llm/rig_adapter.rs introduced by
PR #132, and collapse nested if in rig_adapter.rs per clippy.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-19 22:01:57 +00:00
ccf60055f4 feat: support per-request model override in /v1/chat/completions (#103)
* feat: support per-request model override for /v1/chat/completions

- add optional model override to completion request types\n- forward request model through gateway, worker, and orchestrator proxy paths\n- use request model in NEAR AI providers with fallback to active model\n- replace model-mismatch integration test with override propagation checks\n- update FEATURE_PARITY.md note for OpenAI-compatible API behavior\n\nRefs #49

* Wire gateway OpenAI-compatible routes to active LLM provider

* Validate OpenAI model name length before streaming

* Address PR103 review feedback on model override and validation

* Report effective model in OpenAI-compatible responses

* Use async mutexes in OpenAI compatibility integration tests

* fix tests for per-request model field in response cache

* fix formatting and clippy lint after main merge

* Fix model override reporting and cache correctness

---------

Co-authored-by: Illia Polosukhin <[email protected]>
2026-02-19 21:45:37 +00:00
124 changed files with 10612 additions and 1501 deletions
+23 -8
View File
@@ -2,18 +2,27 @@
DATABASE_URL=postgres://localhost/ironclaw
DATABASE_POOL_SIZE=10
# LLM Provider (NEAR AI)
# NEAR AI provides a unified interface to all models with user authentication
# Session token is stored in ~/.ironclaw/session.json and managed automatically.
# On first run, the agent will open a browser for OAuth authentication.
NEARAI_MODEL=claude-3-5-sonnet-20241022
# LLM Provider
# LLM_BACKEND=nearai # default
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
# === NEAR AI Chat (Responses API, session token auth) ===
# Default mode. Uses browser OAuth (GitHub/Google) on first run.
# Session token stored in ~/.ironclaw/session.json automatically.
# For hosting providers: set NEARAI_SESSION_TOKEN env var directly.
NEARAI_MODEL=zai-org/GLM-5-FP8
NEARAI_BASE_URL=https://private.near.ai
NEARAI_AUTH_URL=https://private.near.ai
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown
# NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown
# === NEAR AI Cloud (Chat Completions API, API key auth) ===
# Auto-selected when NEARAI_API_KEY is set. Get a key from cloud.near.ai.
# NEARAI_API_KEY=...
# NEARAI_BASE_URL=https://cloud-api.near.ai # default for cloud mode
# NEARAI_API_MODE=chat_completions # auto-detected from API key
# Local LLM Providers (Ollama, LM Studio, vLLM, LiteLLM)
# LLM_BACKEND=nearai # default
# Possible values: nearai, ollama, openai_compatible, openai, anthropic
# === Ollama ===
# OLLAMA_MODEL=llama3.2
@@ -68,6 +77,12 @@ HEARTBEAT_INTERVAL_SECS=1800
HEARTBEAT_NOTIFY_CHANNEL=cli
HEARTBEAT_NOTIFY_USER=default
# Memory hygiene settings (automatic cleanup of stale workspace documents)
# Runs on each heartbeat tick; identity files (IDENTITY.md, SOUL.md) are never deleted
# MEMORY_HYGIENE_ENABLED=true
# MEMORY_HYGIENE_RETENTION_DAYS=30 # delete daily/ docs older than this many days
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
# Safety settings
SAFETY_MAX_OUTPUT_LENGTH=100000
SAFETY_INJECTION_CHECK_ENABLED=true
+166
View File
@@ -0,0 +1,166 @@
# Scope labels for actions/labeler@v6
# Maps file path globs to scope labels. Multiple labels can apply per PR.
"scope: agent":
- changed-files:
- any-glob-to-any-file:
- src/agent/**
"scope: channel":
- changed-files:
- any-glob-to-any-file:
- src/channels/channel.rs
- src/channels/manager.rs
- src/channels/mod.rs
"scope: channel/cli":
- changed-files:
- any-glob-to-any-file:
- src/channels/cli/**
- src/cli/**
"scope: channel/web":
- changed-files:
- any-glob-to-any-file:
- src/channels/web/**
"scope: channel/wasm":
- changed-files:
- any-glob-to-any-file:
- src/channels/wasm/**
"scope: tool":
- changed-files:
- any-glob-to-any-file:
- src/tools/tool.rs
- src/tools/registry.rs
- src/tools/mod.rs
- src/tools/sandbox.rs
"scope: tool/builtin":
- changed-files:
- any-glob-to-any-file:
- src/tools/builtin/**
"scope: tool/wasm":
- changed-files:
- any-glob-to-any-file:
- src/tools/wasm/**
"scope: tool/mcp":
- changed-files:
- any-glob-to-any-file:
- src/tools/mcp/**
"scope: tool/builder":
- changed-files:
- any-glob-to-any-file:
- src/tools/builder/**
"scope: db":
- changed-files:
- any-glob-to-any-file:
- src/db/mod.rs
"scope: db/postgres":
- changed-files:
- any-glob-to-any-file:
- src/db/postgres.rs
- migrations/**
"scope: db/libsql":
- changed-files:
- any-glob-to-any-file:
- src/db/libsql_backend.rs
- src/db/libsql_migrations.rs
"scope: safety":
- changed-files:
- any-glob-to-any-file:
- src/safety/**
"scope: llm":
- changed-files:
- any-glob-to-any-file:
- src/llm/**
"scope: workspace":
- changed-files:
- any-glob-to-any-file:
- src/workspace/**
"scope: orchestrator":
- changed-files:
- any-glob-to-any-file:
- src/orchestrator/**
"scope: worker":
- changed-files:
- any-glob-to-any-file:
- src/worker/**
"scope: secrets":
- changed-files:
- any-glob-to-any-file:
- src/secrets/**
"scope: config":
- changed-files:
- any-glob-to-any-file:
- src/config.rs
- src/settings.rs
"scope: extensions":
- changed-files:
- any-glob-to-any-file:
- src/extensions/**
"scope: setup":
- changed-files:
- any-glob-to-any-file:
- src/setup/**
"scope: evaluation":
- changed-files:
- any-glob-to-any-file:
- src/evaluation/**
"scope: estimation":
- changed-files:
- any-glob-to-any-file:
- src/estimation/**
"scope: sandbox":
- changed-files:
- any-glob-to-any-file:
- src/sandbox/**
- Dockerfile*
"scope: hooks":
- changed-files:
- any-glob-to-any-file:
- src/hooks/**
"scope: pairing":
- changed-files:
- any-glob-to-any-file:
- src/pairing/**
"scope: ci":
- changed-files:
- any-glob-to-any-file:
- .github/workflows/**
- .github/scripts/**
"scope: docs":
- changed-files:
- any-glob-to-any-file:
- "**/*.md"
- docs/**
- LICENSE*
"scope: dependencies":
- changed-files:
- any-glob-to-any-file:
- Cargo.toml
- Cargo.lock
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Idempotent label bootstrap for IronClaw PR automation.
# Uses `gh label create --force` so it can be re-run safely.
#
# Usage: bash .github/scripts/create-labels.sh
# Requires: gh CLI authenticated with repo scope
set -euo pipefail
if ! command -v gh &>/dev/null; then
echo "Error: gh CLI is required. Install from https://cli.github.com" >&2
exit 1
fi
create() {
local name="$1" color="$2" description="$3"
gh label create "$name" --color "$color" --description "$description" --force
}
echo "==> Creating size labels..."
create "size: XS" "F9D0C4" "< 10 changed lines (excluding docs)"
create "size: S" "F5A3A3" "10-49 changed lines"
create "size: M" "E57373" "50-199 changed lines"
create "size: L" "D32F2F" "200-499 changed lines"
create "size: XL" "B71C1C" "500+ changed lines"
echo "==> Creating risk labels..."
create "risk: low" "4CAF50" "Changes to docs, tests, or low-risk modules"
create "risk: medium" "FFC107" "Business logic, config, or moderate-risk modules"
create "risk: high" "F44336" "Safety, secrets, auth, or critical infrastructure"
create "risk: manual" "9E9E9E" "Risk level set manually (sticky, not overwritten)"
echo "==> Creating scope labels..."
create "scope: agent" "006B75" "Agent core (agent loop, router, scheduler)"
create "scope: channel" "00838F" "Channel infrastructure"
create "scope: channel/cli" "00897B" "TUI / CLI channel"
create "scope: channel/web" "00796B" "Web gateway channel"
create "scope: channel/wasm" "00695C" "WASM channel runtime"
create "scope: tool" "1565C0" "Tool infrastructure"
create "scope: tool/builtin" "1976D2" "Built-in tools"
create "scope: tool/wasm" "1E88E5" "WASM tool sandbox"
create "scope: tool/mcp" "2196F3" "MCP client"
create "scope: tool/builder" "42A5F5" "Dynamic tool builder"
create "scope: db" "4A148C" "Database trait / abstraction"
create "scope: db/postgres" "6A1B9A" "PostgreSQL backend"
create "scope: db/libsql" "7B1FA2" "libSQL / Turso backend"
create "scope: safety" "880E4F" "Prompt injection defense"
create "scope: llm" "4527A0" "LLM integration"
create "scope: workspace" "283593" "Persistent memory / workspace"
create "scope: orchestrator" "0D47A1" "Container orchestrator"
create "scope: worker" "01579B" "Container worker"
create "scope: secrets" "BF360C" "Secrets management"
create "scope: config" "E65100" "Configuration"
create "scope: extensions" "33691E" "Extension management"
create "scope: setup" "827717" "Onboarding / setup"
create "scope: evaluation" "558B2F" "Success evaluation"
create "scope: estimation" "9E9D24" "Cost/time estimation"
create "scope: sandbox" "00BFA5" "Docker sandbox"
create "scope: hooks" "6D4C41" "Git/event hooks"
create "scope: pairing" "4E342E" "Pairing mode"
create "scope: ci" "546E7A" "CI/CD workflows"
create "scope: docs" "78909C" "Documentation"
create "scope: dependencies" "90A4AE" "Dependency updates"
echo "==> Creating contributor labels..."
create "contributor: new" "FFF9C4" "First-time contributor"
create "contributor: regular" "FFE082" "2-5 merged PRs"
create "contributor: experienced" "FFB74D" "6-19 merged PRs"
create "contributor: core" "FF8A65" "20+ merged PRs"
echo "Done. All labels created/updated."
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Classify a PR by size, risk, and contributor tier.
# Called by the pr-label-classify workflow.
#
# Inputs (env vars):
# PR_NUMBER — pull request number
# REPO — owner/repo (e.g. "user/ironclaw")
#
# Requires: gh CLI, jq
set -euo pipefail
PR_NUMBER="${PR_NUMBER:?PR_NUMBER is required}"
REPO="${REPO:?REPO is required}"
# ─── helpers ────────────────────────────────────────────────────────────────
# Remove all labels in a dimension except the desired one.
# Usage: set_exclusive_label "size" "size: M"
set_exclusive_label() {
local prefix="$1" desired="$2"
# Fetch current labels on the PR
local current
current=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '.labels[].name')
# Remove any existing label with the same prefix
while IFS= read -r label; do
[[ -z "$label" ]] && continue
if [[ "$label" == "${prefix}:"* && "$label" != "$desired" ]]; then
gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$label" 2>/dev/null || true
fi
done <<< "$current"
# Add the desired label
gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$desired"
}
# ─── size ───────────────────────────────────────────────────────────────────
classify_size() {
# Sum changed lines across non-doc files
local total
total=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" \
--paginate --jq '
[.[] | select(.filename | test("\\.(md|txt|rst|adoc)$") | not) | .changes]
| add // 0
')
local label
if (( total < 10 )); then label="size: XS"
elif (( total < 50 )); then label="size: S"
elif (( total < 200 )); then label="size: M"
elif (( total < 500 )); then label="size: L"
else label="size: XL"
fi
echo "Size: ${total} changed lines -> ${label}"
set_exclusive_label "size" "$label"
}
# ─── risk ───────────────────────────────────────────────────────────────────
classify_risk() {
# If "risk: manual" is present, skip — it's a sticky override
local current
current=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '.labels[].name')
if echo "$current" | grep -qx "risk: manual"; then
echo "Risk: skipped (manual override)"
return
fi
# Fetch changed file paths
local files
files=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" \
--paginate --jq '.[].filename')
local risk="low"
while IFS= read -r file; do
[[ -z "$file" ]] && continue
case "$file" in
# High risk: safety, secrets, auth, crypto, setup, orchestrator auth
src/safety/*|src/secrets/*|src/llm/session.rs|src/orchestrator/auth.rs|\
src/channels/web/auth.rs|src/setup/*)
risk="high"
break # can't go higher
;;
# Medium risk: agent core, config, database, worker, tools, channels
src/agent/*|src/config.rs|src/settings.rs|src/db/*|src/worker/*|\
src/tools/*|src/channels/*|src/orchestrator/*|src/context/*|\
src/hooks/*|src/sandbox/*|src/extensions/*|Cargo.toml|\
.github/workflows/*)
# Only upgrade, never downgrade
[[ "$risk" != "high" ]] && risk="medium"
;;
# Low risk: docs, tests, estimation, evaluation, history, etc.
*)
;;
esac
done <<< "$files"
echo "Risk: ${risk}"
set_exclusive_label "risk" "risk: ${risk}"
}
# ─── contributor tier ───────────────────────────────────────────────────────
classify_contributor() {
# Get PR author
local author
author=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json author --jq '.author.login')
# Count merged PRs by this author in this repo
local count
count=$(gh pr list --repo "$REPO" --state merged --author "$author" \
--limit 100 --json number --jq 'length')
local label
if (( count == 0 )); then label="contributor: new"
elif (( count < 6 )); then label="contributor: regular"
elif (( count < 20 )); then label="contributor: experienced"
else label="contributor: core"
fi
echo "Contributor: ${author} has ${count} merged PRs -> ${label}"
set_exclusive_label "contributor" "$label"
}
# ─── main ───────────────────────────────────────────────────────────────────
echo "Classifying PR #${PR_NUMBER} in ${REPO}..."
classify_size
classify_risk
classify_contributor
echo "Done."
+26
View File
@@ -0,0 +1,26 @@
name: "PR: Classify (Size, Risk, Contributor)"
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
issues: read # needed for search/issues API (contributor count)
jobs:
classify:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}
- name: Classify PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: bash .github/scripts/pr-labeler.sh
+18
View File
@@ -0,0 +1,18 @@
name: "PR: Scope Labels"
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
scope:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
with:
configuration-path: .github/labeler.yml
sync-labels: false # additive only — never remove scope labels
+30
View File
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.7.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.6.0...ironclaw-v0.7.0) - 2026-02-19
### Added
- extend lifecycle hooks with declarative bundles ([#176](https://github.com/nearai/ironclaw/pull/176))
- support per-request model override in /v1/chat/completions ([#103](https://github.com/nearai/ironclaw/pull/103))
### Fixed
- harden openai-compatible provider, approval replay, and embeddings defaults ([#237](https://github.com/nearai/ironclaw/pull/237))
- Network Security Findings ([#201](https://github.com/nearai/ironclaw/pull/201))
### Added
- Refactored OpenAI-compatible chat completion routing to use the rig adapter and `RetryProvider` composition for custom base URL usage.
- Added Ollama embeddings provider support (`EMBEDDING_PROVIDER=ollama`, `OLLAMA_BASE_URL`) in workspace embeddings.
- Added migration `V9__flexible_embedding_dimension.sql` for flexible embedding vector dimensions.
### Changed
- Changed default sandbox image to `ironclaw-worker:latest` in config/settings/sandbox defaults.
- Improved tool-message sanitization and provider compatibility handling across NEAR AI, rig adapter, and shared LLM provider code.
### Fixed
- Fixed approval-input aliases (`a`, `/approve`, `/always`, `/deny`, etc.) in submission parsing.
- Fixed multi-tool approval resume flow by preserving and replaying deferred tool calls so all prior `tool_use` IDs receive matching `tool_result` messages.
- Fixed REPL quit/exit handling to route shutdown through the agent loop for graceful termination.
## [0.6.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.5.0...ironclaw-v0.6.0) - 2026-02-19
### Added
@@ -94,6 +123,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bump MSRV to 1.92, add GCP deployment files ([#40](https://github.com/nearai/ironclaw/pull/40))
- Add OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models) ([#31](https://github.com/nearai/ironclaw/pull/31))
## [0.1.3](https://github.com/nearai/ironclaw/compare/v0.1.2...v0.1.3) - 2026-02-12
### Other
+210 -330
View File
@@ -13,14 +13,17 @@
### Features
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, WASM channels (Telegram, Slack), web gateway
- **Parallel job execution** with state machine and self-repair for stuck jobs
- **Sandbox execution**: Docker container isolation with orchestrator/worker pattern
- **Sandbox execution**: Docker container isolation with network proxy and credential injection
- **Claude Code mode**: Delegate jobs to Claude CLI inside containers
- **Skills system**: SKILL.md prompt extensions with trust model, tool attenuation, and ClawHub registry
- **Routines**: Scheduled (cron) and reactive (event, webhook) task execution
- **Web gateway**: Browser UI with SSE/WebSocket real-time streaming
- **Extension management**: Install, auth, activate MCP/WASM extensions
- **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection, shell env scrubbing
- **Multi-provider LLM**: NEAR AI, OpenAI, Anthropic, Ollama, OpenAI-compatible, Tinfoil private inference
- **Setup wizard**: 7-step interactive onboarding for first-run configuration
- **Heartbeat system**: Proactive periodic execution with checklist
## Build & Test
@@ -64,6 +67,7 @@ src/
│ ├── context_monitor.rs # Memory pressure detection
│ ├── undo.rs # Turn-based undo/redo with checkpoints
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
│ ├── dispatcher.rs # Skill-aware job dispatching
│ ├── task.rs # Sub-task execution framework
│ ├── routine.rs # Routine types (Trigger, Action, Guardrails)
│ └── routine_engine.rs # Routine execution (cron ticker, event matcher)
@@ -113,11 +117,19 @@ src/
│ ├── policy.rs # PolicyRule system with severity/actions
│ └── leak_detector.rs # Secret detection (API keys, tokens, etc.)
├── llm/ # LLM integration (NEAR AI only)
├── llm/ # LLM integration (multi-provider)
│ ├── mod.rs # Provider factory, LlmBackend enum
│ ├── provider.rs # LlmProvider trait, message types
│ ├── nearai.rs # NEAR AI chat-api implementation
│ ├── nearai.rs # NEAR AI Responses API provider
│ ├── nearai_chat.rs # NEAR AI Chat Completions fallback
│ ├── reasoning.rs # Planning, tool selection, evaluation
── session.rs # Session token management with auto-renewal
── session.rs # Session token management with auto-renewal
│ ├── circuit_breaker.rs # Circuit breaker for provider failures
│ ├── retry.rs # Retry with exponential backoff
│ ├── failover.rs # Multi-provider failover chain
│ ├── response_cache.rs # LLM response caching
│ ├── costs.rs # Token cost tracking
│ └── rig_adapter.rs # Rig framework adapter
├── tools/ # Extensible tool system
│ ├── tool.rs # Tool trait, ToolOutput, ToolError
@@ -131,6 +143,7 @@ src/
│ │ ├── job.rs # CreateJob, ListJobs, JobStatus, CancelJob
│ │ ├── routine.rs # routine_create/list/update/delete/history
│ │ ├── extension_tools.rs # Extension install/auth/activate/remove
│ │ ├── skill_tools.rs # skill_list/search/install/remove tools
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
│ ├── builder/ # Dynamic tool building
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
@@ -180,11 +193,38 @@ src/
│ ├── success.rs # SuccessEvaluator trait, RuleBasedEvaluator, LlmEvaluator
│ └── metrics.rs # MetricsCollector, QualityMetrics
├── sandbox/ # Docker execution sandbox
│ ├── mod.rs # Public API, default allowlist
│ ├── config.rs # SandboxConfig, SandboxPolicy enum
│ ├── manager.rs # SandboxManager orchestration
│ ├── container.rs # ContainerRunner, Docker lifecycle
│ ├── error.rs # SandboxError types
│ └── proxy/ # Network proxy for containers
│ ├── mod.rs # NetworkProxyBuilder
│ ├── http.rs # HttpProxy, CredentialResolver trait
│ ├── policy.rs # NetworkPolicyDecider trait
│ └── allowlist.rs # DomainAllowlist validation
├── secrets/ # Secrets management
│ ├── crypto.rs # AES-256-GCM encryption
│ ├── store.rs # Secret storage
│ └── types.rs # Credential types
├── setup/ # Onboarding wizard (spec: src/setup/README.md)
│ ├── mod.rs # Entry point, check_onboard_needed()
│ ├── wizard.rs # 7-step interactive wizard
│ ├── channels.rs # Channel setup helpers
│ └── prompts.rs # Terminal prompts (select, confirm, secret)
├── skills/ # SKILL.md prompt extension system
│ ├── mod.rs # Core types (SkillTrust, LoadedSkill)
│ ├── registry.rs # SkillRegistry: discover, install, remove
│ ├── selector.rs # Deterministic scoring prefilter
│ ├── attenuation.rs # Trust-based tool ceiling
│ ├── gating.rs # Requirement checks (bins, env, config)
│ ├── parser.rs # SKILL.md frontmatter + markdown parser
│ └── catalog.rs # ClawHub registry client
└── history/ # Persistence
├── store.rs # PostgreSQL repositories
└── analytics.rs # Aggregation queries (JobStats, ToolStats)
@@ -214,6 +254,7 @@ When designing new features or systems, always prefer generic/extensible archite
- `LlmProvider` - Add new LLM backends
- `SuccessEvaluator` - Custom evaluation logic
- `EmbeddingProvider` - Add embedding backends (workspace search)
- `NetworkPolicyDecider` - Custom network access policies for sandbox containers
### Tool Implementation
```rust
@@ -252,6 +293,40 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted
\-> Failed
```
### Code Style
- Use `crate::` imports, not `super::`
- No `pub use` re-exports unless exposing to downstream consumers
- Prefer strong types over strings (enums, newtypes)
- Keep functions focused, extract helpers when logic is reused
- Comments for non-obvious logic only
### Review & Fix Discipline
Hard-won lessons from code review -- follow these when fixing bugs or addressing review feedback.
**Fix the pattern, not just the instance:** When a reviewer flags a bug (e.g., TOCTOU race in INSERT + SELECT-back), search the entire codebase for all instances of that same pattern. A fix in `SecretsStore::create()` that doesn't also fix `WasmToolStore::store()` is half a fix.
**Propagate architectural fixes to satellite types:** If a core type changes its concurrency model (e.g., `LibSqlBackend` switches to connection-per-operation), every type that was handed a resource from the old model (e.g., `LibSqlSecretsStore`, `LibSqlWasmToolStore` holding a single `Connection`) must also be updated. Grep for the old type across the codebase.
**Schema translation is more than DDL:** When translating a database schema between backends (PostgreSQL to libSQL, etc.), check for:
- **Indexes** -- diff `CREATE INDEX` statements between the two schemas
- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
- **Semantic differences** -- document where SQL functions behave differently (e.g., `json_patch` vs `jsonb_set`)
**Feature flag testing:** When adding feature-gated code, test compilation with each feature in isolation:
```bash
cargo check # default features
cargo check --no-default-features --features libsql # libsql only
cargo check --all-features # all features
```
Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature.
**Mechanical verification before committing:** Run these checks on changed files before committing:
- `grep -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
- `grep -rn 'super::' <files>` -- use `crate::` imports
- If you fixed a pattern bug, `grep` for other instances of that pattern across `src/`
## Configuration
Environment variables (see `.env.example`):
@@ -263,10 +338,15 @@ LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default)
# LIBSQL_URL=libsql://xxx.turso.io # Turso cloud (optional)
# LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL
# NEAR AI (required)
NEARAI_SESSION_TOKEN=sess_...
NEARAI_MODEL=claude-3-5-sonnet-20241022
# NEAR AI (when LLM_BACKEND=nearai, the default)
# Two modes: "NEAR AI Chat" (session token) or "NEAR AI Cloud" (API key)
# NEAR AI Chat (Responses API, default):
NEARAI_SESSION_TOKEN=sess_... # session token for chat-api
NEARAI_BASE_URL=https://private.near.ai
# NEAR AI Cloud (Chat Completions API, auto-selected when API key is set):
# NEARAI_API_KEY=... # API key from cloud.near.ai
# NEARAI_BASE_URL=https://cloud-api.near.ai
NEARAI_MODEL=claude-3-5-sonnet-20241022
# Agent settings
AGENT_NAME=ironclaw
@@ -297,6 +377,10 @@ SANDBOX_ENABLED=true
SANDBOX_IMAGE=ironclaw-worker:latest
SANDBOX_MEMORY_LIMIT_MB=512
SANDBOX_TIMEOUT_SECS=1800
SANDBOX_CPU_LIMIT=1.0 # CPU cores per container
SANDBOX_NETWORK_PROXY=true # Enable network proxy for containers
SANDBOX_PROXY_PORT=8080 # Proxy listener port
SANDBOX_DEFAULT_POLICY=workspace_write # ReadOnly, WorkspaceWrite, FullAccess
# Claude Code mode (runs inside sandbox containers)
CLAUDE_CODE_ENABLED=false
@@ -308,16 +392,27 @@ CLAUDE_CODE_CONFIG_DIR=/home/worker/.claude
ROUTINES_ENABLED=true
ROUTINES_CRON_INTERVAL=60 # Tick interval in seconds
ROUTINES_MAX_CONCURRENT=3
# Skills system
SKILLS_ENABLED=true
SKILLS_MAX_TOKENS=4000 # Max prompt budget per turn
SKILLS_CATALOG_URL=https://clawhub.dev # ClawHub registry URL
SKILLS_AUTO_DISCOVER=true # Scan skill directories on startup
# Tinfoil private inference
TINFOIL_API_KEY=... # Required when LLM_BACKEND=tinfoil
TINFOIL_MODEL=kimi-k2-5 # Default model
```
### NEAR AI Provider
### LLM Providers
Uses the NEAR AI chat-api (`https://api.near.ai/v1/responses`) which provides:
- Unified access to multiple models (OpenAI, Anthropic, etc.)
- User authentication via session tokens
- Usage tracking and billing through NEAR AI
IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, and `tinfoil`.
Session tokens have the format `sess_xxx` (37 characters). They are authenticated against the NEAR AI auth service.
**NEAR AI Chat** -- Uses the NEAR AI Responses API (`https://private.near.ai/v1/responses`). Authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google). Supports response chaining (delta-only follow-up messages) for efficient multi-turn conversations. This is the default mode when no `NEARAI_API_KEY` is set. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment. Configure with `NEARAI_BASE_URL` (default: `https://private.near.ai`).
**NEAR AI Cloud** -- Uses the OpenAI-compatible Chat Completions API (`https://cloud-api.near.ai/v1/chat/completions`). Authenticates with API keys from `cloud.near.ai`. Auto-selected when `NEARAI_API_KEY` is set (or explicitly via `NEARAI_API_MODE=chat_completions`). Tool messages are flattened to plain text for compatibility. Configure with `NEARAI_API_KEY` and `NEARAI_BASE_URL` (default: `https://cloud-api.near.ai`).
**Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API only (not the Responses API, so tool calls are adapted to chat format). Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`).
## Database
@@ -386,22 +481,7 @@ Both backends implement this trait. PostgreSQL delegates to the existing `Store`
- `tool_failures` - Self-repair tracking
- `secrets`, `wasm_tools`, `tool_capabilities` - Extension infrastructure
### Configuration
```bash
# Backend selection (default: postgres)
DATABASE_BACKEND=libsql
# PostgreSQL
DATABASE_URL=postgres://user:pass@localhost/ironclaw
# libSQL (embedded)
LIBSQL_PATH=~/.ironclaw/ironclaw.db # Default path
# libSQL (Turso cloud sync)
LIBSQL_URL=libsql://your-db.turso.io
LIBSQL_AUTH_TOKEN=your-token # Required when LIBSQL_URL is set
```
Database configuration: see Configuration section above.
### Current Limitations (libSQL backend)
@@ -419,6 +499,7 @@ All external tool output passes through `SafetyLayer`:
1. **Sanitizer** - Detects injection patterns, escapes dangerous content
2. **Validator** - Checks length, encoding, forbidden patterns
3. **Policy** - Rules with severity (Critical/High/Medium/Low) and actions (Block/Warn/Review/Sanitize)
4. **Leak Detector** - Scans for 15+ secret patterns (API keys, tokens, private keys, connection strings) at two points: tool output before it reaches the LLM, and LLM responses before they reach the user. Actions per pattern: Block (reject entirely), Redact (mask the secret), or Warn (flag but allow)
Tool outputs are wrapped before reaching LLM:
```xml
@@ -427,6 +508,95 @@ Tool outputs are wrapped before reaching LLM:
</tool_output>
```
### Shell Environment Scrubbing
The shell tool (`src/tools/builtin/shell.rs`) scrubs sensitive environment variables before executing commands, preventing secrets from leaking through `env`, `printenv`, or `$VAR` expansion. The sanitizer (`src/safety/sanitizer.rs`) also detects command injection patterns (chained commands, subshells, path traversal) and blocks or escapes them based on policy rules.
## Skills System
Skills are SKILL.md files that extend the agent's prompt with domain-specific instructions. Each skill is a YAML frontmatter block (metadata, activation criteria, required tools) followed by a markdown body that gets injected into the LLM context when the skill activates.
### Trust Model
| Trust Level | Source | Tool Access |
|-------------|--------|-------------|
| **Trusted** | User-placed in `~/.ironclaw/skills/` or workspace `skills/` | All tools available to the agent |
| **Installed** | Downloaded from ClawHub registry | Read-only tools only (no shell, file write, HTTP) |
### SKILL.md Format
```yaml
---
name: my-skill
version: 0.1.0
description: Does something useful
activation:
patterns:
- "deploy to.*production"
keywords:
- "deployment"
max_context_tokens: 2000
metadata:
openclaw:
requires:
bins: [docker, kubectl]
env: [KUBECONFIG]
---
# Deployment Skill
Instructions for the agent when this skill activates...
```
### Selection Pipeline
1. **Gating** -- Check binary/env/config requirements; skip skills whose prerequisites are missing
2. **Scoring** -- Deterministic scoring against message content using keywords, tags, and regex patterns
3. **Budget** -- Select top-scoring skills that fit within `SKILLS_MAX_TOKENS` prompt budget
4. **Attenuation** -- Apply trust-based tool ceiling; installed skills lose access to dangerous tools
### Skill Tools
Four built-in tools for managing skills at runtime:
- **`skill_list`** -- List all discovered skills with trust level and status
- **`skill_search`** -- Search ClawHub registry for available skills
- **`skill_install`** -- Download and install a skill from ClawHub
- **`skill_remove`** -- Remove an installed skill
### Skill Directories
- `~/.ironclaw/skills/` -- User's global skills (trusted)
- `<workspace>/skills/` -- Per-workspace skills (trusted)
- `~/.ironclaw/installed_skills/` -- Registry-installed skills (installed trust)
Skills configuration: see Configuration section above.
## Docker Sandbox
The `src/sandbox/` module provides Docker-based isolation for job execution with a network proxy that controls outbound access and injects credentials.
### Sandbox Policies
| Policy | Filesystem | Network | Use Case |
|--------|-----------|---------|----------|
| **ReadOnly** | Read-only workspace mount | Allowlisted domains only | Analysis, code review |
| **WorkspaceWrite** | Read-write workspace mount | Allowlisted domains only | Code generation, file edits |
| **FullAccess** | Full filesystem | Unrestricted | Trusted admin tasks |
### Network Proxy
Containers route all HTTP/HTTPS traffic through a host-side proxy (`src/sandbox/proxy/`):
- **Domain allowlist** -- Only allowlisted domains are reachable (default: package registries, docs sites, GitHub, common APIs)
- **Credential injection** -- The `CredentialResolver` trait injects auth headers into proxied requests so secrets never enter the container environment
- **CONNECT tunnel** -- HTTPS traffic uses CONNECT method; the proxy validates the target domain against the allowlist before establishing the tunnel
- **Policy decisions** -- The `NetworkPolicyDecider` trait allows custom logic for allow/deny/inject decisions per request
### Zero-Exposure Credential Model
Secrets (API keys, tokens) are stored encrypted on the host and injected into HTTP requests by the proxy at transit time. Container processes never have access to raw credential values, preventing exfiltration even if container code is compromised.
Sandbox configuration: see Configuration section above.
## Testing
Tests are in `mod tests {}` blocks at the bottom of each file. Run specific module tests:
@@ -451,164 +621,13 @@ Key test patterns:
7. **Webhook trigger endpoint** - Routines webhook trigger not yet exposed in web gateway
8. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard
### Completed
## Tool Architecture
-**Workspace integration** - Memory tools registered, workspace passed to Agent and heartbeat
-**WASM sandboxing** - Full implementation in `tools/wasm/` with fuel metering, memory limits, capabilities
-**Dynamic tool building** - `tools/builder/` has LlmSoftwareBuilder with iterative build loop
-**HTTP webhook security** - Secret validation implemented, proper error handling (no panics)
-**Embeddings integration** - OpenAI and NEAR AI providers wired to workspace for semantic search
-**Workspace system prompt** - Identity files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md) injected into LLM context
-**Heartbeat notifications** - Route through channel manager (broadcast API) instead of logging-only
-**Auto-context compaction** - Triggers automatically when context exceeds threshold
-**Embedding backfill** - Runs on startup when embeddings provider is enabled
-**Clippy clean** - All warnings addressed via config struct refactoring
-**Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session
-**Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session
-**Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty
-**Gateway control plane** - Web gateway with 40+ API endpoints, SSE/WebSocket
-**Web Control UI** - Browser-based dashboard with chat, memory, jobs, logs, extensions, routines
-**Slack/Telegram channels** - Implemented as WASM tools
-**Docker sandbox** - Orchestrator/worker containers with per-job auth
-**Claude Code mode** - Delegate jobs to Claude CLI inside containers
-**Routines system** - Cron, event, webhook, and manual triggers with guardrails
-**Extension management** - Install, auth, activate MCP/WASM extensions via CLI and web UI
-**libSQL/Turso backend** - Database trait abstraction (`src/db/`), feature-gated dual backend support (postgres/libsql), embedded SQLite for zero-dependency local mode
**Keep tool-specific logic out of the main agent codebase.** The main agent provides generic infrastructure; tools are self-contained units that declare their requirements through `capabilities.json` files (API endpoints, credentials, rate limits, auth setup). Service-specific auth flows, CLI commands, and configuration do not belong in the main agent.
## Adding a New Tool
Tools can be built as **WASM** (sandboxed, credential-injected, single binary) or **MCP servers** (ecosystem of pre-built servers, any language, but no sandbox). Both are first-class via `ironclaw tool install`. Auth is declared in capabilities files with OAuth and manual token entry support.
### Built-in Tools (Rust)
1. Create `src/tools/builtin/my_tool.rs`
2. Implement the `Tool` trait
3. Add `mod my_tool;` and `pub use` in `src/tools/builtin/mod.rs`
4. Register in `ToolRegistry::register_builtin_tools()` in `registry.rs`
5. Add tests
### WASM Tools (Recommended)
WASM tools are the preferred way to add new capabilities. They run in a sandboxed environment with explicit capabilities.
1. Create a new crate in `tools-src/<name>/`
2. Implement the WIT interface (`wit/tool.wit`)
3. Create `<name>.capabilities.json` declaring required permissions
4. Build with `cargo build --target wasm32-wasip2 --release`
5. Install with `ironclaw tool install path/to/tool.wasm`
See `tools-src/` for examples.
## Tool Architecture Principles
**CRITICAL: Keep tool-specific logic out of the main agent codebase.**
The main agent provides generic infrastructure; tools are self-contained units that declare their requirements through capabilities files.
### What Goes in Tools (capabilities.json)
- API endpoints the tool needs (HTTP allowlist)
- Credentials required (secret names, injection locations)
- Rate limits and timeouts
- Auth setup instructions (see below)
- Workspace paths the tool can read
### What Does NOT Go in Main Agent
- Service-specific auth flows (OAuth for Notion, Slack, etc.)
- Service-specific CLI commands (`auth notion`, `auth slack`)
- Service-specific configuration handling
- Hardcoded API URLs or token formats
### Tool Authentication
Tools declare their auth requirements in `<tool>.capabilities.json` under the `auth` section. Two methods are supported:
#### OAuth (Browser-based login)
For services that support OAuth, users just click through browser login:
```json
{
"auth": {
"secret_name": "notion_api_token",
"display_name": "Notion",
"oauth": {
"authorization_url": "https://api.notion.com/v1/oauth/authorize",
"token_url": "https://api.notion.com/v1/oauth/token",
"client_id_env": "NOTION_OAUTH_CLIENT_ID",
"client_secret_env": "NOTION_OAUTH_CLIENT_SECRET",
"scopes": [],
"use_pkce": false,
"extra_params": { "owner": "user" }
},
"env_var": "NOTION_TOKEN"
}
}
```
To enable OAuth for a tool:
1. Register a public OAuth app with the service (e.g., notion.so/my-integrations)
2. Configure redirect URIs: `http://localhost:9876/callback` through `http://localhost:9886/callback`
3. Set environment variables for client_id and client_secret
#### Manual Token Entry (Fallback)
For services without OAuth or when OAuth isn't configured:
```json
{
"auth": {
"secret_name": "openai_api_key",
"display_name": "OpenAI",
"instructions": "Get your API key from platform.openai.com/api-keys",
"setup_url": "https://platform.openai.com/api-keys",
"token_hint": "Starts with 'sk-'",
"env_var": "OPENAI_API_KEY"
}
}
```
#### Auth Flow Priority
When running `ironclaw tool auth <tool>`:
1. Check `env_var` - if set in environment, use it directly
2. Check `oauth` - if configured, open browser for OAuth flow
3. Fall back to `instructions` + manual token entry
The agent reads auth config from the tool's capabilities file and provides the appropriate flow. No service-specific code in the main agent.
### WASM Tools vs MCP Servers: When to Use Which
Both are first-class in the extension system (`ironclaw tool install` handles both), but they have different strengths.
**WASM Tools (IronClaw native)**
- Sandboxed: fuel metering, memory limits, no access except what's allowlisted
- Credentials injected by host runtime, tool code never sees the actual token
- Output scanned for secret leakage before returning to the LLM
- Auth (OAuth/manual) declared in `capabilities.json`, agent handles the flow
- Single binary, no process management, works offline
- Cost: must build yourself in Rust, no ecosystem, synchronous only
**MCP Servers (Model Context Protocol)**
- Growing ecosystem of pre-built servers (GitHub, Notion, Postgres, etc.)
- Any language (TypeScript/Python most common)
- Can do websockets, streaming, background polling
- Cost: external process with full system access (no sandbox), manages own credentials, IronClaw can't prevent leaks
**Decision guide:**
| Scenario | Use |
|----------|-----|
| Good MCP server already exists | **MCP** |
| Handles sensitive credentials (email send, banking) | **WASM** |
| Quick prototype or one-off integration | **MCP** |
| Core capability you'll maintain long-term | **WASM** |
| Needs background connections (websockets, polling) | **MCP** |
| Multiple tools share one OAuth token (e.g., Google suite) | **WASM** |
The LLM-facing interface is identical for both (tool name, schema, execute), so swapping between them is transparent to the agent.
See `src/tools/README.md` for full tool architecture, adding new tools (built-in Rust and WASM), auth JSON examples, and WASM vs MCP decision guide.
## Adding a New Channel
@@ -645,154 +664,15 @@ for that module's behavior. When modifying code in a module that has a spec:
| Module | Spec File |
|--------|-----------|
| `src/setup/` | `src/setup/README.md` |
## Code Style
- Use `crate::` imports, not `super::`
- No `pub use` re-exports unless exposing to downstream consumers
- Prefer strong types over strings (enums, newtypes)
- Keep functions focused, extract helpers when logic is reused
- Comments for non-obvious logic only
## Review & Fix Discipline
Hard-won lessons from code review -- follow these when fixing bugs or addressing review feedback.
### Fix the pattern, not just the instance
When a reviewer flags a bug (e.g., TOCTOU race in INSERT + SELECT-back), search the entire codebase for all instances of that same pattern. A fix in `SecretsStore::create()` that doesn't also fix `WasmToolStore::store()` is half a fix.
### Propagate architectural fixes to satellite types
If a core type changes its concurrency model (e.g., `LibSqlBackend` switches to connection-per-operation), every type that was handed a resource from the old model (e.g., `LibSqlSecretsStore`, `LibSqlWasmToolStore` holding a single `Connection`) must also be updated. Grep for the old type across the codebase.
### Schema translation is more than DDL
When translating a database schema between backends (PostgreSQL to libSQL, etc.), check for:
- **Indexes** -- diff `CREATE INDEX` statements between the two schemas
- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
- **Semantic differences** -- document where SQL functions behave differently (e.g., `json_patch` vs `jsonb_set`)
### Feature flag testing
When adding feature-gated code, test compilation with each feature in isolation:
```bash
cargo check # default features
cargo check --no-default-features --features libsql # libsql only
cargo check --all-features # all features
```
Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature.
### Mechanical verification before committing
Run these checks on changed files before committing:
- `grep -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
- `grep -rn 'super::' <files>` -- use `crate::` imports
- If you fixed a pattern bug, `grep` for other instances of that pattern across `src/`
| `src/workspace/` | `src/workspace/README.md` |
| `src/tools/` | `src/tools/README.md` |
## Workspace & Memory System
Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace provides persistent memory for agents with a flexible filesystem-like structure.
OpenClaw-inspired persistent memory with a flexible filesystem-like structure. Principle: "Memory is database, not RAM" -- if you want to remember something, write it explicitly. Uses hybrid search combining FTS (keyword) + vector (semantic) via Reciprocal Rank Fusion.
### Key Principles
Four memory tools for LLM use: `memory_search` (hybrid search -- call before answering questions about prior work), `memory_write`, `memory_read`, `memory_tree`. Identity files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md) are injected into the LLM system prompt.
1. **"Memory is database, not RAM"** - If you want to remember something, write it explicitly
2. **Flexible structure** - Create any directory/file hierarchy you need
3. **Self-documenting** - Use README.md files to describe directory structure
4. **Hybrid search** - Combines FTS (keyword) + vector (semantic) via Reciprocal Rank Fusion
The heartbeat system runs proactive periodic execution (default: 30 minutes), reading `HEARTBEAT.md` and notifying via channel if findings are detected.
### Filesystem Structure
```
workspace/
├── README.md <- Root runbook/index
├── MEMORY.md <- Long-term curated memory
├── HEARTBEAT.md <- Periodic checklist
├── IDENTITY.md <- Agent name, nature, vibe
├── SOUL.md <- Core values
├── AGENTS.md <- Behavior instructions
├── USER.md <- User context
├── context/ <- Identity-related docs
│ ├── vision.md
│ └── priorities.md
├── daily/ <- Daily logs
│ ├── 2024-01-15.md
│ └── 2024-01-16.md
├── projects/ <- Arbitrary structure
│ └── alpha/
│ ├── README.md
│ └── notes.md
└── ...
```
### Using the Workspace
```rust
use crate::workspace::{Workspace, OpenAiEmbeddings, paths};
// Create workspace for a user
let workspace = Workspace::new("user_123", pool)
.with_embeddings(Arc::new(OpenAiEmbeddings::new(api_key)));
// Read/write any path
let doc = workspace.read("projects/alpha/notes.md").await?;
workspace.write("context/priorities.md", "# Priorities\n\n1. Feature X").await?;
workspace.append("daily/2024-01-15.md", "Completed task X").await?;
// Convenience methods for well-known files
workspace.append_memory("User prefers dark mode").await?;
workspace.append_daily_log("Session note").await?;
// List directory contents
let entries = workspace.list("projects/").await?;
// Search (hybrid FTS + vector)
let results = workspace.search("dark mode preference", 5).await?;
// Get system prompt from identity files
let prompt = workspace.system_prompt().await?;
```
### Memory Tools
Four tools for LLM use:
- **`memory_search`** - Hybrid search, MUST be called before answering questions about prior work
- **`memory_write`** - Write to any path (memory, daily_log, or custom paths)
- **`memory_read`** - Read any file by path
- **`memory_tree`** - View workspace structure as a tree (depth parameter, default 1)
### Hybrid Search (RRF)
Combines full-text search and vector similarity using Reciprocal Rank Fusion:
```
score(d) = Σ 1/(k + rank(d)) for each method where d appears
```
Default k=60. Results from both methods are combined, with documents appearing in both getting boosted scores.
**Backend differences:**
- **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF
- **libSQL:** FTS5 for keyword search only (vector search via `libsql_vector_idx` not yet wired)
### Heartbeat System
Proactive periodic execution (default: 30 minutes):
1. Reads `HEARTBEAT.md` checklist
2. Runs agent turn with checklist prompt
3. If findings, notifies via channel
4. If nothing, agent replies "HEARTBEAT_OK" (no notification)
```rust
use crate::agent::{HeartbeatConfig, spawn_heartbeat};
let config = HeartbeatConfig::default()
.with_interval(Duration::from_secs(60 * 30))
.with_notify("user_123", "telegram");
spawn_heartbeat(config, workspace, llm, response_tx);
```
### Chunking Strategy
Documents are chunked for search indexing:
- Default: 800 words per chunk (roughly 800 tokens for English)
- 15% overlap between chunks for context preservation
- Minimum chunk size: 50 words (tiny trailing chunks merge with previous)
See `src/workspace/README.md` for full API documentation, filesystem structure, hybrid search details, chunking strategy, and heartbeat system.
Generated
+1 -1
View File
@@ -2490,7 +2490,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"aes-gcm",
"aho-corasick",
+12 -2
View File
@@ -1,15 +1,25 @@
[workspace]
members = [".", "benchmarks"]
exclude = [
"channels-src/discord",
"channels-src/telegram",
"channels-src/slack",
"channels-src/whatsapp",
"tools-src/github",
"tools-src/gmail",
"tools-src/google-calendar",
"tools-src/google-docs",
"tools-src/google-drive",
"tools-src/google-sheets",
"tools-src/google-slides",
"tools-src/okta",
"tools-src/slack",
"tools-src/telegram",
]
[package]
name = "ironclaw"
version = "0.6.0"
version = "0.7.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
@@ -78,7 +88,7 @@ termimad = "0.34"
# Channel integrations
axum = { version = "0.8", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors"] }
tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
# Cron scheduling for routines
cron = "0.13"
+7 -7
View File
@@ -37,7 +37,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Session management/routing | ✅ | ✅ | SessionManager exists |
| Configuration hot-reload | ✅ | ❌ | |
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions |
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
| Gateway lock (PID-based) | ✅ | ❌ | |
| launchd/systemd integration | ✅ | ❌ | |
@@ -278,7 +278,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Auth plugins | ✅ | ❌ | |
| Memory plugins | ✅ | ❌ | Custom backends |
| Tool plugins | ✅ | ✅ | WASM tools |
| Hook plugins | ✅ | | |
| Hook plugins | ✅ | | Declarative hooks from extension capabilities |
| Provider plugins | ✅ | ❌ | |
| Plugin CLI (`install`, `list`) | ✅ | ✅ | `tool` subcommand |
| ClawHub registry | ✅ | ❌ | Discovery |
@@ -421,10 +421,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
| `transformResponse` hook | ✅ | ✅ | P2 | |
| `llm_input`/`llm_output` hooks | ✅ | ❌ | P3 | LLM payload inspection |
| Bundled hooks | ✅ | | P2 | |
| Plugin hooks | ✅ | | P3 | |
| Workspace hooks | ✅ | | P2 | Inline code |
| Outbound webhooks | ✅ | | P2 | |
| Bundled hooks | ✅ | | P2 | Audit + declarative rule/webhook hooks |
| Plugin hooks | ✅ | | P3 | Registered from WASM `capabilities.json` |
| Workspace hooks | ✅ | | P2 | `hooks/hooks.json` and `hooks/*.hook.json` |
| Outbound webhooks | ✅ | | P2 | Fire-and-forget lifecycle event delivery |
| Heartbeat system | ✅ | ✅ | - | Periodic execution |
| Gmail pub/sub | ✅ | ❌ | P3 | |
@@ -528,7 +528,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
- ❌ WhatsApp channel
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
- ✅ Hooks system (beforeInbound, beforeToolCall, beforeOutbound, onSessionStart, onSessionEnd, transformResponse)
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
### P2 - Medium Priority
- ❌ Media handling (images, PDFs)
+1 -1
View File
@@ -402,7 +402,7 @@ async fn run_task_isolated(params: TaskRunParams<'_>) -> TaskResult {
let mut channels = ChannelManager::new();
channels.add(Box::new(bench_channel));
let agent = Agent::new(agent_config, deps, channels, None, None, None, None);
let agent = Agent::new(agent_config, deps, channels, None, None, None, None, None);
// Build the full prompt with context
let full_prompt = if let Some(ref ctx) = task.context {
+2
View File
@@ -21,3 +21,5 @@ lto = true
codegen-units = 1
[workspace]
+2
View File
@@ -27,3 +27,5 @@ opt-level = "s"
lto = true
strip = true
codegen-units = 1
[workspace]
+2
View File
@@ -25,3 +25,5 @@ opt-level = "s"
lto = true
strip = true
codegen-units = 1
[workspace]
+2
View File
@@ -16,3 +16,5 @@ serde_json = "1"
opt-level = "s"
lto = true
strip = true
[workspace]
+8 -5
View File
@@ -2,12 +2,15 @@
# Do not use placeholder passwords in production.
DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw
# NEAR AI
NEARAI_SESSION_TOKEN=CHANGE_ME
# NEAR AI Cloud (API key auth, Chat Completions API)
# Get an API key from https://cloud.near.ai
NEARAI_API_KEY=CHANGE_ME
NEARAI_MODEL=claude-3-5-sonnet-20241022
NEARAI_BASE_URL=https://private.near.ai
NEARAI_AUTH_URL=https://private.near.ai
NEARAI_API_MODE=chat_completions
NEARAI_BASE_URL=https://cloud-api.near.ai
# Or use NEAR AI Chat (session token auth, Responses API):
# NEARAI_SESSION_TOKEN=sess_...
# NEARAI_BASE_URL=https://private.near.ai
# Agent
AGENT_NAME=ironclaw
@@ -0,0 +1,43 @@
-- Allow embedding vectors of any dimension (not just 1536).
-- This supports Ollama models (768-dim nomic-embed-text, 1024-dim mxbai-embed-large)
-- alongside OpenAI models (1536-dim text-embedding-3-small, 3072-dim text-embedding-3-large).
--
-- NOTE: HNSW indexes require a fixed dimension, so we drop the index.
-- Exact (sequential) cosine distance search still works without the index.
-- For a personal assistant workspace the dataset is small enough that this
-- has negligible impact on query latency.
-- Drop dependent views first
DROP VIEW IF EXISTS chunks_pending_embedding;
DROP VIEW IF EXISTS memory_documents_summary;
DROP INDEX IF EXISTS idx_memory_chunks_embedding;
ALTER TABLE memory_chunks
ALTER COLUMN embedding TYPE vector
USING embedding::vector;
-- Recreate the views
CREATE VIEW memory_documents_summary AS
SELECT
d.id,
d.user_id,
d.path,
d.created_at,
d.updated_at,
COUNT(c.id) as chunk_count,
COUNT(c.embedding) as embedded_chunk_count
FROM memory_documents d
LEFT JOIN memory_chunks c ON c.document_id = d.id
GROUP BY d.id;
CREATE VIEW chunks_pending_embedding AS
SELECT
c.id as chunk_id,
c.document_id,
d.user_id,
d.path,
LENGTH(c.content) as content_length
FROM memory_chunks c
JOIN memory_documents d ON d.id = c.document_id
WHERE c.embedding IS NULL;
+42
View File
@@ -0,0 +1,42 @@
{
"bundles": {
"google": {
"display_name": "Google Suite",
"description": "Gmail, Calendar, Drive, Docs, Sheets, Slides",
"extensions": [
"tools/gmail",
"tools/google-calendar",
"tools/google-docs",
"tools/google-drive",
"tools/google-sheets",
"tools/google-slides"
],
"shared_auth": "google_oauth_token"
},
"messaging": {
"display_name": "Messaging Channels",
"description": "Discord, Telegram, Slack, and WhatsApp channels",
"extensions": [
"channels/discord",
"channels/telegram",
"channels/slack",
"channels/whatsapp"
],
"shared_auth": null
},
"default": {
"display_name": "Recommended Set",
"description": "Core tools and channels for a productive setup",
"extensions": [
"tools/github",
"tools/gmail",
"tools/google-calendar",
"tools/google-drive",
"tools/slack",
"channels/telegram",
"channels/slack"
],
"shared_auth": null
}
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "discord",
"display_name": "Discord",
"kind": "channel",
"version": "0.1.0",
"description": "Discord Gateway/Webhook channel for slash commands, buttons, and messages",
"keywords": ["messaging", "chat", "discord", "bot"],
"source": {
"dir": "channels-src/discord",
"capabilities": "discord.capabilities.json",
"crate_name": "discord-channel"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "manual",
"provider": "Discord",
"secrets": ["discord_bot_token"],
"shared_auth": null,
"setup_url": "https://discord.com/developers/applications"
},
"tags": ["messaging"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "slack",
"display_name": "Slack",
"kind": "channel",
"version": "0.1.0",
"description": "Slack Events API channel for receiving and responding to Slack messages",
"keywords": ["messaging", "chat", "workspace", "slack"],
"source": {
"dir": "channels-src/slack",
"capabilities": "slack.capabilities.json",
"crate_name": "slack-channel"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "manual",
"provider": "Slack",
"secrets": ["slack_bot_token", "slack_signing_secret"],
"shared_auth": null,
"setup_url": "https://api.slack.com/apps"
},
"tags": ["default", "messaging"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "telegram",
"display_name": "Telegram",
"kind": "channel",
"version": "0.1.0",
"description": "Telegram Bot API channel for receiving and responding to messages",
"keywords": ["messaging", "bot", "chat", "telegram"],
"source": {
"dir": "channels-src/telegram",
"capabilities": "telegram.capabilities.json",
"crate_name": "telegram-channel"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "manual",
"provider": "Telegram",
"secrets": ["telegram_bot_token"],
"shared_auth": null,
"setup_url": "https://t.me/BotFather"
},
"tags": ["default", "messaging"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "whatsapp",
"display_name": "WhatsApp",
"kind": "channel",
"version": "0.1.0",
"description": "WhatsApp Cloud API channel for receiving and responding to messages",
"keywords": ["messaging", "chat", "whatsapp", "meta"],
"source": {
"dir": "channels-src/whatsapp",
"capabilities": "whatsapp.capabilities.json",
"crate_name": "whatsapp-channel"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "manual",
"provider": "Meta",
"secrets": ["whatsapp_access_token", "whatsapp_verify_token"],
"shared_auth": null,
"setup_url": "https://developers.facebook.com/apps/"
},
"tags": ["messaging"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "github",
"display_name": "GitHub",
"kind": "tool",
"version": "0.1.0",
"description": "GitHub integration for issues, PRs, repos, and code search",
"keywords": ["git", "code", "issues", "pull-requests", "repositories"],
"source": {
"dir": "tools-src/github",
"capabilities": "github-tool.capabilities.json",
"crate_name": "github-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "manual",
"provider": "GitHub",
"secrets": ["github_token"],
"shared_auth": null,
"setup_url": "https://github.com/settings/tokens"
},
"tags": ["default", "development"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "gmail",
"display_name": "Gmail",
"kind": "tool",
"version": "0.1.0",
"description": "Read, send, and manage Gmail messages and threads",
"keywords": ["email", "google", "mail", "messaging"],
"source": {
"dir": "tools-src/gmail",
"capabilities": "gmail-tool.capabilities.json",
"crate_name": "gmail-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "oauth",
"provider": "Google",
"secrets": ["google_oauth_token"],
"shared_auth": "google_oauth_token",
"setup_url": "https://console.cloud.google.com/apis/credentials"
},
"tags": ["default", "google", "messaging"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "google-calendar",
"display_name": "Google Calendar",
"kind": "tool",
"version": "0.1.0",
"description": "Create, read, update, and delete Google Calendar events",
"keywords": ["calendar", "google", "scheduling", "events"],
"source": {
"dir": "tools-src/google-calendar",
"capabilities": "google-calendar-tool.capabilities.json",
"crate_name": "google-calendar-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "oauth",
"provider": "Google",
"secrets": ["google_oauth_token"],
"shared_auth": "google_oauth_token",
"setup_url": "https://console.cloud.google.com/apis/credentials"
},
"tags": ["default", "google", "productivity"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "google-docs",
"display_name": "Google Docs",
"kind": "tool",
"version": "0.1.0",
"description": "Create and edit Google Docs documents",
"keywords": ["documents", "google", "writing", "docs"],
"source": {
"dir": "tools-src/google-docs",
"capabilities": "google-docs-tool.capabilities.json",
"crate_name": "google-docs-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "oauth",
"provider": "Google",
"secrets": ["google_oauth_token"],
"shared_auth": "google_oauth_token",
"setup_url": "https://console.cloud.google.com/apis/credentials"
},
"tags": ["google", "productivity"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "google-drive",
"display_name": "Google Drive",
"kind": "tool",
"version": "0.1.0",
"description": "Upload, download, search, and manage Google Drive files and folders",
"keywords": ["storage", "google", "files", "drive"],
"source": {
"dir": "tools-src/google-drive",
"capabilities": "google-drive-tool.capabilities.json",
"crate_name": "google-drive-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "oauth",
"provider": "Google",
"secrets": ["google_oauth_token"],
"shared_auth": "google_oauth_token",
"setup_url": "https://console.cloud.google.com/apis/credentials"
},
"tags": ["default", "google", "storage"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "google-sheets",
"display_name": "Google Sheets",
"kind": "tool",
"version": "0.1.0",
"description": "Read and write Google Sheets spreadsheet data",
"keywords": ["spreadsheets", "google", "data", "sheets"],
"source": {
"dir": "tools-src/google-sheets",
"capabilities": "google-sheets-tool.capabilities.json",
"crate_name": "google-sheets-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "oauth",
"provider": "Google",
"secrets": ["google_oauth_token"],
"shared_auth": "google_oauth_token",
"setup_url": "https://console.cloud.google.com/apis/credentials"
},
"tags": ["google", "productivity"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "google-slides",
"display_name": "Google Slides",
"kind": "tool",
"version": "0.1.0",
"description": "Create and edit Google Slides presentations",
"keywords": ["presentations", "google", "slides"],
"source": {
"dir": "tools-src/google-slides",
"capabilities": "google-slides-tool.capabilities.json",
"crate_name": "google-slides-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "oauth",
"provider": "Google",
"secrets": ["google_oauth_token"],
"shared_auth": "google_oauth_token",
"setup_url": "https://console.cloud.google.com/apis/credentials"
},
"tags": ["google", "productivity"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "okta",
"display_name": "Okta",
"kind": "tool",
"version": "0.1.0",
"description": "Okta SSO for user profile, app catalog, and SSO launch links",
"keywords": ["sso", "identity", "authentication", "okta"],
"source": {
"dir": "tools-src/okta",
"capabilities": "okta-tool.capabilities.json",
"crate_name": "okta-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "oauth",
"provider": "Okta",
"secrets": ["okta_oauth_token"],
"shared_auth": null,
"setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/"
},
"tags": ["identity"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "slack",
"display_name": "Slack",
"kind": "tool",
"version": "0.1.0",
"description": "Post messages, read channels, and manage conversations via Slack API",
"keywords": ["messaging", "chat", "workspace"],
"source": {
"dir": "tools-src/slack",
"capabilities": "slack-tool.capabilities.json",
"crate_name": "slack-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "oauth",
"provider": "Slack",
"secrets": ["slack_bot_token"],
"shared_auth": null,
"setup_url": "https://api.slack.com/apps"
},
"tags": ["default", "messaging"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "telegram",
"display_name": "Telegram",
"kind": "tool",
"version": "0.1.0",
"description": "Telegram user-mode integration via MTProto for messages and contacts",
"keywords": ["messaging", "chat", "telegram", "mtproto"],
"source": {
"dir": "tools-src/telegram",
"capabilities": "telegram-tool.capabilities.json",
"crate_name": "telegram-tool"
},
"artifacts": {
"wasm32-wasip2": {
"url": null,
"sha256": null
}
},
"auth_summary": {
"method": "manual",
"provider": "Telegram",
"secrets": ["telegram_api_id", "telegram_api_hash"],
"shared_auth": null,
"setup_url": "https://my.telegram.org/apps"
},
"tags": ["messaging"]
}
+566
View File
@@ -0,0 +1,566 @@
# IronClaw Network Security Reference
This document catalogs every network-facing surface in IronClaw, its authentication mechanism, bind address, security controls, and known findings. Use this as the authoritative reference during code reviews that touch network-facing code.
**Last updated:** 2026-02-18
---
## Threat Model
IronClaw operates across four trust boundaries:
| Boundary | Trust Level | Examples |
|----------|------------|---------|
| **Local user** | Fully trusted | TUI, web gateway (loopback), CLI commands |
| **Browser client** | Authenticated | Web UI connected via bearer token; subject to CORS, Origin validation, CSRF protections |
| **Docker containers** | Untrusted (sandboxed) | Worker containers executing user jobs; isolated via per-job tokens, allowlisted egress, dropped capabilities |
| **External services** | Untrusted | Webhook senders (Telegram, Slack); authenticated via shared secret |
**Key assumptions:**
- The local machine is single-user. The web gateway and OAuth listener bind to loopback and do not defend against other local users.
- Docker containers are adversarial. A compromised container should not be able to access other jobs, exfiltrate secrets, or reach the host network beyond the orchestrator API.
- Webhook senders must prove knowledge of the shared secret. The secret is never transmitted in the clear by IronClaw itself.
- MCP server URLs are operator-configured and treated as trusted destinations (see [MCP Client](#mcp-client)).
---
## Network Surface Inventory
| Listener | Default Port | Default Bind | Auth Mechanism | Config Env Var | Source |
|----------|-------------|-------------|----------------|----------------|--------|
| Web Gateway | 3000 | `127.0.0.1` | Bearer token (constant-time) | `GATEWAY_HOST`, `GATEWAY_PORT`, `GATEWAY_AUTH_TOKEN` | `server.rs``start_server()` |
| HTTP Webhook Server | 8080 | `0.0.0.0` | Shared secret (body field) | `HTTP_HOST`, `HTTP_PORT`, `HTTP_WEBHOOK_SECRET` | `webhook_server.rs``start()` |
| Orchestrator Internal API | 50051 | `127.0.0.1` (macOS/Win) / `0.0.0.0` (Linux) | Per-job bearer token (constant-time) | `ORCHESTRATOR_PORT` | `api.rs``OrchestratorApi::start()` |
| OAuth Callback Listener | 9876 | `127.0.0.1` | None (ephemeral, 5-min timeout) | N/A (hardcoded) | `oauth_defaults.rs``bind_callback_listener()` |
| Sandbox HTTP Proxy | OS-assigned (ephemeral) | `127.0.0.1` | None (loopback only) | N/A (auto-assigned) | `proxy/http.rs``SandboxProxy::start()` |
---
## 1. Web Gateway
**Source:** `src/channels/web/server.rs`, `src/channels/web/auth.rs`
### Bind Address
Configurable via `GATEWAY_HOST` (default `127.0.0.1`) and `GATEWAY_PORT` (default `3000`). The gateway is designed as a local-first, single-user service.
**Reference:** `src/config.rs``gateway_host` default (`"127.0.0.1"`), `gateway_port` default (`3000`)
### Authentication
Bearer token middleware applied to all `/api/*` routes via `route_layer`. Token checked in two locations:
1. `Authorization: Bearer <token>` header (primary)
2. `?token=<token>` query parameter (fallback for SSE `EventSource` which cannot set headers)
Both paths use **constant-time comparison** via `subtle::ConstantTimeEq` (`ct_eq`).
**Reference:** `src/channels/web/auth.rs``auth_middleware()`, header check and query-param fallback both use `ct_eq`
If `GATEWAY_AUTH_TOKEN` is not set, a random hex token is generated at startup.
### Unauthenticated Routes
| Route | Purpose | Response |
|-------|---------|----------|
| `/api/health` | Health check endpoint | `{"status":"healthy","channel":"gateway"}` — no version, uptime, or fingerprinting data |
| `/` | Static HTML (embedded) | Single-page app shell |
| `/style.css` | Static CSS (embedded) | Stylesheet |
| `/app.js` | Static JS (embedded) | Client-side app |
### CORS Policy
Restricted to a two-origin allowlist (not browser same-origin policy, but a CORS allowlist that achieves equivalent protection):
- `http://<bind_ip>:<bind_port>`
- `http://localhost:<bind_port>`
Allowed methods: `GET`, `POST`, `PUT`, `DELETE`. Allowed headers: `Content-Type`, `Authorization`. Credentials allowed.
**Reference:** `src/channels/web/server.rs``CorsLayer::new()` block
### WebSocket Origin Validation
The `/api/chat/ws` endpoint has two layers of protection:
1. **Bearer token auth** — the route is inside the `protected` router with `route_layer`, so `auth_middleware` runs before the handler. The token is passed via the `Authorization: Bearer` header on the HTTP upgrade request (not via query parameter).
2. **Origin header validation** (inside the handler) as a defense-in-depth guard against cross-site WebSocket hijacking (CSWSH):
- Origin header is **required** — missing Origin returns 403 (browsers always send it for WS upgrades; absence implies a non-browser client)
- Origin host is extracted by stripping scheme and port, then compared **exactly** against `localhost`, `127.0.0.1`, and `[::1]`
- Partial matches like `localhost.evil.com` are rejected because the check extracts the host portion before the first `:` or `/`
**Reference:** `src/channels/web/server.rs``chat_ws_handler()` (origin validation block)
### Rate Limiting
Chat endpoint (`/api/chat/send`) enforces a sliding-window rate limit: **30 requests per 60 seconds** (global, not per-IP — single-user gateway).
**Reference:** `src/channels/web/server.rs``RateLimiter` struct, `chat_rate_limiter` field
### Body Limits
- Global: **1 MB** max request body (`DefaultBodyLimit::max(1024 * 1024)`)
- **Reference:** `src/channels/web/server.rs``.layer(DefaultBodyLimit::max(...))`
### Project File Serving
The `/projects/{project_id}/*` routes serve files from project directories. These are **behind auth middleware** to prevent unauthorized file access.
**Reference:** `src/channels/web/server.rs` — project file routes in `protected` router
### Security Headers
The gateway sets the following security headers on all responses (via `SetResponseHeaderLayer::if_not_present`, so handlers can override):
- `X-Content-Type-Options: nosniff` — prevents MIME-sniffing
- `X-Frame-Options: DENY` — prevents clickjacking via iframes
**Reference:** `src/channels/web/server.rs``SetResponseHeaderLayer` calls
### Graceful Shutdown
Shutdown is triggered via a `oneshot::Sender` stored in `GatewayState::shutdown_tx`. The server uses `axum::serve(...).with_graceful_shutdown(...)` to drain in-flight requests before closing the listener.
**Reference:** `src/channels/web/server.rs``shutdown_tx` / `shutdown_rx` setup
---
## 2. HTTP Webhook Server
**Source:** `src/channels/webhook_server.rs`, `src/channels/http.rs`
### Bind Address
Configurable via `HTTP_HOST` (default `0.0.0.0`) and `HTTP_PORT` (default `8080`).
**WARNING:** The default bind address is `0.0.0.0`, meaning the webhook server listens on **all interfaces** by default. This is intentional (webhooks must be reachable from external services like Telegram/Slack), but operators should be aware of the exposure.
**Reference:** `src/config.rs``http_host` default (`"0.0.0.0"`), `http_port` default (`8080`)
### Authentication
Webhook secret is passed **in the JSON request body** (`secret` field), not as a header. The secret is compared using **constant-time** `subtle::ConstantTimeEq` (`ct_eq`).
The secret is required to start the channel — if `HTTP_WEBHOOK_SECRET` is not set, `start()` returns an error.
**CSRF note:** Because the secret is in the JSON body (not a cookie or header that browsers auto-attach), a cross-origin form POST cannot forge a valid request. Browsers would send `application/x-www-form-urlencoded`, which the `Json<T>` extractor rejects with HTTP 415. Even if `Content-Type` were spoofed via CORS preflight, the attacker would need the secret value, which is never stored in the browser.
**Reference:** `src/channels/http.rs``webhook_handler()` (secret validation with `ct_eq`), `start()` (required-secret check)
### Content-Type Validation
The webhook endpoint uses axum's `Json<WebhookRequest>` extractor, which enforces `Content-Type: application/json`. Requests with missing or incorrect Content-Type are rejected with **HTTP 415 Unsupported Media Type** before the handler body executes. Malformed JSON bodies are rejected with **HTTP 422 Unprocessable Entity**.
**Reference:** `src/channels/http.rs``webhook_handler()` function signature (`Json(req): Json<WebhookRequest>`)
### Rate Limiting
**60 requests per minute**, enforced via a mutex-protected sliding window.
**Reference:** `src/channels/http.rs``MAX_REQUESTS_PER_MINUTE` constant, rate-limit check in `webhook_handler()`
### Body Limits
- JSON body: **64 KB** max (`MAX_BODY_BYTES`)
- Message content: **32 KB** max (`MAX_CONTENT_BYTES`)
- Pending synchronous responses: **100 max** (`MAX_PENDING_RESPONSES`)
- Synchronous response timeout: **60 seconds**
**Reference:** `src/channels/http.rs` — constants block (`MAX_BODY_BYTES`, `MAX_CONTENT_BYTES`, `MAX_PENDING_RESPONSES`, `MAX_REQUESTS_PER_MINUTE`)
### Routes
| Route | Auth | Purpose | Response |
|-------|------|---------|----------|
| `/health` | None | Health check | `{"status":"healthy","channel":"http"}` — no fingerprinting data |
| `/webhook` | Webhook secret | Receive messages | Webhook response |
### Graceful Shutdown
Shutdown is triggered via a `oneshot::Sender` stored on the `WebhookServer` struct. The server uses `axum::serve(...).with_graceful_shutdown(...)`. The public `shutdown()` method sends the signal and awaits the task join handle, ensuring a clean drain-and-wait.
**Reference:** `src/channels/webhook_server.rs``shutdown()` method
---
## 3. Orchestrator Internal API
**Source:** `src/orchestrator/api.rs`, `src/orchestrator/auth.rs`
### Bind Address
Platform-dependent:
- **macOS / Windows**: `127.0.0.1:<port>` — Docker Desktop routes `host.docker.internal` through its VM to `127.0.0.1`
- **Linux**: `0.0.0.0:<port>` — containers reach the host via the Docker bridge gateway (`172.17.0.1`), which is not loopback
Default port: `50051`.
**Reference:** `src/orchestrator/api.rs``OrchestratorApi::start()`, platform-conditional bind address block
### Authentication
Per-job bearer tokens validated by `worker_auth_middleware`:
1. Tokens are **cryptographically random** (32 bytes, hex-encoded = 64 chars)
2. Tokens are **scoped to a specific job_id** — a token for job A cannot access endpoints for job B
3. Comparison uses **constant-time** `subtle::ConstantTimeEq`
4. Tokens are **ephemeral** (in-memory only, never persisted to disk or DB)
5. Tokens and associated credential grants are **revoked** when the container is cleaned up
**Reference:** `src/orchestrator/auth.rs``TokenStore::create_token()`, `TokenStore::validate()`, `generate_token()`
### Token Extraction
The middleware extracts the job UUID from the URL path (`/worker/{job_id}/...`) and validates the `Authorization: Bearer` header against the stored token for that specific job.
**Reference:** `src/orchestrator/auth.rs``worker_auth_middleware()`, `extract_job_id_from_path()`
### Credential Grants
The orchestrator can grant per-job access to specific secrets from the encrypted secrets store. Grants are:
- Stored alongside the token in the `TokenStore`
- Scoped to specific `(secret_name, env_var)` pairs
- Revoked when the job token is revoked
- Decrypted on-demand when the worker requests `/worker/{job_id}/credentials`
**Reference:** `src/orchestrator/auth.rs``CredentialGrant` struct, `src/orchestrator/api.rs``get_credentials_handler()`
### Rate Limiting
**None.** The orchestrator API has no rate limiting. All `/worker/*` endpoints are authenticated via per-job bearer tokens, but a compromised container could spam authenticated endpoints without throttling.
**Mitigation:** Tokens are scoped per-job so a compromised container can only abuse its own job's endpoints. Container execution is time-bounded (see [Docker Container Security](#docker-container-security)), which limits the window for abuse.
### Routes
| Route | Auth | Purpose | Response |
|-------|------|---------|----------|
| `/health` | None | Health check | `"ok"` (plain text) — no fingerprinting data |
| `/worker/{job_id}/job` | Per-job token | Get job description | Job JSON |
| `/worker/{job_id}/llm/complete` | Per-job token | Proxy LLM completion | LLM response |
| `/worker/{job_id}/llm/complete_with_tools` | Per-job token | Proxy LLM tool completion | LLM response |
| `/worker/{job_id}/status` | Per-job token | Report worker status | Ack |
| `/worker/{job_id}/complete` | Per-job token | Report job completion | Ack |
| `/worker/{job_id}/event` | Per-job token | Send job events (SSE broadcast) | Ack |
| `/worker/{job_id}/prompt` | Per-job token | Poll for follow-up prompts | Prompt or empty |
| `/worker/{job_id}/credentials` | Per-job token | Retrieve decrypted credentials | Credentials JSON |
### Graceful Shutdown
**None.** The orchestrator calls `axum::serve(listener, router).await?` without `.with_graceful_shutdown()`. The server stops only when the task is dropped (process exit or tokio task cancellation). In-flight requests may be interrupted.
**Reference:** `src/orchestrator/api.rs``OrchestratorApi::start()`
---
## 4. OAuth Callback Listener
**Source:** `src/cli/oauth_defaults.rs`
### Bind Address
Always binds to **loopback only**: `127.0.0.1:9876`. Falls back to `[::1]:9876` (IPv6 loopback) if IPv4 binding fails for reasons other than `AddrInUse`. If the port is already in use, the error is returned immediately (fail-fast).
Both IPv4 and IPv6 loopback addresses are security-equivalent — they are only reachable from the local machine.
**Reference:** `src/cli/oauth_defaults.rs``OAUTH_CALLBACK_PORT` constant, `bind_callback_listener()`
### Lifecycle
The listener is **ephemeral** — it is started only when an OAuth flow is initiated (e.g., `ironclaw tool auth <name>`) and shut down after the callback is received or the timeout expires.
### Timeout
**5-minute timeout** (`Duration::from_secs(300)`). If the user does not complete the OAuth flow in the browser within 5 minutes, the listener shuts down.
**Reference:** `src/cli/oauth_defaults.rs``tokio::time::timeout(Duration::from_secs(300), ...)`
### Security Controls
- **HTML escaping**: Provider names displayed in the landing page are HTML-escaped to prevent XSS (escapes `&`, `<`, `>`, `"`, `'`)
- **Error parameter checking**: The handler checks for `error=` in the callback query string before extracting the auth code
- **URL decoding**: Callback parameters are URL-decoded safely
**Reference:** `src/cli/oauth_defaults.rs``html_escape()`
### Built-in OAuth Credentials
Google OAuth client ID and secret are compiled into the binary (with compile-time override via `IRONCLAW_GOOGLE_CLIENT_ID` / `IRONCLAW_GOOGLE_CLIENT_SECRET`). As noted in the source, Google Desktop App client secrets are [not actually secret](https://developers.google.com/identity/protocols/oauth2/native-app) per Google's documentation.
**Reference:** `src/cli/oauth_defaults.rs``GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` constants
### Graceful Shutdown
Implicit. The listener is a raw `TcpListener` (not axum) inside a `tokio::time::timeout` future. Once the authorization code or error is received, the future returns and the `TcpListener` is dropped, closing the port. No explicit shutdown signal is needed.
**Reference:** `src/cli/oauth_defaults.rs``wait_for_callback()`
---
## 5. Sandbox HTTP Proxy
**Source:** `src/sandbox/proxy/http.rs`, `src/sandbox/proxy/allowlist.rs`, `src/sandbox/proxy/policy.rs`
### Bind Address
Always binds to **`127.0.0.1`** (localhost only). Port is OS-assigned (port `0`, ephemeral). Falls back to `[::1]` (IPv6 loopback) if IPv4 is unavailable.
Both IPv4 and IPv6 loopback addresses are security-equivalent — they are only reachable from the local machine.
**Reference:** `src/sandbox/proxy/http.rs``SandboxProxy::start()`, `TcpListener::bind("127.0.0.1:0")`
### Purpose
Acts as an HTTP/HTTPS proxy for Docker sandbox containers. Containers are configured with `http_proxy` / `https_proxy` environment variables pointing to this proxy, so all outbound HTTP traffic is routed through it.
### Domain Allowlisting
All requests are validated against a domain allowlist before being forwarded:
- **Empty allowlist = deny all** (fail-closed default)
- Supports exact matches and wildcard patterns (`*.example.com`)
- Validates URL scheme (HTTP/HTTPS only, rejects `ftp://`, `file://`, etc.)
**Reference:** `src/sandbox/proxy/allowlist.rs``DomainAllowlist` struct, `is_allowed()` method
### HTTPS Tunneling (CONNECT)
- CONNECT requests for HTTPS tunneling are subject to the same allowlist
- **30-minute timeout** on established tunnels to prevent indefinite holds
- **No MITM**: the proxy cannot inspect or inject credentials into HTTPS traffic (by design — containers that need credentials must use the orchestrator's `/worker/{job_id}/credentials` endpoint)
**Reference:** `src/sandbox/proxy/http.rs``handle_connect()` function
### Credential Injection (HTTP only)
For plain HTTP requests to allowed hosts, the proxy can inject credentials:
- Bearer tokens in `Authorization` header
- Custom headers (e.g., `X-API-Key`)
- Query parameters
- Credentials are resolved at request time from the encrypted secrets store
- Credentials never enter the container's environment or filesystem
**Reference:** `src/sandbox/proxy/http.rs` — credential injection block in `handle_request()`
### Hop-by-Hop Header Filtering
The proxy strips hop-by-hop headers to prevent header-based attacks: `connection`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `te`, `trailers`, `transfer-encoding`, `upgrade`.
**Reference:** `src/sandbox/proxy/http.rs``is_hop_by_hop_header()`
### Docker Container Security
Containers that use the proxy are configured with defense-in-depth:
| Control | Setting | Reference |
|---------|---------|-----------|
| Capabilities | Drop ALL, add only CHOWN | `src/sandbox/container.rs``cap_drop` / `cap_add` |
| Privilege escalation | `no-new-privileges:true` | `src/sandbox/container.rs``security_opt` |
| Root filesystem | Read-only (except FullAccess policy) | `src/sandbox/container.rs``readonly_rootfs` |
| User | Non-root (UID 1000:1000) | `src/sandbox/container.rs``user` field |
| Network | Bridge mode (isolated) | `src/sandbox/container.rs``network_mode` |
| Tmpfs | `/tmp` (512 MB), `/home/sandbox/.cargo/registry` (1 GB) | `src/sandbox/container.rs``tmpfs` block |
| Auto-remove | Enabled | `src/sandbox/container.rs``auto_remove` |
| Output limits | Configurable max stdout/stderr | `src/sandbox/container.rs``collect_logs()` |
| Timeout | Enforced with forced container removal | `src/sandbox/container.rs``tokio::time::timeout` in `run()` |
### Graceful Shutdown
Shutdown is triggered via a `oneshot::Sender` stored on the proxy. The accept loop uses `tokio::select!` to race `listener.accept()` against the shutdown signal. The `stop()` method fires the signal; the loop breaks on the next iteration. Note: `stop()` does not await a join handle, so there is no drain-and-wait for in-flight connections.
**Reference:** `src/sandbox/proxy/http.rs``stop()` method, `tokio::select!` loop
---
## Egress Controls
### WASM Tool HTTP Requests
WASM tools execute HTTP requests through the host runtime, subject to:
1. **Endpoint allowlist** — declared in `<tool>.capabilities.json`, validated by `AllowlistValidator`
- Host matching (exact or wildcard)
- Path prefix matching
- HTTP method restriction
- HTTPS required by default
- Userinfo in URLs (`user:pass@host`) rejected to prevent allowlist bypass
- Path traversal (`../`, `%2e%2e/`) normalized and blocked
- Invalid percent-encoding rejected
- **Reference:** `src/tools/wasm/allowlist.rs`
2. **Credential injection** — secrets injected at the host boundary by `CredentialInjector`
- WASM code never sees actual credential values
- Secrets must be in the tool's `allowed_secrets` list
- Injection supports: Bearer header, Basic auth, custom header, query parameter
- **Reference:** `src/tools/wasm/credential_injector.rs`
3. **Leak detection**`LeakDetector` scans both outbound requests and inbound responses for secret patterns
- Runs at two points: before sending and after receiving
- Uses Aho-Corasick for fast multi-pattern matching
- **Reference:** `src/safety/leak_detector.rs`
### Built-in HTTP Tool
The `http` tool (`src/tools/builtin/http.rs`) has its own SSRF protections:
| Protection | Details | Reference |
|-----------|---------|-----------|
| HTTPS only | Rejects `http://` URLs | `http.rs` — scheme check |
| Localhost blocked | Rejects `localhost` and `*.localhost` | `http.rs` — host check |
| Private IP blocked | Rejects RFC 1918, loopback, link-local, multicast, unspecified | `http.rs``is_disallowed_ip()` |
| DNS rebinding | Resolves hostname and checks all resolved IPs against blocklist | `http.rs` — DNS resolution block |
| Cloud metadata | Blocks `169.254.169.254` (AWS/GCP metadata endpoint) | `http.rs``is_disallowed_ip()` |
| Redirect blocking | Returns error on 3xx responses (prevents SSRF via redirect) | `http.rs` — status code check |
| Response size limit | **5 MB** max, enforced both via Content-Length header and streaming | `http.rs``MAX_RESPONSE_SIZE` constant, streaming cap |
| Outbound leak scan | Scans URL, headers, and body for secrets before sending | `http.rs``LeakDetector::scan_http_request()` |
| Approval required | Requires user approval before execution | `http.rs``requires_approval()` returns `true` |
| Timeout | 30 seconds default | `http.rs``reqwest::Client` builder |
| No redirects | `redirect::Policy::none()` — redirects are not followed | `http.rs``reqwest::Client` builder |
### MCP Client
MCP servers are external processes accessed via HTTP. The MCP client (`src/tools/mcp/client.rs`) uses `reqwest` with a 30-second timeout but has **no SSRF protections** — it connects to whatever URL is configured for the MCP server.
This is by design: MCP server URLs come from **operator-controlled configuration** (config files, environment variables, or the CLI `tool install` command), not from user input or LLM output. A compromised config file is outside IronClaw's threat model — it would imply the operator's machine is already compromised.
**Reference:** `src/tools/mcp/client.rs``reqwest::Client` builder
### Sandbox Domain Allowlists
Sandbox containers route all HTTP traffic through the proxy, which enforces a domain allowlist. The allowlist is built from:
1. A default set of domains (`src/sandbox/config.rs``default_allowlist()`)
2. Additional domains from `SANDBOX_EXTRA_DOMAINS` env var (comma-separated)
**Reference:** `src/config.rs` — sandbox allowlist assembly
---
## Authentication Mechanisms Summary
| Mechanism | Constant-Time | Used By | Reference |
|-----------|:------------:|---------|-----------|
| Gateway bearer token | Yes | Web gateway (header + query) | `src/channels/web/auth.rs``auth_middleware()` |
| Webhook shared secret | Yes | HTTP webhook (`ct_eq` comparison) | `src/channels/http.rs``webhook_handler()` |
| Per-job bearer token | Yes | Orchestrator worker API | `src/orchestrator/auth.rs``TokenStore::validate()` |
| OAuth callback | N/A | CLI OAuth flow (no auth, loopback-only) | `src/cli/oauth_defaults.rs``bind_callback_listener()` |
| Sandbox proxy | N/A | No auth (loopback-only, ephemeral) | `src/sandbox/proxy/http.rs``SandboxProxy::start()` |
---
## Known Security Findings
### Open
#### F-2. No TLS at the application layer
**Severity:** Low (for local deployment)
**Details:** None of the listeners terminate TLS. All communication is plain HTTP.
**Mitigation:** The web gateway and OAuth callback bind to loopback by default. For production, users are expected to front the gateway with a reverse proxy (nginx, Caddy) or tunnel (Cloudflare, ngrok) that provides TLS.
**Recommendation:** Document the requirement for a TLS-terminating reverse proxy in deployment guides.
#### F-3. Orchestrator binds to `0.0.0.0` on Linux
**Severity:** Medium
**Location:** `src/orchestrator/api.rs` — platform-conditional bind in `OrchestratorApi::start()`
**Details:** On Linux, the orchestrator API binds to all interfaces because Docker containers reach the host via the bridge gateway (`172.17.0.1`), not loopback. This means the API is reachable from any network interface on the host.
**Mitigation:** All `/worker/*` endpoints require per-job bearer tokens (constant-time, cryptographically random). The `/health` endpoint is the only unauthenticated route and returns only `"ok"`. Firewall rules should block external access to port 50051.
**Recommendation:** Document firewall requirements for Linux deployments. Consider binding to the Docker bridge IP (`172.17.0.1`) instead of `0.0.0.0`.
#### F-6. WebSocket/SSE connection limit
**Severity:** Info
**Details:** The `SseManager` enforces a hard limit of **100 concurrent connections** (`MAX_CONNECTIONS` constant in `src/channels/web/sse.rs`). Both SSE subscribers and WebSocket connections share this counter. When exceeded, new WebSocket upgrades are rejected with a warning log and the connection is immediately closed.
**Reference:** `src/channels/web/sse.rs``MAX_CONNECTIONS`, `src/channels/web/ws.rs``handle_ws_connection()` early return
#### F-7. Orchestrator API has no rate limiting
**Severity:** Low
**Details:** The orchestrator API has no request-rate throttling. A compromised container could spam authenticated endpoints (e.g., `/worker/{job_id}/llm/complete`) to drive up LLM costs or degrade service for other jobs.
**Mitigation:** Tokens are scoped per-job, limiting blast radius. Container execution is time-bounded by the sandbox timeout, which caps the abuse window.
**Recommendation:** Consider adding per-token rate limiting on the LLM proxy endpoints.
#### F-8. Orchestrator API has no graceful shutdown
**Severity:** Info
**Details:** The orchestrator calls `axum::serve(listener, router).await?` without `.with_graceful_shutdown()`. In-flight requests (including LLM proxy calls) may be interrupted during process shutdown.
**Reference:** `src/orchestrator/api.rs``OrchestratorApi::start()`
### Resolved / Mitigated
<details>
<summary>Resolved and mitigated findings (click to expand)</summary>
#### F-1. ~~Webhook secret comparison is not constant-time~~ (Resolved)
**Severity:** Low
**Location:** `src/channels/http.rs``webhook_handler()`
**Status:** Resolved — webhook secret now uses `subtle::ConstantTimeEq` (`ct_eq`), consistent with web gateway and orchestrator auth.
#### F-4. ~~HTTP webhook server binds to `0.0.0.0` by default~~ (Mitigated)
**Severity:** Low
**Location:** `src/config.rs`, `src/main.rs`
**Status:** Mitigated — a `tracing::warn!` is now emitted at startup when the webhook server binds to an unspecified address (`0.0.0.0` or `::`), advising operators to set `HTTP_HOST=127.0.0.1` to restrict to localhost. The default bind address remains `0.0.0.0`, so webhook exposure is still controlled by operator configuration and external network controls (firewalls, ingress rules).
#### F-5. ~~Missing security headers on web gateway~~ (Mitigated)
**Severity:** Low
**Status:** Mitigated — `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` are now set on all gateway responses via `SetResponseHeaderLayer::if_not_present`. Layer ordering ensures these headers are applied even to error responses generated by inner layers (e.g., `DefaultBodyLimit` 413 rejections).
</details>
---
## Review Checklist for Network Changes
Use this checklist for any PR that adds or modifies network-facing code.
### New Listener
- [ ] **Bind address**: Does it bind to loopback (`127.0.0.1`) or all interfaces (`0.0.0.0`)? Justify if `0.0.0.0`.
- [ ] **Port configuration**: Is the port configurable via env var? Is a sensible default set?
- [ ] **Authentication**: Is auth required? If yes, is it constant-time? If no, why not?
- [ ] **Rate limiting**: Is there a rate limiter? What are the limits?
- [ ] **Body size limit**: Is `DefaultBodyLimit` (or equivalent) set?
- [ ] **Content-Type validation**: Does the handler validate Content-Type (e.g., via axum `Json<T>` extractor)?
- [ ] **Graceful shutdown**: Does the listener support graceful shutdown via oneshot or similar?
- [ ] **Inventory update**: Is this document updated with the new listener?
### New Route on Existing Listener
- [ ] **Auth layer**: Is the route behind the auth middleware? If public, why?
- [ ] **Input validation**: Are path parameters, query parameters, and body fields validated?
- [ ] **Error responses**: Do error responses avoid leaking internal details?
### Egress (Outbound HTTP)
- [ ] **SSRF protection**: Does the code block private IPs, localhost, and cloud metadata endpoints?
- [ ] **DNS rebinding**: Are resolved IPs checked (not just the hostname)?
- [ ] **Redirect handling**: Are redirects blocked or validated?
- [ ] **Response size**: Is there a max response size?
- [ ] **Timeout**: Is a request timeout set?
- [ ] **Leak detection**: Is the outbound request scanned for secrets?
### Credential Handling
- [ ] **Constant-time comparison**: Are secrets compared with `subtle::ConstantTimeEq`?
- [ ] **No logging**: Are credentials excluded from log messages?
- [ ] **Ephemeral storage**: Are tokens stored in memory only (not persisted)?
- [ ] **Scope**: Are credentials scoped to the minimum necessary (per-job, per-tool)?
- [ ] **Revocation**: Are credentials revoked when no longer needed?
### Container / Sandbox
- [ ] **Capabilities**: Are all capabilities dropped except what's needed?
- [ ] **Filesystem**: Is the root filesystem read-only?
- [ ] **User**: Does the container run as non-root?
- [ ] **Network**: Is network access routed through the proxy?
- [ ] **Timeout**: Is there an execution timeout with forced cleanup?
- [ ] **Output limits**: Are stdout/stderr capped?
+11
View File
@@ -85,6 +85,7 @@ pub struct Agent {
pub(super) session_manager: Arc<SessionManager>,
pub(super) context_monitor: ContextMonitor,
pub(super) heartbeat_config: Option<HeartbeatConfig>,
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
pub(super) routine_config: Option<RoutineConfig>,
}
@@ -93,11 +94,13 @@ impl Agent {
///
/// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing
/// with external components (job tools, web gateway). Creates new ones if not provided.
#[allow(clippy::too_many_arguments)]
pub fn new(
config: AgentConfig,
deps: AgentDeps,
channels: ChannelManager,
heartbeat_config: Option<HeartbeatConfig>,
hygiene_config: Option<crate::config::HygieneConfig>,
routine_config: Option<RoutineConfig>,
context_manager: Option<Arc<ContextManager>>,
session_manager: Option<Arc<SessionManager>>,
@@ -127,6 +130,7 @@ impl Agent {
session_manager,
context_monitor: ContextMonitor::new(),
heartbeat_config,
hygiene_config,
routine_config,
}
}
@@ -358,8 +362,15 @@ impl Agent {
"Heartbeat enabled with {}s interval",
hb_config.interval_secs
);
let hygiene = self
.hygiene_config
.as_ref()
.map(|h| h.to_workspace_config())
.unwrap_or_default();
Some(spawn_heartbeat(
config,
hygiene,
workspace.clone(),
self.cheap_llm().clone(),
Some(notify_tx),
+1
View File
@@ -232,6 +232,7 @@ impl Agent {
let runner = crate::agent::HeartbeatRunner::new(
crate::agent::HeartbeatConfig::default(),
crate::workspace::hygiene::HygieneConfig::default(),
workspace.clone(),
self.llm().clone(),
);
+20 -2
View File
@@ -105,7 +105,16 @@ impl ContextCompactor {
// Write to workspace if available
let summary_written = if let Some(ws) = workspace {
self.write_summary_to_workspace(ws, &summary).await.is_ok()
match self.write_summary_to_workspace(ws, &summary).await {
Ok(()) => true,
Err(e) => {
tracing::warn!(
"Compaction summary write failed (turns will still be truncated): {}",
e
);
false
}
}
} else {
false
};
@@ -157,7 +166,16 @@ impl ContextCompactor {
let content = format_turns_for_storage(old_turns);
// Write to workspace
let written = self.write_context_to_workspace(ws, &content).await.is_ok();
let written = match self.write_context_to_workspace(ws, &content).await {
Ok(()) => true,
Err(e) => {
tracing::warn!(
"Compaction context write failed (turns will still be truncated): {}",
e
);
false
}
};
// Truncate
thread.truncate_turns(keep_recent);
+687 -243
View File
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -31,6 +31,7 @@ use tokio::sync::mpsc;
use crate::channels::OutgoingResponse;
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
use crate::workspace::Workspace;
use crate::workspace::hygiene::HygieneConfig;
/// Configuration for the heartbeat runner.
#[derive(Debug, Clone)]
@@ -96,6 +97,7 @@ pub enum HeartbeatResult {
/// Heartbeat runner for proactive periodic execution.
pub struct HeartbeatRunner {
config: HeartbeatConfig,
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
@@ -106,11 +108,13 @@ impl HeartbeatRunner {
/// Create a new heartbeat runner.
pub fn new(
config: HeartbeatConfig,
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>,
) -> Self {
Self {
config,
hygiene_config,
workspace,
llm,
response_tx: None,
@@ -145,6 +149,22 @@ impl HeartbeatRunner {
loop {
interval.tick().await;
// Run memory hygiene in the background so it never delays the
// heartbeat checklist. Failures are logged inside run_if_due.
let hygiene_workspace = Arc::clone(&self.workspace);
let hygiene_config = self.hygiene_config.clone();
tokio::spawn(async move {
let report =
crate::workspace::hygiene::run_if_due(&hygiene_workspace, &hygiene_config)
.await;
if report.had_work() {
tracing::info!(
daily_logs_deleted = report.daily_logs_deleted,
"heartbeat: memory hygiene deleted stale documents"
);
}
});
match self.check_heartbeat().await {
HeartbeatResult::Ok => {
tracing::debug!("Heartbeat OK");
@@ -332,11 +352,12 @@ fn strip_html_comments(content: &str) -> String {
/// Returns a handle that can be used to stop the runner.
pub fn spawn_heartbeat(
config: HeartbeatConfig,
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
) -> tokio::task::JoinHandle<()> {
let mut runner = HeartbeatRunner::new(config, workspace, llm);
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm);
if let Some(tx) = response_tx {
runner = runner.with_response_channel(tx);
}
+1 -1
View File
@@ -44,6 +44,6 @@ pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
pub use session_manager::SessionManager;
pub use submission::{Submission, SubmissionParser, SubmissionResult};
pub use task::{Task, TaskContext, TaskHandler, TaskOutput, TaskStatus};
pub use task::{Task, TaskContext, TaskHandler, TaskOutput};
pub use undo::{Checkpoint, UndoManager};
pub use worker::{Worker, WorkerDeps};
+38 -13
View File
@@ -26,6 +26,8 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::RoutineError;
/// A routine is a named, persistent, user-owned task with a trigger and an action.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Routine {
@@ -86,13 +88,16 @@ impl Trigger {
}
/// Parse a trigger from its DB representation.
pub fn from_db(trigger_type: &str, config: serde_json::Value) -> Result<Self, String> {
pub fn from_db(trigger_type: &str, config: serde_json::Value) -> Result<Self, RoutineError> {
match trigger_type {
"cron" => {
let schedule = config
.get("schedule")
.and_then(|v| v.as_str())
.ok_or("cron trigger missing 'schedule'")?
.ok_or_else(|| RoutineError::MissingField {
context: "cron trigger".into(),
field: "schedule".into(),
})?
.to_string();
Ok(Trigger::Cron { schedule })
}
@@ -100,7 +105,10 @@ impl Trigger {
let pattern = config
.get("pattern")
.and_then(|v| v.as_str())
.ok_or("event trigger missing 'pattern'")?
.ok_or_else(|| RoutineError::MissingField {
context: "event trigger".into(),
field: "pattern".into(),
})?
.to_string();
let channel = config
.get("channel")
@@ -120,7 +128,9 @@ impl Trigger {
Ok(Trigger::Webhook { path, secret })
}
"manual" => Ok(Trigger::Manual),
other => Err(format!("unknown trigger type: {other}")),
other => Err(RoutineError::UnknownTriggerType {
trigger_type: other.to_string(),
}),
}
}
@@ -186,13 +196,16 @@ impl RoutineAction {
}
/// Parse an action from its DB representation.
pub fn from_db(action_type: &str, config: serde_json::Value) -> Result<Self, String> {
pub fn from_db(action_type: &str, config: serde_json::Value) -> Result<Self, RoutineError> {
match action_type {
"lightweight" => {
let prompt = config
.get("prompt")
.and_then(|v| v.as_str())
.ok_or("lightweight action missing 'prompt'")?
.ok_or_else(|| RoutineError::MissingField {
context: "lightweight action".into(),
field: "prompt".into(),
})?
.to_string();
let context_paths = config
.get("context_paths")
@@ -217,12 +230,18 @@ impl RoutineAction {
let title = config
.get("title")
.and_then(|v| v.as_str())
.ok_or("full_job action missing 'title'")?
.ok_or_else(|| RoutineError::MissingField {
context: "full_job action".into(),
field: "title".into(),
})?
.to_string();
let description = config
.get("description")
.and_then(|v| v.as_str())
.ok_or("full_job action missing 'description'")?
.ok_or_else(|| RoutineError::MissingField {
context: "full_job action".into(),
field: "description".into(),
})?
.to_string();
let max_iterations = config
.get("max_iterations")
@@ -235,7 +254,9 @@ impl RoutineAction {
max_iterations,
})
}
other => Err(format!("unknown action type: {other}")),
other => Err(RoutineError::UnknownActionType {
action_type: other.to_string(),
}),
}
}
@@ -334,14 +355,16 @@ impl std::fmt::Display for RunStatus {
}
impl FromStr for RunStatus {
type Err = String;
type Err = RoutineError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"running" => Ok(RunStatus::Running),
"ok" => Ok(RunStatus::Ok),
"attention" => Ok(RunStatus::Attention),
"failed" => Ok(RunStatus::Failed),
other => Err(format!("unknown run status: {other}")),
other => Err(RoutineError::UnknownRunStatus {
status: other.to_string(),
}),
}
}
}
@@ -370,9 +393,11 @@ pub fn content_hash(content: &str) -> u64 {
}
/// Parse a cron expression and compute the next fire time from now.
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, String> {
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, RoutineError> {
let cron_schedule =
cron::Schedule::from_str(schedule).map_err(|e| format!("invalid cron: {e}"))?;
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
reason: e.to_string(),
})?;
Ok(cron_schedule.upcoming(Utc).next())
}
+58 -25
View File
@@ -25,6 +25,7 @@ use crate::agent::routine::{
use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::config::RoutineConfig;
use crate::db::Database;
use crate::error::RoutineError;
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
use crate::workspace::Workspace;
@@ -174,23 +175,26 @@ impl RoutineEngine {
}
/// Fire a routine manually (from tool call or CLI).
pub async fn fire_manual(&self, routine_id: Uuid) -> Result<Uuid, String> {
pub async fn fire_manual(&self, routine_id: Uuid) -> Result<Uuid, RoutineError> {
let routine = self
.store
.get_routine(routine_id)
.await
.map_err(|e| format!("DB error: {e}"))?
.ok_or_else(|| format!("routine {routine_id} not found"))?;
.map_err(|e| RoutineError::Database {
reason: e.to_string(),
})?
.ok_or(RoutineError::NotFound { id: routine_id })?;
if !routine.enabled {
return Err(format!("routine '{}' is disabled", routine.name));
return Err(RoutineError::Disabled {
name: routine.name.clone(),
});
}
if !self.check_concurrent(&routine).await {
return Err(format!(
"routine '{}' already at max concurrent runs",
routine.name
));
return Err(RoutineError::MaxConcurrent {
name: routine.name.clone(),
});
}
let run_id = Uuid::new_v4();
@@ -209,7 +213,9 @@ impl RoutineEngine {
};
if let Err(e) = self.store.create_routine_run(&run).await {
return Err(format!("failed to create run record: {e}"));
return Err(RoutineError::Database {
reason: format!("failed to create run record: {e}"),
});
}
// Execute inline for manual triggers (caller wants to wait)
@@ -313,13 +319,27 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
max_tokens,
} => execute_lightweight(&ctx, &routine, prompt, context_paths, *max_tokens).await,
RoutineAction::FullJob { description, .. } => {
// Full job mode: for now, execute as lightweight with the description
// as prompt. Full scheduler integration will come as a follow-up.
tracing::info!(
// Full job mode: scheduler integration not yet implemented.
// Execute as lightweight and prepend a warning to the summary.
tracing::warn!(
routine = %routine.name,
"FullJob mode executing as lightweight (scheduler integration pending)"
"FullJob mode not yet implemented; falling back to lightweight execution"
);
execute_lightweight(&ctx, &routine, description, &[], ctx.max_lightweight_tokens).await
match execute_lightweight(&ctx, &routine, description, &[], ctx.max_lightweight_tokens)
.await
{
Ok((status, summary, tokens)) => {
let warning = "[Note: FullJob mode is not yet implemented. This routine ran as \
a single LLM call without tool access. Configure as 'lightweight' \
or wait for full scheduler integration.]";
let summary = match summary {
Some(s) => Some(format!("{warning}\n\n{s}")),
None => Some(warning.to_string()),
};
Ok((status, summary, tokens))
}
Err(e) => Err(e),
}
}
};
@@ -331,7 +351,7 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
Ok(execution) => execution,
Err(e) => {
tracing::error!(routine = %routine.name, "Execution failed: {}", e);
(RunStatus::Failed, Some(e), None)
(RunStatus::Failed, Some(e.to_string()), None)
}
};
@@ -384,6 +404,20 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
.await;
}
/// Sanitize a routine name for use in workspace paths.
/// Only keeps alphanumeric, dash, and underscore characters; replaces everything else.
fn sanitize_routine_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
/// Execute a lightweight routine (single LLM call).
async fn execute_lightweight(
ctx: &EngineContext,
@@ -391,7 +425,7 @@ async fn execute_lightweight(
prompt: &str,
context_paths: &[String],
max_tokens: u32,
) -> Result<(RunStatus, Option<String>, Option<i32>), String> {
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
// Load context from workspace
let mut context_parts = Vec::new();
for path in context_paths {
@@ -408,8 +442,9 @@ async fn execute_lightweight(
}
}
// Load routine state from workspace
let state_path = format!("routines/{}/state.md", routine.name);
// Load routine state from workspace (name sanitized to prevent path traversal)
let safe_name = sanitize_routine_name(&routine.name);
let state_path = format!("routines/{safe_name}/state.md");
let state_content = match ctx.workspace.read(&state_path).await {
Ok(doc) => Some(doc.content),
Err(_) => None,
@@ -469,7 +504,9 @@ async fn execute_lightweight(
.llm
.complete(request)
.await
.map_err(|e| format!("LLM call failed: {e}"))?;
.map_err(|e| RoutineError::LlmFailed {
reason: e.to_string(),
})?;
let content = response.content.trim();
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
@@ -477,13 +514,9 @@ async fn execute_lightweight(
// Empty content guard (same as heartbeat)
if content.is_empty() {
return if response.finish_reason == FinishReason::Length {
Err(
"LLM response truncated (finish_reason=length) with no content. \
Model may have exhausted token budget on reasoning."
.to_string(),
)
Err(RoutineError::TruncatedResponse)
} else {
Err("LLM returned empty content.".to_string())
Err(RoutineError::EmptyResponse)
};
}
+11 -3
View File
@@ -136,7 +136,9 @@ impl Scheduler {
});
// Start the worker
let _ = tx.send(WorkerMessage::Start).await;
if tx.send(WorkerMessage::Start).await.is_err() {
tracing::error!(job_id = %job_id, "Worker died before receiving Start message");
}
// Insert while still holding the write lock
jobs.insert(job_id, ScheduledJob { handle, tx });
@@ -418,10 +420,16 @@ impl Scheduler {
// Update job state
self.context_manager
.update_context(job_id, |ctx| {
let _ = ctx.transition_to(
if let Err(e) = ctx.transition_to(
JobState::Cancelled,
Some("Stopped by scheduler".to_string()),
);
) {
tracing::warn!(
job_id = %job_id,
error = %e,
"Failed to transition job to Cancelled state"
);
}
})
.await?;
+8 -6
View File
@@ -66,12 +66,14 @@ pub trait SelfRepair: Send + Sync {
/// Default self-repair implementation.
pub struct DefaultSelfRepair {
context_manager: Arc<ContextManager>,
#[allow(dead_code)] // Will be used for time-based stuck detection
// TODO: use for time-based stuck detection (currently only max_repair_attempts is checked)
#[allow(dead_code)]
stuck_threshold: Duration,
max_repair_attempts: u32,
store: Option<Arc<dyn Database>>,
builder: Option<Arc<dyn SoftwareBuilder>>,
#[allow(dead_code)] // Will be used for tool hot-reload after repair
// TODO: use for tool hot-reload after repair
#[allow(dead_code)]
tools: Option<Arc<ToolRegistry>>,
}
@@ -93,15 +95,15 @@ impl DefaultSelfRepair {
}
/// Add a Store for tool failure tracking.
#[allow(dead_code)] // Public API for configuring repair with persistence
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
#[allow(dead_code)] // TODO: wire up in main.rs when persistence is needed
pub(crate) fn with_store(mut self, store: Arc<dyn Database>) -> Self {
self.store = Some(store);
self
}
/// Add a Builder and ToolRegistry for automatic tool repair.
#[allow(dead_code)] // Public API for enabling automatic tool repair
pub fn with_builder(
#[allow(dead_code)] // TODO: wire up in main.rs when auto-repair is needed
pub(crate) fn with_builder(
mut self,
builder: Arc<dyn SoftwareBuilder>,
tools: Arc<ToolRegistry>,
+26 -9
View File
@@ -16,7 +16,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::llm::ChatMessage;
use crate::llm::{ChatMessage, ToolCall};
/// A session containing one or more threads.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -70,10 +70,9 @@ impl Session {
pub fn create_thread(&mut self) -> &mut Thread {
let thread = Thread::new(self.id);
let thread_id = thread.id;
self.threads.insert(thread_id, thread);
self.active_thread = Some(thread_id);
self.last_active_at = Utc::now();
self.threads.get_mut(&thread_id).expect("just inserted")
self.threads.entry(thread_id).or_insert(thread)
}
/// Get the active thread.
@@ -88,10 +87,19 @@ impl Session {
/// Get or create the active thread.
pub fn get_or_create_thread(&mut self) -> &mut Thread {
if self.active_thread.is_none() {
self.create_thread();
match self.active_thread {
None => self.create_thread(),
Some(id) => {
if self.threads.contains_key(&id) {
// Safe: contains_key confirmed the entry exists.
self.threads.get_mut(&id).unwrap()
} else {
// Stale active_thread ID: create a new thread, which
// updates self.active_thread to the new thread's ID.
self.create_thread()
}
}
}
self.active_thread_mut().expect("just created")
}
/// Switch to a different thread.
@@ -148,6 +156,10 @@ pub struct PendingApproval {
pub tool_call_id: String,
/// Context messages at the time of the request (to resume from).
pub context_messages: Vec<ChatMessage>,
/// Remaining tool calls from the same assistant message that were not
/// executed yet when approval was requested.
#[serde(default)]
pub deferred_tool_calls: Vec<ToolCall>,
}
/// A conversation thread within a session.
@@ -236,7 +248,8 @@ impl Thread {
self.turns.push(turn);
self.state = ThreadState::Processing;
self.updated_at = Utc::now();
self.turns.last_mut().expect("just pushed")
// turn_number was len() before push, so it's a valid index after push
&mut self.turns[turn_number]
}
/// Complete the current turn with a response.
@@ -349,8 +362,10 @@ impl Thread {
if let Some(next) = iter.peek()
&& next.role == crate::llm::Role::Assistant
{
let response = iter.next().expect("peeked");
turn.complete(&response.content);
// iter.next() is guaranteed Some after a successful peek()
if let Some(response) = iter.next() {
turn.complete(&response.content);
}
}
self.turns.push(turn);
@@ -946,6 +961,7 @@ mod tests {
description: "dangerous command".to_string(),
tool_call_id: "call_123".to_string(),
context_messages: vec![ChatMessage::user("do it")],
deferred_tool_calls: vec![],
};
thread.await_approval(approval);
@@ -969,6 +985,7 @@ mod tests {
description: "test".to_string(),
tool_call_id: "call_456".to_string(),
context_messages: vec![],
deferred_tool_calls: vec![],
};
thread.await_approval(approval);
+11
View File
@@ -13,6 +13,9 @@ use crate::agent::session::Session;
use crate::agent::undo::UndoManager;
use crate::hooks::HookRegistry;
/// Warn when session count exceeds this threshold.
const SESSION_COUNT_WARNING_THRESHOLD: usize = 1000;
/// Key for mapping external thread IDs to internal ones.
#[derive(Clone, Hash, Eq, PartialEq)]
struct ThreadKey {
@@ -68,6 +71,14 @@ impl SessionManager {
let session = Arc::new(Mutex::new(new_session));
sessions.insert(user_id.to_string(), Arc::clone(&session));
if sessions.len() >= SESSION_COUNT_WARNING_THRESHOLD && sessions.len() % 100 == 0 {
tracing::warn!(
"High session count: {} active sessions. \
Pruning runs every 10 minutes; consider reducing session_idle_timeout.",
sessions.len()
);
}
// Fire OnSessionStart hook (fire-and-forget)
if let Some(ref hooks) = self.hooks {
let hooks = hooks.clone();
+62 -3
View File
@@ -118,19 +118,19 @@ impl SubmissionParser {
// Approval responses (simple yes/no/always for pending approvals)
// These are short enough to check explicitly
match lower.as_str() {
"yes" | "y" | "approve" | "ok" => {
"yes" | "y" | "approve" | "ok" | "/approve" | "/yes" | "/y" => {
return Submission::ApprovalResponse {
approved: true,
always: false,
};
}
"always" | "yes always" | "approve always" => {
"always" | "a" | "yes always" | "approve always" | "/always" | "/a" => {
return Submission::ApprovalResponse {
approved: true,
always: true,
};
}
"no" | "n" | "deny" | "reject" | "cancel" => {
"no" | "n" | "deny" | "reject" | "cancel" | "/deny" | "/no" | "/n" => {
return Submission::ApprovalResponse {
approved: false,
always: false,
@@ -234,6 +234,7 @@ impl Submission {
}
/// Create an approval submission.
#[cfg(test)]
pub fn approval(request_id: Uuid, approved: bool) -> Self {
Self::ExecApproval {
request_id,
@@ -243,6 +244,7 @@ impl Submission {
}
/// Create an "always approve" submission.
#[cfg(test)]
pub fn always_approve(request_id: Uuid) -> Self {
Self::ExecApproval {
request_id,
@@ -252,26 +254,31 @@ impl Submission {
}
/// Create an interrupt submission.
#[cfg(test)]
pub fn interrupt() -> Self {
Self::Interrupt
}
/// Create a compact submission.
#[cfg(test)]
pub fn compact() -> Self {
Self::Compact
}
/// Create an undo submission.
#[cfg(test)]
pub fn undo() -> Self {
Self::Undo
}
/// Create a redo submission.
#[cfg(test)]
pub fn redo() -> Self {
Self::Redo
}
/// Check if this submission starts a new turn.
#[cfg(test)]
pub fn starts_turn(&self) -> bool {
matches!(self, Self::UserInput { .. })
}
@@ -340,6 +347,7 @@ impl SubmissionResult {
}
/// Create an OK result.
#[cfg(test)]
pub fn ok() -> Self {
Self::Ok { message: None }
}
@@ -475,6 +483,57 @@ mod tests {
assert!(matches!(submission, Submission::UserInput { content } if content == "/unknown"));
}
#[test]
fn test_parser_approval_response_aliases() {
// approve once
assert!(matches!(
SubmissionParser::parse("y"),
Submission::ApprovalResponse {
approved: true,
always: false
}
));
assert!(matches!(
SubmissionParser::parse("/approve"),
Submission::ApprovalResponse {
approved: true,
always: false
}
));
// approve always
assert!(matches!(
SubmissionParser::parse("a"),
Submission::ApprovalResponse {
approved: true,
always: true
}
));
assert!(matches!(
SubmissionParser::parse("/always"),
Submission::ApprovalResponse {
approved: true,
always: true
}
));
// deny
assert!(matches!(
SubmissionParser::parse("n"),
Submission::ApprovalResponse {
approved: false,
always: false
}
));
assert!(matches!(
SubmissionParser::parse("/deny"),
Submission::ApprovalResponse {
approved: false,
always: false
}
));
}
#[test]
fn test_parser_json_exec_approval() {
let req_id = Uuid::new_v4();
+7
View File
@@ -29,6 +29,7 @@ impl TaskOutput {
}
/// Create a text result.
#[cfg(test)]
pub fn text(text: impl Into<String>, duration: Duration) -> Self {
Self {
result: serde_json::Value::String(text.into()),
@@ -37,6 +38,7 @@ impl TaskOutput {
}
/// Create an empty success result.
#[cfg(test)]
pub fn empty(duration: Duration) -> Self {
Self {
result: serde_json::Value::Null,
@@ -130,6 +132,7 @@ impl Task {
}
/// Create a new Job task with a specific ID.
#[cfg(test)]
pub fn job_with_id(id: Uuid, title: impl Into<String>, description: impl Into<String>) -> Self {
Self::Job {
id,
@@ -152,6 +155,7 @@ impl Task {
}
/// Create a new Background task.
#[cfg(test)]
pub fn background(handler: std::sync::Arc<dyn TaskHandler>) -> Self {
Self::Background {
id: Uuid::new_v4(),
@@ -160,6 +164,7 @@ impl Task {
}
/// Create a new Background task with a specific ID.
#[cfg(test)]
pub fn background_with_id(id: Uuid, handler: std::sync::Arc<dyn TaskHandler>) -> Self {
Self::Background { id, handler }
}
@@ -174,6 +179,7 @@ impl Task {
}
/// Get the parent ID for sub-tasks.
#[cfg(test)]
pub fn parent_id(&self) -> Option<Uuid> {
match self {
Self::Job { .. } => None,
@@ -225,6 +231,7 @@ impl fmt::Debug for Task {
}
/// Status of a scheduled task.
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskStatus {
/// Task is queued waiting for execution.
+365 -34
View File
@@ -6,12 +6,15 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task::JoinSet;
use uuid::Uuid;
use crate::agent::Agent;
use crate::agent::compaction::ContextCompactor;
use crate::agent::dispatcher::{AgenticLoopResult, detect_auth_awaiting, parse_auth_result};
use crate::agent::session::{Session, ThreadState};
use crate::agent::dispatcher::{
AgenticLoopResult, check_auth_required, execute_chat_tool_standalone, parse_auth_result,
};
use crate::agent::session::{PendingApproval, Session, ThreadState};
use crate::agent::submission::SubmissionResult;
use crate::channels::{IncomingMessage, StatusUpdate};
use crate::context::JobContext;
@@ -608,8 +611,8 @@ impl Agent {
approved: bool,
always: bool,
) -> Result<SubmissionResult, Error> {
// Get thread state and pending approval
let (_thread_state, pending) = {
// Get pending approval for this thread
let pending = {
let mut sess = session.lock().await;
let thread = sess
.threads
@@ -620,8 +623,7 @@ impl Agent {
return Ok(SubmissionResult::error("No pending approval request."));
}
let pending = thread.take_pending_approval();
(thread.state, pending)
thread.take_pending_approval()
};
let pending = match pending {
@@ -712,6 +714,7 @@ impl Agent {
// Build context including the tool result
let mut context_messages = pending.context_messages;
let deferred_tool_calls = pending.deferred_tool_calls;
// Record result in thread
{
@@ -733,29 +736,17 @@ impl Agent {
// If tool_auth returned awaiting_token, enter auth mode and
// return instructions directly (skip agentic loop continuation).
if let Some((ext_name, instructions)) =
detect_auth_awaiting(&pending.tool_name, &tool_result)
check_auth_required(&pending.tool_name, &tool_result)
{
let auth_data = parse_auth_result(&tool_result);
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(ext_name.clone());
thread.complete_turn(&instructions);
}
}
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
extension_name: ext_name,
instructions: Some(instructions.clone()),
auth_url: auth_data.auth_url,
setup_url: auth_data.setup_url,
},
&message.metadata,
)
.await;
self.handle_auth_intercept(
&session,
thread_id,
message,
&tool_result,
ext_name,
instructions.clone(),
)
.await;
return Ok(SubmissionResult::response(instructions));
}
@@ -780,6 +771,290 @@ impl Agent {
result_content,
));
// Replay deferred tool calls from the same assistant message so
// every tool_use ID gets a matching tool_result before the next
// LLM call.
if !deferred_tool_calls.is_empty() {
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::Thinking(format!(
"Executing {} deferred tool(s)...",
deferred_tool_calls.len()
)),
&message.metadata,
)
.await;
}
// === Phase 1: Preflight (sequential) ===
// Walk deferred tools checking approval. Collect runnable
// tools; stop at the first that needs approval.
let mut runnable: Vec<crate::llm::ToolCall> = Vec::new();
let mut approval_needed: Option<(
usize,
crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>,
)> = None;
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await
&& tool.requires_approval()
{
let is_auto_approved = {
let sess = session.lock().await;
let mut approved = sess.is_tool_auto_approved(&tc.name);
if approved && tool.requires_approval_for(&tc.arguments) {
approved = false;
}
approved
};
if !is_auto_approved {
approval_needed = Some((idx, tc.clone(), tool));
break; // remaining tools stay deferred
}
}
runnable.push(tc.clone());
}
// === Phase 2: Parallel execution ===
let exec_results: Vec<(crate::llm::ToolCall, Result<String, Error>)> = if runnable.len()
<= 1
{
// Single tool (or none): execute inline
let mut results = Vec::new();
for tc in &runnable {
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::ToolStarted {
name: tc.name.clone(),
},
&message.metadata,
)
.await;
let result = self
.execute_chat_tool(&tc.name, &tc.arguments, &job_ctx)
.await;
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::ToolCompleted {
name: tc.name.clone(),
success: result.is_ok(),
},
&message.metadata,
)
.await;
results.push((tc.clone(), result));
}
results
} else {
// Multiple tools: execute in parallel via JoinSet
let mut join_set = JoinSet::new();
let runnable_count = runnable.len();
for (spawn_idx, tc) in runnable.iter().enumerate() {
let tools = self.tools().clone();
let safety = self.safety().clone();
let channels = self.channels.clone();
let job_ctx = job_ctx.clone();
let tc = tc.clone();
let channel = message.channel.clone();
let metadata = message.metadata.clone();
join_set.spawn(async move {
let _ = channels
.send_status(
&channel,
StatusUpdate::ToolStarted {
name: tc.name.clone(),
},
&metadata,
)
.await;
let result = execute_chat_tool_standalone(
&tools,
&safety,
&tc.name,
&tc.arguments,
&job_ctx,
)
.await;
let _ = channels
.send_status(
&channel,
StatusUpdate::ToolCompleted {
name: tc.name.clone(),
success: result.is_ok(),
},
&metadata,
)
.await;
(spawn_idx, tc, result)
});
}
// Collect and reorder by original index
let mut ordered: Vec<Option<(crate::llm::ToolCall, Result<String, Error>)>> =
(0..runnable_count).map(|_| None).collect();
while let Some(join_result) = join_set.join_next().await {
match join_result {
Ok((idx, tc, result)) => {
ordered[idx] = Some((tc, result));
}
Err(e) => {
if e.is_panic() {
tracing::error!("Deferred tool execution task panicked: {}", e);
} else {
tracing::error!("Deferred tool execution task cancelled: {}", e);
}
}
}
}
// Fill panicked slots with error results
ordered
.into_iter()
.enumerate()
.map(|(i, opt)| {
opt.unwrap_or_else(|| {
let tc = runnable[i].clone();
let err: Error = crate::error::ToolError::ExecutionFailed {
name: tc.name.clone(),
reason: "Task failed during execution".to_string(),
}
.into();
(tc, Err(err))
})
})
.collect()
};
// === Phase 3: Post-flight (sequential, in original order) ===
// Process all results before any conditional return so every
// tool result is recorded in the session audit trail.
let mut deferred_auth: Option<String> = None;
for (tc, deferred_result) in exec_results {
if let Ok(ref output) = deferred_result
&& !output.is_empty()
{
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::ToolResult {
name: tc.name.clone(),
preview: output.clone(),
},
&message.metadata,
)
.await;
}
// Record in thread
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id)
&& let Some(turn) = thread.last_turn_mut()
{
match &deferred_result {
Ok(output) => turn.record_tool_result(serde_json::json!(output)),
Err(e) => turn.record_tool_error(e.to_string()),
}
}
}
// Auth detection — defer return until all results are recorded
if deferred_auth.is_none()
&& let Some((ext_name, instructions)) =
check_auth_required(&tc.name, &deferred_result)
{
self.handle_auth_intercept(
&session,
thread_id,
message,
&deferred_result,
ext_name,
instructions.clone(),
)
.await;
deferred_auth = Some(instructions);
}
let deferred_content = match deferred_result {
Ok(output) => {
let sanitized = self.safety().sanitize_tool_output(&tc.name, &output);
self.safety().wrap_for_llm(
&tc.name,
&sanitized.content,
sanitized.was_modified,
)
}
Err(e) => format!("Error: {}", e),
};
context_messages.push(ChatMessage::tool_result(&tc.id, &tc.name, deferred_content));
}
// Return auth response after all results are recorded
if let Some(instructions) = deferred_auth {
return Ok(SubmissionResult::response(instructions));
}
// Handle approval if a tool needed it
if let Some((approval_idx, tc, tool)) = approval_needed {
let new_pending = PendingApproval {
request_id: Uuid::new_v4(),
tool_name: tc.name.clone(),
parameters: tc.arguments.clone(),
description: tool.description().to_string(),
tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(),
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
};
let request_id = new_pending.request_id;
let tool_name = new_pending.tool_name.clone();
let description = new_pending.description.clone();
let parameters = new_pending.parameters.clone();
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.await_approval(new_pending);
}
}
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::Status("Awaiting approval".into()),
&message.metadata,
)
.await;
return Ok(SubmissionResult::NeedApproval {
request_id,
tool_name,
description,
parameters,
});
}
// Continue the agentic loop (a tool was already executed this turn)
let result = self
.run_agentic_loop(message, session.clone(), thread_id, context_messages, true)
@@ -794,7 +1069,11 @@ impl Agent {
match result {
Ok(AgenticLoopResult::Response(response)) => {
let user_input = thread.last_turn().map(|t| t.user_input.clone());
thread.complete_turn(&response);
if let Some(input) = user_input {
self.persist_turn(thread_id, &message.user_id, &input, Some(&response));
}
self.persist_response_chain(thread);
let _ = self
.channels
@@ -830,16 +1109,30 @@ impl Agent {
})
}
Err(e) => {
let user_input = thread.last_turn().map(|t| t.user_input.clone());
thread.fail_turn(e.to_string());
if let Some(input) = user_input {
self.persist_turn(thread_id, &message.user_id, &input, None);
}
Ok(SubmissionResult::error(e.to_string()))
}
}
} else {
// Rejected - clear approval and return to idle
// Rejected - complete the turn with a rejection message and persist
let rejection = format!(
"Tool '{}' was rejected. The agent will not execute this tool.\n\n\
You can continue the conversation or try a different approach.",
pending.tool_name
);
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
let user_input = thread.last_turn().map(|t| t.user_input.clone());
thread.clear_pending_approval();
thread.complete_turn(&rejection);
if let Some(input) = user_input {
self.persist_turn(thread_id, &message.user_id, &input, Some(&rejection));
}
}
}
@@ -852,14 +1145,52 @@ impl Agent {
)
.await;
Ok(SubmissionResult::response(format!(
"Tool '{}' was rejected. The agent will not execute this tool.\n\n\
You can continue the conversation or try a different approach.",
pending.tool_name
)))
Ok(SubmissionResult::response(rejection))
}
}
/// Handle an auth-required result from a tool execution.
///
/// Enters auth mode on the thread, completes + persists the turn,
/// and sends the AuthRequired status to the channel.
/// Returns the instructions string for the caller to wrap in a response.
async fn handle_auth_intercept(
&self,
session: &Arc<Mutex<Session>>,
thread_id: Uuid,
message: &IncomingMessage,
tool_result: &Result<String, Error>,
ext_name: String,
instructions: String,
) {
let auth_data = parse_auth_result(tool_result);
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
let user_input = thread.last_turn().map(|t| t.user_input.clone());
thread.enter_auth_mode(ext_name.clone());
thread.complete_turn(&instructions);
if let Some(input) = user_input {
self.persist_turn(thread_id, &message.user_id, &input, Some(&instructions));
}
self.persist_response_chain(thread);
}
}
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
extension_name: ext_name,
instructions: Some(instructions.clone()),
auth_url: auth_data.auth_url,
setup_url: auth_data.setup_url,
},
&message.metadata,
)
.await;
}
/// Handle an auth token submitted while the thread is in auth mode.
///
/// The token goes directly to the extension manager's credential store,
+4
View File
@@ -67,6 +67,7 @@ impl UndoManager {
}
/// Create with a custom checkpoint limit.
#[cfg(test)]
pub fn with_max_checkpoints(mut self, max: usize) -> Self {
self.max_checkpoints = max;
self
@@ -126,6 +127,7 @@ impl UndoManager {
}
/// Pop the last checkpoint from the undo stack.
#[cfg(test)]
pub fn pop_undo(&mut self) -> Option<Checkpoint> {
self.undo_stack.pop_back()
}
@@ -178,6 +180,7 @@ impl UndoManager {
}
/// Get a checkpoint by ID.
#[cfg(test)]
pub fn get_checkpoint(&self, id: Uuid) -> Option<&Checkpoint> {
self.undo_stack
.iter()
@@ -186,6 +189,7 @@ impl UndoManager {
}
/// List all available checkpoints (for UI display).
#[cfg(test)]
pub fn list_checkpoints(&self) -> Vec<&Checkpoint> {
self.undo_stack.iter().collect()
}
+329 -46
View File
@@ -3,8 +3,8 @@
use std::sync::Arc;
use std::time::Duration;
use futures::future::join_all;
use tokio::sync::mpsc;
use tokio::task::JoinSet;
use uuid::Uuid;
use crate::agent::scheduler::WorkerMessage;
@@ -292,19 +292,21 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
tool_calls.clone(),
));
for tc in tool_calls {
let result = self.execute_tool(&tc.name, &tc.arguments).await;
// Create synthetic selection for process_tool_result
let selection = ToolSelection {
// Convert ToolCalls to ToolSelections and execute in parallel
let selections: Vec<ToolSelection> = tool_calls
.iter()
.map(|tc| ToolSelection {
tool_name: tc.name.clone(),
parameters: tc.arguments.clone(),
reasoning: String::new(),
alternatives: vec![],
tool_call_id: tc.id.clone(),
};
})
.collect();
self.process_tool_result(reason_ctx, &selection, result)
let results = self.execute_tools_parallel(&selections).await;
for (selection, result) in selections.iter().zip(results) {
self.process_tool_result(reason_ctx, selection, result.result)
.await?;
}
}
@@ -347,24 +349,71 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
}
/// Execute multiple tools in parallel.
/// Execute multiple tools in parallel using a JoinSet.
///
/// Each task is tagged with its original index so results are returned
/// in the same order as `selections`, regardless of completion order.
async fn execute_tools_parallel(&self, selections: &[ToolSelection]) -> Vec<ToolExecResult> {
let futures: Vec<_> = selections
.iter()
.map(|selection| {
let tool_name = selection.tool_name.clone();
let params = selection.parameters.clone();
let deps = self.deps.clone();
let job_id = self.job_id;
let count = selections.len();
async move {
let result = Self::execute_tool_inner(&deps, job_id, &tool_name, &params).await;
ToolExecResult { result }
// Short-circuit for single tool: execute directly without JoinSet overhead
if count <= 1 {
let mut results = Vec::with_capacity(count);
for selection in selections {
let result = Self::execute_tool_inner(
&self.deps,
self.job_id,
&selection.tool_name,
&selection.parameters,
)
.await;
results.push(ToolExecResult { result });
}
return results;
}
let mut join_set = JoinSet::new();
for (idx, selection) in selections.iter().enumerate() {
let deps = self.deps.clone();
let job_id = self.job_id;
let tool_name = selection.tool_name.clone();
let params = selection.parameters.clone();
join_set.spawn(async move {
let result = Self::execute_tool_inner(&deps, job_id, &tool_name, &params).await;
(idx, ToolExecResult { result })
});
}
// Collect and reorder by original index
let mut results: Vec<Option<ToolExecResult>> = (0..count).map(|_| None).collect();
while let Some(join_result) = join_set.join_next().await {
match join_result {
Ok((idx, exec_result)) => results[idx] = Some(exec_result),
Err(e) => {
if e.is_panic() {
tracing::error!("Tool execution task panicked: {}", e);
} else {
tracing::error!("Tool execution task cancelled: {}", e);
}
}
})
.collect();
}
}
join_all(futures).await
// Fill any panicked slots with error results
results
.into_iter()
.enumerate()
.map(|(i, opt)| {
opt.unwrap_or_else(|| ToolExecResult {
result: Err(crate::error::ToolError::ExecutionFailed {
name: selections[i].tool_name.clone(),
reason: "Task failed during execution".to_string(),
}
.into()),
})
})
.collect()
}
/// Inner tool execution logic that can be called from both single and parallel paths.
@@ -505,7 +554,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
let output_str = serde_json::to_string_pretty(&output.result)
.ok()
.map(|s| deps.safety.sanitize_tool_output(tool_name, &s).content);
deps.context_manager
match deps
.context_manager
.update_memory(job_id, |mem| {
let rec = mem.create_action(tool_name, params.clone()).succeed(
output_str.clone(),
@@ -516,30 +566,52 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
rec
})
.await
.ok()
{
Ok(rec) => Some(rec),
Err(e) => {
tracing::warn!(job_id = %job_id, tool = tool_name, "Failed to record action in memory: {e}");
None
}
}
}
Ok(Err(e)) => {
match deps
.context_manager
.update_memory(job_id, |mem| {
let rec = mem
.create_action(tool_name, params.clone())
.fail(e.to_string(), elapsed);
mem.record_action(rec.clone());
rec
})
.await
{
Ok(rec) => Some(rec),
Err(e) => {
tracing::warn!(job_id = %job_id, tool = tool_name, "Failed to record action in memory: {e}");
None
}
}
}
Err(_) => {
match deps
.context_manager
.update_memory(job_id, |mem| {
let rec = mem
.create_action(tool_name, params.clone())
.fail("Execution timeout", elapsed);
mem.record_action(rec.clone());
rec
})
.await
{
Ok(rec) => Some(rec),
Err(e) => {
tracing::warn!(job_id = %job_id, tool = tool_name, "Failed to record action in memory: {e}");
None
}
}
}
Ok(Err(e)) => deps
.context_manager
.update_memory(job_id, |mem| {
let rec = mem
.create_action(tool_name, params.clone())
.fail(e.to_string(), elapsed);
mem.record_action(rec.clone());
rec
})
.await
.ok(),
Err(_) => deps
.context_manager
.update_memory(job_id, |mem| {
let rec = mem
.create_action(tool_name, params.clone())
.fail("Execution timeout", elapsed);
mem.record_action(rec.clone());
rec
})
.await
.ok(),
};
// Persist action to database (fire-and-forget)
@@ -800,6 +872,102 @@ mod tests {
use crate::llm::ToolSelection;
use crate::util::llm_signals_completion;
use super::*;
use crate::config::SafetyConfig;
use crate::context::JobContext;
use crate::llm::{
CompletionRequest, CompletionResponse, LlmProvider, ToolCompletionRequest,
ToolCompletionResponse,
};
use crate::safety::SafetyLayer;
use crate::tools::{Tool, ToolError, ToolOutput};
/// A test tool that sleeps for a configurable duration before returning.
struct SlowTool {
tool_name: String,
delay: Duration,
}
#[async_trait::async_trait]
impl Tool for SlowTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
"Test tool with configurable delay"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {}})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
tokio::time::sleep(self.delay).await;
Ok(ToolOutput::text(
format!("done_{}", self.tool_name),
start.elapsed(),
))
}
fn requires_sanitization(&self) -> bool {
false
}
}
/// Stub LLM provider (never called in these tests).
struct StubLlm;
#[async_trait::async_trait]
impl LlmProvider for StubLlm {
fn model_name(&self) -> &str {
"stub"
}
fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) {
(rust_decimal::Decimal::ZERO, rust_decimal::Decimal::ZERO)
}
async fn complete(
&self,
_req: CompletionRequest,
) -> Result<CompletionResponse, crate::error::LlmError> {
unimplemented!("stub")
}
async fn complete_with_tools(
&self,
_req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, crate::error::LlmError> {
unimplemented!("stub")
}
}
/// Build a Worker wired to a ToolRegistry containing the given tools.
async fn make_worker(tools: Vec<Arc<dyn Tool>>) -> Worker {
let registry = ToolRegistry::new();
for t in tools {
registry.register(t).await;
}
let cm = Arc::new(crate::context::ContextManager::new(5));
let job_id = cm.create_job("test", "test job").await.unwrap();
let deps = WorkerDeps {
context_manager: cm,
llm: Arc::new(StubLlm),
safety: Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
})),
tools: Arc::new(registry),
store: None,
hooks: Arc::new(crate::hooks::HookRegistry::new()),
timeout: Duration::from_secs(30),
use_planning: false,
};
Worker::new(job_id, deps)
}
#[test]
fn test_tool_selection_preserves_call_id() {
let selection = ToolSelection {
@@ -876,4 +1044,119 @@ mod tests {
"The tool returned: TASK_COMPLETE signal"
));
}
#[tokio::test]
async fn test_parallel_speedup() {
// 3 tools each sleeping 200ms should finish in roughly 200ms (parallel),
// not ~600ms (sequential).
let tools: Vec<Arc<dyn Tool>> = (0..3)
.map(|i| {
Arc::new(SlowTool {
tool_name: format!("slow_{}", i),
delay: Duration::from_millis(200),
}) as Arc<dyn Tool>
})
.collect();
let worker = make_worker(tools).await;
let selections: Vec<ToolSelection> = (0..3)
.map(|i| ToolSelection {
tool_name: format!("slow_{}", i),
parameters: serde_json::json!({}),
reasoning: String::new(),
alternatives: vec![],
tool_call_id: format!("call_{}", i),
})
.collect();
let start = std::time::Instant::now();
let results = worker.execute_tools_parallel(&selections).await;
let elapsed = start.elapsed();
assert_eq!(results.len(), 3);
for r in &results {
assert!(r.result.is_ok(), "Tool should succeed");
}
// Parallel should complete well under the sequential 600ms threshold.
assert!(
elapsed < Duration::from_millis(500),
"Parallel execution took {:?}, expected < 500ms",
elapsed
);
}
#[tokio::test]
async fn test_result_ordering_preserved() {
// Tools with different delays finish in different order.
// Results must be returned in the original request order.
let tools: Vec<Arc<dyn Tool>> = vec![
Arc::new(SlowTool {
tool_name: "tool_a".into(),
delay: Duration::from_millis(300),
}),
Arc::new(SlowTool {
tool_name: "tool_b".into(),
delay: Duration::from_millis(100),
}),
Arc::new(SlowTool {
tool_name: "tool_c".into(),
delay: Duration::from_millis(200),
}),
];
let worker = make_worker(tools).await;
let selections = vec![
ToolSelection {
tool_name: "tool_a".into(),
parameters: serde_json::json!({}),
reasoning: String::new(),
alternatives: vec![],
tool_call_id: "call_a".into(),
},
ToolSelection {
tool_name: "tool_b".into(),
parameters: serde_json::json!({}),
reasoning: String::new(),
alternatives: vec![],
tool_call_id: "call_b".into(),
},
ToolSelection {
tool_name: "tool_c".into(),
parameters: serde_json::json!({}),
reasoning: String::new(),
alternatives: vec![],
tool_call_id: "call_c".into(),
},
];
let results = worker.execute_tools_parallel(&selections).await;
// Results must be in same order as selections, not completion order.
assert!(results[0].result.as_ref().unwrap().contains("done_tool_a"));
assert!(results[1].result.as_ref().unwrap().contains("done_tool_b"));
assert!(results[2].result.as_ref().unwrap().contains("done_tool_c"));
}
#[tokio::test]
async fn test_missing_tool_produces_error_not_panic() {
// If a tool doesn't exist, the result slot should contain an error.
let worker = make_worker(vec![]).await;
let selections = vec![ToolSelection {
tool_name: "nonexistent_tool".into(),
parameters: serde_json::json!({}),
reasoning: String::new(),
alternatives: vec![],
tool_call_id: "call_x".into(),
}];
let results = worker.execute_tools_parallel(&selections).await;
assert_eq!(results.len(), 1);
assert!(
results[0].result.is_err(),
"Missing tool should produce an error, not a panic"
);
}
}
+7 -2
View File
@@ -473,6 +473,7 @@ impl AppBuilder {
pub async fn init_extensions(
&self,
tools: &Arc<ToolRegistry>,
hooks: &Arc<HookRegistry>,
) -> Result<
(
Arc<McpSessionManager>,
@@ -661,6 +662,7 @@ impl AppBuilder {
Arc::clone(&mcp_session_manager),
Arc::clone(secrets),
Arc::clone(tools),
Some(Arc::clone(hooks)),
wasm_tool_runtime.clone(),
self.config.wasm.tools_dir.clone(),
self.config.channels.wasm_channels_dir.clone(),
@@ -697,8 +699,12 @@ impl AppBuilder {
let (llm, cheap_llm) = self.init_llm()?;
let (safety, tools, embeddings, workspace) = self.init_tools(&llm).await?;
// Create hook registry early so runtime extension activation can register hooks.
let hooks = Arc::new(HookRegistry::new());
let (mcp_session_manager, wasm_tool_runtime, extension_manager) =
self.init_extensions(&tools).await?;
self.init_extensions(&tools, &hooks).await?;
// Seed workspace and backfill embeddings
if let Some(ref ws) = workspace {
@@ -741,7 +747,6 @@ impl AppBuilder {
};
let context_manager = Arc::new(ContextManager::new(self.config.agent.max_parallel_jobs));
let hooks = Arc::new(HookRegistry::new());
let cost_guard = Arc::new(crate::agent::cost_guard::CostGuard::new(
crate::agent::cost_guard::CostGuardConfig {
max_cost_per_day_cents: self.config.agent.max_cost_per_day_cents,
+89 -1
View File
@@ -103,7 +103,67 @@ pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
content.push_str(&format!("{}=\"{}\"\n", key, escaped));
}
std::fs::write(&path, content)
std::fs::write(&path, &content)?;
restrict_file_permissions(&path)?;
Ok(())
}
/// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content.
///
/// Unlike `save_bootstrap_env` (which overwrites the entire file), this
/// reads the current `.env`, replaces the line for `key` if it exists,
/// or appends it otherwise. Use this when writing a single bootstrap var
/// outside the wizard (which manages the full set via `save_bootstrap_env`).
pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> {
let path = ironclaw_env_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
let new_line = format!("{}=\"{}\"", key, escaped);
let prefix = format!("{}=", key);
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut found = false;
let mut result = String::new();
for line in existing.lines() {
if line.starts_with(&prefix) {
if !found {
result.push_str(&new_line);
result.push('\n');
found = true;
}
// Skip duplicate lines for this key
continue;
}
result.push_str(line);
result.push('\n');
}
if !found {
result.push_str(&new_line);
result.push('\n');
}
std::fs::write(&path, result)?;
restrict_file_permissions(&path)?;
Ok(())
}
/// Set restrictive file permissions (0o600) on Unix systems.
///
/// The `.env` file may contain database credentials and API keys,
/// so it should only be readable by the owner.
fn restrict_file_permissions(_path: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(_path, perms)?;
}
Ok(())
}
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
@@ -492,4 +552,32 @@ INJECTED="pwned"#;
assert_eq!(parsed.len(), 2);
assert!(parsed.iter().all(|(k, _)| k != "DATABASE_URL"));
}
#[test]
fn test_onboard_completed_round_trips_through_env() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
// Simulate what the wizard writes: bootstrap vars + ONBOARD_COMPLETED
let vars = [
("DATABASE_BACKEND", "libsql"),
("ONBOARD_COMPLETED", "true"),
];
let mut content = String::new();
for (key, value) in &vars {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
content.push_str(&format!("{}=\"{}\"\n", key, escaped));
}
std::fs::write(&env_path, &content).unwrap();
// Verify dotenvy parses ONBOARD_COMPLETED correctly
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(parsed.len(), 2);
let onboard = parsed.iter().find(|(k, _)| k == "ONBOARD_COMPLETED");
assert!(onboard.is_some(), "ONBOARD_COMPLETED must be present");
assert_eq!(onboard.unwrap().1, "true");
}
}
+80 -9
View File
@@ -12,6 +12,7 @@ use axum::{
};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use tokio::sync::{RwLock, mpsc, oneshot};
use tokio_stream::wrappers::ReceiverStream;
use uuid::Uuid;
@@ -173,7 +174,7 @@ async fn webhook_handler(
// Validate secret if configured
if let Some(ref expected_secret) = state.webhook_secret {
match &req.secret {
Some(provided) if provided == expected_secret => {
Some(provided) if bool::from(provided.as_bytes().ct_eq(expected_secret.as_bytes())) => {
// Secret matches, continue
}
Some(_) => {
@@ -356,19 +357,89 @@ impl Channel for HttpChannel {
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::Request;
use secrecy::SecretString;
use tower::ServiceExt;
use super::*;
fn test_channel(secret: Option<&str>) -> HttpChannel {
HttpChannel::new(HttpConfig {
host: "127.0.0.1".to_string(),
port: 0,
webhook_secret: secret.map(|s| SecretString::from(s.to_string())),
user_id: "http".to_string(),
})
}
#[tokio::test]
async fn test_http_channel_requires_secret() {
let config = HttpConfig {
host: "127.0.0.1".to_string(),
port: 0,
webhook_secret: None,
user_id: "http".to_string(),
};
let channel = HttpChannel::new(config);
let channel = test_channel(None);
let result = channel.start().await;
assert!(result.is_err());
}
#[tokio::test]
async fn webhook_correct_secret_returns_ok() {
let channel = test_channel(Some("test-secret-123"));
// Start the channel so the tx sender is populated (otherwise 503).
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello",
"secret": "test-secret-123"
});
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn webhook_wrong_secret_returns_unauthorized() {
let channel = test_channel(Some("correct-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello",
"secret": "wrong-secret"
});
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn webhook_missing_secret_returns_unauthorized() {
let channel = test_channel(Some("correct-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello"
});
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}
+7 -1
View File
@@ -330,7 +330,13 @@ impl Channel for ReplChannel {
// Handle local REPL commands (only commands that need
// immediate local handling stay here)
match line.to_lowercase().as_str() {
"/quit" | "/exit" => break,
"/quit" | "/exit" => {
// Forward shutdown command so the agent loop exits even
// when other channels (e.g. web gateway) are still active.
let msg = IncomingMessage::new("repl", "default", "/quit");
let _ = tx.blocking_send(msg);
break;
}
"/help" => {
print_help();
continue;
+1
View File
@@ -305,6 +305,7 @@ impl Channel for GatewayChannel {
description,
parameters: serde_json::to_string_pretty(&parameters)
.unwrap_or_else(|_| parameters.to_string()),
thread_id,
},
StatusUpdate::AuthRequired {
extension_name,
+51 -24
View File
@@ -24,6 +24,8 @@ use crate::llm::{
use super::server::GatewayState;
const MAX_MODEL_NAME_BYTES: usize = 256;
// ---------------------------------------------------------------------------
// OpenAI request types
// ---------------------------------------------------------------------------
@@ -380,6 +382,27 @@ fn unix_timestamp() -> u64 {
.as_secs()
}
fn validate_model_name(model: &str) -> Result<(), String> {
let trimmed = model.trim();
if trimmed.is_empty() {
return Err("model must not be empty".to_string());
}
if trimmed != model {
return Err("model must not have leading or trailing whitespace".to_string());
}
if model.len() > MAX_MODEL_NAME_BYTES {
return Err(format!(
"model must be at most {} bytes",
MAX_MODEL_NAME_BYTES
));
}
if model.chars().any(char::is_control) {
return Err("model contains control characters".to_string());
}
Ok(())
}
/// Extract stop sequences from the flexible `stop` field.
fn parse_stop(val: &serde_json::Value) -> Option<Vec<String>> {
match val {
@@ -426,29 +449,17 @@ pub async fn chat_completions_handler(
"invalid_request_error",
));
}
// Validate the requested model matches the active model.
// Per-request model switching is not yet supported (see GH issue).
let active_model = llm.active_model_name();
if req.model != active_model {
return Err((
StatusCode::NOT_FOUND,
Json(OpenAiErrorResponse {
error: OpenAiErrorDetail {
message: format!(
"Model '{}' not found. The active model is '{}'.",
req.model, active_model
),
error_type: "invalid_request_error".to_string(),
param: Some("model".to_string()),
code: Some("model_not_found".to_string()),
},
}),
if let Err(e) = validate_model_name(&req.model) {
return Err(openai_error(
StatusCode::BAD_REQUEST,
e,
"invalid_request_error",
));
}
let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
let stream = req.stream.unwrap_or(false);
let requested_model = req.model.clone();
if stream {
return handle_streaming(llm.clone(), req, has_tools)
@@ -460,13 +471,12 @@ pub async fn chat_completions_handler(
let messages = convert_messages(&req.messages)
.map_err(|e| openai_error(StatusCode::BAD_REQUEST, e, "invalid_request_error"))?;
let model_name = llm.active_model_name();
let id = chat_completion_id();
let created = unix_timestamp();
if has_tools {
let tools = convert_tools(req.tools.as_deref().unwrap_or(&[]));
let mut tool_req = ToolCompletionRequest::new(messages, tools);
let mut tool_req = ToolCompletionRequest::new(messages, tools).with_model(req.model);
if let Some(t) = req.temperature {
tool_req = tool_req.with_temperature(t);
}
@@ -483,6 +493,7 @@ pub async fn chat_completions_handler(
.complete_with_tools(tool_req)
.await
.map_err(map_llm_error)?;
let model_name = llm.effective_model_name(Some(requested_model.as_str()));
let tool_calls_openai = if resp.tool_calls.is_empty() {
None
@@ -515,7 +526,7 @@ pub async fn chat_completions_handler(
Ok(Json(response).into_response())
} else {
let mut comp_req = CompletionRequest::new(messages);
let mut comp_req = CompletionRequest::new(messages).with_model(req.model);
if let Some(t) = req.temperature {
comp_req = comp_req.with_temperature(t);
}
@@ -527,6 +538,7 @@ pub async fn chat_completions_handler(
}
let resp = llm.complete(comp_req).await.map_err(map_llm_error)?;
let model_name = llm.effective_model_name(Some(requested_model.as_str()));
let response = OpenAiChatResponse {
id,
@@ -570,7 +582,7 @@ async fn handle_streaming(
let messages = convert_messages(&req.messages)
.map_err(|e| openai_error(StatusCode::BAD_REQUEST, e, "invalid_request_error"))?;
let model_name = llm.active_model_name();
let requested_model = req.model.clone();
let id = chat_completion_id();
let created = unix_timestamp();
@@ -584,7 +596,7 @@ async fn handle_streaming(
let llm_result = if has_tools {
let tools = convert_tools(req.tools.as_deref().unwrap_or(&[]));
let mut tool_req = ToolCompletionRequest::new(messages, tools);
let mut tool_req = ToolCompletionRequest::new(messages, tools).with_model(req.model);
if let Some(t) = req.temperature {
tool_req = tool_req.with_temperature(t);
}
@@ -602,7 +614,7 @@ async fn handle_streaming(
.map_err(map_llm_error)?,
)
} else {
let mut comp_req = CompletionRequest::new(messages);
let mut comp_req = CompletionRequest::new(messages).with_model(req.model);
if let Some(t) = req.temperature {
comp_req = comp_req.with_temperature(t);
}
@@ -614,6 +626,7 @@ async fn handle_streaming(
}
LlmResult::Simple(llm.complete(comp_req).await.map_err(map_llm_error)?)
};
let model_name = llm.effective_model_name(Some(requested_model.as_str()));
// LLM succeeded — emit the response as SSE chunks
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, std::convert::Infallible>>(64);
@@ -1091,4 +1104,18 @@ mod tests {
let v = serde_json::Value::Null;
assert_eq!(parse_stop(&v), None);
}
#[test]
fn test_validate_model_name_rejects_leading_or_trailing_whitespace() {
let err = validate_model_name(" gpt-4").unwrap_err();
assert!(err.contains("leading or trailing whitespace"));
let err = validate_model_name("gpt-4 ").unwrap_err();
assert!(err.contains("leading or trailing whitespace"));
}
#[test]
fn test_validate_model_name_accepts_normal_name() {
assert!(validate_model_name("gpt-4").is_ok());
}
}
+10 -1
View File
@@ -22,6 +22,7 @@ use serde::Deserialize;
use tokio::sync::{mpsc, oneshot};
use tokio_stream::StreamExt;
use tower_http::cors::{AllowHeaders, CorsLayer};
use tower_http::set_header::SetResponseHeaderLayer;
use uuid::Uuid;
use crate::agent::SessionManager;
@@ -304,8 +305,16 @@ pub async fn start_server(
.merge(statics)
.merge(projects)
.merge(protected)
.layer(cors)
.layer(DefaultBodyLimit::max(1024 * 1024)) // 1 MB max request body
.layer(cors)
.layer(SetResponseHeaderLayer::if_not_present(
header::X_CONTENT_TYPE_OPTIONS,
header::HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::X_FRAME_OPTIONS,
header::HeaderValue::from_static("DENY"),
))
.with_state(state.clone());
let (shutdown_tx, shutdown_rx) = oneshot::channel();
+1
View File
@@ -159,6 +159,7 @@ function connectSSE() {
eventSource.addEventListener('approval_needed', (e) => {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
showApproval(data);
});
+4
View File
@@ -137,6 +137,8 @@ pub enum SseEvent {
tool_name: String,
description: String,
parameters: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "auth_required")]
AuthRequired {
@@ -785,12 +787,14 @@ mod tests {
tool_name: "shell".to_string(),
description: "Run ls".to_string(),
parameters: "{}".to_string(),
thread_id: Some("t1".to_string()),
};
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
WsServerMessage::Event { event_type, data } => {
assert_eq!(event_type, "approval_needed");
assert_eq!(data["tool_name"], "shell");
assert_eq!(data["thread_id"], "t1");
}
_ => panic!("Expected Event variant"),
}
+6
View File
@@ -17,6 +17,7 @@ mod mcp;
pub mod memory;
pub mod oauth_defaults;
mod pairing;
mod registry;
mod service;
pub mod status;
mod tool;
@@ -29,6 +30,7 @@ pub use memory::MemoryCommand;
pub use memory::run_memory_command;
pub use memory::run_memory_command_with_db;
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
pub use registry::{RegistryCommand, run_registry_command};
pub use service::{ServiceCommand, run_service_command};
pub use status::run_status_command;
pub use tool::{ToolCommand, run_tool_command};
@@ -90,6 +92,10 @@ pub enum Command {
#[command(subcommand)]
Tool(ToolCommand),
/// Browse and install extensions from the registry
#[command(subcommand)]
Registry(RegistryCommand),
/// Manage MCP servers (hosted tool providers)
#[command(subcommand)]
Mcp(McpCommand),
+60 -1
View File
@@ -62,6 +62,18 @@ pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
/// `http://localhost:9876/callback` (or `/auth/callback` for NEAR AI).
pub const OAUTH_CALLBACK_PORT: u16 = 9876;
/// Returns the OAuth callback base URL.
///
/// Checks `IRONCLAW_OAUTH_CALLBACK_URL` env var first (useful for remote/VPS
/// deployments where `127.0.0.1` is unreachable from the user's browser),
/// then falls back to `http://127.0.0.1:{OAUTH_CALLBACK_PORT}`.
pub fn callback_url() -> String {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| format!("http://127.0.0.1:{}", OAUTH_CALLBACK_PORT))
}
/// Error from the OAuth callback listener.
#[derive(Debug, thiserror::Error)]
pub enum OAuthCallbackError {
@@ -297,7 +309,54 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
#[cfg(test)]
mod tests {
use crate::cli::oauth_defaults::{builtin_credentials, landing_html};
use std::sync::Mutex;
use crate::cli::oauth_defaults::{builtin_credentials, callback_url, landing_html};
/// Serializes env-mutating tests to prevent parallel races.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn test_callback_url_default() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
// Clear the env var to test default behavior
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
let url = callback_url();
assert_eq!(url, "http://127.0.0.1:9876");
// Restore
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
}
}
}
#[test]
fn test_callback_url_env_override() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var(
"IRONCLAW_OAUTH_CALLBACK_URL",
"https://myserver.example.com:9876",
);
}
let url = callback_url();
assert_eq!(url, "https://myserver.example.com:9876");
// Restore
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
} else {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
}
}
#[test]
fn test_unknown_provider_returns_none() {
+339
View File
@@ -0,0 +1,339 @@
//! Registry CLI commands for discovering and installing extensions.
use std::path::PathBuf;
use clap::Subcommand;
use crate::registry::catalog::RegistryCatalog;
use crate::registry::installer::RegistryInstaller;
use crate::registry::manifest::ManifestKind;
#[derive(Subcommand, Debug, Clone)]
pub enum RegistryCommand {
/// List available extensions in the registry
List {
/// Filter by kind: "tool" or "channel"
#[arg(short, long)]
kind: Option<String>,
/// Filter by tag (e.g. "default", "google", "messaging")
#[arg(short, long)]
tag: Option<String>,
/// Show detailed information
#[arg(short, long)]
verbose: bool,
},
/// Show detailed information about an extension or bundle
Info {
/// Extension or bundle name (e.g. "slack", "google", "tools/gmail")
name: String,
},
/// Install an extension or bundle from the registry
Install {
/// Extension or bundle name (e.g. "slack", "google", "default")
name: String,
/// Force overwrite if already installed
#[arg(short, long)]
force: bool,
/// Build from source instead of downloading pre-built artifact
#[arg(long)]
build: bool,
},
/// Install the default bundle of recommended extensions
InstallDefaults {
/// Force overwrite if already installed
#[arg(short, long)]
force: bool,
/// Build from source instead of downloading pre-built artifact
#[arg(long)]
build: bool,
},
}
/// Run a registry command.
pub async fn run_registry_command(cmd: RegistryCommand) -> anyhow::Result<()> {
let registry_dir = find_registry_dir()?;
let catalog = RegistryCatalog::load(&registry_dir)?;
match cmd {
RegistryCommand::List { kind, tag, verbose } => {
cmd_list(&catalog, kind.as_deref(), tag.as_deref(), verbose)
}
RegistryCommand::Info { name } => cmd_info(&catalog, &name),
RegistryCommand::Install { name, force, build } => {
cmd_install(&catalog, &registry_dir, &name, force, build).await
}
RegistryCommand::InstallDefaults { force, build } => {
cmd_install(&catalog, &registry_dir, "default", force, build).await
}
}
}
/// Find the registry directory by looking relative to the current executable or cwd.
fn find_registry_dir() -> anyhow::Result<PathBuf> {
// Try relative to current directory (for dev usage)
let cwd = std::env::current_dir()?;
let candidate = cwd.join("registry");
if candidate.is_dir() {
return Ok(candidate);
}
// Try relative to executable (covers installed binary, target/debug/, target/release/)
if let Ok(exe) = std::env::current_exe()
&& let Some(parent) = exe.parent()
{
// Walk up to 3 levels: exe dir, parent (target/release → target), grandparent (→ repo root)
let mut dir = Some(parent);
for _ in 0..3 {
if let Some(d) = dir {
let candidate = d.join("registry");
if candidate.is_dir() {
return Ok(candidate);
}
dir = d.parent();
}
}
}
// Try CARGO_MANIFEST_DIR (compile-time, works in dev builds)
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let candidate = manifest_dir.join("registry");
if candidate.is_dir() {
return Ok(candidate);
}
anyhow::bail!(
"Could not find registry/ directory. Run from the ironclaw repo root, \
or ensure registry/ is next to the ironclaw binary."
)
}
fn cmd_list(
catalog: &RegistryCatalog,
kind: Option<&str>,
tag: Option<&str>,
verbose: bool,
) -> anyhow::Result<()> {
let kind_filter = match kind {
Some("tool" | "tools") => Some(ManifestKind::Tool),
Some("channel" | "channels") => Some(ManifestKind::Channel),
Some(other) => anyhow::bail!("Unknown kind '{}'. Use 'tool' or 'channel'.", other),
None => None,
};
let manifests = catalog.list(kind_filter, tag);
if manifests.is_empty() {
println!("No extensions found matching the criteria.");
return Ok(());
}
// Print header
if verbose {
println!(
"{:<20} {:<8} {:<8} {:<10} DESCRIPTION",
"NAME", "KIND", "VERSION", "AUTH"
);
println!("{}", "-".repeat(80));
} else {
println!("{:<20} {:<8} DESCRIPTION", "NAME", "KIND");
println!("{}", "-".repeat(60));
}
for m in &manifests {
if verbose {
let auth = m
.auth_summary
.as_ref()
.and_then(|a| a.method.as_deref())
.unwrap_or("none");
println!(
"{:<20} {:<8} {:<8} {:<10} {}",
m.name, m.kind, m.version, auth, m.description
);
} else {
println!("{:<20} {:<8} {}", m.name, m.kind, m.description);
}
}
println!("\n{} extension(s) found.", manifests.len());
// Show bundles hint
let bundle_names = catalog.bundle_names();
if !bundle_names.is_empty() {
println!("\nBundles available: {}", bundle_names.join(", "));
println!("Use `ironclaw registry info <bundle>` for details.");
}
Ok(())
}
fn cmd_info(catalog: &RegistryCatalog, name: &str) -> anyhow::Result<()> {
// Check if it's a bundle
if let Some(bundle) = catalog.get_bundle(name) {
println!("Bundle: {}", bundle.display_name);
if let Some(desc) = &bundle.description {
println!(" {}", desc);
}
println!("\nExtensions:");
for ext_key in &bundle.extensions {
if let Some(m) = catalog.get(ext_key) {
println!(" {} - {} ({})", ext_key, m.description, m.kind);
} else {
println!(" {} (not found in registry)", ext_key);
}
}
if let Some(shared) = &bundle.shared_auth {
println!("\nShared auth: {}", shared);
}
return Ok(());
}
// Single extension (use get_strict to surface ambiguous bare names)
let manifest = catalog
.get_strict(name)
.map_err(|e| anyhow::anyhow!("{}", e))?;
println!("{} ({})", manifest.display_name, manifest.kind);
println!(" Version: {}", manifest.version);
println!(" {}", manifest.description);
if !manifest.keywords.is_empty() {
println!(" Keywords: {}", manifest.keywords.join(", "));
}
println!("\nSource:");
println!(" Directory: {}", manifest.source.dir);
println!(" Crate: {}", manifest.source.crate_name);
println!(" Capabilities: {}", manifest.source.capabilities);
if let Some(artifact) = manifest.artifacts.get("wasm32-wasip2") {
println!("\nArtifact (wasm32-wasip2):");
match &artifact.url {
Some(url) => println!(" URL: {}", url),
None => println!(" URL: (not yet published)"),
}
match &artifact.sha256 {
Some(sha) => println!(" SHA256: {}", sha),
None => println!(" SHA256: (not yet computed)"),
}
}
if let Some(auth) = &manifest.auth_summary {
println!("\nAuthentication:");
if let Some(method) = &auth.method {
println!(" Method: {}", method);
}
if let Some(provider) = &auth.provider {
println!(" Provider: {}", provider);
}
if !auth.secrets.is_empty() {
println!(" Secrets: {}", auth.secrets.join(", "));
}
if let Some(shared) = &auth.shared_auth {
println!(" Shared with: {}", shared);
}
if let Some(url) = &auth.setup_url {
println!(" Setup: {}", url);
}
}
if !manifest.tags.is_empty() {
println!("\nTags: {}", manifest.tags.join(", "));
}
Ok(())
}
async fn cmd_install(
catalog: &RegistryCatalog,
registry_dir: &std::path::Path,
name: &str,
force: bool,
prefer_build: bool,
) -> anyhow::Result<()> {
// Registry dir parent is the repo root
let repo_root = registry_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root from registry dir"))?;
let installer = RegistryInstaller::with_defaults(repo_root.to_path_buf());
let (manifests, bundle) = catalog.resolve(name)?;
if manifests.is_empty() {
anyhow::bail!("No extensions found for '{}'.", name);
}
if let Some(bundle_def) = bundle {
// Bundle install
println!(
"Installing bundle '{}' ({} extensions)...\n",
bundle_def.display_name,
manifests.len()
);
let (outcomes, hints) = installer
.install_bundle(&manifests, bundle_def, force, prefer_build)
.await;
println!("\n--- Results ---");
for outcome in &outcomes {
let caps_status = if outcome.has_capabilities { "+" } else { "-" };
println!(
" [{}] {} ({}) -> {}",
caps_status,
outcome.name,
outcome.kind,
outcome.wasm_path.display()
);
for w in &outcome.warnings {
println!(" Warning: {}", w);
}
}
if !hints.is_empty() {
println!("\nAuth setup:");
for hint in &hints {
println!("{}", hint);
}
}
println!(
"\nInstalled {}/{} extensions.",
outcomes.len(),
manifests.len()
);
} else {
// Single extension
let manifest = manifests[0];
let outcome = installer.install(manifest, force, prefer_build).await?;
println!("\nInstalled successfully:");
println!(" Name: {}", outcome.name);
println!(" Kind: {}", outcome.kind);
println!(" WASM: {}", outcome.wasm_path.display());
println!(" Capabilities: {}", outcome.has_capabilities);
if let Some(auth) = &manifest.auth_summary
&& auth.method.as_deref() != Some("none")
{
println!(
"\nNext step: authenticate with `ironclaw tool auth {}`",
manifest.name
);
if let Some(url) = &auth.setup_url {
println!(" Setup credentials at: {}", url);
}
}
}
Ok(())
}
+42 -8
View File
@@ -9,25 +9,48 @@ use crate::settings::Settings;
pub struct EmbeddingsConfig {
/// Whether embeddings are enabled.
pub enabled: bool,
/// Provider to use: "openai" or "nearai"
/// Provider to use: "openai", "nearai", or "ollama"
pub provider: String,
/// OpenAI API key (for OpenAI provider).
pub openai_api_key: Option<SecretString>,
/// Model to use for embeddings.
pub model: String,
/// Ollama base URL (for Ollama provider). Defaults to http://localhost:11434.
pub ollama_base_url: String,
/// Embedding vector dimension. Inferred from the model name when not set explicitly.
pub dimension: usize,
}
impl Default for EmbeddingsConfig {
fn default() -> Self {
let model = "text-embedding-3-small".to_string();
let dimension = default_dimension_for_model(&model);
Self {
enabled: false,
provider: "openai".to_string(),
openai_api_key: None,
model: "text-embedding-3-small".to_string(),
model,
ollama_base_url: "http://localhost:11434".to_string(),
dimension,
}
}
}
/// Infer the embedding dimension from a well-known model name.
///
/// Falls back to 1536 (OpenAI text-embedding-3-small default) for unknown models.
fn default_dimension_for_model(model: &str) -> usize {
match model {
"text-embedding-3-small" => 1536,
"text-embedding-3-large" => 3072,
"text-embedding-ada-002" => 1536,
"nomic-embed-text" => 768,
"mxbai-embed-large" => 1024,
"all-minilm" => 384,
_ => 1536,
}
}
impl EmbeddingsConfig {
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
@@ -38,6 +61,19 @@ impl EmbeddingsConfig {
let model =
optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone());
let ollama_base_url = optional_env("OLLAMA_BASE_URL")?
.or_else(|| settings.ollama_base_url.clone())
.unwrap_or_else(|| "http://localhost:11434".to_string());
let dimension = optional_env("EMBEDDING_DIMENSION")?
.map(|s| s.parse::<usize>())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "EMBEDDING_DIMENSION".to_string(),
message: format!("must be a positive integer: {e}"),
})?
.unwrap_or_else(|| default_dimension_for_model(&model));
let enabled = optional_env("EMBEDDING_ENABLED")?
.map(|s| s.parse())
.transpose()
@@ -52,6 +88,8 @@ impl EmbeddingsConfig {
provider,
openai_api_key,
model,
ollama_base_url,
dimension,
})
}
@@ -64,16 +102,12 @@ impl EmbeddingsConfig {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::settings::{EmbeddingsSettings, Settings};
use std::sync::Mutex;
/// Serializes env-mutating tests to prevent parallel races.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
/// Clear all embedding-related env vars.
fn clear_embedding_env() {
// SAFETY: Only called under ENV_MUTEX in tests. No other threads
// observe these vars while the lock is held.
// SAFETY: Only called under ENV_MUTEX in tests.
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
std::env::remove_var("EMBEDDING_PROVIDER");
+9
View File
@@ -2,6 +2,15 @@ use crate::error::ConfigError;
use super::INJECTED_VARS;
/// Crate-wide mutex for tests that mutate process environment variables.
///
/// The process environment is global state shared across all threads.
/// Per-module mutexes do NOT prevent races between modules running in
/// parallel. Every `unsafe { set_var / remove_var }` call in tests
/// MUST hold this single lock.
#[cfg(test)]
pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
// Check real env vars first (always win over injected secrets)
match std::env::var(key) {
+70
View File
@@ -0,0 +1,70 @@
use crate::config::helpers::optional_env;
use crate::error::ConfigError;
/// Memory hygiene configuration.
///
/// Controls automatic cleanup of stale workspace documents.
/// Maps to `crate::workspace::hygiene::HygieneConfig`.
#[derive(Debug, Clone)]
pub struct HygieneConfig {
/// Whether hygiene is enabled. Env: `MEMORY_HYGIENE_ENABLED` (default: true).
pub enabled: bool,
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_RETENTION_DAYS` (default: 30).
pub retention_days: u32,
/// Minimum hours between hygiene passes. Env: `MEMORY_HYGIENE_CADENCE_HOURS` (default: 12).
pub cadence_hours: u32,
}
impl Default for HygieneConfig {
fn default() -> Self {
Self {
enabled: true,
retention_days: 30,
cadence_hours: 12,
}
}
}
impl HygieneConfig {
pub(crate) fn resolve() -> Result<Self, ConfigError> {
Ok(Self {
enabled: optional_env("MEMORY_HYGIENE_ENABLED")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "MEMORY_HYGIENE_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(true),
retention_days: optional_env("MEMORY_HYGIENE_RETENTION_DAYS")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "MEMORY_HYGIENE_RETENTION_DAYS".to_string(),
message: format!("must be a positive integer: {e}"),
})?
.unwrap_or(30),
cadence_hours: optional_env("MEMORY_HYGIENE_CADENCE_HOURS")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "MEMORY_HYGIENE_CADENCE_HOURS".to_string(),
message: format!("must be a positive integer: {e}"),
})?
.unwrap_or(12),
})
}
/// Convert to the workspace hygiene config, resolving the state directory
/// to the standard `~/.ironclaw` location.
pub fn to_workspace_config(&self) -> crate::workspace::hygiene::HygieneConfig {
crate::workspace::hygiene::HygieneConfig {
enabled: self.enabled,
retention_days: self.retention_days,
cadence_hours: self.cadence_hours,
state_dir: dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".ironclaw"),
}
}
}
+35 -14
View File
@@ -64,6 +64,8 @@ impl std::fmt::Display for LlmBackend {
pub struct OpenAiDirectConfig {
pub api_key: SecretString,
pub model: String,
/// Optional base URL override (e.g. for proxies like VibeProxy).
pub base_url: Option<String>,
}
/// Configuration for direct Anthropic API access.
@@ -71,6 +73,8 @@ pub struct OpenAiDirectConfig {
pub struct AnthropicDirectConfig {
pub api_key: SecretString,
pub model: String,
/// Optional base URL override (e.g. for proxies like VibeProxy).
pub base_url: Option<String>,
}
/// Configuration for local Ollama.
@@ -118,12 +122,15 @@ pub struct LlmConfig {
}
/// API mode for NEAR AI.
///
/// - `Responses` = **NEAR AI Chat** (`private.near.ai`, session token auth)
/// - `ChatCompletions` = **NEAR AI Cloud** (`cloud-api.near.ai`, API key auth)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NearAiApiMode {
/// Use the Responses API (chat-api proxy) - session-based auth
/// NEAR AI Chat: Responses API with session token auth
#[default]
Responses,
/// Use the Chat Completions API (cloud-api) - API key auth
/// NEAR AI Cloud: Chat Completions API with API key auth
ChatCompletions,
}
@@ -144,7 +151,7 @@ impl std::str::FromStr for NearAiApiMode {
}
}
/// NEAR AI chat-api configuration.
/// NEAR AI configuration (shared by Chat and Cloud modes).
#[derive(Debug, Clone)]
pub struct NearAiConfig {
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
@@ -152,15 +159,17 @@ pub struct NearAiConfig {
/// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation).
/// Falls back to the main model if not set.
pub cheap_model: Option<String>,
/// Base URL for the NEAR AI API (default: https://private.near.ai).
/// Base URL for the NEAR AI API.
/// Chat mode default: `https://private.near.ai`
/// Cloud mode default: `https://cloud-api.near.ai`
pub base_url: String,
/// Base URL for auth/refresh endpoints (default: https://private.near.ai)
pub auth_base_url: String,
/// Path to session file (default: ~/.ironclaw/session.json)
pub session_path: PathBuf,
/// API mode: "responses" (chat-api) or "chat_completions" (cloud-api)
/// API mode: NEAR AI Chat (Responses) or NEAR AI Cloud (ChatCompletions)
pub api_mode: NearAiApiMode,
/// API key for cloud-api (required for chat_completions mode)
/// API key for NEAR AI Cloud (required for ChatCompletions mode)
pub api_key: Option<SecretString>,
/// Optional fallback model for failover (default: None).
/// When set, a secondary provider is created with this model and wrapped
@@ -239,8 +248,13 @@ impl LlmConfig {
.to_string()
}),
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
base_url: optional_env("NEARAI_BASE_URL")?
.unwrap_or_else(|| "https://private.near.ai".to_string()),
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
if api_mode == NearAiApiMode::ChatCompletions {
"https://cloud-api.near.ai".to_string()
} else {
"https://private.near.ai".to_string()
}
}),
auth_base_url: optional_env("NEARAI_AUTH_URL")?
.unwrap_or_else(|| "https://private.near.ai".to_string()),
session_path: optional_env("NEARAI_SESSION_PATH")?
@@ -274,7 +288,12 @@ impl LlmConfig {
hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(),
})?;
let model = optional_env("OPENAI_MODEL")?.unwrap_or_else(|| "gpt-4o".to_string());
Some(OpenAiDirectConfig { api_key, model })
let base_url = optional_env("OPENAI_BASE_URL")?;
Some(OpenAiDirectConfig {
api_key,
model,
base_url,
})
} else {
None
};
@@ -288,7 +307,12 @@ impl LlmConfig {
})?;
let model = optional_env("ANTHROPIC_MODEL")?
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
Some(AnthropicDirectConfig { api_key, model })
let base_url = optional_env("ANTHROPIC_BASE_URL")?;
Some(AnthropicDirectConfig {
api_key,
model,
base_url,
})
} else {
None
};
@@ -359,11 +383,8 @@ fn default_session_path() -> PathBuf {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
use crate::settings::Settings;
use std::sync::Mutex;
/// Serializes env-mutating tests to prevent parallel races.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
/// Clear all openai-compatible-related env vars.
fn clear_openai_compatible_env() {
+5
View File
@@ -12,6 +12,7 @@ mod database;
mod embeddings;
mod heartbeat;
pub(crate) mod helpers;
mod hygiene;
mod llm;
mod routines;
mod safety;
@@ -34,6 +35,7 @@ pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig};
pub use self::database::{DatabaseBackend, DatabaseConfig, default_libsql_path};
pub use self::embeddings::EmbeddingsConfig;
pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig;
pub use self::llm::{
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig, OllamaConfig,
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
@@ -67,6 +69,7 @@ pub struct Config {
pub secrets: SecretsConfig,
pub builder: BuilderModeConfig,
pub heartbeat: HeartbeatConfig,
pub hygiene: HygieneConfig,
pub routines: RoutineConfig,
pub sandbox: SandboxModeConfig,
pub claude_code: ClaudeCodeConfig,
@@ -190,6 +193,7 @@ impl Config {
secrets: SecretsConfig::resolve().await?,
builder: BuilderModeConfig::resolve()?,
heartbeat: HeartbeatConfig::resolve(settings)?,
hygiene: HygieneConfig::resolve()?,
routines: RoutineConfig::resolve()?,
sandbox: SandboxModeConfig::resolve()?,
claude_code: ClaudeCodeConfig::resolve()?,
@@ -215,6 +219,7 @@ pub async fn inject_llm_keys_from_secrets(
("llm_openai_api_key", "OPENAI_API_KEY"),
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
("llm_compatible_api_key", "LLM_API_KEY"),
("llm_nearai_api_key", "NEARAI_API_KEY"),
];
let mut injected = HashMap::new();
+2 -2
View File
@@ -30,7 +30,7 @@ impl Default for SandboxModeConfig {
timeout_secs: 120,
memory_limit_mb: 2048,
cpu_shares: 1024,
image: "ghcr.io/nearai/sandbox:latest".to_string(),
image: "ironclaw-worker:latest".to_string(),
auto_pull_image: true,
extra_allowed_domains: Vec::new(),
}
@@ -57,7 +57,7 @@ impl SandboxModeConfig {
memory_limit_mb: parse_optional_env("SANDBOX_MEMORY_LIMIT_MB", 2048)?,
cpu_shares: parse_optional_env("SANDBOX_CPU_SHARES", 1024)?,
image: optional_env("SANDBOX_IMAGE")?
.unwrap_or_else(|| "ghcr.io/nearai/sandbox:latest".to_string()),
.unwrap_or_else(|| "ironclaw-worker:latest".to_string()),
auto_pull_image: optional_env("SANDBOX_AUTO_PULL")?
.map(|s| s.parse())
.transpose()
+4 -4
View File
@@ -320,10 +320,10 @@ pub(crate) fn row_to_routine_libsql(row: &libsql::Row) -> Result<Routine, Databa
let max_concurrent = get_i64(row, 10);
let dedup_window_secs: Option<i64> = row.get::<i64>(11).ok();
let trigger =
Trigger::from_db(&trigger_type, trigger_config).map_err(DatabaseError::Serialization)?;
let trigger = Trigger::from_db(&trigger_type, trigger_config)
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
let action = RoutineAction::from_db(&action_type, action_config)
.map_err(DatabaseError::Serialization)?;
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
Ok(Routine {
id: get_text(row, 0).parse().unwrap_or_default(),
@@ -359,7 +359,7 @@ pub(crate) fn row_to_routine_run_libsql(row: &libsql::Row) -> Result<RoutineRun,
let status_str = get_text(row, 5);
let status: RunStatus = status_str
.parse()
.map_err(|e: String| DatabaseError::Serialization(e))?;
.map_err(|e: crate::error::RoutineError| DatabaseError::Serialization(e.to_string()))?;
Ok(RoutineRun {
id: get_text(row, 0).parse().unwrap_or_default(),
+43
View File
@@ -48,6 +48,9 @@ pub enum Error {
#[error("Worker error: {0}")]
Worker(#[from] WorkerError),
#[error("Routine error: {0}")]
Routine(#[from] RoutineError),
}
/// Configuration-related errors.
@@ -365,5 +368,45 @@ pub enum WorkerError {
MissingToken,
}
/// Routine-related errors.
#[derive(Debug, thiserror::Error)]
pub enum RoutineError {
#[error("Unknown trigger type: {trigger_type}")]
UnknownTriggerType { trigger_type: String },
#[error("Unknown action type: {action_type}")]
UnknownActionType { action_type: String },
#[error("Missing field in {context}: {field}")]
MissingField { context: String, field: String },
#[error("Invalid cron expression: {reason}")]
InvalidCron { reason: String },
#[error("Unknown run status: {status}")]
UnknownRunStatus { status: String },
#[error("Routine {name} is disabled")]
Disabled { name: String },
#[error("Routine not found: {id}")]
NotFound { id: Uuid },
#[error("Routine {name} at max concurrent runs")]
MaxConcurrent { name: String },
#[error("Database error: {reason}")]
Database { reason: String },
#[error("LLM call failed: {reason}")]
LlmFailed { reason: String },
#[error("LLM returned empty content")]
EmptyResponse,
#[error("LLM response truncated (finish_reason=length) with no content")]
TruncatedResponse,
}
/// Result type alias for the agent.
pub type Result<T> = std::result::Result<T, Error>;
+62
View File
@@ -16,6 +16,7 @@ use crate::extensions::{
ActivateResult, AuthResult, ExtensionError, ExtensionKind, ExtensionSource, InstallResult,
InstalledExtension, RegistryEntry, ResultSource, SearchResult,
};
use crate::hooks::HookRegistry;
use crate::secrets::{CreateSecretParams, SecretsStore};
use crate::tools::ToolRegistry;
use crate::tools::mcp::McpClient;
@@ -52,6 +53,7 @@ pub struct ExtensionManager {
// Shared
secrets: Arc<dyn SecretsStore + Send + Sync>,
tool_registry: Arc<ToolRegistry>,
hooks: Option<Arc<HookRegistry>>,
pending_auth: RwLock<HashMap<String, PendingAuth>>,
/// Tunnel URL for remote OAuth callbacks (used in future iterations).
_tunnel_url: Option<String>,
@@ -66,6 +68,7 @@ impl ExtensionManager {
mcp_session_manager: Arc<McpSessionManager>,
secrets: Arc<dyn SecretsStore + Send + Sync>,
tool_registry: Arc<ToolRegistry>,
hooks: Option<Arc<HookRegistry>>,
wasm_tool_runtime: Option<Arc<WasmToolRuntime>>,
wasm_tools_dir: PathBuf,
wasm_channels_dir: PathBuf,
@@ -83,6 +86,7 @@ impl ExtensionManager {
wasm_channels_dir,
secrets,
tool_registry,
hooks,
pending_auth: RwLock::new(HashMap::new()),
_tunnel_url: tunnel_url,
user_id,
@@ -320,6 +324,21 @@ impl ExtensionManager {
// Unregister from tool registry
self.tool_registry.unregister(name).await;
// Unregister hooks registered from this plugin source.
let removed_hooks = self
.unregister_hook_prefix(&format!("plugin.tool:{}::", name))
.await
+ self
.unregister_hook_prefix(&format!("plugin.dev_tool:{}::", name))
.await;
if removed_hooks > 0 {
tracing::info!(
extension = name,
removed_hooks = removed_hooks,
"Removed plugin hooks for WASM tool"
);
}
// Delete files
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
let cap_path = self
@@ -969,6 +988,34 @@ impl ExtensionManager {
.await
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
if let Some(ref hooks) = self.hooks
&& let Some(cap_path) = cap_path_option
{
let source = format!("plugin.tool:{}", name);
let registration =
crate::hooks::bootstrap::register_plugin_bundle_from_capabilities_file(
hooks, &source, cap_path,
)
.await;
if registration.total_registered() > 0 {
tracing::info!(
extension = name,
hooks = registration.hooks,
outbound_webhooks = registration.outbound_webhooks,
"Registered plugin hooks for activated WASM tool"
);
}
if registration.errors > 0 {
tracing::warn!(
extension = name,
errors = registration.errors,
"Some plugin hooks failed to register"
);
}
}
tracing::info!("Activated WASM tool '{}'", name);
Ok(ActivateResult {
@@ -1008,6 +1055,21 @@ impl ExtensionManager {
let mut pending = self.pending_auth.write().await;
pending.retain(|_, auth| auth.created_at.elapsed() < std::time::Duration::from_secs(300));
}
async fn unregister_hook_prefix(&self, prefix: &str) -> usize {
let Some(ref hooks) = self.hooks else {
return 0;
};
let names = hooks.list().await;
let mut removed = 0;
for hook_name in names {
if hook_name.starts_with(prefix) && hooks.unregister(&hook_name).await {
removed += 1;
}
}
removed
}
}
/// Infer the extension kind from a URL.
+4 -4
View File
@@ -1179,10 +1179,10 @@ fn row_to_routine(row: &tokio_postgres::Row) -> Result<Routine, DatabaseError> {
let max_concurrent: i32 = row.get("max_concurrent");
let dedup_window_secs: Option<i32> = row.get("dedup_window_secs");
let trigger =
Trigger::from_db(&trigger_type, trigger_config).map_err(DatabaseError::Serialization)?;
let trigger = Trigger::from_db(&trigger_type, trigger_config)
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
let action = RoutineAction::from_db(&action_type, action_config)
.map_err(DatabaseError::Serialization)?;
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
Ok(Routine {
id: row.get("id"),
@@ -1219,7 +1219,7 @@ fn row_to_routine_run(row: &tokio_postgres::Row) -> Result<RoutineRun, DatabaseE
let status_str: String = row.get("status");
let status: RunStatus = status_str
.parse()
.map_err(|e: String| DatabaseError::Serialization(e))?;
.map_err(|e: crate::error::RoutineError| DatabaseError::Serialization(e.to_string()))?;
Ok(RoutineRun {
id: row.get("id"),
+378
View File
@@ -0,0 +1,378 @@
//! Hook bootstrap helpers for loading bundled, plugin, and workspace hooks.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::channels::wasm::discover_channels;
use crate::hooks::bundled::{
HookBundleConfig, HookRegistrationSummary, register_bundle, register_bundled_hooks,
};
use crate::hooks::registry::HookRegistry;
use crate::tools::wasm::{discover_dev_tools, discover_tools};
use crate::workspace::Workspace;
/// Summary of hook bootstrap work done at startup.
#[derive(Debug, Default, Clone, Copy)]
pub struct HookBootstrapSummary {
/// Number of bundled built-in hooks registered.
pub bundled_hooks: usize,
/// Number of plugin-provided rule hooks registered.
pub plugin_hooks: usize,
/// Number of workspace-provided rule hooks registered.
pub workspace_hooks: usize,
/// Number of outbound webhook hooks registered.
pub outbound_webhooks: usize,
/// Number of invalid hook configs skipped.
pub errors: usize,
}
impl HookBootstrapSummary {
/// Total number of hooks registered across all categories.
pub fn total_hooks(&self) -> usize {
self.bundled_hooks + self.plugin_hooks + self.workspace_hooks + self.outbound_webhooks
}
}
/// Register bundled hooks, then load plugin and workspace hook bundles.
pub async fn bootstrap_hooks(
registry: &Arc<HookRegistry>,
workspace: Option<&Arc<Workspace>>,
wasm_tools_dir: &Path,
wasm_channels_dir: &Path,
active_tool_names: &[String],
active_channel_names: &[String],
dev_loaded_tool_names: &[String],
) -> HookBootstrapSummary {
let mut summary = HookBootstrapSummary::default();
let bundled = register_bundled_hooks(registry).await;
summary.bundled_hooks += bundled.hooks;
summary.outbound_webhooks += bundled.outbound_webhooks;
summary.errors += bundled.errors;
let plugin = register_plugin_bundles(
registry,
wasm_tools_dir,
wasm_channels_dir,
active_tool_names,
active_channel_names,
dev_loaded_tool_names,
)
.await;
summary.plugin_hooks += plugin.hooks;
summary.outbound_webhooks += plugin.outbound_webhooks;
summary.errors += plugin.errors;
if let Some(workspace) = workspace {
let workspace_loaded = register_workspace_bundles(registry, workspace).await;
summary.workspace_hooks += workspace_loaded.hooks;
summary.outbound_webhooks += workspace_loaded.outbound_webhooks;
summary.errors += workspace_loaded.errors;
}
summary
}
async fn register_plugin_bundles(
registry: &Arc<HookRegistry>,
wasm_tools_dir: &Path,
wasm_channels_dir: &Path,
active_tool_names: &[String],
active_channel_names: &[String],
dev_loaded_tool_names: &[String],
) -> HookRegistrationSummary {
let mut summary = HookRegistrationSummary::default();
let files = collect_plugin_capability_files(
wasm_tools_dir,
wasm_channels_dir,
active_tool_names,
active_channel_names,
dev_loaded_tool_names,
)
.await;
for (source, path) in files {
let registered =
register_plugin_bundle_from_capabilities_file(registry, &source, &path).await;
summary.merge(registered);
}
summary
}
/// Register a plugin hook bundle from a single capabilities file.
///
/// This is used by startup bootstrap and by runtime extension activation.
pub async fn register_plugin_bundle_from_capabilities_file(
registry: &Arc<HookRegistry>,
source: &str,
path: &Path,
) -> HookRegistrationSummary {
match load_plugin_bundle_from_capabilities_file(path).await {
Ok(Some(bundle)) => register_bundle(registry, source, bundle).await,
Ok(None) => HookRegistrationSummary::default(),
Err(err) => {
tracing::warn!(
source = source,
path = %path.display(),
error = %err,
"Skipping plugin hook bundle"
);
HookRegistrationSummary {
hooks: 0,
outbound_webhooks: 0,
errors: 1,
}
}
}
}
async fn collect_plugin_capability_files(
wasm_tools_dir: &Path,
wasm_channels_dir: &Path,
active_tool_names: &[String],
active_channel_names: &[String],
dev_loaded_tool_names: &[String],
) -> Vec<(String, PathBuf)> {
let mut files: Vec<(String, PathBuf)> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let active_tools: HashSet<&str> = active_tool_names.iter().map(String::as_str).collect();
let active_channels: HashSet<&str> = active_channel_names.iter().map(String::as_str).collect();
let dev_loaded_tools: HashSet<&str> =
dev_loaded_tool_names.iter().map(String::as_str).collect();
if wasm_tools_dir.exists() {
match discover_tools(wasm_tools_dir).await {
Ok(tools) => {
for (name, tool) in tools {
if let Some(path) = tool.capabilities_path
&& active_tools.contains(name.as_str())
&& !dev_loaded_tools.contains(name.as_str())
{
insert_unique(&mut files, &mut seen, format!("plugin.tool:{}", name), path);
}
}
}
Err(err) => {
tracing::warn!(
path = %wasm_tools_dir.display(),
error = %err,
"Failed to discover WASM tool capabilities for plugin hooks"
);
}
}
}
match discover_dev_tools().await {
Ok(dev_tools) => {
for (name, tool) in dev_tools {
if let Some(path) = tool.capabilities_path
&& active_tools.contains(name.as_str())
&& dev_loaded_tools.contains(name.as_str())
{
insert_unique(
&mut files,
&mut seen,
format!("plugin.dev_tool:{}", name),
path,
);
}
}
}
Err(err) => {
tracing::debug!(error = %err, "No dev tool capabilities discovered for plugin hooks");
}
}
if wasm_channels_dir.exists() {
match discover_channels(wasm_channels_dir).await {
Ok(channels) => {
for (name, channel) in channels {
if let Some(path) = channel.capabilities_path
&& active_channels.contains(name.as_str())
{
insert_unique(
&mut files,
&mut seen,
format!("plugin.channel:{}", name),
path,
);
}
}
}
Err(err) => {
tracing::warn!(
path = %wasm_channels_dir.display(),
error = %err,
"Failed to discover WASM channel capabilities for plugin hooks"
);
}
}
}
files.sort_by(|a, b| a.0.cmp(&b.0));
files
}
fn insert_unique(
files: &mut Vec<(String, PathBuf)>,
seen: &mut HashSet<String>,
source: String,
path: PathBuf,
) {
let key = path.to_string_lossy().to_string();
if seen.insert(key) {
files.push((source, path));
}
}
async fn load_plugin_bundle_from_capabilities_file(
path: &Path,
) -> Result<Option<HookBundleConfig>, String> {
let bytes = tokio::fs::read(path)
.await
.map_err(|e| format!("read failed: {e}"))?;
let value: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON: {e}"))?;
let Some(hooks_value) = extract_hooks_section(&value) else {
return Ok(None);
};
HookBundleConfig::from_value(hooks_value)
.map(Some)
.map_err(|e| e.to_string())
}
fn extract_hooks_section(root: &serde_json::Value) -> Option<&serde_json::Value> {
root.get("hooks")
.or_else(|| root.get("capabilities").and_then(|c| c.get("hooks")))
}
async fn register_workspace_bundles(
registry: &Arc<HookRegistry>,
workspace: &Arc<Workspace>,
) -> HookRegistrationSummary {
let mut summary = HookRegistrationSummary::default();
let paths = match workspace.list_all().await {
Ok(paths) => paths,
Err(err) => {
summary.errors += 1;
tracing::warn!(error = %err, "Failed to list workspace paths for hooks");
return summary;
}
};
let mut hook_paths: Vec<String> = paths
.into_iter()
.filter(|path| is_workspace_hook_file(path))
.collect();
hook_paths.sort();
for path in hook_paths {
let doc = match workspace.read(&path).await {
Ok(doc) => doc,
Err(err) => {
summary.errors += 1;
tracing::warn!(path = %path, error = %err, "Skipping unreadable workspace hook file");
continue;
}
};
let parsed: serde_json::Value = match serde_json::from_str(&doc.content) {
Ok(value) => value,
Err(err) => {
summary.errors += 1;
tracing::warn!(path = %path, error = %err, "Workspace hook file is not valid JSON");
continue;
}
};
let bundle = match parse_workspace_bundle(&parsed) {
Ok(bundle) => bundle,
Err(err) => {
summary.errors += 1;
tracing::warn!(path = %path, error = %err, "Skipping invalid workspace hook bundle");
continue;
}
};
let source = format!("workspace:{}", path);
let registered = register_bundle(registry, &source, bundle).await;
summary.merge(registered);
}
summary
}
fn parse_workspace_bundle(value: &serde_json::Value) -> Result<HookBundleConfig, String> {
if let Some(nested) = value.get("hooks") {
HookBundleConfig::from_value(nested).map_err(|e| e.to_string())
} else {
HookBundleConfig::from_value(value).map_err(|e| e.to_string())
}
}
fn is_workspace_hook_file(path: &str) -> bool {
path == "hooks/hooks.json" || (path.starts_with("hooks/") && path.ends_with(".hook.json"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_hooks_section_from_tool_caps() {
let value = serde_json::json!({
"http": {"allowlist": []},
"hooks": {"rules": []}
});
let extracted = extract_hooks_section(&value).unwrap();
assert!(extracted.get("rules").is_some());
}
#[test]
fn test_extract_hooks_section_from_channel_caps() {
let value = serde_json::json!({
"type": "channel",
"capabilities": {
"hooks": {
"rules": []
}
}
});
let extracted = extract_hooks_section(&value).unwrap();
assert!(extracted.get("rules").is_some());
}
#[test]
fn test_workspace_hook_file_filter() {
assert!(is_workspace_hook_file("hooks/hooks.json"));
assert!(is_workspace_hook_file("hooks/redact.hook.json"));
assert!(!is_workspace_hook_file("hooks/readme.md"));
assert!(!is_workspace_hook_file("MEMORY.md"));
}
#[test]
fn test_parse_workspace_bundle_wrapped_hooks() {
let value = serde_json::json!({
"hooks": {
"rules": [
{
"name": "append-bang",
"points": ["beforeInbound"],
"append": "!"
}
]
}
});
let bundle = parse_workspace_bundle(&value).unwrap();
assert_eq!(bundle.rules.len(), 1);
}
}
+1234
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -3,9 +3,11 @@
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
/// Points in the agent lifecycle where hooks can be attached.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum HookPoint {
/// Before processing an inbound user message.
BeforeInbound,
@@ -21,8 +23,22 @@ pub enum HookPoint {
TransformResponse,
}
impl HookPoint {
/// Human-readable hook point identifier.
pub fn as_str(&self) -> &'static str {
match self {
HookPoint::BeforeInbound => "beforeInbound",
HookPoint::BeforeToolCall => "beforeToolCall",
HookPoint::BeforeOutbound => "beforeOutbound",
HookPoint::OnSessionStart => "onSessionStart",
HookPoint::OnSessionEnd => "onSessionEnd",
HookPoint::TransformResponse => "transformResponse",
}
}
}
/// Contextual data carried with each hook invocation.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum HookEvent {
/// An inbound user message about to be processed.
Inbound {
@@ -133,7 +149,8 @@ impl HookOutcome {
}
/// How to handle hook execution failures.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookFailureMode {
/// On error/timeout, continue processing as if the hook returned `ok()`.
FailOpen,
+6
View File
@@ -12,8 +12,14 @@
//! Hooks are executed in priority order (lower number = higher priority).
//! Each hook can pass through, modify content, or reject the event.
pub mod bootstrap;
pub mod bundled;
pub mod hook;
pub mod registry;
pub use bootstrap::{HookBootstrapSummary, bootstrap_hooks};
pub use bundled::{
HookBundleConfig, HookRegistrationSummary, register_bundle, register_bundled_hooks,
};
pub use hook::{Hook, HookContext, HookError, HookEvent, HookFailureMode, HookOutcome, HookPoint};
pub use registry::HookRegistry;
+54 -1
View File
@@ -39,7 +39,22 @@ impl HookRegistry {
/// Lower priority number = runs first.
pub async fn register_with_priority(&self, hook: Arc<dyn Hook>, priority: u32) {
let mut hooks = self.hooks.write().await;
hooks.push(HookEntry { hook, priority });
let hook_name = hook.name().to_string();
if let Some(existing) = hooks
.iter_mut()
.find(|entry| entry.hook.name() == hook_name)
{
tracing::warn!(
hook = %hook_name,
"Replacing existing hook registration with same name"
);
existing.hook = hook;
existing.priority = priority;
} else {
hooks.push(HookEntry { hook, priority });
}
hooks.sort_by_key(|e| e.priority);
}
@@ -346,6 +361,44 @@ mod tests {
assert_eq!(names, vec!["hook-a", "hook-b"]);
}
#[tokio::test]
async fn test_register_duplicate_name_replaces_existing() {
let registry = HookRegistry::new();
registry
.register_with_priority(
Arc::new(ModifyHook {
name: "dup".into(),
suffix: "-A".into(),
points: vec![HookPoint::BeforeInbound],
}),
100,
)
.await;
registry
.register_with_priority(
Arc::new(ModifyHook {
name: "dup".into(),
suffix: "-B".into(),
points: vec![HookPoint::BeforeInbound],
}),
10,
)
.await;
let names = registry.list().await;
assert_eq!(names, vec!["dup"]);
let result = registry.run(&test_event()).await.unwrap();
match result {
HookOutcome::Continue {
modified: Some(value),
} => assert_eq!(value, "hello-B"),
other => panic!("expected modified output, got {other:?}"),
}
}
#[tokio::test]
async fn test_priority_ordering() {
let registry = HookRegistry::new();
+1
View File
@@ -57,6 +57,7 @@ pub mod llm;
pub mod observability;
pub mod orchestrator;
pub mod pairing;
pub mod registry;
pub mod safety;
pub mod sandbox;
pub mod secrets;
+22 -4
View File
@@ -123,7 +123,11 @@ impl CircuitBreakerProvider {
);
Ok(())
} else {
let remaining = self.config.recovery_timeout - opened_at.elapsed();
let remaining = self
.config
.recovery_timeout
.checked_sub(opened_at.elapsed())
.unwrap_or(Duration::ZERO);
Err(LlmError::RequestFailed {
provider: self.inner.model_name().to_string(),
reason: format!(
@@ -208,8 +212,16 @@ impl CircuitBreakerProvider {
/// Returns `true` for errors that indicate the provider is degraded
/// (server errors, rate limits, network failures, auth infrastructure down).
///
/// Client errors (wrong model, bad credentials, context overflow) are NOT
/// transient: they are the caller's problem, not a sign of backend trouble.
/// This answers: "should this error count toward tripping the circuit breaker?"
///
/// Includes `SessionExpired` because repeated session failures signal backend
/// auth infrastructure trouble.
///
/// Excludes client errors that are the caller's problem, not backend trouble:
/// `AuthFailed`, `ContextLengthExceeded`, `ModelNotAvailable`, `Json`.
///
/// See also `retry::is_retryable()` which answers a different question:
/// "could retrying this exact request succeed?"
fn is_transient(err: &LlmError) -> bool {
matches!(
err,
@@ -219,7 +231,6 @@ fn is_transient(err: &LlmError) -> bool {
| LlmError::SessionExpired { .. }
| LlmError::SessionRenewalFailed { .. }
| LlmError::Http(_)
| LlmError::Json(_)
| LlmError::Io(_)
)
}
@@ -273,6 +284,10 @@ impl LlmProvider for CircuitBreakerProvider {
self.inner.model_metadata().await
}
fn effective_model_name(&self, requested_model: Option<&str>) -> String {
self.inner.effective_model_name(requested_model)
}
fn active_model_name(&self) -> String {
self.inner.active_model_name()
}
@@ -543,6 +558,9 @@ mod tests {
provider: "p".into(),
model: "m".into(),
}));
assert!(!is_transient(&LlmError::Json(
serde_json::from_str::<String>("bad").unwrap_err()
)));
}
// -- Passthrough delegation tests --
+38 -12
View File
@@ -17,7 +17,17 @@ pub fn model_cost(model_id: &str) -> Option<(Decimal, Decimal)> {
.unwrap_or(model_id);
match id {
// OpenAI models -- prices per token (USD)
// OpenAI — GPT-5.x / Codex
"gpt-5.3-codex" | "gpt-5.3-codex-spark" => Some((dec!(0.000002), dec!(0.000008))),
"gpt-5.2-codex" | "gpt-5.2-pro" | "gpt-5.2" => Some((dec!(0.000002), dec!(0.000008))),
"gpt-5.1-codex" | "gpt-5.1-codex-max" | "gpt-5.1" => Some((dec!(0.000002), dec!(0.000008))),
"gpt-5.1-codex-mini" => Some((dec!(0.0000003), dec!(0.0000012))),
"gpt-5-codex" | "gpt-5-pro" | "gpt-5" => Some((dec!(0.000002), dec!(0.000008))),
"gpt-5-mini" | "gpt-5-nano" => Some((dec!(0.0000003), dec!(0.0000012))),
// OpenAI — GPT-4.x
"gpt-4.1" => Some((dec!(0.000002), dec!(0.000008))),
"gpt-4.1-mini" => Some((dec!(0.0000004), dec!(0.0000016))),
"gpt-4.1-nano" => Some((dec!(0.0000001), dec!(0.0000004))),
"gpt-4o" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" => {
Some((dec!(0.0000025), dec!(0.00001)))
}
@@ -25,20 +35,36 @@ pub fn model_cost(model_id: &str) -> Option<(Decimal, Decimal)> {
"gpt-4-turbo" | "gpt-4-turbo-2024-04-09" => Some((dec!(0.00001), dec!(0.00003))),
"gpt-4" | "gpt-4-0613" => Some((dec!(0.00003), dec!(0.00006))),
"gpt-3.5-turbo" | "gpt-3.5-turbo-0125" => Some((dec!(0.0000005), dec!(0.0000015))),
// OpenAI — reasoning
"o3" => Some((dec!(0.000002), dec!(0.000008))),
"o3-mini" | "o3-mini-2025-01-31" => Some((dec!(0.0000011), dec!(0.0000044))),
"o4-mini" => Some((dec!(0.0000011), dec!(0.0000044))),
"o1" | "o1-2024-12-17" => Some((dec!(0.000015), dec!(0.00006))),
"o1-mini" | "o1-mini-2024-09-12" => Some((dec!(0.000003), dec!(0.000012))),
"o3-mini" | "o3-mini-2025-01-31" => Some((dec!(0.0000011), dec!(0.0000044))),
// Anthropic models
"claude-3-5-sonnet-20241022" | "claude-3-5-sonnet-latest" | "claude-sonnet-4-20250514" => {
Some((dec!(0.000003), dec!(0.000015)))
}
"claude-3-5-haiku-20241022" | "claude-3-5-haiku-latest" => {
Some((dec!(0.0000008), dec!(0.000004)))
}
"claude-3-opus-20240229" | "claude-3-opus-latest" | "claude-opus-4-20250514" => {
Some((dec!(0.000015), dec!(0.000075)))
}
// Anthropic
"claude-opus-4-6"
| "claude-opus-4-5"
| "claude-opus-4-5-20251101"
| "claude-opus-4-1"
| "claude-opus-4-1-20250805"
| "claude-opus-4-0"
| "claude-opus-4-20250514"
| "claude-3-opus-20240229"
| "claude-3-opus-latest" => Some((dec!(0.000015), dec!(0.000075))),
"claude-sonnet-4-6"
| "claude-sonnet-4-5"
| "claude-sonnet-4-5-20250929"
| "claude-sonnet-4-0"
| "claude-sonnet-4-20250514"
| "claude-3-7-sonnet-20250219"
| "claude-3-7-sonnet-latest"
| "claude-3-5-sonnet-20241022"
| "claude-3-5-sonnet-latest" => Some((dec!(0.000003), dec!(0.000015))),
"claude-haiku-4-5"
| "claude-haiku-4-5-20251001"
| "claude-3-5-haiku-20241022"
| "claude-3-5-haiku-latest" => Some((dec!(0.0000008), dec!(0.000004))),
"claude-3-haiku-20240307" => Some((dec!(0.00000025), dec!(0.00000125))),
// Ollama / local models -- free
+128 -41
View File
@@ -7,8 +7,10 @@
//! so subsequent requests skip them, reducing latency when a provider
//! is known to be down. Cooldown state is lock-free (atomics only).
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
@@ -17,34 +19,11 @@ use rust_decimal::Decimal;
use crate::error::LlmError;
use crate::llm::provider::{
CompletionRequest, CompletionResponse, LlmProvider, ToolCompletionRequest,
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest,
ToolCompletionResponse,
};
/// Returns `true` if the error is transient and the request should be retried
/// on the next provider in the failover chain.
///
/// Retryable: `RequestFailed`, `RateLimited`, `InvalidResponse`,
/// `SessionRenewalFailed`, `ModelNotAvailable`, `Http`, `Io`.
///
/// `ModelNotAvailable` is retryable because the next provider in the chain may
/// offer a different model, so it's worth trying.
///
/// Non-retryable errors (`AuthFailed`, `SessionExpired`, `ContextLengthExceeded`)
/// propagate immediately because a different provider won't fix them.
fn is_retryable(err: &LlmError) -> bool {
matches!(
err,
LlmError::RequestFailed { .. }
| LlmError::RateLimited { .. }
| LlmError::InvalidResponse { .. }
| LlmError::SessionRenewalFailed { .. }
// ModelNotAvailable is retryable: the next provider may offer a different model.
| LlmError::ModelNotAvailable { .. }
| LlmError::Http(_)
| LlmError::Io(_)
)
}
use crate::llm::retry::is_retryable;
/// Configuration for per-provider cooldown behavior.
///
@@ -139,6 +118,12 @@ pub struct FailoverProvider {
epoch: Instant,
/// Cooldown configuration.
cooldown_config: CooldownConfig,
/// Request-scoped provider index keyed by Tokio task ID.
///
/// This allows `effective_model_name()` to report the provider that handled
/// the *current* request, even when other concurrent requests update
/// `last_used`.
provider_for_task: Mutex<HashMap<tokio::task::Id, usize>>,
}
impl FailoverProvider {
@@ -171,6 +156,7 @@ impl FailoverProvider {
cooldowns,
epoch: Instant::now(),
cooldown_config,
provider_for_task: Mutex::new(HashMap::new()),
})
}
@@ -182,12 +168,36 @@ impl FailoverProvider {
self.epoch.elapsed().as_nanos() as u64
}
/// Current Tokio task ID if available.
fn current_task_id() -> Option<tokio::task::Id> {
tokio::task::try_id()
}
/// Bind the selected provider index to the current task.
fn bind_provider_to_current_task(&self, provider_idx: usize) {
let Some(task_id) = Self::current_task_id() else {
return;
};
if let Ok(mut guard) = self.provider_for_task.lock() {
guard.insert(task_id, provider_idx);
}
}
/// Take and remove the provider index bound to the current task.
fn take_bound_provider_for_current_task(&self) -> Option<usize> {
let task_id = Self::current_task_id()?;
self.provider_for_task
.lock()
.ok()
.and_then(|mut guard| guard.remove(&task_id))
}
/// Try each provider in sequence until one succeeds or all fail.
///
/// Providers in cooldown are skipped unless *all* providers are in
/// cooldown, in which case the one with the oldest cooldown timestamp
/// (most likely to have recovered) is tried.
async fn try_providers<T, F, Fut>(&self, mut call: F) -> Result<T, LlmError>
async fn try_providers<T, F, Fut>(&self, mut call: F) -> Result<(usize, T), LlmError>
where
F: FnMut(Arc<dyn LlmProvider>) -> Fut,
Fut: Future<Output = Result<T, LlmError>>,
@@ -236,7 +246,7 @@ impl FailoverProvider {
Ok(response) => {
self.last_used.store(i, Ordering::Relaxed);
self.cooldowns[i].reset();
return Ok(response);
return Ok((i, response));
}
Err(err) => {
if !is_retryable(&err) {
@@ -287,22 +297,28 @@ impl LlmProvider for FailoverProvider {
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
self.try_providers(|provider| {
let req = request.clone();
async move { provider.complete(req).await }
})
.await
let (provider_idx, response) = self
.try_providers(|provider| {
let req = request.clone();
async move { provider.complete(req).await }
})
.await?;
self.bind_provider_to_current_task(provider_idx);
Ok(response)
}
async fn complete_with_tools(
&self,
request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
self.try_providers(|provider| {
let req = request.clone();
async move { provider.complete_with_tools(req).await }
})
.await
let (provider_idx, response) = self
.try_providers(|provider| {
let req = request.clone();
async move { provider.complete_with_tools(req).await }
})
.await?;
self.bind_provider_to_current_task(provider_idx);
Ok(response)
}
fn active_model_name(&self) -> String {
@@ -336,6 +352,34 @@ impl LlmProvider for FailoverProvider {
all_models.dedup();
Ok(all_models)
}
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
self.providers[self.last_used.load(Ordering::Relaxed)]
.model_metadata()
.await
}
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.providers[self.last_used.load(Ordering::Relaxed)]
.seed_response_chain(thread_id, response_id);
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.providers[self.last_used.load(Ordering::Relaxed)].get_response_chain_id(thread_id)
}
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
self.providers[self.last_used.load(Ordering::Relaxed)]
.calculate_cost(input_tokens, output_tokens)
}
fn effective_model_name(&self, requested_model: Option<&str>) -> String {
if let Some(provider_idx) = self.take_bound_provider_for_current_task() {
return self.providers[provider_idx].effective_model_name(requested_model);
}
self.providers[self.last_used.load(Ordering::Relaxed)].effective_model_name(requested_model)
}
}
#[cfg(test)]
@@ -610,6 +654,49 @@ mod tests {
assert_eq!(failover.cost_per_token(), (fallback_cost, fallback_cost));
}
// Test: model reporting is request-scoped under concurrent requests.
#[tokio::test]
async fn effective_model_name_is_request_scoped_under_concurrency() {
let config = CooldownConfig {
cooldown_duration: Duration::from_secs(60),
failure_threshold: 3,
};
let primary = Arc::new(MultiCallMockProvider::fail_then_ok("primary", 1));
let fallback = Arc::new(MultiCallMockProvider::always_ok("fallback"));
let failover =
Arc::new(FailoverProvider::with_cooldown(vec![primary, fallback], config).unwrap());
let (first_done_tx, first_done_rx) = tokio::sync::oneshot::channel::<()>();
let (second_done_tx, second_done_rx) = tokio::sync::oneshot::channel::<()>();
let failover_a = Arc::clone(&failover);
let task_a = tokio::spawn(async move {
// First request: primary fails once, fallback serves.
let _ = failover_a.complete(make_request()).await.unwrap();
let _ = first_done_tx.send(());
// Wait until the second request finishes and updates global state.
let _ = second_done_rx.await;
failover_a.effective_model_name(None)
});
let failover_b = Arc::clone(&failover);
let task_b = tokio::spawn(async move {
let _ = first_done_rx.await;
// Second request: primary now succeeds.
let _ = failover_b.complete(make_request()).await.unwrap();
let model = failover_b.effective_model_name(None);
let _ = second_done_tx.send(());
model
});
let model_b = task_b.await.unwrap();
let model_a = task_a.await.unwrap();
assert_eq!(model_a, "fallback");
assert_eq!(model_b, "primary");
}
// Test: list_models aggregates from all providers.
#[tokio::test]
async fn list_models_aggregates_all() {
@@ -1021,10 +1108,6 @@ mod tests {
std::io::ErrorKind::ConnectionReset,
"reset"
))));
assert!(is_retryable(&LlmError::ModelNotAvailable {
provider: "p".into(),
model: "m".into(),
}));
// Non-retryable
assert!(!is_retryable(&LlmError::AuthFailed {
@@ -1037,6 +1120,10 @@ mod tests {
used: 100_000,
limit: 50_000,
}));
assert!(!is_retryable(&LlmError::ModelNotAvailable {
provider: "p".into(),
model: "m".into(),
}));
}
// Test: empty providers list returns error (not panic).
+59 -33
View File
@@ -15,7 +15,7 @@ mod nearai_chat;
mod provider;
mod reasoning;
pub mod response_cache;
mod retry;
pub mod retry;
mod rig_adapter;
pub mod session;
@@ -32,6 +32,7 @@ pub use reasoning::{
ToolSelection,
};
pub use response_cache::{CachedProvider, ResponseCacheConfig};
pub use retry::{RetryConfig, RetryProvider};
pub use rig_adapter::RigAdapter;
pub use session::{SessionConfig, SessionManager, create_session_manager};
@@ -74,14 +75,16 @@ pub fn create_llm_provider_with_config(
NearAiApiMode::Responses => {
tracing::info!(
model = %config.model,
"Using Responses API (chat-api) with session auth"
base_url = %config.base_url,
"Using NEAR AI Chat (Responses API, session token auth)"
);
Ok(Arc::new(NearAiProvider::new(config.clone(), session)))
Ok(Arc::new(NearAiProvider::new(config.clone(), session)?))
}
NearAiApiMode::ChatCompletions => {
tracing::info!(
model = %config.model,
"Using Chat Completions API (cloud-api) with API key auth"
base_url = %config.base_url,
"Using NEAR AI Cloud (Chat Completions API, API key auth)"
);
Ok(Arc::new(NearAiChatProvider::new(config.clone())?))
}
@@ -99,15 +102,30 @@ fn create_openai_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, Ll
// (Responses API). The Responses API path in rig-core panics when tool results
// are sent back because ironclaw doesn't thread `call_id` through its ToolCall
// type. The Chat Completions API works correctly with the existing code.
let client: openai::CompletionsClient = openai::Client::new(oai.api_key.expose_secret())
.map_err(|e| LlmError::RequestFailed {
provider: "openai".to_string(),
reason: format!("Failed to create OpenAI client: {}", e),
})?
.completions_api();
let client: openai::CompletionsClient = if let Some(ref base_url) = oai.base_url {
tracing::info!(
"Using OpenAI direct API (chat completions, model: {}, base_url: {})",
oai.model,
base_url,
);
openai::Client::builder()
.base_url(base_url)
.api_key(oai.api_key.expose_secret())
.build()
} else {
tracing::info!(
"Using OpenAI direct API (chat completions, model: {}, base_url: default)",
oai.model,
);
openai::Client::new(oai.api_key.expose_secret())
}
.map_err(|e| LlmError::RequestFailed {
provider: "openai".to_string(),
reason: format!("Failed to create OpenAI client: {}", e),
})?
.completions_api();
let model = client.completion_model(&oai.model);
tracing::info!("Using OpenAI direct API (model: {})", oai.model);
Ok(Arc::new(RigAdapter::new(model, &oai.model)))
}
@@ -121,16 +139,25 @@ fn create_anthropic_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>,
use rig::providers::anthropic;
let client: anthropic::Client =
anthropic::Client::new(anth.api_key.expose_secret()).map_err(|e| {
LlmError::RequestFailed {
provider: "anthropic".to_string(),
reason: format!("Failed to create Anthropic client: {}", e),
}
})?;
let client: anthropic::Client = if let Some(ref base_url) = anth.base_url {
anthropic::Client::builder()
.api_key(anth.api_key.expose_secret())
.base_url(base_url)
.build()
} else {
anthropic::Client::new(anth.api_key.expose_secret())
}
.map_err(|e| LlmError::RequestFailed {
provider: "anthropic".to_string(),
reason: format!("Failed to create Anthropic client: {}", e),
})?;
let model = client.completion_model(&anth.model);
tracing::info!("Using Anthropic direct API (model: {})", anth.model);
tracing::info!(
"Using Anthropic direct API (model: {}, base_url: {})",
anth.model,
anth.base_url.as_deref().unwrap_or("default"),
);
Ok(Arc::new(RigAdapter::new(model, &anth.model)))
}
@@ -199,26 +226,25 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmPr
use rig::providers::openai;
let api_key = compat
.api_key
.as_ref()
.map(|k| k.expose_secret().to_string())
.unwrap_or_else(|| "no-key".to_string());
let client: openai::Client = openai::Client::builder()
let client: openai::CompletionsClient = openai::Client::builder()
.base_url(&compat.base_url)
.api_key(api_key)
.api_key(
compat
.api_key
.as_ref()
.map(|k| k.expose_secret().to_string())
.unwrap_or_else(|| "no-key".to_string()),
)
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "openai_compatible".to_string(),
reason: format!("Failed to create OpenAI-compatible client: {}", e),
})?;
})?
.completions_api();
// OpenAI-compatible providers (e.g. OpenRouter) are most reliable on Chat Completions.
// This avoids Responses-API-specific assumptions such as required tool call IDs.
let model = client.completions_api().completion_model(&compat.model);
let model = client.completion_model(&compat.model);
tracing::info!(
"Using OpenAI-compatible endpoint via Chat Completions API (base_url: {}, model: {})",
"Using OpenAI-compatible endpoint (chat completions, base_url: {}, model: {})",
compat.base_url,
compat.model
);
@@ -252,7 +278,7 @@ pub fn create_cheap_llm_provider(
tracing::info!("Cheap LLM provider: {}", cheap_model);
match cheap_config.api_mode {
NearAiApiMode::Responses => Ok(Some(Arc::new(NearAiProvider::new(cheap_config, session)))),
NearAiApiMode::Responses => Ok(Some(Arc::new(NearAiProvider::new(cheap_config, session)?))),
NearAiApiMode::ChatCompletions => {
Ok(Some(Arc::new(NearAiChatProvider::new(cheap_config)?)))
}
+145 -153
View File
@@ -1,7 +1,9 @@
//! NEAR AI Chat API provider implementation.
//! NEAR AI Chat provider implementation (Responses API).
//!
//! This provider uses the NEAR AI chat-api which provides a unified interface
//! to multiple LLM models (OpenAI, Anthropic, etc.) with user authentication.
//! This provider uses the NEAR AI Responses API (`private.near.ai`) which
//! provides a unified interface to multiple LLM models with session token
//! authentication. Supports response chaining for efficient multi-turn
//! conversations.
use std::collections::HashMap;
use std::sync::Arc;
@@ -19,7 +21,6 @@ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse,
};
use crate::llm::retry::{is_retryable_status, retry_backoff_delay};
use crate::llm::session::SessionManager;
/// Information about an available model from NEAR AI API.
@@ -54,28 +55,34 @@ pub struct NearAiProvider {
impl NearAiProvider {
/// Create a new NEAR AI provider with a session manager.
pub fn new(config: NearAiConfig, session: Arc<SessionManager>) -> Self {
pub fn new(config: NearAiConfig, session: Arc<SessionManager>) -> Result<Self, LlmError> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.unwrap_or_else(|_| Client::new());
.map_err(|e| LlmError::RequestFailed {
provider: "nearai".to_string(),
reason: format!("Failed to build HTTP client: {}", e),
})?;
let active_model = std::sync::RwLock::new(config.model.clone());
Self {
Ok(Self {
client,
config,
session,
active_model,
response_chains: std::sync::RwLock::new(HashMap::new()),
}
})
}
/// Seed a response chain for a thread (e.g. when restoring from DB).
pub fn seed_response_id(&self, thread_id: &str, response_id: String) {
let mut chains = self
.response_chains
.write()
.expect("response_chains lock poisoned");
let mut chains = match self.response_chains.write() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::warn!("response_chains lock poisoned in seed; recovering");
poisoned.into_inner()
}
};
chains.insert(
thread_id.to_string(),
ChainState {
@@ -87,19 +94,25 @@ impl NearAiProvider {
/// Get the last response ID for a thread (for persistence).
pub fn get_response_id(&self, thread_id: &str) -> Option<String> {
let chains = self
.response_chains
.read()
.expect("response_chains lock poisoned");
let chains = match self.response_chains.read() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::warn!("response_chains lock poisoned in get; recovering");
poisoned.into_inner()
}
};
chains.get(thread_id).map(|c| c.response_id.clone())
}
/// Store a response chain state after a successful call.
fn store_chain(&self, thread_id: &str, response_id: String, input_count: usize) {
let mut chains = self
.response_chains
.write()
.expect("response_chains lock poisoned");
let mut chains = match self.response_chains.write() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::warn!("response_chains lock poisoned in store; recovering");
poisoned.into_inner()
}
};
chains.insert(
thread_id.to_string(),
ChainState {
@@ -111,10 +124,13 @@ impl NearAiProvider {
/// Clear the chain for a thread (on error / fallback).
fn clear_chain(&self, thread_id: &str) {
let mut chains = self
.response_chains
.write()
.expect("response_chains lock poisoned");
let mut chains = match self.response_chains.write() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::warn!("response_chains lock poisoned in clear; recovering");
poisoned.into_inner()
}
};
chains.remove(thread_id);
}
@@ -160,7 +176,10 @@ impl NearAiProvider {
})?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
provider: "nearai".to_string(),
reason: format!("Failed to read response body: {}", e),
})?;
if !status.is_success() {
if status.as_u16() == 401 {
@@ -283,139 +302,95 @@ impl NearAiProvider {
}
}
/// Inner request implementation with retry logic for transient errors.
/// Inner request implementation (single attempt).
///
/// Retries on HTTP 429, 500, 502, 503, 504 with exponential backoff.
/// Does not retry on client errors (400, 401, 403, 404) or parse errors.
/// Does not retry internally — retries are handled by the external
/// `RetryProvider` wrapper in the composition chain.
async fn send_request_inner<T: Serialize + std::fmt::Debug, R: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &T,
) -> Result<R, LlmError> {
let url = self.api_url(path);
let max_retries = self.config.max_retries;
let token = self.session.get_token().await?;
for attempt in 0..=max_retries {
let token = self.session.get_token().await?;
tracing::debug!("Sending request to NEAR AI: {}", url);
tracing::debug!("Request body: {:?}", body);
tracing::debug!(
"Sending request to NEAR AI: {} (attempt {})",
url,
attempt + 1
);
tracing::debug!("Request body: {:?}", body);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", token.expose_secret()))
.header("Content-Type", "application/json")
.json(body)
.send()
.await
.map_err(|e| {
tracing::error!("NEAR AI request failed: {}", e);
LlmError::Http(e)
})?;
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", token.expose_secret()))
.header("Content-Type", "application/json")
.json(body)
.send()
.await;
let status = response.status();
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
provider: "nearai".to_string(),
reason: format!("Failed to read response body: {}", e),
})?;
let response = match response {
Ok(r) => r,
Err(e) => {
tracing::error!("NEAR AI request failed: {}", e);
// Network errors (timeout, connection refused) are transient
if attempt < max_retries {
let delay = retry_backoff_delay(attempt);
tracing::warn!(
"NEAR AI request error (attempt {}/{}), retrying in {:?}: {}",
attempt + 1,
max_retries + 1,
delay,
e,
);
tokio::time::sleep(delay).await;
continue;
}
return Err(e.into());
}
};
tracing::debug!("NEAR AI response status: {}", status);
tracing::debug!("NEAR AI response body: {}", response_text);
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
if !status.is_success() {
let status_code = status.as_u16();
tracing::debug!("NEAR AI response status: {}", status);
tracing::debug!("NEAR AI response body: {}", response_text);
// Check for session expiration (401 with specific message patterns)
if status_code == 401 {
let lower = response_text.to_lowercase();
let is_session_expired = lower.contains("session")
&& (lower.contains("expired") || lower.contains("invalid"));
if !status.is_success() {
let status_code = status.as_u16();
// Check for session expiration (401 with specific message patterns)
if status_code == 401 {
let lower = response_text.to_lowercase();
let is_session_expired = lower.contains("session")
&& (lower.contains("expired") || lower.contains("invalid"));
if is_session_expired {
return Err(LlmError::SessionExpired {
provider: "nearai".to_string(),
});
}
// Generic 401 -- not retryable
return Err(LlmError::AuthFailed {
if is_session_expired {
return Err(LlmError::SessionExpired {
provider: "nearai".to_string(),
});
}
// Check if this is a transient error worth retrying
if is_retryable_status(status_code) && attempt < max_retries {
let delay = retry_backoff_delay(attempt);
tracing::warn!(
"NEAR AI returned HTTP {} (attempt {}/{}), retrying in {:?}",
status_code,
attempt + 1,
max_retries + 1,
delay,
);
tokio::time::sleep(delay).await;
continue;
}
// Non-retryable error or exhausted retries
if let Ok(error) = serde_json::from_str::<NearAiErrorResponse>(&response_text) {
if status_code == 429 {
return Err(LlmError::RateLimited {
provider: "nearai".to_string(),
retry_after: None,
});
}
return Err(LlmError::RequestFailed {
provider: "nearai".to_string(),
reason: error.error,
});
}
return Err(LlmError::RequestFailed {
return Err(LlmError::AuthFailed {
provider: "nearai".to_string(),
reason: format!("HTTP {}: {}", status, response_text),
});
}
// Success -- parse the response
return match serde_json::from_str::<R>(&response_text) {
Ok(parsed) => Ok(parsed),
Err(e) => {
tracing::debug!("Response is not expected JSON format: {}", e);
tracing::debug!("Will try alternative parsing in caller");
Err(LlmError::InvalidResponse {
provider: "nearai".to_string(),
reason: format!("Parse error: {}. Raw: {}", e, response_text),
})
}
};
if status_code == 429 {
return Err(LlmError::RateLimited {
provider: "nearai".to_string(),
retry_after: None,
});
}
if let Ok(error) = serde_json::from_str::<NearAiErrorResponse>(&response_text) {
return Err(LlmError::RequestFailed {
provider: "nearai".to_string(),
reason: error.error,
});
}
return Err(LlmError::RequestFailed {
provider: "nearai".to_string(),
reason: format!("HTTP {}: {}", status, response_text),
});
}
// This is unreachable because the loop always returns, but the compiler
// cannot prove that. Return a generic error as a safety net.
Err(LlmError::RequestFailed {
provider: "nearai".to_string(),
reason: "retry loop exited unexpectedly".to_string(),
})
// Success -- parse the response
match serde_json::from_str::<R>(&response_text) {
Ok(parsed) => Ok(parsed),
Err(e) => {
tracing::debug!("Response is not expected JSON format: {}", e);
tracing::debug!("Will try alternative parsing in caller");
Err(LlmError::InvalidResponse {
provider: "nearai".to_string(),
reason: format!("Parse error: {}. Raw: {}", e, response_text),
})
}
}
}
}
@@ -462,11 +437,14 @@ fn split_messages(
#[async_trait]
impl LlmProvider for NearAiProvider {
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let model = req.model.unwrap_or_else(|| self.active_model_name());
let thread_id = req.metadata.get("thread_id").cloned();
let (instructions, input) = split_messages(req.messages, false);
let mut messages = req.messages;
crate::llm::provider::sanitize_tool_messages(&mut messages);
let (instructions, input) = split_messages(messages, false);
let request = NearAiRequest {
model: self.active_model_name(),
model,
instructions,
input,
previous_response_id: None,
@@ -579,14 +557,22 @@ impl LlmProvider for NearAiProvider {
&self,
req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let model = req.model.unwrap_or_else(|| self.active_model_name());
let thread_id = req.metadata.get("thread_id").cloned();
let mut messages = req.messages;
crate::llm::provider::sanitize_tool_messages(&mut messages);
// Look up chaining state for this thread
let chain_state = thread_id.as_ref().and_then(|tid| {
let chains = self
.response_chains
.read()
.expect("response_chains lock poisoned");
let chains = match self.response_chains.read() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::warn!(
"response_chains lock poisoned in complete_with_tools; recovering"
);
poisoned.into_inner()
}
};
chains
.get(tid)
.map(|c| (c.response_id.clone(), c.input_count))
@@ -599,7 +585,7 @@ impl LlmProvider for NearAiProvider {
// When chaining, only send new messages (the delta since last call).
// Tool results are converted to function_call_output items.
let (instructions, all_input) = split_messages(req.messages, chaining);
let (instructions, all_input) = split_messages(messages, chaining);
let input = if chaining && all_input.len() > prev_input_count {
all_input[prev_input_count..].to_vec()
} else {
@@ -619,7 +605,7 @@ impl LlmProvider for NearAiProvider {
.collect();
let request = NearAiRequest {
model: self.active_model_name(),
model: model.clone(),
instructions: if chaining { None } else { instructions.clone() },
input,
previous_response_id: previous_response_id.clone(),
@@ -660,7 +646,7 @@ impl LlmProvider for NearAiProvider {
false,
);
let retry_request = NearAiRequest {
model: self.active_model_name(),
model,
instructions: instructions_full,
input: input_full,
previous_response_id: None,
@@ -804,18 +790,25 @@ impl LlmProvider for NearAiProvider {
}
fn active_model_name(&self) -> String {
self.active_model
.read()
.expect("active_model lock poisoned")
.clone()
match self.active_model.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => {
tracing::warn!("active_model lock poisoned while reading; continuing");
poisoned.into_inner().clone()
}
}
}
fn set_model(&self, model: &str) -> Result<(), LlmError> {
let mut guard = self
.active_model
.write()
.expect("active_model lock poisoned");
*guard = model.to_string();
match self.active_model.write() {
Ok(mut guard) => {
*guard = model.to_string();
}
Err(poisoned) => {
tracing::warn!("active_model lock poisoned while writing; continuing");
*poisoned.into_inner() = model.to_string();
}
}
Ok(())
}
@@ -950,7 +943,6 @@ struct NearAiTool {
/// Primary response format (output array style)
#[derive(Debug, Deserialize)]
struct NearAiResponse {
#[allow(dead_code)]
id: String,
output: Vec<NearAiOutputItem>,
usage: NearAiUsage,
+207 -132
View File
@@ -1,7 +1,8 @@
//! NEAR AI Chat Completions API provider implementation.
//! NEAR AI Cloud provider implementation (Chat Completions API).
//!
//! This provider uses the standard OpenAI-compatible chat completions API
//! with API key authentication (for cloud-api).
//! This provider uses the NEAR AI Cloud API (`cloud-api.near.ai`) which
//! exposes an OpenAI-compatible chat completions endpoint with API key
//! authentication.
use async_trait::async_trait;
use reqwest::Client;
@@ -16,18 +17,29 @@ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
};
use crate::llm::retry::{is_retryable_status, retry_backoff_delay};
/// NEAR AI Chat Completions API provider.
/// NEAR AI Cloud provider (Chat Completions API, API key auth).
pub struct NearAiChatProvider {
client: Client,
config: NearAiConfig,
active_model: std::sync::RwLock<String>,
flatten_tool_messages: bool,
}
impl NearAiChatProvider {
/// Create a new NEAR AI chat completions provider with API key auth.
/// Create a new NEAR AI Cloud provider with API key auth.
///
/// By default this enables tool-message flattening for compatibility with
/// providers that reject `role: "tool"` messages.
pub fn new(config: NearAiConfig) -> Result<Self, LlmError> {
Self::new_with_flatten(config, true)
}
/// Create a chat completions provider with configurable tool-message flattening.
pub fn new_with_flatten(
config: NearAiConfig,
flatten_tool_messages: bool,
) -> Result<Self, LlmError> {
if config.api_key.is_none() {
return Err(LlmError::AuthFailed {
provider: "nearai_chat".to_string(),
@@ -37,22 +49,29 @@ impl NearAiChatProvider {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.unwrap_or_else(|_| Client::new());
.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("Failed to build HTTP client: {}", e),
})?;
let active_model = std::sync::RwLock::new(config.model.clone());
Ok(Self {
client,
config,
active_model,
flatten_tool_messages,
})
}
fn api_url(&self, path: &str) -> String {
format!(
"{}/v1/{}",
self.config.base_url,
path.trim_start_matches('/')
)
let base = self.config.base_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
if base.ends_with("/v1") {
format!("{}/{}", base, path)
} else {
format!("{}/v1/{}", base, path)
}
}
fn api_key(&self) -> String {
@@ -63,116 +82,75 @@ impl NearAiChatProvider {
.unwrap_or_default()
}
/// Send a request to the chat completions API with retry on transient errors.
/// Send a single request to the chat completions API.
///
/// Retries on HTTP 429, 500, 502, 503, 504 with exponential backoff.
/// Does not retry on client errors (400, 401, 403, 404) or parse errors.
/// Does not retry internally — retries are handled by the external
/// `RetryProvider` wrapper in the composition chain.
async fn send_request<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
body: &T,
) -> Result<R, LlmError> {
let url = self.api_url("chat/completions");
let max_retries = self.config.max_retries;
for attempt in 0..=max_retries {
tracing::debug!(
"Sending request to NEAR AI Chat: {} (attempt {})",
url,
attempt + 1,
);
tracing::debug!("Sending request to NEAR AI Chat: {}", url);
if tracing::enabled!(tracing::Level::DEBUG)
&& let Ok(json) = serde_json::to_string(body)
{
tracing::debug!("NEAR AI Chat request body: {}", json);
}
if tracing::enabled!(tracing::Level::DEBUG)
&& let Ok(json) = serde_json::to_string(body)
{
tracing::debug!("NEAR AI Chat request body: {}", json);
}
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key()))
.header("Content-Type", "application/json")
.json(body)
.send()
.await;
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key()))
.header("Content-Type", "application/json")
.json(body)
.send()
.await
.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: e.to_string(),
})?;
let response = match response {
Ok(r) => r,
Err(e) => {
tracing::error!("NEAR AI Chat request failed: {}", e);
if attempt < max_retries {
let delay = retry_backoff_delay(attempt);
tracing::warn!(
"NEAR AI Chat request error (attempt {}/{}), retrying in {:?}: {}",
attempt + 1,
max_retries + 1,
delay,
e,
);
tokio::time::sleep(delay).await;
continue;
}
return Err(LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: e.to_string(),
});
}
};
let status = response.status();
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("Failed to read response body: {}", e),
})?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
tracing::debug!("NEAR AI Chat response status: {}", status);
tracing::debug!("NEAR AI Chat response body: {}", response_text);
tracing::debug!("NEAR AI Chat response status: {}", status);
tracing::debug!("NEAR AI Chat response body: {}", response_text);
if !status.is_success() {
let status_code = status.as_u16();
if !status.is_success() {
let status_code = status.as_u16();
// Auth errors are not retryable
if status_code == 401 {
return Err(LlmError::AuthFailed {
provider: "nearai_chat".to_string(),
});
}
// Transient errors: retry with backoff
if is_retryable_status(status_code) && attempt < max_retries {
let delay = retry_backoff_delay(attempt);
tracing::warn!(
"NEAR AI Chat returned HTTP {} (attempt {}/{}), retrying in {:?}",
status_code,
attempt + 1,
max_retries + 1,
delay,
);
tokio::time::sleep(delay).await;
continue;
}
// Non-retryable or exhausted retries
if status_code == 429 {
return Err(LlmError::RateLimited {
provider: "nearai_chat".to_string(),
retry_after: None,
});
}
return Err(LlmError::RequestFailed {
if status_code == 401 {
return Err(LlmError::AuthFailed {
provider: "nearai_chat".to_string(),
reason: format!("HTTP {}: {}", status, response_text),
});
}
// Success — parse the response
return serde_json::from_str(&response_text).map_err(|e| LlmError::InvalidResponse {
if status_code == 429 {
return Err(LlmError::RateLimited {
provider: "nearai_chat".to_string(),
retry_after: None,
});
}
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
return Err(LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("JSON parse error: {}. Raw: {}", e, response_text),
reason: format!("HTTP {}: {}", status, truncated),
});
}
// Safety net: unreachable because the loop always returns
Err(LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: "retry loop exited unexpectedly".to_string(),
serde_json::from_str(&response_text).map_err(|e| {
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
LlmError::InvalidResponse {
provider: "nearai_chat".to_string(),
reason: format!("JSON parse error: {}. Raw: {}", e, truncated),
}
})
}
@@ -192,12 +170,16 @@ impl NearAiChatProvider {
})?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("Failed to read response body: {}", e),
})?;
if !status.is_success() {
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
return Err(LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("HTTP {}: {}", status, response_text),
reason: format!("HTTP {}: {}", status, truncated),
});
}
@@ -227,11 +209,14 @@ struct ApiModelEntry {
#[async_trait]
impl LlmProvider for NearAiChatProvider {
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let model = req.model.unwrap_or_else(|| self.active_model_name());
let mut raw_messages = req.messages;
crate::llm::provider::sanitize_tool_messages(&mut raw_messages);
let messages: Vec<ChatCompletionMessage> =
req.messages.into_iter().map(|m| m.into()).collect();
raw_messages.into_iter().map(|m| m.into()).collect();
let request = ChatCompletionRequest {
model: self.active_model_name(),
model,
messages,
temperature: req.temperature,
max_tokens: req.max_tokens,
@@ -260,11 +245,13 @@ impl LlmProvider for NearAiChatProvider {
_ => FinishReason::Unknown,
};
let (input_tokens, output_tokens) = parse_usage(response.usage.as_ref());
Ok(CompletionResponse {
content,
finish_reason,
input_tokens: response.usage.prompt_tokens,
output_tokens: response.usage.completion_tokens,
input_tokens,
output_tokens,
response_id: None,
})
}
@@ -273,14 +260,19 @@ impl LlmProvider for NearAiChatProvider {
&self,
req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let model = req.model.unwrap_or_else(|| self.active_model_name());
let mut raw_messages = req.messages;
crate::llm::provider::sanitize_tool_messages(&mut raw_messages);
let messages: Vec<ChatCompletionMessage> =
req.messages.into_iter().map(|m| m.into()).collect();
raw_messages.into_iter().map(|m| m.into()).collect();
// NEAR AI cloud-api does not support multi-turn tool calling (rejects
// any request containing role:"tool" messages with HTTP 400). Rewrite
// tool-call / tool-result pairs into plain text so the conversation
// history is preserved without using unsupported message roles.
let messages = flatten_tool_messages(messages);
// Some OpenAI-compatible providers reject `role:"tool"` messages.
// When enabled, rewrite tool-call / tool-result pairs into plain text.
let messages = if self.flatten_tool_messages {
flatten_tool_messages(messages)
} else {
messages
};
let tools: Vec<ChatCompletionTool> = req
.tools
@@ -296,7 +288,7 @@ impl LlmProvider for NearAiChatProvider {
.collect();
let request = ChatCompletionRequest {
model: self.active_model_name(),
model,
messages,
temperature: req.temperature,
max_tokens: req.max_tokens,
@@ -347,12 +339,14 @@ impl LlmProvider for NearAiChatProvider {
}
};
let (input_tokens, output_tokens) = parse_usage(response.usage.as_ref());
Ok(ToolCompletionResponse {
content,
tool_calls,
finish_reason,
input_tokens: response.usage.prompt_tokens,
output_tokens: response.usage.completion_tokens,
input_tokens,
output_tokens,
response_id: None,
})
}
@@ -382,18 +376,25 @@ impl LlmProvider for NearAiChatProvider {
}
fn active_model_name(&self) -> String {
self.active_model
.read()
.expect("active_model lock poisoned")
.clone()
match self.active_model.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => {
tracing::warn!("active_model lock poisoned while reading; continuing");
poisoned.into_inner().clone()
}
}
}
fn set_model(&self, model: &str) -> Result<(), crate::error::LlmError> {
let mut guard = self
.active_model
.write()
.expect("active_model lock poisoned");
*guard = model.to_string();
match self.active_model.write() {
Ok(mut guard) => {
*guard = model.to_string();
}
Err(poisoned) => {
tracing::warn!("active_model lock poisoned while writing; continuing");
*poisoned.into_inner() = model.to_string();
}
}
Ok(())
}
}
@@ -543,9 +544,11 @@ struct ChatCompletionFunction {
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
#[allow(dead_code)]
id: String,
#[serde(default)]
id: Option<String>,
choices: Vec<ChatCompletionChoice>,
usage: ChatCompletionUsage,
#[serde(default)]
usage: Option<ChatCompletionUsage>,
}
#[derive(Debug, Deserialize)]
@@ -577,18 +580,90 @@ struct ChatCompletionToolCallFunction {
arguments: String,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Default)]
struct ChatCompletionUsage {
prompt_tokens: u32,
completion_tokens: u32,
#[allow(dead_code)]
total_tokens: u32,
#[serde(default)]
prompt_tokens: Option<u64>,
#[serde(default)]
completion_tokens: Option<u64>,
#[serde(default)]
total_tokens: Option<u64>,
}
fn saturate_u32(val: u64) -> u32 {
val.min(u32::MAX as u64) as u32
}
fn parse_usage(usage: Option<&ChatCompletionUsage>) -> (u32, u32) {
let Some(u) = usage else {
return (0, 0);
};
let input = u.prompt_tokens.map(saturate_u32).unwrap_or(0);
let output = u.completion_tokens.map(saturate_u32).unwrap_or_else(|| {
// Fall back to total - prompt if completion is missing.
match (u.total_tokens, u.prompt_tokens) {
(Some(total), Some(prompt)) => saturate_u32(total.saturating_sub(prompt)),
(Some(total), None) => saturate_u32(total),
_ => 0,
}
});
(input, output)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_nearai_config(base_url: &str) -> NearAiConfig {
NearAiConfig {
model: "test-model".to_string(),
base_url: base_url.to_string(),
auth_base_url: "https://private.near.ai".to_string(),
session_path: std::path::PathBuf::from("/tmp/session.json"),
api_mode: crate::config::NearAiApiMode::ChatCompletions,
api_key: Some(secrecy::SecretString::from("test-key".to_string())),
cheap_model: None,
fallback_model: None,
max_retries: 0,
circuit_breaker_threshold: None,
circuit_breaker_recovery_secs: 30,
response_cache_enabled: false,
response_cache_ttl_secs: 3600,
response_cache_max_entries: 1000,
failover_cooldown_secs: 300,
failover_cooldown_threshold: 3,
}
}
#[test]
fn test_api_url_with_base_without_v1() {
let mut cfg = test_nearai_config("http://127.0.0.1:8318");
let provider = NearAiChatProvider::new(cfg.clone()).expect("provider");
assert_eq!(
provider.api_url("chat/completions"),
"http://127.0.0.1:8318/v1/chat/completions"
);
cfg.base_url = "http://127.0.0.1:8318/".to_string();
let provider = NearAiChatProvider::new(cfg).expect("provider");
assert_eq!(
provider.api_url("/chat/completions"),
"http://127.0.0.1:8318/v1/chat/completions"
);
}
#[test]
fn test_api_url_with_base_already_v1() {
let cfg = test_nearai_config("http://127.0.0.1:8318/v1");
let provider = NearAiChatProvider::new(cfg).expect("provider");
assert_eq!(
provider.api_url("chat/completions"),
"http://127.0.0.1:8318/v1/chat/completions"
);
}
#[test]
fn test_message_conversion() {
let msg = ChatMessage::user("Hello");
+149
View File
@@ -105,6 +105,8 @@ impl ChatMessage {
#[derive(Debug, Clone)]
pub struct CompletionRequest {
pub messages: Vec<ChatMessage>,
/// Optional per-request model override.
pub model: Option<String>,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub stop_sequences: Option<Vec<String>>,
@@ -117,6 +119,7 @@ impl CompletionRequest {
pub fn new(messages: Vec<ChatMessage>) -> Self {
Self {
messages,
model: None,
max_tokens: None,
temperature: None,
stop_sequences: None,
@@ -124,6 +127,12 @@ impl CompletionRequest {
}
}
/// Set model override.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
/// Set max tokens.
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = Some(max_tokens);
@@ -188,6 +197,8 @@ pub struct ToolResult {
pub struct ToolCompletionRequest {
pub messages: Vec<ChatMessage>,
pub tools: Vec<ToolDefinition>,
/// Optional per-request model override.
pub model: Option<String>,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
/// How to handle tool use: "auto", "required", or "none".
@@ -202,6 +213,7 @@ impl ToolCompletionRequest {
Self {
messages,
tools,
model: None,
max_tokens: None,
temperature: None,
tool_choice: None,
@@ -209,6 +221,12 @@ impl ToolCompletionRequest {
}
}
/// Set model override.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
/// Set max tokens.
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = Some(max_tokens);
@@ -283,6 +301,16 @@ pub trait LlmProvider: Send + Sync {
})
}
/// Resolve which model should be reported for a given request.
///
/// Providers that ignore per-request model overrides should override this
/// and return `active_model_name()`.
fn effective_model_name(&self, requested_model: Option<&str>) -> String {
requested_model
.map(std::borrow::ToOwned::to_owned)
.unwrap_or_else(|| self.active_model_name())
}
/// Get the currently active model name.
///
/// May differ from `model_name()` if the model was switched at runtime
@@ -319,3 +347,124 @@ pub trait LlmProvider: Send + Sync {
input_cost * Decimal::from(input_tokens) + output_cost * Decimal::from(output_tokens)
}
}
/// Sanitize a message list to ensure tool_use / tool_result integrity.
///
/// LLM APIs (especially Anthropic) require every tool_result to reference a
/// tool_call_id that exists in an immediately preceding assistant message's
/// tool_calls. Orphaned tool_results cause HTTP 400 errors.
///
/// This function:
/// 1. Tracks all tool_call_ids emitted by assistant messages.
/// 2. Rewrites orphaned tool_result messages (whose tool_call_id has no
/// matching assistant tool_call) as user messages so the content is
/// preserved without violating the protocol.
///
/// Call this before sending messages to any LLM provider.
pub fn sanitize_tool_messages(messages: &mut [ChatMessage]) {
use std::collections::HashSet;
// Collect all tool_call_ids from assistant messages with tool_calls.
let mut known_ids: HashSet<String> = HashSet::new();
for msg in messages.iter() {
if msg.role == Role::Assistant
&& let Some(ref calls) = msg.tool_calls
{
for tc in calls {
known_ids.insert(tc.id.clone());
}
}
}
// Rewrite orphaned tool_result messages as user messages.
for msg in messages.iter_mut() {
if msg.role != Role::Tool {
continue;
}
let is_orphaned = match &msg.tool_call_id {
Some(id) => !known_ids.contains(id),
None => true,
};
if is_orphaned {
let tool_name = msg.name.as_deref().unwrap_or("unknown");
tracing::debug!(
tool_call_id = ?msg.tool_call_id,
tool_name,
"Rewriting orphaned tool_result as user message",
);
msg.role = Role::User;
msg.content = format!("[Tool `{}` returned: {}]", tool_name, msg.content);
msg.tool_call_id = None;
msg.name = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_preserves_valid_pairs() {
let tc = ToolCall {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
};
let mut messages = vec![
ChatMessage::user("hello"),
ChatMessage::assistant_with_tool_calls(None, vec![tc]),
ChatMessage::tool_result("call_1", "echo", "result"),
];
sanitize_tool_messages(&mut messages);
assert_eq!(messages[2].role, Role::Tool);
assert_eq!(messages[2].tool_call_id, Some("call_1".to_string()));
}
#[test]
fn test_sanitize_rewrites_orphaned_tool_result() {
let mut messages = vec![
ChatMessage::user("hello"),
ChatMessage::assistant("I'll use a tool"),
ChatMessage::tool_result("call_missing", "search", "some result"),
];
sanitize_tool_messages(&mut messages);
assert_eq!(messages[2].role, Role::User);
assert!(messages[2].content.contains("[Tool `search` returned:"));
assert!(messages[2].tool_call_id.is_none());
assert!(messages[2].name.is_none());
}
#[test]
fn test_sanitize_handles_no_tool_messages() {
let mut messages = vec![
ChatMessage::system("prompt"),
ChatMessage::user("hello"),
ChatMessage::assistant("hi"),
];
let original_len = messages.len();
sanitize_tool_messages(&mut messages);
assert_eq!(messages.len(), original_len);
}
#[test]
fn test_sanitize_multiple_orphaned() {
let tc = ToolCall {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
};
let mut messages = vec![
ChatMessage::user("test"),
ChatMessage::assistant_with_tool_calls(None, vec![tc]),
ChatMessage::tool_result("call_1", "echo", "ok"),
// These are orphaned (call_2 and call_3 have no matching assistant message)
ChatMessage::tool_result("call_2", "search", "orphan 1"),
ChatMessage::tool_result("call_3", "http", "orphan 2"),
];
sanitize_tool_messages(&mut messages);
assert_eq!(messages[2].role, Role::Tool); // call_1 is valid
assert_eq!(messages[3].role, Role::User); // call_2 orphaned
assert_eq!(messages[4].role, Role::User); // call_3 orphaned
}
}
+27 -1
View File
@@ -144,7 +144,8 @@ impl LlmProvider for CachedProvider {
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let key = cache_key(self.inner.model_name(), &request);
let effective_model = self.inner.effective_model_name(request.model.as_deref());
let key = cache_key(&effective_model, &request);
let now = Instant::now();
// Check cache
@@ -216,6 +217,10 @@ impl LlmProvider for CachedProvider {
self.inner.model_metadata().await
}
fn effective_model_name(&self, requested_model: Option<&str>) -> String {
self.inner.effective_model_name(requested_model)
}
fn active_model_name(&self) -> String {
self.inner.active_model_name()
}
@@ -242,6 +247,7 @@ mod tests {
fn simple_request() -> CompletionRequest {
CompletionRequest {
messages: vec![ChatMessage::user("hello")],
model: None,
max_tokens: None,
temperature: None,
stop_sequences: None,
@@ -252,6 +258,7 @@ mod tests {
fn different_request() -> CompletionRequest {
CompletionRequest {
messages: vec![ChatMessage::user("goodbye")],
model: None,
max_tokens: None,
temperature: None,
stop_sequences: None,
@@ -378,6 +385,7 @@ mod tests {
// Add a third: should evict the oldest
let third = CompletionRequest {
messages: vec![ChatMessage::user("third")],
model: None,
max_tokens: None,
temperature: None,
stop_sequences: None,
@@ -396,6 +404,7 @@ mod tests {
let req = ToolCompletionRequest {
messages: vec![ChatMessage::user("use tool")],
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
tool_choice: None,
@@ -444,6 +453,23 @@ mod tests {
assert!(cached.is_empty().await);
}
#[tokio::test]
async fn model_override_gets_distinct_cache_entries() {
let stub = Arc::new(StubLlm::new("cached response"));
let cached = CachedProvider::new(stub.clone(), ResponseCacheConfig::default());
let mut req_a = simple_request();
req_a.model = Some("model-a".to_string());
let mut req_b = simple_request();
req_b.model = Some("model-b".to_string());
cached.complete(req_a).await.unwrap();
cached.complete(req_b).await.unwrap();
assert_eq!(stub.calls(), 2);
assert_eq!(cached.len().await, 2);
}
#[test]
fn default_config_is_reasonable() {
let cfg = ResponseCacheConfig::default();
+334 -24
View File
@@ -1,15 +1,50 @@
//! Shared retry helpers for LLM providers.
//! Shared retry helpers and composable `RetryProvider` decorator for LLM providers.
//!
//! Provides exponential backoff with jitter and retryable status classification
//! used by both `NearAiProvider` and `NearAiChatProvider`.
//! Provides:
//! - `is_retryable()` — `LlmError`-level retryability classification (shared with `failover.rs`)
//! - `retry_backoff_delay()` — exponential backoff with jitter
//! - `RetryProvider` — decorator that wraps any `LlmProvider` with automatic retries
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use rand::Rng;
use rust_decimal::Decimal;
/// Returns `true` if the HTTP status code is transient and worth retrying.
pub(crate) fn is_retryable_status(status: u16) -> bool {
matches!(status, 429 | 500 | 502 | 503 | 504)
use crate::error::LlmError;
use crate::llm::provider::{
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest,
ToolCompletionResponse,
};
/// Returns `true` if the `LlmError` is transient and the request should be retried.
///
/// Used by `RetryProvider` (retry the same provider) and `FailoverProvider`
/// (try the next provider). The question is: "could this exact same request
/// succeed if we try again?"
///
/// Retryable: `RequestFailed`, `RateLimited`, `InvalidResponse`,
/// `SessionRenewalFailed`, `Http`, `Io`.
///
/// Non-retryable: `AuthFailed`, `SessionExpired`, `ContextLengthExceeded`,
/// `ModelNotAvailable`, `Json`.
/// - `SessionExpired` — handled by session renewal layer, not by retry
/// - `ModelNotAvailable` — the model won't appear between attempts
/// - `Json` — a serde parse bug, not a transient failure
///
/// See also `circuit_breaker::is_transient()` which answers a different
/// question: "does this error indicate the backend is degraded?"
pub(crate) fn is_retryable(err: &LlmError) -> bool {
matches!(
err,
LlmError::RequestFailed { .. }
| LlmError::RateLimited { .. }
| LlmError::InvalidResponse { .. }
| LlmError::SessionRenewalFailed { .. }
| LlmError::Http(_)
| LlmError::Io(_)
)
}
/// Calculate exponential backoff delay with random jitter.
@@ -31,31 +66,183 @@ pub(crate) fn retry_backoff_delay(attempt: u32) -> Duration {
Duration::from_millis(delay_ms)
}
/// Configuration for the retry decorator.
#[derive(Debug, Clone)]
pub struct RetryConfig {
/// Maximum number of retry attempts (not counting the initial attempt).
/// Default: 3.
pub max_retries: u32,
}
impl Default for RetryConfig {
fn default() -> Self {
Self { max_retries: 3 }
}
}
/// Composable decorator that wraps any `LlmProvider` with automatic retries.
///
/// On transient errors, sleeps using exponential backoff and retries.
/// On non-transient errors (`AuthFailed`, `ContextLengthExceeded`, `SessionExpired`),
/// returns immediately.
///
/// Special handling for `RateLimited { retry_after }`: uses the provider-suggested
/// duration if available, otherwise falls back to standard backoff.
pub struct RetryProvider {
inner: Arc<dyn LlmProvider>,
config: RetryConfig,
}
impl RetryProvider {
pub fn new(inner: Arc<dyn LlmProvider>, config: RetryConfig) -> Self {
Self { inner, config }
}
}
#[async_trait]
impl LlmProvider for RetryProvider {
fn model_name(&self) -> &str {
self.inner.model_name()
}
fn cost_per_token(&self) -> (Decimal, Decimal) {
self.inner.cost_per_token()
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let mut last_error: Option<LlmError> = None;
for attempt in 0..=self.config.max_retries {
let req = request.clone();
match self.inner.complete(req).await {
Ok(resp) => return Ok(resp),
Err(err) => {
if !is_retryable(&err) || attempt == self.config.max_retries {
return Err(err);
}
let delay = match &err {
LlmError::RateLimited {
retry_after: Some(duration),
..
} => *duration,
_ => retry_backoff_delay(attempt),
};
tracing::warn!(
provider = %self.inner.model_name(),
attempt = attempt + 1,
max_retries = self.config.max_retries,
delay_ms = delay.as_millis() as u64,
error = %err,
"Retrying after transient error"
);
last_error = Some(err);
tokio::time::sleep(delay).await;
}
}
}
Err(last_error.unwrap_or_else(|| LlmError::RequestFailed {
provider: self.inner.model_name().to_string(),
reason: "retry loop exited unexpectedly".to_string(),
}))
}
async fn complete_with_tools(
&self,
request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let mut last_error: Option<LlmError> = None;
for attempt in 0..=self.config.max_retries {
let req = request.clone();
match self.inner.complete_with_tools(req).await {
Ok(resp) => return Ok(resp),
Err(err) => {
if !is_retryable(&err) || attempt == self.config.max_retries {
return Err(err);
}
let delay = match &err {
LlmError::RateLimited {
retry_after: Some(duration),
..
} => *duration,
_ => retry_backoff_delay(attempt),
};
tracing::warn!(
provider = %self.inner.model_name(),
attempt = attempt + 1,
max_retries = self.config.max_retries,
delay_ms = delay.as_millis() as u64,
error = %err,
"Retrying after transient error (tools)"
);
last_error = Some(err);
tokio::time::sleep(delay).await;
}
}
}
Err(last_error.unwrap_or_else(|| LlmError::RequestFailed {
provider: self.inner.model_name().to_string(),
reason: "retry loop exited unexpectedly".to_string(),
}))
}
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
self.inner.list_models().await
}
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
self.inner.model_metadata().await
}
fn active_model_name(&self) -> String {
self.inner.active_model_name()
}
fn set_model(&self, model: &str) -> Result<(), LlmError> {
self.inner.set_model(model)
}
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.inner.seed_response_chain(thread_id, response_id)
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.inner.get_response_chain_id(thread_id)
}
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
self.inner.calculate_cost(input_tokens, output_tokens)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_retryable_status() {
// Transient errors should be retryable
assert!(is_retryable_status(429));
assert!(is_retryable_status(500));
assert!(is_retryable_status(502));
assert!(is_retryable_status(503));
assert!(is_retryable_status(504));
use crate::testing::StubLlm;
// Client errors should not be retryable
assert!(!is_retryable_status(400));
assert!(!is_retryable_status(401));
assert!(!is_retryable_status(403));
assert!(!is_retryable_status(404));
assert!(!is_retryable_status(422));
// Success codes should not be retryable
assert!(!is_retryable_status(200));
assert!(!is_retryable_status(201));
fn make_request() -> CompletionRequest {
CompletionRequest::new(vec![crate::llm::ChatMessage::user("hello")])
}
fn make_tool_request() -> ToolCompletionRequest {
ToolCompletionRequest::new(vec![crate::llm::ChatMessage::user("hello")], vec![])
}
fn fast_config(max_retries: u32) -> RetryConfig {
RetryConfig { max_retries }
}
// -- Backoff delay tests --
#[test]
fn test_retry_backoff_delay_exponential_growth() {
// Run multiple samples to verify the range, accounting for jitter
@@ -93,4 +280,127 @@ mod tests {
let delay = retry_backoff_delay(30);
assert!(delay.as_millis() >= 100);
}
// -- is_retryable() classification tests --
#[test]
fn test_is_retryable_classification() {
// Retryable
assert!(is_retryable(&LlmError::RequestFailed {
provider: "p".into(),
reason: "err".into(),
}));
assert!(is_retryable(&LlmError::RateLimited {
provider: "p".into(),
retry_after: None,
}));
assert!(is_retryable(&LlmError::InvalidResponse {
provider: "p".into(),
reason: "bad".into(),
}));
assert!(is_retryable(&LlmError::SessionRenewalFailed {
provider: "p".into(),
reason: "timeout".into(),
}));
assert!(is_retryable(&LlmError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"reset"
))));
// NOT retryable
assert!(!is_retryable(&LlmError::AuthFailed {
provider: "p".into(),
}));
assert!(!is_retryable(&LlmError::SessionExpired {
provider: "p".into(),
}));
assert!(!is_retryable(&LlmError::ContextLengthExceeded {
used: 100_000,
limit: 50_000,
}));
assert!(!is_retryable(&LlmError::ModelNotAvailable {
provider: "p".into(),
model: "m".into(),
}));
}
// -- RetryProvider tests --
#[tokio::test]
async fn success_on_first_attempt() {
let stub = Arc::new(StubLlm::new("ok").with_model_name("test"));
let retry = RetryProvider::new(stub.clone(), fast_config(3));
let resp = retry.complete(make_request()).await;
assert!(resp.is_ok());
assert_eq!(resp.unwrap().content, "ok");
assert_eq!(stub.calls(), 1);
}
#[tokio::test]
async fn retries_transient_errors_then_succeeds() {
// StubLlm starts failing, then we flip it to succeed.
// With max_retries=2, it will try 3 times total.
let stub = Arc::new(StubLlm::failing("test"));
let retry = RetryProvider::new(stub.clone(), fast_config(2));
// Spawn a task that flips the stub to succeed after a short delay
let stub_clone = stub.clone();
tokio::spawn(async move {
// Wait for at least 1 retry attempt (backoff is ~1s, so 1.5s should be enough)
tokio::time::sleep(Duration::from_millis(1500)).await;
stub_clone.set_failing(false);
});
let resp = retry.complete(make_request()).await;
assert!(resp.is_ok());
// Should have called at least twice (first fail, then succeed after flip)
assert!(stub.calls() >= 2);
}
#[tokio::test]
async fn non_transient_error_fails_immediately() {
let stub = Arc::new(StubLlm::failing_non_transient("test"));
let retry = RetryProvider::new(stub.clone(), fast_config(3));
let err = retry.complete(make_request()).await.unwrap_err();
assert!(matches!(err, LlmError::ContextLengthExceeded { .. }));
// Should only be called once — no retries for non-transient errors
assert_eq!(stub.calls(), 1);
}
#[tokio::test]
async fn exhausts_retries_then_returns_error() {
let stub = Arc::new(StubLlm::failing("test"));
// max_retries=0 means only the initial attempt, no retries
let retry = RetryProvider::new(stub.clone(), fast_config(0));
let err = retry.complete(make_request()).await.unwrap_err();
assert!(matches!(err, LlmError::RequestFailed { .. }));
assert_eq!(stub.calls(), 1);
}
#[tokio::test]
async fn complete_with_tools_retries_same_as_complete() {
let stub = Arc::new(StubLlm::failing_non_transient("test"));
let retry = RetryProvider::new(stub.clone(), fast_config(3));
let err = retry
.complete_with_tools(make_tool_request())
.await
.unwrap_err();
assert!(matches!(err, LlmError::ContextLengthExceeded { .. }));
assert_eq!(stub.calls(), 1);
}
#[tokio::test]
async fn passthrough_methods_delegate_to_inner() {
let stub = Arc::new(StubLlm::new("ok").with_model_name("my-model"));
let retry = RetryProvider::new(stub, fast_config(3));
assert_eq!(retry.model_name(), "my-model");
assert_eq!(retry.active_model_name(), "my-model");
assert_eq!(retry.cost_per_token(), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(retry.calculate_cost(100, 50), Decimal::ZERO);
}
}
+97 -3
View File
@@ -18,6 +18,8 @@ use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value as JsonValue;
use std::collections::HashSet;
use crate::error::LlmError;
use crate::llm::costs;
use crate::llm::provider::{
@@ -404,7 +406,19 @@ where
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let (preamble, history) = convert_messages(&request.messages);
if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str()
{
tracing::warn!(
requested_model = requested_model,
active_model = %self.model_name,
"Per-request model override is not supported for this provider; using configured model"
);
}
let mut messages = request.messages;
crate::llm::provider::sanitize_tool_messages(&mut messages);
let (preamble, history) = convert_messages(&messages);
let rig_req = build_rig_request(
preamble,
@@ -439,7 +453,22 @@ where
&self,
request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let (preamble, history) = convert_messages(&request.messages);
if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str()
{
tracing::warn!(
requested_model = requested_model,
active_model = %self.model_name,
"Per-request model override is not supported for this provider; using configured model"
);
}
let known_tool_names: HashSet<String> =
request.tools.iter().map(|t| t.name.clone()).collect();
let mut messages = request.messages;
crate::llm::provider::sanitize_tool_messages(&mut messages);
let (preamble, history) = convert_messages(&messages);
let tools = convert_tools(&request.tools);
let tool_choice = convert_tool_choice(request.tool_choice.as_deref());
@@ -461,7 +490,20 @@ where
reason: e.to_string(),
})?;
let (text, tool_calls, finish) = extract_response(&response.choice, &response.usage);
let (text, mut tool_calls, finish) = extract_response(&response.choice, &response.usage);
// Normalize tool call names: some proxies prepend "proxy_" prefixes.
for tc in &mut tool_calls {
let normalized = normalize_tool_name(&tc.name, &known_tool_names);
if normalized != tc.name {
tracing::debug!(
original = %tc.name,
normalized = %normalized,
"Normalized tool call name from provider",
);
tc.name = normalized;
}
}
Ok(ToolCompletionResponse {
content: text,
@@ -477,6 +519,10 @@ where
self.model_name.clone()
}
fn effective_model_name(&self, _requested_model: Option<&str>) -> String {
self.active_model_name()
}
fn set_model(&self, _model: &str) -> Result<(), LlmError> {
// rig-core models are baked at construction time.
// Switching requires creating a new adapter.
@@ -489,6 +535,25 @@ where
}
}
/// Normalize a tool call name returned by an OpenAI-compatible provider.
///
/// Some proxies (e.g. VibeProxy) prepend `proxy_` to tool names.
/// If the returned name doesn't match any known tool but stripping a
/// `proxy_` prefix yields a match, use the stripped version.
fn normalize_tool_name(name: &str, known_tools: &HashSet<String>) -> String {
if known_tools.contains(name) {
return name.to_string();
}
if let Some(stripped) = name.strip_prefix("proxy_")
&& known_tools.contains(stripped)
{
return stripped.to_string();
}
name.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -777,4 +842,33 @@ mod tests {
assert_eq!(saturate_u32(u64::MAX), u32::MAX);
assert_eq!(saturate_u32(u32::MAX as u64), u32::MAX);
}
// -- normalize_tool_name tests --
#[test]
fn test_normalize_tool_name_exact_match() {
let known = HashSet::from(["echo".to_string(), "list_jobs".to_string()]);
assert_eq!(normalize_tool_name("echo", &known), "echo");
}
#[test]
fn test_normalize_tool_name_proxy_prefix_match() {
let known = HashSet::from(["echo".to_string(), "list_jobs".to_string()]);
assert_eq!(normalize_tool_name("proxy_echo", &known), "echo");
}
#[test]
fn test_normalize_tool_name_proxy_prefix_no_match_kept() {
let known = HashSet::from(["echo".to_string(), "list_jobs".to_string()]);
assert_eq!(
normalize_tool_name("proxy_unknown", &known),
"proxy_unknown"
);
}
#[test]
fn test_normalize_tool_name_unknown_passthrough() {
let known = HashSet::from(["echo".to_string()]);
assert_eq!(normalize_tool_name("other_tool", &known), "other_tool");
}
}
+120 -43
View File
@@ -217,38 +217,43 @@ impl SessionManager {
self.initiate_login().await
}
/// Start the OAuth login flow.
/// Start the login flow.
///
/// 1. Bind the fixed callback port
/// Shows the auth method menu FIRST (before binding any listener), so
/// that the API-key path can skip network binding entirely. This is
/// important for remote/headless servers where `127.0.0.1` is
/// unreachable from the user's browser.
///
/// For OAuth paths (GitHub, Google):
/// 1. Bind the callback listener
/// 2. Print the auth URL and attempt to open browser
/// 3. Wait for OAuth callback with session token
/// 4. Save and return the token
///
/// For NEAR AI Cloud API key:
/// 1. Prompt user for API key from cloud.near.ai
/// 2. Set NEARAI_API_KEY env var and save to bootstrap .env
/// 3. No session token saved (different auth model)
async fn initiate_login(&self) -> Result<(), LlmError> {
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
use crate::cli::oauth_defaults;
let listener = oauth_defaults::bind_callback_listener()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: e.to_string(),
})?;
let cb_url = oauth_defaults::callback_url();
let callback_url = format!("http://127.0.0.1:{}", OAUTH_CALLBACK_PORT);
// Show auth provider menu
// Show auth provider menu BEFORE binding the listener
println!();
println!("╔════════════════════════════════════════════════════════════════╗");
println!("║ NEAR AI Authentication ║");
println!("╠════════════════════════════════════════════════════════════════╣");
println!("║ Choose an authentication method: ║");
println!("║ ║");
println!("║ [1] GitHub ");
println!("║ [2] Google ");
println!("║ [1] GitHub (requires localhost browser access)");
println!("║ [2] Google (requires localhost browser access)");
println!("║ [3] NEAR Wallet (coming soon) ║");
println!("║ [4] NEAR AI Cloud API key ║");
println!("║ ║");
println!("╚════════════════════════════════════════════════════════════════╝");
println!();
print!("Enter choice [1-3]: ");
print!("Enter choice [1-4]: ");
// Flush stdout to ensure prompt is displayed
use std::io::Write;
@@ -263,23 +268,8 @@ impl SessionManager {
reason: format!("Failed to read input: {}", e),
})?;
let (auth_provider, auth_url) = match choice.trim() {
"1" | "" => {
let url = format!(
"{}/v1/auth/github?frontend_callback={}",
self.config.auth_base_url,
urlencoding::encode(&callback_url)
);
("github", url)
}
"2" => {
let url = format!(
"{}/v1/auth/google?frontend_callback={}",
self.config.auth_base_url,
urlencoding::encode(&callback_url)
);
("google", url)
}
match choice.trim() {
"4" => return self.api_key_login().await,
"3" => {
println!();
println!("NEAR Wallet authentication is not yet implemented.");
@@ -289,12 +279,41 @@ impl SessionManager {
reason: "NEAR Wallet auth not yet implemented".to_string(),
});
}
_ => {
"1" | "" | "2" => {} // handled below after listener bind
other => {
return Err(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Invalid choice: {}", choice.trim()),
reason: format!("Invalid choice: {}", other),
});
}
}
// OAuth paths: bind the callback listener now
let listener = oauth_defaults::bind_callback_listener()
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: e.to_string(),
})?;
let (auth_provider, auth_url) = match choice.trim() {
"2" => {
let url = format!(
"{}/v1/auth/google?frontend_callback={}",
self.config.auth_base_url,
urlencoding::encode(&cb_url)
);
("google", url)
}
_ => {
// "1" or "" (default)
let url = format!(
"{}/v1/auth/github?frontend_callback={}",
self.config.auth_base_url,
urlencoding::encode(&cb_url)
);
("github", url)
}
};
println!();
@@ -341,6 +360,63 @@ impl SessionManager {
Ok(())
}
/// NEAR AI Cloud API key entry flow.
///
/// Prompts the user to enter a NEAR AI Cloud API key from
/// cloud.near.ai. The key is set as `NEARAI_API_KEY` env var so
/// `LlmConfig::resolve()` auto-selects ChatCompletions mode, and
/// saved to `~/.ironclaw/.env` for persistence across restarts.
/// No session token is saved and no `/v1/users/me` validation is
/// performed (different auth model).
async fn api_key_login(&self) -> Result<(), LlmError> {
println!();
println!("NEAR AI Cloud API key");
println!("─────────────────────");
println!();
println!(" 1. Open https://cloud.near.ai in your browser");
println!(" 2. Sign in and navigate to API Keys");
println!(" 3. Create or copy an existing API key");
println!();
let key_secret =
crate::setup::secret_input("API key").map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("Failed to read input: {}", e),
})?;
use secrecy::ExposeSecret;
let key = key_secret.expose_secret().to_string();
if key.is_empty() {
return Err(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "API key cannot be empty".to_string(),
});
}
// Set env var so Config picks it up immediately
// (LlmConfig::resolve() auto-selects ChatCompletions mode when
// NEARAI_API_KEY is present).
//
// SAFETY: called during single-threaded interactive login flow.
#[allow(unused_unsafe)]
unsafe {
std::env::set_var("NEARAI_API_KEY", &key);
}
// Persist to ~/.ironclaw/.env so the key survives restarts
// (bootstrap layer — available before DB is connected).
// Uses upsert to avoid clobbering existing bootstrap vars.
if let Err(e) = crate::bootstrap::upsert_bootstrap_var("NEARAI_API_KEY", &key) {
tracing::warn!("Failed to save API key to bootstrap .env: {}", e);
}
println!();
crate::setup::print_success("NEAR AI Cloud API key saved.");
println!();
Ok(())
}
/// Save session data to disk and (if available) to the database.
async fn save_session(&self, token: &str, auth_provider: Option<&str>) -> Result<(), LlmError> {
let session = SessionData {
@@ -508,20 +584,21 @@ impl SessionManager {
}
}
/// Create a session manager from a config, migrating from env var if present.
/// Create a session manager from a config, loading env var if present.
///
/// When `NEARAI_SESSION_TOKEN` is set, it takes precedence over file-based
/// tokens. This supports hosting providers that inject the token via env var.
pub async fn create_session_manager(config: SessionConfig) -> Arc<SessionManager> {
let manager = SessionManager::new_async(config).await;
// Check for legacy env var and migrate if present and no file token
if !manager.has_token().await
&& let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN")
// NEARAI_SESSION_TOKEN env var always takes precedence over file-based
// tokens. Hosting providers set this env var and expect it to be used
// directly — no file persistence needed.
if let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN")
&& !token.is_empty()
{
tracing::info!("Migrating session token from NEARAI_SESSION_TOKEN env var to file");
manager.set_token(SecretString::from(token.clone())).await;
if let Err(e) = manager.save_session(&token, None).await {
tracing::warn!("Failed to save migrated session: {}", e);
}
tracing::info!("Using session token from NEARAI_SESSION_TOKEN env var");
manager.set_token(SecretString::from(token)).await;
}
Arc::new(manager)
+152 -29
View File
@@ -23,12 +23,12 @@ use ironclaw::{
config::Config,
context::ContextManager,
extensions::ExtensionManager,
hooks::HookRegistry,
hooks::{HookRegistry, bootstrap_hooks},
llm::{
CachedProvider, CircuitBreakerConfig, CircuitBreakerProvider, CooldownConfig,
FailoverProvider, LlmProvider, ResponseCacheConfig, SessionConfig,
create_cheap_llm_provider, create_llm_provider, create_llm_provider_with_config,
create_session_manager,
FailoverProvider, LlmProvider, ResponseCacheConfig, RetryConfig, RetryProvider,
SessionConfig, create_cheap_llm_provider, create_llm_provider,
create_llm_provider_with_config, create_session_manager,
},
orchestrator::{
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
@@ -42,7 +42,9 @@ use ironclaw::{
mcp::{McpClient, McpSessionManager, config::load_mcp_servers_from_db, is_authenticated},
wasm::{WasmToolLoader, WasmToolRuntime, load_dev_tools},
},
workspace::{EmbeddingProvider, NearAiEmbeddings, OpenAiEmbeddings, Workspace},
workspace::{
EmbeddingProvider, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings, Workspace,
},
};
#[cfg(feature = "libsql")]
@@ -78,6 +80,15 @@ async fn main() -> anyhow::Result<()> {
return ironclaw::cli::run_config_command(config_cmd.clone()).await;
}
Some(Command::Registry(registry_cmd)) => {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
)
.init();
return ironclaw::cli::run_registry_command(registry_cmd.clone()).await;
}
Some(Command::Mcp(mcp_cmd)) => {
// Simple logging for MCP commands
tracing_subscriber::fmt()
@@ -115,18 +126,20 @@ async fn main() -> anyhow::Result<()> {
&config.llm.nearai.base_url,
session,
)
.with_model(&config.embeddings.model, 1536),
.with_model(&config.embeddings.model, config.embeddings.dimension),
)),
"ollama" => Some(Arc::new(
ironclaw::workspace::OllamaEmbeddings::new(
&config.embeddings.ollama_base_url,
)
.with_model(&config.embeddings.model, config.embeddings.dimension),
)),
_ => {
if let Some(api_key) = config.embeddings.openai_api_key() {
let dim = match config.embeddings.model.as_str() {
"text-embedding-3-large" => 3072,
_ => 1536,
};
Some(Arc::new(ironclaw::workspace::OpenAiEmbeddings::with_model(
api_key,
&config.embeddings.model,
dim,
config.embeddings.dimension,
)))
} else {
None
@@ -137,6 +150,23 @@ async fn main() -> anyhow::Result<()> {
None
};
// Warn if libSQL backend is used with non-1536 embedding dimension.
// libSQL schema uses F32_BLOB(1536) which cannot be altered without a
// table rebuild, so non-1536 embeddings will cause storage failures.
if config.database.backend == ironclaw::config::DatabaseBackend::LibSql
&& config.embeddings.enabled
&& config.embeddings.dimension != 1536
{
tracing::warn!(
configured_dimension = config.embeddings.dimension,
"Embedding dimension {} is not 1536. The libSQL schema uses \
F32_BLOB(1536) which requires exactly 1536 dimensions. \
Embedding storage will fail. Use PostgreSQL or set \
EMBEDDING_DIMENSION=1536.",
config.embeddings.dimension
);
}
// Create a Database-trait-backed workspace for the memory command
let db: Arc<dyn ironclaw::db::Database> =
ironclaw::db::connect_from_config(&config.database)
@@ -599,6 +629,22 @@ async fn main() -> anyhow::Result<()> {
let llm = create_llm_provider(&config.llm, session.clone())?;
tracing::info!("LLM provider initialized: {}", llm.model_name());
// Wrap each provider with RetryProvider for automatic retries on transient errors.
// RetryProvider sits inside FailoverProvider so each provider in the failover chain
// gets its own retry attempts before the failover moves to the next provider.
let retry_config = RetryConfig {
max_retries: config.llm.nearai.max_retries,
};
let llm: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
tracing::info!(
max_retries = retry_config.max_retries,
"LLM retry wrapper enabled"
);
Arc::new(RetryProvider::new(llm, retry_config.clone()))
} else {
llm
};
// Wrap in failover if a fallback model is configured
let llm: Arc<dyn LlmProvider> =
if let Some(fallback_model) = config.llm.nearai.fallback_model.as_ref() {
@@ -615,6 +661,12 @@ async fn main() -> anyhow::Result<()> {
fallback = %fallback.model_name(),
"LLM failover enabled"
);
// Wrap fallback with retry too
let fallback: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
Arc::new(RetryProvider::new(fallback, retry_config.clone()))
} else {
fallback
};
let cooldown_config = CooldownConfig {
cooldown_duration: std::time::Duration::from_secs(
config.llm.nearai.failover_cooldown_secs,
@@ -685,28 +737,39 @@ async fn main() -> anyhow::Result<()> {
match config.embeddings.provider.as_str() {
"nearai" => {
tracing::info!(
"Embeddings enabled via NEAR AI (model: {})",
config.embeddings.model
"Embeddings enabled via NEAR AI (model: {}, dim: {})",
config.embeddings.model,
config.embeddings.dimension,
);
Some(Arc::new(
NearAiEmbeddings::new(&config.llm.nearai.base_url, session.clone())
.with_model(&config.embeddings.model, 1536),
.with_model(&config.embeddings.model, config.embeddings.dimension),
))
}
"ollama" => {
tracing::info!(
"Embeddings enabled via Ollama (model: {}, url: {}, dim: {})",
config.embeddings.model,
config.embeddings.ollama_base_url,
config.embeddings.dimension,
);
Some(Arc::new(
OllamaEmbeddings::new(&config.embeddings.ollama_base_url)
.with_model(&config.embeddings.model, config.embeddings.dimension),
))
}
_ => {
// Default to OpenAI for unknown providers
if let Some(api_key) = config.embeddings.openai_api_key() {
tracing::info!(
"Embeddings enabled via OpenAI (model: {})",
config.embeddings.model
"Embeddings enabled via OpenAI (model: {}, dim: {})",
config.embeddings.model,
config.embeddings.dimension,
);
Some(Arc::new(OpenAiEmbeddings::with_model(
api_key,
&config.embeddings.model,
match config.embeddings.model.as_str() {
"text-embedding-3-large" => 3072,
_ => 1536, // text-embedding-3-small and ada-002
},
config.embeddings.dimension,
)))
} else {
tracing::warn!("Embeddings configured but OPENAI_API_KEY not set");
@@ -719,6 +782,21 @@ async fn main() -> anyhow::Result<()> {
None
};
// Warn if libSQL backend is used with non-1536 embedding dimension.
if config.database.backend == ironclaw::config::DatabaseBackend::LibSql
&& config.embeddings.enabled
&& config.embeddings.dimension != 1536
{
tracing::warn!(
configured_dimension = config.embeddings.dimension,
"Embedding dimension {} is not 1536. The libSQL schema uses \
F32_BLOB(1536) which requires exactly 1536 dimensions. \
Embedding storage will fail. Use PostgreSQL or set \
EMBEDDING_DIMENSION=1536.",
config.embeddings.dimension
);
}
// Register memory tools if database is available
if let Some(ref db) = db {
let mut workspace = Workspace::new_with_db("default", Arc::clone(db));
@@ -746,6 +824,9 @@ async fn main() -> anyhow::Result<()> {
let mcp_session_manager = Arc::new(McpSessionManager::new());
// Create hook registry early so runtime extension activation can register hooks.
let hooks = Arc::new(HookRegistry::new());
// Create WASM tool runtime (sync, just builds the wasmtime engine)
let wasm_tool_runtime: Option<Arc<WasmToolRuntime>> =
if config.wasm.enabled && config.wasm.tools_dir.exists() {
@@ -763,6 +844,8 @@ async fn main() -> anyhow::Result<()> {
// Load WASM tools and MCP servers concurrently.
// Both register into the shared ToolRegistry (RwLock-based) so concurrent writes are safe.
let wasm_tools_future = async {
let mut dev_loaded_tool_names: Vec<String> = Vec::new();
if let Some(ref runtime) = wasm_tool_runtime {
let mut loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&tools));
if let Some(ref secrets) = secrets_store {
@@ -791,6 +874,7 @@ async fn main() -> anyhow::Result<()> {
// Load dev tools from build artifacts (overrides installed if newer)
match load_dev_tools(&loader, &config.wasm.tools_dir).await {
Ok(results) => {
dev_loaded_tool_names.extend(results.loaded.iter().cloned());
if !results.loaded.is_empty() {
tracing::info!(
"Loaded {} dev WASM tools from build artifacts",
@@ -803,6 +887,8 @@ async fn main() -> anyhow::Result<()> {
}
}
}
dev_loaded_tool_names
};
let mcp_servers_future = async {
@@ -908,7 +994,7 @@ async fn main() -> anyhow::Result<()> {
}
};
tokio::join!(wasm_tools_future, mcp_servers_future);
let (dev_loaded_tool_names, _) = tokio::join!(wasm_tools_future, mcp_servers_future);
// Create extension manager for in-chat discovery/install/auth/activate
let extension_manager = if let Some(ref secrets) = secrets_store {
@@ -916,6 +1002,7 @@ async fn main() -> anyhow::Result<()> {
Arc::clone(&mcp_session_manager),
Arc::clone(secrets),
Arc::clone(&tools),
Some(Arc::clone(&hooks)),
wasm_tool_runtime.clone(),
config.wasm.tools_dir.clone(),
config.channels.wasm_channels_dir.clone(),
@@ -1013,6 +1100,7 @@ async fn main() -> anyhow::Result<()> {
// Initialize channel manager
let mut channels = ChannelManager::new();
let mut channel_names: Vec<String> = Vec::new();
let mut loaded_wasm_channel_names: Vec<String> = Vec::new();
if let Some(repl) = repl_channel {
channels.add(Box::new(repl));
@@ -1045,6 +1133,7 @@ async fn main() -> anyhow::Result<()> {
for loaded in results.loaded {
let channel_name = loaded.name().to_string();
loaded_wasm_channel_names.push(channel_name.clone());
tracing::info!("Loaded WASM channel: {}", channel_name);
let secret_name = loaded.webhook_secret_name();
@@ -1213,6 +1302,13 @@ async fn main() -> anyhow::Result<()> {
let mut webhook_server = if !webhook_routes.is_empty() {
let addr =
webhook_server_addr.unwrap_or_else(|| std::net::SocketAddr::from(([0, 0, 0, 0], 8080)));
if addr.ip().is_unspecified() {
tracing::warn!(
"Webhook server is binding to {} — it will be reachable from all network interfaces. \
Set HTTP_HOST=127.0.0.1 to restrict to localhost.",
addr.ip()
);
}
let mut server = WebhookServer::new(WebhookServerConfig { addr });
for routes in webhook_routes {
server.add_routes(routes);
@@ -1263,8 +1359,27 @@ async fn main() -> anyhow::Result<()> {
// Create context manager (shared between job tools and agent)
let context_manager = Arc::new(ContextManager::new(config.agent.max_parallel_jobs));
// Create hook registry
let hooks = Arc::new(HookRegistry::new());
// Register bundled/plugin/workspace hooks.
let active_tool_names = tools.list().await;
let hook_bootstrap = bootstrap_hooks(
&hooks,
workspace.as_ref(),
&config.wasm.tools_dir,
&config.channels.wasm_channels_dir,
&active_tool_names,
&loaded_wasm_channel_names,
&dev_loaded_tool_names,
)
.await;
tracing::info!(
bundled = hook_bootstrap.bundled_hooks,
plugin = hook_bootstrap.plugin_hooks,
workspace = hook_bootstrap.workspace_hooks,
outbound_webhooks = hook_bootstrap.outbound_webhooks,
errors = hook_bootstrap.errors,
"Lifecycle hooks initialized"
);
// Create session manager (shared between agent and web gateway)
let session_manager = Arc::new(SessionManager::new().with_hooks(hooks.clone()));
@@ -1305,7 +1420,7 @@ async fn main() -> anyhow::Result<()> {
// Add web gateway channel if configured
let mut gateway_url: Option<String> = None;
if let Some(ref gw_config) = config.channels.gateway {
let mut gw = GatewayChannel::new(gw_config.clone());
let mut gw = GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&llm));
if let Some(ref ws) = workspace {
gw = gw.with_workspace(Arc::clone(ws));
}
@@ -1390,6 +1505,7 @@ async fn main() -> anyhow::Result<()> {
deps,
channels,
Some(config.heartbeat.clone()),
Some(config.hygiene.clone()),
Some(config.routines.clone()),
Some(context_manager),
Some(session_manager),
@@ -1469,14 +1585,21 @@ fn check_onboard_needed() -> Option<&'static str> {
return Some("Database not configured");
}
// The wizard writes ONBOARD_COMPLETED=true to ~/.ironclaw/.env,
// which load_ironclaw_env() loads before this function runs.
if std::env::var("ONBOARD_COMPLETED")
.map(|v| v == "true")
.unwrap_or(false)
{
return None;
}
// First run (onboarding never completed and no session).
// Reads NEARAI_API_KEY env var directly because this function runs
// before Config is loaded -- Config::from_env() may fail without a
// database URL, which is what triggers onboarding in the first place.
// Check for a NEAR AI API key or session file as a fallback
// for users who configured credentials manually (no wizard).
if std::env::var("NEARAI_API_KEY").is_err() {
let settings = ironclaw::settings::Settings::load();
let session_path = ironclaw::llm::session::default_session_path();
if !settings.onboard_completed && !session_path.exists() {
if !session_path.exists() {
return Some("First run");
}
}
+2
View File
@@ -143,6 +143,7 @@ async fn llm_complete(
) -> Result<Json<ProxyCompletionResponse>, StatusCode> {
let completion_req = CompletionRequest {
messages: req.messages,
model: req.model,
max_tokens: req.max_tokens,
temperature: req.temperature,
stop_sequences: req.stop_sequences,
@@ -170,6 +171,7 @@ async fn llm_complete_with_tools(
let tool_req = ToolCompletionRequest {
messages: req.messages,
tools: req.tools,
model: req.model,
max_tokens: req.max_tokens,
temperature: req.temperature,
tool_choice: req.tool_choice,
+580
View File
@@ -0,0 +1,580 @@
//! Registry catalog: loads manifests from disk, provides list/search/resolve operations.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::registry::manifest::{BundleDefinition, BundlesFile, ExtensionManifest, ManifestKind};
/// Error type for registry operations.
#[derive(Debug, thiserror::Error)]
pub enum RegistryError {
#[error("Registry directory not found: {0}")]
DirectoryNotFound(PathBuf),
#[error("Failed to read manifest {path}: {reason}")]
ManifestRead { path: PathBuf, reason: String },
#[error("Failed to parse manifest {path}: {reason}")]
ManifestParse { path: PathBuf, reason: String },
#[error("Extension not found: {0}")]
ExtensionNotFound(String),
#[error("'{name}' already installed at {path}. Use --force to overwrite.")]
AlreadyInstalled {
name: String,
path: std::path::PathBuf,
},
#[error("Download failed for {url}: {reason}")]
DownloadFailed { url: String, reason: String },
#[error(
"Ambiguous name '{name}': exists as both {kind_a} and {kind_b}. Use '{prefix_a}/{name}' or '{prefix_b}/{name}'."
)]
AmbiguousName {
name: String,
kind_a: &'static str,
prefix_a: &'static str,
kind_b: &'static str,
prefix_b: &'static str,
},
#[error("Bundle not found: {0}")]
BundleNotFound(String),
#[error("Failed to read bundles file: {0}")]
BundlesRead(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
/// Central catalog loaded from the `registry/` directory.
#[derive(Debug, Clone)]
pub struct RegistryCatalog {
/// All loaded manifests, keyed by "<kind>/<name>" (e.g. "tools/slack").
manifests: HashMap<String, ExtensionManifest>,
/// Bundle definitions from `_bundles.json`.
bundles: HashMap<String, BundleDefinition>,
/// Root directory of the registry.
root: PathBuf,
}
impl RegistryCatalog {
/// Load the catalog from a registry directory.
///
/// Expects the structure:
/// ```text
/// registry/
/// ├── tools/*.json
/// ├── channels/*.json
/// └── _bundles.json
/// ```
pub fn load(registry_dir: &Path) -> Result<Self, RegistryError> {
if !registry_dir.exists() {
return Err(RegistryError::DirectoryNotFound(registry_dir.to_path_buf()));
}
let mut manifests = HashMap::new();
// Load tools
let tools_dir = registry_dir.join("tools");
if tools_dir.is_dir() {
Self::load_manifests_from_dir(&tools_dir, "tools", &mut manifests)?;
}
// Load channels
let channels_dir = registry_dir.join("channels");
if channels_dir.is_dir() {
Self::load_manifests_from_dir(&channels_dir, "channels", &mut manifests)?;
}
// Load bundles
let bundles_path = registry_dir.join("_bundles.json");
let bundles = if bundles_path.is_file() {
let content = std::fs::read_to_string(&bundles_path).map_err(|e| {
RegistryError::BundlesRead(format!("{}: {}", bundles_path.display(), e))
})?;
let bundles_file: BundlesFile = serde_json::from_str(&content).map_err(|e| {
RegistryError::BundlesRead(format!("{}: {}", bundles_path.display(), e))
})?;
bundles_file.bundles
} else {
HashMap::new()
};
Ok(Self {
manifests,
bundles,
root: registry_dir.to_path_buf(),
})
}
fn load_manifests_from_dir(
dir: &Path,
kind_prefix: &str,
manifests: &mut HashMap<String, ExtensionManifest>,
) -> Result<(), RegistryError> {
let entries = std::fs::read_dir(dir).map_err(|e| RegistryError::ManifestRead {
path: dir.to_path_buf(),
reason: e.to_string(),
})?;
for entry in entries {
let entry = entry.map_err(|e| RegistryError::ManifestRead {
path: dir.to_path_buf(),
reason: e.to_string(),
})?;
let path = entry.path();
if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content =
std::fs::read_to_string(&path).map_err(|e| RegistryError::ManifestRead {
path: path.clone(),
reason: e.to_string(),
})?;
let manifest: ExtensionManifest =
serde_json::from_str(&content).map_err(|e| RegistryError::ManifestParse {
path: path.clone(),
reason: e.to_string(),
})?;
let key = format!("{}/{}", kind_prefix, manifest.name);
manifests.insert(key, manifest);
}
Ok(())
}
/// The root directory this catalog was loaded from.
pub fn root(&self) -> &Path {
&self.root
}
/// Get all manifests.
pub fn all(&self) -> Vec<&ExtensionManifest> {
let mut items: Vec<_> = self.manifests.values().collect();
items.sort_by(|a, b| a.name.cmp(&b.name));
items
}
/// List manifests, optionally filtered by kind and/or tag.
pub fn list(&self, kind: Option<ManifestKind>, tag: Option<&str>) -> Vec<&ExtensionManifest> {
let mut results: Vec<_> = self
.manifests
.values()
.filter(|m| kind.is_none_or(|k| m.kind == k))
.filter(|m| tag.is_none_or(|t| m.tags.iter().any(|mt| mt == t)))
.collect();
results.sort_by(|a, b| a.name.cmp(&b.name));
results
}
/// Get a manifest by name. Tries exact key match first ("tools/slack"),
/// then searches by bare name ("slack").
///
/// If a bare name matches both a tool and a channel, returns `None`.
/// Use a qualified key ("tools/slack" or "channels/slack") to disambiguate.
pub fn get(&self, name: &str) -> Option<&ExtensionManifest> {
// Try exact key first
if let Some(m) = self.manifests.get(name) {
return Some(m);
}
// Try with kind prefix, detecting collisions
let tool = self.manifests.get(&format!("tools/{}", name));
let channel = self.manifests.get(&format!("channels/{}", name));
match (tool, channel) {
(Some(_), Some(_)) => None, // ambiguous
(Some(m), None) => Some(m),
(None, Some(m)) => Some(m),
(None, None) => None,
}
}
/// Get a manifest by name, returning a `Result` with an explicit error for
/// ambiguous bare names.
pub fn get_strict(&self, name: &str) -> Result<&ExtensionManifest, RegistryError> {
// Try exact key first
if let Some(m) = self.manifests.get(name) {
return Ok(m);
}
let has_tool = self.manifests.contains_key(&format!("tools/{}", name));
let has_channel = self.manifests.contains_key(&format!("channels/{}", name));
match (has_tool, has_channel) {
(true, true) => Err(RegistryError::AmbiguousName {
name: name.to_string(),
kind_a: "tool",
prefix_a: "tools",
kind_b: "channel",
prefix_b: "channels",
}),
(true, false) => Ok(self.manifests.get(&format!("tools/{}", name)).unwrap()),
(false, true) => Ok(self.manifests.get(&format!("channels/{}", name)).unwrap()),
(false, false) => Err(RegistryError::ExtensionNotFound(name.to_string())),
}
}
/// Get the full key ("tools/slack" or "channels/telegram") for a manifest.
pub fn key_for(&self, name: &str) -> Option<String> {
if self.manifests.contains_key(name) {
return Some(name.to_string());
}
let has_tool = self.manifests.contains_key(&format!("tools/{}", name));
let has_channel = self.manifests.contains_key(&format!("channels/{}", name));
match (has_tool, has_channel) {
(true, true) => None, // ambiguous
(true, false) => Some(format!("tools/{}", name)),
(false, true) => Some(format!("channels/{}", name)),
(false, false) => None,
}
}
/// Search manifests by query string (matches name, display_name, description, keywords).
pub fn search(&self, query: &str) -> Vec<&ExtensionManifest> {
let query_lower = query.to_lowercase();
let tokens: Vec<&str> = query_lower.split_whitespace().collect();
let mut scored: Vec<(&ExtensionManifest, usize)> = self
.manifests
.values()
.filter_map(|m| {
let score = Self::score_manifest(m, &tokens);
if score > 0 { Some((m, score)) } else { None }
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.name.cmp(&b.0.name)));
scored.into_iter().map(|(m, _)| m).collect()
}
fn score_manifest(manifest: &ExtensionManifest, tokens: &[&str]) -> usize {
let mut score = 0;
let name_lower = manifest.name.to_lowercase();
let display_lower = manifest.display_name.to_lowercase();
let desc_lower = manifest.description.to_lowercase();
for token in tokens {
if name_lower == *token {
score += 10;
} else if name_lower.contains(token) {
score += 5;
}
if display_lower == *token {
score += 8;
} else if display_lower.contains(token) {
score += 4;
}
if desc_lower.contains(token) {
score += 2;
}
for kw in &manifest.keywords {
if kw.to_lowercase() == *token {
score += 6;
} else if kw.to_lowercase().contains(token) {
score += 3;
}
}
for tag in &manifest.tags {
if tag.to_lowercase() == *token {
score += 4;
}
}
}
score
}
/// Get a bundle definition by name.
pub fn get_bundle(&self, name: &str) -> Option<&BundleDefinition> {
self.bundles.get(name)
}
/// List all bundle names.
pub fn bundle_names(&self) -> Vec<&str> {
let mut names: Vec<_> = self.bundles.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
/// Resolve a bundle into its constituent manifests.
/// Returns the manifests and any extension keys that couldn't be found.
pub fn resolve_bundle(
&self,
bundle_name: &str,
) -> Result<(Vec<&ExtensionManifest>, Vec<String>), RegistryError> {
let bundle = self
.bundles
.get(bundle_name)
.ok_or_else(|| RegistryError::BundleNotFound(bundle_name.to_string()))?;
let mut found = Vec::new();
let mut missing = Vec::new();
for ext_key in &bundle.extensions {
if let Some(manifest) = self.manifests.get(ext_key) {
found.push(manifest);
} else {
missing.push(ext_key.clone());
}
}
Ok((found, missing))
}
/// Check if a name refers to a bundle rather than an individual extension.
pub fn is_bundle(&self, name: &str) -> bool {
self.bundles.contains_key(name)
}
/// Resolve a name to either a single manifest or the manifests in a bundle.
/// Returns (manifests, bundle_definition_if_bundle).
pub fn resolve(
&self,
name: &str,
) -> Result<(Vec<&ExtensionManifest>, Option<&BundleDefinition>), RegistryError> {
// Check bundle first
if let Some(bundle) = self.bundles.get(name) {
let (manifests, missing) = self.resolve_bundle(name)?;
if !missing.is_empty() {
tracing::warn!(
"Bundle '{}' references missing extensions: {:?}",
name,
missing
);
}
return Ok((manifests, Some(bundle)));
}
// Single extension (use get_strict to catch ambiguous bare names)
let manifest = self.get_strict(name)?;
Ok((vec![manifest], None))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn create_test_registry(dir: &Path) {
let tools_dir = dir.join("tools");
let channels_dir = dir.join("channels");
fs::create_dir_all(&tools_dir).unwrap();
fs::create_dir_all(&channels_dir).unwrap();
fs::write(
tools_dir.join("slack.json"),
r#"{
"name": "slack",
"display_name": "Slack",
"kind": "tool",
"version": "0.1.0",
"description": "Post messages via Slack API",
"keywords": ["messaging", "chat"],
"source": {
"dir": "tools-src/slack",
"capabilities": "slack-tool.capabilities.json",
"crate_name": "slack-tool"
},
"auth_summary": {
"method": "oauth",
"provider": "Slack",
"secrets": ["slack_bot_token"]
},
"tags": ["default", "messaging"]
}"#,
)
.unwrap();
fs::write(
tools_dir.join("github.json"),
r#"{
"name": "github",
"display_name": "GitHub",
"kind": "tool",
"version": "0.1.0",
"description": "GitHub integration for issues and PRs",
"keywords": ["code", "git"],
"source": {
"dir": "tools-src/github",
"capabilities": "github-tool.capabilities.json",
"crate_name": "github-tool"
},
"tags": ["default", "development"]
}"#,
)
.unwrap();
fs::write(
channels_dir.join("telegram.json"),
r#"{
"name": "telegram",
"display_name": "Telegram",
"kind": "channel",
"version": "0.1.0",
"description": "Telegram Bot API channel",
"source": {
"dir": "channels-src/telegram",
"capabilities": "telegram.capabilities.json",
"crate_name": "telegram-channel"
},
"tags": ["messaging"]
}"#,
)
.unwrap();
fs::write(
dir.join("_bundles.json"),
r#"{
"bundles": {
"default": {
"display_name": "Recommended",
"extensions": ["tools/slack", "tools/github", "channels/telegram"]
},
"messaging": {
"display_name": "Messaging",
"extensions": ["tools/slack", "channels/telegram"],
"shared_auth": null
}
}
}"#,
)
.unwrap();
}
#[test]
fn test_load_catalog() {
let tmp = tempfile::tempdir().unwrap();
create_test_registry(tmp.path());
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
assert_eq!(catalog.all().len(), 3);
}
#[test]
fn test_list_by_kind() {
let tmp = tempfile::tempdir().unwrap();
create_test_registry(tmp.path());
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
let tools = catalog.list(Some(ManifestKind::Tool), None);
assert_eq!(tools.len(), 2);
let channels = catalog.list(Some(ManifestKind::Channel), None);
assert_eq!(channels.len(), 1);
}
#[test]
fn test_list_by_tag() {
let tmp = tempfile::tempdir().unwrap();
create_test_registry(tmp.path());
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
let defaults = catalog.list(None, Some("default"));
assert_eq!(defaults.len(), 2);
let messaging = catalog.list(None, Some("messaging"));
assert_eq!(messaging.len(), 2); // slack (tool) and telegram (channel) both have "messaging" tag
}
#[test]
fn test_get_by_name() {
let tmp = tempfile::tempdir().unwrap();
create_test_registry(tmp.path());
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
// Full key
assert!(catalog.get("tools/slack").is_some());
// Bare name
assert!(catalog.get("slack").is_some());
assert!(catalog.get("telegram").is_some());
// Missing
assert!(catalog.get("nonexistent").is_none());
}
#[test]
fn test_search() {
let tmp = tempfile::tempdir().unwrap();
create_test_registry(tmp.path());
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
let results = catalog.search("slack");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "slack");
let results = catalog.search("messaging");
assert!(!results.is_empty());
let results = catalog.search("nonexistent query");
assert!(results.is_empty());
}
#[test]
fn test_resolve_bundle() {
let tmp = tempfile::tempdir().unwrap();
create_test_registry(tmp.path());
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
let (manifests, missing) = catalog.resolve_bundle("default").unwrap();
assert_eq!(manifests.len(), 3);
assert!(missing.is_empty());
assert!(catalog.resolve_bundle("nonexistent").is_err());
}
#[test]
fn test_resolve_single_or_bundle() {
let tmp = tempfile::tempdir().unwrap();
create_test_registry(tmp.path());
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
// Single extension
let (manifests, bundle) = catalog.resolve("slack").unwrap();
assert_eq!(manifests.len(), 1);
assert!(bundle.is_none());
// Bundle
let (manifests, bundle) = catalog.resolve("default").unwrap();
assert_eq!(manifests.len(), 3);
assert!(bundle.is_some());
}
#[test]
fn test_bundle_names() {
let tmp = tempfile::tempdir().unwrap();
create_test_registry(tmp.path());
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
let names = catalog.bundle_names();
assert_eq!(names, vec!["default", "messaging"]);
}
#[test]
fn test_directory_not_found() {
let result = RegistryCatalog::load(Path::new("/nonexistent/path"));
assert!(result.is_err());
}
}
+415
View File
@@ -0,0 +1,415 @@
//! Install extensions from the registry: build-from-source or download pre-built artifacts.
use std::path::{Path, PathBuf};
use tokio::fs;
use crate::registry::catalog::RegistryError;
use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind};
/// Result of installing a single extension from the registry.
#[derive(Debug)]
pub struct InstallOutcome {
/// Extension name.
pub name: String,
/// Whether this is a tool or channel.
pub kind: ManifestKind,
/// Destination path of the installed WASM binary.
pub wasm_path: PathBuf,
/// Whether a capabilities file was also installed.
pub has_capabilities: bool,
/// Any warning messages.
pub warnings: Vec<String>,
}
/// Handles installing extensions from registry manifests.
pub struct RegistryInstaller {
/// Root of the repo (parent of `registry/`), used to resolve `source.dir`.
repo_root: PathBuf,
/// Directory for installed tools (`~/.ironclaw/tools/`).
tools_dir: PathBuf,
/// Directory for installed channels (`~/.ironclaw/channels/`).
channels_dir: PathBuf,
}
impl RegistryInstaller {
pub fn new(repo_root: PathBuf, tools_dir: PathBuf, channels_dir: PathBuf) -> Self {
Self {
repo_root,
tools_dir,
channels_dir,
}
}
/// Default installer using standard paths.
pub fn with_defaults(repo_root: PathBuf) -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Self {
repo_root,
tools_dir: home.join(".ironclaw").join("tools"),
channels_dir: home.join(".ironclaw").join("channels"),
}
}
/// Install a single extension by building from source.
pub async fn install_from_source(
&self,
manifest: &ExtensionManifest,
force: bool,
) -> Result<InstallOutcome, RegistryError> {
let source_dir = self.repo_root.join(&manifest.source.dir);
if !source_dir.exists() {
return Err(RegistryError::ManifestRead {
path: source_dir.clone(),
reason: "source directory does not exist".to_string(),
});
}
let target_dir = match manifest.kind {
ManifestKind::Tool => &self.tools_dir,
ManifestKind::Channel => &self.channels_dir,
};
fs::create_dir_all(target_dir)
.await
.map_err(RegistryError::Io)?;
// Use manifest.name for installed filenames so discovery, auth, and
// CLI commands (`ironclaw tool auth <name>`) all agree on the stem.
let target_wasm = target_dir.join(format!("{}.wasm", manifest.name));
// Check if already exists
if target_wasm.exists() && !force {
return Err(RegistryError::AlreadyInstalled {
name: manifest.name.clone(),
path: target_wasm,
});
}
// Build the WASM component
println!(
"Building {} '{}' from {}...",
manifest.kind,
manifest.display_name,
source_dir.display()
);
let crate_name = &manifest.source.crate_name;
let wasm_path = build_wasm_component(&source_dir, crate_name)
.await
.map_err(|e| RegistryError::ManifestRead {
path: source_dir.clone(),
reason: format!("build failed: {}", e),
})?;
// Copy WASM binary
println!(" Installing to {}", target_wasm.display());
fs::copy(&wasm_path, &target_wasm)
.await
.map_err(RegistryError::Io)?;
// Copy capabilities file
let caps_source = source_dir.join(&manifest.source.capabilities);
let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name));
let has_capabilities = if caps_source.exists() {
fs::copy(&caps_source, &target_caps)
.await
.map_err(RegistryError::Io)?;
true
} else {
false
};
let mut warnings = Vec::new();
if !has_capabilities {
warnings.push(format!(
"No capabilities file found at {}",
caps_source.display()
));
}
Ok(InstallOutcome {
name: manifest.name.clone(),
kind: manifest.kind,
wasm_path: target_wasm,
has_capabilities,
warnings,
})
}
/// Download and install a pre-built artifact.
pub async fn install_from_artifact(
&self,
manifest: &ExtensionManifest,
force: bool,
) -> Result<InstallOutcome, RegistryError> {
let artifact = manifest.artifacts.get("wasm32-wasip2").ok_or_else(|| {
RegistryError::ExtensionNotFound(format!(
"No wasm32-wasip2 artifact for '{}'",
manifest.name
))
})?;
let url = artifact.url.as_ref().ok_or_else(|| {
RegistryError::ExtensionNotFound(format!(
"No artifact URL for '{}'. Use --build to build from source.",
manifest.name
))
})?;
let expected_sha = artifact.sha256.as_ref().ok_or_else(|| {
RegistryError::ExtensionNotFound(format!(
"No SHA256 hash for '{}'. Cannot verify download.",
manifest.name
))
})?;
let target_dir = match manifest.kind {
ManifestKind::Tool => &self.tools_dir,
ManifestKind::Channel => &self.channels_dir,
};
fs::create_dir_all(target_dir)
.await
.map_err(RegistryError::Io)?;
let target_wasm = target_dir.join(format!("{}.wasm", manifest.name));
if target_wasm.exists() && !force {
return Err(RegistryError::AlreadyInstalled {
name: manifest.name.clone(),
path: target_wasm,
});
}
// Download
println!(
"Downloading {} '{}'...",
manifest.kind, manifest.display_name
);
let response = reqwest::get(url)
.await
.map_err(|e| RegistryError::DownloadFailed {
url: url.clone(),
reason: format!("request failed: {}", e),
})?;
let response = response
.error_for_status()
.map_err(|e| RegistryError::DownloadFailed {
url: url.clone(),
reason: e.to_string(),
})?;
let bytes = response
.bytes()
.await
.map_err(|e| RegistryError::DownloadFailed {
url: url.clone(),
reason: format!("failed to read body: {}", e),
})?;
// Verify SHA256
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&bytes);
let actual_sha = format!("{:x}", hasher.finalize());
if actual_sha != *expected_sha {
return Err(RegistryError::DownloadFailed {
url: url.clone(),
reason: format!(
"SHA256 mismatch: expected {}, got {}",
expected_sha, actual_sha
),
});
}
// Write file
fs::write(&target_wasm, &bytes)
.await
.map_err(RegistryError::Io)?;
// Copy capabilities from source dir (still needed even for pre-built artifacts).
// NOTE: This requires the source tree to be present. When pre-built artifact
// distribution is implemented, capabilities should be bundled with the artifact
// or fetched from a separate URL.
let caps_source = self
.repo_root
.join(&manifest.source.dir)
.join(&manifest.source.capabilities);
let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name));
let has_capabilities = if caps_source.exists() {
fs::copy(&caps_source, &target_caps)
.await
.map_err(RegistryError::Io)?;
true
} else {
false
};
println!(" Installed to {}", target_wasm.display());
Ok(InstallOutcome {
name: manifest.name.clone(),
kind: manifest.kind,
wasm_path: target_wasm,
has_capabilities,
warnings: Vec::new(),
})
}
/// Install a single manifest, choosing build vs download based on artifact availability and flags.
pub async fn install(
&self,
manifest: &ExtensionManifest,
force: bool,
prefer_build: bool,
) -> Result<InstallOutcome, RegistryError> {
let has_artifact = manifest
.artifacts
.get("wasm32-wasip2")
.and_then(|a| a.url.as_ref())
.is_some();
if prefer_build || !has_artifact {
self.install_from_source(manifest, force).await
} else {
self.install_from_artifact(manifest, force).await
}
}
/// Install all extensions in a bundle.
/// Returns the outcomes and any shared auth hints.
pub async fn install_bundle(
&self,
manifests: &[&ExtensionManifest],
bundle: &BundleDefinition,
force: bool,
prefer_build: bool,
) -> (Vec<InstallOutcome>, Vec<String>) {
let mut outcomes = Vec::new();
let mut errors = Vec::new();
for manifest in manifests {
match self.install(manifest, force, prefer_build).await {
Ok(outcome) => outcomes.push(outcome),
Err(e) => errors.push(format!("{}: {}", manifest.name, e)),
}
}
// Collect auth hints
let mut auth_hints = Vec::new();
if let Some(shared) = &bundle.shared_auth {
auth_hints.push(format!(
"Bundle uses shared auth '{}'. Run `ironclaw tool auth <any-member>` to authenticate all members.",
shared
));
}
// Collect unique auth providers that need setup
let mut seen_providers = std::collections::HashSet::new();
for manifest in manifests {
if let Some(auth) = &manifest.auth_summary {
let key = auth
.shared_auth
.as_deref()
.unwrap_or(manifest.name.as_str());
if seen_providers.insert(key.to_string())
&& let Some(url) = &auth.setup_url
{
auth_hints.push(format!(
" {} ({}): {}",
auth.provider.as_deref().unwrap_or(&manifest.name),
auth.method.as_deref().unwrap_or("manual"),
url
));
}
}
}
if !errors.is_empty() {
auth_hints.push(format!(
"\nFailed to install {} extension(s):",
errors.len()
));
for err in errors {
auth_hints.push(format!(" - {}", err));
}
}
(outcomes, auth_hints)
}
}
/// Build a WASM component from a source directory using `cargo component build --release`.
///
/// Uses `tokio::process::Command` with inherited stdio so build progress is visible.
/// Looks for the specific `{crate_name}.wasm` in the release directory rather than
/// picking the first `.wasm` file found.
async fn build_wasm_component(source_dir: &Path, crate_name: &str) -> anyhow::Result<PathBuf> {
use tokio::process::Command;
// Check cargo-component availability
let check = Command::new("cargo")
.args(["component", "--version"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await;
if check.is_err() || !check.as_ref().map(|s| s.success()).unwrap_or(false) {
anyhow::bail!("cargo-component not found. Install with: cargo install cargo-component");
}
// Use status() with inherited stdio so build output streams to the terminal.
let status = Command::new("cargo")
.current_dir(source_dir)
.args(["component", "build", "--release"])
.status()
.await?;
if !status.success() {
anyhow::bail!("Build failed (exit code: {})", status);
}
// Look for the specific crate's WASM file (Cargo uses underscores in artifact names).
let wasm_filename = format!("{}.wasm", crate_name.replace('-', "_"));
let target_base = source_dir.join("target");
let candidates = [
"wasm32-wasip1",
"wasm32-wasip2",
"wasm32-wasi",
"wasm32-unknown-unknown",
];
for target in &candidates {
let wasm_path = target_base
.join(target)
.join("release")
.join(&wasm_filename);
if wasm_path.exists() {
return Ok(wasm_path);
}
}
anyhow::bail!(
"Could not find {} in {}/target/*/release/",
wasm_filename,
source_dir.display()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_installer_creation() {
let installer = RegistryInstaller::new(
PathBuf::from("/repo"),
PathBuf::from("/home/.ironclaw/tools"),
PathBuf::from("/home/.ironclaw/channels"),
);
assert_eq!(installer.repo_root, PathBuf::from("/repo"));
}
}
+271
View File
@@ -0,0 +1,271 @@
//! Serde structs for extension registry manifests.
//!
//! Each manifest describes a single extension (tool or channel) with its source
//! location, build artifacts, authentication requirements, and tags.
use serde::{Deserialize, Serialize};
use crate::extensions::{AuthHint, ExtensionKind, ExtensionSource, RegistryEntry};
/// A single extension manifest loaded from `registry/{tools,channels}/<name>.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionManifest {
/// Unique identifier (matches crate name stem, e.g. "slack").
pub name: String,
/// Human-readable name (e.g. "Slack").
pub display_name: String,
/// Whether this is a tool or channel.
pub kind: ManifestKind,
/// Semver version from Cargo.toml.
pub version: String,
/// One-line description.
pub description: String,
/// Search keywords beyond the name.
#[serde(default)]
pub keywords: Vec<String>,
/// Source code location and build info.
pub source: SourceSpec,
/// Pre-built binary artifacts keyed by target triple.
#[serde(default)]
pub artifacts: std::collections::HashMap<String, ArtifactSpec>,
/// Summary of authentication requirements.
#[serde(default)]
pub auth_summary: Option<AuthSummary>,
/// Tags for filtering (e.g. "default", "messaging", "google").
#[serde(default)]
pub tags: Vec<String>,
}
/// Extension kind as declared in manifests.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ManifestKind {
Tool,
Channel,
}
impl From<ManifestKind> for ExtensionKind {
fn from(kind: ManifestKind) -> Self {
match kind {
ManifestKind::Tool => ExtensionKind::WasmTool,
ManifestKind::Channel => ExtensionKind::WasmChannel,
}
}
}
impl std::fmt::Display for ManifestKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ManifestKind::Tool => write!(f, "tool"),
ManifestKind::Channel => write!(f, "channel"),
}
}
}
/// Source code location for building from source.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceSpec {
/// Path relative to repo root (e.g. "tools-src/slack").
pub dir: String,
/// Capabilities filename relative to source dir.
pub capabilities: String,
/// Rust crate name for `cargo component build`.
pub crate_name: String,
}
/// A pre-built binary artifact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactSpec {
/// Download URL (null until release).
pub url: Option<String>,
/// Hex SHA256 of the WASM binary (null until release).
pub sha256: Option<String>,
}
/// Summary of authentication requirements extracted from capabilities.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthSummary {
/// Auth method: "oauth", "manual", or "none".
#[serde(default)]
pub method: Option<String>,
/// Display name for the auth provider (e.g. "Google", "Slack").
#[serde(default)]
pub provider: Option<String>,
/// Secret names required by this extension.
#[serde(default)]
pub secrets: Vec<String>,
/// If this extension shares auth with others (e.g. all Google tools share
/// `google_oauth_token`), this is the shared secret name.
#[serde(default)]
pub shared_auth: Option<String>,
/// URL where users can set up credentials.
#[serde(default)]
pub setup_url: Option<String>,
}
/// Bundle definition grouping related extensions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleDefinition {
/// Human-readable name.
pub display_name: String,
/// Description of what this bundle contains.
#[serde(default)]
pub description: Option<String>,
/// Extension references as "tools/<name>" or "channels/<name>".
pub extensions: Vec<String>,
/// Shared auth secret across bundle members (if any).
#[serde(default)]
pub shared_auth: Option<String>,
}
/// Top-level structure of `_bundles.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundlesFile {
pub bundles: std::collections::HashMap<String, BundleDefinition>,
}
impl ExtensionManifest {
/// Convert this manifest into a [`RegistryEntry`] for use with the in-chat
/// extension discovery system.
pub fn to_registry_entry(&self) -> RegistryEntry {
let source = ExtensionSource::WasmBuildable {
repo_url: self.source.dir.clone(),
build_dir: Some(self.source.dir.clone()),
};
let auth_hint = match self.auth_summary.as_ref().and_then(|a| a.method.as_deref()) {
Some("oauth") => AuthHint::CapabilitiesAuth,
Some("manual") => AuthHint::CapabilitiesAuth,
Some("none") | None => AuthHint::None,
Some(_) => AuthHint::CapabilitiesAuth,
};
RegistryEntry {
name: self.name.clone(),
display_name: self.display_name.clone(),
kind: self.kind.into(),
description: self.description.clone(),
keywords: self.keywords.clone(),
source,
auth_hint,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_tool_manifest() {
let json = r#"{
"name": "slack",
"display_name": "Slack",
"kind": "tool",
"version": "0.1.0",
"description": "Post messages via Slack API",
"keywords": ["messaging"],
"source": {
"dir": "tools-src/slack",
"capabilities": "slack-tool.capabilities.json",
"crate_name": "slack-tool"
},
"artifacts": {
"wasm32-wasip2": { "url": null, "sha256": null }
},
"auth_summary": {
"method": "oauth",
"provider": "Slack",
"secrets": ["slack_bot_token"],
"shared_auth": null,
"setup_url": "https://api.slack.com/apps"
},
"tags": ["default", "messaging"]
}"#;
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
assert_eq!(manifest.name, "slack");
assert_eq!(manifest.kind, ManifestKind::Tool);
assert_eq!(manifest.version, "0.1.0");
assert!(manifest.tags.contains(&"default".to_string()));
let entry = manifest.to_registry_entry();
assert_eq!(entry.kind, ExtensionKind::WasmTool);
}
#[test]
fn test_parse_channel_manifest() {
let json = r#"{
"name": "telegram",
"display_name": "Telegram",
"kind": "channel",
"version": "0.1.0",
"description": "Telegram Bot API channel",
"source": {
"dir": "channels-src/telegram",
"capabilities": "telegram.capabilities.json",
"crate_name": "telegram-channel"
},
"tags": ["messaging"]
}"#;
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
assert_eq!(manifest.kind, ManifestKind::Channel);
assert!(manifest.auth_summary.is_none());
assert!(manifest.artifacts.is_empty());
let entry = manifest.to_registry_entry();
assert_eq!(entry.kind, ExtensionKind::WasmChannel);
}
#[test]
fn test_parse_bundles() {
let json = r#"{
"bundles": {
"google": {
"display_name": "Google Suite",
"description": "All Google tools",
"extensions": ["tools/gmail", "tools/google-calendar"],
"shared_auth": "google_oauth_token"
},
"default": {
"display_name": "Recommended Set",
"extensions": ["tools/github", "tools/slack"]
}
}
}"#;
let bundles: BundlesFile = serde_json::from_str(json).expect("parse bundles");
assert_eq!(bundles.bundles.len(), 2);
assert_eq!(
bundles.bundles["google"].shared_auth.as_deref(),
Some("google_oauth_token")
);
assert!(bundles.bundles["default"].shared_auth.is_none());
}
#[test]
fn test_manifest_kind_display() {
assert_eq!(ManifestKind::Tool.to_string(), "tool");
assert_eq!(ManifestKind::Channel.to_string(), "channel");
}
}
+23
View File
@@ -0,0 +1,23 @@
//! Extension registry: metadata catalog for tools and channels.
//!
//! The registry provides a central index of all available extensions (WASM tools
//! and channels) with their source locations, build artifacts, authentication
//! requirements, and grouping via bundles.
//!
//! ```text
//! registry/
//! ├── tools/ <- One JSON manifest per tool
//! ├── channels/ <- One JSON manifest per channel
//! └── _bundles.json <- Bundle definitions (google, messaging, default)
//! ```
pub mod catalog;
pub mod installer;
pub mod manifest;
pub use catalog::{RegistryCatalog, RegistryError};
pub use installer::RegistryInstaller;
pub use manifest::{
ArtifactSpec, AuthSummary, BundleDefinition, BundlesFile, ExtensionManifest, ManifestKind,
SourceSpec,
};
+1 -1
View File
@@ -34,7 +34,7 @@ impl Default for SandboxConfig {
memory_limit_mb: 2048,
cpu_shares: 1024,
network_allowlist: default_allowlist(),
image: "ghcr.io/nearai/sandbox:latest".to_string(),
image: "ironclaw-worker:latest".to_string(),
auto_pull_image: true,
proxy_port: 0,
}
+104 -1
View File
@@ -462,7 +462,7 @@ fn default_sandbox_cpu_shares() -> u32 {
}
fn default_sandbox_image() -> String {
"ghcr.io/nearai/sandbox:latest".to_string()
"ironclaw-worker:latest".to_string()
}
impl Default for SandboxSettings {
@@ -1229,4 +1229,107 @@ mod tests {
assert_eq!(s.tunnel.cf_token, Some("cf_tok_xyz".to_string()));
assert!(s.tunnel.ts_funnel);
}
/// Simulates the wizard recovery scenario:
///
/// 1. A prior partial run saved steps 1-4 to the DB
/// 2. User re-runs the wizard, Step 1 sets a new database_url
/// 3. Prior settings are loaded from the DB
/// 4. Step 1's fresh choices must win over stale DB values
///
/// This tests the ordering: load DB → merge_from(step1_overrides).
#[test]
fn wizard_recovery_step1_overrides_stale_db() {
// Simulate prior partial run (steps 1-4 completed):
let prior_run = Settings {
database_backend: Some("postgres".to_string()),
database_url: Some("postgres://old-host/ironclaw".to_string()),
llm_backend: Some("anthropic".to_string()),
selected_model: Some("claude-sonnet-4-5".to_string()),
embeddings: EmbeddingsSettings {
enabled: true,
provider: "openai".to_string(),
..Default::default()
},
..Default::default()
};
// Save to DB and reload (simulates persistence round-trip)
let db_map = prior_run.to_db_map();
let from_db = Settings::from_db_map(&db_map);
// Step 1 of the new wizard run: user enters a NEW database_url
let mut step1_settings = Settings::default();
step1_settings.database_backend = Some("postgres".to_string());
step1_settings.database_url = Some("postgres://new-host/ironclaw".to_string());
// Wizard flow: load DB → merge_from(step1_overrides)
let mut current = step1_settings.clone();
// try_load_existing_settings: merge DB into current
current.merge_from(&from_db);
// Re-apply Step 1 choices on top
current.merge_from(&step1_settings);
// Step 1's fresh database_url wins over stale DB value
assert_eq!(
current.database_url,
Some("postgres://new-host/ironclaw".to_string()),
"Step 1 fresh choice must override stale DB value"
);
// Prior run's steps 2-4 settings are preserved
assert_eq!(
current.llm_backend,
Some("anthropic".to_string()),
"Prior run's LLM backend must be recovered"
);
assert_eq!(
current.selected_model,
Some("claude-sonnet-4-5".to_string()),
"Prior run's model must be recovered"
);
assert!(
current.embeddings.enabled,
"Prior run's embeddings setting must be recovered"
);
}
/// Verifies that persisting defaults doesn't clobber prior settings
/// when the merge ordering is correct.
#[test]
fn wizard_recovery_defaults_dont_clobber_prior() {
// Prior run saved non-default settings
let prior_run = Settings {
llm_backend: Some("openai".to_string()),
selected_model: Some("gpt-4o".to_string()),
heartbeat: HeartbeatSettings {
enabled: true,
interval_secs: 900,
..Default::default()
},
..Default::default()
};
let db_map = prior_run.to_db_map();
let from_db = Settings::from_db_map(&db_map);
// New wizard run: Step 1 only sets DB fields (rest is default)
let step1 = Settings {
database_backend: Some("libsql".to_string()),
..Default::default()
};
// Correct merge ordering
let mut current = step1.clone();
current.merge_from(&from_db);
current.merge_from(&step1);
// Prior settings preserved (Step 1 doesn't touch these)
assert_eq!(current.llm_backend, Some("openai".to_string()));
assert_eq!(current.selected_model, Some("gpt-4o".to_string()));
assert!(current.heartbeat.enabled);
assert_eq!(current.heartbeat.interval_secs, 900);
// Step 1's choice applied
assert_eq!(current.database_backend, Some("libsql".to_string()));
}
}
+131 -25
View File
@@ -19,8 +19,9 @@ Explicit invocation. Loads `.env` files, runs the wizard, exits.
ironclaw (first run, no database configured)
```
Auto-detection via `check_onboard_needed()` in `main.rs`. Triggers when
none of these are true:
Auto-detection via `check_onboard_needed()` in `main.rs`. Skips onboarding
when `ONBOARD_COMPLETED` env var is set (written to `~/.ironclaw/.env` by
the wizard). Otherwise triggers when no database is configured:
- `DATABASE_URL` env var is set
- `LIBSQL_PATH` env var is set
- `~/.ironclaw/ironclaw.db` exists on disk
@@ -49,7 +50,7 @@ The `--no-onboard` CLI flag suppresses auto-detection.
---
## The 7-Step Wizard
## The 8-Step Wizard
### Overview
@@ -60,7 +61,8 @@ Step 3: Inference Provider ← skipped if --skip-auth
Step 4: Model Selection
Step 5: Embeddings
Step 6: Channel Configuration
Step 7: Background Tasks (heartbeat)
Step 7: Extensions (tools)
Step 8: Background Tasks (heartbeat)
save_and_summarize()
```
@@ -165,7 +167,8 @@ env-var mode or skipped secrets.
| Provider | Auth Method | Secret Name | Env Var |
|----------|-------------|-------------|---------|
| NEAR AI | Browser OAuth | (session token) | `NEARAI_SESSION_TOKEN` |
| NEAR AI Chat | Browser OAuth or session token | - | `NEARAI_SESSION_TOKEN` |
| NEAR AI Cloud | API key | `llm_nearai_api_key` | `NEARAI_API_KEY` |
| Anthropic | API key | `anthropic_api_key` | `ANTHROPIC_API_KEY` |
| OpenAI | API key | `openai_api_key` | `OPENAI_API_KEY` |
| Ollama | None | - | - |
@@ -178,8 +181,18 @@ env-var mode or skipped secrets.
4. **Cache key in `self.llm_api_key`** for model fetching in Step 4
**NEAR AI** (`setup_nearai`):
- Calls `session_manager.ensure_authenticated()` which opens browser
- Session token saved to `~/.ironclaw/session.json`
- Calls `session_manager.ensure_authenticated()` which shows the auth menu:
- Options 1-2 (GitHub/Google): browser OAuth → **NEAR AI Chat** mode
(Responses API at `private.near.ai`, session token auth)
- Option 4: NEAR AI Cloud API key → **NEAR AI Cloud** mode
(Chat Completions API at `cloud-api.near.ai`, API key auth)
- **NEAR AI Chat** path: session token saved to `~/.ironclaw/session.json`.
Hosting providers can set `NEARAI_SESSION_TOKEN` env var directly (takes
precedence over file-based tokens).
- **NEAR AI Cloud** path: `NEARAI_API_KEY` saved to `~/.ironclaw/.env`
(bootstrap) and encrypted secrets store (`llm_nearai_api_key`).
`LlmConfig::resolve()` auto-selects `ChatCompletions` mode when the
API key is present.
**`self.llm_api_key` caching:** The wizard caches the API key as
`Option<SecretString>` so that Step 4 (model fetching) and Step 5
@@ -242,13 +255,20 @@ key first, then falls back to the standard env var.
```
6a. Tunnel setup (if webhook channels needed)
6b. Discover WASM channels from ~/.ironclaw/channels/
6c. Multi-select: CLI/TUI, HTTP, discovered channels, bundled channels
6d. Install missing bundled channels (copy WASM binaries)
6e. Initialize SecretsContext (for token storage)
6f. Setup HTTP webhook (if selected)
6g. Setup each WASM channel (secrets, owner binding)
6c. Build channel options: discovered + bundled + registry catalog
6d. Multi-select: CLI/TUI, HTTP, all available channels
6e. Install missing bundled channels (copy WASM binaries)
6f. Install missing registry channels (build from source)
6g. Initialize SecretsContext (for token storage)
6h. Setup HTTP webhook (if selected)
6i. Setup each WASM channel (secrets, owner binding)
```
**Channel sources** (priority order for installation):
1. Already installed in `~/.ironclaw/channels/`
2. Bundled channels (pre-compiled in `channels-src/`)
3. Registry channels (`registry/channels/*.json`, built from source)
**Tunnel setup** (`setup_tunnel`):
- Options: ngrok, Cloudflare Tunnel, localtunnel, custom URL
- Validates HTTPS requirement
@@ -272,7 +292,33 @@ key first, then falls back to the standard env var.
---
### Step 7: Heartbeat
### Step 7: Extensions (Tools)
**Module:** `wizard.rs``step_extensions()`
**Goal:** Install WASM tools from the extension registry.
**Flow:**
1. Load `RegistryCatalog` from `registry/` directory
2. If registry not found, print info and skip
3. List all tool manifests from the catalog
4. Discover already-installed tools in `~/.ironclaw/tools/`
5. Multi-select: show all registry tools with display name, auth method,
and description. Pre-check tools tagged `"default"` and already installed.
6. For each selected tool not yet installed, build from source via
`RegistryInstaller::install_from_source()`
7. Print consolidated auth hints (deduplicated by provider, e.g. one hint
for all Google tools sharing `google_oauth_token`)
**Registry lookup** (`load_registry_catalog`):
Searches for `registry/` directory in order:
1. Current working directory
2. Next to the executable
3. `CARGO_MANIFEST_DIR` (compile-time, dev builds)
---
### Step 8: Heartbeat
**Module:** `wizard.rs``step_heartbeat()`
@@ -337,24 +383,60 @@ heartbeat.enabled = "true"
heartbeat.interval_secs = "300"
```
### Incremental Persistence
Settings are persisted **after every successful step**, not just at the end.
This prevents data loss if a later step fails (e.g., the user enters an
API key in step 3 but step 5 crashes — they won't need to re-enter it).
**`persist_after_step()`** is called after each step in `run()` and:
1. Writes bootstrap vars to `~/.ironclaw/.env` via `write_bootstrap_env()`
2. Writes all current settings to the database via `persist_settings()`
3. Silently ignores errors (e.g., if called before Step 1 establishes a DB)
**`try_load_existing_settings()`** is called after Step 1 establishes a
database connection. It loads any previously saved settings from the
database using `get_all_settings("default")``Settings::from_db_map()`
`merge_from()`. This recovers progress from prior partial wizard runs.
**Ordering after Step 1 is critical:**
```
step_database() → sets DB fields in self.settings
let step1 = self.settings.clone() → snapshot Step 1 choices
try_load_existing_settings() → merge DB values into self.settings
self.settings.merge_from(&step1) → re-apply Step 1 (fresh wins over stale)
persist_after_step() → save merged state
```
This ordering ensures:
- Prior progress (steps 2-7 from a previous partial run) is recovered
- Fresh Step 1 choices override stale DB values (not the reverse)
- The first DB persist doesn't clobber prior settings with defaults
### save_and_summarize()
Final step of the wizard:
```
1. Mark onboard_completed = true
2. Write ALL settings to database (try postgres pool, then libSQL backend)
3. Write bootstrap vars to ~/.ironclaw/.env:
- DATABASE_BACKEND (always)
- DATABASE_URL (if postgres)
- LIBSQL_PATH (if libsql)
- LIBSQL_URL (if turso sync)
- LLM_BACKEND (always, when set)
- LLM_BASE_URL (if openai_compatible)
- OLLAMA_BASE_URL (if ollama)
2. Call persist_settings() for final write (idempotent — ensures
onboard_completed flag is saved)
3. Call write_bootstrap_env() for final .env write (idempotent)
4. Print configuration summary
```
Bootstrap vars written to `~/.ironclaw/.env`:
- `DATABASE_BACKEND` (always)
- `DATABASE_URL` (if postgres)
- `LIBSQL_PATH` (if libsql)
- `LIBSQL_URL` (if turso sync)
- `LLM_BACKEND` (always, when set)
- `LLM_BASE_URL` (if openai_compatible)
- `OLLAMA_BASE_URL` (if ollama)
- `NEARAI_API_KEY` (if API key auth path)
- `ONBOARD_COMPLETED` (always, "true")
**Invariant:** Both Layer 1 and Layer 2 must be written. If the database
write fails, the wizard returns an error and the `.env` file is not written.
@@ -462,9 +544,9 @@ anthropic_api_key → encrypted API key
| `confirm(label, default)` | `[Y/n]` or `[y/N]` prompt |
| `print_header(text)` | Bold section header with underline |
| `print_step(n, total, text)` | `[1/7] Step Name` |
| `print_success(text)` | Green checkmark prefix |
| `print_error(text)` | Red X prefix |
| `print_info(text)` | Blue info prefix |
| `print_success(text)` | Green `✓` prefix (ANSI color), message in default color |
| `print_error(text)` | Red `✗` prefix (ANSI color), message in default color |
| `print_info(text)` | Blue `` prefix (ANSI color), message in default color |
`select_many` uses `crossterm` raw mode for arrow key navigation.
Must properly restore terminal state on all exit paths.
@@ -487,6 +569,30 @@ Must properly restore terminal state on all exit paths.
- May need `gnome-keyring` daemon running
- Collection unlock may prompt for password
### Remote Server Authentication
On remote/VPS servers, the browser-based OAuth flow for NEAR AI may not
work because `http://127.0.0.1:9876` is unreachable from the user's
local browser.
**Solutions:**
1. **NEAR AI Cloud API key (option 4 in auth menu):** Get an API key
from `https://cloud.near.ai` and paste it into the terminal. No
local listener is needed. The key is saved to `~/.ironclaw/.env`
and the encrypted secrets store. Uses the OpenAI-compatible
ChatCompletions API mode.
2. **Custom callback URL:** Set `IRONCLAW_OAUTH_CALLBACK_URL` to a
publicly accessible URL (e.g., via SSH tunnel or reverse proxy) that
forwards to port 9876 on the server:
```bash
export IRONCLAW_OAUTH_CALLBACK_URL=https://myserver.example.com:9876
```
The `callback_url()` function in `oauth_defaults.rs` checks this env var
and falls back to `http://127.0.0.1:{OAUTH_CALLBACK_PORT}`.
### URL Passwords
- `#` is common in URL-encoded passwords (`%23` decoded)
+46 -4
View File
@@ -363,12 +363,54 @@ pub fn setup_tunnel(settings: &Settings) -> Result<TunnelSettings, ChannelSetupE
// Show existing config
let has_existing = settings.tunnel.public_url.is_some() || settings.tunnel.provider.is_some();
if has_existing {
if let Some(ref url) = settings.tunnel.public_url {
print_info(&format!("Existing static tunnel URL: {}", url));
println!();
print_info("Current tunnel configuration:");
let t = &settings.tunnel;
match t.provider.as_deref() {
Some("ngrok") => {
print_info(" Provider: ngrok");
if let Some(ref domain) = t.ngrok_domain {
print_info(&format!(" Domain: {}", domain));
}
if t.ngrok_token.is_some() {
print_info(" Auth: token configured");
}
}
Some("cloudflare") => {
print_info(" Provider: Cloudflare Tunnel");
if t.cf_token.is_some() {
print_info(" Auth: token configured");
}
}
Some("tailscale") => {
let mode = if t.ts_funnel {
"Funnel (public)"
} else {
"Serve (tailnet-only)"
};
print_info(&format!(" Provider: Tailscale {}", mode));
if let Some(ref hostname) = t.ts_hostname {
print_info(&format!(" Hostname: {}", hostname));
}
}
Some("custom") => {
print_info(" Provider: Custom command");
if let Some(ref cmd) = t.custom_command {
print_info(&format!(" Command: {}", cmd));
}
if let Some(ref url) = t.custom_health_url {
print_info(&format!(" Health: {}", url));
}
}
Some(other) => {
print_info(&format!(" Provider: {}", other));
}
None => {}
}
if let Some(ref provider) = settings.tunnel.provider {
print_info(&format!("Existing managed provider: {}", provider));
if let Some(ref url) = t.public_url {
print_info(&format!(" URL: {}", url));
}
println!();
if !confirm("Change tunnel configuration?", false)? {
return Ok(settings.tunnel.clone());
}

Some files were not shown because too many files have changed in this diff Show More