Compare commits

...
Author SHA1 Message Date
ironclaw-ci[bot]GitHubironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com>
91a241a3c7 chore: release v0.21.0 (#1472)
Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com>
2026-03-20 11:23:39 -07:00
Henry ParkandGitHub d1d74d665a Merge pull request #1420 from nearai/staging-promote/71f9012d-23307625134
chore: promote staging to staging-promote/ec04354c-23271447493 (2026-03-19 17:20 UTC)
2026-03-20 10:51:43 -07:00
Henry Park e077e1277d fix: bump Feishu channel version for promotion 2026-03-20 10:33:57 -07:00
Henry ParkandGitHub 6fc8cc2f39 Merge pull request #1422 from nearai/staging-promote/71f41dd1-23309993684
chore: promote staging to staging-promote/71f9012d-23307625134 (2026-03-19 18:14 UTC)
2026-03-20 10:11:43 -07:00
Henry ParkandGitHub e031d8246b Merge pull request #1425 from nearai/staging-promote/52ca9d65-23312673755
chore: promote staging to staging-promote/71f41dd1-23309993684 (2026-03-19 19:18 UTC)
2026-03-20 10:11:32 -07:00
Henry ParkandGitHub 23263029f9 Merge pull request #1428 from nearai/staging-promote/65062f3c-23317058602
chore: promote staging to staging-promote/52ca9d65-23312673755 (2026-03-19 21:10 UTC)
2026-03-20 10:11:15 -07:00
Henry ParkandGitHub d5e08b95f9 Merge pull request #1439 from nearai/staging-promote/c4ab3825-23321164063
chore: promote staging to staging-promote/65062f3c-23317058602 (2026-03-19 23:06 UTC)
2026-03-20 10:10:45 -07:00
Henry ParkandGitHub c4ab382522 Make hosted OAuth and MCP auth generic (#1375)
* Make hosted OAuth and MCP auth generic

* Address PR feedback and lint issues

* Suppress built-in Google secret in hosted proxy flows

* Align hosted OAuth secret suppression with proxy config

* Harden hosted OAuth callback helpers

* Tighten hosted OAuth URL rewriting
2026-03-19 15:50:54 -07:00
65062f3cc0 feat: structured fallback deliverables for failed/stuck jobs (#236)
* feat: structured fallback deliverables for failed/stuck jobs (#221)

When a job fails or gets stuck, build a FallbackDeliverable that captures
partial results, action statistics, cost, timing, and repair attempts.
This replaces opaque error strings with structured data users can act on.

- Add FallbackDeliverable, LastAction, ActionStats types in context/fallback.rs
- Store fallback in JobContext.metadata["fallback_deliverable"] on failure
- Surface fallback in job_status tool output and SSE job_result events
- Update mark_failed() and mark_stuck() in worker to build fallback
- 8 unit tests covering zero/mixed actions, truncation, timing, serialization

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

* fix: address review comments on fallback deliverables

- Fix doc comment: "200 chars" -> "200 bytes (UTF-8 safe)" since
  truncate_str operates on byte length, not character count.
- Add code comment documenting that SSE fallback_deliverable is
  currently always None (forward-compatible infrastructure).

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

* refactor: take Option<&FallbackDeliverable> instead of &Option<…>

Addresses Gemini review feedback: idiomatic Rust prefers
Option<&T> over &Option<T> for borrowed optional values.

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

* fix: guard against non-object metadata and add fallback test

- store_fallback_in_metadata now resets metadata to {} when it's any
  non-object type (string, array, number), not just null. Prevents
  panic on index assignment.
- Add test_job_status_includes_fallback_deliverable to verify the
  fallback field is surfaced in job_status tool output.

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

* fix: use sanitized output in fallback preview + add integration tests

Security fix: FallbackDeliverable::build() now uses output_sanitized
instead of output_raw, preventing secrets/PII from leaking through
the job_status tool and SSE job_result events.

Also adds:
- test_fallback_uses_sanitized_output: proves raw secrets don't leak
- test_store_fallback_in_metadata_roundtrip: full serialize/deserialize
- test_store_fallback_handles_non_object_metadata: edge case coverage
- test_store_fallback_none_is_noop: None input is safe

Addresses serrrfirat review feedback on PR #236.

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

* fix: harden fallback deliverables against review findings

- Truncate failure_reason to 1000 bytes to prevent metadata bloat
- Add tracing::warn on fallback serialization failure (was silently discarded)
- Fix module/struct docs to cover stuck jobs, remove stale SSE claim
- Fix job.rs test to use real FallbackDeliverable field names
- Add tests for failure_reason truncation and completed_at=None elapsed time
- Fix pre-existing clippy warning in settings.rs (field_reassign_with_default)

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

* fix: address Copilot review findings on fallback deliverables

- Fix output_raw/output_sanitized field swap in ActionRecord::succeed()
  so sanitized data actually goes into the sanitized field (security)
- Return None instead of empty Memory when get_memory fails in
  build_fallback, with tracing::warn for observability
- Replace manual elapsed calculation with ctx.elapsed() which already
  clamps negative durations

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

* fix: resolve rebase conflicts and update tests for parameter swap

- Add fallback field to SseEvent::JobResult in job_monitor
- Fix type annotation in fallback deliverable test
- Update test_action_record_succeed_sets_fields for new parameter order
- Use create_job_for_user in test (API changed on main)

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

* chore: trigger CI re-check after rebase

* fix: fall back to error message for failed action output_preview

When the last action is a failed tool call, output_sanitized is None,
leaving output_preview empty. Now falls back to the action's error
message so users see what went wrong.

[skip-regression-check]

* ci: add safety comments to test code for no-panics check

The CI no-panics grep check cannot distinguish test code inside
src/ files from production code. Add // safety: test annotations
to .unwrap(), .expect(), and assert!() calls in #[cfg(test)] modules.

* fix: clarify succeed() doc and avoid clone in output_preview

- Fix doc comment: output_raw is stored as pretty-printed JSON string,
  not a raw JSON value
- Borrow string slice directly in fallback preview to avoid cloning
  potentially large sanitized outputs before truncation

* refactor: reuse floor_char_boundary in truncate_str

Replace hand-rolled UTF-8 boundary logic with existing
crate::util::floor_char_boundary to reduce duplication.

* fix: rename SSE fallback field to fallback_deliverable for consistency

The SSE JobResult field was named `fallback` while everywhere else
(metadata key, job_status tool) uses `fallback_deliverable`. Align
the SSE wire format to avoid forcing clients to handle two names.

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-19 13:43:04 -07:00
86ae12747b feat: LRU embedding cache for workspace search (#1423)
* feat: LRU embedding cache for workspace search (#165)

Add CachedEmbeddingProvider that wraps any EmbeddingProvider with an
in-memory LRU cache keyed by SHA-256(model_name + text). This avoids
redundant HTTP calls when the same text is embedded multiple times
(common during reindexing and repeated searches).

- Cache uses HashMap + last_accessed tracking with manual LRU eviction
  (same pattern as llm::response_cache::CachedProvider)
- Lock is never held during HTTP calls to prevent blocking
- embed_batch() partitions into hits/misses and only fetches misses
- Default 10,000 entries (~58 MB for 1536-dim vectors)
- Configurable via EMBEDDING_CACHE_SIZE env var
- Workspace.with_embeddings() auto-wraps; with_embeddings_uncached()
  available for tests

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

* fix: address review comments on embedding cache

- Validate embed_batch return count matches expected miss count
- Replace unwrap_or_default() with proper error propagation
- Fix batch eviction: run final eviction pass after insert to enforce cap
- Fix test: use different-length inputs to verify ordering correctness
- Reject EMBEDDING_CACHE_SIZE=0 in config validation (minimum is 1)

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

* fix: replace .expect() with proper error handling in embed_batch

The all-cache-hits early-return path used .expect("all cache hits") which
violates the project convention of no .unwrap()/.expect() in production
code. Replaced with the same ok_or_else pattern used in the normal path.

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

* fix: clarify memory sizing docs and use saturating_add for eviction

- Update memory comments in embedding_cache.rs, config/embeddings.rs,
  and workspace/mod.rs to note the ~58 MB figure is payload-only
  (actual memory is higher due to HashMap/key/allocation overhead)
- Use saturating_add(1) instead of + 1 for eviction threshold to
  prevent overflow if max_entries is usize::MAX

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

* fix: address Copilot review on embedding cache

- Avoid double-clone per miss in embed_batch: move embedding into
  results, clone only for the cache entry
- Evict per-insert instead of after all inserts to keep peak memory
  bounded during large batches
- Clamp max_entries to at least 1 in constructor to prevent unexpected
  eviction behavior when set to 0 via the public API

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

* fix: reduce embedding_cache module visibility to private

Types are already re-exported via `pub use`, so the module itself
doesn't need to be public. Reduces unnecessary API surface.

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

* fix: address serrrfirat review feedback on embedding cache

- Add TODO comment for O(n) LRU eviction scalability
- Add thundering herd note at lock release in embed()
- Warn when cache max_entries exceeds 100k
- Use with_embeddings_uncached() in integration test
- Add tests: error_does_not_pollute_cache, embed_batch_empty_input
- Update README with cache-aware with_embeddings() docs

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

* fix: prevent u32 wrapping in FailThenSucceedMock failure counter

fetch_sub(1) wraps to u32::MAX when called past zero, silently
breaking the mock for 3+ calls. Switch to load-then-store to avoid
the wrapping bug in both embed() and embed_batch().

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

* fix: address Copilot and serrrfirat review findings on embedding cache

- Switch tokio::sync::Mutex to std::sync::Mutex (lock never held across
  .await — cheaper synchronous lock)
- Extract DEFAULT_EMBEDDING_CACHE_SIZE constant to avoid 10_000 duplication
  between EmbeddingCacheConfig and EmbeddingsConfig

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

* test: add all-misses batch test for embedding cache

Adds embed_batch_all_misses test covering the case where every text in a
batch is a cache miss — fulfilling the commitment from serrrfirat's review.

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

* chore: trigger CI re-check after rebase

* fix: use raw [u8;32] cache keys and pre-allocate HashMap capacity

Address Copilot review findings:
- cache_key() now returns [u8; 32] instead of hex String, avoiding a
  64-byte allocation per lookup
- HashMap::with_capacity(max_entries) avoids incremental reallocation
- Fix pre-existing staging compilation error in cli/routines.rs
  (missing max_tool_rounds/use_tools fields)

[skip-regression-check]

* fix: make cache accessors sync and update doc for [u8;32] keys

Address Copilot review:
- len(), is_empty(), clear() are now sync since they only take a
  std::sync::Mutex lock with no .await points
- Update cache_size doc comment to reflect [u8;32] keys instead of
  String keys

[skip-regression-check]

* fix: remove clone_on_copy for [u8; 32] cache keys

[skip-regression-check]

* ci: add safety comments to test code for no-panics check

The CI no-panics grep check cannot distinguish test code inside
src/ files from production code. Add // safety: test annotations
to .unwrap(), .expect(), and assert!() calls in #[cfg(test)] modules.

* fix: correct cache doc and demote hit/miss logs to trace

- Fix misleading "String keys" in memory comment (cache uses [u8; 32])
- Demote per-request hit/miss logs from debug to trace to reduce noise
  on hot paths (batch summary stays at trace too)

* docs: add missing Arc import in workspace README example

* perf: batch eviction in embed_batch to avoid O(n×m) cost

Replace per-insert evict_lru call with a single evict_k_oldest pass
that computes eviction count upfront and removes the k oldest entries
in one O(n) scan. Avoids O(n×m) HashMap iterations while holding the
mutex during batch inserts.

* fix: cap batch cache inserts at max_entries and use O(n) selection

- evict_k_oldest now uses select_nth_unstable_by_key for O(n) average
  partial selection instead of O(n log n) full sort
- embed_batch caps cached entries at max_entries when misses exceed
  capacity, preventing the cache from growing unbounded
- Added test: batch_exceeding_capacity_respects_max_entries

* fix: flatten test assert for fmt compatibility

Shorten assert message to fit single line so cargo fmt doesn't
split the safety annotation onto a separate line.

* fix: address review feedback and improve embedding cache (takeover #235)

- Fix merge conflict: add missing allow_always field in PendingApproval
- Thread EmbeddingCacheConfig through CLI memory commands so they respect
  EMBEDDING_CACHE_SIZE instead of silently using default (fixes #235 review)
- Cap HashMap pre-allocation at min(max_entries, 1024) to avoid upfront
  memory waste at large cache sizes
- Fix FailThenSucceedMock race: replace load+store with atomic fetch_update
- Remove noisy '// safety: test' comments (40+ lines of diff noise)
- Fix collapsed lines from comment removal
- Simplify redundant Ok(...collect()?) to just collect()

Co-Authored-By: ztsalexey <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(embedding-cache): skip eviction on concurrent duplicate insert

When the lock is released for the HTTP call, another caller may insert
the same key. Re-check under lock and just update the existing entry
without evicting, avoiding unnecessary cache churn under concurrency.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: ztsalexey <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: ztsalexey <[email protected]>
2026-03-19 13:37:55 -07:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
e4d3200d80 chore: update WASM artifact SHA256 checksums [skip ci] (#1424)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-19 13:04:07 -07:00
52ca9d6588 feat: receive relay events via webhook callbacks (#1254)
* feat: receive relay events via webhook callbacks instead of SSE

Replace the SSE pull model with push-based webhook callbacks from
channel-relay. Eliminates the reconnect loop, stream token auth,
and SSE parser — events arrive via HTTP POST to /relay/events.

- Add webhook handler with HMAC signature verification
- Simplify RelayChannel to use mpsc from webhook handler
- Remove SSE connect/reconnect/parse logic from RelayClient
- Add register_callback() to RelayClient for callback URL registration
- Update activation flow to create event channel and register callback
- Wire relay webhook endpoint into web gateway

* fix: address review feedback on webhook callback PR

- Return 503 when relay event channel is full/closed (enables retry)
- Reject malformed timestamps with 400 instead of proceeding
- Allow relay activation without settings store (no-store/ephemeral mode)
- Check installed_relay_extensions set in is_relay_channel for no-db mode
- Fix staging test constructors for new RelayChannel signature

* security: adapt relay client to new channel-relay auth model

Adapts the relay integration to the hardened channel-relay security model:

- Switch from X-API-Key header to Authorization: Bearer sk-agent-*
  for all relay API calls (chat-api token verification)
- Remove register_callback() — PUT /callbacks endpoint removed
- Remove event_callback_url from initiate_oauth() — parameter removed
- Make signing_secret a required field in RelayConfig (new env var:
  CHANNEL_RELAY_SIGNING_SECRET)
- Update integration tests for Bearer auth and removed endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: use server-side approval tokens, remove caller-supplied routing

- Approval flow now calls POST /approvals to register server-side
  record, then embeds only the opaque approval_token in button value
- Remove instance_id parameter from proxy_provider() — channel-relay
  no longer accepts it (uses verified identity)
- Remove instance_id and user_id from initiate_oauth() — channel-relay
  derives them from the Bearer token
- Add create_approval() to RelayClient

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: pass webhook_url during OAuth so callback_url is set on connection

The channel-relay OAuth flow now accepts webhook_url to set the
callback_url during connection creation. IronClaw computes its webhook
URL from callback_base + webhook_path and passes it during initiate_oauth.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: remove webhook_url from OAuth initiation

Channel-relay now derives the callback URL from chat-api's instance_url.
IronClaw no longer supplies webhook_url during OAuth — the relay is the
authority on where events get delivered.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: remove all URL params from OAuth initiation

IronClaw no longer supplies any URLs to channel-relay. The relay
derives all URLs from the trusted instance_url in chat-api.
initiate_oauth() takes no parameters.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: restore CSRF nonce for OAuth callback validation

Re-add nonce generation and secret storage in auth_channel_relay.
The nonce is passed to channel-relay as state_nonce param (not a URL).
Channel-relay embeds it in the signed state and appends it to the
redirect URL so IronClaw's callback handler can validate and activate.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: per-instance callback signing secrets

relay_signing_secret() now prefers OPENCLAW_GATEWAY_TOKEN (per-instance)
over the shared CHANNEL_RELAY_SIGNING_SECRET. A compromised instance
can no longer forge callbacks to other instances on the same relay.
CHANNEL_RELAY_SIGNING_SECRET is now optional in RelayConfig.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: clean per-instance callback secrets, no shared secrets, no fallbacks

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: pass team_id to get_signing_secret for workspace-scoped lookup

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* security: remove sender_id from create_approval — relay derives it

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: remove stale relay sender_id validation

* fix: harden relay webhook activation lifecycle

---------

Co-authored-by: Pierre <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 11:53:46 -07:00
09e1c97a27 fix(approval): make "always" auto-approve work for credentialed HTTP requests (#1257)
The HTTP tool returned `ApprovalRequirement::Always` for requests with
credentials, but `Always` is hardcoded to ignore the session auto-approve
set. This meant users who clicked "always" were re-prompted on every
subsequent HTTP call — the UI offered "always" but the backend ignored it.

Two fixes:
1. HTTP credentialed requests now return `UnlessAutoApproved` instead of
   `Always`, so the session auto-approve set is respected.
2. `StatusUpdate::ApprovalNeeded` now carries `allow_always: bool`. All
   channel UIs (Telegram, Slack, Signal, REPL, Web) conditionally hide
   the "always" option when a tool truly requires per-invocation approval
   (`ApprovalRequirement::Always`, e.g. destructive shell commands).

Also boxes `PendingApproval` in `AgenticLoopResult::NeedApproval` to fix
a pre-existing clippy `large_enum_variant` warning.

Regression tests included (test_credentialed_requests_respect_auto_approve,
test_allow_always_matches_approval_requirement) but CI heuristic cannot
detect them in cross-fork PR diffs.

[skip-regression-check]

Co-authored-by: Tyler <[email protected]>
2026-03-19 11:45:32 -07:00
ironclaw-ci[bot]GitHubironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com>
7dc3c6d067 chore: release v0.20.0 (#1310)
Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com>
2026-03-19 11:20:16 -07:00
Henry ParkandGitHub e1774e9ec0 Merge pull request #1387 from nearai/staging-promote/ec04354c-23271447493
chore: promote staging to main (2026-03-18 23:07 UTC)
2026-03-19 10:35:49 -07:00
71f41dd123 fix(feishu): parse flat token response from tenant_access_token API (#1419)
* fix(feishu): parse flat token response from tenant_access_token API

  The Feishu /auth/v3/tenant_access_token/internal endpoint returns a flat
  JSON response with tenant_access_token and expire at the top level, not
  nested under a "data" field. The previous code used FeishuApiResponse<T>
  which expects a "data" wrapper, causing all token exchanges to fail with
  "Token response missing data" despite receiving a valid HTTP 200 response.

  - Replace TenantAccessTokenData with TenantAccessTokenResponse that includes
  code/msg/tenant_access_token/expire at the top level
  - Deserialize token response directly instead of via FeishuApiResponse<T> wrapper
  - Add empty-token guard to catch malformed responses
  - No changes to FeishuApiResponse<T> or other API call paths

  Fixes #1391

* fix(feishu): address review feedback on token response parsing

- Remove #[serde(default)] from tenant_access_token and expire fields
  so deserialization fails explicitly when critical fields are missing
- Add expire > 0 validation guard to prevent refresh loops or overflow
- Use saturating_add/saturating_mul for expiry calculation
- Add 5 regression tests for TenantAccessTokenResponse deserialization

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: reidliu <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 10:33:58 -07:00
71f9012de3 fix: skip NEAR AI session check when backend is not nearai (#1413)
* fix: skip NEAR AI session check when backend is not nearai

When a user configures a non-NEAR AI backend (e.g. Anthropic), the
doctor command was incorrectly failing with "session file not found"
even though no NEAR AI session is needed. The check now skips with a
descriptive message when LLM_BACKEND is not nearai/near_ai/near.

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

* fix(ci): avoid holding sync MutexGuard across await in doctor test

Convert check_nearai_session_skips_for_non_nearai_backend from
#[tokio::test] to #[test] with block_on, matching the pattern used by
all other ENV_MUTEX tests. Fixes clippy::await_holding_lock error.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Kristian Glass <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-19 10:10:08 -07:00
Henry ParkandGitHub e1d9827b21 Merge pull request #1411 from nearai/staging-promote/38dafb96-23306226661
chore: promote staging to staging-promote/ec04354c-23271447493 (2026-03-19 16:48 UTC)
2026-03-19 09:54:37 -07:00
38dafb96b1 chore: bump telegram channel version to 0.2.5 (#1410)
Bump registry version to pass check-version-bumps.sh after
channels-src/telegram/ changes.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 09:47:40 -07:00
CPU-216andGitHub 9c34fe90f4 chore(ci): enforce test requirement for state machine and resilience changes (#1230) (#1304) 2026-03-19 09:35:37 -07:00
Henry ParkandGitHub e582166781 Merge pull request #1396 from nearai/staging-promote/3dcccc1e-23280048384
chore: promote staging to staging-promote/ec04354c-23271447493 (2026-03-19 04:37 UTC)
2026-03-19 08:58:29 -07:00
Henry ParkandGitHub 656d1f3e86 Merge pull request #1402 from nearai/staging-promote/b9e5acf6-23283208580
chore: promote staging to staging-promote/3dcccc1e-23280048384 (2026-03-19 06:44 UTC)
2026-03-19 08:58:09 -07:00
Henry ParkandGitHub 0e3aa4f806 Merge pull request #1409 from nearai/staging-promote/07c6ca72-23302016242
chore: promote staging to staging-promote/b9e5acf6-23283208580 (2026-03-19 15:15 UTC)
2026-03-19 08:57:54 -07:00
07c6ca72e9 fix: navigate telegram E2E tests to channels subtab (#1408)
* fix: navigate telegram E2E tests to channels subtab

wasm_channel extensions (like telegram) are now rendered in the
Settings → Channels subtab, not the Extensions subtab. Update
test_telegram_hot_activation to navigate there and use the correct
card selector. Also mock /api/gateway/status which loadChannelsStatus
fetches.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: select telegram card by name, not first card in channels subtab

Built-in channel cards (Web Gateway, HTTP, etc.) render first in the
channels subtab content, so .first matches them instead of the
telegram extension card. Select by has_text="Telegram" to target
the correct card.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor: make gateway_status_handler parameterizable in mock helper

Address review feedback: extract default gateway status handler and
accept an optional gateway_status_handler kwarg in mock_extension_lists
for test flexibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 08:11:15 -07:00
b9e5acf66e fix: add missing builder field and update E2E extensions tab navigation (#1400)
- Add `builder: None` to AgentDeps initializer in e2e_telegram_message_routing
  test (field added in #712 but test not updated)
- Update go_to_extensions() in test_telegram_hot_activation to navigate via
  settings tab -> extensions subtab (extensions tab was moved to settings)

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-18 23:38:33 -07:00
3dcccc1e64 feat(self-repair): wire stuck_threshold, store, and builder (#712)
* feat(self-repair): wire stuck_threshold, store, and builder (#647)

Wire the previously dead-code fields in DefaultSelfRepair:

- stuck_threshold: detect_stuck_jobs() now filters by duration, only
  reporting jobs stuck longer than the configured threshold
- with_store(): wired in agent_loop.rs from AgentDeps.store for
  tool failure tracking via Database trait
- with_builder(): wired from register_builder_tool() return value
  through AppComponents and AgentDeps for automatic tool rebuilding
- tools: passed alongside builder for hot-reload logging

Remove all #[allow(dead_code)] annotations. Add regression tests for
threshold-based filtering (both above and below threshold).

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

* fix: add missing `builder` field to AgentDeps in gateway workflow harness

After rebase onto staging, AgentDeps gained a `builder` field for
self-repair tool rebuilding. The gateway workflow test harness was
missing this field, causing CI compilation failure.

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

* ci: retrigger CI

* fix: force CI refresh after path_routing_tests dedup

* test: add E2E test for stuck job repair and tool rebuild cycle

Tests the full self-repair flow requested in review:
1. Job transitions Pending -> InProgress -> Stuck
2. detect_stuck_jobs() finds it (zero threshold)
3. repair_stuck_job() recovers it back to InProgress
4. A broken tool is repaired via MockBuilder
5. Verify builder was invoked and repair succeeded

Uses a MockBuilder (impl SoftwareBuilder) that returns successful
BuildResult without requiring an LLM or filesystem. Uses libsql
test database for the store (increment_repair_attempts, mark_tool_repaired).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(self-repair): measure stuck_duration from Stuck transition, not started_at

- Use ctx.transitions to find the most recent Stuck transition timestamp
  instead of ctx.started_at (which reflects job start, not stuck time)
- Fix StuckJob.last_activity to use stuck transition timestamp
- Remove misleading "hot-reloaded into registry" log
- Remove stray "// ci fix" comment in memory.rs
- Add regression test: backdated started_at must not inflate stuck_duration

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

* ci: re-trigger CI with latest changes

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

* fix: add type annotation to Ok(()) in test to resolve E0282

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-18 20:51:21 -07:00
c8ee55ed19 feat(testing): add FaultInjector framework for StubLlm (#1233)
* feat(testing): add FaultInjector framework for StubLlm (#1220)

Adds a configurable fault injection framework for testing retry, failover,
and circuit breaker behavior. The FaultInjector attaches to StubLlm and
provides per-call control over failure type, timing, and sequencing.

Components:
- FaultType: maps to LlmError variants (RequestFailed, RateLimited,
  AuthFailed, InvalidResponse, IoError, ContextLengthExceeded, SessionExpired)
- FaultAction: Succeed, Fail(FaultType), Delay(Duration)
- FaultMode: SequenceOnce (play then succeed), SequenceLoop (repeat forever),
  Random (seeded xorshift64 PRNG for reproducibility)
- FaultInjector: thread-safe (AtomicU32 counter + Mutex RNG)

Integration:
- StubLlm gains optional fault_injector field via with_fault_injector()
- When set, takes precedence over should_fail/error_kind
- Backward compatible: existing StubLlm usage unchanged

Closes #1220

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(testing): address review feedback on FaultInjector

- Remove redundant .abs() in random fault comparison
- Extract check_faults() helper to DRY up StubLlm methods
- Guard xorshift seed=0 (fixed point) by mapping to 1
- Add StubLlm integration test (stub_llm_fault_injector_sequence)
- Remove dead seed field from FaultMode::Random
- Move pub mod fault_injection to top of mod.rs
- Add Debug impl for FaultInjector
- Add empty_sequence_always_succeeds test
- Add random_seed_zero_does_not_always_fail test

* fix(testing): address #1233 review -- seed-0 bug, reset(), Debug derive

- Store seed in FaultMode::Random so reset() can re-init the RNG
- Add reset() method for test reproducibility (re-seeds RNG, zeros counter)
- Strengthen seed=0 regression test to 100 iterations with stricter assertion
- Add reset_restores_random_rng_from_stored_seed test
- Debug impl and empty_sequence test were already present from prior commit

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* ci: re-trigger CI with latest changes

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

* ci: trigger new run with skip-regression-check label

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

* fix(testing): address PR #1233 review -- error_rate validation and edge cases

- Validate error_rate is in 0.0..=1.0 and not NaN (panics on invalid input)
- Fix error_rate==1.0 edge case: use <= instead of < so 1.0 always fails
- Add regression tests for error_rate validation (NaN, negative, >1.0)
- Add tests for error_rate boundary values (0.0 never fails, 1.0 always fails)
- Add delay action test using tokio::time::pause() for deterministic timing

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-18 20:38:29 -07:00
8b15f8b259 feat(telegram): support auto split large message (#1084)
* feat(telegram): support auto split large message

* fix(telegram): strengthen split_message test assertion

Replace word-by-word contains check with assert_eq! on rejoined chunks,
ensuring split_message preserves content exactly.

send_response is still used (lines 745, 753) so it is intentionally kept.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(telegram): add missing split_message tests and document limitations

- Add test for sentence-boundary splitting
- Add test for hard-cut on pathological input (no spaces)
- Add test for multi-byte character safety (emoji)
- Document CJK sentence punctuation limitation
- Document trim behavior at chunk boundaries

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

* ci: re-trigger CI with latest changes

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

---------

Co-authored-by: Hans <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-18 20:37:00 -07:00
Henry ParkandGitHub 44d16732a7 Merge pull request #1390 from nearai/staging-promote/94e4d9d3-23273403042
chore: promote staging to staging-promote/ec04354c-23271447493 (2026-03-19 00:12 UTC)
2026-03-18 17:30:59 -07:00
Henry ParkandGitHub 94e4d9d3dd Merge pull request #1389 from nearai/main
chore: sync main and staging
2026-03-18 17:11:54 -07:00
b7a1edf346 fix: remove debug_assert guards that panic on valid error paths (#1385)
* fix: remove debug_assert guards that panic on valid error paths (#1312)

Two debug_assert! calls added in #1312 fire on expected runtime error
paths (not programmer bugs), turning graceful error returns into panics
in debug/test builds:

- state.rs: Completed→Cancelled is a user-facing error handled by
  transition_to() returning Err — not a bug
- execute.rs: empty tool_name from malformed LLM output is handled by
  ToolError::NotFound — not a bug

Removes both asserts; keeps the circuit-breaker assert (genuinely guards
a caller invariant).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: tighten empty tool name test to assert ToolError::NotFound variant

Address review feedback: assert the specific error variant instead of
just is_err() so the regression test actually enforces the expected
error path.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-18 17:02:09 -07:00
4566181f40 feat(gateway): unified settings page with subtabs (#1191)
* feat(gateway): full settings page polish with all tiers

- Backend: add ActiveConfigSnapshot to expose resolved LLM backend,
  model, and enabled channels via /api/gateway/status
- Add missing Agent settings (daily cost cap, actions/hour, local tools)
- Add Sandbox, Routines, Safety, Skills, and Search setting groups
- Settings import/export (JSON download + file upload)
- Active env defaults shown as placeholders in Inference settings
- Styled confirmation modals replace window.confirm() for remove actions
- Global restart banner persists across settings subtab switches
- Client-side validation with min/max constraints on number inputs
- Accessibility: aria-label on inputs, role=status on save indicators
- Settings search filters rows across current subtab
- Smooth CSS transitions for conditional field visibility (showWhen)
- Tunnel settings in Channels subtab
- Mobile responsive settings layout at 768px breakpoint
- i18n keys for toolbar, search, and import/export in en + zh-CN

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

* feat(gateway): polish settings page and remove registered tools debug section

Remove the "Registered Tools" table from the extensions tab (debug info
not useful to end users), clean up associated CSS/i18n/JS. Additional
settings page UI polish: extension card state styling, layout refinements.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): address PR review feedback [skip-regression-check]

- Use refreshCurrentSettingsTab() in SSE event handlers to reduce duplication
- Remove unused formatGroupName/formatSettingLabel helpers
- Use i18n keys for MCP Configure/Reconfigure buttons
- Add data-i18n-placeholder to settings search input
- Remove data-i18n from confirm modal button (set dynamically by showConfirmModal)
- Fix cargo fmt in main.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(e2e): update tests for unified settings tab layout [skip-regression-check]

- Update TABS list: replace extensions/skills with settings
- Add settings_subtab/settings_subpanel selectors to helpers
- Update test_connection, test_skills, test_extensions, test_wasm_lifecycle
  to navigate via Settings > subtab instead of top-level tabs
- Move MCP card tests to use go_to_mcp() helper (MCP is now a separate subtab)
- Remove tools table tests and mock_ext_apis tools= parameter
- Fix CSP violation: replace inline onclick on confirm modal cancel button

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): address second round of PR review feedback [skip-regression-check]

- Use I18n.t() for MCP empty state, export/import toasts, confirm modal
- Fix CLI channel card using wrong channel key ('repl' -> 'cli')
- Fix settings search counting hidden rows as visible
- Add aria-label i18n for settings search input
- Add common.loadFailed i18n key (en + zh-CN)
- Update E2E tests: WASM channel tests use Channels subtab,
  remove tests use custom confirm modal instead of window.confirm

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(e2e): fix WASM channel card selector and skills remove confirm [skip-regression-check]

- WASM channel tests: filter by display name to avoid matching built-in
  channel cards in the Channels subtab
- Skills remove test: click confirm modal button instead of using
  window.confirm (skill removal now uses custom confirm modal)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): address third round of PR review feedback [skip-regression-check]

- approval_needed SSE: refresh any active settings subtab, not just
  Extensions — approvals can surface from Channels/MCP setup flows too
- renderCardsSkeleton: remove nested .extensions-list wrapper that
  caused skeleton cards to render constrained inside grid cells

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(e2e): fix auth_completed reload test race condition [skip-regression-check]

Use expect_response to deterministically wait for the /api/extensions
reload triggered by handleAuthCompleted → refreshCurrentSettingsTab,
instead of a fixed 600ms sleep that was too short under CI load.
Also remove stale /api/extensions/tools route handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(e2e): debug auth_completed reload test with function counter [skip-regression-check]

Inject a counter wrapper around refreshCurrentSettingsTab to verify it's
actually called, and wait for the async fetch to complete before
asserting the reload count.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(gateway): localize all settings labels, descriptions, and channel cards [skip-regression-check]

Move 120+ hardcoded strings in settings definitions (INFERENCE_SETTINGS,
AGENT_SETTINGS, NETWORKING_SETTINGS) and channel card labels to i18n
keys. Render functions now resolve labels via I18n.t() so the settings
page translates when switching locales.

Covers: group titles, setting labels/descriptions, built-in channel
names/descriptions, and the "No settings found" empty state.

Both en.js and zh-CN.js updated with all new cfg.* and channels.* keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): localize remaining hardcoded UI strings [skip-regression-check]

- Fix export error toast using wrong i18n key (importFailed → exportFailed)
- Replace "Failed to load settings:" with I18n.t('common.loadFailed')
- Localize renderBuiltinChannelCard: "Built-in", "Active", "Inactive"
- Localize settings placeholders: "env: ", "env default", "use env default"
- Localize "✓ Saved" indicator
- Add new i18n keys to both en.js and zh-CN.js

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): confirm modal a11y, Esc/click-outside, search guard [skip-regression-check]

- Add role="dialog", aria-modal="true", aria-labelledby to confirm modal
- Focus confirm button when modal opens
- Close modal on Escape key or overlay click
- Skip settings search on non-settings panels (Extensions/MCP/Skills/Channels)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): boolean tri-state, search reset on subtab switch, stale model suggestions [skip-regression-check]

Address PR review feedback:
- Boolean settings now use a tri-state select (env default / On / Off)
  instead of a checkbox, matching the pattern used by other select settings
  and allowing users to revert to the env default
- Clear search input when switching settings subtabs so stale filters
  don't carry over to the new panel
- Always assign model suggestions (even empty array) so stale IDs from a
  previous successful /v1/models fetch don't persist when the endpoint
  later returns empty

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): auth_completed handler, bedrock_cross_region select, integer-only number inputs [skip-regression-check]

Address PR review feedback:
- auth_completed SSE listener now delegates to handleAuthCompleted(data)
  instead of inlining logic with a bare closeConfigureModal() call, so
  only the matching extension's modal is dismissed
- bedrock_cross_region changed from free text to select with the four
  valid values (us/eu/apac/global), matching backend validation
- Number settings now use step=1 and parseInt() instead of parseFloat(),
  preventing fractional values that the backend (u32/u64) would reject

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-18 16:18:29 -07:00
ec04354c6b fix: address valid review comments from PR #1359 (#1380)
- Cache discovery_schema() with OnceLock for routine tools (fixes #1361, #1371)
- Early-return on empty event cache before allocating Vec (fixes #1369)
- Extract batch concurrent count query helper to reduce duplication
- Fix ROUTINE_OK sentinel substring matching
- Migrate crate::safety import to ironclaw_safety per project convention

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-18 15:34:05 -07:00
14abd60917 fix: full_job routine runs stay running until linked job completion (#1374)
* fix: full_job routine runs stay running until linked job completion (#1317)

Previously, execute_full_job() returned RunStatus::Ok immediately after
dispatching the job, causing routine runs to be marked as completed before
the linked worker job had actually finished. This meant failure notifications
were never sent and max_concurrent guardrails stopped applying once the run
was prematurely finalized.

Changes:
- execute_full_job() now returns RunStatus::Running instead of Ok
- execute_routine() skips finalization for Running status (leaves run open)
- New sync_dispatched_runs() polls on each cron tick, checks linked job
  state, and finalizes runs when jobs reach terminal states
- New list_dispatched_routine_runs() DB method on both backends
- Deferred notifications are sent when the run is actually finalized
- consecutive_failures is preserved (not reset) while outcome is unknown

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review feedback (watcher predicate, running_count safety)

- FullJobWatcher: use is_parallel_blocking() instead of is_active() so
  the watcher exits when a job reaches Completed (not terminal but
  finished executing). Fixes infinite-poll for routine jobs.
- Remove running_count decrement from sync_dispatched_runs() — in normal
  flow execute_routine() handles it; sync only runs for crash recovery
  where the counter is already 0.
- Update PR description to match actual FullJobWatcher behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: sync only at startup to prevent double-completion race

- Move sync_dispatched_runs() out of cron loop into startup-only path.
  During normal operation FullJobWatcher handles finalization inline;
  running sync on every tick would race with the watcher.
- Update complete_dispatched_run() to properly advance runtime fields
  (last_run_at, next_fire_at, run_count) for crash recovery — in that
  scenario execute_routine() never reached its runtime update.
- Fix stale doc comment on complete_dispatched_run().

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: use boot_time filter for safe periodic sync of orphaned runs

- Add boot_time field to RoutineEngine, set to Utc::now() at creation.
- sync_dispatched_runs() now filters runs by started_at < boot_time,
  so it only processes orphans from a previous process — never races
  with FullJobWatcher instances from the current process.
- Move sync back into the cron loop (safe with boot_time filter) and
  run it BEFORE check_cron_triggers to avoid picking up freshly
  dispatched runs.
- Fix doc comments to match actual behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-18 15:33:57 -07:00
Henry ParkandGitHub a95a84ea79 Merge pull request #1379 from nearai/staging-promote/6831bb4d-23264725970
chore: promote staging to staging-promote/f2cd1d37-23262791325 (2026-03-18 20:09 UTC)
2026-03-18 14:16:45 -07:00
Henry ParkandGitHub 2033d77579 Merge pull request #1376 from nearai/staging-promote/f2cd1d37-23262791325
chore: promote staging to staging-promote/428303af-23255149035 (2026-03-18 19:20 UTC)
2026-03-18 14:16:32 -07:00
Henry ParkandGitHub 59acab43f4 Merge pull request #1359 from nearai/staging-promote/428303af-23255149035
chore: promote staging to main (2026-03-18 16:22 UTC)
2026-03-18 14:16:06 -07:00
6831bb4d7b fix: full_job routine concurrency tracks linked job lifetime (#1372)
* fix: add FullJobWatcher to track full_job lifecycle for concurrency (#1318)

full_job routines previously bypassed max_concurrent and global concurrency
limits because execute_full_job() returned RunStatus::Ok immediately after
dispatch. This meant running_count was decremented and the routine_run row
was finalized before the actual job completed.

Introduce FullJobWatcher struct that polls store.get_job() every 5s until
the linked job reaches a non-active state, then maps the final JobState to
RunStatus. execute_full_job now creates and awaits the watcher, keeping both
the DB-level running row and the in-memory running_count elevated for the
full job duration.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test: full_job concurrency regression tests (issue #1318)

Add two integration tests verifying full_job routine concurrency:

1. full_job_max_concurrent_blocks_second_fire_while_first_active:
   Inserts a Running routine_run (simulating an in-flight full_job) and
   verifies fire_manual returns MaxConcurrent error for max_concurrent=1.

2. global_concurrency_counts_live_full_job_runs:
   Elevates running_count to simulate a live full_job holding the global
   slot, verifies check_cron_triggers skips due routines, then releases
   the slot and verifies the routine fires.

Also makes running_count_for_test() unconditionally public so integration
tests (separate crate) can access it.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: fmt and clippy fixes for full_job concurrency tests

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review feedback on FullJobWatcher

- Add #[doc(hidden)] to running_count_for_test() to hide from public API
- Derive MAX_POLLS from POLL_INTERVAL to keep constants coupled
- Check job state before first sleep to finalize promptly for fast jobs
- Update execute_full_job doc comment to reflect blocking behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-18 12:29:58 -07:00
42ffefabe4 fix: remove -x from coverage pytest to prevent suite-blocking failures (#1360)
One flaky test (test_builtin_echo_tool timeout) was stopping the entire
e2e coverage suite via -x, preventing 118+ remaining tests from running
and generating coverage data.

Tests are independent (each gets a fresh browser context via the
function-scoped page fixture), so removing -x is safe.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-18 12:29:44 -07:00
20202700db Fix duplicate LLM responses for matched event routines (#1275)
* fix: consume matched event routine messages

* style: run rustfmt for event routine fix

* fix: preserve preprocessing for routine-triggered messages

* fix: match routines against rewritten input

* refactor: narrow check_event_triggers API and simplify routine_engine_slot

Address Copilot review feedback:

- Change check_event_triggers to accept (user_id, channel, content) instead
  of &IncomingMessage, eliminating the need to clone the full message
  (including attachments) when hooks rewrite content.

- Remove routine_trigger_message and the Cow<IncomingMessage> indirection;
  the event-trigger check now inlines the is_internal + UserInput guard and
  passes the post-hook content string directly.

- Make routine_engine_slot non-optional since Agent::new() always
  initializes it. Removes the redundant Option wrapper and simplifies
  accessor/setter methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-18 12:29:35 -07:00
Ikko Eltociear AshimineGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
f2cd1d37bc docs: add Japanese README (#1306)
* docs: add Japanese README

* Update README.ja.md

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update README.ja.md

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-18 11:34:19 -07:00
07e6e30ee3 fix: add debug_assert invariant guards to critical code paths (#1312)
* fix: add debug_assert invariant guards to critical code paths (closes #1215)

Add three debug_assert! calls to catch impossible-in-correct-code states
early in debug builds without affecting release performance:

- execute_tool_with_safety: assert tool_name is non-empty at entry
- JobContext::transition_to: assert state machine transition is valid
- CircuitBreakerProvider::record_success: assert circuit is not Open
  (check_allowed() must gate all calls before record_success())

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

* test: add regression test for empty tool name invariant guard

Covers the debug_assert!(!tool_name.is_empty()) added in execute_tool_with_safety.

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

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-18 11:34:11 -07:00
OctopusandGitHub 2d0b195321 feat: upgrade MiniMax default model to M2.7 (#1357)
* feat: upgrade MiniMax default model to M2.7

- Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to model list
- Set MiniMax-M2.7 as default model
- Keep all previous models as alternatives
- Update related tests

* fix: use canonical model name in test per review

Use MiniMax-M2.7-highspeed (canonical casing) in the reasoning
models test for consistency with the documentation and provider
configuration.

[skip-regression-check]
2026-03-18 11:34:05 -07:00
CPU-216andGitHub 9286978547 chore(ci): add coverage gates via codecov.yml (#1228) (#1291)
- Project target: 80% with 2% threshold (was: auto with 1%)
- Patch target: 90% (was: 80% with 5% threshold)
- Add PR comment config with reach/diff/flags layout
- Enable require_changes to reduce comment noise
2026-03-18 11:33:58 -07:00
NigeandGitHub 0be591028a fix(mcp): retry after missing session id errors (#1355) 2026-03-18 11:33:51 -07:00
NigeandGitHub 33a2dd2c78 fix(telegram): preserve polling after secret-blocked updates (#1353)
* fix(telegram): preserve polling after secret-blocked updates

* style(telegram): simplify polling leak-scan guard

* style(telegram): satisfy clippy for poll leak guard
2026-03-18 11:33:45 -07:00
NigeGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
bedc71ebdc fix(llm): cap retry-after delays (#1351)
* fix(llm): cap retry-after delays

* Update src/llm/retry.rs

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-18 11:33:38 -07:00
NigeandGitHub e9b0823db9 fix(setup): remove nonexistent webhook secret command hint (#1349)
* fix(setup): remove nonexistent webhook secret command hint

* test(setup): cover webhook secret onboarding hint
2026-03-18 11:33:31 -07:00
Henry ParkandGitHub 428303af11 Redesign routine create requests for LLMs (#1147)
* Redesign routine create requests for LLMs

* Fix panic-check false positives in routine tests

* Tighten routine schema requirements

* Tighten routine schema tests

* Mark test assertions safe for CI scan

* Align test assertions with panic scan

* Polish routine schema metadata

* Simplify routine test assertions

* Improve tool discovery guidance

* Clarify lightweight routine delivery prompts

* Fix routine delivery target defaults
2026-03-18 09:04:00 -07:00
Henry ParkandGitHub 9bb05d2dcd Merge pull request #1285 from nearai/staging-promote/5c56032b-23178585631
chore: promote staging to main (2026-03-17 04:34 UTC)
2026-03-17 08:43:16 -07:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
7a4673c11e chore: update WASM artifact SHA256 checksums [skip ci] (#1297)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-16 23:13:13 -07:00
Henry ParkandGitHub 059fd97ce6 Merge pull request #1296 from nearai/staging-promote/2784cef4-23180012288
chore: promote staging to staging-promote/5c56032b-23178585631 (2026-03-17 05:32 UTC)
2026-03-16 22:34:14 -07:00
Henry ParkandGitHub ef5715cb96 fix: mark ironclaw_safety unpublished in release-plz (#1286) 2026-03-16 21:55:49 -07:00
github-actions[bot]GitHubironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com>
1ad1335fea chore: release v0.19.0 (#973)
Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com>
2026-03-16 21:39:47 -07:00
Henry ParkandGitHub deee24c65b Merge pull request #1197 from nearai/staging-promote/e0f393bf-23105705354
chore: promote staging to staging-promote/e74214dc-23104855330 (2026-03-15 07:18 UTC)
2026-03-16 20:39:40 -07:00
Henry ParkandGitHub 2b6404e8b2 Merge pull request #1276 from nearai/staging-promote/90655277-23176260323
chore: promote staging to staging-promote/e0f393bf-23105705354 (2026-03-17 02:56 UTC)
2026-03-16 20:25:28 -07:00
Henry ParkandGitHub 0e7eb7f390 Merge pull request #1279 from nearai/staging-promote/4675e961-23176922462
chore: promote staging to staging-promote/90655277-23176260323 (2026-03-17 03:24 UTC)
2026-03-16 20:25:16 -07:00
Henry ParkandGitHub d3e392ac16 Merge pull request #1267 from nearai/staging-promote/1f209db0-23170138026
chore: promote staging to staging-promote/e0f393bf-23105705354 (2026-03-16 23:06 UTC)
2026-03-16 16:43:27 -07:00
Henry ParkandGitHub 47659e9545 Merge pull request #1268 from nearai/staging-promote/c6128f4e-23170341776
chore: promote staging to staging-promote/1f209db0-23170138026 (2026-03-16 23:13 UTC)
2026-03-16 16:43:17 -07:00
Henry ParkandGitHub cb5f9796aa Merge pull request #1260 from nearai/staging-promote/878a67cd-23166116689
chore: promote staging to staging-promote/e0f393bf-23105705354 (2026-03-16 21:11 UTC)
2026-03-16 15:27:34 -07:00
Henry ParkandGitHub 2961e70da1 Merge pull request #1263 from nearai/staging-promote/026beb00-23168216794
chore: promote staging to staging-promote/878a67cd-23166116689 (2026-03-16 22:08 UTC)
2026-03-16 15:27:17 -07:00
Henry ParkandGitHub e397546902 Merge pull request #1212 from nearai/staging-promote/3f874e73-23119318963
chore: promote staging to staging-promote/e0f393bf-23105705354 (2026-03-15 21:06 UTC)
2026-03-16 13:30:24 -07:00
Henry ParkandGitHub 409a2ab9c0 Merge pull request #1231 from nearai/staging-promote/57c397bd-23120362128
chore: promote staging to staging-promote/3f874e73-23119318963 (2026-03-15 22:04 UTC)
2026-03-16 13:29:50 -07:00
Henry ParkandGitHub 8ba8def607 Merge pull request #1239 from nearai/staging-promote/946c040f-23134229055
chore: promote staging to staging-promote/57c397bd-23120362128 (2026-03-16 08:20 UTC)
2026-03-16 13:29:33 -07:00
Henry ParkandGitHub e212c0066d Merge pull request #1246 from nearai/staging-promote/63a23550-23151342222
chore: promote staging to staging-promote/946c040f-23134229055 (2026-03-16 15:23 UTC)
2026-03-16 13:29:07 -07:00
fe53f6993f chore: promote staging to staging-promote/57c397bd-23120362128 (2026-03-16 05:35 UTC) (#1236)
* refactor(setup): extract init logic from wizard into owning modules (#1210)

* refactor(setup): extract init logic from wizard into owning modules

Move database, LLM model discovery, and secrets initialization logic
out of the setup wizard and into their owning modules, following the
CLAUDE.md principle that module-specific initialization must live in
the owning module as a public factory function.

Database (src/db/mod.rs, src/config/database.rs):
- Add DatabaseConfig::from_postgres_url() and from_libsql_path()
- Add connect_without_migrations() for connectivity testing
- Add validate_postgres() returning structured PgDiagnostic results

LLM (src/llm/models.rs — new file):
- Extract 8 model-fetching functions from wizard.rs (~380 lines)
- fetch_anthropic_models, fetch_openai_models, fetch_ollama_models,
  fetch_openai_compatible_models, build_nearai_model_fetch_config,
  and OpenAI sorting/filtering helpers

Secrets (src/secrets/mod.rs):
- Add resolve_master_key() unifying env var + keychain resolution
- Add crypto_from_hex() convenience wrapper

Wizard restructuring (src/setup/wizard.rs):
- Replace cfg-gated db_pool/db_backend fields with generic
  db: Option<Arc<dyn Database>> + db_handles: Option<DatabaseHandles>
- Delete 6 backend-specific methods (reconnect_postgres/libsql,
  test_database_connection_postgres/libsql, run_migrations_postgres/
  libsql, create_postgres/libsql_secrets_store)
- Simplify persist_settings, try_load_existing_settings,
  persist_session_to_db, init_secrets_context to backend-agnostic
  implementations using the new module factories
- Eliminate all references to deadpool_postgres, PoolConfig,
  LibSqlBackend, Store::from_pool, refinery::embed_migrations

Net: -878 lines from wizard, +395 lines in owning modules, +378 new.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test(settings): add wizard re-run regression tests

Add 10 tests covering settings preservation during wizard re-runs:
- provider_only rerun preserves channels/embeddings/heartbeat
- channels_only rerun preserves provider/model/embeddings
- quick mode rerun preserves prior channels and heartbeat
- full rerun same provider preserves model through merge
- full rerun different provider clears model through merge
- incremental persist doesn't clobber prior steps
- switching DB backend allows fresh connection settings
- merge preserves true booleans when overlay has default false
- embeddings survive rerun that skips step 5

These cover the scenarios where re-running the wizard would
previously risk resetting models, providers, or channel settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(setup): eliminate cfg(feature) gates from wizard methods

Replace compile-time #[cfg(feature)] dispatch in the wizard with
runtime dispatch via DatabaseBackend enum and cfg!() macro constants.

- Merge step_database_postgres + step_database_libsql into step_database
  using runtime backend selection
- Rewrite auto_setup_database without feature gates
- Remove cfg(feature = "postgres") from mask_password_in_url (pure fn)
- Remove cfg(feature = "postgres") from test_mask_password_in_url

Only one internal #[cfg(feature = "postgres")] remains: guarding the
call to db::validate_postgres() which is itself feature-gated.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(db): fold PG validation into connect_without_migrations

Move PostgreSQL prerequisite validation (version >= 15, pgvector)
from the wizard into connect_without_migrations() in the db module.
The validation now returns DatabaseError directly with user-facing
messages, eliminating the PgDiagnostic enum and the last
#[cfg(feature)] gate from the wizard.

The wizard's test_database_connection() is now a 5-line method that
calls the db module factory and stores the result.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review comments [skip-regression-check]

- Use .as_ref().map() to avoid partial move of db_config.libsql_path
  (gemini-code-assist)
- Default to available backend when DATABASE_BACKEND is invalid, not
  unconditionally to Postgres which may not be compiled (Copilot)
- Match DatabaseBackend::Postgres explicitly instead of _ => wildcard
  in connect_with_handles, connect_without_migrations, and
  create_secrets_store to avoid silently routing LibSql configs through
  the Postgres path when libsql feature is disabled (Copilot)
- Upgrade Ollama connection failure log from info to warn with the
  base URL for better visibility in wizard UX (Copilot)
- Clarify crypto_from_hex doc: SecretsCrypto validates key length,
  not hex encoding (Copilot)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address zmanian's PR review feedback [skip-regression-check]

- Update src/setup/README.md to reflect Arc<dyn Database> flow
- Remove stale "Test PostgreSQL connection" doc comment
- Replace unwrap_or(0) in validate_postgres with descriptive error
- Add NearAiConfig::for_model_discovery() constructor
- Narrow pub to pub(crate) for internal model helpers

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address Copilot review comments (quick-mode postgres gate, empty env vars) [skip-regression-check]

- Gate DATABASE_URL auto-detection on POSTGRES_AVAILABLE in quick mode
  so libsql-only builds don't attempt a postgres connection
- Match empty-env-var filtering in key source detection to align with
  resolve_master_key() behavior
- Filter empty strings to None in DatabaseConfig::from_libsql_path()
  for turso_url/turso_token

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>

* fix: Telegram bot token validation fails intermittently (HTTP 404) (#1166)

* fix: Telegram bot token validation fails intermittently (HTTP 404)

* fix: code style

* fix

* fix

* fix

* review fix

---------

Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Nick Pismenkov <[email protected]>
2026-03-16 08:09:34 +00:00
115 changed files with 10860 additions and 2608 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ NEARAI_AUTH_URL=https://private.near.ai
# === MiniMax ===
# LLM_BACKEND=minimax
# MINIMAX_API_KEY=...
# MINIMAX_MODEL=MiniMax-M2.5
# MINIMAX_MODEL=MiniMax-M2.7
# MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China
# === Anthropic Direct ===
+1 -1
View File
@@ -174,7 +174,7 @@ jobs:
- name: Run E2E tests
run: |
pytest tests/e2e/ -v -x --timeout=120
pytest tests/e2e/ -v --timeout=120
env:
RUST_LOG: ironclaw=info
RUST_BACKTRACE: "1"
+41 -6
View File
@@ -43,12 +43,42 @@ jobs:
fi
fi
if [ "$IS_FIX" = false ]; then
echo "Not a fix PR — skipping regression test check."
# --- 1b. Does this PR touch high-risk state machine or resilience code? ---
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
TOUCHES_HIGH_RISK=false
HIGH_RISK_PATTERNS=(
"src/context/state.rs"
"src/agent/session.rs"
"src/llm/circuit_breaker.rs"
"src/llm/retry.rs"
"src/llm/failover.rs"
"src/agent/self_repair.rs"
"src/agent/agentic_loop.rs"
"src/tools/execute.rs"
"crates/ironclaw_safety/src/"
)
for pattern in "${HIGH_RISK_PATTERNS[@]}"; do
if echo "$CHANGED_FILES" | grep -q "$pattern"; then
TOUCHES_HIGH_RISK=true
echo "High-risk file matched: $pattern"
break
fi
done
# Skip only if NEITHER condition holds — no double-firing on fix PRs
if [ "$IS_FIX" = false ] && [ "$TOUCHES_HIGH_RISK" = false ]; then
echo "Not a fix PR and no high-risk files changed — skipping."
exit 0
fi
echo "Fix PR detected."
if [ "$IS_FIX" = true ]; then
echo "Fix PR detected."
fi
if [ "$TOUCHES_HIGH_RISK" = true ]; then
echo "High-risk state machine or resilience code modified."
fi
# --- 2. Skip label or commit message marker ---
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
@@ -63,8 +93,6 @@ jobs:
fi
# --- 3. Exempt static-only / docs-only changes ---
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
if [ -z "$CHANGED_FILES" ]; then
echo "No changed files — skipping."
exit 0
@@ -110,5 +138,12 @@ jobs:
fi
# --- 5. No tests found ---
echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible."
if [ "$IS_FIX" = true ]; then
echo "::warning::This PR looks like a bug fix but contains no test changes."
fi
if [ "$TOUCHES_HIGH_RISK" = true ]; then
echo "::warning::This PR modifies high-risk state machine or resilience code but includes no test changes."
fi
echo "::warning::Please add tests exercising the changed behavior, or apply the 'skip-regression-check' label if not feasible."
exit 1
+199
View File
@@ -7,6 +7,205 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.21.0](https://github.com/nearai/ironclaw/compare/v0.20.0...v0.21.0) - 2026-03-20
### Added
- structured fallback deliverables for failed/stuck jobs ([#236](https://github.com/nearai/ironclaw/pull/236))
- LRU embedding cache for workspace search ([#1423](https://github.com/nearai/ironclaw/pull/1423))
- receive relay events via webhook callbacks ([#1254](https://github.com/nearai/ironclaw/pull/1254))
### Fixed
- bump Feishu channel version for promotion
- *(approval)* make "always" auto-approve work for credentialed HTTP requests ([#1257](https://github.com/nearai/ironclaw/pull/1257))
- skip NEAR AI session check when backend is not nearai ([#1413](https://github.com/nearai/ironclaw/pull/1413))
### Other
- Make hosted OAuth and MCP auth generic ([#1375](https://github.com/nearai/ironclaw/pull/1375))
## [0.20.0](https://github.com/nearai/ironclaw/compare/v0.19.0...v0.20.0) - 2026-03-19
### Added
- *(self-repair)* wire stuck_threshold, store, and builder ([#712](https://github.com/nearai/ironclaw/pull/712))
- *(testing)* add FaultInjector framework for StubLlm ([#1233](https://github.com/nearai/ironclaw/pull/1233))
- *(gateway)* unified settings page with subtabs ([#1191](https://github.com/nearai/ironclaw/pull/1191))
- upgrade MiniMax default model to M2.7 ([#1357](https://github.com/nearai/ironclaw/pull/1357))
### Fixed
- navigate telegram E2E tests to channels subtab ([#1408](https://github.com/nearai/ironclaw/pull/1408))
- add missing `builder` field and update E2E extensions tab navigation ([#1400](https://github.com/nearai/ironclaw/pull/1400))
- remove debug_assert guards that panic on valid error paths ([#1385](https://github.com/nearai/ironclaw/pull/1385))
- address valid review comments from PR #1359 ([#1380](https://github.com/nearai/ironclaw/pull/1380))
- full_job routine runs stay running until linked job completion ([#1374](https://github.com/nearai/ironclaw/pull/1374))
- full_job routine concurrency tracks linked job lifetime ([#1372](https://github.com/nearai/ironclaw/pull/1372))
- remove -x from coverage pytest to prevent suite-blocking failures ([#1360](https://github.com/nearai/ironclaw/pull/1360))
- add debug_assert invariant guards to critical code paths ([#1312](https://github.com/nearai/ironclaw/pull/1312))
- *(mcp)* retry after missing session id errors ([#1355](https://github.com/nearai/ironclaw/pull/1355))
- *(telegram)* preserve polling after secret-blocked updates ([#1353](https://github.com/nearai/ironclaw/pull/1353))
- *(llm)* cap retry-after delays ([#1351](https://github.com/nearai/ironclaw/pull/1351))
- *(setup)* remove nonexistent webhook secret command hint ([#1349](https://github.com/nearai/ironclaw/pull/1349))
- Rate limiter returns retry after None instead of a duration ([#1269](https://github.com/nearai/ironclaw/pull/1269))
### Other
- bump telegram channel version to 0.2.5 ([#1410](https://github.com/nearai/ironclaw/pull/1410))
- *(ci)* enforce test requirement for state machine and resilience changes ([#1230](https://github.com/nearai/ironclaw/pull/1230)) ([#1304](https://github.com/nearai/ironclaw/pull/1304))
- Fix duplicate LLM responses for matched event routines ([#1275](https://github.com/nearai/ironclaw/pull/1275))
- add Japanese README ([#1306](https://github.com/nearai/ironclaw/pull/1306))
- *(ci)* add coverage gates via codecov.yml ([#1228](https://github.com/nearai/ironclaw/pull/1228)) ([#1291](https://github.com/nearai/ironclaw/pull/1291))
- Redesign routine create requests for LLMs ([#1147](https://github.com/nearai/ironclaw/pull/1147))
## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
### Added
- verify telegram owner during hot activation ([#1157](https://github.com/nearai/ironclaw/pull/1157))
- *(config)* unify config resolution with Settings fallback (Phase 2, #1119) ([#1203](https://github.com/nearai/ironclaw/pull/1203))
- *(sandbox)* add retry logic for transient container failures ([#1232](https://github.com/nearai/ironclaw/pull/1232))
- *(heartbeat)* fire_at time-of-day scheduling with IANA timezone ([#1029](https://github.com/nearai/ironclaw/pull/1029))
- Reuse Codex CLI OAuth tokens for ChatGPT backend LLM calls ([#693](https://github.com/nearai/ironclaw/pull/693))
- add pre-push git hook with delta lint mode ([#833](https://github.com/nearai/ironclaw/pull/833))
- *(cli)* add `logs` command for gateway log access ([#1105](https://github.com/nearai/ironclaw/pull/1105))
- add Feishu/Lark WASM channel plugin ([#1110](https://github.com/nearai/ironclaw/pull/1110))
- add Criterion benchmarks for safety layer hot paths ([#836](https://github.com/nearai/ironclaw/pull/836))
- *(routines)* human-readable cron schedule summaries in web UI ([#1154](https://github.com/nearai/ironclaw/pull/1154))
- *(web)* add follow-up suggestion chips and ghost text ([#1156](https://github.com/nearai/ironclaw/pull/1156))
- *(ci)* include commit history in staging promotion PRs ([#952](https://github.com/nearai/ironclaw/pull/952))
- *(tools)* add reusable sensitive JSON redaction helper ([#457](https://github.com/nearai/ironclaw/pull/457))
- configurable hybrid search fusion strategy ([#234](https://github.com/nearai/ironclaw/pull/234))
- *(cli)* add cron subcommand for managing scheduled routines ([#1017](https://github.com/nearai/ironclaw/pull/1017))
- adds context-llm tool support ([#616](https://github.com/nearai/ironclaw/pull/616))
- *(web-chat)* add hover copy button for user/assistant messages ([#948](https://github.com/nearai/ironclaw/pull/948))
- add Slack approval buttons for tool execution in DMs ([#796](https://github.com/nearai/ironclaw/pull/796))
- enhance HTTP tool parameter parsing ([#911](https://github.com/nearai/ironclaw/pull/911))
- *(routines)* enable tool access in lightweight routine execution ([#257](https://github.com/nearai/ironclaw/pull/257)) ([#730](https://github.com/nearai/ironclaw/pull/730))
- add MiniMax as a built-in LLM provider ([#940](https://github.com/nearai/ironclaw/pull/940))
- *(cli)* add `ironclaw channels list` subcommand ([#933](https://github.com/nearai/ironclaw/pull/933))
- *(cli)* add `ironclaw skills list/search/info` subcommands ([#918](https://github.com/nearai/ironclaw/pull/918))
- add cargo-deny for supply chain safety ([#834](https://github.com/nearai/ironclaw/pull/834))
- *(setup)* display ASCII art banner during onboarding ([#851](https://github.com/nearai/ironclaw/pull/851))
- *(extensions)* unify auth and configure into single entrypoint ([#677](https://github.com/nearai/ironclaw/pull/677))
- *(i18n)* Add internationalization support with Chinese and English translations ([#929](https://github.com/nearai/ironclaw/pull/929))
- Import OpenClaw memory, history and settings ([#903](https://github.com/nearai/ironclaw/pull/903))
### Fixed
- jobs limit ([#1274](https://github.com/nearai/ironclaw/pull/1274))
- misleading UI message ([#1265](https://github.com/nearai/ironclaw/pull/1265))
- bump channel registry versions for promotion ([#1264](https://github.com/nearai/ironclaw/pull/1264))
- cover staging CI all-features and routine batch regressions ([#1256](https://github.com/nearai/ironclaw/pull/1256))
- resolve merge conflict fallout and missing config fields
- web/CLI routine mutations do not refresh live event trigger cache ([#1255](https://github.com/nearai/ironclaw/pull/1255))
- *(jobs)* make completed->completed transition idempotent to prevent race errors ([#1068](https://github.com/nearai/ironclaw/pull/1068))
- *(llm)* persist refreshed Anthropic OAuth token after Keychain re-read ([#1213](https://github.com/nearai/ironclaw/pull/1213))
- *(worker)* prevent orphaned tool_results and fix parallel merging ([#1069](https://github.com/nearai/ironclaw/pull/1069))
- Telegram bot token validation fails intermittently (HTTP 404) ([#1166](https://github.com/nearai/ironclaw/pull/1166))
- *(security)* prevent metadata spoofing of internal job monitor flag ([#1195](https://github.com/nearai/ironclaw/pull/1195))
- *(security)* default webhook server to loopback when tunnel is configured ([#1194](https://github.com/nearai/ironclaw/pull/1194))
- *(auth)* avoid false success and block chat during pending auth ([#1111](https://github.com/nearai/ironclaw/pull/1111))
- *(config)* unify ChannelsConfig resolution to env > settings > default ([#1124](https://github.com/nearai/ironclaw/pull/1124))
- *(web-chat)* normalize chat copy to plain text ([#1114](https://github.com/nearai/ironclaw/pull/1114))
- *(skill)* treat empty url param as absent when installing skills ([#1128](https://github.com/nearai/ironclaw/pull/1128))
- preserve AuthError type in oauth_http_client cache ([#1152](https://github.com/nearai/ironclaw/pull/1152))
- *(web)* prevent Safari IME composition Enter from sending message ([#1140](https://github.com/nearai/ironclaw/pull/1140))
- *(mcp)* handle 400 auth errors, clear auth mode after OAuth, trim tokens ([#1158](https://github.com/nearai/ironclaw/pull/1158))
- eliminate panic paths in production code ([#1184](https://github.com/nearai/ironclaw/pull/1184))
- N+1 query pattern in event trigger loop (routine_engine) ([#1163](https://github.com/nearai/ironclaw/pull/1163))
- *(llm)* add stop_sequences parity for tool completions ([#1170](https://github.com/nearai/ironclaw/pull/1170))
- *(channels)* use live owner binding during wasm hot activation ([#1171](https://github.com/nearai/ironclaw/pull/1171))
- Non-transactional multi-step context updates between metadata/to… ([#1161](https://github.com/nearai/ironclaw/pull/1161))
- *(webhook)* avoid lock-held awaits in server lifecycle paths ([#1168](https://github.com/nearai/ironclaw/pull/1168))
- Google Sheets returns 403 PERMISSION_DENIED after completing OAuth ([#1164](https://github.com/nearai/ironclaw/pull/1164))
- HTTP webhook secret transmitted in request body rather than via header, docs inconsistency and security concern ([#1162](https://github.com/nearai/ironclaw/pull/1162))
- *(ci)* exclude ironclaw_safety from release automation ([#1146](https://github.com/nearai/ironclaw/pull/1146))
- *(registry)* bump versions for github, web-search, and discord extensions ([#1106](https://github.com/nearai/ironclaw/pull/1106))
- *(mcp)* address 14 audit findings across MCP module ([#1094](https://github.com/nearai/ironclaw/pull/1094))
- *(http)* replace .expect() with match in webhook handler ([#1133](https://github.com/nearai/ironclaw/pull/1133))
- *(time)* treat empty timezone string as absent ([#1127](https://github.com/nearai/ironclaw/pull/1127))
- 5 critical/high-priority bugs (auth bypass, relay failures, unbounded recursion, context growth) ([#1083](https://github.com/nearai/ironclaw/pull/1083))
- *(ci)* checkout promotion PR head for metadata refresh ([#1097](https://github.com/nearai/ironclaw/pull/1097))
- *(ci)* add missing attachments field and crates/ dir to Dockerfiles ([#1100](https://github.com/nearai/ironclaw/pull/1100))
- *(registry)* bump telegram channel version for capabilities change ([#1064](https://github.com/nearai/ironclaw/pull/1064))
- *(ci)* repair staging promotion workflow behavior ([#1091](https://github.com/nearai/ironclaw/pull/1091))
- *(wasm)* address #1086 review followups -- description hint and coercion safety ([#1092](https://github.com/nearai/ironclaw/pull/1092))
- *(ci)* repair staging-ci workflow parsing ([#1090](https://github.com/nearai/ironclaw/pull/1090))
- *(extensions)* fix lifecycle bugs + comprehensive E2E tests ([#1070](https://github.com/nearai/ironclaw/pull/1070))
- add tool_info schema discovery for WASM tools ([#1086](https://github.com/nearai/ironclaw/pull/1086))
- resolve bug_bash UX/logging issues (#1054 #1055 #1058) ([#1072](https://github.com/nearai/ironclaw/pull/1072))
- *(http)* fail closed when webhook secret is missing at runtime ([#1075](https://github.com/nearai/ironclaw/pull/1075))
- *(service)* set CLI_ENABLED=false in macOS launchd plist ([#1079](https://github.com/nearai/ironclaw/pull/1079))
- relax approval requirements for low-risk tools ([#922](https://github.com/nearai/ironclaw/pull/922))
- *(web)* make approval requests appear without page reload ([#996](https://github.com/nearai/ironclaw/pull/996)) ([#1073](https://github.com/nearai/ironclaw/pull/1073))
- *(routines)* run cron checks immediately on ticker startup ([#1066](https://github.com/nearai/ironclaw/pull/1066))
- *(web)* recompute cron next_fire_at when re-enabling routines ([#1080](https://github.com/nearai/ironclaw/pull/1080))
- *(memory)* reject absolute filesystem paths with corrective routing ([#934](https://github.com/nearai/ironclaw/pull/934))
- remove all inline event handlers for CSP script-src compliance ([#1063](https://github.com/nearai/ironclaw/pull/1063))
- *(mcp)* include OAuth state parameter in authorization URLs ([#1049](https://github.com/nearai/ironclaw/pull/1049))
- *(mcp)* open MCP OAuth in same browser as gateway ([#951](https://github.com/nearai/ironclaw/pull/951))
- *(deploy)* harden production container and bootstrap security ([#1014](https://github.com/nearai/ironclaw/pull/1014))
- release lock guards before awaiting channel send ([#869](https://github.com/nearai/ironclaw/pull/869)) ([#1003](https://github.com/nearai/ironclaw/pull/1003))
- *(registry)* use versioned artifact URLs and checksums for all WASM manifests ([#1007](https://github.com/nearai/ironclaw/pull/1007))
- *(setup)* preserve model selection on provider re-run ([#679](https://github.com/nearai/ironclaw/pull/679)) ([#987](https://github.com/nearai/ironclaw/pull/987))
- *(mcp)* attach session manager for non-OAuth HTTP clients ([#793](https://github.com/nearai/ironclaw/pull/793)) ([#986](https://github.com/nearai/ironclaw/pull/986))
- *(security)* migrate webhook auth to HMAC-SHA256 signature header ([#970](https://github.com/nearai/ironclaw/pull/970))
- *(security)* make unsafe env::set_var calls safe with explicit invariants ([#968](https://github.com/nearai/ironclaw/pull/968))
- *(security)* require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy ([#967](https://github.com/nearai/ironclaw/pull/967))
- *(security)* add Content-Security-Policy header to web gateway ([#966](https://github.com/nearai/ironclaw/pull/966))
- *(test)* stabilize openai compat oversized-body regression ([#839](https://github.com/nearai/ironclaw/pull/839))
- *(ci)* disambiguate WASM bundle filenames to prevent tool/channel collision ([#964](https://github.com/nearai/ironclaw/pull/964))
- *(setup)* validate channel credentials during setup ([#684](https://github.com/nearai/ironclaw/pull/684))
- drain tunnel pipes to prevent zombie process ([#735](https://github.com/nearai/ironclaw/pull/735))
- *(mcp)* header safety validation and Authorization conflict bug from #704 ([#752](https://github.com/nearai/ironclaw/pull/752))
- *(agent)* block thread_id-based context pollution across users ([#760](https://github.com/nearai/ironclaw/pull/760))
- *(mcp)* stdio/unix transports skip initialize handshake ([#890](https://github.com/nearai/ironclaw/pull/890)) ([#935](https://github.com/nearai/ironclaw/pull/935))
- *(setup)* drain residual events and filter key kind in onboard prompts ([#937](https://github.com/nearai/ironclaw/pull/937)) ([#949](https://github.com/nearai/ironclaw/pull/949))
- *(security)* load WASM tool description and schema from capabilities.json ([#520](https://github.com/nearai/ironclaw/pull/520))
- *(security)* resolve DNS once and reuse for SSRF validation to prevent rebinding ([#518](https://github.com/nearai/ironclaw/pull/518))
- *(security)* replace regex HTML sanitizer with DOMPurify to prevent XSS ([#510](https://github.com/nearai/ironclaw/pull/510))
- *(ci)* improve Claude Code review reliability ([#955](https://github.com/nearai/ironclaw/pull/955))
- *(ci)* run gated test jobs during staging CI ([#956](https://github.com/nearai/ironclaw/pull/956))
- *(ci)* prevent staging-ci tag failure and chained PR auto-close ([#900](https://github.com/nearai/ironclaw/pull/900))
- *(ci)* WASM WIT compat sqlite3 duplicate symbol conflict ([#953](https://github.com/nearai/ironclaw/pull/953))
- resolve deferred review items from PRs #883, #848, #788 ([#915](https://github.com/nearai/ironclaw/pull/915))
- *(web)* improve UX readability and accessibility in chat UI ([#910](https://github.com/nearai/ironclaw/pull/910))
### Other
- Fix Telegram auto-verify flow and routing ([#1273](https://github.com/nearai/ironclaw/pull/1273))
- *(e2e)* fix approval waiting regression coverage ([#1270](https://github.com/nearai/ironclaw/pull/1270))
- isolate heavy integration tests ([#1266](https://github.com/nearai/ironclaw/pull/1266))
- Merge branch 'main' into fix/resolve-conflicts
- Refactor owner scope across channels and fix default routing fallback ([#1151](https://github.com/nearai/ironclaw/pull/1151))
- *(extensions)* document relay manager init order ([#928](https://github.com/nearai/ironclaw/pull/928))
- *(setup)* extract init logic from wizard into owning modules ([#1210](https://github.com/nearai/ironclaw/pull/1210))
- mention MiniMax as built-in provider in all READMEs ([#1209](https://github.com/nearai/ironclaw/pull/1209))
- Fix schema-guided tool parameter coercion ([#1143](https://github.com/nearai/ironclaw/pull/1143))
- Make no-panics CI check test-aware ([#1160](https://github.com/nearai/ironclaw/pull/1160))
- *(mcp)* avoid reallocating SSE buffer on each chunk ([#1153](https://github.com/nearai/ironclaw/pull/1153))
- *(routines)* avoid full message history clone each tool iteration ([#1172](https://github.com/nearai/ironclaw/pull/1172))
- *(registry)* align manifest versions with published artifacts ([#1169](https://github.com/nearai/ironclaw/pull/1169))
- remove __pycache__ from repo and add to .gitignore ([#1177](https://github.com/nearai/ironclaw/pull/1177))
- *(registry)* move MCP servers from code to JSON manifests ([#1144](https://github.com/nearai/ironclaw/pull/1144))
- improve routine schema guidance ([#1089](https://github.com/nearai/ironclaw/pull/1089))
- add event-trigger routine e2e coverage ([#1088](https://github.com/nearai/ironclaw/pull/1088))
- enforce no .unwrap(), .expect(), or assert!() in production code ([#1087](https://github.com/nearai/ironclaw/pull/1087))
- periodic sync main into staging (resolved conflicts) ([#1098](https://github.com/nearai/ironclaw/pull/1098))
- fix formatting in cli/mod.rs and mcp/auth.rs ([#1071](https://github.com/nearai/ironclaw/pull/1071))
- Expose the shared agent session manager via AppComponents ([#532](https://github.com/nearai/ironclaw/pull/532))
- *(agent)* remove unnecessary Worker re-export ([#923](https://github.com/nearai/ironclaw/pull/923))
- Fix UTF-8 unsafe truncation in WASM emit_message ([#1015](https://github.com/nearai/ironclaw/pull/1015))
- extract safety module into ironclaw_safety crate ([#1024](https://github.com/nearai/ironclaw/pull/1024))
- Add Z.AI provider support for GLM-5 ([#938](https://github.com/nearai/ironclaw/pull/938))
- *(html_to_markdown)* refresh golden files after renderer bump ([#1016](https://github.com/nearai/ironclaw/pull/1016))
- Migrate GitHub webhook normalization into github tool ([#758](https://github.com/nearai/ironclaw/pull/758))
- Fix systemctl unit ([#472](https://github.com/nearai/ironclaw/pull/472))
- add Russian localization (README.ru.md) ([#850](https://github.com/nearai/ironclaw/pull/850))
- Add generic host-verified /webhook/tools/{tool} ingress ([#757](https://github.com/nearai/ironclaw/pull/757))
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
### Other
Generated
+1 -1
View File
@@ -3436,7 +3436,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.18.0"
version = "0.21.0"
dependencies = [
"aes-gcm",
"aho-corasick",
+1 -1
View File
@@ -20,7 +20,7 @@ exclude = [
[package]
name = "ironclaw"
version = "0.18.0"
version = "0.21.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
+1 -1
View File
@@ -465,7 +465,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Device pairing | ✅ | ❌ | |
| Tailscale identity | ✅ | ❌ | |
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth plus hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Per-group tool policies | ✅ | ❌ | |
+330
View File
@@ -0,0 +1,330 @@
<p align="center">
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
</p>
<h1 align="center">IronClaw</h1>
<p align="center">
<strong>あなたの味方になる、安全なパーソナルAIアシスタント</strong>
</p>
<p align="center">
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
<a href="#フィロソフィー">フィロソフィー</a> •
<a href="#機能">機能</a> •
<a href="#インストール">インストール</a> •
<a href="#設定">設定</a> •
<a href="#セキュリティ">セキュリティ</a> •
<a href="#アーキテクチャ">アーキテクチャ</a>
</p>
---
## フィロソフィー
IronClawはシンプルな原則に基づいて構築されています:**あなたのAIアシスタントは、あなたのために働くべきであり、あなたに不利益をもたらすべきではありません。**
AIシステムがデータの取り扱いについて不透明になり、企業の利益に沿って調整されることが増えている世界で、IronClawは異なるアプローチを取ります:
- **あなたのデータはあなたのもの** - すべての情報はローカルに保存・暗号化され、あなたの管理下から離れることはありません
- **設計段階からの透明性** - オープンソース、監査可能、隠れたテレメトリやデータ収集なし
- **自己拡張する能力** - ベンダーのアップデートを待たずに、新しいツールをその場で構築
- **多層防御** - 複数のセキュリティレイヤーがプロンプトインジェクションやデータ流出から保護
IronClawは、個人生活にも仕事にも本当に信頼できるAIアシスタントです。
## 機能
### セキュリティファースト
- **WASMサンドボックス** - 信頼されていないツールは、機能ベースの権限を持つ隔離されたWebAssemblyコンテナで実行
- **認証情報の保護** - シークレットはツールに公開されず、リーク検出付きでホスト境界で注入
- **プロンプトインジェクション防御** - パターン検出、コンテンツサニタイズ、ポリシー適用
- **エンドポイントの許可リスト** - HTTPリクエストは明示的に許可されたホストとパスのみに制限
### 常時利用可能
- **マルチチャネル** - REPL、HTTPウェブフック、WASMチャネル(Telegram、Slack)、Webゲートウェイ
- **Dockerサンドボックス** - ジョブごとのトークンとオーケストレーター/ワーカーパターンによる隔離されたコンテナ実行
- **Webゲートウェイ** - リアルタイムSSE/WebSocketストリーミング対応のブラウザUI
- **ルーティン** - cronスケジュール、イベントトリガー、ウェブフックハンドラーによるバックグラウンド自動化
- **ハートビートシステム** - 監視・保守タスクのためのプロアクティブなバックグラウンド実行
- **並列ジョブ** - 隔離されたコンテキストで複数のリクエストを同時に処理
- **自己修復** - スタックした操作の自動検出と復旧
### 自己拡張
- **動的ツール構築** - 必要なものを説明すると、IronClawがWASMツールとして構築
- **MCPプロトコル** - Model Context Protocolサーバーに接続して追加機能を利用
- **プラグインアーキテクチャ** - 再起動なしで新しいWASMツールやチャネルを追加
### 永続メモリ
- **ハイブリッド検索** - Reciprocal Rank Fusionを使用した全文検索+ベクトル検索
- **ワークスペースファイルシステム** - メモ、ログ、コンテキストのための柔軟なパスベースストレージ
- **アイデンティティファイル** - セッション間で一貫した人格と設定を維持
## インストール
### 前提条件
- Rust 1.85+
- PostgreSQL 15+ ([pgvector](https://github.com/pgvector/pgvector)拡張機能を含む)
- NEAR AIアカウント(セットアップウィザードで認証を処理)
## ダウンロードまたはビルド
最新のアップデートは[リリースページ](https://github.com/nearai/ironclaw/releases/)をご覧ください。
<details>
<summary>Windowsインストーラーでインストール(Windows</summary>
[Windowsインストーラー](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)をダウンロードして実行してください。
</details>
<details>
<summary>PowerShellスクリプトでインストール(Windows</summary>
```sh
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
```
</details>
<details>
<summary>シェルスクリプトでインストール(macOS、Linux、Windows/WSL</summary>
```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
```
</details>
<details>
<summary>Homebrewでインストール(macOS/Linux</summary>
```sh
brew install ironclaw
```
</details>
<details>
<summary>ソースコードからコンパイル(Windows、Linux、macOSでCargo</summary>
`cargo`でインストールします。コンピューターに[Rust](https://rustup.rs)がインストールされていることを確認してください。
```bash
# リポジトリをクローン
git clone https://github.com/nearai/ironclaw.git
cd ironclaw
# ビルド
cargo build --release
# テストを実行
cargo test
```
**フルリリース**(チャネルソースを変更した後)の場合、まず`./scripts/build-all.sh`を実行してチャネルを再ビルドしてください。
</details>
### データベースのセットアップ
```bash
# データベースを作成
createdb ironclaw
# pgvectorを有効化
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
```
## 設定
セットアップウィザードを実行してIronClawを設定します:
```bash
ironclaw onboard
```
ウィザードは、データベース接続、NEAR AI認証(ブラウザOAuth経由)、シークレットの暗号化(システムキーチェーンを使用)を処理します。設定は接続されたデータベースに永続化されます。ブートストラップ変数(例:`DATABASE_URL``LLM_BACKEND`)は、データベース接続前に利用できるよう`~/.ironclaw/.env`に書き込まれます。
### 代替LLMプロバイダー
IronClawはデフォルトでNEAR AIを使用しますが、多くのLLMプロバイダーをすぐに利用できます。組み込みプロバイダーには**Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral**、**Ollama**(ローカル)が含まれます。**OpenRouter**300以上のモデル)、**Together AI**、**Fireworks AI**、セルフホストサーバー(**vLLM**、**LiteLLM**)などのOpenAI互換サービスもサポートされています。
ウィザードでプロバイダーを選択するか、環境変数を直接設定してください:
```env
# 例:MiniMax(組み込み、204Kコンテキスト)
LLM_BACKEND=minimax
MINIMAX_API_KEY=...
# 例:OpenAI互換エンドポイント
LLM_BACKEND=openai_compatible
LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
完全なプロバイダーガイドは[docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md)をご覧ください。
## セキュリティ
IronClawは、データを保護し悪用を防ぐために多層防御を実装しています。
### WASMサンドボックス
すべての信頼されていないツールは、隔離されたWebAssemblyコンテナで実行されます:
- **機能ベースの権限** - HTTP、シークレット、ツール呼び出しの明示的なオプトイン
- **エンドポイントの許可リスト** - 許可されたホスト/パスへのHTTPリクエストのみ
- **認証情報の注入** - シークレットはホスト境界で注入され、WASMコードに公開されない
- **リーク検出** - リクエストとレスポンスのシークレット流出試行をスキャン
- **レート制限** - 悪用防止のためのツールごとのリクエスト制限
- **リソース制限** - メモリ、CPU、実行時間の制約
```
WASM ──► 許可リスト ──► リーク ──► 認証情報 ──► リクエスト ──► リーク ──► WASM
バリデーター スキャン 注入 実行 スキャン
(リクエスト) (レスポンス)
```
### プロンプトインジェクション防御
外部コンテンツは複数のセキュリティレイヤーを通過します:
- パターンベースのインジェクション試行検出
- コンテンツのサニタイズとエスケープ
- 重要度レベル付きポリシールール(ブロック/警告/レビュー/サニタイズ)
- 安全なLLMコンテキスト注入のためのツール出力ラッピング
### データ保護
- すべてのデータはローカルのPostgreSQLデータベースに保存
- AES-256-GCMでシークレットを暗号化
- テレメトリ、分析、データ共有なし
- すべてのツール実行の完全な監査ログ
## アーキテクチャ
```
┌────────────────────────────────────────────────────────────────┐
│ チャネル │
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ REPL │ │ HTTP │ │WASMチャネル │ │ Web │ │
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ ゲートウェイ│ │
│ │ │ │ │(SSE + WS) │ │
│ │ │ │ └──────┬──────┘ │
│ └─────────┴──────────────┴────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ エージェントループ │ インテントルーティング│
│ └────┬──────────┬───┘ │
│ │ │ │
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
│ │ スケジューラー │ │ ルーティン │ │
│ │ (並列ジョブ) │ │ エンジン │ │
│ └──────┬────────┘ │(cron,event,wh) │ │
│ │ └────────┬─────────┘ │
│ ┌─────────────┼────────────────────┘ │
│ │ │ │
│ ┌───▼─────┐ ┌────▼────────────────┐ │
│ │ ローカル │ │ オーケストレーター │ │
│ │ ワーカー │ │ ┌───────────────┐ │ │
│ │(プロセス │ │ │ Docker │ │ │
│ │ 内) │ │ │ サンドボックス│ │ │
│ └───┬─────┘ │ │ コンテナ │ │ │
│ │ │ │ ┌───────────┐ │ │ │
│ │ │ │ │Worker / CC│ │ │ │
│ │ │ │ └───────────┘ │ │ │
│ │ │ └───────────────┘ │ │
│ │ └─────────┬───────────┘ │
│ └──────────────────┤ │
│ │ │
│ ┌───────────▼──────────┐ │
│ │ ツールレジストリ │ │
│ │ 組み込み, MCP, WASM │ │
│ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
```
### コアコンポーネント
| コンポーネント | 目的 |
|---------------|------|
| **エージェントループ** | メインのメッセージ処理とジョブの調整 |
| **ルーター** | ユーザーの意図を分類(コマンド、クエリ、タスク) |
| **スケジューラー** | 優先度付きの並列ジョブ実行を管理 |
| **ワーカー** | LLM推論とツール呼び出しでジョブを実行 |
| **オーケストレーター** | コンテナのライフサイクル、LLMプロキシ、ジョブごとの認証 |
| **Webゲートウェイ** | チャット、メモリ、ジョブ、ログ、拡張機能、ルーティンのブラウザUI |
| **ルーティンエンジン** | スケジュール(cron)とリアクティブ(イベント、ウェブフック)のバックグラウンドタスク |
| **ワークスペース** | ハイブリッド検索付き永続メモリ |
| **セーフティレイヤー** | プロンプトインジェクション防御とコンテンツサニタイズ |
## 使い方
```bash
# 初回セットアップ(データベース、認証などを設定)
ironclaw onboard
# インタラクティブREPLを起動
cargo run
# デバッグログ付き
RUST_LOG=ironclaw=debug cargo run
```
## 開発
```bash
# コードフォーマット
cargo fmt
# リント
cargo clippy --all --benches --tests --examples --all-features
# テスト実行
createdb ironclaw_test
cargo test
# 特定のテストを実行
cargo test test_name
```
- **Telegramチャネル**: セットアップとDMペアリングについては[docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md)を参照してください。
- **チャネルソースの変更**: `cargo build`の前に`./channels-src/telegram/build.sh`を実行して、更新されたWASMをバンドルしてください。
## OpenClawの系譜
IronClawは[OpenClaw](https://github.com/openclaw/openclaw)にインスパイアされたRust再実装です。完全な対応表は[FEATURE_PARITY.md](FEATURE_PARITY.md)をご覧ください。
主な違い:
- **Rust vs TypeScript** - ネイティブパフォーマンス、メモリ安全性、シングルバイナリ
- **WASMサンドボックス vs Docker** - 軽量、機能ベースのセキュリティ
- **PostgreSQL vs SQLite** - 本番環境対応の永続化
- **セキュリティファースト設計** - 複数の防御レイヤー、認証情報の保護
## ライセンス
以下のいずれかのライセンスの下で提供されています:
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
- MIT License ([LICENSE-MIT](LICENSE-MIT))
お好みに応じて選択してください。
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
+87 -13
View File
@@ -206,9 +206,17 @@ struct FeishuApiResponse<T> {
data: Option<T>,
}
/// Tenant access token response.
#[derive(Debug, Default, Deserialize)]
struct TenantAccessTokenData {
/// Tenant access token response (flat format).
///
/// Unlike most Feishu APIs that nest results under `data`, the
/// `/auth/v3/tenant_access_token/internal` endpoint returns `code`, `msg`,
/// `tenant_access_token`, and `expire` at the top level.
#[derive(Debug, Deserialize)]
struct TenantAccessTokenResponse {
#[serde(default)]
code: i32,
#[serde(default)]
msg: String,
tenant_access_token: String,
expire: i64,
}
@@ -770,9 +778,8 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
));
}
let token_resp: FeishuApiResponse<TenantAccessTokenData> =
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse token response: {}", e))?;
let token_resp: TenantAccessTokenResponse = serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse token response: {}", e))?;
if token_resp.code != 0 {
return Err(format!(
@@ -781,23 +788,33 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
));
}
let data = token_resp
.data
.ok_or_else(|| "Token response missing data".to_string())?;
if token_resp.tenant_access_token.is_empty() {
return Err("Token response missing tenant_access_token".to_string());
}
if token_resp.expire <= 0 {
return Err(format!(
"Token response has invalid expire value: {}",
token_resp.expire
));
}
// Cache the token with expiry.
let now = channel_host::now_millis();
let expiry = now + (data.expire as u64) * 1000;
let expiry = now.saturating_add((token_resp.expire as u64).saturating_mul(1000));
let _ = channel_host::workspace_write(TOKEN_PATH, &data.tenant_access_token);
let _ = channel_host::workspace_write(TOKEN_PATH, &token_resp.tenant_access_token);
let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string());
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Tenant access token refreshed, expires in {}s", data.expire),
&format!(
"Tenant access token refreshed, expires in {}s",
token_resp.expire
),
);
Ok(data.tenant_access_token)
Ok(token_resp.tenant_access_token)
}
Err(e) => Err(format!("Token exchange request failed: {}", e)),
}
@@ -819,3 +836,60 @@ fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse {
body: body_bytes,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_flat_token_response() {
let json = r#"{
"code": 0,
"msg": "ok",
"tenant_access_token": "t-abc123",
"expire": 7200
}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 0);
assert_eq!(resp.msg, "ok");
assert_eq!(resp.tenant_access_token, "t-abc123");
assert_eq!(resp.expire, 7200);
}
#[test]
fn parse_token_response_rejects_missing_token() {
let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
assert!(result.is_err(), "should fail when tenant_access_token is missing");
}
#[test]
fn parse_token_response_rejects_missing_expire() {
let json = r#"{"code": 0, "msg": "ok", "tenant_access_token": "t-abc"}"#;
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
assert!(result.is_err(), "should fail when expire is missing");
}
#[test]
fn parse_token_response_defaults_code_and_msg() {
let json = r#"{"tenant_access_token": "t-abc", "expire": 3600}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 0);
assert_eq!(resp.msg, "");
assert_eq!(resp.tenant_access_token, "t-abc");
assert_eq!(resp.expire, 3600);
}
#[test]
fn parse_token_error_response() {
let json = r#"{
"code": 10003,
"msg": "invalid app_id",
"tenant_access_token": "",
"expire": 0
}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 10003);
assert!(resp.tenant_access_token.is_empty());
}
}
+222 -19
View File
@@ -360,6 +360,8 @@ enum TelegramStatusAction {
}
const TELEGRAM_STATUS_MAX_CHARS: usize = 600;
/// Telegram's hard limit for message text length.
const TELEGRAM_MAX_MESSAGE_LEN: usize = 4096;
fn truncate_status_message(input: &str, max_chars: usize) -> String {
let mut iter = input.chars();
@@ -371,6 +373,73 @@ fn truncate_status_message(input: &str, max_chars: usize) -> String {
}
}
/// Split a long message into chunks that fit within Telegram's 4096-char limit.
///
/// Tries to split at the most natural boundary available (in priority order):
/// 1. Double newline (paragraph break)
/// 2. Single newline
/// 3. Sentence end (`. `, `! `, `? `)
/// 4. Word boundary (space)
/// 5. Hard cut at the limit (last resort for pathological input)
fn split_message(text: &str) -> Vec<String> {
if text.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN {
return vec![text.to_string()];
}
let mut chunks: Vec<String> = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
// Count chars to find the byte offset for our window.
let window_bytes = remaining
.char_indices()
.take(TELEGRAM_MAX_MESSAGE_LEN)
.last()
.map(|(byte_idx, ch)| byte_idx + ch.len_utf8())
.unwrap_or(remaining.len());
if window_bytes >= remaining.len() {
// Remainder fits entirely.
chunks.push(remaining.to_string());
break;
}
let window = &remaining[..window_bytes];
// 1. Double newline — best paragraph boundary
let split_at = window.rfind("\n\n")
// 2. Single newline
.or_else(|| window.rfind('\n'))
// 3. Sentence-ending punctuation followed by space.
// Note: this only detects ASCII punctuation (. ! ?), not CJK
// sentence-ending marks (。!?). CJK text falls through to
// word-boundary or hard-cut splitting.
.or_else(|| {
let bytes = window.as_bytes();
// Search backwards for '. ', '! ', '? '
(1..bytes.len()).rev().find(|&i| {
matches!(bytes[i - 1], b'.' | b'!' | b'?') && bytes[i] == b' '
})
})
// 4. Word boundary (last space)
.or_else(|| window.rfind(' '))
// 5. Hard cut
.unwrap_or(window_bytes);
// Avoid empty chunks (e.g. text starting with \n\n).
let split_at = if split_at == 0 { window_bytes } else { split_at };
// Trim whitespace at chunk boundaries for clean Telegram display.
// Note: this drops leading/trailing spaces at split points, which is
// acceptable for chat messages but means the concatenation of chunks
// may not exactly equal the original text when split at spaces.
chunks.push(remaining[..split_at].trim_end().to_string());
remaining = remaining[split_at..].trim_start();
}
chunks
}
fn status_message_for_user(update: &StatusUpdate) -> Option<String> {
let message = update.message.trim();
if message.is_empty() {
@@ -1242,26 +1311,64 @@ fn send_response(
return Ok(());
}
// Try Markdown, fall back to plain text on parse errors
match send_message(
chat_id,
&response.content,
reply_to_message_id,
Some("Markdown"),
message_thread_id,
) {
Ok(_) => Ok(()),
Err(SendError::ParseEntities(_)) => send_message(
chat_id,
&response.content,
reply_to_message_id,
None,
message_thread_id,
)
.map(|_| ())
.map_err(|e| format!("Plain-text retry also failed: {}", e)),
Err(e) => Err(e.to_string()),
// Split large messages into chunks that fit Telegram's limit.
let chunks = split_message(&response.content);
let total = chunks.len();
// The first chunk replies to the original message; subsequent chunks
// reply to the previously sent chunk so they form a visual thread.
let mut reply_to = reply_to_message_id;
for (i, chunk) in chunks.into_iter().enumerate() {
// Try Markdown, fall back to plain text on parse errors
let result = send_message(chat_id, &chunk, reply_to, Some("Markdown"), message_thread_id);
let msg_id = match result {
Ok(id) => {
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Sent message chunk {}/{} to chat {}: message_id={}",
i + 1,
total,
chat_id,
id,
),
);
id
}
Err(SendError::ParseEntities(detail)) => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Markdown parse failed on chunk {}/{} ({}), retrying as plain text",
i + 1,
total,
detail
),
);
let id = send_message(chat_id, &chunk, reply_to, None, message_thread_id)
.map_err(|e| format!("Plain-text retry also failed: {}", e))?;
channel_host::log(
channel_host::LogLevel::Debug,
&format!(
"Sent plain-text chunk {}/{} to chat {}: message_id={}",
i + 1,
total,
chat_id,
id,
),
);
id
}
Err(e) => return Err(e.to_string()),
};
// Each subsequent chunk threads off the previous sent message.
reply_to = Some(msg_id);
}
Ok(())
}
/// Send a single attachment, choosing sendPhoto or sendDocument based on MIME type.
@@ -2043,6 +2150,102 @@ export!(TelegramChannel);
mod tests {
use super::*;
#[test]
fn test_split_message_short() {
let text = "Hello, world!";
let chunks = split_message(text);
assert_eq!(chunks, vec![text]);
}
#[test]
fn test_split_message_paragraph_boundary() {
let para_a = "A".repeat(3000);
let para_b = "B".repeat(3000);
let text = format!("{}\n\n{}", para_a, para_b);
let chunks = split_message(&text);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0], para_a);
assert_eq!(chunks[1], para_b);
}
#[test]
fn test_split_message_word_boundary() {
// Build a string well over the limit with no newlines.
let words: Vec<String> = (0..1000).map(|i| format!("word{:04}", i)).collect();
let text = words.join(" ");
assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
assert!(chunks.len() > 1, "expected multiple chunks");
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
}
// Rejoined chunks must equal the original text exactly.
let rejoined = chunks.join(" ");
assert_eq!(rejoined, text);
}
#[test]
fn test_split_message_each_chunk_fits() {
// Stress-test: 20 000 chars of mixed text.
let text: String = (0..500)
.map(|i| format!("Sentence number {}. ", i))
.collect();
assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
}
}
#[test]
fn test_split_message_sentence_boundary() {
// Build text that exceeds the limit, with sentence boundaries inside.
let sentence = "This is a test sentence. ";
let repeat_count = TELEGRAM_MAX_MESSAGE_LEN / sentence.len() + 5;
let text: String = sentence.repeat(repeat_count);
assert!(text.chars().count() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
assert!(chunks.len() > 1);
// First chunk should end at a sentence boundary (trimmed)
let first = &chunks[0];
assert!(
first.ends_with('.'),
"First chunk should end at a sentence boundary, got: ...{}",
&first[first.len().saturating_sub(20)..]
);
}
#[test]
fn test_split_message_hard_cut_no_spaces() {
// Pathological input: a single huge "word" with no spaces or newlines.
let text = "x".repeat(TELEGRAM_MAX_MESSAGE_LEN * 2 + 100);
let chunks = split_message(&text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
}
// Rejoined must preserve all characters
let rejoined: String = chunks.concat();
assert_eq!(rejoined, text);
}
#[test]
fn test_split_message_multibyte_chars() {
// Emoji are 4 bytes each. Ensure we don't panic or split mid-character.
let emoji = "\u{1F600}"; // 😀
let text: String = emoji.repeat(TELEGRAM_MAX_MESSAGE_LEN + 100);
assert!(text.chars().count() > TELEGRAM_MAX_MESSAGE_LEN);
let chunks = split_message(&text);
assert!(chunks.len() >= 2);
for chunk in &chunks {
assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
// Every char should be a complete emoji
assert!(chunk.chars().all(|c| c == '\u{1F600}'));
}
}
#[test]
fn test_clean_message_text() {
// Without bot_username: strips any leading @mention
+8 -4
View File
@@ -2,9 +2,13 @@ coverage:
status:
project:
default:
target: auto
threshold: 1%
target: 80%
threshold: 2%
patch:
default:
target: 80%
threshold: 5%
target: 90%
comment:
layout: "reach,diff,flags"
behavior: default
require_changes: true
+2 -2
View File
@@ -15,7 +15,7 @@ configurations.
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.5 models |
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models |
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
| Ollama | `ollama` | No | Local inference |
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
@@ -84,7 +84,7 @@ LLM_BACKEND=minimax
MINIMAX_API_KEY=...
```
Available models: `MiniMax-M2.5` (default), `MiniMax-M2.5-highspeed`
Available models: `MiniMax-M2.7` (default), `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`
To use the China mainland endpoint, set:
+2 -2
View File
@@ -393,8 +393,8 @@
"api_key_required": true,
"base_url_env": "MINIMAX_BASE_URL",
"model_env": "MINIMAX_MODEL",
"default_model": "MiniMax-M2.5",
"description": "MiniMax API (MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
"default_model": "MiniMax-M2.7",
"description": "MiniMax API (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
"setup": {
"kind": "api_key",
"secret_name": "llm_minimax_api_key",
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-discord-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "6159cb54aa44a9d8219e29bf0aea9404213b20ff567506fe75f23d4698d6ec18"
}
},
"auth_summary": {
+7 -2
View File
@@ -2,7 +2,7 @@
"name": "feishu",
"display_name": "Feishu / Lark Channel",
"kind": "channel",
"version": "0.1.1",
"version": "0.1.2",
"wit_version": "0.3.0",
"description": "Talk to your agent through a Feishu or Lark bot",
"keywords": [
@@ -17,7 +17,12 @@
"capabilities": "feishu.capabilities.json",
"crate_name": "feishu-channel"
},
"artifacts": {},
"artifacts": {
"wasm32-wasip2": {
"sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714",
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz"
}
},
"auth_summary": {
"method": "manual",
"provider": "Feishu / Lark",
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "telegram",
"display_name": "Telegram Channel",
"kind": "channel",
"version": "0.2.4",
"version": "0.2.5",
"wit_version": "0.3.0",
"description": "Talk to your agent through a Telegram bot",
"keywords": [
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz",
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.20.0/channel-telegram-0.2.5-wasm32-wasip2.tar.gz",
"sha256": "1ef20a538f55b379e049356e4d6758006251846bc3365ceaa1c87eba8379a329"
}
},
"auth_summary": {
+2 -2
View File
@@ -19,8 +19,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758"
}
},
"auth_summary": {
+2 -2
View File
@@ -21,8 +21,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz",
"sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz",
"sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e"
}
},
"auth_summary": {
+2 -2
View File
@@ -17,8 +17,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb"
}
},
"auth_summary": {
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz",
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4"
}
},
"auth_summary": {
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218"
}
},
"auth_summary": {
+1
View File
@@ -3,4 +3,5 @@ git_release_enable = false
[[package]]
name = "ironclaw_safety"
publish = false
release = false
@@ -8,15 +8,21 @@ Replace `{{...}}` placeholders before use.
{
"name": "wf-issue-plan",
"description": "Create implementation plan when a new issue arrives",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "issue.opened",
"event_filters": {
"repository_name": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
"cooldown_secs": 30
"request": {
"kind": "system_event",
"source": "github",
"event_type": "issue.opened",
"filters": {
"repository_name": "{{repository}}"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 30
}
}
```
@@ -28,16 +34,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-maintainer-comment-gate-{{maintainer}}",
"description": "React to maintainer guidance comments on issues/PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.comment.created",
"event_filters": {
"repository_name": "{{repository}}",
"comment_author": "{{maintainer}}"
},
"action_type": "full_job",
"prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.",
"cooldown_secs": 20
"request": {
"kind": "system_event",
"source": "github",
"event_type": "pr.comment.created",
"filters": {
"repository_name": "{{repository}}",
"comment_author": "{{maintainer}}"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 20
}
}
```
@@ -47,15 +59,21 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-pr-monitor-loop",
"description": "Keep PR healthy: address review comments and refresh branch",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.synchronize",
"event_filters": {
"repository_name": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
"cooldown_secs": 20
"request": {
"kind": "system_event",
"source": "github",
"event_type": "pr.synchronize",
"filters": {
"repository_name": "{{repository}}"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 20
}
}
```
@@ -65,16 +83,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-ci-fix-loop",
"description": "Fix failing CI checks on active PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "ci.check_run.completed",
"event_filters": {
"repository_name": "{{repository}}",
"ci_conclusion": "failure"
},
"action_type": "full_job",
"prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.",
"cooldown_secs": 20
"request": {
"kind": "system_event",
"source": "github",
"event_type": "ci.check_run.completed",
"filters": {
"repository_name": "{{repository}}",
"ci_conclusion": "failure"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 20
}
}
```
@@ -84,11 +108,17 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-staging-batch-review",
"description": "Batch correctness review through staging, then merge to main",
"trigger_type": "cron",
"schedule": "0 0 */{{batch_interval_hours}} * * *",
"action_type": "full_job",
"prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.",
"cooldown_secs": 120
"request": {
"kind": "cron",
"schedule": "0 0 */{{batch_interval_hours}} * * *"
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 120
}
}
```
@@ -98,16 +128,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-learning-memory",
"description": "Capture merge learnings into shared memory",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.closed",
"event_filters": {
"repository_name": "{{repository}}",
"pr_merged": "true"
},
"action_type": "full_job",
"prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.",
"cooldown_secs": 30
"request": {
"kind": "system_event",
"source": "github",
"event_type": "pr.closed",
"filters": {
"repository_name": "{{repository}}",
"pr_merged": "true"
}
},
"execution": {
"mode": "full_job"
},
"advanced": {
"cooldown_secs": 30
}
}
```
@@ -115,7 +151,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
```json
{
"source": "github",
"event_source": "github",
"event_type": "issue.opened",
"payload": {
"repository_name": "{{repository}}",
+41 -36
View File
@@ -146,6 +146,8 @@ pub struct AgentDeps {
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
/// Software builder for self-repair tool rebuilding.
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
}
/// The main agent that coordinates all components.
@@ -161,9 +163,10 @@ pub struct Agent {
pub(super) heartbeat_config: Option<HeartbeatConfig>,
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
pub(super) routine_config: Option<RoutineConfig>,
/// Optional slot to expose the routine engine to the gateway for manual triggering.
/// Shared routine-engine slot used for internal event matching and for exposing
/// the engine to gateway/manual trigger entry points.
pub(super) routine_engine_slot:
Option<Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>>,
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
}
impl Agent {
@@ -228,16 +231,21 @@ impl Agent {
heartbeat_config,
hygiene_config,
routine_config,
routine_engine_slot: None,
routine_engine_slot: Arc::new(tokio::sync::RwLock::new(None)),
}
}
/// Set the routine engine slot for exposing the engine to the gateway.
/// Replace the routine-engine slot with a shared one so the gateway and
/// agent reference the same engine.
pub fn set_routine_engine_slot(
&mut self,
slot: Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
) {
self.routine_engine_slot = Some(slot);
self.routine_engine_slot = slot;
}
async fn routine_engine(&self) -> Option<Arc<crate::agent::routine_engine::RoutineEngine>> {
self.routine_engine_slot.read().await.clone()
}
// Convenience accessors
@@ -334,11 +342,18 @@ impl Agent {
let mut message_stream = self.channels.start_all().await?;
// Start self-repair task with notification forwarding
let repair = Arc::new(DefaultSelfRepair::new(
let mut self_repair = DefaultSelfRepair::new(
self.context_manager.clone(),
self.config.stuck_threshold,
self.config.max_repair_attempts,
));
);
if let Some(ref store) = self.deps.store {
self_repair = self_repair.with_store(Arc::clone(store));
}
if let Some(ref builder) = self.deps.builder {
self_repair = self_repair.with_builder(Arc::clone(builder), Arc::clone(self.tools()));
}
let repair = Arc::new(self_repair);
let repair_interval = self.config.repair_check_interval;
let repair_channels = self.channels.clone();
let repair_owner_id = self.owner_id().to_string();
@@ -633,9 +648,7 @@ impl Agent {
// via a local to use in the message loop below.
// Expose engine to gateway for manual triggering
if let Some(ref slot) = self.routine_engine_slot {
*slot.write().await = Some(Arc::clone(&engine));
}
*self.routine_engine_slot.write().await = Some(Arc::clone(&engine));
tracing::debug!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
@@ -655,9 +668,6 @@ impl Agent {
None
};
// Extract engine ref for use in message loop
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
@@ -693,29 +703,6 @@ impl Agent {
// Store successfully extracted document text in workspace for indexing
self.store_extracted_documents(&message).await;
// Event-triggered routines consume plain user input before it enters
// the normal chat/tool pipeline. This avoids a duplicate turn where
// the main agent responds and the routine also fires on the same
// inbound message.
if !message.is_internal
&& matches!(
SubmissionParser::parse(&message.content),
Submission::UserInput { .. }
)
&& let Some(ref engine) = routine_engine_for_loop
{
let fired = engine.check_event_triggers(&message).await;
if fired > 0 {
tracing::debug!(
channel = %message.channel,
user = %message.user_id,
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
continue;
}
}
match self.handle_message(&message).await {
Ok(Some(response)) if !response.is_empty() => {
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
@@ -1032,6 +1019,24 @@ impl Agent {
message.content.len()
);
if !message.is_internal
&& let Submission::UserInput { ref content } = submission
&& let Some(engine) = self.routine_engine().await
{
let fired = engine
.check_event_triggers(&message.user_id, &message.channel, content)
.await;
if fired > 0 {
tracing::debug!(
channel = %message.channel,
user = %message.user_id,
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
return Ok(Some(String::new()));
}
}
// Process based on submission type
let result = match submission {
Submission::UserInput { content } => {
+42 -7
View File
@@ -29,7 +29,7 @@ pub(super) enum AgenticLoopResult {
/// A tool requires approval before continuing.
NeedApproval {
/// The pending approval request to store.
pending: PendingApproval,
pending: Box<PendingApproval>,
},
}
@@ -217,9 +217,7 @@ impl Agent {
reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"),
}
.into()),
LoopOutcome::NeedApproval(pending) => {
Ok(AgenticLoopResult::NeedApproval { pending: *pending })
}
LoopOutcome::NeedApproval(pending) => Ok(AgenticLoopResult::NeedApproval { pending }),
}
}
@@ -482,6 +480,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
usize,
crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>,
bool, // allow_always
)> = None;
for (idx, original_tc) in tool_calls.iter().enumerate() {
@@ -551,7 +550,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
&& let Some(tool) = tool_opt
{
use crate::tools::ApprovalRequirement;
let needs_approval = match tool.requires_approval(&tc.arguments) {
let requirement = tool.requires_approval(&tc.arguments);
let needs_approval = match requirement {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = self.session.lock().await;
@@ -586,7 +586,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
continue;
}
approval_needed = Some((idx, tc, tool));
let allow_always = !matches!(requirement, ApprovalRequirement::Always);
approval_needed = Some((idx, tc, tool, allow_always));
break;
}
}
@@ -887,7 +888,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
}
// Handle approval if a tool needed it
if let Some((approval_idx, tc, tool)) = approval_needed {
if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
let display_params = redact_params(&tc.arguments, tool.sensitive_params());
let pending = PendingApproval {
request_id: Uuid::new_v4(),
@@ -899,6 +900,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
context_messages: reason_ctx.messages.clone(),
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
user_timezone: Some(self.user_tz.name().to_string()),
allow_always,
};
return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending))));
@@ -1197,6 +1199,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
Agent::new(
@@ -1364,6 +1367,35 @@ mod tests {
assert!(always_needs, "Always must always require approval");
}
/// Regression test: `allow_always` must be `false` for `Always` and
/// `true` for `UnlessAutoApproved`, so the UI hides the "always" button
/// for tools that truly cannot be auto-approved.
#[test]
fn test_allow_always_matches_approval_requirement() {
use crate::tools::ApprovalRequirement;
// Mirrors the expression used in dispatcher.rs and thread_ops.rs:
// let allow_always = !matches!(requirement, ApprovalRequirement::Always);
// UnlessAutoApproved → allow_always = true
let req = ApprovalRequirement::UnlessAutoApproved;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(
allow_always,
"UnlessAutoApproved should set allow_always = true"
);
// Always → allow_always = false
let req = ApprovalRequirement::Always;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(!allow_always, "Always should set allow_always = false");
// Never → allow_always = true (approval is never needed, but if it were, always would be ok)
let req = ApprovalRequirement::Never;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(allow_always, "Never should set allow_always = true");
}
#[test]
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
// PendingApproval from before the deferred_tool_calls field was added
@@ -1409,6 +1441,7 @@ mod tests {
},
],
user_timezone: None,
allow_always: true,
};
let json = serde_json::to_string(&pending).expect("serialize");
@@ -2037,6 +2070,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
Agent::new(
@@ -2155,6 +2189,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
Agent::new(
+1
View File
@@ -211,6 +211,7 @@ mod tests {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
fallback_deliverable: None,
},
))
.unwrap();
+615 -74
View File
@@ -10,6 +10,7 @@
//! Lightweight routines execute inline (single LLM call, no scheduler slot).
//! Full-job routines are delegated to the existing `Scheduler`.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
@@ -23,19 +24,19 @@ use crate::agent::Scheduler;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
};
use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::channels::OutgoingResponse;
use crate::config::RoutineConfig;
use crate::context::JobContext;
use crate::context::{JobContext, JobState};
use crate::db::Database;
use crate::error::RoutineError;
use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
};
use crate::safety::SafetyLayer;
use crate::tools::{
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
};
use crate::workspace::Workspace;
use ironclaw_safety::SafetyLayer;
enum EventMatcher {
Message { routine: Routine, regex: Regex },
@@ -60,6 +61,10 @@ pub struct RoutineEngine {
tools: Arc<ToolRegistry>,
/// Safety layer for tool output sanitization.
safety: Arc<SafetyLayer>,
/// Timestamp when this engine instance was created. Used by
/// `sync_dispatched_runs` to distinguish orphaned runs (from a previous
/// process) from actively-watched runs (from this process).
boot_time: chrono::DateTime<Utc>,
}
impl RoutineEngine {
@@ -85,9 +90,16 @@ impl RoutineEngine {
scheduler,
tools,
safety,
boot_time: Utc::now(),
}
}
/// Expose the running count for integration tests.
#[doc(hidden)]
pub fn running_count_for_test(&self) -> &Arc<AtomicUsize> {
&self.running_count
}
/// Refresh the in-memory event trigger cache from DB.
pub async fn refresh_event_cache(&self) {
match self.store.list_event_routines().await {
@@ -135,10 +147,19 @@ impl RoutineEngine {
/// Check incoming message against event triggers. Returns number of routines fired.
///
/// Called synchronously from the main loop after handle_message(). The actual
/// execution is spawned async so this returns quickly.
pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize {
/// Accepts only the three fields needed for matching (user scope, channel,
/// message content) so callers never need to clone a full `IncomingMessage`.
pub async fn check_event_triggers(&self, user_id: &str, channel: &str, content: &str) -> usize {
let cache = self.event_cache.read().await;
// Early return if there are no message matchers at all.
if !cache
.iter()
.any(|m| matches!(m, EventMatcher::Message { .. }))
{
return 0;
}
let mut fired = 0;
// Collect routine IDs for batch query
@@ -155,16 +176,9 @@ impl RoutineEngine {
}
// Single batch query instead of N queries
let concurrent_counts = match self
.store
.count_running_routine_runs_batch(&routine_ids)
.await
{
Ok(counts) => counts,
Err(e) => {
tracing::error!("Failed to batch-load concurrent counts: {}", e);
return 0;
}
let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
Some(counts) => counts,
None => return 0,
};
for matcher in cache.iter() {
@@ -173,7 +187,7 @@ impl RoutineEngine {
EventMatcher::System { .. } => continue,
};
if routine.user_id != message.user_id {
if routine.user_id != user_id {
continue;
}
@@ -181,13 +195,13 @@ impl RoutineEngine {
if let Trigger::Event {
channel: Some(ch), ..
} = &routine.trigger
&& ch != &message.channel
&& ch != channel
{
continue;
}
// Regex match
if !re.is_match(&message.content) {
if !re.is_match(content) {
continue;
}
@@ -210,7 +224,7 @@ impl RoutineEngine {
continue;
}
let detail = truncate(&message.content, 200);
let detail = truncate(content, 200);
self.spawn_fire(routine.clone(), "event", Some(detail));
fired += 1;
}
@@ -229,6 +243,15 @@ impl RoutineEngine {
user_id: Option<&str>,
) -> usize {
let cache = self.event_cache.read().await;
// Early return if there are no system-event matchers at all.
if !cache
.iter()
.any(|m| matches!(m, EventMatcher::System { .. }))
{
return 0;
}
let mut fired = 0;
// Collect routine IDs for batch query
@@ -245,19 +268,9 @@ impl RoutineEngine {
}
// Single batch query instead of N queries
let concurrent_counts = match self
.store
.count_running_routine_runs_batch(&routine_ids)
.await
{
Ok(counts) => counts,
Err(e) => {
tracing::error!(
"Failed to batch-load concurrent counts for system events: {}",
e
);
return 0;
}
let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
Some(counts) => counts,
None => return 0,
};
for matcher in cache.iter() {
@@ -331,6 +344,23 @@ impl RoutineEngine {
fired
}
/// Batch-load concurrent run counts for a set of routine IDs.
///
/// Returns `None` on database error (already logged).
async fn batch_concurrent_counts(&self, routine_ids: &[Uuid]) -> Option<HashMap<Uuid, i64>> {
match self
.store
.count_running_routine_runs_batch(routine_ids)
.await
{
Ok(counts) => Some(counts),
Err(e) => {
tracing::error!("Failed to batch-load concurrent counts: {}", e);
None
}
}
}
/// Check all due cron routines and fire them. Called by the cron ticker.
pub async fn check_cron_triggers(&self) {
let routines = match self.store.list_due_cron_routines().await {
@@ -365,6 +395,230 @@ impl RoutineEngine {
}
}
/// Reconcile orphaned full_job routine runs with their linked job outcomes.
///
/// Called on each cron tick. Finds routine runs that are still `running`
/// with a linked `job_id`, checks the job state, and finalizes the run
/// when the job reaches a completed or terminal state.
///
/// Only processes runs started **before** this engine's boot time, so it
/// never races with `FullJobWatcher` instances from the current process.
/// This makes it safe to call on every tick as a crash-recovery mechanism.
pub async fn sync_dispatched_runs(&self) {
let runs = match self.store.list_dispatched_routine_runs().await {
Ok(r) => r,
Err(e) => {
tracing::error!("Failed to list dispatched routine runs: {}", e);
return;
}
};
// Only process runs from a previous process instance. Runs started
// after boot_time are actively watched by a FullJobWatcher in this
// process and should not be finalized here.
let orphaned: Vec<_> = runs
.into_iter()
.filter(|r| r.started_at < self.boot_time)
.collect();
if orphaned.is_empty() {
return;
}
tracing::info!(
"Recovering {} orphaned dispatched routine runs",
orphaned.len()
);
for run in orphaned {
let job_id = match run.job_id {
Some(id) => id,
None => continue, // Should not happen (query filters), but guard anyway
};
// Fetch the linked job
let job = match self.store.get_job(job_id).await {
Ok(Some(j)) => j,
Ok(None) => {
// Orphaned: job record was deleted or never persisted
tracing::warn!(
run_id = %run.id,
job_id = %job_id,
"Linked job not found, marking routine run as failed"
);
self.complete_dispatched_run(
&run,
RunStatus::Failed,
&format!("Linked job {job_id} not found (orphaned)"),
)
.await;
continue;
}
Err(e) => {
tracing::error!(
run_id = %run.id,
job_id = %job_id,
"Failed to fetch linked job: {}", e
);
continue;
}
};
// Map job state to final run status
let final_status = match job.state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
// Pending, InProgress, Stuck — still running
_ => None,
};
let status = match final_status {
Some(s) => s,
None => continue, // Job still active, check again next tick
};
// Build summary
let summary = if status == RunStatus::Failed {
match self.store.get_agent_job_failure_reason(job_id).await {
Ok(Some(reason)) => format!("Job {job_id} failed: {reason}"),
_ => format!("Job {job_id} {}", job.state),
}
} else {
format!("Job {job_id} completed successfully")
};
self.complete_dispatched_run(&run, status, &summary).await;
}
}
/// Finalize a dispatched routine run: update DB, update routine runtime,
/// persist to conversation thread, and send notification.
async fn complete_dispatched_run(&self, run: &RoutineRun, status: RunStatus, summary: &str) {
// Complete the run record in DB
if let Err(e) = self
.store
.complete_routine_run(run.id, status, Some(summary), None)
.await
{
tracing::error!(
run_id = %run.id,
"Failed to complete dispatched routine run: {}", e
);
return;
}
tracing::info!(
run_id = %run.id,
status = %status,
"Finalized dispatched routine run"
);
// Load the routine to update consecutive_failures and send notification
let routine = match self.store.get_routine(run.routine_id).await {
Ok(Some(r)) => r,
Ok(None) => {
tracing::warn!(
run_id = %run.id,
routine_id = %run.routine_id,
"Routine not found for dispatched run finalization"
);
return;
}
Err(e) => {
tracing::error!(
run_id = %run.id,
"Failed to load routine for dispatched run: {}", e
);
return;
}
};
// Update runtime fields. In crash recovery, execute_routine() never
// reached its normal runtime update, so we must advance all fields here.
let new_failures = if status == RunStatus::Failed {
routine.consecutive_failures + 1
} else {
0
};
let now = Utc::now();
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
} = routine.trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
} else {
None
};
if let Err(e) = self
.store
.update_routine_runtime(
routine.id,
now,
next_fire,
routine.run_count + 1,
new_failures,
&routine.state,
)
.await
{
tracing::error!(
routine = %routine.name,
"Failed to update routine runtime after dispatched run: {}", e
);
}
// Persist result to the routine's conversation thread
let thread_id = match self
.store
.get_or_create_routine_conversation(routine.id, &routine.name, &routine.user_id)
.await
{
Ok(conv_id) => {
let msg = format!("[dispatched] {}: {}", status, summary);
if let Err(e) = self
.store
.add_conversation_message(conv_id, "assistant", &msg)
.await
{
tracing::error!(
routine = %routine.name,
"Failed to persist dispatched run message: {}", e
);
}
Some(conv_id.to_string())
}
Err(e) => {
tracing::error!(
routine = %routine.name,
"Failed to get routine conversation: {}", e
);
None
}
};
// Send notification
send_notification(
&self.notify_tx,
&routine.notify,
&routine.user_id,
&routine.name,
status,
Some(summary),
thread_id.as_deref(),
)
.await;
// Note: we do NOT decrement running_count here. In normal flow,
// execute_routine() handles that after FullJobWatcher returns.
// This sync path only runs for crash recovery (process restarted),
// where running_count was already reset to 0.
}
/// Fire a routine manually (from tool call or CLI).
///
/// Bypasses cooldown checks (those only apply to cron/event triggers).
@@ -508,6 +762,92 @@ impl RoutineEngine {
}
}
/// Watches a dispatched full_job until the linked scheduler job completes.
///
/// Polls `store.get_job(job_id)` at a fixed interval until the job leaves
/// an active state (Pending/InProgress/Stuck). Maps the final `JobState` to
/// a `RunStatus` for the routine run.
struct FullJobWatcher {
store: Arc<dyn Database>,
job_id: Uuid,
routine_name: String,
}
impl FullJobWatcher {
/// Poll interval between DB checks.
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// Safety ceiling: 24 hours, derived from POLL_INTERVAL.
const MAX_POLLS: u32 = (24 * 60 * 60) / Self::POLL_INTERVAL.as_secs() as u32;
fn new(store: Arc<dyn Database>, job_id: Uuid, routine_name: String) -> Self {
Self {
store,
job_id,
routine_name,
}
}
/// Block until the linked job finishes and return the mapped status + summary.
async fn wait_for_completion(&self) -> (RunStatus, Option<String>) {
let mut polls = 0u32;
let final_status = loop {
// Check job state before sleeping so we finalize promptly
// if the job is already done (e.g. fast-failing jobs).
match self.store.get_job(self.job_id).await {
Ok(Some(job_ctx)) => {
// Use is_parallel_blocking (Pending/InProgress/Stuck) instead
// of is_active (!is_terminal) because routine jobs typically
// stop at Completed — which is NOT terminal but IS finished
// from an execution standpoint.
if !job_ctx.state.is_parallel_blocking() {
break Self::map_job_state(&job_ctx.state);
}
}
Ok(None) => {
tracing::warn!(
routine = %self.routine_name,
job_id = %self.job_id,
"full_job disappeared from DB while polling"
);
break RunStatus::Failed;
}
Err(e) => {
tracing::error!(
routine = %self.routine_name,
job_id = %self.job_id,
"Error polling full_job state: {}", e
);
break RunStatus::Failed;
}
}
polls += 1;
if polls >= Self::MAX_POLLS {
tracing::error!(
routine = %self.routine_name,
job_id = %self.job_id,
"full_job timed out after 24 hours, treating as failed"
);
break RunStatus::Failed;
}
tokio::time::sleep(Self::POLL_INTERVAL).await;
};
let summary = format!("Job {} finished ({})", self.job_id, final_status);
(final_status, Some(summary))
}
fn map_job_state(state: &crate::context::JobState) -> RunStatus {
use crate::context::JobState;
match state {
JobState::Failed | JobState::Cancelled => RunStatus::Failed,
_ => RunStatus::Ok, // Completed / Submitted / Accepted
}
}
}
/// Shared context passed to the execution function.
struct EngineContext {
config: RoutineConfig,
@@ -682,8 +1022,10 @@ fn sanitize_routine_name(name: &str) -> String {
///
/// Fire-and-forget: creates a job via `Scheduler::dispatch_job` (which handles
/// creation, metadata, persistence, and scheduling), links the routine run to
/// the job, and returns immediately. The job runs independently via the
/// existing Worker/Scheduler with full tool access.
/// the job, then watches it via `FullJobWatcher` until it reaches a
/// non-active state (not Pending/InProgress/Stuck). Returns the final
/// `RunStatus` mapped from the job outcome. This keeps the routine run
/// active for the full job lifetime so concurrency guardrails apply.
async fn execute_full_job(
ctx: &EngineContext,
routine: &Routine,
@@ -726,25 +1068,30 @@ async fn execute_full_job(
reason: format!("failed to dispatch job: {e}"),
})?;
// Link the routine run to the dispatched job
if let Err(e) = ctx.store.link_routine_run_to_job(run.id, job_id).await {
tracing::error!(
routine = %routine.name,
"Failed to link run to job: {}", e
);
}
// Link the routine run to the dispatched job.
// This MUST succeed — if it fails, sync_dispatched_runs() will never find
// this run (it filters on job_id IS NOT NULL), leaving it stuck as 'running'
// with running_count permanently elevated.
ctx.store
.link_routine_run_to_job(run.id, job_id)
.await
.map_err(|e| RoutineError::Database {
reason: format!("failed to link run to job: {e}"),
})?;
tracing::info!(
routine = %routine.name,
job_id = %job_id,
max_iterations = max_iterations,
"Dispatched full job for routine"
"Dispatched full job for routine, watching for completion"
);
let summary = format!(
"Dispatched job {job_id} for full execution with tool access (max_iterations: {max_iterations})"
);
Ok((RunStatus::Ok, Some(summary), None))
// Watch the job until it finishes — keeps the routine run active
// so concurrency guardrails (running_count, routine_runs status)
// remain enforced for the full job lifetime.
let watcher = FullJobWatcher::new(ctx.store.clone(), job_id, routine.name.clone());
let (status, summary) = watcher.wait_for_completion().await;
Ok((status, summary, None))
}
/// Execute a lightweight routine with optional tool support.
@@ -784,23 +1131,12 @@ async fn execute_lightweight(
Err(_) => None,
};
// Build the user-facing prompt
let mut full_prompt = String::new();
full_prompt.push_str(prompt);
if !context_parts.is_empty() {
full_prompt.push_str("\n\n---\n\n# Context\n\n");
full_prompt.push_str(&context_parts.join("\n\n"));
}
if let Some(state) = &state_content {
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
full_prompt.push_str(state);
}
full_prompt.push_str(
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
If something needs attention, provide a concise summary.",
let full_prompt = build_lightweight_prompt(
prompt,
&context_parts,
state_content.as_deref(),
&routine.notify,
use_tools,
);
// Get system prompt
@@ -844,6 +1180,65 @@ async fn execute_lightweight(
}
}
fn build_lightweight_prompt(
prompt: &str,
context_parts: &[String],
state_content: Option<&str>,
notify: &NotifyConfig,
use_tools: bool,
) -> String {
let mut full_prompt = String::new();
full_prompt.push_str(prompt);
if notify.on_attention {
full_prompt.push_str("\n\n---\n\n# Delivery\n\n");
full_prompt.push_str(
"If you reply with anything other than ROUTINE_OK, the host will deliver your \
reply as the routine notification. Return the message exactly as it should be sent.\n",
);
if let Some(channel) = notify.channel.as_deref() {
full_prompt.push_str(&format!(
"The configured delivery channel for this routine is `{channel}`.\n"
));
}
if let Some(user) = notify.user.as_deref() {
full_prompt.push_str(&format!(
"The configured delivery target for this routine is `{user}`.\n"
));
}
full_prompt.push_str(
"Do not claim you lack messaging integrations or ask the user to set one up when \
a plain reply is sufficient.\n",
);
}
if !use_tools {
full_prompt.push_str(
"\nTools are disabled for this routine run. Do not ask to call tools or describe tool limitations unless they prevent a necessary external action.\n",
);
}
if !context_parts.is_empty() {
full_prompt.push_str("\n\n---\n\n# Context\n\n");
full_prompt.push_str(&context_parts.join("\n\n"));
}
if let Some(state) = state_content {
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
full_prompt.push_str(state);
}
full_prompt.push_str(
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
If something needs attention, provide a concise summary.",
);
full_prompt
}
/// Execute a lightweight routine without tool support (original single-call behavior).
async fn execute_lightweight_no_tools(
ctx: &EngineContext,
@@ -901,8 +1296,8 @@ fn handle_text_response(
};
}
// Check for the "nothing to do" sentinel
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
// Check for the "nothing to do" sentinel (exact match on trimmed content).
if content == "ROUTINE_OK" {
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
return Ok((RunStatus::Ok, None, total_tokens));
}
@@ -1268,14 +1663,22 @@ pub fn spawn_cron_ticker(
interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
// Run one check immediately so routines due at startup don't wait
// an extra full polling interval.
// Recover orphaned runs from a previous process crash before
// dispatching any new work, so we don't confuse fresh dispatches
// with crash orphans.
engine.sync_dispatched_runs().await;
// Run one cron check immediately so routines due at startup don't
// wait an extra full polling interval.
engine.check_cron_triggers().await;
let mut ticker = tokio::time::interval(interval);
loop {
ticker.tick().await;
// Sync first: only processes runs from before boot_time, so it
// never races with FullJobWatcher instances from this process.
engine.sync_dispatched_runs().await;
engine.check_cron_triggers().await;
}
})
@@ -1385,22 +1788,78 @@ mod tests {
}
}
#[test]
fn test_build_lightweight_prompt_explains_delivery_and_disabled_tools() {
let notify = NotifyConfig {
channel: Some("telegram".to_string()),
user: Some("default".to_string()),
on_attention: true,
on_failure: true,
on_success: false,
};
let prompt = super::build_lightweight_prompt(
"Send a Telegram reminder message to the user.",
&[],
None,
&notify,
false,
);
assert!(
prompt.contains("the host will deliver your reply as the routine notification"),
"delivery guidance should explain host delivery: {prompt}",
);
assert!(
prompt.contains("configured delivery channel for this routine is `telegram`"),
"delivery guidance should mention telegram channel: {prompt}",
);
assert!(
prompt.contains("Do not claim you lack messaging integrations"),
"delivery guidance should suppress fake setup chatter: {prompt}",
);
assert!(
prompt.contains("Tools are disabled for this routine run"),
"prompt should explain that tools are disabled: {prompt}",
);
}
#[test]
fn test_build_lightweight_prompt_skips_delivery_block_when_attention_notifications_disabled() {
let notify = NotifyConfig {
on_attention: false,
..NotifyConfig::default()
};
let prompt = super::build_lightweight_prompt("Check inbox.", &[], None, &notify, true);
assert!(
!prompt.contains("# Delivery"),
"prompt should not include delivery guidance when attention notifications are off: {prompt}",
);
assert!(
!prompt.contains("Tools are disabled for this routine run"),
"prompt should not claim tools are disabled when they are enabled: {prompt}",
);
}
#[test]
fn test_routine_sentinel_detection_exact_match() {
// The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK")
// After trim(), whitespace is removed
// Sentinel detection uses exact match on trimmed content to avoid
// false positives from substrings like "NOT_ROUTINE_OK".
let test_cases = vec![
("ROUTINE_OK", true),
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
("something ROUTINE_OK something", true),
("ROUTINE_OK is done", true),
("done ROUTINE_OK", true),
("something ROUTINE_OK something", false), // substring no longer matches
("ROUTINE_OK is done", false), // substring no longer matches
("done ROUTINE_OK", false), // substring no longer matches
("NOT_ROUTINE_OK", false), // exact match prevents this
("no sentinel here", false),
];
for (content, should_match) in test_cases {
let trimmed = content.trim();
let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK");
let matches = trimmed == "ROUTINE_OK";
assert_eq!(
matches, should_match,
"Content '{}' sentinel detection should be {}, got {}",
@@ -1514,4 +1973,86 @@ mod tests {
assert_eq!(snapshot[1].content, "a"); // safety: test-only no-panics CI false positive
assert_eq!(snapshot[2].content, "b"); // safety: test-only no-panics CI false positive
}
/// Regression test for #1317: FullJobWatcher maps terminal job states correctly.
#[test]
fn test_full_job_watcher_state_mapping() {
use crate::context::JobState;
// Failed/Cancelled → RunStatus::Failed
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Failed),
RunStatus::Failed
);
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Cancelled),
RunStatus::Failed
);
// All other non-active states → RunStatus::Ok
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Completed),
RunStatus::Ok
);
assert_eq!(
super::FullJobWatcher::map_job_state(&JobState::Accepted),
RunStatus::Ok
);
}
/// Verify that job state to run status mapping covers all expected cases.
#[test]
fn test_job_state_to_run_status_mapping() {
use crate::context::JobState;
// Success states
for state in [JobState::Completed, JobState::Submitted, JobState::Accepted] {
let status = match state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
_ => None,
};
assert_eq!(
status,
Some(RunStatus::Ok),
"{:?} should map to RunStatus::Ok",
state
);
}
// Failure states
for state in [JobState::Failed, JobState::Cancelled] {
let status = match state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
_ => None,
};
assert_eq!(
status,
Some(RunStatus::Failed),
"{:?} should map to RunStatus::Failed",
state
);
}
// Active states (should not finalize)
for state in [JobState::Pending, JobState::InProgress, JobState::Stuck] {
let status = match state {
JobState::Completed | JobState::Submitted | JobState::Accepted => {
Some(RunStatus::Ok)
}
JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
_ => None,
};
assert_eq!(
status, None,
"{:?} should not finalize the routine run",
state
);
}
}
}
+259 -17
View File
@@ -66,14 +66,10 @@ pub trait SelfRepair: Send + Sync {
/// Default self-repair implementation.
pub struct DefaultSelfRepair {
context_manager: Arc<ContextManager>,
// 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>>,
// TODO: use for tool hot-reload after repair
#[allow(dead_code)]
tools: Option<Arc<ToolRegistry>>,
}
@@ -95,15 +91,13 @@ impl DefaultSelfRepair {
}
/// Add a Store for tool failure tracking.
#[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 {
pub 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)] // TODO: wire up in main.rs when auto-repair is needed
pub(crate) fn with_builder(
pub fn with_builder(
mut self,
builder: Arc<dyn SoftwareBuilder>,
tools: Arc<ToolRegistry>,
@@ -124,18 +118,30 @@ impl SelfRepair for DefaultSelfRepair {
if let Ok(ctx) = self.context_manager.get_context(job_id).await
&& ctx.state == JobState::Stuck
{
let stuck_duration = ctx
.started_at
.map(|start| {
let now = Utc::now();
let duration = now.signed_duration_since(start);
// Measure stuck_duration from the most recent Stuck transition,
// not from started_at (which reflects when the job first ran).
let stuck_since = ctx
.transitions
.iter()
.rev()
.find(|t| t.to == JobState::Stuck)
.map(|t| t.timestamp);
let stuck_duration = stuck_since
.map(|ts| {
let duration = Utc::now().signed_duration_since(ts);
Duration::from_secs(duration.num_seconds().max(0) as u64)
})
.unwrap_or_default();
// Only report jobs that have been stuck long enough
if stuck_duration < self.stuck_threshold {
continue;
}
stuck_jobs.push(StuckJob {
job_id,
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
last_activity: stuck_since.unwrap_or(ctx.created_at),
stuck_duration,
last_error: None,
repair_attempts: ctx.repair_attempts,
@@ -273,9 +279,8 @@ impl SelfRepair for DefaultSelfRepair {
tracing::warn!("Failed to mark tool as repaired: {}", e);
}
// Log if the tool was auto-registered
if result.registered {
tracing::info!("Repaired tool '{}' auto-registered", tool.name);
tracing::info!("Repaired tool '{}' auto-registered by builder", tool.name);
}
Ok(RepairResult::Success {
@@ -417,7 +422,8 @@ mod tests {
.unwrap()
.unwrap();
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
// Use zero threshold so the just-stuck job is detected immediately.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(0), 3);
let stuck = repair.detect_stuck_jobs().await;
assert_eq!(stuck.len(), 1);
assert_eq!(stuck[0].job_id, job_id);
@@ -483,6 +489,98 @@ mod tests {
);
}
#[tokio::test]
async fn detect_stuck_jobs_filters_by_threshold() {
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
// Transition to InProgress, then to Stuck.
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
})
.await
.unwrap()
.unwrap();
// Use a very large threshold (1 hour). Job just became stuck, so
// stuck_duration < threshold. It should be filtered out.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(3600), 3);
let stuck = repair.detect_stuck_jobs().await;
assert!(
stuck.is_empty(),
"Job stuck for <1s should be filtered by 1h threshold"
);
}
#[tokio::test]
async fn detect_stuck_jobs_includes_when_over_threshold() {
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
// Transition to InProgress, then to Stuck.
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
})
.await
.unwrap()
.unwrap();
// Use a zero threshold -- any stuck duration should be included.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(0), 3);
let stuck = repair.detect_stuck_jobs().await;
assert_eq!(stuck.len(), 1, "Job should be detected with zero threshold");
assert_eq!(stuck[0].job_id, job_id);
}
/// Regression: stuck_duration must be measured from the Stuck transition,
/// not from started_at. A job that ran for 2 hours before becoming stuck
/// should NOT immediately exceed a 5-minute threshold.
#[tokio::test]
async fn stuck_duration_measured_from_stuck_transition_not_started_at() {
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("Long runner", "desc").await.unwrap();
// Transition to InProgress (sets started_at to now).
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
// Backdate started_at to 2 hours ago to simulate a long-running job.
cm.update_context(job_id, |ctx| {
ctx.started_at = Some(Utc::now() - chrono::Duration::hours(2));
Ok::<(), crate::error::Error>(())
})
.await
.unwrap()
.unwrap();
// Now transition to Stuck (stuck transition timestamp is ~now).
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("wedged".into()))
})
.await
.unwrap()
.unwrap();
// With a 5-minute threshold, the job JUST became stuck — should NOT be detected.
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(300), 3);
let stuck = repair.detect_stuck_jobs().await;
assert!(
stuck.is_empty(),
"Job stuck for <1s should not exceed 5min threshold, \
but stuck_duration was computed from started_at (2h ago)"
);
}
#[tokio::test]
async fn detect_broken_tools_returns_empty_without_store() {
let cm = Arc::new(ContextManager::new(10));
@@ -515,4 +613,148 @@ mod tests {
result
);
}
/// Mock SoftwareBuilder that returns a successful build result.
struct MockBuilder {
build_count: std::sync::atomic::AtomicU32,
}
impl MockBuilder {
fn new() -> Self {
Self {
build_count: std::sync::atomic::AtomicU32::new(0),
}
}
fn builds(&self) -> u32 {
self.build_count.load(std::sync::atomic::Ordering::Relaxed)
}
}
#[async_trait]
impl crate::tools::SoftwareBuilder for MockBuilder {
async fn analyze(
&self,
_description: &str,
) -> Result<crate::tools::BuildRequirement, crate::error::ToolError> {
Ok(crate::tools::BuildRequirement {
name: "mock-tool".to_string(),
description: "mock".to_string(),
software_type: crate::tools::SoftwareType::WasmTool,
language: crate::tools::Language::Rust,
input_spec: None,
output_spec: None,
dependencies: vec![],
capabilities: vec![],
})
}
async fn build(
&self,
requirement: &crate::tools::BuildRequirement,
) -> Result<crate::tools::BuildResult, crate::error::ToolError> {
self.build_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(crate::tools::BuildResult {
build_id: Uuid::new_v4(),
requirement: requirement.clone(),
artifact_path: std::path::PathBuf::from("/tmp/mock.wasm"),
logs: vec![],
success: true,
error: None,
started_at: Utc::now(),
completed_at: Utc::now(),
iterations: 1,
validation_warnings: vec![],
tests_passed: 1,
tests_failed: 0,
registered: true,
})
}
async fn repair(
&self,
_result: &crate::tools::BuildResult,
_error: &str,
) -> Result<crate::tools::BuildResult, crate::error::ToolError> {
unimplemented!("not needed for this test")
}
}
/// E2E test: stuck job detected -> repaired -> transitions back to InProgress,
/// and broken tool detected -> builder invoked -> tool marked repaired.
#[cfg(feature = "libsql")]
#[tokio::test]
async fn e2e_stuck_job_repair_and_tool_rebuild() {
// --- Setup ---
let cm = Arc::new(ContextManager::new(10));
let job_id = cm.create_job("E2E stuck job", "desc").await.unwrap();
// Transition job: Pending -> InProgress -> Stuck
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
cm.update_context(job_id, |ctx| {
ctx.transition_to(JobState::Stuck, Some("deadlocked".to_string()))
})
.await
.unwrap()
.unwrap();
// Create a mock builder and a real test database (for store)
let builder = Arc::new(MockBuilder::new());
let tools = Arc::new(ToolRegistry::new());
let (db, _tmp_dir) = crate::testing::test_db().await;
// Create self-repair with zero threshold (detect immediately),
// wired with store, builder, and tools.
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(0), 3)
.with_store(Arc::clone(&db))
.with_builder(
Arc::clone(&builder) as Arc<dyn crate::tools::SoftwareBuilder>,
tools,
);
// --- Phase 1: Detect and repair stuck job ---
let stuck_jobs = repair.detect_stuck_jobs().await;
assert_eq!(stuck_jobs.len(), 1, "Should detect the stuck job");
assert_eq!(stuck_jobs[0].job_id, job_id);
let result = repair.repair_stuck_job(&stuck_jobs[0]).await.unwrap();
assert!(
matches!(result, RepairResult::Success { .. }),
"Job repair should succeed: {:?}",
result
);
// Verify job transitioned back to InProgress
let ctx = cm.get_context(job_id).await.unwrap();
assert_eq!(
ctx.state,
JobState::InProgress,
"Job should be back to InProgress after repair"
);
// --- Phase 2: Repair a broken tool via builder ---
let broken = BrokenTool {
name: "broken-wasm-tool".to_string(),
failure_count: 10,
last_error: Some("panic in tool execution".to_string()),
first_failure: Utc::now() - chrono::Duration::hours(1),
last_failure: Utc::now(),
last_build_result: None,
repair_attempts: 0,
};
let tool_result = repair.repair_broken_tool(&broken).await.unwrap();
assert!(
matches!(tool_result, RepairResult::Success { .. }),
"Tool repair should succeed with mock builder: {:?}",
tool_result
);
// Verify builder was actually invoked
assert_eq!(builder.builds(), 1, "Builder should have been called once");
}
}
+11
View File
@@ -188,6 +188,15 @@ pub struct PendingApproval {
/// through the approval flow even if the approval message lacks timezone.
#[serde(default)]
pub user_timezone: Option<String>,
/// Whether the "always" auto-approve option should be offered to the user.
/// `false` when the tool returned `ApprovalRequirement::Always` (e.g.
/// destructive shell commands), meaning every invocation must be confirmed.
#[serde(default = "default_true")]
pub allow_always: bool,
}
fn default_true() -> bool {
true
}
/// A conversation thread within a session.
@@ -1106,6 +1115,7 @@ mod tests {
context_messages: vec![ChatMessage::user("do it")],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: false,
};
thread.await_approval(approval);
@@ -1132,6 +1142,7 @@ mod tests {
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: true,
};
thread.await_approval(approval);
+2
View File
@@ -382,6 +382,8 @@ pub enum SubmissionResult {
description: String,
/// Parameters being passed.
parameters: serde_json::Value,
/// Whether "always" auto-approve should be offered to the user.
allow_always: bool,
},
/// Successfully processed (for control commands).
+21 -8
View File
@@ -506,7 +506,8 @@ impl Agent {
let tool_name = pending.tool_name.clone();
let description = pending.description.clone();
let parameters = pending.display_parameters.clone();
thread.await_approval(pending);
let allow_always = pending.allow_always;
thread.await_approval(*pending);
let _ = self
.channels
.send_status(
@@ -516,6 +517,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
allow_always,
},
&message.metadata,
)
@@ -525,6 +527,7 @@ impl Agent {
tool_name,
description,
parameters,
allow_always,
})
}
Err(e) => {
@@ -1069,28 +1072,31 @@ impl Agent {
usize,
crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>,
bool, // allow_always
)> = None;
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await {
// Match dispatcher.rs: when auto_approve_tools is true, skip
// all approval checks (including ApprovalRequirement::Always).
let needs_approval = if self.config.auto_approve_tools {
false
let (needs_approval, allow_always) = if self.config.auto_approve_tools {
(false, true)
} else {
use crate::tools::ApprovalRequirement;
match tool.requires_approval(&tc.arguments) {
let requirement = tool.requires_approval(&tc.arguments);
let needs = match requirement {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name)
}
ApprovalRequirement::Always => true,
}
};
(needs, !matches!(requirement, ApprovalRequirement::Always))
};
if needs_approval {
approval_needed = Some((idx, tc.clone(), tool));
approval_needed = Some((idx, tc.clone(), tool, allow_always));
break; // remaining tools stay deferred
}
}
@@ -1298,7 +1304,7 @@ impl Agent {
}
// Handle approval if a tool needed it
if let Some((approval_idx, tc, tool)) = approval_needed {
if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
let new_pending = PendingApproval {
request_id: Uuid::new_v4(),
tool_name: tc.name.clone(),
@@ -1310,6 +1316,7 @@ impl Agent {
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
// Carry forward the resolved timezone from the original pending approval
user_timezone: pending.user_timezone.clone(),
allow_always,
};
let request_id = new_pending.request_id;
@@ -1333,6 +1340,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
allow_always,
},
&message.metadata,
)
@@ -1343,6 +1351,7 @@ impl Agent {
tool_name,
description,
parameters,
allow_always,
});
}
@@ -1411,7 +1420,8 @@ impl Agent {
let tool_name = new_pending.tool_name.clone();
let description = new_pending.description.clone();
let parameters = new_pending.display_parameters.clone();
thread.await_approval(new_pending);
let allow_always = new_pending.allow_always;
thread.await_approval(*new_pending);
let _ = self
.channels
.send_status(
@@ -1421,6 +1431,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
allow_always,
},
&message.metadata,
)
@@ -1430,6 +1441,7 @@ impl Agent {
tool_name,
description,
parameters,
allow_always,
})
}
Err(e) => {
@@ -1949,6 +1961,7 @@ mod tests {
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: false,
};
thread.await_approval(pending);
+17 -8
View File
@@ -25,7 +25,7 @@ use crate::tools::ToolRegistry;
use crate::tools::mcp::{McpProcessManager, McpSessionManager};
use crate::tools::wasm::SharedCredentialRegistry;
use crate::tools::wasm::WasmToolRuntime;
use crate::workspace::{EmbeddingProvider, Workspace};
use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, Workspace};
/// Fully initialized application components, ready for channel wiring
/// and agent construction.
@@ -56,6 +56,7 @@ pub struct AppComponents {
pub session: Arc<SessionManager>,
pub catalog_entries: Vec<crate::extensions::RegistryEntry>,
pub dev_loaded_tool_names: Vec<String>,
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
}
/// Options that control optional init phases.
@@ -280,6 +281,7 @@ impl AppBuilder {
Arc<ToolRegistry>,
Option<Arc<dyn EmbeddingProvider>>,
Option<Arc<Workspace>>,
Option<Arc<dyn crate::tools::SoftwareBuilder>>,
),
anyhow::Error,
> {
@@ -311,10 +313,13 @@ impl AppBuilder {
// Register memory tools if database is available
let workspace = if let Some(ref db) = self.db {
let emb_cache_config = EmbeddingCacheConfig {
max_entries: self.config.embeddings.cache_size,
};
let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
.with_search_config(&self.config.search);
if let Some(ref emb) = embeddings {
ws = ws.with_embeddings(emb.clone());
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
}
let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws));
@@ -367,16 +372,19 @@ impl AppBuilder {
}
// Register builder tool if enabled
if self.config.builder.enabled
let builder = if self.config.builder.enabled
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
{
tools
let b = tools
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await;
tracing::debug!("Builder mode enabled");
}
tracing::info!("Builder mode enabled");
Some(b)
} else {
None
};
Ok((safety, tools, embeddings, workspace))
Ok((safety, tools, embeddings, workspace, builder))
}
/// Phase 5: Load WASM tools, MCP servers, and create extension manager.
@@ -699,7 +707,7 @@ impl AppBuilder {
} else {
self.init_llm().await?
};
let (safety, tools, embeddings, workspace) = self.init_tools(&llm).await?;
let (safety, tools, embeddings, workspace, builder) = self.init_tools(&llm).await?;
// Create hook registry early so runtime extension activation can register hooks.
let hooks = Arc::new(HookRegistry::new());
@@ -819,6 +827,7 @@ impl AppBuilder {
session: self.session,
catalog_entries,
dev_loaded_tool_names,
builder,
})
}
}
+5
View File
@@ -305,6 +305,11 @@ pub enum StatusUpdate {
tool_name: String,
description: String,
parameters: serde_json::Value,
/// When `true`, the UI should offer an "always" option that auto-approves
/// future calls to this tool for the rest of the session. When `false`
/// (i.e. `ApprovalRequirement::Always`), the tool must be approved every
/// time and the "always" button should be hidden.
allow_always: bool,
},
/// Extension needs user authentication (token or OAuth).
AuthRequired {
+5
View File
@@ -239,6 +239,11 @@ impl ChannelManager {
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.read().await.get(name).cloned()
}
/// Remove a channel from the manager.
pub async fn remove(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.write().await.remove(name)
}
}
impl Default for ChannelManager {
+193 -383
View File
@@ -1,16 +1,16 @@
//! Channel trait implementation for channel-relay SSE streams.
//! Channel trait implementation for channel-relay webhook callbacks.
//!
//! `RelayChannel` connects to a channel-relay service via SSE, converts
//! incoming events to `IncomingMessage`s, and sends responses via the
//! relay's provider-specific proxy API (Slack).
//! `RelayChannel` receives events from channel-relay via HTTP POST callbacks
//! (pushed through an mpsc channel by the webhook handler), converts them
//! to `IncomingMessage`s, and sends responses via the relay's provider-specific
//! proxy API (Slack).
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::{RwLock, mpsc};
use tokio::sync::mpsc;
use crate::channels::relay::client::{RelayClient, RelayError};
use crate::channels::relay::client::{ChannelEvent, RelayClient};
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
@@ -39,44 +39,34 @@ impl RelayProvider {
}
}
/// Channel implementation that connects to a channel-relay SSE stream.
/// Channel implementation that receives events from channel-relay via webhook callbacks.
pub struct RelayChannel {
client: RelayClient,
provider: RelayProvider,
stream_token: Arc<RwLock<String>>,
team_id: String,
instance_id: String,
user_id: String,
/// SSE stream long-poll timeout in seconds.
stream_timeout_secs: u64,
/// Initial exponential backoff in milliseconds.
backoff_initial_ms: u64,
/// Maximum exponential backoff in milliseconds.
backoff_max_ms: u64,
/// Handle to the reconnect task for clean shutdown.
reconnect_handle: RwLock<Option<tokio::task::JoinHandle<()>>>,
/// Handle to the SSE parser task for clean shutdown.
parser_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
/// Maximum consecutive reconnect failures before giving up.
max_consecutive_failures: u64,
/// Sender side of the event channel — shared with the webhook handler.
event_tx: mpsc::Sender<ChannelEvent>,
/// Receiver side — taken once by `start()`.
event_rx: tokio::sync::Mutex<Option<mpsc::Receiver<ChannelEvent>>>,
}
impl RelayChannel {
/// Create a new relay channel for Slack (default provider).
pub fn new(
client: RelayClient,
stream_token: String,
team_id: String,
instance_id: String,
user_id: String,
event_tx: mpsc::Sender<ChannelEvent>,
event_rx: mpsc::Receiver<ChannelEvent>,
) -> Self {
Self::new_with_provider(
client,
RelayProvider::Slack,
stream_token,
team_id,
instance_id,
user_id,
event_tx,
event_rx,
)
}
@@ -84,44 +74,24 @@ impl RelayChannel {
pub fn new_with_provider(
client: RelayClient,
provider: RelayProvider,
stream_token: String,
team_id: String,
instance_id: String,
user_id: String,
event_tx: mpsc::Sender<ChannelEvent>,
event_rx: mpsc::Receiver<ChannelEvent>,
) -> Self {
Self {
client,
provider,
stream_token: Arc::new(RwLock::new(stream_token)),
team_id,
instance_id,
user_id,
stream_timeout_secs: 86400,
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
reconnect_handle: RwLock::new(None),
parser_handle: Arc::new(RwLock::new(None)),
max_consecutive_failures: 50,
event_tx,
event_rx: tokio::sync::Mutex::new(Some(event_rx)),
}
}
/// Set backoff/timeout parameters from relay config values.
pub fn with_timeouts(
mut self,
stream_timeout_secs: u64,
backoff_initial_ms: u64,
backoff_max_ms: u64,
) -> Self {
self.stream_timeout_secs = stream_timeout_secs;
self.backoff_initial_ms = backoff_initial_ms;
self.backoff_max_ms = backoff_max_ms;
self
}
/// Set the maximum number of consecutive reconnect failures before giving up.
pub fn with_max_failures(mut self, max: u64) -> Self {
self.max_consecutive_failures = max;
self
/// Get a clone of the event sender for wiring into the webhook endpoint.
pub fn event_sender(&self) -> mpsc::Sender<ChannelEvent> {
self.event_tx.clone()
}
/// Build a provider-appropriate proxy body for sending a message.
@@ -151,15 +121,9 @@ impl RelayChannel {
team_id: &str,
method: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> {
) -> Result<serde_json::Value, crate::channels::relay::client::RelayError> {
self.client
.proxy_provider(
self.provider.as_str(),
team_id,
method,
body,
Some(&self.instance_id),
)
.proxy_provider(self.provider.as_str(), team_id, method, body)
.await
}
}
@@ -172,204 +136,82 @@ impl Channel for RelayChannel {
async fn start(&self) -> Result<MessageStream, ChannelError> {
let channel_name = self.name().to_string();
let token = self.stream_token.read().await.clone();
let (stream, initial_parser_handle) = self
.client
.connect_stream(&token, self.stream_timeout_secs)
.await
.map_err(|e| ChannelError::StartupFailed {
name: channel_name.clone(),
reason: e.to_string(),
})?;
*self.parser_handle.write().await = Some(initial_parser_handle);
// Take the receiver (can only start once)
let mut event_rx =
self.event_rx
.lock()
.await
.take()
.ok_or_else(|| ChannelError::StartupFailed {
name: channel_name.clone(),
reason: "RelayChannel already started".to_string(),
})?;
let (tx, rx) = mpsc::channel(64);
// Spawn the stream reader + reconnect task
let client = self.client.clone();
let stream_token = Arc::clone(&self.stream_token);
let instance_id = self.instance_id.clone();
let user_id = self.user_id.clone();
let team_id = self.team_id.clone();
let stream_timeout_secs = self.stream_timeout_secs;
let backoff_initial_ms = self.backoff_initial_ms;
let backoff_max_ms = self.backoff_max_ms;
let max_consecutive_failures = self.max_consecutive_failures;
let parser_handle = Arc::clone(&self.parser_handle);
let provider_str = self.provider.as_str().to_string();
let relay_name = channel_name.clone();
let handle = tokio::spawn(async move {
use futures::StreamExt;
let mut current_stream = stream;
let mut backoff_ms = backoff_initial_ms;
let mut consecutive_failures: u64 = 0;
loop {
// Read events from the current stream
while let Some(event) = current_stream.next().await {
// Reset backoff and failure count on successful event
backoff_ms = backoff_initial_ms;
consecutive_failures = 0;
// Validate required fields
if event.sender_id.is_empty()
|| event.channel_id.is_empty()
|| event.provider_scope.is_empty()
{
tracing::debug!(
event_type = %event.event_type,
sender_id = %event.sender_id,
channel_id = %event.channel_id,
"Relay: skipping event with missing required fields"
);
continue;
}
// Skip non-message events
if !event.is_message() {
tracing::debug!(
event_type = %event.event_type,
"Relay: skipping non-message event"
);
continue;
}
tracing::info!(
// Spawn a task that reads events from the webhook handler and converts to IncomingMessage
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
// Validate required fields
if event.sender_id.is_empty()
|| event.channel_id.is_empty()
|| event.provider_scope.is_empty()
{
tracing::debug!(
event_type = %event.event_type,
sender = %event.sender_id,
channel = %event.channel_id,
provider = %provider_str,
"Relay: received message from {}", provider_str
sender_id = %event.sender_id,
channel_id = %event.channel_id,
"Relay: skipping event with missing required fields"
);
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
.with_user_name(event.display_name())
.with_metadata(serde_json::json!({
"team_id": event.team_id(),
"channel_id": event.channel_id,
"sender_id": event.sender_id,
"sender_name": event.display_name(),
"event_type": event.event_type,
"thread_id": event.thread_id,
"provider": event.provider,
}));
let msg = if let Some(ref thread_id) = event.thread_id {
msg.with_thread(thread_id)
} else {
msg.with_thread(&event.channel_id)
};
if tx.send(msg).await.is_err() {
tracing::info!("Relay channel receiver dropped, stopping");
return;
}
continue;
}
// Stream ended, attempt reconnect with backoff
consecutive_failures += 1;
if consecutive_failures >= max_consecutive_failures {
tracing::error!(
channel = %relay_name,
failures = consecutive_failures,
"Relay channel giving up after {} consecutive failures",
consecutive_failures
// Skip non-message events
if !event.is_message() {
tracing::debug!(
event_type = %event.event_type,
"Relay: skipping non-message event"
);
break;
continue;
}
tracing::warn!(
backoff_ms = backoff_ms,
failures = consecutive_failures,
"Relay SSE stream ended, reconnecting..."
tracing::info!(
event_type = %event.event_type,
sender = %event.sender_id,
channel = %event.channel_id,
provider = %provider_str,
"Relay: received message from {}", provider_str
);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
backoff_ms = (backoff_ms * 2).min(backoff_max_ms);
// Try to reconnect
let token = stream_token.read().await.clone();
match client.connect_stream(&token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!("Relay SSE stream reconnected");
consecutive_failures = 0;
backoff_ms = backoff_initial_ms;
current_stream = new_stream;
// Abort old parser before replacing
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(RelayError::TokenExpired) => {
// Attempt token renewal
tracing::info!("Relay stream token expired, renewing...");
match client.renew_token(&instance_id, &user_id).await {
Ok(new_token) => {
*stream_token.write().await = new_token.clone();
match client.connect_stream(&new_token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!(
"Relay SSE stream reconnected with new token"
);
consecutive_failures = 0;
backoff_ms = backoff_initial_ms;
current_stream = new_stream;
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to reconnect after token renewal"
);
}
}
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to renew relay stream token"
);
}
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to reconnect relay SSE stream");
}
}
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
.with_user_name(event.display_name())
.with_metadata(serde_json::json!({
"team_id": event.team_id(),
"channel_id": event.channel_id,
"sender_id": event.sender_id,
"sender_name": event.display_name(),
"event_type": event.event_type,
"thread_id": event.thread_id,
"provider": event.provider,
}));
// Check if the team is still valid (skip when team_id is unknown,
// e.g. when no DB store was available at activation time)
if !team_id.is_empty() {
match client.list_connections(&instance_id).await {
Ok(conns) => {
let has_team =
conns.iter().any(|c| c.team_id == team_id && c.connected);
if !has_team {
tracing::warn!(
team_id = %team_id,
"Team no longer connected, stopping relay channel"
);
return;
}
}
Err(e) => {
tracing::warn!(
error = %e,
"Could not verify team connection, will retry next iteration"
);
}
}
let msg = if let Some(ref thread_id) = event.thread_id {
msg.with_thread(thread_id)
} else {
msg.with_thread(&event.channel_id)
};
if tx.send(msg).await.is_err() {
tracing::info!("Relay channel receiver dropped, stopping");
return;
}
}
});
*self.reconnect_handle.write().await = Some(handle);
tracing::info!("Relay event channel closed");
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Ok(Box::pin(stream))
@@ -423,6 +265,7 @@ impl Channel for RelayChannel {
tool_name,
description,
parameters,
allow_always: _,
} = status
else {
return Ok(());
@@ -450,28 +293,24 @@ impl Channel for RelayChannel {
name: self.name().to_string(),
reason: "Missing channel_id for approval buttons".into(),
})?;
let sender_id = metadata
.get("sender_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::SendFailed {
name: self.name().to_string(),
reason: "Missing sender_id for approval buttons".into(),
})?;
let thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
let team_id = metadata
.get("team_id")
.and_then(|v| v.as_str())
.unwrap_or(&self.team_id);
// Button value payload (Slack limits button values to 2000 chars;
// safe with typical UUIDs but documented here as a constraint)
// Register server-side approval record and get opaque token.
// The button value contains ONLY the token — no routing fields.
let approval_token = self
.client
.create_approval(team_id, channel_id, thread_id, &request_id)
.await
.map_err(|e| ChannelError::SendFailed {
name: self.name().to_string(),
reason: format!("Failed to register approval: {e}"),
})?;
let value_payload = serde_json::json!({
"instance_id": self.instance_id,
"team_id": team_id,
"channel_id": channel_id,
"thread_ts": thread_id,
"request_id": request_id,
"sender_id": sender_id,
"approval_token": approval_token,
});
let value_str = value_payload.to_string();
@@ -582,12 +421,8 @@ impl Channel for RelayChannel {
}
async fn shutdown(&self) -> Result<(), ChannelError> {
if let Some(handle) = self.reconnect_handle.write().await.take() {
handle.abort();
}
if let Some(handle) = self.parser_handle.write().await.take() {
handle.abort();
}
// Relay cleanup is driven by the extension manager dropping the shared
// sender and removing the channel from the channel manager.
Ok(())
}
}
@@ -605,27 +440,20 @@ mod tests {
.expect("client")
}
fn make_channel() -> RelayChannel {
let (tx, rx) = mpsc::channel(64);
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx, rx)
}
#[test]
fn relay_channel_name() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
}
#[test]
fn conversation_context_extracts_metadata() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
let metadata = serde_json::json!({
"sender_name": "bob",
@@ -640,8 +468,6 @@ mod tests {
#[test]
fn metadata_shape_includes_event_type_and_sender_name() {
// Regression: metadata JSON must include event_type and sender_name
// for downstream routing (DM vs channel) and conversation_context().
let metadata = serde_json::json!({
"team_id": "T123",
"channel_id": "C456",
@@ -651,43 +477,19 @@ mod tests {
"thread_id": null,
"provider": "slack",
});
// event_type must be present for DM-vs-channel routing
assert_eq!(
metadata.get("event_type").and_then(|v| v.as_str()),
Some("direct_message")
);
// sender_name must be present for conversation_context
assert_eq!(
metadata.get("sender_name").and_then(|v| v.as_str()),
Some("alice")
);
}
#[test]
fn with_timeouts_sets_values() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_timeouts(43200, 2000, 120000);
assert_eq!(channel.stream_timeout_secs, 43200);
assert_eq!(channel.backoff_initial_ms, 2000);
assert_eq!(channel.backoff_max_ms, 120000);
}
#[test]
fn build_send_body_slack() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
assert_eq!(method, "chat.postMessage");
assert_eq!(body["channel"], "C456");
@@ -695,72 +497,95 @@ mod tests {
assert_eq!(body["thread_ts"], "1234567.890");
}
#[test]
fn parser_handle_is_shared_arc() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
// parser_handle should be an Arc — cloning should give a second reference
let handle_clone = Arc::clone(&channel.parser_handle);
// Both point to the same allocation
assert!(Arc::ptr_eq(&channel.parser_handle, &handle_clone));
#[tokio::test]
async fn start_processes_events() {
let (tx, rx) = mpsc::channel(64);
let channel =
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
let mut stream = channel.start().await.unwrap();
// Send an event
tx.send(ChannelEvent {
id: "1".into(),
event_type: "message".into(),
provider: "slack".into(),
provider_scope: "T123".into(),
channel_id: "C456".into(),
sender_id: "U789".into(),
sender_name: Some("alice".into()),
content: Some("hello".into()),
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
use futures::StreamExt;
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
.await
.unwrap()
.unwrap();
assert_eq!(msg.content, "hello");
assert_eq!(msg.user_id, "U789");
}
#[test]
fn with_max_failures_sets_value() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_max_failures(10);
#[tokio::test]
async fn start_skips_non_message_events() {
let (tx, rx) = mpsc::channel(64);
let channel =
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
assert_eq!(channel.max_consecutive_failures, 10);
}
let mut stream = channel.start().await.unwrap();
#[test]
fn default_max_failures_is_50() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.max_consecutive_failures, 50);
}
// Send a non-message event (should be skipped)
tx.send(ChannelEvent {
id: "1".into(),
event_type: "reaction".into(),
provider: "slack".into(),
provider_scope: "T123".into(),
channel_id: "C456".into(),
sender_id: "U789".into(),
sender_name: None,
content: None,
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
#[test]
fn empty_team_id_accepted_at_construction() {
// Regression: empty team_id (when no DB store is available) must not
// prevent channel construction or cause immediate shutdown.
let channel = RelayChannel::new(
test_client(),
"token".into(),
String::new(), // empty team_id
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.team_id, "");
// The reconnect loop now skips team validation when team_id is empty,
// so the channel remains alive.
// Send a real message
tx.send(ChannelEvent {
id: "2".into(),
event_type: "message".into(),
provider: "slack".into(),
provider_scope: "T123".into(),
channel_id: "C456".into(),
sender_id: "U789".into(),
sender_name: None,
content: Some("real message".into()),
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
use futures::StreamExt;
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
.await
.unwrap()
.unwrap();
assert_eq!(msg.content, "real message");
}
#[tokio::test]
async fn test_send_status_non_approval_is_noop() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
let metadata = serde_json::json!({});
let result = channel
.send_status(
@@ -775,13 +600,7 @@ mod tests {
#[tokio::test]
async fn test_send_status_approval_non_dm_skips() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "message",
"channel_id": "C456",
@@ -794,6 +613,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
@@ -804,13 +624,7 @@ mod tests {
#[tokio::test]
async fn test_send_status_approval_dm_missing_channel_id_errors() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "direct_message",
"sender_id": "U789",
@@ -822,6 +636,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
@@ -835,14 +650,8 @@ mod tests {
}
#[tokio::test]
async fn test_send_status_approval_dm_missing_sender_id_errors() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
async fn test_send_status_approval_dm_without_sender_id_is_ok() {
let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "direct_message",
"channel_id": "C456",
@@ -854,6 +663,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
allow_always: true,
},
&metadata,
)
@@ -861,8 +671,8 @@ mod tests {
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("sender_id"),
"expected sender_id error, got: {err}"
!err.contains("sender_id"),
"sender_id should not be required anymore, got: {err}"
);
}
}
+90 -205
View File
@@ -1,15 +1,10 @@
//! HTTP client for the channel-relay service.
//!
//! Wraps reqwest for all channel-relay API calls: OAuth initiation,
//! SSE streaming, token renewal, and Slack API proxy.
//! approvals, signing-secret fetch, and Slack API proxy.
use std::pin::Pin;
use std::task::{Context, Poll};
use futures::Stream;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
/// Known relay event types.
pub mod event_types {
@@ -18,7 +13,7 @@ pub mod event_types {
pub const MENTION: &str = "mention";
}
/// A parsed SSE event from the channel-relay stream.
/// A parsed event from the channel-relay webhook callback.
///
/// Field names match the channel-relay `ChannelEvent` struct exactly.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -123,21 +118,19 @@ impl RelayClient {
///
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
/// returns the `Location` header (Slack OAuth URL) without following it.
pub async fn initiate_oauth(
&self,
instance_id: &str,
user_id: &str,
callback_url: &str,
) -> Result<String, RelayError> {
/// Initiate Slack OAuth. Channel-relay derives all URLs from the trusted
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
/// for validating the callback — no URLs.
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
let mut query: Vec<(&str, &str)> = vec![];
if let Some(nonce) = state_nonce {
query.push(("state_nonce", nonce));
}
let resp = self
.http
.get(format!("{}/oauth/slack/auth", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.query(&[
("instance_id", instance_id),
("user_id", user_id),
("callback", callback_url),
])
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
@@ -173,104 +166,69 @@ impl RelayClient {
}
}
/// Connect to the SSE event stream.
/// Register a pending approval and return the opaque approval token.
///
/// Returns a stream of parsed `ChannelEvent`s and the `JoinHandle` of the
/// background SSE parser task. The caller is responsible for reconnection
/// logic on stream end/error and for aborting the handle on shutdown.
pub async fn connect_stream(
/// Calls `POST /approvals` with the target team/channel/request identifiers.
/// The returned token is embedded in Slack button values instead of routing fields.
/// The relay derives the authorized approver from the connection's authed_user_id.
pub async fn create_approval(
&self,
stream_token: &str,
stream_timeout_secs: u64,
) -> Result<(ChannelEventStream, tokio::task::JoinHandle<()>), RelayError> {
let resp = self
.http
.get(format!("{}/stream", self.base_url))
.query(&[("token", stream_token)])
.timeout(std::time::Duration::from_secs(stream_timeout_secs))
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(RelayError::TokenExpired);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status: status.as_u16(),
message: body,
});
}
// Spawn a background task that reads the SSE stream and sends parsed events
let (tx, rx) = mpsc::channel(64);
let byte_stream = resp.bytes_stream();
let handle = tokio::spawn(parse_sse_stream(byte_stream, tx));
Ok((ChannelEventStream { rx }, handle))
}
/// Renew an expired stream token.
///
/// Calls `POST /stream/renew` with API key auth, returns a new stream token.
pub async fn renew_token(
&self,
instance_id: &str,
user_id: &str,
team_id: &str,
channel_id: &str,
thread_ts: Option<&str>,
request_id: &str,
) -> Result<String, RelayError> {
let mut body = serde_json::json!({
"team_id": team_id,
"channel_id": channel_id,
"request_id": request_id,
});
if let Some(ts) = thread_ts {
body["thread_ts"] = serde_json::Value::String(ts.to_string());
}
let resp = self
.http
.post(format!("{}/stream/renew", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.json(&serde_json::json!({
"instance_id": instance_id,
"user_id": user_id,
}))
.post(format!("{}/approvals", self.base_url))
.bearer_auth(self.api_key.expose_secret())
.json(&body)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status: status.as_u16(),
status,
message: body,
});
}
let body: serde_json::Value = resp
let result: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("stream_token")
.or_else(|| body.get("token"))
result
.get("approval_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| RelayError::Protocol("Response missing stream_token field".to_string()))
.ok_or_else(|| RelayError::Protocol("missing approval_token in response".to_string()))
}
/// Proxy an API call through channel-relay for any provider.
///
/// Calls `POST /proxy/{provider}/{method}?team_id=X&instance_id=Y` with the given JSON body.
pub async fn proxy_provider(
&self,
provider: &str,
team_id: &str,
method: &str,
body: serde_json::Value,
instance_id: Option<&str>,
) -> Result<serde_json::Value, RelayError> {
let mut query: Vec<(&str, &str)> = vec![("team_id", team_id)];
if let Some(iid) = instance_id {
query.push(("instance_id", iid));
}
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
let resp = self
.http
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.header("X-API-Key", self.api_key.expose_secret())
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.json(&body)
.send()
@@ -291,12 +249,58 @@ impl RelayClient {
.map_err(|e| RelayError::Protocol(e.to_string()))
}
/// Fetch the per-instance callback signing secret from channel-relay.
///
/// Calls `GET /relay/signing-secret` (authenticated) and returns the decoded
/// 32-byte secret. Called once at activation time; the result is cached in the
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
let resp = self
.http
.get(format!("{}/relay/signing-secret", self.base_url))
.bearer_auth(self.api_key.expose_secret())
.query(&[("team_id", team_id)])
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status,
message: body,
});
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("signing_secret")
.and_then(|v| v.as_str())
.ok_or_else(|| RelayError::Protocol("missing signing_secret in response".to_string()))
.and_then(|raw| {
let decoded = hex::decode(raw).map_err(|e| {
RelayError::Protocol(format!("invalid signing_secret hex: {e}"))
})?;
if decoded.len() != 32 {
return Err(RelayError::Protocol(format!(
"invalid signing_secret length: expected 32 bytes, got {}",
decoded.len()
)));
}
Ok(decoded)
})
}
/// List active connections for an instance.
pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> {
let resp = self
.http
.get(format!("{}/connections", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.bearer_auth(self.api_key.expose_secret())
.query(&[("instance_id", instance_id)])
.send()
.await
@@ -317,91 +321,6 @@ impl RelayClient {
}
}
/// Async stream of parsed channel events from SSE.
pub struct ChannelEventStream {
rx: mpsc::Receiver<ChannelEvent>,
}
impl Stream for ChannelEventStream {
type Item = ChannelEvent;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.rx.poll_recv(cx)
}
}
/// Parse SSE format from a reqwest bytes stream.
///
/// SSE format:
/// ```text
/// event: message
/// data: {"key": "value"}
///
/// ```
/// Blank line terminates an event.
async fn parse_sse_stream(
byte_stream: impl futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
tx: mpsc::Sender<ChannelEvent>,
) {
use futures::StreamExt;
let mut buffer = Vec::<u8>::new();
let mut event_type = String::new();
let mut data_lines = Vec::new();
let mut byte_stream = std::pin::pin!(byte_stream);
while let Some(chunk_result) = byte_stream.next().await {
let chunk = match chunk_result {
Ok(c) => c,
Err(e) => {
tracing::debug!(error = %e, "SSE stream chunk error");
break;
}
};
buffer.extend_from_slice(&chunk);
// Process complete lines (decode UTF-8 only on full lines to avoid
// corruption when multi-byte characters span chunk boundaries)
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
let line = String::from_utf8_lossy(&buffer[..newline_pos])
.trim_end_matches('\r')
.to_string();
buffer.drain(..=newline_pos);
if line.is_empty() {
// Blank line = end of event
if !data_lines.is_empty() {
let data = data_lines.join("\n");
if let Ok(mut event) = serde_json::from_str::<ChannelEvent>(&data) {
if event.event_type.is_empty() && !event_type.is_empty() {
event.event_type = event_type.clone();
}
if tx.send(event).await.is_err() {
return; // receiver dropped
}
} else {
tracing::debug!(
event_type = %event_type,
data_len = data.len(),
"Failed to parse SSE event data as ChannelEvent"
);
}
}
event_type.clear();
data_lines.clear();
} else if let Some(value) = line.strip_prefix("event:") {
event_type = value.trim().to_string();
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim().to_string());
}
// Ignore other fields (id:, retry:, comments)
}
}
tracing::debug!("SSE stream ended");
}
/// Errors from relay client operations.
#[derive(Debug, thiserror::Error)]
pub enum RelayError {
@@ -413,9 +332,6 @@ pub enum RelayError {
#[error("Protocol error: {0}")]
Protocol(String),
#[error("Stream token expired")]
TokenExpired,
}
#[cfg(test)]
@@ -494,9 +410,6 @@ mod tests {
message: "unauthorized".into(),
};
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
let err = RelayError::TokenExpired;
assert_eq!(err.to_string(), "Stream token expired");
}
#[test]
@@ -518,32 +431,4 @@ mod tests {
assert!(make(event_types::DIRECT_MESSAGE).is_message());
assert!(make(event_types::MENTION).is_message());
}
#[tokio::test]
async fn parse_sse_handles_multibyte_utf8_across_chunks() {
// The crab emoji (🦀) is 4 bytes: [0xF0, 0x9F, 0xA6, 0x80].
// Split it across two chunks to verify no U+FFFD corruption.
let event_json = r#"{"event_type":"message","content":"hello 🦀 world","provider_scope":"T1","channel_id":"C1","sender_id":"U1"}"#;
let full = format!("event: message\ndata: {}\n\n", event_json);
let bytes = full.as_bytes();
// Find the crab emoji and split mid-character
let crab_pos = bytes
.windows(4)
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
.expect("crab emoji not found");
let split_at = crab_pos + 2; // split in the middle of the 4-byte emoji
let chunk1 = bytes::Bytes::copy_from_slice(&bytes[..split_at]);
let chunk2 = bytes::Bytes::copy_from_slice(&bytes[split_at..]);
let chunks: Vec<Result<bytes::Bytes, reqwest::Error>> = vec![Ok(chunk1), Ok(chunk2)];
let stream = futures::stream::iter(chunks);
let (tx, mut rx) = mpsc::channel(8);
parse_sse_stream(stream, tx).await;
let event = rx.recv().await.expect("should receive event");
assert_eq!(event.text(), "hello 🦀 world");
}
}
+4 -3
View File
@@ -1,12 +1,13 @@
//! Channel-relay integration for connecting to external messaging platforms
//! (Slack) via the channel-relay service.
//!
//! The relay service handles OAuth, credential storage, webhook ingestion,
//! and SSE event streaming. IronClaw consumes the SSE stream and sends
//! messages via the relay's proxy API.
//! The relay service handles OAuth, credential storage, and webhook ingestion.
//! IronClaw receives events via webhook callbacks and sends messages via the
//! relay's proxy API.
pub mod channel;
pub mod client;
pub mod webhook;
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
pub use client::RelayClient;
+66
View File
@@ -0,0 +1,66 @@
//! Shared relay webhook signature verification helpers.
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Verify a relay callback HMAC signature.
pub fn verify_relay_signature(
secret: &[u8],
timestamp: &str,
body: &[u8],
signature: &str,
) -> bool {
verify_signature(secret, timestamp, body, signature)
}
fn verify_signature(secret: &[u8], timestamp: &str, body: &[u8], signature: &str) -> bool {
let mut mac = match HmacSha256::new_from_slice(secret) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), signature.as_bytes()).into()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_signature(secret: &[u8], timestamp: &str, body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
#[test]
fn verify_valid_signature() {
let secret = b"test-secret";
let body = b"hello";
let ts = "1234567890";
let sig = make_signature(secret, ts, body);
assert!(verify_signature(secret, ts, body, &sig));
}
#[test]
fn verify_wrong_secret_fails() {
let body = b"hello";
let ts = "1234567890";
let sig = make_signature(b"correct", ts, body);
assert!(!verify_signature(b"wrong", ts, body, &sig));
}
#[test]
fn verify_tampered_body_fails() {
let secret = b"secret";
let ts = "1234567890";
let sig = make_signature(secret, ts, b"original");
assert!(!verify_signature(secret, ts, b"tampered", &sig));
}
}
+8 -3
View File
@@ -539,6 +539,7 @@ impl Channel for ReplChannel {
tool_name,
description,
parameters,
allow_always,
} => {
let term_width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
@@ -582,9 +583,13 @@ impl Channel for ReplChannel {
}
eprintln!(" \u{2502}");
eprintln!(
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
);
if allow_always {
eprintln!(
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
);
} else {
eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)");
}
eprintln!(" {bot_border}");
eprintln!();
}
+11 -3
View File
@@ -915,20 +915,28 @@ impl Channel for SignalChannel {
tool_name,
description: _,
parameters,
allow_always,
} = &status
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
{
let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default();
let always_line = if *allow_always {
format!(
"\n• `always` or `a` - Approve and auto-approve future {} requests",
tool_name
)
} else {
String::new()
};
let message = format!(
"⚠️ *Approval Required*\n\n\
*Request ID:* `{}`\n\
*Tool:* {}\n\
*Parameters:*\n```\n{}\n```\n\n\
Reply with:\n\
`yes` or `y` - Approve this request\n\
`always` or `a` - Approve and auto-approve future {} requests\n\
`yes` or `y` - Approve this request{}\n\
`no` or `n` - Deny",
request_id, tool_name, params_json, tool_name
request_id, tool_name, params_json, always_line
);
self.send_status_message(target_str, &message).await;
}
+65 -11
View File
@@ -492,8 +492,16 @@ impl near::agent::channel_host::Host for ChannelStoreData {
tracing::debug!(body = %truncated, "Response body");
}
// Leak detection on response body (best-effort)
if let Ok(body_str) = std::str::from_utf8(&body) {
// Leak detection on response body (best-effort).
//
// Telegram `getUpdates` is special: it is inbound polling data, so
// user-pasted secrets can legitimately appear in the response body.
// Those messages are still checked later by the inbound message
// safety layer before they reach the LLM, so we allow the polling
// response to continue here to avoid poisoning the offset state.
if let Ok(body_str) = std::str::from_utf8(&body)
&& !should_skip_response_leak_scan(&url)
{
leak_detector
.scan_and_clean(body_str)
.map_err(|e| format!("Potential secret leak in response: {}", e))?;
@@ -2035,6 +2043,7 @@ impl WasmChannel {
tool_name,
description,
parameters,
allow_always,
..
} => {
// WASM channels (Telegram, Slack, etc.) cannot render
@@ -2073,6 +2082,11 @@ impl WasmChannel {
})
.unwrap_or_default();
let reply_hint = if *allow_always {
"Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
} else {
"Reply \"yes\" to approve or \"no\" to deny."
};
let prompt = format!(
"Approval needed: {tool_name}\n\
{description}\n\
@@ -2080,7 +2094,7 @@ impl WasmChannel {
Parameters:\n\
{params_preview}\n\
\n\
Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
{reply_hint}"
);
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
@@ -2973,15 +2987,23 @@ fn status_to_wit(
request_id,
tool_name,
description,
allow_always,
..
} => wit_channel::StatusUpdate {
status: wit_channel::StatusType::ApprovalNeeded,
message: format!(
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: yes (or /approve), no (or /deny), or always (or /always).",
tool_name, description, request_id
),
metadata_json,
},
} => {
let reply_hint = if *allow_always {
"yes (or /approve), no (or /deny), or always (or /always)"
} else {
"yes (or /approve) or no (or /deny)"
};
wit_channel::StatusUpdate {
status: wit_channel::StatusType::ApprovalNeeded,
message: format!(
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: {}.",
tool_name, description, request_id, reply_hint
),
metadata_json,
}
}
StatusUpdate::JobStarted {
job_id,
title,
@@ -3122,6 +3144,19 @@ fn extract_host_from_url(url: &str) -> Option<String> {
})
}
fn should_skip_response_leak_scan(url: &str) -> bool {
url::Url::parse(url).is_ok_and(|parsed| {
matches!(parsed.scheme(), "http" | "https")
&& parsed
.host_str()
.is_some_and(|host| host.eq_ignore_ascii_case("api.telegram.org"))
&& parsed
.path_segments()
.and_then(|segments| segments.rev().find(|segment| !segment.is_empty()))
.is_some_and(|segment| segment == "getUpdates")
})
}
/// Pre-resolve host credentials for all HTTP capability mappings.
///
/// Called once per callback (in async context, before spawn_blocking) so the
@@ -3649,6 +3684,7 @@ mod tests {
tool_name: "http_request".into(),
description: "Fetch weather".into(),
parameters: serde_json::json!({"url": "https://wttr.in"}),
allow_always: true,
},
&metadata,
)
@@ -4110,6 +4146,7 @@ mod tests {
tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}),
allow_always: true,
},
&metadata,
)
@@ -4135,6 +4172,7 @@ mod tests {
tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}),
allow_always: true,
},
&metadata,
)
@@ -4386,6 +4424,22 @@ mod tests {
assert_eq!(store.redact_credentials(input), input);
}
#[test]
fn test_should_skip_response_leak_scan_only_for_telegram_getupdates() {
use super::should_skip_response_leak_scan;
assert!(should_skip_response_leak_scan(
"https://api.telegram.org/bot123/getUpdates?offset=1"
));
assert!(!should_skip_response_leak_scan(
"https://api.telegram.org/bot123/sendMessage"
));
assert!(!should_skip_response_leak_scan(
"https://api.example.com/getUpdates"
));
assert!(!should_skip_response_leak_scan("not a url"));
}
/// Verify that WASM HTTP host functions work using a dedicated
/// current-thread runtime inside spawn_blocking.
#[tokio::test]
+10
View File
@@ -102,6 +102,7 @@ impl GatewayChannel {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: server::ActiveConfigSnapshot::default(),
});
Self {
@@ -139,6 +140,7 @@ impl GatewayChannel {
cost_guard: self.state.cost_guard.clone(),
routine_engine: Arc::clone(&self.state.routine_engine),
startup_time: self.state.startup_time,
active_config: self.state.active_config.clone(),
};
mutate(&mut new_state);
self.state = Arc::new(new_state);
@@ -250,6 +252,12 @@ impl GatewayChannel {
self
}
/// Inject the active (resolved) configuration snapshot for the status endpoint.
pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self {
self.rebuild_state(|s| s.active_config = config);
self
}
/// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
&self.auth_token
@@ -366,6 +374,7 @@ impl Channel for GatewayChannel {
tool_name,
description,
parameters,
allow_always,
} => SseEvent::ApprovalNeeded {
request_id,
tool_name,
@@ -373,6 +382,7 @@ impl Channel for GatewayChannel {
parameters: serde_json::to_string_pretty(&parameters)
.unwrap_or_else(|_| parameters.to_string()),
thread_id,
allow_always,
},
StatusUpdate::AuthRequired {
extension_name,
+253 -81
View File
@@ -19,6 +19,7 @@ use axum::{
routing::{get, post},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tokio::sync::{mpsc, oneshot};
use tokio_stream::StreamExt;
use tower_http::cors::{AllowHeaders, CorsLayer};
@@ -63,6 +64,16 @@ pub type PromptQueue = Arc<
pub type RoutineEngineSlot =
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>;
fn redact_oauth_state_for_logs(state: &str) -> String {
let digest = Sha256::digest(state.as_bytes());
let mut short_hash = String::with_capacity(12);
for byte in &digest[..6] {
use std::fmt::Write as _;
let _ = write!(&mut short_hash, "{byte:02x}");
}
format!("sha256:{short_hash}:len={}", state.len())
}
/// Simple sliding-window rate limiter.
///
/// Tracks the number of requests in the current window. Resets when the window expires.
@@ -126,6 +137,14 @@ impl RateLimiter {
}
}
/// Snapshot of the active (resolved) configuration exposed to the frontend.
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct ActiveConfigSnapshot {
pub llm_backend: String,
pub llm_model: String,
pub enabled_channels: Vec<String>,
}
/// Shared state for all gateway handlers.
pub struct GatewayState {
/// Channel to send messages to the agent loop.
@@ -177,6 +196,8 @@ pub struct GatewayState {
pub routine_engine: RoutineEngineSlot,
/// Server startup time for uptime calculation.
pub startup_time: std::time::Instant,
/// Snapshot of active (resolved) configuration for the frontend.
pub active_config: ActiveConfigSnapshot,
}
/// Start the gateway HTTP server.
@@ -208,7 +229,8 @@ pub async fn start_server(
.route(
"/oauth/slack/callback",
get(slack_relay_oauth_callback_handler),
);
)
.route("/relay/events", post(relay_events_handler));
// Protected routes (require auth)
let auth_state = AuthState { token: auth_token };
@@ -555,22 +577,35 @@ async fn oauth_callback_handler(
}
};
// Strip instance prefix from state for registry lookup.
// Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only.
let lookup_key = oauth_defaults::strip_instance_prefix(&state_param);
let decoded_state = match oauth_defaults::decode_hosted_oauth_state(&state_param) {
Ok(decoded) => decoded,
Err(error) => {
let redacted_state = redact_oauth_state_for_logs(&state_param);
tracing::warn!(
state = %redacted_state,
error = %error,
"OAuth callback received with malformed state"
);
clear_auth_mode(&state).await;
return oauth_error_page("IronClaw");
}
};
let lookup_key = decoded_state.flow_id.clone();
let flow = ext_mgr
.pending_oauth_flows()
.write()
.await
.remove(lookup_key);
.remove(&lookup_key);
let flow = match flow {
Some(f) => f,
None => {
let redacted_state = redact_oauth_state_for_logs(&state_param);
let redacted_lookup_key = redact_oauth_state_for_logs(&lookup_key);
tracing::warn!(
state = %state_param,
lookup_key = %lookup_key,
state = %redacted_state,
lookup_key = %redacted_lookup_key,
"OAuth callback received with unknown or expired state"
);
clear_auth_mode(&state).await;
@@ -597,33 +632,29 @@ async fn oauth_callback_handler(
}
// Exchange the authorization code for tokens.
// Use the platform exchange proxy when configured (keeps client_secret off container),
// otherwise call the provider's token URL directly.
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
// Use the platform exchange proxy when configured, otherwise call the
// provider's token URL directly.
let exchange_proxy_url = oauth_defaults::exchange_proxy_url();
let result: Result<(), String> = async {
let token_response = if let (Some(proxy_url), None) = (&exchange_proxy_url, &flow.resource)
{
// Use the platform exchange proxy when configured and no resource
// parameter is needed. The proxy holds client_secret server-side so
// the container never sees it. MCP flows (resource.is_some()) bypass
// the proxy because it doesn't forward the RFC 8707 resource param.
let token_response = if let Some(proxy_url) = &exchange_proxy_url {
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
oauth_defaults::exchange_via_proxy(
oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
proxy_url,
gateway_token,
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
)
token_url: &flow.token_url,
client_id: &flow.client_id,
client_secret: flow.client_secret.as_deref(),
code: &code,
redirect_uri: &flow.redirect_uri,
code_verifier: flow.code_verifier.as_deref(),
access_token_field: &flow.access_token_field,
extra_token_params: &flow.token_exchange_extra_params,
})
.await
.map_err(|e| e.to_string())?
} else {
// Direct token exchange: uses exchange_oauth_code_with_resource so MCP
// flows can include the RFC 8707 `resource` parameter to scope the
// issued token to the specific MCP server.
oauth_defaults::exchange_oauth_code_with_resource(
oauth_defaults::exchange_oauth_code_with_params(
&flow.token_url,
&flow.client_id,
flow.client_secret.as_deref(),
@@ -631,7 +662,7 @@ async fn oauth_callback_handler(
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
flow.resource.as_deref(),
&flow.token_exchange_extra_params,
)
.await
.map_err(|e| e.to_string())?
@@ -658,10 +689,8 @@ async fn oauth_callback_handler(
.await
.map_err(|e| e.to_string())?;
// For MCP OAuth flows (identified by resource field), persist the
// client_id so token refresh works without re-authentication.
// The CLI flow stores this in authorize_mcp_server(); the gateway
// callback must do the same.
// Persist the client_id for flows that need it after the session ends
// (for example DCR-based MCP refresh).
if let Some(ref client_id_secret) = flow.client_id_secret_name {
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default());
@@ -742,11 +771,103 @@ async fn oauth_callback_handler(
axum::response::Html(html).into_response()
}
/// Webhook endpoint for receiving relay events from channel-relay.
///
/// PUBLIC route — authenticated via HMAC signature (X-Relay-Signature header).
async fn relay_events_handler(
State(state): State<Arc<GatewayState>>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> impl IntoResponse {
let ext_mgr = match state.extension_manager.as_ref() {
Some(mgr) => mgr,
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "not ready").into_response();
}
};
let signing_secret = match ext_mgr.relay_signing_secret() {
Some(s) => s,
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "relay not configured").into_response();
}
};
// Verify signature
let signature = match headers
.get("x-relay-signature")
.and_then(|v| v.to_str().ok())
{
Some(s) => s.to_string(),
None => {
return (StatusCode::UNAUTHORIZED, "missing signature").into_response();
}
};
let timestamp = match headers
.get("x-relay-timestamp")
.and_then(|v| v.to_str().ok())
{
Some(t) => t.to_string(),
None => {
return (StatusCode::UNAUTHORIZED, "missing timestamp").into_response();
}
};
// Check timestamp freshness (5 min window)
let ts: i64 = match timestamp.parse() {
Ok(t) => t,
Err(_) => {
return (StatusCode::BAD_REQUEST, "malformed timestamp").into_response();
}
};
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > 300 {
return (StatusCode::UNAUTHORIZED, "stale timestamp").into_response();
}
// Verify HMAC: sha256(secret, timestamp + "." + body)
if !crate::channels::relay::webhook::verify_relay_signature(
&signing_secret,
&timestamp,
&body,
&signature,
) {
return (StatusCode::UNAUTHORIZED, "invalid signature").into_response();
}
// Parse event
let event: crate::channels::relay::client::ChannelEvent = match serde_json::from_slice(&body) {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "relay callback invalid JSON");
return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
}
};
// Push to relay channel
let event_tx_guard = ext_mgr.relay_event_tx();
let event_tx = event_tx_guard.lock().await;
match event_tx.as_ref() {
Some(tx) => {
if let Err(e) = tx.try_send(event) {
tracing::warn!(error = %e, "relay event channel full or closed");
return (StatusCode::SERVICE_UNAVAILABLE, "event queue full").into_response();
}
}
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "relay channel not active").into_response();
}
}
Json(serde_json::json!({"ok": true})).into_response()
}
/// OAuth callback for Slack via channel-relay.
///
/// This is a PUBLIC route (no Bearer token required) because channel-relay
/// redirects the user's browser here after Slack OAuth completes.
/// Query params: `stream_token`, `provider`, `team_id`.
/// Query params: `provider`, `team_id`.
async fn slack_relay_oauth_callback_handler(
State(state): State<Arc<GatewayState>>,
Query(params): Query<std::collections::HashMap<String, String>>,
@@ -763,27 +884,6 @@ async fn slack_relay_oauth_callback_handler(
.into_response();
}
// Validate stream_token: required, non-empty, max 2048 bytes
let stream_token = match params.get("stream_token") {
Some(t) if !t.is_empty() && t.len() <= 2048 => t.clone(),
Some(t) if t.len() > 2048 => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
_ => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
};
// Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
let team_id = params.get("team_id").cloned().unwrap_or_default();
if !team_id.is_empty() {
@@ -869,30 +969,16 @@ async fn slack_relay_oauth_callback_handler(
let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
let result: Result<(), String> = async {
// Store the stream token as a secret
let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME);
let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await;
ext_mgr
.secrets()
.create(
&state.user_id,
crate::secrets::CreateSecretParams {
name: token_key,
value: secrecy::SecretString::from(stream_token),
provider: Some(provider.clone()),
expires_at: None,
},
)
.await
.map_err(|e| format!("Failed to store stream token: {}", e))?;
let store = state.store.as_ref().ok_or_else(|| {
"Relay activation requires persistent settings storage; no-db mode is unsupported."
.to_string()
})?;
// Store team_id in settings
if let Some(ref store) = state.store {
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
let _ = store
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
.await;
}
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
let _ = store
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
.await;
// Activate the relay channel
ext_mgr
@@ -2669,6 +2755,9 @@ async fn gateway_status_handler(
daily_cost,
actions_this_hour,
model_usage,
llm_backend: state.active_config.llm_backend.clone(),
llm_model: state.active_config.llm_model.clone(),
enabled_channels: state.active_config.enabled_channels.clone(),
})
}
@@ -2694,6 +2783,9 @@ struct GatewayStatusResponse {
actions_this_hour: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
model_usage: Option<Vec<ModelUsageEntry>>,
llm_backend: String,
llm_model: String,
enabled_channels: Vec<String>,
}
#[cfg(test)]
@@ -2890,6 +2982,7 @@ mod tests {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(),
})
}
@@ -3236,7 +3329,7 @@ mod tests {
secrets,
sse_sender: None,
gateway_token: None,
resource: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
@@ -3304,7 +3397,7 @@ mod tests {
secrets,
sse_sender: Some(sender),
gateway_token: None,
resource: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
@@ -3407,7 +3500,7 @@ mod tests {
secrets,
sse_sender: None,
gateway_token: None,
resource: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
// Expired — handler will reject after lookup (no network I/O)
created_at,
@@ -3459,6 +3552,85 @@ mod tests {
);
}
#[tokio::test]
async fn test_oauth_callback_accepts_versioned_hosted_state() {
use axum::body::Body;
use tower::ServiceExt;
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
TEST_GATEWAY_CRYPTO_KEY.to_string(),
))
.expect("crypto"),
)));
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
let Some(created_at) = expired_flow_created_at() else {
eprintln!("Skipping versioned OAuth state test: monotonic uptime below expiry window");
return;
};
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
extension_name: "test_tool".to_string(),
display_name: "Test Tool".to_string(),
token_url: "https://example.com/token".to_string(),
client_id: "client123".to_string(),
client_secret: None,
redirect_uri: "https://example.com/oauth/callback".to_string(),
code_verifier: None,
access_token_field: "access_token".to_string(),
secret_name: "test_token".to_string(),
provider: None,
validation_endpoint: None,
scopes: vec![],
user_id: "test".to_string(),
secrets,
sse_sender: None,
gateway_token: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
ext_mgr
.pending_oauth_flows()
.write()
.await
.insert("test_nonce".to_string(), flow);
let state = test_gateway_state(Some(ext_mgr.clone()));
let app = test_oauth_router(state);
let versioned_state =
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", Some("myinstance"));
let req = axum::http::Request::builder()
.uri(format!(
"/oauth/callback?code=fake_code&state={}",
urlencoding::encode(&versioned_state)
))
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Authorization Failed"));
assert!(
ext_mgr
.pending_oauth_flows()
.read()
.await
.get("test_nonce")
.is_none()
);
}
// --- Slack relay OAuth CSRF tests ---
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
@@ -3516,7 +3688,7 @@ mod tests {
// Callback without state param should be rejected
let req = axum::http::Request::builder()
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack")
.uri("/oauth/slack/callback?team_id=T123&provider=slack")
.body(Body::empty())
.expect("request");
@@ -3560,7 +3732,7 @@ mod tests {
// Callback with wrong state param
let req = axum::http::Request::builder()
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state=wrong-nonce")
.uri("/oauth/slack/callback?team_id=T123&provider=slack&state=wrong-nonce")
.body(Body::empty())
.expect("request");
@@ -3608,7 +3780,7 @@ mod tests {
// we just verify it doesn't return a CSRF error.
let req = axum::http::Request::builder()
.uri(format!(
"/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}",
"/oauth/slack/callback?team_id=T123&provider=slack&state={}",
nonce
))
.body(Body::empty())
File diff suppressed because it is too large Load Diff
+170 -10
View File
@@ -29,9 +29,15 @@ I18n.register('en', {
'tab.memory': 'Memory',
'tab.jobs': 'Jobs',
'tab.routines': 'Routines',
'tab.settings': 'Settings',
'tab.extensions': 'Extensions',
'tab.skills': 'Skills',
'tab.logs': 'Logs',
'settings.inference': 'Inference',
'settings.agent': 'Agent',
'settings.channels': 'Channels',
'settings.networking': 'Networking',
'settings.mcp': 'MCP',
// Status
'status.connected': 'Connected',
@@ -131,10 +137,10 @@ I18n.register('en', {
// Extensions Tab
'extensions.installed': 'Installed Extensions',
'extensions.available': 'Available WASM Extensions',
'extensions.installWasm': 'Install WASM Extension',
'extensions.available': 'Available Extensions',
'extensions.installWasm': 'Install Extension',
'extensions.noInstalled': 'No extensions installed',
'extensions.noAvailable': 'No additional WASM extensions available',
'extensions.noAvailable': 'No additional extensions available',
'extensions.loading': 'Loading...',
'extensions.install': 'Install',
'extensions.installing': 'Installing...',
@@ -156,13 +162,8 @@ I18n.register('en', {
'mcp.addCustom': 'Add Custom MCP Server',
'mcp.add': 'Add',
'mcp.addedSuccess': 'Added MCP server {name}',
// Registered Tools
'tools.registered': 'Registered Tools',
'tools.name': 'Name',
'tools.description': 'Description',
'tools.empty': 'No tools registered',
// Skills Tab
'skills.installed': 'Installed Skills',
'skills.noInstalled': 'No skills installed',
@@ -302,6 +303,7 @@ I18n.register('en', {
// Common
'common.loading': 'Loading...',
'common.loadFailed': 'Failed to load',
'common.noData': 'No data',
'common.search': 'Search',
'common.add': 'Add',
@@ -328,6 +330,8 @@ I18n.register('en', {
// Extensions
'ext.active': 'Active',
'ext.inactive': 'Inactive',
'ext.builtin': 'Built-in',
'ext.remove': 'Remove',
'ext.install': 'Install',
'ext.installing': 'Installing...',
@@ -355,4 +359,160 @@ I18n.register('en', {
'config.autoGenerate': 'Auto-generated if empty',
'config.save': 'Save',
'config.cancel': 'Cancel',
// Settings toolbar
'settings.export': 'Export',
'settings.import': 'Import',
'settings.searchPlaceholder': 'Search settings...',
'settings.exportSuccess': 'Settings exported',
'settings.exportFailed': 'Export failed: {message}',
'settings.importSuccess': 'Settings imported successfully',
'settings.importFailed': 'Import failed: {message}',
'settings.restartRequired': 'Restart required for changes to take effect.',
'settings.restartNow': 'Restart Now',
'settings.noMatchingSettings': 'No settings matching "{query}"',
'settings.noSettings': 'No settings found',
'settings.saved': 'Saved',
'settings.on': 'On',
'settings.off': 'Off',
'settings.envValue': 'env: {value}',
'settings.envDefault': 'env default',
'settings.useEnvDefault': 'use env default',
// Settings groups
'cfg.group.llm': 'LLM Provider',
'cfg.group.embeddings': 'Embeddings',
'cfg.group.agent': 'Agent',
'cfg.group.heartbeat': 'Heartbeat',
'cfg.group.sandbox': 'Sandbox',
'cfg.group.routines': 'Routines',
'cfg.group.safety': 'Safety',
'cfg.group.skills': 'Skills',
'cfg.group.search': 'Search',
'cfg.group.tunnel': 'Tunnel',
'cfg.group.gateway': 'Gateway',
// Inference settings
'cfg.llm_backend.label': 'Backend',
'cfg.llm_backend.desc': 'LLM inference provider',
'cfg.selected_model.label': 'Model',
'cfg.selected_model.desc': 'Model name or ID for the selected backend',
'cfg.ollama_base_url.label': 'Ollama URL',
'cfg.ollama_base_url.desc': 'Base URL for Ollama API',
'cfg.openai_compatible_base_url.label': 'OpenAI-compatible URL',
'cfg.openai_compatible_base_url.desc': 'Base URL for OpenAI-compatible API',
'cfg.bedrock_region.label': 'Bedrock Region',
'cfg.bedrock_region.desc': 'AWS region for Bedrock',
'cfg.bedrock_cross_region.label': 'Cross-Region',
'cfg.bedrock_cross_region.desc': 'Enable cross-region inference',
'cfg.bedrock_profile.label': 'AWS Profile',
'cfg.bedrock_profile.desc': 'AWS profile for Bedrock auth',
'cfg.embeddings_enabled.label': 'Enabled',
'cfg.embeddings_enabled.desc': 'Enable vector embeddings for memory search',
'cfg.embeddings_provider.label': 'Provider',
'cfg.embeddings_provider.desc': 'Embeddings API provider',
'cfg.embeddings_model.label': 'Model',
'cfg.embeddings_model.desc': 'Embedding model name',
// Agent settings
'cfg.agent_name.label': 'Name',
'cfg.agent_name.desc': 'Agent display name',
'cfg.agent_max_parallel_jobs.label': 'Max Parallel Jobs',
'cfg.agent_max_parallel_jobs.desc': 'Maximum concurrent background jobs',
'cfg.agent_job_timeout.label': 'Job Timeout',
'cfg.agent_job_timeout.desc': 'Max duration per job in seconds',
'cfg.agent_max_tool_iterations.label': 'Max Tool Iterations',
'cfg.agent_max_tool_iterations.desc': 'Max tool calls per turn',
'cfg.agent_use_planning.label': 'Planning',
'cfg.agent_use_planning.desc': 'Enable multi-step planning before execution',
'cfg.agent_auto_approve.label': 'Auto-approve Tools',
'cfg.agent_auto_approve.desc': 'Skip manual approval for tool calls',
'cfg.agent_timezone.label': 'Timezone',
'cfg.agent_timezone.desc': 'Default timezone (IANA)',
'cfg.agent_session_idle.label': 'Session Idle Timeout',
'cfg.agent_session_idle.desc': 'Seconds before idle session expires',
'cfg.agent_stuck_threshold.label': 'Stuck Threshold',
'cfg.agent_stuck_threshold.desc': 'Seconds before a job is considered stuck',
'cfg.agent_max_repair.label': 'Max Repair Attempts',
'cfg.agent_max_repair.desc': 'Auto-recovery attempts for stuck jobs',
'cfg.agent_max_cost.label': 'Max Daily Cost',
'cfg.agent_max_cost.desc': 'Daily LLM spend cap in cents (0 = unlimited)',
'cfg.agent_max_actions.label': 'Max Actions/Hour',
'cfg.agent_max_actions.desc': 'Hourly tool call rate limit (0 = unlimited)',
'cfg.agent_allow_local.label': 'Allow Local Tools',
'cfg.agent_allow_local.desc': 'Enable local filesystem tool execution',
// Heartbeat settings
'cfg.heartbeat_enabled.label': 'Enabled',
'cfg.heartbeat_enabled.desc': 'Run periodic background checks',
'cfg.heartbeat_interval.label': 'Interval',
'cfg.heartbeat_interval.desc': 'Seconds between heartbeats (default: 1800)',
'cfg.heartbeat_notify_channel.label': 'Notify Channel',
'cfg.heartbeat_notify_channel.desc': 'Channel to send heartbeat findings to',
'cfg.heartbeat_notify_user.label': 'Notify User',
'cfg.heartbeat_notify_user.desc': 'User ID to notify',
'cfg.heartbeat_quiet_start.label': 'Quiet Hours Start',
'cfg.heartbeat_quiet_start.desc': 'Hour (0-23) to stop heartbeats',
'cfg.heartbeat_quiet_end.label': 'Quiet Hours End',
'cfg.heartbeat_quiet_end.desc': 'Hour (0-23) to resume heartbeats',
'cfg.heartbeat_timezone.label': 'Timezone',
'cfg.heartbeat_timezone.desc': 'Timezone for quiet hours (IANA)',
// Sandbox settings
'cfg.sandbox_enabled.label': 'Enabled',
'cfg.sandbox_enabled.desc': 'Enable Docker sandbox for background jobs',
'cfg.sandbox_policy.label': 'Policy',
'cfg.sandbox_policy.desc': 'Sandbox security policy',
'cfg.sandbox_timeout.label': 'Timeout',
'cfg.sandbox_timeout.desc': 'Max job duration in seconds',
'cfg.sandbox_memory.label': 'Memory Limit',
'cfg.sandbox_memory.desc': 'Container memory limit (MB)',
'cfg.sandbox_image.label': 'Docker Image',
'cfg.sandbox_image.desc': 'Container image for sandbox jobs',
// Routines settings
'cfg.routines_max_concurrent.label': 'Max Concurrent',
'cfg.routines_max_concurrent.desc': 'Maximum routines running simultaneously',
'cfg.routines_cooldown.label': 'Default Cooldown',
'cfg.routines_cooldown.desc': 'Minimum seconds between routine fires',
// Safety settings
'cfg.safety_max_output.label': 'Max Output Length',
'cfg.safety_max_output.desc': 'Maximum output tokens per response',
'cfg.safety_injection_check.label': 'Injection Check',
'cfg.safety_injection_check.desc': 'Enable prompt injection detection',
// Skills settings
'cfg.skills_max_active.label': 'Max Active Skills',
'cfg.skills_max_active.desc': 'Maximum skills active simultaneously',
'cfg.skills_max_tokens.label': 'Max Context Tokens',
'cfg.skills_max_tokens.desc': 'Token budget for skill prompts',
// Search settings
'cfg.search_fusion.label': 'Fusion Strategy',
'cfg.search_fusion.desc': 'Hybrid search ranking method',
// Networking settings
'cfg.tunnel_provider.label': 'Provider',
'cfg.tunnel_provider.desc': 'Public URL tunnel provider',
'cfg.tunnel_public_url.label': 'Public URL',
'cfg.tunnel_public_url.desc': 'Static public URL (if not using tunnel provider)',
'cfg.gateway_rate_limit.label': 'Rate Limit',
'cfg.gateway_rate_limit.desc': 'Max chat messages per minute',
'cfg.gateway_max_connections.label': 'Max Connections',
'cfg.gateway_max_connections.desc': 'Max simultaneous SSE/WS connections',
// Channels subtab
'channels.builtin': 'Built-in Channels',
'channels.messaging': 'Messaging Channels',
'channels.webGateway': 'Web Gateway',
'channels.webGatewayDesc': 'Browser-based chat interface',
'channels.httpWebhook': 'HTTP Webhook',
'channels.httpWebhookDesc': 'Incoming webhook endpoint for external integrations',
'channels.cli': 'CLI',
'channels.cliDesc': 'Terminal UI with Ratatui',
'channels.repl': 'REPL',
'channels.replDesc': 'Simple read-eval-print loop for testing',
'channels.configureVia': 'Configure via {env}',
'channels.runWith': 'Run with: {cmd}',
});
+170 -10
View File
@@ -29,9 +29,15 @@ I18n.register('zh-CN', {
'tab.memory': '记忆',
'tab.jobs': '任务',
'tab.routines': '定时任务',
'tab.settings': '设置',
'tab.extensions': '扩展',
'tab.skills': '技能',
'tab.logs': '日志',
'settings.inference': '推理',
'settings.agent': '代理',
'settings.channels': '频道',
'settings.networking': '网络',
'settings.mcp': 'MCP',
// 状态
'status.connected': '已连接',
@@ -131,10 +137,10 @@ I18n.register('zh-CN', {
// 扩展标签页
'extensions.installed': '已安装扩展',
'extensions.available': '可用 WASM 扩展',
'extensions.installWasm': '安装 WASM 扩展',
'extensions.available': '可用扩展',
'extensions.installWasm': '安装扩展',
'extensions.noInstalled': '没有安装扩展',
'extensions.noAvailable': '没有其他可用的 WASM 扩展',
'extensions.noAvailable': '没有其他可用扩展',
'extensions.loading': '加载中...',
'extensions.install': '安装',
'extensions.installing': '安装中...',
@@ -156,13 +162,8 @@ I18n.register('zh-CN', {
'mcp.addCustom': '添加自定义 MCP 服务器',
'mcp.add': '添加',
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
// 注册工具
'tools.registered': '注册工具',
'tools.name': '名称',
'tools.description': '描述',
'tools.empty': '没有注册工具',
// 技能标签页
'skills.installed': '已安装技能',
'skills.noInstalled': '没有安装技能',
@@ -302,6 +303,7 @@ I18n.register('zh-CN', {
// 通用
'common.loading': '加载中...',
'common.loadFailed': '加载失败',
'common.noData': '暂无数据',
'common.search': '搜索',
'common.add': '添加',
@@ -328,6 +330,8 @@ I18n.register('zh-CN', {
// 扩展
'ext.active': '已激活',
'ext.inactive': '未激活',
'ext.builtin': '内置',
'ext.remove': '移除',
'ext.install': '安装',
'ext.installing': '安装中...',
@@ -354,4 +358,160 @@ I18n.register('zh-CN', {
'config.autoGenerate': '如果为空则自动生成',
'config.save': '保存',
'config.cancel': '取消',
// 设置工具栏
'settings.export': '导出',
'settings.import': '导入',
'settings.searchPlaceholder': '搜索设置...',
'settings.exportSuccess': '设置已导出',
'settings.exportFailed': '导出失败: {message}',
'settings.importSuccess': '设置导入成功',
'settings.importFailed': '导入失败: {message}',
'settings.restartRequired': '需要重启才能使更改生效。',
'settings.restartNow': '立即重启',
'settings.noMatchingSettings': '没有匹配 "{query}" 的设置',
'settings.noSettings': '未找到设置',
'settings.saved': '已保存',
'settings.on': '开启',
'settings.off': '关闭',
'settings.envValue': '环境变量: {value}',
'settings.envDefault': '使用环境变量默认值',
'settings.useEnvDefault': '使用环境变量默认值',
// 设置分组
'cfg.group.llm': 'LLM 提供商',
'cfg.group.embeddings': '嵌入向量',
'cfg.group.agent': '代理',
'cfg.group.heartbeat': '心跳',
'cfg.group.sandbox': '沙箱',
'cfg.group.routines': '定时任务',
'cfg.group.safety': '安全',
'cfg.group.skills': '技能',
'cfg.group.search': '搜索',
'cfg.group.tunnel': '隧道',
'cfg.group.gateway': '网关',
// 推理设置
'cfg.llm_backend.label': '后端',
'cfg.llm_backend.desc': 'LLM 推理提供商',
'cfg.selected_model.label': '模型',
'cfg.selected_model.desc': '所选后端的模型名称或 ID',
'cfg.ollama_base_url.label': 'Ollama URL',
'cfg.ollama_base_url.desc': 'Ollama API 基础 URL',
'cfg.openai_compatible_base_url.label': 'OpenAI 兼容 URL',
'cfg.openai_compatible_base_url.desc': 'OpenAI 兼容 API 基础 URL',
'cfg.bedrock_region.label': 'Bedrock 区域',
'cfg.bedrock_region.desc': 'Bedrock 的 AWS 区域',
'cfg.bedrock_cross_region.label': '跨区域',
'cfg.bedrock_cross_region.desc': '启用跨区域推理',
'cfg.bedrock_profile.label': 'AWS 配置文件',
'cfg.bedrock_profile.desc': 'Bedrock 认证的 AWS 配置文件',
'cfg.embeddings_enabled.label': '启用',
'cfg.embeddings_enabled.desc': '启用向量嵌入以支持记忆搜索',
'cfg.embeddings_provider.label': '提供商',
'cfg.embeddings_provider.desc': '嵌入向量 API 提供商',
'cfg.embeddings_model.label': '模型',
'cfg.embeddings_model.desc': '嵌入向量模型名称',
// 代理设置
'cfg.agent_name.label': '名称',
'cfg.agent_name.desc': '代理显示名称',
'cfg.agent_max_parallel_jobs.label': '最大并行任务数',
'cfg.agent_max_parallel_jobs.desc': '最大并发后台任务数',
'cfg.agent_job_timeout.label': '任务超时',
'cfg.agent_job_timeout.desc': '每个任务的最大持续时间(秒)',
'cfg.agent_max_tool_iterations.label': '最大工具迭代次数',
'cfg.agent_max_tool_iterations.desc': '每轮最大工具调用次数',
'cfg.agent_use_planning.label': '规划',
'cfg.agent_use_planning.desc': '执行前启用多步规划',
'cfg.agent_auto_approve.label': '自动批准工具',
'cfg.agent_auto_approve.desc': '跳过工具调用的手动审批',
'cfg.agent_timezone.label': '时区',
'cfg.agent_timezone.desc': '默认时区(IANA',
'cfg.agent_session_idle.label': '会话空闲超时',
'cfg.agent_session_idle.desc': '空闲会话过期前的秒数',
'cfg.agent_stuck_threshold.label': '卡住阈值',
'cfg.agent_stuck_threshold.desc': '任务被认为卡住前的秒数',
'cfg.agent_max_repair.label': '最大修复尝试次数',
'cfg.agent_max_repair.desc': '卡住任务的自动恢复尝试次数',
'cfg.agent_max_cost.label': '每日最大费用',
'cfg.agent_max_cost.desc': '每日 LLM 支出上限(美分,0 = 无限制)',
'cfg.agent_max_actions.label': '每小时最大操作数',
'cfg.agent_max_actions.desc': '每小时工具调用速率限制(0 = 无限制)',
'cfg.agent_allow_local.label': '允许本地工具',
'cfg.agent_allow_local.desc': '启用本地文件系统工具执行',
// 心跳设置
'cfg.heartbeat_enabled.label': '启用',
'cfg.heartbeat_enabled.desc': '运行定期后台检查',
'cfg.heartbeat_interval.label': '间隔',
'cfg.heartbeat_interval.desc': '心跳间隔秒数(默认:1800',
'cfg.heartbeat_notify_channel.label': '通知频道',
'cfg.heartbeat_notify_channel.desc': '发送心跳发现的频道',
'cfg.heartbeat_notify_user.label': '通知用户',
'cfg.heartbeat_notify_user.desc': '要通知的用户 ID',
'cfg.heartbeat_quiet_start.label': '静默时段开始',
'cfg.heartbeat_quiet_start.desc': '停止心跳的小时(0-23',
'cfg.heartbeat_quiet_end.label': '静默时段结束',
'cfg.heartbeat_quiet_end.desc': '恢复心跳的小时(0-23',
'cfg.heartbeat_timezone.label': '时区',
'cfg.heartbeat_timezone.desc': '静默时段的时区(IANA',
// 沙箱设置
'cfg.sandbox_enabled.label': '启用',
'cfg.sandbox_enabled.desc': '启用 Docker 沙箱以运行后台任务',
'cfg.sandbox_policy.label': '策略',
'cfg.sandbox_policy.desc': '沙箱安全策略',
'cfg.sandbox_timeout.label': '超时',
'cfg.sandbox_timeout.desc': '最大任务持续时间(秒)',
'cfg.sandbox_memory.label': '内存限制',
'cfg.sandbox_memory.desc': '容器内存限制(MB',
'cfg.sandbox_image.label': 'Docker 镜像',
'cfg.sandbox_image.desc': '沙箱任务的容器镜像',
// 定时任务设置
'cfg.routines_max_concurrent.label': '最大并发数',
'cfg.routines_max_concurrent.desc': '同时运行的最大定时任务数',
'cfg.routines_cooldown.label': '默认冷却时间',
'cfg.routines_cooldown.desc': '定时任务触发间的最小秒数',
// 安全设置
'cfg.safety_max_output.label': '最大输出长度',
'cfg.safety_max_output.desc': '每次响应的最大输出令牌数',
'cfg.safety_injection_check.label': '注入检查',
'cfg.safety_injection_check.desc': '启用提示注入检测',
// 技能设置
'cfg.skills_max_active.label': '最大活跃技能数',
'cfg.skills_max_active.desc': '同时活跃的最大技能数',
'cfg.skills_max_tokens.label': '最大上下文令牌数',
'cfg.skills_max_tokens.desc': '技能提示的令牌预算',
// 搜索设置
'cfg.search_fusion.label': '融合策略',
'cfg.search_fusion.desc': '混合搜索排名方法',
// 网络设置
'cfg.tunnel_provider.label': '提供商',
'cfg.tunnel_provider.desc': '公网 URL 隧道提供商',
'cfg.tunnel_public_url.label': '公网 URL',
'cfg.tunnel_public_url.desc': '静态公网 URL(不使用隧道提供商时)',
'cfg.gateway_rate_limit.label': '速率限制',
'cfg.gateway_rate_limit.desc': '每分钟最大聊天消息数',
'cfg.gateway_max_connections.label': '最大连接数',
'cfg.gateway_max_connections.desc': '最大同时 SSE/WS 连接数',
// 频道子标签
'channels.builtin': '内置频道',
'channels.messaging': '消息频道',
'channels.webGateway': 'Web 网关',
'channels.webGatewayDesc': '基于浏览器的聊天界面',
'channels.httpWebhook': 'HTTP Webhook',
'channels.httpWebhookDesc': '用于外部集成的传入 webhook 端点',
'channels.cli': 'CLI',
'channels.cliDesc': '使用 Ratatui 的终端 UI',
'channels.repl': 'REPL',
'channels.replDesc': '用于测试的简单读取-求值-打印循环',
'channels.configureVia': '通过 {env} 配置',
'channels.runWith': '运行命令: {cmd}',
});
+111 -64
View File
@@ -95,8 +95,7 @@
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button>
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
<button data-tab="settings" data-i18n="tab.settings">Settings</button>
<div class="spacer"></div>
<!-- Language Switcher -->
@@ -271,77 +270,125 @@
</div>
</div>
<!-- Extensions Tab -->
<div class="tab-panel" id="tab-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
<!-- Settings Tab -->
<div class="tab-panel" id="tab-settings">
<div class="settings-layout">
<div class="settings-sidebar">
<button class="settings-subtab active" data-settings-subtab="inference" data-i18n="settings.inference">Inference</button>
<button class="settings-subtab" data-settings-subtab="agent" data-i18n="settings.agent">Agent</button>
<button class="settings-subtab" data-settings-subtab="channels" data-i18n="settings.channels">Channels</button>
<button class="settings-subtab" data-settings-subtab="networking" data-i18n="settings.networking">Networking</button>
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
</div>
<div class="extensions-section" id="available-wasm-section">
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
<div class="settings-content">
<div class="settings-toolbar">
<div class="settings-search">
<input type="text" id="settings-search-input" data-i18n-placeholder="settings.searchPlaceholder" placeholder="Search settings..." data-i18n-attr="aria-label" data-i18n="settings.searchPlaceholder" aria-label="Search settings...">
</div>
<button id="settings-export-btn" class="settings-toolbar-btn" data-i18n="settings.export">Export</button>
<button id="settings-import-btn" class="settings-toolbar-btn" data-i18n="settings.import">Import</button>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button id="wasm-install-btn" data-i18n="extensions.install">Install</button>
<div class="settings-subpanel active" id="settings-inference">
<div class="extensions-container" id="settings-inference-content">
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
<div class="settings-subpanel" id="settings-agent">
<div class="extensions-container" id="settings-agent-content">
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
</div>
</div>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button>
<div class="settings-subpanel" id="settings-channels">
<div class="extensions-container" id="settings-channels-content">
<div class="empty-state" data-i18n="common.loading">Loading channels...</div>
</div>
</div>
<div class="settings-subpanel" id="settings-networking">
<div class="extensions-container" id="settings-networking-content">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="settings-subpanel" id="settings-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section" id="available-wasm-section">
<h3 data-i18n="extensions.available">Available Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="extensions.installWasm">Install Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button id="wasm-install-btn" data-i18n="extensions.install">Install</button>
</div>
</div>
</div>
</div>
<div class="settings-subpanel" id="settings-mcp">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button>
</div>
</div>
</div>
</div>
<div class="settings-subpanel" id="settings-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search for skills...">
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installed">Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
<button id="skill-install-btn" data-i18n="extensions.install">Install</button>
</div>
</div>
</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="tools.registered">Registered Tools</h3>
<table class="tools-table" id="tools-table">
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
<tbody id="tools-tbody"></tbody>
</table>
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
</div>
</div>
</div>
</div>
<!-- Skills Tab -->
<div class="tab-panel" id="tab-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installed">Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
<button id="skill-install-btn" data-i18n="extensions.install">Install</button>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirm-modal" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
<div class="modal">
<h3 id="confirm-modal-title"></h3>
<p id="confirm-modal-message"></p>
<div class="modal-actions">
<button id="confirm-modal-cancel-btn" class="btn-secondary" data-i18n="btn.cancel">Cancel</button>
<button id="confirm-modal-btn" class="btn-danger">Confirm</button>
</div>
</div>
</div>
+545 -61
View File
@@ -18,6 +18,12 @@
--radius-lg: 12px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
--text-muted: #71717a;
--bg-hover: rgba(255, 255, 255, 0.03);
--danger-soft: rgba(230, 76, 76, 0.15);
--warning-soft: rgba(245, 166, 35, 0.15);
--transition-fast: 150ms ease;
--transition-base: 0.2s ease;
}
* {
@@ -332,10 +338,10 @@ body {
.restart-loader-content {
position: relative;
z-index: 10000;
background-color: #1a1a1a;
border: 1px solid #333;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -352,7 +358,7 @@ body {
}
.restart-title {
color: #e0e0e0;
color: var(--text);
font-size: 0.85rem;
margin-bottom: 1rem;
margin-top: 0;
@@ -388,10 +394,10 @@ body {
.restart-modal-content {
position: relative;
z-index: 10000;
background-color: #1a1a1a;
border: 1px solid #333;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -403,11 +409,11 @@ body {
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #2a2a2a;
border-bottom: 1px solid var(--border);
}
.restart-modal-header h2 {
color: #e0e0e0;
color: var(--text);
font-size: 0.95rem;
margin: 0;
}
@@ -426,8 +432,8 @@ body {
}
.restart-modal-close:hover {
color: #ccc;
background-color: #2a2a2a;
color: var(--text-secondary);
background-color: var(--bg-tertiary);
}
.restart-modal-body {
@@ -435,21 +441,21 @@ body {
}
.restart-modal-description {
color: #aaa;
color: var(--text-secondary);
font-size: 0.85rem;
margin: 0;
}
.restart-modal-warning {
margin-top: 1rem;
background-color: #1e1400;
border: 1px solid #3a2a00;
background-color: var(--warning-soft);
border: 1px solid rgba(245, 166, 35, 0.25);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.restart-modal-warning p {
color: #facc15;
color: var(--warning);
font-size: 0.8rem;
margin: 0;
}
@@ -460,7 +466,7 @@ body {
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid #2a2a2a;
border-top: 1px solid var(--border);
}
.restart-modal-btn {
@@ -473,28 +479,28 @@ body {
}
.restart-modal-btn.cancel {
color: #ccc;
color: var(--text-secondary);
background-color: transparent;
}
.restart-modal-btn.cancel:hover {
background-color: #2a2a2a;
background-color: var(--bg-tertiary);
}
.restart-modal-btn.confirm {
background-color: #00D894;
color: #111;
background-color: var(--accent);
color: #09090b;
}
.restart-modal-btn.confirm:hover {
background-color: #00be82;
background-color: var(--accent-hover);
}
/* Progress Bar for Restart */
.restart-progress-bar {
width: 100%;
height: 0.375rem;
background-color: #2a2a2a;
background-color: var(--bg-tertiary);
border-radius: 9999px;
overflow: hidden;
}
@@ -502,7 +508,7 @@ body {
.restart-progress-fill {
height: 100%;
border-radius: 9999px;
background-color: #00D894;
background-color: var(--accent);
width: 40%;
animation: indeterminate 1.5s ease-in-out infinite;
}
@@ -523,14 +529,14 @@ body {
}
.restart-modal-info {
color: #666;
color: var(--text-secondary);
font-size: 0.8rem;
margin-top: 1.25rem;
margin-bottom: 0;
}
.restart-modal-info a {
color: #00D894;
color: var(--accent);
text-decoration: none;
}
@@ -2522,17 +2528,21 @@ body {
}
.extensions-section h3 {
font-size: 15px;
font-size: 11px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.extensions-section h4 {
font-size: 13px;
font-size: 11px;
font-weight: 600;
margin: 16px 0 8px;
color: var(--text-secondary);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.extensions-list {
@@ -2544,12 +2554,29 @@ body {
.ext-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-left: 3px solid transparent;
border-radius: var(--radius-lg);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.2s, transform 0.2s;
transition: border-color var(--transition-base), box-shadow var(--transition-base), transform 0.2s;
}
.ext-card.state-active {
border-left-color: var(--success);
}
.ext-card.state-inactive {
border-left-color: var(--text-muted);
}
.ext-card.state-error {
border-left-color: var(--danger);
}
.ext-card.state-pairing {
border-left-color: var(--warning);
}
.ext-card:hover {
@@ -2592,6 +2619,11 @@ body {
color: var(--warning);
}
.ext-kind.kind-builtin {
background: rgba(161, 161, 170, 0.15);
color: var(--text-secondary);
}
.ext-version {
font-size: 11px;
color: var(--text-muted);
@@ -2767,13 +2799,20 @@ body {
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text);
transition: all var(--transition-fast);
}
.btn-ext:hover {
background: var(--border);
transform: translateY(-1px);
}
.btn-ext:active {
transform: scale(0.97);
}
.btn-ext.activate {
@@ -2873,6 +2912,7 @@ body {
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
@@ -2893,7 +2933,7 @@ body {
.configure-modal h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: var(--text-primary);
color: var(--text);
}
.configure-hint {
@@ -3036,31 +3076,6 @@ body {
justify-content: flex-end;
}
.tools-table {
width: 100%;
border-collapse: collapse;
}
.tools-table th,
.tools-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.tools-table th {
color: var(--text-secondary);
font-weight: 500;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
.tools-table tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
/* --- Activity tab (unified sandbox job events) --- */
.activity-terminal {
@@ -3714,10 +3729,14 @@ mark {
gap: 8px;
align-items: center;
flex-wrap: wrap;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
}
.ext-install-form input {
padding: 6px 10px;
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -3759,6 +3778,10 @@ mark {
gap: 8px;
align-items: center;
margin-bottom: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
}
.skill-search-box input {
@@ -3795,10 +3818,10 @@ mark {
}
.skill-trust {
font-size: 10px;
padding: 2px 6px;
border-radius: 8px;
font-weight: 500;
font-size: 11px;
padding: 3px 8px;
border-radius: 9999px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
@@ -3942,6 +3965,27 @@ mark {
border-bottom: 1px solid var(--border);
}
/* Settings layout: horizontal subtabs on mobile */
.settings-layout { flex-direction: column; }
.settings-sidebar {
width: 100%;
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 0;
}
.settings-subtab {
border-left: none;
border-bottom: 2px solid transparent;
white-space: nowrap;
padding: 8px 16px;
}
.settings-subtab.active {
border-left-color: transparent;
border-bottom-color: var(--accent);
}
/* Extension install form */
.ext-install-form {
flex-direction: column;
@@ -3968,6 +4012,238 @@ mark {
}
}
/* --- Settings Tab Layout --- */
.settings-layout {
flex: 1;
display: flex;
overflow: hidden;
}
.settings-sidebar {
width: 180px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--bg-secondary);
padding: 12px 0;
flex-shrink: 0;
}
.settings-subtab {
display: block;
width: 100%;
padding: 10px 20px;
background: none;
border: none;
border-left: 2px solid transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
font-weight: 500;
text-align: left;
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.settings-subtab:hover {
color: var(--text);
background: var(--bg-tertiary);
}
.settings-subtab.active {
color: var(--accent);
border-left-color: var(--accent);
background: var(--bg-tertiary);
}
.settings-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.settings-subpanel {
display: none;
flex: 1;
overflow: hidden;
flex-direction: column;
opacity: 0;
}
.settings-subpanel.active {
display: flex;
animation: settingsFadeIn 0.2s ease forwards;
}
@keyframes settingsFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Settings form styles (General subtab) */
.settings-group {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 16px;
}
.settings-group-title {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
margin: 0 -12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
border-radius: 6px;
gap: 16px;
max-height: 80px;
overflow: hidden;
transition: max-height 0.2s ease, opacity 0.2s ease, margin 0.2s ease, padding 0.2s ease, background var(--transition-fast);
opacity: 1;
}
.settings-row:hover {
background: var(--bg-hover);
}
.settings-row.hidden {
max-height: 0;
opacity: 0;
margin: 0;
padding: 0;
border-bottom: none;
}
.settings-row.search-hidden {
display: none;
}
.settings-row:last-child { border-bottom: none; }
.settings-label {
font-size: 13px;
color: var(--text);
font-weight: 500;
flex-shrink: 0;
min-width: 180px;
}
.settings-input {
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: 'IBM Plex Mono', monospace;
width: 240px;
max-width: 100%;
}
.settings-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
}
.settings-saved-indicator {
font-size: 11px;
color: var(--success);
opacity: 0;
transform: translateY(4px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.settings-saved-indicator.visible {
opacity: 1;
transform: translateY(0);
}
.settings-description {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
.restart-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--warning-soft);
border: 1px solid rgba(245, 166, 35, 0.25);
border-radius: var(--radius);
color: var(--text);
font-size: 12px;
margin: 8px 16px;
animation: settingsFadeIn 0.25s ease forwards;
}
.restart-banner-text {
flex: 1;
}
.restart-banner-btn {
padding: 4px 12px;
background: var(--warning);
color: #09090b;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
transition: opacity var(--transition-fast);
}
.restart-banner-btn:hover {
opacity: 0.85;
}
.settings-label-wrap {
display: flex;
flex-direction: column;
flex-shrink: 0;
min-width: 180px;
}
.settings-select {
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: 'IBM Plex Mono', monospace;
width: 240px;
max-width: 100%;
cursor: pointer;
}
.settings-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
}
input[type="checkbox"]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Slash command autocomplete dropdown */
.slash-autocomplete {
position: relative;
@@ -4156,3 +4432,211 @@ mark {
padding: 4px 8px;
background: var(--bg-secondary);
}
/* Settings toolbar (search + import/export) */
.settings-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.settings-search {
flex: 1;
}
.settings-search input {
width: 100%;
padding: 6px 10px 6px 32px;
background: var(--bg);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='M21 21l-4.35-4.35'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 10px center;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: 'IBM Plex Mono', monospace;
}
.settings-search input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
}
.settings-toolbar-btn {
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.settings-toolbar-btn:hover {
background: var(--bg-secondary);
color: var(--text);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.settings-toolbar-btn:active {
transform: scale(0.98);
}
/* Confirmation modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: modalFadeIn 0.15s ease;
}
@keyframes modalFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalSlideIn {
from { opacity: 0; transform: translateY(10px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 0;
max-width: 420px;
width: 90%;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
animation: modalSlideIn 0.2s ease;
}
.modal h3 {
margin: 0;
padding: 16px 20px;
font-size: 16px;
color: var(--text);
border-bottom: 1px solid var(--border);
}
.modal p {
margin: 0;
padding: 16px 20px;
font-size: 13px;
color: var(--text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border);
}
.btn-secondary {
padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
cursor: pointer;
font-size: 13px;
}
.btn-secondary:hover {
background: var(--bg);
}
.btn-danger {
padding: 8px 16px;
background: var(--danger);
border: 1px solid var(--danger);
border-radius: var(--radius);
color: white;
cursor: pointer;
font-size: 13px;
}
.btn-danger:hover {
opacity: 0.9;
}
/* Mobile settings responsiveness */
@media (max-width: 768px) {
.settings-row {
flex-direction: column;
align-items: stretch;
max-height: 140px;
}
.settings-label-wrap {
min-width: unset;
}
.settings-input, .settings-select {
width: 100%;
}
.settings-toolbar {
flex-wrap: wrap;
}
.settings-search {
min-width: 150px;
}
}
/* Loading skeletons */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
gap: 16px;
}
.skeleton-bar {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, var(--bg-tertiary) 25%, rgba(255,255,255,0.06) 50%, var(--bg-tertiary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Settings search empty state */
.settings-search-empty {
padding: 32px 16px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
+1
View File
@@ -87,6 +87,7 @@ impl TestGatewayBuilder {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
})
}
+5
View File
@@ -177,6 +177,8 @@ pub enum SseEvent {
parameters: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
/// Whether the "always" auto-approve option should be shown.
allow_always: bool,
},
#[serde(rename = "auth_required")]
AuthRequired {
@@ -230,6 +232,8 @@ pub enum SseEvent {
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
fallback_deliverable: Option<serde_json::Value>,
},
/// An image was generated by a tool.
@@ -1080,6 +1084,7 @@ mod tests {
description: "Run ls".to_string(),
parameters: "{}".to_string(),
thread_id: Some("t1".to_string()),
allow_always: true,
};
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
+1
View File
@@ -521,6 +521,7 @@ mod tests {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
}
}
}
+59 -3
View File
@@ -33,7 +33,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check(
"NEAR AI session",
check_nearai_session().await,
check_nearai_session(&settings).await,
&mut passed,
&mut failed,
&mut skipped,
@@ -215,7 +215,22 @@ fn check_settings_file() -> CheckResult {
// ── NEAR AI session ─────────────────────────────────────────
async fn check_nearai_session() -> CheckResult {
async fn check_nearai_session(settings: &Settings) -> CheckResult {
// Skip entirely when the configured backend is not NEAR AI.
let llm_config = match crate::config::LlmConfig::resolve(settings) {
Ok(config) => config,
Err(e) => {
// check_llm_config will report the full error; just skip here.
return CheckResult::Skip(format!("LLM config error: {e}"));
}
};
if llm_config.backend != "nearai" {
return CheckResult::Skip(format!(
"not using NEAR AI backend (backend={})",
llm_config.backend
));
}
// Check if session file exists
let session_path = crate::config::llm::default_session_path();
if !session_path.exists() {
@@ -620,12 +635,53 @@ mod tests {
#[tokio::test]
async fn check_nearai_session_does_not_panic() {
let result = check_nearai_session().await;
let settings = Settings::default();
let result = check_nearai_session(&settings).await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_nearai_session_skips_for_non_nearai_backend() {
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
// SAFETY: Under ENV_MUTEX.
unsafe {
match &self.1 {
Some(val) => std::env::set_var(self.0, val),
None => std::env::remove_var(self.0),
}
}
}
}
let _mutex = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
let prev = std::env::var("LLM_BACKEND").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("LLM_BACKEND", "anthropic");
}
let _env_guard = EnvGuard("LLM_BACKEND", prev);
let settings = Settings::default();
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let result = rt.block_on(check_nearai_session(&settings));
match result {
CheckResult::Skip(msg) => {
assert!(
msg.contains("backend=anthropic"),
"expected backend name in skip message, got: {msg}"
);
}
other => panic!(
"expected Skip for non-nearai backend, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_settings_file_handles_missing() {
// Settings::default_path() might or might not exist, but must not panic
+5 -3
View File
@@ -7,17 +7,18 @@ use std::sync::Arc;
use clap::Subcommand;
use crate::workspace::{EmbeddingProvider, SearchConfig, Workspace};
use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, SearchConfig, Workspace};
/// Run a memory command using the Database trait (works with any backend).
pub async fn run_memory_command_with_db(
cmd: MemoryCommand,
db: std::sync::Arc<dyn crate::db::Database>,
embeddings: Option<Arc<dyn EmbeddingProvider>>,
cache_config: EmbeddingCacheConfig,
) -> anyhow::Result<()> {
let mut workspace = Workspace::new_with_db("default", db);
if let Some(emb) = embeddings {
workspace = workspace.with_embeddings(emb);
workspace = workspace.with_embeddings_cached(emb, cache_config);
}
match cmd {
@@ -85,10 +86,11 @@ pub async fn run_memory_command(
cmd: MemoryCommand,
pool: deadpool_postgres::Pool,
embeddings: Option<Arc<dyn EmbeddingProvider>>,
cache_config: EmbeddingCacheConfig,
) -> anyhow::Result<()> {
let mut workspace = Workspace::new("default", pool);
if let Some(emb) = embeddings {
workspace = workspace.with_embeddings(emb);
workspace = workspace.with_embeddings_cached(emb, cache_config);
}
match cmd {
+4 -1
View File
@@ -336,7 +336,10 @@ pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await
let cache_config = crate::workspace::EmbeddingCacheConfig {
max_entries: config.embeddings.cache_size,
};
run_memory_command_with_db(mem_cmd.clone(), db, embeddings, cache_config).await
}
#[cfg(test)]
+264 -74
View File
@@ -5,17 +5,10 @@
//!
//! # Built-in Credentials
//!
//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials
//! so users don't need to register their own OAuth app. Google explicitly
//! documents that client_secret for "Desktop App" / "Installed App" types
//! is NOT actually secret.
//!
//! Default credentials are hardcoded below. They can be overridden at:
//!
//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
//! env vars before building to replace the hardcoded defaults.
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
//! env vars, which take priority over built-in defaults.
//! Some providers ship with built-in OAuth credentials so users don't need to
//! register their own OAuth app just to get started. Today this module only
//! includes built-in defaults for Google-family tools, and those defaults can
//! be overridden by provider-specific environment variables when needed.
use std::collections::HashMap;
use std::sync::Arc;
@@ -23,6 +16,7 @@ use std::time::Duration;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::RwLock;
@@ -60,6 +54,14 @@ pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
}
}
/// Returns the compile-time override env var name, if this provider supports one.
pub fn builtin_client_id_override_env(secret_name: &str) -> Option<&'static str> {
match secret_name {
"google_oauth_token" => Some("IRONCLAW_GOOGLE_CLIENT_ID"),
_ => None,
}
}
// ── Shared callback server ──────────────────────────────────────────────
// Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers`
@@ -173,9 +175,8 @@ pub async fn exchange_oauth_code(
code_verifier: Option<&str>,
access_token_field: &str,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
// Delegates to exchange_oauth_code_with_resource with resource=None.
// Non-MCP OAuth flows don't need the RFC 8707 resource parameter.
exchange_oauth_code_with_resource(
let extra_token_params = HashMap::new();
exchange_oauth_code_with_params(
token_url,
client_id,
client_secret,
@@ -183,16 +184,14 @@ pub async fn exchange_oauth_code(
redirect_uri,
code_verifier,
access_token_field,
None,
&extra_token_params,
)
.await
}
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
///
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
/// Exchange an OAuth authorization code for tokens with generic extra form parameters.
#[allow(clippy::too_many_arguments)]
pub async fn exchange_oauth_code_with_resource(
pub async fn exchange_oauth_code_with_params(
token_url: &str,
client_id: &str,
client_secret: Option<&str>,
@@ -200,7 +199,7 @@ pub async fn exchange_oauth_code_with_resource(
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
resource: Option<&str>,
extra_token_params: &HashMap<String, String>,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
let client = reqwest::Client::new();
let mut token_params = vec![
@@ -213,10 +212,8 @@ pub async fn exchange_oauth_code_with_resource(
token_params.push(("code_verifier", verifier.to_string()));
}
// RFC 8707: include the `resource` parameter so the authorization server
// scopes the issued token to the specific MCP server (protected resource).
if let Some(resource) = resource {
token_params.push(("resource", resource.to_string()));
for (key, value) in extra_token_params {
token_params.push((key.as_str(), value.clone()));
}
let mut request = client.post(token_url);
@@ -276,6 +273,37 @@ pub async fn exchange_oauth_code_with_resource(
})
}
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
///
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
#[allow(clippy::too_many_arguments)]
pub async fn exchange_oauth_code_with_resource(
token_url: &str,
client_id: &str,
client_secret: Option<&str>,
code: &str,
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
resource: Option<&str>,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
let mut extra_token_params = HashMap::new();
if let Some(resource) = resource {
extra_token_params.insert("resource".to_string(), resource.to_string());
}
exchange_oauth_code_with_params(
token_url,
client_id,
client_secret,
code,
redirect_uri,
code_verifier,
access_token_field,
&extra_token_params,
)
.await
}
/// Store OAuth tokens (access + refresh) in the secrets store.
///
/// Also stores the granted scopes as `{secret_name}_scopes` so that scope
@@ -423,9 +451,9 @@ pub struct PendingOAuthFlow {
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
/// Gateway auth token for authenticating with the platform token exchange proxy.
pub gateway_token: Option<String>,
/// RFC 8707 resource parameter (MCP OAuth only).
/// Sent during token exchange to scope the token to a specific MCP server.
pub resource: Option<String>,
/// Additional form params for the token exchange request.
/// Used for provider-specific requirements such as RFC 8707 `resource`.
pub token_exchange_extra_params: HashMap<String, String>,
/// Secret name for persisting the client ID (MCP OAuth only).
/// Needed so token refresh can find the client_id after the session ends.
pub client_id_secret_name: Option<String>,
@@ -459,9 +487,7 @@ pub fn new_pending_oauth_registry() -> PendingOAuthRegistry {
/// URL, meaning the user's browser will redirect to a hosted gateway rather than
/// localhost.
pub fn use_gateway_callback() -> bool {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
.map(|raw| {
url::Url::parse(&raw)
.ok()
@@ -472,6 +498,13 @@ pub fn use_gateway_callback() -> bool {
.unwrap_or(false)
}
/// Returns the configured OAuth token-exchange proxy URL, if any.
pub fn exchange_proxy_url() -> Option<String> {
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_EXCHANGE_URL")
.map(|url| url.trim().to_string())
.filter(|url| !url.is_empty())
}
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
@@ -486,23 +519,117 @@ pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
// ── Platform routing helpers ────────────────────────────────────────
/// Prepend instance name to CSRF state for platform routing.
const HOSTED_STATE_PREFIX: &str = "ic2";
const HOSTED_STATE_CHECKSUM_BYTES: usize = 12;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedHostedOAuthState {
pub flow_id: String,
pub instance_name: Option<String>,
pub is_legacy: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HostedOAuthStatePayload {
flow_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
instance_name: Option<String>,
issued_at: u64,
}
fn current_instance_name() -> Option<String> {
crate::config::helpers::env_or_override("IRONCLAW_INSTANCE_NAME")
.or_else(|| crate::config::helpers::env_or_override("OPENCLAW_INSTANCE_NAME"))
.filter(|v| !v.is_empty())
}
fn hosted_state_checksum(payload_bytes: &[u8]) -> String {
let digest = Sha256::digest(payload_bytes);
URL_SAFE_NO_PAD.encode(&digest[..HOSTED_STATE_CHECKSUM_BYTES])
}
/// Build a versioned hosted OAuth state envelope.
///
/// The NEAR AI platform nginx proxy at `auth.DOMAIN` parses the instance name
/// from the `state` query parameter (format: `instance:nonce`) to route the
/// OAuth callback to the correct container.
///
/// Returns the nonce unchanged when `IRONCLAW_INSTANCE_NAME` is not set
/// (local/non-platform mode).
pub fn build_platform_state(nonce: &str) -> String {
let instance = std::env::var("IRONCLAW_INSTANCE_NAME")
.or_else(|_| std::env::var("OPENCLAW_INSTANCE_NAME"))
.ok()
.filter(|v| !v.is_empty());
match instance {
Some(name) => format!("{}:{}", name, nonce),
None => nonce.to_string(),
/// The encoded value is opaque to providers and can be decoded by both
/// IronClaw and the external auth proxy for routing and callback lookup.
pub fn encode_hosted_oauth_state(flow_id: &str, instance_name: Option<&str>) -> String {
let payload = HostedOAuthStatePayload {
flow_id: flow_id.to_string(),
instance_name: instance_name
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string),
issued_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
let payload_json = match serde_json::to_vec(&payload) {
Ok(payload_json) => payload_json,
Err(error) => {
tracing::warn!(%error, flow_id, "Failed to serialize hosted OAuth state payload");
return payload.flow_id;
}
};
let payload = URL_SAFE_NO_PAD.encode(&payload_json);
let checksum = hosted_state_checksum(&payload_json);
format!("{HOSTED_STATE_PREFIX}.{payload}.{checksum}")
}
/// Decode hosted OAuth state in either the new versioned format or the
/// legacy `instance:nonce`/`nonce` forms.
pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState, String> {
if let Some(rest) = state.strip_prefix(&format!("{HOSTED_STATE_PREFIX}."))
&& let Some((payload_b64, checksum)) = rest.rsplit_once('.')
&& let Ok(payload_json) = URL_SAFE_NO_PAD.decode(payload_b64)
{
let expected_checksum = hosted_state_checksum(&payload_json);
if checksum != expected_checksum {
return Err("Hosted OAuth state checksum mismatch".to_string());
}
if let Ok(payload) = serde_json::from_slice::<HostedOAuthStatePayload>(&payload_json)
&& !payload.flow_id.trim().is_empty()
{
return Ok(DecodedHostedOAuthState {
flow_id: payload.flow_id,
instance_name: payload.instance_name.filter(|v| !v.is_empty()),
is_legacy: false,
});
}
}
if let Some((instance_name, flow_id)) = state.split_once(':') {
if flow_id.is_empty() {
return Err("Hosted OAuth legacy state is missing flow_id".to_string());
}
return Ok(DecodedHostedOAuthState {
flow_id: flow_id.to_string(),
instance_name: if instance_name.is_empty() {
None
} else {
Some(instance_name.to_string())
},
is_legacy: true,
});
}
if state.is_empty() {
return Err("Hosted OAuth state is empty".to_string());
}
Ok(DecodedHostedOAuthState {
flow_id: state.to_string(),
instance_name: None,
is_legacy: true,
})
}
/// Build the hosted callback state used by the public OAuth callback endpoint.
///
/// New flows emit a versioned opaque envelope, while callback decoding accepts
/// both the envelope and the legacy `instance:nonce` contract.
pub fn build_platform_state(nonce: &str) -> String {
encode_hosted_oauth_state(nonce, current_instance_name().as_deref())
}
/// Strip the instance prefix from a state parameter to recover the lookup nonce.
@@ -517,43 +644,62 @@ pub fn strip_instance_prefix(state: &str) -> &str {
.unwrap_or(state)
}
pub struct ProxyTokenExchangeRequest<'a> {
pub proxy_url: &'a str,
pub gateway_token: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
pub client_secret: Option<&'a str>,
pub code: &'a str,
pub redirect_uri: &'a str,
pub code_verifier: Option<&'a str>,
pub access_token_field: &'a str,
pub extra_token_params: &'a HashMap<String, String>,
}
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
///
/// The proxy holds `client_secret` server-side so the container never sees it.
/// Authenticated via the gateway auth token (Bearer header).
/// Authenticated via the gateway auth token (Bearer header). The caller may
/// either rely on proxy-side secret lookup or forward a `client_secret` when
/// the provider requires it.
///
/// The proxy expects form params `{code, redirect_uri, code_verifier}` and
/// returns a standard Google token response `{access_token, refresh_token, expires_in}`.
/// The proxy expects standard OAuth form params plus optional provider-specific
/// token params and returns a standard token response such as
/// `{access_token, refresh_token, expires_in}`.
pub async fn exchange_via_proxy(
proxy_url: &str,
gateway_token: &str,
code: &str,
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
request: ProxyTokenExchangeRequest<'_>,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if gateway_token.is_empty() {
if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"Gateway auth token is required for proxy token exchange".to_string(),
));
}
let exchange_url = format!("{}/oauth/exchange", proxy_url.trim_end_matches('/'));
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
let mut params = vec![
("code", code.to_string()),
("redirect_uri", redirect_uri.to_string()),
("code", request.code.to_string()),
("redirect_uri", request.redirect_uri.to_string()),
("token_url", request.token_url.to_string()),
("client_id", request.client_id.to_string()),
("access_token_field", request.access_token_field.to_string()),
];
if let Some(verifier) = code_verifier {
if let Some(verifier) = request.code_verifier {
params.push(("code_verifier", verifier.to_string()));
}
if let Some(secret) = request.client_secret {
params.push(("client_secret", secret.to_string()));
}
for (key, value) in request.extra_token_params {
params.push((key.as_str(), value.clone()));
}
let response = client
.post(&exchange_url)
.bearer_auth(gateway_token)
.bearer_auth(request.gateway_token)
.form(&params)
.send()
.await
@@ -576,7 +722,7 @@ pub async fn exchange_via_proxy(
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
let access_token = token_data
.get(access_token_field)
.get(request.access_token_field)
.and_then(|v| v.as_str())
.ok_or_else(|| {
let fields: Vec<&str> = token_data
@@ -585,7 +731,7 @@ pub async fn exchange_via_proxy(
.unwrap_or_default();
OAuthCallbackError::Io(format!(
"No '{}' field in proxy response (fields present: {:?})",
access_token_field, fields
request.access_token_field, fields
))
})?
.to_string();
@@ -605,14 +751,10 @@ pub async fn exchange_via_proxy(
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use crate::cli::oauth_defaults::{
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
};
/// Serializes env-mutating tests to prevent parallel races.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
use crate::config::helpers::ENV_MUTEX;
#[test]
fn test_is_loopback_host() {
@@ -935,7 +1077,7 @@ mod tests {
#[test]
fn test_build_platform_state_with_instance() {
use crate::cli::oauth_defaults::build_platform_state;
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -943,7 +1085,11 @@ mod tests {
unsafe {
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
}
assert_eq!(build_platform_state("abc123"), "kind-deer:abc123");
let encoded = build_platform_state("abc123");
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
assert!(!decoded.is_legacy);
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -955,7 +1101,7 @@ mod tests {
#[test]
fn test_build_platform_state_without_instance() {
use crate::cli::oauth_defaults::build_platform_state;
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -965,7 +1111,11 @@ mod tests {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
}
assert_eq!(build_platform_state("abc123"), "abc123");
let encoded = build_platform_state("abc123");
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name, None);
assert!(!decoded.is_legacy);
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -978,7 +1128,7 @@ mod tests {
#[test]
fn test_build_platform_state_with_openclaw_instance() {
use crate::cli::oauth_defaults::build_platform_state;
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -988,7 +1138,11 @@ mod tests {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
}
assert_eq!(build_platform_state("xyz789"), "quiet-lion:xyz789");
let encoded = build_platform_state("xyz789");
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
assert_eq!(decoded.flow_id, "xyz789");
assert_eq!(decoded.instance_name.as_deref(), Some("quiet-lion"));
assert!(!decoded.is_legacy);
unsafe {
if let Some(val) = original_ic {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -1017,6 +1171,42 @@ mod tests {
assert_eq!(strip_instance_prefix(""), "");
}
#[test]
fn test_decode_hosted_oauth_state_accepts_legacy_formats() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let decoded = decode_hosted_oauth_state("kind-deer:abc123").expect("legacy prefixed");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
assert!(decoded.is_legacy);
let decoded = decode_hosted_oauth_state("abc123").expect("legacy raw");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name, None);
assert!(decoded.is_legacy);
}
#[test]
fn test_decode_hosted_oauth_state_falls_back_for_non_envelope_ic2_prefix() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let decoded =
decode_hosted_oauth_state("ic2.provider-owned-state").expect("prefixed fallback");
assert_eq!(decoded.flow_id, "ic2.provider-owned-state");
assert_eq!(decoded.instance_name, None);
assert!(decoded.is_legacy);
}
#[test]
fn test_decode_hosted_oauth_state_rejects_tampered_checksum() {
use crate::cli::oauth_defaults::{decode_hosted_oauth_state, encode_hosted_oauth_state};
let encoded = encode_hosted_oauth_state("abc123", Some("kind-deer"));
let tampered = format!("{encoded}broken");
let err = decode_hosted_oauth_state(&tampered).expect_err("tampered state should fail");
assert!(err.contains("checksum"), "unexpected error: {err}");
}
/// Verify that `build_oauth_url` includes the RFC 8707 `resource` parameter
/// when passed through `extra_params`, which is how MCP OAuth gateway mode
/// scopes tokens to a specific MCP server.
+14 -7
View File
@@ -651,8 +651,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
// Check for OAuth configuration
if let Some(ref oauth) = auth.oauth {
// For providers with shared tokens (e.g., all Google tools share google_oauth_token),
// combine scopes from all installed tools so one auth covers everything.
// For providers with shared tokens, combine scopes from all installed
// tools so one auth covers everything.
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
if combined.scopes.len() > oauth.scopes.len() {
let extra = combined.scopes.len() - oauth.scopes.len();
@@ -670,8 +670,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
}
/// Scan the tools directory for all capabilities files sharing the same secret_name
/// and combine their OAuth scopes. This way, authing any Google tool requests scopes
/// for ALL installed Google tools, so one login covers everything.
/// and combine their OAuth scopes so one authorization covers the full shared
/// credential set.
async fn combine_provider_scopes(
tools_dir: &Path,
secret_name: &str,
@@ -736,11 +736,18 @@ async fn auth_tool_oauth(
})
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
.ok_or_else(|| {
anyhow::anyhow!(
let mut message = format!(
"OAuth client_id not configured.\n\
Set {} env var, or build with IRONCLAW_GOOGLE_CLIENT_ID.",
Set {} env var",
oauth.client_id_env.as_deref().unwrap_or("the client_id")
)
);
if let Some(override_env) =
oauth_defaults::builtin_client_id_override_env(&auth.secret_name)
{
message.push_str(&format!(", or build with {override_env}"));
}
message.push('.');
anyhow::anyhow!(message)
})?;
// Get client_secret: capabilities file > runtime env var > built-in defaults
+42 -5
View File
@@ -8,6 +8,9 @@ use crate::llm::SessionManager;
use crate::settings::Settings;
use crate::workspace::EmbeddingProvider;
/// Default maximum number of cached embeddings.
pub const DEFAULT_EMBEDDING_CACHE_SIZE: usize = 10_000;
/// Embeddings provider configuration.
#[derive(Debug, Clone)]
pub struct EmbeddingsConfig {
@@ -26,6 +29,12 @@ pub struct EmbeddingsConfig {
/// Custom base URL for OpenAI-compatible embedding providers.
/// When set, overrides the default `https://api.openai.com`.
pub openai_base_url: Option<String>,
/// Maximum entries in the embedding LRU cache (default 10,000).
///
/// Approximate raw embedding payload: `cache_size × dimension × 4 bytes`.
/// 10,000 × 1536 floats ≈ 58 MB (payload only; actual memory is higher
/// due to HashMap buckets, per-entry Vec/timestamp overhead).
pub cache_size: usize,
}
impl Default for EmbeddingsConfig {
@@ -40,6 +49,7 @@ impl Default for EmbeddingsConfig {
ollama_base_url: "http://localhost:11434".to_string(),
dimension,
openai_base_url: None,
cache_size: DEFAULT_EMBEDDING_CACHE_SIZE,
}
}
}
@@ -80,6 +90,15 @@ impl EmbeddingsConfig {
let openai_base_url = optional_env("EMBEDDING_BASE_URL")?;
let cache_size = parse_optional_env("EMBEDDING_CACHE_SIZE", DEFAULT_EMBEDDING_CACHE_SIZE)?;
if cache_size == 0 {
return Err(ConfigError::InvalidValue {
key: "EMBEDDING_CACHE_SIZE".to_string(),
message: "must be at least 1".to_string(),
});
}
Ok(Self {
enabled,
provider,
@@ -88,6 +107,7 @@ impl EmbeddingsConfig {
ollama_base_url,
dimension,
openai_base_url,
cache_size,
})
}
@@ -183,13 +203,13 @@ mod tests {
std::env::remove_var("EMBEDDING_MODEL");
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("EMBEDDING_BASE_URL");
std::env::remove_var("EMBEDDING_CACHE_SIZE");
}
}
#[test]
fn embeddings_disabled_not_overridden_by_openai_key() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -240,7 +260,6 @@ mod tests {
#[test]
fn embeddings_env_override_takes_precedence() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -281,10 +300,8 @@ mod tests {
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
assert_eq!(
config.openai_base_url.as_deref(),
Some("https://custom.example.com"),
"EMBEDDING_BASE_URL env var should be parsed into openai_base_url"
Some("https://custom.example.com")
);
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_BASE_URL");
@@ -303,4 +320,24 @@ mod tests {
"openai_base_url should be None when EMBEDDING_BASE_URL is not set"
);
}
#[test]
fn cache_size_zero_rejected() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("EMBEDDING_CACHE_SIZE", "0");
}
let settings = Settings::default();
let result = EmbeddingsConfig::resolve(&settings);
assert!(result.is_err(), "cache_size=0 should be rejected");
let err = result.unwrap_err().to_string();
assert!(err.contains("at least 1"), "should mention minimum: {err}");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_CACHE_SIZE");
}
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ pub use self::channels::{
ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig,
};
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
pub use self::embeddings::EmbeddingsConfig;
pub use self::embeddings::{DEFAULT_EMBEDDING_CACHE_SIZE, EmbeddingsConfig};
pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig;
pub use self::llm::default_session_path;
+29 -35
View File
@@ -7,7 +7,7 @@ use secrecy::SecretString;
pub struct RelayConfig {
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
pub url: String,
/// API key for authenticated channel-relay endpoints.
/// Bearer token for authenticated channel-relay endpoints (`sk-agent-*`).
pub api_key: SecretString,
/// Override for the OAuth callback URL (e.g., a tunnel URL).
pub callback_url: Option<String>,
@@ -15,12 +15,8 @@ pub struct RelayConfig {
pub instance_id: Option<String>,
/// HTTP request timeout in seconds (default: 30).
pub request_timeout_secs: u64,
/// SSE stream long-poll timeout in seconds (default: 86400 = 24 h).
pub stream_timeout_secs: u64,
/// Initial exponential backoff in milliseconds (default: 1000).
pub backoff_initial_ms: u64,
/// Maximum exponential backoff in milliseconds (default: 60000).
pub backoff_max_ms: u64,
/// Path for the webhook callback endpoint (default: `/relay/events`).
pub webhook_path: String,
}
impl std::fmt::Debug for RelayConfig {
@@ -31,9 +27,7 @@ impl std::fmt::Debug for RelayConfig {
.field("callback_url", &self.callback_url)
.field("instance_id", &self.instance_id)
.field("request_timeout_secs", &self.request_timeout_secs)
.field("stream_timeout_secs", &self.stream_timeout_secs)
.field("backoff_initial_ms", &self.backoff_initial_ms)
.field("backoff_max_ms", &self.backoff_max_ms)
.field("webhook_path", &self.webhook_path)
.finish()
}
}
@@ -41,8 +35,10 @@ impl std::fmt::Debug for RelayConfig {
impl RelayConfig {
/// Load relay config from environment variables.
///
/// Returns `None` if either `CHANNEL_RELAY_URL` or `CHANNEL_RELAY_API_KEY`
/// is not set, making the relay integration opt-in.
/// Returns `None` if either of the required env vars (`CHANNEL_RELAY_URL`,
/// `CHANNEL_RELAY_API_KEY`) is not set, making the relay integration opt-in.
/// The signing secret is fetched from channel-relay at activation time via
/// the authenticated `/relay/signing-secret` endpoint — no env var required.
pub fn from_env() -> Option<Self> {
Self::from_env_reader(|key| std::env::var(key).ok())
}
@@ -55,9 +51,7 @@ impl RelayConfig {
callback_url: None,
instance_id: None,
request_timeout_secs: 30,
stream_timeout_secs: 86400,
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
webhook_path: "/relay/events".into(),
}
}
@@ -73,15 +67,7 @@ impl RelayConfig {
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
.and_then(|v| v.parse().ok())
.unwrap_or(30),
stream_timeout_secs: env("RELAY_STREAM_TIMEOUT_SECS")
.and_then(|v| v.parse().ok())
.unwrap_or(86400),
backoff_initial_ms: env("RELAY_BACKOFF_INITIAL_MS")
.and_then(|v| v.parse().ok())
.unwrap_or(1000),
backoff_max_ms: env("RELAY_BACKOFF_MAX_MS")
.and_then(|v| v.parse().ok())
.unwrap_or(60000),
webhook_path: env("RELAY_WEBHOOK_PATH").unwrap_or_else(|| "/relay/events".into()),
})
}
}
@@ -97,7 +83,21 @@ mod tests {
}
#[test]
fn from_env_reader_loads_defaults() {
fn from_env_reader_requires_only_url_and_api_key() {
// Signing secret is fetched at activation time — only URL + API key needed.
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
_ => None,
});
assert!(
config.is_some(),
"relay config should load with just URL + API key"
);
}
#[test]
fn from_env_reader_loads_all_required() {
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
@@ -107,9 +107,7 @@ mod tests {
assert_eq!(config.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30);
assert_eq!(config.stream_timeout_secs, 86400);
assert_eq!(config.backoff_initial_ms, 1000);
assert_eq!(config.backoff_max_ms, 60000);
assert_eq!(config.webhook_path, "/relay/events");
assert!(config.callback_url.is_none());
assert!(config.instance_id.is_none());
}
@@ -122,9 +120,7 @@ mod tests {
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
"RELAY_STREAM_TIMEOUT_SECS" => Some("43200".into()),
"RELAY_BACKOFF_INITIAL_MS" => Some("2000".into()),
"RELAY_BACKOFF_MAX_MS" => Some("120000".into()),
"RELAY_WEBHOOK_PATH" => Some("/custom/events".into()),
_ => None,
})
.expect("config should be Some");
@@ -135,9 +131,7 @@ mod tests {
);
assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
assert_eq!(config.request_timeout_secs, 60);
assert_eq!(config.stream_timeout_secs, 43200);
assert_eq!(config.backoff_initial_ms, 2000);
assert_eq!(config.backoff_max_ms, 120000);
assert_eq!(config.webhook_path, "/custom/events");
}
#[test]
@@ -148,7 +142,7 @@ mod tests {
}
#[test]
fn debug_redacts_api_key() {
fn debug_redacts_secrets() {
let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
let debug = format!("{:?}", config);
assert!(debug.contains("[REDACTED]"));
+319
View File
@@ -0,0 +1,319 @@
//! Structured fallback deliverables for failed or stuck jobs.
//!
//! When a job fails or is detected as stuck, a [`FallbackDeliverable`] captures
//! what was accomplished before the failure: partial results, action statistics,
//! cost, and timing. This gives users visibility into terminal jobs instead of
//! just an error string.
//!
//! Fallback deliverables are stored in `JobContext.metadata["fallback_deliverable"]`
//! and surfaced through the `job_status` tool.
use serde::{Deserialize, Serialize};
use crate::context::memory::Memory;
use crate::context::state::JobContext;
/// Structured summary of a failed or stuck job.
///
/// Stored in `JobContext.metadata["fallback_deliverable"]` when a job fails
/// or is marked stuck. Surfaced through the `job_status` tool.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FallbackDeliverable {
/// True if at least one action succeeded before failure.
pub partial: bool,
/// Why the job failed.
pub failure_reason: String,
/// Last action taken before failure.
pub last_action: Option<LastAction>,
/// Aggregate action statistics.
pub action_stats: ActionStats,
/// Total tokens consumed.
pub tokens_used: u64,
/// Total cost incurred (decimal as string for JSON safety).
pub cost: String,
/// Wall-clock elapsed time in seconds.
pub elapsed_secs: f64,
/// Number of self-repair attempts.
pub repair_attempts: u32,
}
/// Summary of the last action taken before failure.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastAction {
pub tool_name: String,
/// Truncated to 200 bytes (UTF-8 safe).
pub output_preview: String,
pub success: bool,
}
/// Aggregate action counts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionStats {
pub total: u32,
pub successful: u32,
pub failed: u32,
}
impl FallbackDeliverable {
/// Build a fallback deliverable from a job context and its memory.
pub fn build(ctx: &JobContext, memory: &Memory, reason: &str) -> Self {
let successful = memory.successful_actions() as u32;
let failed = memory.failed_actions() as u32;
let total = memory.actions.len() as u32;
let last_action = memory.last_action().map(|a| {
// Use sanitized output to avoid leaking secrets through the fallback API surface.
// For failed actions (no sanitized output), fall back to the error message.
// Borrow the string slice directly when possible to avoid cloning
// potentially large outputs just for truncation.
let owned_fallback;
let preview_str: &str = if let Some(v) = a.output_sanitized.as_ref() {
match v {
serde_json::Value::String(s) => s.as_str(),
other => {
owned_fallback = serde_json::to_string(other).unwrap_or_default();
&owned_fallback
}
}
} else if let Some(ref err) = a.error {
err.as_str()
} else {
""
};
let preview = truncate_str(preview_str, 200);
LastAction {
tool_name: a.tool_name.clone(),
output_preview: preview.to_string(),
success: a.success,
}
});
let elapsed_secs = ctx.elapsed().map_or(0.0, |d| d.as_secs_f64());
Self {
partial: successful > 0,
failure_reason: truncate_str(reason, 1000).to_string(),
last_action,
action_stats: ActionStats {
total,
successful,
failed,
},
tokens_used: ctx.total_tokens_used,
cost: ctx.actual_cost.to_string(),
elapsed_secs,
repair_attempts: ctx.repair_attempts,
}
}
}
/// Truncate a string to at most `max_len` bytes on a char boundary.
fn truncate_str(s: &str, max_len: usize) -> &str {
&s[..crate::util::floor_char_boundary(s, max_len)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::memory::Memory;
use crate::context::state::JobContext;
use chrono::{Duration, Utc};
use rust_decimal::Decimal;
use std::time::Duration as StdDuration;
#[test]
fn test_fallback_zero_actions() {
let ctx = JobContext::new("Test", "Empty job");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "timed out");
assert!(!fb.partial); // safety: test
assert_eq!(fb.failure_reason, "timed out"); // safety: test
assert!(fb.last_action.is_none()); // safety: test
assert_eq!(fb.action_stats.total, 0); // safety: test
assert_eq!(fb.action_stats.successful, 0); // safety: test
assert_eq!(fb.action_stats.failed, 0); // safety: test
assert_eq!(fb.tokens_used, 0); // safety: test
assert_eq!(fb.cost, "0"); // safety: test
assert_eq!(fb.repair_attempts, 0); // safety: test
}
#[test]
fn test_fallback_mixed_actions() {
let mut ctx = JobContext::new("Test", "Mixed job");
ctx.total_tokens_used = 5000;
ctx.actual_cost = Decimal::new(42, 2); // 0.42
ctx.repair_attempts = 1;
let mut memory = Memory::new(ctx.job_id);
// 3 successes
for _ in 0..3 {
let action = memory
.create_action("tool_a", serde_json::json!({}))
.succeed(
Some("output".to_string()),
serde_json::json!({}),
StdDuration::from_secs(1),
);
memory.record_action(action);
}
// 2 failures
for _ in 0..2 {
let action = memory
.create_action("tool_b", serde_json::json!({}))
.fail("broke", StdDuration::from_secs(1));
memory.record_action(action);
}
let fb = FallbackDeliverable::build(&ctx, &memory, "max iterations");
assert!(fb.partial); // safety: test
assert_eq!(fb.action_stats.total, 5); // safety: test
assert_eq!(fb.action_stats.successful, 3); // safety: test
assert_eq!(fb.action_stats.failed, 2); // safety: test
assert_eq!(fb.tokens_used, 5000); // safety: test
assert_eq!(fb.cost, "0.42"); // safety: test
assert_eq!(fb.repair_attempts, 1); // safety: test
assert!(fb.last_action.is_some()); // safety: test
let la = fb.last_action.unwrap(); // safety: test
assert_eq!(la.tool_name, "tool_b"); // safety: test
assert!(!la.success); // safety: test
// Failed actions should surface the error message as the output preview
assert_eq!(la.output_preview, "broke"); // safety: test
}
#[test]
fn test_fallback_failed_action_shows_error() {
let ctx = JobContext::new("Test", "Error preview");
let mut memory = Memory::new(ctx.job_id);
let action = memory
.create_action("broken_tool", serde_json::json!({}))
.fail("connection timed out after 30s", StdDuration::from_secs(30));
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "tool failure");
let la = fb.last_action.unwrap(); // safety: test
assert!(!la.success); // safety: test
assert_eq!(la.output_preview, "connection timed out after 30s"); // safety: test
}
#[test]
fn test_fallback_last_action_truncation() {
let ctx = JobContext::new("Test", "Truncation");
let mut memory = Memory::new(ctx.job_id);
let long_output = "x".repeat(500);
let action = memory
.create_action("tool_c", serde_json::json!({}))
.succeed(
Some(long_output.clone()),
serde_json::Value::String(long_output),
StdDuration::from_secs(1),
);
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
let la = fb.last_action.unwrap(); // safety: test
assert!(la.output_preview.len() <= 200); // safety: test
assert!(!la.output_preview.is_empty()); // safety: test
}
#[test]
fn test_fallback_uses_sanitized_output() {
let ctx = JobContext::new("Test", "Sanitized");
let mut memory = Memory::new(ctx.job_id);
let action = memory
.create_action("tool_d", serde_json::json!({}))
.succeed(
Some("[REDACTED]".to_string()),
serde_json::json!({"api_key": "sk-secret-key-12345"}),
StdDuration::from_secs(1),
);
memory.record_action(action);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
let la = fb.last_action.unwrap(); // safety: test
// Must use sanitized output, not raw
assert!(!la.output_preview.contains("sk-secret")); // safety: test
assert!(la.output_preview.contains("REDACTED")); // safety: test
}
#[test]
fn test_fallback_elapsed_time() {
let mut ctx = JobContext::new("Test", "Timing");
let now = Utc::now();
ctx.started_at = Some(now - Duration::seconds(10));
ctx.completed_at = Some(now);
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
// Should be approximately 10 seconds
assert!((fb.elapsed_secs - 10.0).abs() < 0.1); // safety: test
}
#[test]
fn test_fallback_no_started_at() {
let ctx = JobContext::new("Test", "Never started");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
assert!((fb.elapsed_secs - 0.0).abs() < 0.001); // safety: test
}
#[test]
fn test_fallback_elapsed_time_no_completed_at() {
let mut ctx = JobContext::new("Test", "Still running");
ctx.started_at = Some(Utc::now() - Duration::seconds(5));
// completed_at is None — should use Utc::now() as fallback
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "stuck");
// Should be approximately 5 seconds (using now as end time)
assert!(fb.elapsed_secs >= 4.0 && fb.elapsed_secs <= 7.0); // safety: test
}
#[test]
fn test_fallback_failure_reason_truncation() {
let ctx = JobContext::new("Test", "Long reason");
let memory = Memory::new(ctx.job_id);
let long_reason = "x".repeat(5000);
let fb = FallbackDeliverable::build(&ctx, &memory, &long_reason);
assert!(fb.failure_reason.len() <= 1000); // safety: test
assert!(!fb.failure_reason.is_empty()); // safety: test
}
#[test]
fn test_truncate_str_ascii() {
assert_eq!(truncate_str("hello", 10), "hello"); // safety: test
assert_eq!(truncate_str("hello world", 5), "hello"); // safety: test
}
#[test]
fn test_truncate_str_unicode() {
// "é" is 2 bytes in UTF-8
let s = "café";
assert_eq!(truncate_str(s, 10), "café"); // safety: test
// Truncating at 4 would split "é", should back up to 3
assert_eq!(truncate_str(s, 4), "caf"); // safety: test
}
#[test]
fn test_fallback_serialization() {
let ctx = JobContext::new("Test", "Serialize");
let memory = Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "test error");
// Should serialize to JSON and back without error
let json = serde_json::to_value(&fb).unwrap(); // safety: test
let deserialized: FallbackDeliverable = serde_json::from_value(json).unwrap(); // safety: test
assert_eq!(deserialized.failure_reason, "test error"); // safety: test
}
}
+79 -70
View File
@@ -58,15 +58,19 @@ impl ActionRecord {
}
/// Mark the action as successful.
///
/// `output_sanitized` is the tool output after safety processing (string).
/// `output_raw` is the original tool result (JSON value, stored as a
/// pretty-printed JSON string in `ActionRecord.output_raw`).
pub fn succeed(
mut self,
output_raw: Option<String>,
output_sanitized: serde_json::Value,
output_sanitized: Option<String>,
output_raw: serde_json::Value,
duration: Duration,
) -> Self {
self.success = true;
self.output_raw = output_raw;
self.output_sanitized = Some(output_sanitized);
self.output_raw = Some(serde_json::to_string_pretty(&output_raw).unwrap_or_default());
self.output_sanitized = output_sanitized.map(serde_json::Value::String);
self.duration = duration;
self
}
@@ -248,15 +252,15 @@ mod tests {
#[test]
fn test_action_record() {
let action = ActionRecord::new(0, "test", serde_json::json!({"key": "value"}));
assert_eq!(action.sequence, 0);
assert!(!action.success);
assert_eq!(action.sequence, 0); // safety: test
assert!(!action.success); // safety: test
let action = action.succeed(
Some("raw".to_string()),
serde_json::json!({"result": "ok"}),
Duration::from_millis(100),
);
assert!(action.success);
assert!(action.success); // safety: test
}
#[test]
@@ -267,7 +271,7 @@ mod tests {
memory.add(ChatMessage::user("How are you?"));
memory.add(ChatMessage::assistant("Good!"));
assert_eq!(memory.len(), 3); // Oldest removed
assert_eq!(memory.len(), 3); // Oldest removed // safety: test
}
#[test]
@@ -286,9 +290,9 @@ mod tests {
.with_cost(Decimal::new(20, 1));
memory.record_action(action2);
assert_eq!(memory.total_cost(), Decimal::new(30, 1));
assert_eq!(memory.total_duration(), Duration::from_secs(3));
assert_eq!(memory.successful_actions(), 2);
assert_eq!(memory.total_cost(), Decimal::new(30, 1)); // safety: test
assert_eq!(memory.total_duration(), Duration::from_secs(3)); // safety: test
assert_eq!(memory.successful_actions(), 2); // safety: test
}
#[test]
@@ -296,11 +300,11 @@ mod tests {
let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1}));
let action = action.fail("something went wrong", Duration::from_millis(50));
assert!(!action.success);
assert_eq!(action.error.as_deref(), Some("something went wrong"));
assert_eq!(action.duration, Duration::from_millis(50));
assert!(action.output_raw.is_none());
assert!(action.output_sanitized.is_none());
assert!(!action.success); // safety: test
assert_eq!(action.error.as_deref(), Some("something went wrong")); // safety: test
assert_eq!(action.duration, Duration::from_millis(50)); // safety: test
assert!(action.output_raw.is_none()); // safety: test
assert!(action.output_sanitized.is_none()); // safety: test
}
#[test]
@@ -308,9 +312,9 @@ mod tests {
let action = ActionRecord::new(0, "risky_tool", serde_json::json!({}));
let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]);
assert_eq!(action.sanitization_warnings.len(), 2);
assert_eq!(action.sanitization_warnings[0], "suspicious pattern");
assert_eq!(action.sanitization_warnings[1], "possible xss");
assert_eq!(action.sanitization_warnings.len(), 2); // safety: test
assert_eq!(action.sanitization_warnings[0], "suspicious pattern"); // safety: test
assert_eq!(action.sanitization_warnings[1], "possible xss"); // safety: test
}
#[test]
@@ -319,41 +323,46 @@ mod tests {
let cost = Decimal::new(42, 2); // 0.42
let action = action.with_cost(cost);
assert_eq!(action.cost, Some(Decimal::new(42, 2)));
assert_eq!(action.cost, Some(Decimal::new(42, 2))); // safety: test
}
#[test]
fn test_action_record_new_defaults() {
let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"}));
assert_eq!(action.sequence, 5);
assert_eq!(action.tool_name, "my_tool");
assert_eq!(action.input, serde_json::json!({"key": "val"}));
assert!(!action.success);
assert!(action.output_raw.is_none());
assert!(action.output_sanitized.is_none());
assert!(action.sanitization_warnings.is_empty());
assert!(action.cost.is_none());
assert_eq!(action.duration, Duration::ZERO);
assert!(action.error.is_none());
assert_eq!(action.sequence, 5); // safety: test
assert_eq!(action.tool_name, "my_tool"); // safety: test
assert_eq!(action.input, serde_json::json!({"key": "val"})); // safety: test
assert!(!action.success); // safety: test
assert!(action.output_raw.is_none()); // safety: test
assert!(action.output_sanitized.is_none()); // safety: test
assert!(action.sanitization_warnings.is_empty()); // safety: test
assert!(action.cost.is_none()); // safety: test
assert_eq!(action.duration, Duration::ZERO); // safety: test
assert!(action.error.is_none()); // safety: test
}
#[test]
fn test_action_record_succeed_sets_fields() {
let action = ActionRecord::new(0, "tool", serde_json::json!({}));
let action = action.succeed(
Some("raw output here".into()),
Some("sanitized output".into()),
serde_json::json!({"clean": true}),
Duration::from_secs(7),
);
assert!(action.success);
assert_eq!(action.output_raw.as_deref(), Some("raw output here"));
assert!(action.success); // safety: test
// output_raw is the JSON value pretty-printed
let expected_raw =
serde_json::to_string_pretty(&serde_json::json!({"clean": true})).unwrap(); // safety: test
assert_eq!(action.output_raw.as_deref(), Some(expected_raw.as_str())); // safety: test
// output_sanitized wraps the string in a JSON string value
assert_eq!(
/* safety: test */
action.output_sanitized,
Some(serde_json::json!({"clean": true}))
Some(serde_json::json!("sanitized output"))
);
assert_eq!(action.duration, Duration::from_secs(7));
assert_eq!(action.duration, Duration::from_secs(7)); // safety: test
}
#[test]
@@ -361,13 +370,13 @@ mod tests {
let mut mem = ConversationMemory::new(10);
mem.add(ChatMessage::user("hello"));
mem.add(ChatMessage::assistant("hi"));
assert_eq!(mem.len(), 2);
assert!(!mem.is_empty());
assert_eq!(mem.len(), 2); // safety: test
assert!(!mem.is_empty()); // safety: test
mem.clear();
assert_eq!(mem.len(), 0);
assert!(mem.is_empty());
assert!(mem.messages().is_empty());
assert_eq!(mem.len(), 0); // safety: test
assert!(mem.is_empty()); // safety: test
assert!(mem.messages().is_empty()); // safety: test
}
#[test]
@@ -379,20 +388,20 @@ mod tests {
mem.add(ChatMessage::assistant("four"));
let last_2 = mem.last_n(2);
assert_eq!(last_2.len(), 2);
assert_eq!(last_2[0].content, "three");
assert_eq!(last_2[1].content, "four");
assert_eq!(last_2.len(), 2); // safety: test
assert_eq!(last_2[0].content, "three"); // safety: test
assert_eq!(last_2[1].content, "four"); // safety: test
// Requesting more than available returns all
let last_100 = mem.last_n(100);
assert_eq!(last_100.len(), 4);
assert_eq!(last_100.len(), 4); // safety: test
}
#[test]
fn test_conversation_memory_last_n_empty() {
let mem = ConversationMemory::new(10);
let result = mem.last_n(5);
assert!(result.is_empty());
assert!(result.is_empty()); // safety: test
}
#[test]
@@ -405,13 +414,13 @@ mod tests {
// At capacity (3). Adding one more should trim, but keep system.
mem.add(ChatMessage::user("msg3"));
assert_eq!(mem.len(), 3);
assert_eq!(mem.len(), 3); // safety: test
// System message must survive
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
assert_eq!(mem.messages()[0].content, "You are helpful");
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
assert_eq!(mem.messages()[0].content, "You are helpful"); // safety: test
// Oldest non-system message (msg1) should be gone
assert_eq!(mem.messages()[1].content, "msg2");
assert_eq!(mem.messages()[2].content, "msg3");
assert_eq!(mem.messages()[1].content, "msg2"); // safety: test
assert_eq!(mem.messages()[2].content, "msg3"); // safety: test
}
#[test]
@@ -422,9 +431,9 @@ mod tests {
// Now at capacity. Add another.
mem.add(ChatMessage::user("b"));
assert_eq!(mem.len(), 2);
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
assert_eq!(mem.messages()[1].content, "b");
assert_eq!(mem.len(), 2); // safety: test
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
assert_eq!(mem.messages()[1].content, "b"); // safety: test
}
#[test]
@@ -440,7 +449,7 @@ mod tests {
mem.add(ChatMessage::user("hello"));
// Should have broken out rather than looping forever.
// The system message is protected, so len may exceed max.
assert!(mem.len() <= 2);
assert!(mem.len() <= 2); // safety: test
}
#[test]
@@ -459,14 +468,14 @@ mod tests {
.fail("oops", Duration::from_millis(2));
memory.record_action(err);
assert_eq!(memory.successful_actions(), 1);
assert_eq!(memory.failed_actions(), 1);
assert_eq!(memory.successful_actions(), 1); // safety: test
assert_eq!(memory.failed_actions(), 1); // safety: test
}
#[test]
fn test_memory_last_action() {
let mut memory = Memory::new(Uuid::new_v4());
assert!(memory.last_action().is_none());
assert!(memory.last_action().is_none()); // safety: test
let a1 = memory
.create_action("first", serde_json::json!({}))
@@ -478,8 +487,8 @@ mod tests {
.fail("nope", Duration::ZERO);
memory.record_action(a2);
let last = memory.last_action().unwrap();
assert_eq!(last.tool_name, "second");
let last = memory.last_action().unwrap(); // safety: test
assert_eq!(last.tool_name, "second"); // safety: test
}
#[test]
@@ -499,9 +508,9 @@ mod tests {
);
memory.record_action(a);
assert_eq!(memory.actions_by_tool("shell").len(), 3);
assert_eq!(memory.actions_by_tool("http").len(), 1);
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0);
assert_eq!(memory.actions_by_tool("shell").len(), 3); // safety: test
assert_eq!(memory.actions_by_tool("http").len(), 1); // safety: test
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0); // safety: test
}
#[test]
@@ -509,25 +518,25 @@ mod tests {
let mut memory = Memory::new(Uuid::new_v4());
let a0 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a0.sequence, 0);
assert_eq!(a0.sequence, 0); // safety: test
let a1 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a1.sequence, 1);
assert_eq!(a1.sequence, 1); // safety: test
let a2 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a2.sequence, 2);
assert_eq!(a2.sequence, 2); // safety: test
}
#[test]
fn test_memory_add_message_delegates_to_conversation() {
let mut memory = Memory::new(Uuid::new_v4());
assert!(memory.conversation.is_empty());
assert!(memory.conversation.is_empty()); // safety: test
memory.add_message(ChatMessage::user("hello"));
memory.add_message(ChatMessage::assistant("hi"));
assert_eq!(memory.conversation.len(), 2);
assert_eq!(memory.conversation.messages()[0].content, "hello");
assert_eq!(memory.conversation.len(), 2); // safety: test
assert_eq!(memory.conversation.messages()[0].content, "hello"); // safety: test
}
#[test]
@@ -540,7 +549,7 @@ mod tests {
.succeed(None, serde_json::json!({}), Duration::ZERO);
memory.record_action(a);
assert_eq!(memory.total_cost(), Decimal::ZERO);
assert_eq!(memory.total_cost(), Decimal::ZERO); // safety: test
}
#[test]
@@ -560,6 +569,6 @@ mod tests {
memory.record_action(a2);
// Both successful and failed actions contribute to total duration
assert_eq!(memory.total_duration(), Duration::from_millis(300));
assert_eq!(memory.total_duration(), Duration::from_millis(300)); // safety: test
}
}
+2
View File
@@ -6,10 +6,12 @@
//! - State machine
//! - Resource tracking
pub mod fallback;
mod manager;
mod memory;
mod state;
pub use fallback::FallbackDeliverable;
pub use manager::ContextManager;
pub use memory::{ActionRecord, ConversationMemory, Memory};
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded};
+24
View File
@@ -476,4 +476,28 @@ impl RoutineStore for LibSqlBackend {
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
.query(
&format!(
"SELECT {} FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
ROUTINE_RUN_COLUMNS
),
params![],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
let mut runs = Vec::new();
while let Some(row) = rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
runs.push(row_to_routine_run_libsql(&row)?);
}
Ok(runs)
}
}
+3
View File
@@ -525,6 +525,9 @@ pub trait RoutineStore: Send + Sync {
run_id: Uuid,
job_id: Uuid,
) -> Result<(), DatabaseError>;
/// List routine runs that were dispatched as full_job but have not yet
/// been finalized (status='running' with a linked job_id).
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError>;
}
#[async_trait]
+4
View File
@@ -503,6 +503,10 @@ impl RoutineStore for PgBackend {
) -> Result<(), DatabaseError> {
self.store.link_routine_run_to_job(run_id, job_id).await
}
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
self.store.list_dispatched_routine_runs().await
}
}
// ==================== ToolFailureStore ====================
+516 -220
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -1348,6 +1348,18 @@ impl Store {
.await?;
Ok(())
}
/// List routine runs dispatched as full_job that have not yet been finalized.
pub async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
"SELECT * FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
&[],
)
.await?;
rows.iter().map(row_to_routine_run).collect()
}
}
#[cfg(feature = "postgres")]
+10 -2
View File
@@ -22,6 +22,7 @@ use crate::llm::provider::{
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
strip_unsupported_tool_params,
};
use crate::llm::retry::cap_retry_after;
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
@@ -150,6 +151,7 @@ impl AnthropicOAuthProvider {
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
let response_text = response
@@ -766,9 +768,14 @@ mod tests {
#[test]
fn test_retry_after_large_number() {
// Verify large numbers are accepted
// Verify large numbers are capped to the safe maximum
let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours
assert_eq!(duration, Some(std::time::Duration::from_secs(7200)));
assert_eq!(
duration,
Some(std::time::Duration::from_secs(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
}
/// Helper function to test Retry-After header parsing logic for Anthropic
@@ -779,6 +786,7 @@ mod tests {
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}
+6
View File
@@ -167,6 +167,12 @@ impl CircuitBreakerProvider {
}
}
CircuitState::Open => {
debug_assert!(
false,
"BUG: record_success() called while circuit breaker is Open — \
check_allowed() was bypassed for provider {}",
self.inner.model_name()
);
// Shouldn't get here (check_allowed blocks Open), but recover
state.state = CircuitState::Closed;
state.consecutive_failures = 0;
+18 -10
View File
@@ -22,7 +22,7 @@ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse,
};
use crate::llm::{costs, session::SessionManager};
use crate::llm::{costs, retry::cap_retry_after, session::SessionManager};
/// Information about an available model from NEAR AI API.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -252,7 +252,7 @@ impl NearAiChatProvider {
.and_then(|v| {
// Try delay-seconds first (most common from API providers)
if let Ok(secs) = v.trim().parse::<u64>() {
return Some(std::time::Duration::from_secs(secs));
return Some(cap_retry_after(std::time::Duration::from_secs(secs)));
}
// Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
@@ -260,9 +260,9 @@ impl NearAiChatProvider {
let delta = dt.signed_duration_since(now);
// Use max(0) so past/present dates yield Duration::ZERO
// rather than None (which would cause an immediate retry).
return Some(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64
));
return Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)));
}
None
})
@@ -2306,9 +2306,17 @@ mod tests {
#[test]
fn test_retry_after_large_number() {
// Verify large numbers are accepted
// Verify large numbers are capped to the safe maximum
let duration = parse_retry_after_for_test("3600"); // 1 hour
assert_eq!(duration, Some(std::time::Duration::from_secs(3600)));
let huge = parse_retry_after_for_test("18446744073709551615");
assert_eq!(
huge,
Some(std::time::Duration::from_secs(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
}
/// Helper function to test Retry-After header parsing logic
@@ -2316,13 +2324,13 @@ mod tests {
fn parse_retry_after_for_test(header_value: &str) -> Option<std::time::Duration> {
let trimmed = header_value.trim();
let parsed = if let Ok(secs) = trimmed.parse::<u64>() {
Some(std::time::Duration::from_secs(secs))
Some(cap_retry_after(std::time::Duration::from_secs(secs)))
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
Some(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64
))
Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)))
} else {
None
};
+3 -4
View File
@@ -39,9 +39,7 @@ pub enum OAuthCallbackError {
/// deployments where `127.0.0.1` is unreachable from the user's browser),
/// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`.
pub fn callback_url() -> String {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
.unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT))
}
@@ -57,7 +55,8 @@ pub fn callback_url() -> String {
/// Note: this transmits the session token over plain HTTP — prefer SSH port
/// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible.
pub fn callback_host() -> String {
std::env::var("OAUTH_CALLBACK_HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
crate::config::helpers::env_or_override("OAUTH_CALLBACK_HOST")
.unwrap_or_else(|| "127.0.0.1".to_string())
}
/// Returns `true` if `host` is a loopback address that only accepts local connections.
+2
View File
@@ -108,6 +108,8 @@ mod tests {
assert!(has_native_thinking("nanbeige-4.1-3b"));
assert!(has_native_thinking("step-3.5-flash-197b"));
assert!(has_native_thinking("minimax-m2.5-139b"));
assert!(has_native_thinking("MiniMax-M2.7"));
assert!(has_native_thinking("MiniMax-M2.7-highspeed"));
}
#[test]
+23
View File
@@ -19,6 +19,12 @@ use crate::llm::provider::{
ToolCompletionResponse,
};
/// Upper bound for provider-suggested `Retry-After` delays.
///
/// This prevents malicious or malformed headers from turning a retryable
/// response into an effectively unbounded sleep.
pub(crate) const MAX_RETRY_AFTER_SECS: u64 = 3600;
/// Returns `true` if the `LlmError` is transient and the request should be retried.
///
/// Used by `RetryProvider` (retry the same provider) and `FailoverProvider`
@@ -67,6 +73,11 @@ pub(crate) fn retry_backoff_delay(attempt: u32) -> Duration {
Duration::from_millis(delay_ms)
}
/// Clamp a provider-suggested retry delay to a safe maximum.
pub(crate) fn cap_retry_after(duration: Duration) -> Duration {
duration.min(Duration::from_secs(MAX_RETRY_AFTER_SECS))
}
/// Configuration for the retry decorator.
#[derive(Debug, Clone)]
pub struct RetryConfig {
@@ -421,4 +432,16 @@ mod tests {
panic!("Expected RateLimited error");
}
}
#[test]
fn cap_retry_after_clamps_huge_delays() {
assert_eq!(
cap_retry_after(Duration::from_secs(u64::MAX)),
Duration::from_secs(MAX_RETRY_AFTER_SECS)
);
assert_eq!(
cap_retry_after(Duration::from_secs(0)),
Duration::from_secs(0)
);
}
}
+22
View File
@@ -323,6 +323,17 @@ async fn async_main() -> anyhow::Result<()> {
}));
// Load WASM channels and register their webhook routes.
// Ensure the channels directory exists so the WASM runtime initializes even when
// no channels are installed yet — hot-activation needs the runtime to be available.
if config.channels.wasm_channels_enabled
&& let Err(e) = std::fs::create_dir_all(&config.channels.wasm_channels_dir)
{
tracing::warn!(
path = %config.channels.wasm_channels_dir.display(),
error = %e,
"Failed to create WASM channels directory"
);
}
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
&config,
@@ -511,6 +522,16 @@ async fn async_main() -> anyhow::Result<()> {
gw = gw.with_skill_catalog(Arc::clone(sc));
}
gw = gw.with_cost_guard(Arc::clone(&components.cost_guard));
{
let active_model = components.llm.model_name().to_string();
let mut enabled = channel_names.clone();
enabled.push("gateway".into());
gw = gw.with_active_config(ironclaw::channels::web::server::ActiveConfigSnapshot {
llm_backend: config.llm.backend.to_string(),
llm_model: active_model,
enabled_channels: enabled,
});
}
if config.sandbox.enabled {
gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
@@ -727,6 +748,7 @@ async fn async_main() -> anyhow::Result<()> {
document_extraction: Some(Arc::new(
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
)),
builder: components.builder,
};
let mut agent = Agent::new(
+6
View File
@@ -333,6 +333,12 @@ async fn job_event_handler(
.get("session_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
// NOTE: `fallback_deliverable` is currently always None in SSE events.
// In-memory jobs store fallback data in JobContext.metadata (accessed via job_status tool).
// Sandbox containers don't yet emit fallback data in their event payloads.
// This field is forward-compatible infrastructure for when container workers
// gain context/memory tracking capabilities.
fallback_deliverable: payload.data.get("fallback_deliverable").cloned(),
},
_ => SseEvent::JobStatus {
job_id: job_id_str,
+16 -3
View File
@@ -518,7 +518,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, Cha
.save_secret("http_webhook_secret", &SecretString::from(secret))
.await?;
print_success("Webhook secret generated and saved to database");
print_info("Retrieve it later with: ironclaw secret get http_webhook_secret");
print_info(http_webhook_secret_hint());
}
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
@@ -535,6 +535,10 @@ pub fn generate_webhook_secret() -> String {
generate_secret_with_length(32)
}
fn http_webhook_secret_hint() -> &'static str {
"The secret is stored in the encrypted secrets database and will be loaded automatically on startup."
}
fn validate_e164(account: &str) -> Result<(), String> {
if !account.starts_with('+') {
return Err("E.164 account must start with '+'".to_string());
@@ -1136,8 +1140,9 @@ mod tests {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
use crate::setup::channels::{
SecretsContext, generate_webhook_secret, substitute_validation_placeholders,
validate_cloudflare_token_format, validate_public_https_url,
SecretsContext, generate_webhook_secret, http_webhook_secret_hint,
substitute_validation_placeholders, validate_cloudflare_token_format,
validate_public_https_url,
};
fn test_secrets_context() -> SecretsContext {
@@ -1337,4 +1342,12 @@ mod tests {
.to_string();
assert!(err.contains("DNS resolution failed"));
}
#[test]
fn test_http_webhook_secret_hint_reflects_current_behavior() {
let hint = http_webhook_secret_hint();
assert!(hint.contains("encrypted secrets database"));
assert!(hint.contains("loaded automatically on startup"));
assert!(!hint.contains("ironclaw secret get"));
}
}
+432
View File
@@ -0,0 +1,432 @@
//! Fault injection framework for testing retry, failover, and circuit breaker behavior.
//!
//! Provides [`FaultInjector`] which can be attached to [`StubLlm`](super::StubLlm) to
//! produce configurable error sequences, random failures, and delays.
//!
//! # Example
//!
//! ```rust,no_run
//! use ironclaw::testing::fault_injection::*;
//!
//! // Fail twice with transient errors, then succeed
//! let injector = FaultInjector::sequence([
//! FaultAction::Fail(FaultType::RequestFailed),
//! FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
//! FaultAction::Succeed,
//! ]);
//! ```
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use crate::llm::error::LlmError;
/// The type of fault to inject.
#[derive(Debug, Clone)]
pub enum FaultType {
/// Transient request failure (retryable).
RequestFailed,
/// Rate limited with optional retry-after duration.
RateLimited { retry_after: Option<Duration> },
/// Authentication failure (non-retryable).
AuthFailed,
/// Invalid response from provider (retryable).
InvalidResponse,
/// I/O error (retryable).
IoError,
/// Context length exceeded (non-retryable).
ContextLengthExceeded,
/// Session expired (transient for circuit breaker, not retryable).
SessionExpired,
}
impl FaultType {
/// Convert to the corresponding `LlmError`.
pub fn to_llm_error(&self, provider: &str) -> LlmError {
match self {
FaultType::RequestFailed => LlmError::RequestFailed {
provider: provider.to_string(),
reason: "injected fault: request failed".to_string(),
},
FaultType::RateLimited { retry_after } => LlmError::RateLimited {
provider: provider.to_string(),
retry_after: *retry_after,
},
FaultType::AuthFailed => LlmError::AuthFailed {
provider: provider.to_string(),
},
FaultType::InvalidResponse => LlmError::InvalidResponse {
provider: provider.to_string(),
reason: "injected fault: invalid response".to_string(),
},
FaultType::IoError => LlmError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"injected fault: connection reset",
)),
FaultType::ContextLengthExceeded => LlmError::ContextLengthExceeded {
used: 100_000,
limit: 50_000,
},
FaultType::SessionExpired => LlmError::SessionExpired {
provider: provider.to_string(),
},
}
}
}
/// Action to take on a given call.
#[derive(Debug, Clone)]
pub enum FaultAction {
/// Return a successful response.
Succeed,
/// Return an error of the given type.
Fail(FaultType),
/// Sleep for the given duration, then succeed.
Delay(Duration),
}
/// How the fault sequence is consumed.
#[derive(Debug, Clone)]
pub enum FaultMode {
/// Play the sequence once, then succeed for all subsequent calls.
SequenceOnce,
/// Loop the sequence forever.
SequenceLoop,
/// Fail randomly at the given rate (0.0 = never, 1.0 = always) with
/// the specified fault type. Uses a seeded RNG for reproducibility.
/// The seed is stored so that [`FaultInjector::reset()`] can re-initialize
/// the RNG for test reproducibility.
Random {
error_rate: f64,
fault: FaultType,
seed: u64,
},
}
/// A configurable fault injector for [`StubLlm`](super::StubLlm).
///
/// Thread-safe: uses atomic call counter and mutex-protected RNG.
pub struct FaultInjector {
actions: Vec<FaultAction>,
mode: FaultMode,
call_index: AtomicU32,
/// Seeded RNG for Random mode, behind Mutex for Sync.
rng_state: Mutex<u64>,
}
impl std::fmt::Debug for FaultInjector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FaultInjector")
.field("call_index", &self.call_index.load(Ordering::Relaxed))
.field("mode", &self.mode)
.finish()
}
}
impl FaultInjector {
/// Create a fault injector that plays actions once, then succeeds.
pub fn sequence(actions: impl IntoIterator<Item = FaultAction>) -> Self {
Self {
actions: actions.into_iter().collect(),
mode: FaultMode::SequenceOnce,
call_index: AtomicU32::new(0),
rng_state: Mutex::new(0),
}
}
/// Create a fault injector that loops the action sequence forever.
pub fn sequence_loop(actions: impl IntoIterator<Item = FaultAction>) -> Self {
Self {
actions: actions.into_iter().collect(),
mode: FaultMode::SequenceLoop,
call_index: AtomicU32::new(0),
rng_state: Mutex::new(0),
}
}
/// Create a fault injector with random failures at the given rate.
///
/// # Panics
///
/// Panics if `error_rate` is not in `0.0..=1.0` or is NaN.
///
/// The seed is guarded against zero, which is a fixed point for xorshift.
pub fn random(error_rate: f64, fault: FaultType, seed: u64) -> Self {
assert!(
!error_rate.is_nan() && (0.0..=1.0).contains(&error_rate),
"error_rate must be in 0.0..=1.0 and not NaN, got {error_rate}"
);
let seed = if seed == 0 { 1 } else { seed };
Self {
actions: Vec::new(),
mode: FaultMode::Random {
error_rate,
fault,
seed,
},
call_index: AtomicU32::new(0),
rng_state: Mutex::new(seed),
}
}
/// Get the action for the next call.
pub fn next_action(&self) -> FaultAction {
let index = self.call_index.fetch_add(1, Ordering::Relaxed) as usize;
match &self.mode {
FaultMode::SequenceOnce => {
if index < self.actions.len() {
self.actions[index].clone()
} else {
FaultAction::Succeed
}
}
FaultMode::SequenceLoop => {
if self.actions.is_empty() {
FaultAction::Succeed
} else {
self.actions[index % self.actions.len()].clone()
}
}
FaultMode::Random {
error_rate, fault, ..
} => {
// Simple xorshift64 PRNG for reproducible randomness.
let random_val = {
let mut state = self.rng_state.lock().unwrap_or_else(|p| p.into_inner());
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
(*state as f64) / (u64::MAX as f64)
};
if random_val <= *error_rate {
FaultAction::Fail(fault.clone())
} else {
FaultAction::Succeed
}
}
}
}
/// Get the total number of calls made.
pub fn call_count(&self) -> u32 {
self.call_index.load(Ordering::Relaxed)
}
/// Reset the injector to its initial state.
///
/// For `Random` mode, re-initializes the RNG from the stored seed,
/// which is useful for test reproducibility.
/// For all modes, resets the call counter to zero.
pub fn reset(&self) {
self.call_index.store(0, Ordering::Relaxed);
if let FaultMode::Random { seed, .. } = &self.mode {
let mut state = self.rng_state.lock().unwrap_or_else(|p| p.into_inner());
*state = *seed;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sequence_once_plays_then_succeeds() {
let injector = FaultInjector::sequence([
FaultAction::Fail(FaultType::RequestFailed),
FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
FaultAction::Succeed,
]);
// First two calls should fail
assert!(matches!(
injector.next_action(),
FaultAction::Fail(FaultType::RequestFailed)
));
assert!(matches!(
injector.next_action(),
FaultAction::Fail(FaultType::RateLimited { .. })
));
// Third call is explicit succeed
assert!(matches!(injector.next_action(), FaultAction::Succeed));
// Beyond sequence: implicit succeed
assert!(matches!(injector.next_action(), FaultAction::Succeed));
assert!(matches!(injector.next_action(), FaultAction::Succeed));
assert_eq!(injector.call_count(), 5);
}
#[test]
fn sequence_loop_repeats() {
let injector = FaultInjector::sequence_loop([
FaultAction::Fail(FaultType::RequestFailed),
FaultAction::Succeed,
]);
assert!(matches!(injector.next_action(), FaultAction::Fail(_)));
assert!(matches!(injector.next_action(), FaultAction::Succeed));
assert!(matches!(injector.next_action(), FaultAction::Fail(_)));
assert!(matches!(injector.next_action(), FaultAction::Succeed));
}
#[test]
fn random_mode_is_deterministic_with_seed() {
let injector1 = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
let injector2 = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
let results1: Vec<bool> = (0..20)
.map(|_| matches!(injector1.next_action(), FaultAction::Fail(_)))
.collect();
let results2: Vec<bool> = (0..20)
.map(|_| matches!(injector2.next_action(), FaultAction::Fail(_)))
.collect();
assert_eq!(results1, results2, "Same seed should produce same sequence");
}
#[test]
fn fault_type_produces_correct_llm_errors() {
let provider = "test-provider";
assert!(matches!(
FaultType::RequestFailed.to_llm_error(provider),
LlmError::RequestFailed { .. }
));
assert!(matches!(
FaultType::RateLimited {
retry_after: Some(Duration::from_secs(5))
}
.to_llm_error(provider),
LlmError::RateLimited { .. }
));
assert!(matches!(
FaultType::AuthFailed.to_llm_error(provider),
LlmError::AuthFailed { .. }
));
assert!(matches!(
FaultType::InvalidResponse.to_llm_error(provider),
LlmError::InvalidResponse { .. }
));
assert!(matches!(
FaultType::IoError.to_llm_error(provider),
LlmError::Io(_)
));
assert!(matches!(
FaultType::ContextLengthExceeded.to_llm_error(provider),
LlmError::ContextLengthExceeded { .. }
));
assert!(matches!(
FaultType::SessionExpired.to_llm_error(provider),
LlmError::SessionExpired { .. }
));
}
#[test]
fn delay_action_exists() {
let injector = FaultInjector::sequence([FaultAction::Delay(Duration::from_millis(100))]);
assert!(matches!(injector.next_action(), FaultAction::Delay(_)));
}
#[test]
fn random_seed_zero_does_not_always_fail() {
// seed=0 is a fixed point for xorshift; the constructor guards it to 1.
let injector = FaultInjector::random(0.5, FaultType::RequestFailed, 0);
let failures = (0..100)
.filter(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
.count();
assert!(failures < 100, "seed=0 must not produce stuck RNG");
}
#[test]
fn empty_sequence_always_succeeds() {
let injector = FaultInjector::sequence([]);
for _ in 0..10 {
assert!(matches!(injector.next_action(), FaultAction::Succeed));
}
}
#[test]
fn reset_restores_random_rng_from_stored_seed() {
let injector = FaultInjector::random(0.5, FaultType::RequestFailed, 42);
let run1: Vec<bool> = (0..20)
.map(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
.collect();
injector.reset();
assert_eq!(injector.call_count(), 0);
let run2: Vec<bool> = (0..20)
.map(|_| matches!(injector.next_action(), FaultAction::Fail(_)))
.collect();
assert_eq!(run1, run2, "reset() should reproduce the same sequence");
}
#[test]
#[should_panic(expected = "error_rate must be in 0.0..=1.0")]
fn random_rejects_error_rate_above_one() {
FaultInjector::random(1.5, FaultType::RequestFailed, 42);
}
#[test]
#[should_panic(expected = "error_rate must be in 0.0..=1.0")]
fn random_rejects_negative_error_rate() {
FaultInjector::random(-0.1, FaultType::RequestFailed, 42);
}
#[test]
#[should_panic(expected = "error_rate must be in 0.0..=1.0 and not NaN")]
fn random_rejects_nan_error_rate() {
FaultInjector::random(f64::NAN, FaultType::RequestFailed, 42);
}
#[test]
fn error_rate_one_always_fails() {
let injector = FaultInjector::random(1.0, FaultType::RequestFailed, 42);
for _ in 0..100 {
assert!(
matches!(injector.next_action(), FaultAction::Fail(_)),
"error_rate=1.0 must always produce failures"
);
}
}
#[test]
fn error_rate_zero_never_fails() {
let injector = FaultInjector::random(0.0, FaultType::RequestFailed, 42);
for _ in 0..100 {
assert!(
matches!(injector.next_action(), FaultAction::Succeed),
"error_rate=0.0 must never produce failures"
);
}
}
#[tokio::test]
async fn delay_action_pauses_execution() {
tokio::time::pause();
let injector = FaultInjector::sequence([
FaultAction::Delay(Duration::from_secs(10)),
FaultAction::Succeed,
]);
// First action is a delay
let action = injector.next_action();
assert!(matches!(action, FaultAction::Delay(d) if d == Duration::from_secs(10)));
// Simulate what StubLlm does: sleep then succeed
if let FaultAction::Delay(d) = action {
let start = tokio::time::Instant::now();
tokio::time::sleep(d).await;
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_secs(10),
"delay should have paused for at least 10s, got {elapsed:?}"
);
}
// Next action succeeds
assert!(matches!(injector.next_action(), FaultAction::Succeed));
}
}
+66 -4
View File
@@ -19,9 +19,11 @@
//! ```
pub mod credentials;
pub mod fault_injection;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use async_trait::async_trait;
@@ -84,6 +86,9 @@ pub struct StubLlm {
call_count: AtomicU32,
should_fail: AtomicBool,
error_kind: StubErrorKind,
/// Optional fault injector for fine-grained failure control.
/// When set, takes precedence over the `should_fail` / `error_kind` fields.
fault_injector: Option<Arc<fault_injection::FaultInjector>>,
}
impl StubLlm {
@@ -95,6 +100,7 @@ impl StubLlm {
call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(false),
error_kind: StubErrorKind::Transient,
fault_injector: None,
}
}
@@ -106,6 +112,7 @@ impl StubLlm {
call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(true),
error_kind: StubErrorKind::Transient,
fault_injector: None,
}
}
@@ -117,6 +124,7 @@ impl StubLlm {
call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(true),
error_kind: StubErrorKind::NonTransient,
fault_injector: None,
}
}
@@ -131,11 +139,39 @@ impl StubLlm {
self.call_count.load(Ordering::Relaxed)
}
/// Attach a fault injector for fine-grained failure control.
///
/// When set, the injector's `next_action()` is consulted on every call,
/// taking precedence over the `should_fail` / `error_kind` fields.
pub fn with_fault_injector(mut self, injector: Arc<fault_injection::FaultInjector>) -> Self {
self.fault_injector = Some(injector);
self
}
/// Toggle whether calls should fail at runtime.
pub fn set_failing(&self, fail: bool) {
self.should_fail.store(fail, Ordering::Relaxed);
}
/// Check the fault injector or should_fail flag, returning an error if
/// the call should fail, or None if it should succeed.
async fn check_faults(&self) -> Option<LlmError> {
if let Some(ref injector) = self.fault_injector {
match injector.next_action() {
fault_injection::FaultAction::Fail(fault) => {
return Some(fault.to_llm_error(&self.model_name));
}
fault_injection::FaultAction::Delay(duration) => {
tokio::time::sleep(duration).await;
}
fault_injection::FaultAction::Succeed => {}
}
} else if self.should_fail.load(Ordering::Relaxed) {
return Some(self.make_error());
}
None
}
fn make_error(&self) -> LlmError {
match self.error_kind {
StubErrorKind::Transient => LlmError::RequestFailed {
@@ -168,8 +204,8 @@ impl LlmProvider for StubLlm {
async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
self.call_count.fetch_add(1, Ordering::Relaxed);
if self.should_fail.load(Ordering::Relaxed) {
return Err(self.make_error());
if let Some(err) = self.check_faults().await {
return Err(err);
}
Ok(CompletionResponse {
content: self.response.clone(),
@@ -186,8 +222,8 @@ impl LlmProvider for StubLlm {
_request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
self.call_count.fetch_add(1, Ordering::Relaxed);
if self.should_fail.load(Ordering::Relaxed) {
return Err(self.make_error());
if let Some(err) = self.check_faults().await {
return Err(err);
}
Ok(ToolCompletionResponse {
content: Some(self.response.clone()),
@@ -456,6 +492,7 @@ impl TestHarnessBuilder {
http_interceptor: None,
transcription: None,
document_extraction: None,
builder: None,
};
TestHarness {
@@ -1508,4 +1545,29 @@ mod tests {
.await
.expect("update actuals");
}
#[tokio::test]
async fn stub_llm_fault_injector_sequence() {
use crate::llm::LlmProvider;
use crate::testing::fault_injection::{FaultAction, FaultInjector, FaultType};
let injector = Arc::new(FaultInjector::sequence([
FaultAction::Fail(FaultType::RateLimited { retry_after: None }),
FaultAction::Succeed,
]));
let stub = StubLlm::new("hello").with_fault_injector(injector);
let req = crate::llm::CompletionRequest::new(vec![crate::llm::ChatMessage::user("hi")]);
// First call should fail with RateLimited
let result = stub.complete(req.clone()).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), LlmError::RateLimited { .. }));
// Second call should succeed
let result = stub.complete(req).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().content, "hello");
}
}
+61 -15
View File
@@ -837,7 +837,7 @@ impl Tool for HttpTool {
}));
if has_credentials {
return ApprovalRequirement::Always;
return ApprovalRequirement::UnlessAutoApproved;
}
// GET requests (or missing method, since GET is the default) are low-risk
@@ -1093,25 +1093,31 @@ mod tests {
}
#[test]
fn test_auth_header_object_format_returns_always() {
fn test_auth_header_object_format_returns_unless_auto_approved() {
let tool = HttpTool::new();
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/data",
"headers": {"Authorization": "Bearer token123"}
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
}
#[test]
fn test_auth_header_array_format_returns_always() {
fn test_auth_header_array_format_returns_unless_auto_approved() {
let tool = HttpTool::new();
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/data",
"headers": [{"name": "Authorization", "value": "Bearer token123"}]
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
}
#[test]
@@ -1124,7 +1130,10 @@ mod tests {
"url": "https://example.com",
"headers": {"AUTHORIZATION": "Bearer x"}
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
// Array format with mixed case
let params = serde_json::json!({
@@ -1132,7 +1141,10 @@ mod tests {
"url": "https://example.com",
"headers": [{"name": "X-Api-Key", "value": "key123"}]
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
}
#[test]
@@ -1161,8 +1173,8 @@ mod tests {
});
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::Always,
"Header '{}' should trigger Always approval",
ApprovalRequirement::UnlessAutoApproved,
"Header '{}' should trigger UnlessAutoApproved approval",
header_name
);
}
@@ -1203,7 +1215,7 @@ mod tests {
// ── Credential registry approval tests ─────────────────────────────
#[test]
fn test_host_with_credential_mapping_returns_always() {
fn test_host_with_credential_mapping_returns_unless_auto_approved() {
use crate::secrets::CredentialMapping;
use crate::tools::wasm::SharedCredentialRegistry;
@@ -1223,7 +1235,10 @@ mod tests {
"method": "GET",
"url": "https://api.openai.com/v1/models"
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
}
#[test]
@@ -1243,24 +1258,55 @@ mod tests {
}
#[test]
fn test_url_query_param_credential_returns_always() {
fn test_url_query_param_credential_returns_unless_auto_approved() {
let tool = HttpTool::new();
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/data?api_key=secret123"
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
}
#[test]
fn test_bearer_value_in_custom_header_returns_always() {
fn test_bearer_value_in_custom_header_returns_unless_auto_approved() {
let tool = HttpTool::new();
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {"X-Custom": format!("Bearer {TEST_OPENAI_API_KEY}")}
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
}
/// Regression test: credentialed HTTP requests must return
/// `UnlessAutoApproved` (not `Always`) so that the session auto-approve
/// set is respected when the user says "always".
#[test]
fn test_credentialed_requests_respect_auto_approve() {
let tool = HttpTool::new();
// Manual credentials (Authorization header)
let params = serde_json::json!({
"method": "GET",
"url": "https://api.github.com/orgs/Casa",
"headers": {"Authorization": "Bearer ghp_abc123"}
});
// Must NOT be Always — Always ignores the session auto-approve set
assert_ne!(
tool.requires_approval(&params),
ApprovalRequirement::Always,
"Credentialed HTTP requests must not return Always; use UnlessAutoApproved"
);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved,
);
}
#[test]
+181 -119
View File
@@ -1005,7 +1005,8 @@ impl Tool for JobStatusTool {
"created_at": job_ctx.created_at.to_rfc3339(),
"started_at": job_ctx.started_at.map(|t| t.to_rfc3339()),
"completed_at": job_ctx.completed_at.map(|t| t.to_rfc3339()),
"actual_cost": job_ctx.actual_cost.to_string()
"actual_cost": job_ctx.actual_cost.to_string(),
"fallback_deliverable": job_ctx.metadata.get("fallback_deliverable"),
});
Ok(ToolOutput::success(result, start.elapsed()))
}
@@ -1384,7 +1385,7 @@ mod tests {
let tool = CreateJobTool::new(manager.clone());
// Without sandbox deps, it should use the local path
assert!(!tool.sandbox_enabled());
assert!(!tool.sandbox_enabled()); // safety: test
let params = serde_json::json!({
"title": "Test Job",
@@ -1392,12 +1393,13 @@ mod tests {
});
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap();
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
let job_id = result.result.get("job_id").unwrap().as_str().unwrap();
assert!(!job_id.is_empty());
let job_id = result.result.get("job_id").unwrap().as_str().unwrap(); // safety: test
assert!(!job_id.is_empty()); // safety: test
assert_eq!(
result.result.get("status").unwrap().as_str().unwrap(),
/* safety: test */
result.result.get("status").unwrap().as_str().unwrap(), // safety: test
"pending"
);
}
@@ -1409,11 +1411,11 @@ mod tests {
// Without sandbox
let tool = CreateJobTool::new(Arc::clone(&manager));
let schema = tool.parameters_schema();
let props = schema.get("properties").unwrap().as_object().unwrap();
assert!(props.contains_key("title"));
assert!(props.contains_key("description"));
assert!(!props.contains_key("wait"));
assert!(!props.contains_key("mode"));
let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
assert!(props.contains_key("title")); // safety: test
assert!(props.contains_key("description")); // safety: test
assert!(!props.contains_key("wait")); // safety: test
assert!(!props.contains_key("mode")); // safety: test
}
#[test]
@@ -1422,7 +1424,7 @@ mod tests {
// Without sandbox: default timeout
let tool = CreateJobTool::new(Arc::clone(&manager));
assert_eq!(tool.execution_timeout(), Duration::from_secs(30));
assert_eq!(tool.execution_timeout(), Duration::from_secs(30)); // safety: test
}
#[tokio::test]
@@ -1455,23 +1457,23 @@ mod tests {
let manager = Arc::new(ContextManager::new(5));
// Create some jobs
manager.create_job("Job 1", "Desc 1").await.unwrap();
manager.create_job("Job 2", "Desc 2").await.unwrap();
manager.create_job("Job 1", "Desc 1").await.unwrap(); // safety: test
manager.create_job("Job 2", "Desc 2").await.unwrap(); // safety: test
let tool = ListJobsTool::new(manager);
let params = serde_json::json!({});
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap();
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
let jobs = result.result.get("jobs").unwrap().as_array().unwrap();
assert_eq!(jobs.len(), 2);
let jobs = result.result.get("jobs").unwrap().as_array().unwrap(); // safety: test
assert_eq!(jobs.len(), 2); // safety: test
}
#[tokio::test]
async fn test_job_status_tool() {
let manager = Arc::new(ContextManager::new(5));
let job_id = manager.create_job("Test Job", "Description").await.unwrap();
let job_id = manager.create_job("Test Job", "Description").await.unwrap(); // safety: test
let tool = JobStatusTool::new(manager);
@@ -1479,10 +1481,11 @@ mod tests {
"job_id": job_id.to_string()
});
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap();
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
assert_eq!(
result.result.get("title").unwrap().as_str().unwrap(),
/* safety: test */
result.result.get("title").unwrap().as_str().unwrap(), // safety: test
"Test Job"
);
}
@@ -1496,8 +1499,9 @@ mod tests {
let missing_title = tool
.execute(serde_json::json!({ "description": "A test job" }), &ctx)
.await;
assert!(missing_title.is_err());
assert!(missing_title.is_err()); // safety: test
assert!(
/* safety: test */
missing_title
.unwrap_err()
.to_string()
@@ -1507,8 +1511,9 @@ mod tests {
let missing_description = tool
.execute(serde_json::json!({ "title": "Test Job" }), &ctx)
.await;
assert!(missing_description.is_err());
assert!(missing_description.is_err()); // safety: test
assert!(
/* safety: test */
missing_description
.unwrap_err()
.to_string()
@@ -1522,19 +1527,19 @@ mod tests {
let pending_id = manager
.create_job_for_user("default", "Pending Job", "Todo")
.await
.unwrap();
.unwrap(); // safety: test
let completed_id = manager
.create_job_for_user("default", "Completed Job", "Done")
.await
.unwrap();
.unwrap(); // safety: test
let failed_id = manager
.create_job_for_user("default", "Failed Job", "Oops")
.await
.unwrap();
.unwrap(); // safety: test
manager
.create_job_for_user("other-user", "Other User Job", "Ignore")
.await
.unwrap();
.unwrap(); // safety: test
manager
.update_context(completed_id, |ctx| {
@@ -1542,41 +1547,44 @@ mod tests {
ctx.transition_to(JobState::Completed, Some("done".to_string()))
})
.await
.unwrap()
.unwrap();
.unwrap() // safety: test
.unwrap(); // safety: test
manager
.update_context(failed_id, |ctx| {
ctx.transition_to(JobState::InProgress, None)?;
ctx.transition_to(JobState::Failed, Some("boom".to_string()))
})
.await
.unwrap()
.unwrap();
.unwrap() // safety: test
.unwrap(); // safety: test
let tool = ListJobsTool::new(Arc::clone(&manager));
let ctx = JobContext::default();
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
let result = tool.execute(serde_json::json!({}), &ctx).await.unwrap(); // safety: test
let jobs = result.result.get("jobs").unwrap().as_array().unwrap();
assert_eq!(jobs.len(), 3);
let jobs = result.result.get("jobs").unwrap().as_array().unwrap(); // safety: test
assert_eq!(jobs.len(), 3); // safety: test
assert!(jobs.iter().any(|job| {
// safety: test
job.get("job_id").and_then(|v| v.as_str()) == Some(&pending_id.to_string())
&& job.get("status").and_then(|v| v.as_str()) == Some("Pending")
}));
assert!(jobs.iter().any(|job| {
// safety: test
job.get("job_id").and_then(|v| v.as_str()) == Some(&completed_id.to_string())
&& job.get("status").and_then(|v| v.as_str()) == Some("Completed")
}));
assert!(jobs.iter().any(|job| {
// safety: test
job.get("job_id").and_then(|v| v.as_str()) == Some(&failed_id.to_string())
&& job.get("status").and_then(|v| v.as_str()) == Some("Failed")
}));
let summary = result.result.get("summary").unwrap();
assert_eq!(summary.get("total").and_then(|v| v.as_u64()), Some(3));
assert_eq!(summary.get("pending").and_then(|v| v.as_u64()), Some(1));
assert_eq!(summary.get("completed").and_then(|v| v.as_u64()), Some(1));
assert_eq!(summary.get("failed").and_then(|v| v.as_u64()), Some(1));
let summary = result.result.get("summary").unwrap(); // safety: test
assert_eq!(summary.get("total").and_then(|v| v.as_u64()), Some(3)); // safety: test
assert_eq!(summary.get("pending").and_then(|v| v.as_u64()), Some(1)); // safety: test
assert_eq!(summary.get("completed").and_then(|v| v.as_u64()), Some(1)); // safety: test
assert_eq!(summary.get("failed").and_then(|v| v.as_u64()), Some(1)); // safety: test
}
#[tokio::test]
@@ -1585,29 +1593,30 @@ mod tests {
let job_id = manager
.create_job_for_user("default", "Transition Job", "Track me")
.await
.unwrap();
.unwrap(); // safety: test
manager
.update_context(job_id, |ctx| {
ctx.transition_to(JobState::InProgress, Some("started".to_string()))?;
ctx.transition_to(JobState::Completed, Some("finished".to_string()))
})
.await
.unwrap()
.unwrap();
.unwrap() // safety: test
.unwrap(); // safety: test
let tool = JobStatusTool::new(Arc::clone(&manager));
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
.await
.unwrap();
.unwrap(); // safety: test
assert_eq!(
/* safety: test */
result.result.get("status").and_then(|v| v.as_str()),
Some("Completed")
);
assert!(result.result.get("started_at").unwrap().is_string());
assert!(result.result.get("completed_at").unwrap().is_string());
assert!(result.result.get("started_at").unwrap().is_string()); // safety: test
assert!(result.result.get("completed_at").unwrap().is_string()); // safety: test
}
#[tokio::test]
@@ -1616,26 +1625,27 @@ mod tests {
let job_id = manager
.create_job_for_user("default", "Running Job", "In progress")
.await
.unwrap();
.unwrap(); // safety: test
manager
.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await
.unwrap()
.unwrap();
.unwrap() // safety: test
.unwrap(); // safety: test
let tool = CancelJobTool::new(Arc::clone(&manager));
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
.await
.unwrap();
.unwrap(); // safety: test
assert_eq!(
/* safety: test */
result.result.get("status").and_then(|v| v.as_str()),
Some("cancelled")
);
let updated = manager.get_context(job_id).await.unwrap();
assert_eq!(updated.state, JobState::Cancelled);
let updated = manager.get_context(job_id).await.unwrap(); // safety: test
assert_eq!(updated.state, JobState::Cancelled); // safety: test
}
#[tokio::test]
@@ -1644,39 +1654,81 @@ mod tests {
let job_id = manager
.create_job_for_user("default", "Completed Job", "Already done")
.await
.unwrap();
.unwrap(); // safety: test
manager
.update_context(job_id, |ctx| {
ctx.transition_to(JobState::InProgress, None)?;
ctx.transition_to(JobState::Completed, Some("done".to_string()))
})
.await
.unwrap()
.unwrap();
.unwrap() // safety: test
.unwrap(); // safety: test
let tool = CancelJobTool::new(Arc::clone(&manager));
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
.await
.unwrap();
.unwrap(); // safety: test
let error = result.result.get("error").and_then(|v| v.as_str()).unwrap();
assert!(error.contains("Cannot cancel job"));
assert!(error.contains("completed"));
let error = result.result.get("error").and_then(|v| v.as_str()).unwrap(); // safety: test
assert!(error.contains("Cannot cancel job")); // safety: test
assert!(error.contains("completed")); // safety: test
}
#[tokio::test]
async fn test_job_status_includes_fallback_deliverable() {
let manager = Arc::new(ContextManager::new(5));
let job_id = manager
.create_job_for_user("default", "Failing Job", "Will fail")
.await
.unwrap(); // safety: test
// Inject a real FallbackDeliverable into the job metadata.
let fallback = serde_json::json!({
"partial": true,
"failure_reason": "max iterations",
"last_action": null,
"action_stats": { "total": 5, "successful": 3, "failed": 2 },
"tokens_used": 1000,
"cost": "0.05",
"elapsed_secs": 12.5,
"repair_attempts": 1,
});
manager
.update_context(job_id, |ctx| {
ctx.metadata = serde_json::json!({ "fallback_deliverable": fallback.clone() });
Ok::<(), String>(())
})
.await
.unwrap() // safety: test
.unwrap(); // safety: test
let tool = JobStatusTool::new(manager);
let params = serde_json::json!({ "job_id": job_id.to_string() });
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
let fb = result.result.get("fallback_deliverable").unwrap(); // safety: test
assert_eq!(fb.get("partial").unwrap(), true); // safety: test
assert_eq!(fb.get("failure_reason").unwrap(), "max iterations"); // safety: test
let stats = fb.get("action_stats").unwrap(); // safety: test
assert_eq!(stats.get("total").unwrap(), 5); // safety: test
assert_eq!(stats.get("successful").unwrap(), 3); // safety: test
assert_eq!(stats.get("failed").unwrap(), 2); // safety: test
}
#[test]
fn test_resolve_project_dir_auto() {
let project_id = Uuid::new_v4();
let (dir, browse_id) = resolve_project_dir(None, project_id).unwrap();
assert!(dir.exists());
assert!(dir.ends_with(project_id.to_string()));
assert_eq!(browse_id, project_id.to_string());
let (dir, browse_id) = resolve_project_dir(None, project_id).unwrap(); // safety: test
assert!(dir.exists()); // safety: test
assert!(dir.ends_with(project_id.to_string())); // safety: test
assert_eq!(browse_id, project_id.to_string()); // safety: test
// Must be under the projects base
let base = projects_base().canonicalize().unwrap();
assert!(dir.starts_with(&base));
let base = projects_base().canonicalize().unwrap(); // safety: test
assert!(dir.starts_with(&base)); // safety: test
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1684,33 +1736,34 @@ mod tests {
#[test]
fn test_resolve_project_dir_explicit_under_base() {
let base = projects_base();
std::fs::create_dir_all(&base).unwrap();
std::fs::create_dir_all(&base).unwrap(); // safety: test
let explicit = base.join("test_explicit_project");
// Explicit paths must already exist (no auto-create).
std::fs::create_dir_all(&explicit).unwrap();
std::fs::create_dir_all(&explicit).unwrap(); // safety: test
let project_id = Uuid::new_v4();
let (dir, browse_id) = resolve_project_dir(Some(explicit.clone()), project_id).unwrap();
assert!(dir.exists());
assert_eq!(browse_id, "test_explicit_project");
let (dir, browse_id) = resolve_project_dir(Some(explicit.clone()), project_id).unwrap(); // safety: test
assert!(dir.exists()); // safety: test
assert_eq!(browse_id, "test_explicit_project"); // safety: test
let canonical_base = base.canonicalize().unwrap();
assert!(dir.starts_with(&canonical_base));
let canonical_base = base.canonicalize().unwrap(); // safety: test
assert!(dir.starts_with(&canonical_base)); // safety: test
let _ = std::fs::remove_dir_all(&explicit);
}
#[test]
fn test_resolve_project_dir_rejects_outside_base() {
let tmp = tempfile::tempdir().unwrap();
let tmp = tempfile::tempdir().unwrap(); // safety: test
let escape_attempt = tmp.path().join("evil_project");
// Don't create it: explicit paths that don't exist are rejected
// before the prefix check even runs.
let result = resolve_project_dir(Some(escape_attempt), Uuid::new_v4());
assert!(result.is_err());
assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string();
assert!(
/* safety: test */
err.contains("does not exist"),
"expected 'does not exist' error, got: {}",
err
@@ -1720,13 +1773,14 @@ mod tests {
#[test]
fn test_resolve_project_dir_rejects_outside_base_existing() {
// A directory that exists but is outside the projects base.
let tmp = tempfile::tempdir().unwrap();
let tmp = tempfile::tempdir().unwrap(); // safety: test
let outside = tmp.path().to_path_buf();
let result = resolve_project_dir(Some(outside), Uuid::new_v4());
assert!(result.is_err());
assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string();
assert!(
/* safety: test */
err.contains("must be under"),
"expected 'must be under' error, got: {}",
err
@@ -1740,7 +1794,7 @@ mod tests {
let traversal = base.join("legit").join("..").join("..").join(".ssh");
let result = resolve_project_dir(Some(traversal), Uuid::new_v4());
assert!(result.is_err(), "traversal path should be rejected");
assert!(result.is_err(), "traversal path should be rejected"); // safety: test
// Traversal path that actually resolves gets the prefix check.
// `base/../` resolves to the parent of projects base, which is outside.
@@ -1748,7 +1802,7 @@ mod tests {
std::fs::create_dir_all(&base_parent).ok();
if base_parent.exists() {
let result = resolve_project_dir(Some(base_parent.clone()), Uuid::new_v4());
assert!(result.is_err(), "path outside base should be rejected");
assert!(result.is_err(), "path outside base should be rejected"); // safety: test
let _ = std::fs::remove_dir_all(&base_parent);
}
}
@@ -1762,8 +1816,9 @@ mod tests {
));
let tool = CreateJobTool::new(manager).with_sandbox(jm, None);
let schema = tool.parameters_schema();
let props = schema.get("properties").unwrap().as_object().unwrap();
let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
assert!(
/* safety: test */
props.contains_key("project_dir"),
"sandbox schema must expose project_dir"
);
@@ -1778,8 +1833,9 @@ mod tests {
));
let tool = CreateJobTool::new(manager).with_sandbox(jm, None);
let schema = tool.parameters_schema();
let props = schema.get("properties").unwrap().as_object().unwrap();
let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
assert!(
/* safety: test */
props.contains_key("credentials"),
"sandbox schema must expose credentials"
);
@@ -1792,13 +1848,13 @@ mod tests {
// No credentials parameter
let params = serde_json::json!({"title": "t", "description": "d"});
let grants = tool.parse_credentials(&params, "user1").await.unwrap();
assert!(grants.is_empty());
let grants = tool.parse_credentials(&params, "user1").await.unwrap(); // safety: test
assert!(grants.is_empty()); // safety: test
// Empty credentials object
let params = serde_json::json!({"credentials": {}});
let grants = tool.parse_credentials(&params, "user1").await.unwrap();
assert!(grants.is_empty());
let grants = tool.parse_credentials(&params, "user1").await.unwrap(); // safety: test
assert!(grants.is_empty()); // safety: test
}
#[tokio::test]
@@ -1808,9 +1864,10 @@ mod tests {
let params = serde_json::json!({"credentials": {"my_secret": "MY_SECRET"}});
let result = tool.parse_credentials(&params, "user1").await;
assert!(result.is_err());
assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string();
assert!(
/* safety: test */
err.contains("no secrets store"),
"expected 'no secrets store' error, got: {}",
err
@@ -1828,9 +1885,10 @@ mod tests {
let params = serde_json::json!({"credentials": {"nonexistent_secret": "SOME_VAR"}});
let result = tool.parse_credentials(&params, "user1").await;
assert!(result.is_err());
assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string();
assert!(
/* safety: test */
err.contains("not found"),
"expected 'not found' error, got: {}",
err
@@ -1852,17 +1910,17 @@ mod tests {
CreateSecretParams::new("github_token", TEST_GITHUB_TOKEN),
)
.await
.unwrap();
.unwrap(); // safety: test
let tool = CreateJobTool::new(manager).with_secrets(Arc::clone(&secrets));
let params = serde_json::json!({
"credentials": {"github_token": "GITHUB_TOKEN"}
});
let grants = tool.parse_credentials(&params, "user1").await.unwrap();
assert_eq!(grants.len(), 1);
assert_eq!(grants[0].secret_name, "github_token");
assert_eq!(grants[0].env_var, "GITHUB_TOKEN");
let grants = tool.parse_credentials(&params, "user1").await.unwrap(); // safety: test
assert_eq!(grants.len(), 1); // safety: test
assert_eq!(grants[0].secret_name, "github_token"); // safety: test
assert_eq!(grants[0].env_var, "GITHUB_TOKEN"); // safety: test
}
fn test_prompt_tool(queue: PromptQueue) -> JobPromptTool {
@@ -1876,7 +1934,7 @@ mod tests {
let job_id = cm
.create_job_for_user("default", "Test Job", "desc")
.await
.unwrap();
.unwrap(); // safety: test
let queue: PromptQueue =
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
@@ -1889,18 +1947,19 @@ mod tests {
});
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap();
let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
assert_eq!(
result.result.get("status").unwrap().as_str().unwrap(),
/* safety: test */
result.result.get("status").unwrap().as_str().unwrap(), // safety: test
"queued"
);
let q = queue.lock().await;
let prompts = q.get(&job_id).unwrap();
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].content, "What's the status?");
assert!(!prompts[0].done);
let prompts = q.get(&job_id).unwrap(); // safety: test
assert_eq!(prompts.len(), 1); // safety: test
assert_eq!(prompts[0].content, "What's the status?"); // safety: test
assert!(!prompts[0].done); // safety: test
}
#[tokio::test]
@@ -1910,6 +1969,7 @@ mod tests {
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
let tool = test_prompt_tool(queue);
assert_eq!(
/* safety: test */
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::UnlessAutoApproved
);
@@ -1928,7 +1988,7 @@ mod tests {
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await;
assert!(result.is_err());
assert!(result.is_err()); // safety: test
}
#[tokio::test]
@@ -1943,7 +2003,7 @@ mod tests {
let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await;
assert!(result.is_err());
assert!(result.is_err()); // safety: test
}
#[tokio::test]
@@ -1958,7 +2018,7 @@ mod tests {
let job_id = cm
.create_job_for_user("owner-user", "Secret Job", "classified")
.await
.unwrap();
.unwrap(); // safety: test
// We need a Store to construct the tool, but creating one requires
// a database URL. Instead, test the ownership logic directly:
@@ -1968,9 +2028,9 @@ mod tests {
..Default::default()
};
let job_ctx = cm.get_context(job_id).await.unwrap();
assert_ne!(job_ctx.user_id, attacker_ctx.user_id);
assert_eq!(job_ctx.user_id, "owner-user");
let job_ctx = cm.get_context(job_id).await.unwrap(); // safety: test
assert_ne!(job_ctx.user_id, attacker_ctx.user_id); // safety: test
assert_eq!(job_ctx.user_id, "owner-user"); // safety: test
}
#[test]
@@ -1991,12 +2051,12 @@ mod tests {
"required": ["job_id"]
});
let props = schema.get("properties").unwrap().as_object().unwrap();
assert!(props.contains_key("job_id"));
assert!(props.contains_key("limit"));
let required = schema.get("required").unwrap().as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0].as_str().unwrap(), "job_id");
let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
assert!(props.contains_key("job_id")); // safety: test
assert!(props.contains_key("limit")); // safety: test
let required = schema.get("required").unwrap().as_array().unwrap(); // safety: test
assert_eq!(required.len(), 1); // safety: test
assert_eq!(required[0].as_str().unwrap(), "job_id"); // safety: test
}
#[tokio::test]
@@ -2005,7 +2065,7 @@ mod tests {
let job_id = cm
.create_job_for_user("owner-user", "Test Job", "desc")
.await
.unwrap();
.unwrap(); // safety: test
let queue: PromptQueue =
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
@@ -2023,9 +2083,10 @@ mod tests {
};
let result = tool.execute(params, &ctx).await;
assert!(result.is_err());
assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string();
assert!(
/* safety: test */
err.contains("does not belong to current user"),
"expected ownership error, got: {}",
err
@@ -2035,33 +2096,34 @@ mod tests {
#[tokio::test]
async fn test_resolve_job_id_full_uuid() {
let cm = ContextManager::new(5);
let job_id = cm.create_job("Test", "Desc").await.unwrap();
let job_id = cm.create_job("Test", "Desc").await.unwrap(); // safety: test
let resolved = resolve_job_id(&job_id.to_string(), &cm).await.unwrap();
assert_eq!(resolved, job_id);
let resolved = resolve_job_id(&job_id.to_string(), &cm).await.unwrap(); // safety: test
assert_eq!(resolved, job_id); // safety: test
}
#[tokio::test]
async fn test_resolve_job_id_short_prefix() {
let cm = ContextManager::new(5);
let job_id = cm.create_job("Test", "Desc").await.unwrap();
let job_id = cm.create_job("Test", "Desc").await.unwrap(); // safety: test
// Use first 8 hex chars (without dashes)
let hex = job_id.to_string().replace('-', "");
let prefix = &hex[..8];
let resolved = resolve_job_id(prefix, &cm).await.unwrap();
assert_eq!(resolved, job_id);
let resolved = resolve_job_id(prefix, &cm).await.unwrap(); // safety: test
assert_eq!(resolved, job_id); // safety: test
}
#[tokio::test]
async fn test_resolve_job_id_no_match() {
let cm = ContextManager::new(5);
cm.create_job("Test", "Desc").await.unwrap();
cm.create_job("Test", "Desc").await.unwrap(); // safety: test
let result = resolve_job_id("00000000", &cm).await;
assert!(result.is_err());
assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string();
assert!(
/* safety: test */
err.contains("no job found"),
"expected 'no job found', got: {}",
err
@@ -2072,6 +2134,6 @@ mod tests {
async fn test_resolve_job_id_invalid_input() {
let cm = ContextManager::new(5);
let result = resolve_job_id("not-hex-at-all!", &cm).await;
assert!(result.is_err());
assert!(result.is_err()); // safety: test
}
}
+1563 -333
View File
File diff suppressed because it is too large Load Diff
+121 -19
View File
@@ -1,8 +1,9 @@
//! On-demand tool discovery (like CLI `--help`).
//!
//! Two levels of detail:
//! Three levels of detail:
//! - Default: name, description, parameter names (compact ~150 bytes)
//! - `include_schema: true`: adds the full typed JSON Schema
//! - `detail: "summary"`: adds curated rules, notes, and examples
//! - `detail: "schema"` / `include_schema: true`: adds the full typed JSON Schema
//!
//! Keeps the tools array compact (WASM tools use permissive schemas)
//! while allowing precise discovery when needed.
@@ -13,7 +14,59 @@ use async_trait::async_trait;
use crate::context::JobContext;
use crate::tools::registry::ToolRegistry;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
use crate::tools::tool::{Tool, ToolDiscoverySummary, ToolError, ToolOutput, require_str};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ToolInfoDetail {
Names,
Summary,
Schema,
}
impl ToolInfoDetail {
fn parse(params: &serde_json::Value) -> Result<Self, ToolError> {
if params
.get("include_schema")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Ok(Self::Schema);
}
match params.get("detail").and_then(|v| v.as_str()) {
None | Some("names") => Ok(Self::Names),
Some("summary") => Ok(Self::Summary),
Some("schema") => Ok(Self::Schema),
Some(other) => Err(ToolError::InvalidParameters(format!(
"invalid detail '{other}' (expected 'names', 'summary', or 'schema')"
))),
}
}
}
fn schema_param_names(schema: &serde_json::Value) -> Vec<String> {
schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default()
}
fn fallback_summary(schema: &serde_json::Value) -> ToolDiscoverySummary {
ToolDiscoverySummary {
always_required: schema
.get("required")
.and_then(|v| v.as_array())
.map(|required| {
required
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
..ToolDiscoverySummary::default()
}
}
pub struct ToolInfoTool {
registry: Weak<ToolRegistry>,
@@ -32,8 +85,7 @@ impl Tool for ToolInfoTool {
}
fn description(&self) -> &str {
"Get info about any tool: description and parameter names. \
Set include_schema to true for the full typed parameter schema."
"Get info about any tool: description, parameter names, curated summary guidance, or full discovery schema."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -44,9 +96,15 @@ impl Tool for ToolInfoTool {
"type": "string",
"description": "Name of the tool to get info about"
},
"detail": {
"type": "string",
"enum": ["names", "summary", "schema"],
"description": "Response detail level. 'names' returns parameter names only. 'summary' adds curated rules/examples. 'schema' returns the full discovery schema.",
"default": "names"
},
"include_schema": {
"type": "boolean",
"description": "If true, include the full typed JSON Schema for parameters (larger response). Default: false.",
"description": "Deprecated compatibility alias for detail='schema'. If true, include the full discovery schema.",
"default": false
}
},
@@ -61,10 +119,7 @@ impl Tool for ToolInfoTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
let include_schema = params
.get("include_schema")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let detail = ToolInfoDetail::parse(&params)?;
let registry = self.registry.upgrade().ok_or_else(|| {
ToolError::ExecutionFailed(
@@ -77,13 +132,7 @@ impl Tool for ToolInfoTool {
})?;
let schema = tool.discovery_schema();
// Extract just param names from the schema's "properties" keys
let param_names: Vec<&str> = schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
let param_names = schema_param_names(&schema);
let mut info = serde_json::json!({
"name": tool.name(),
@@ -91,8 +140,21 @@ impl Tool for ToolInfoTool {
"parameters": param_names,
});
if include_schema {
info["schema"] = schema;
match detail {
ToolInfoDetail::Names => {}
ToolInfoDetail::Summary => {
let summary = tool
.discovery_summary()
.unwrap_or_else(|| fallback_summary(&schema));
info["summary"] = serde_json::to_value(summary).map_err(|err| {
ToolError::ExecutionFailed(format!(
"failed to serialize discovery summary: {err}"
))
})?;
}
ToolInfoDetail::Schema => {
info["schema"] = schema;
}
}
Ok(ToolOutput::success(info, start.elapsed()))
@@ -135,6 +197,30 @@ mod tests {
assert!(info.get("schema").is_none());
}
#[tokio::test]
async fn test_tool_info_with_summary() {
let registry = Arc::new(ToolRegistry::new());
registry.register(Arc::new(EchoTool)).await;
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(
serde_json::json!({"name": "echo", "detail": "summary"}),
&ctx,
)
.await
.unwrap();
let info = &result.result;
assert_eq!(info["name"], "echo");
assert!(info["summary"].is_object());
assert_eq!(
info["summary"]["always_required"],
serde_json::json!(["message"])
);
}
#[tokio::test]
async fn test_tool_info_with_schema() {
let registry = Arc::new(ToolRegistry::new());
@@ -157,6 +243,22 @@ mod tests {
assert!(info["schema"]["properties"].is_object());
}
#[tokio::test]
async fn test_tool_info_invalid_detail() {
let registry = Arc::new(ToolRegistry::new());
registry.register(Arc::new(EchoTool)).await;
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(
serde_json::json!({"name": "echo", "detail": "verbose"}),
&ctx,
)
.await;
assert!(matches!(result, Err(ToolError::InvalidParameters(_))));
}
#[tokio::test]
async fn test_tool_info_unknown_tool() {
let registry = Arc::new(ToolRegistry::new());
+27
View File
@@ -291,6 +291,33 @@ mod tests {
registry
}
#[tokio::test]
async fn test_execute_empty_tool_name_returns_not_found() {
// Regression: execute_tool_with_safety must reject empty tool names
// gracefully via ToolError::NotFound (not a panic).
let registry = registry_with(vec![]).await;
let safety = test_safety();
let result = execute_tool_with_safety(
&registry,
&safety,
"",
&serde_json::json!({}),
&test_job_ctx(),
)
.await;
assert!(
matches!(
result,
Err(crate::error::Error::Tool(
crate::error::ToolError::NotFound { .. }
))
),
"Empty tool name should return ToolError::NotFound, got: {result:?}"
);
}
#[tokio::test]
async fn test_execute_success() {
let registry = registry_with(vec![Arc::new(EchoTool)]).await;
+205 -42
View File
@@ -288,6 +288,71 @@ impl McpClient {
Ok(headers)
}
/// Re-run the MCP initialize handshake outside the OnceCell cache.
///
/// This is used for recoverable session-expiry failures when an MCP server
/// reports that the current session ID is no longer valid.
async fn reinitialize_session(&self) -> Result<InitializeResult, ToolError> {
if let Some(ref session_manager) = self.session_manager {
session_manager.terminate(&self.server_name).await;
session_manager
.get_or_create(&self.server_name, &self.server_url)
.await;
}
let request = McpRequest::initialize(self.next_request_id());
let response = self
.transport
.send(&request, &self.build_request_headers().await?)
.await?;
if let Some(error) = response.error {
return Err(ToolError::ExternalService(format!(
"MCP initialization error: {} (code {})",
error.message, error.code
)));
}
let init_result: InitializeResult = response
.result
.ok_or_else(|| {
ToolError::ExternalService("No result in initialize response".to_string())
})
.and_then(|r| {
serde_json::from_value(r).map_err(|e| {
ToolError::ExternalService(format!("Invalid initialize result: {}", e))
})
})?;
if let Some(ref session_manager) = self.session_manager {
session_manager.mark_initialized(&self.server_name).await;
}
let notification = McpRequest::initialized_notification();
if let Err(e) = self
.transport
.send(&notification, &self.build_request_headers().await?)
.await
{
tracing::debug!(
"Failed to send initialized notification to '{}': {}",
self.server_name,
e
);
}
Ok(init_result)
}
/// Return true when the error looks like a recoverable MCP session expiry.
fn is_session_expiry_error(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("session")
&& (lower.contains("400")
|| lower.contains("missing session id")
|| lower.contains("no valid session id"))
}
/// Send a request to the MCP server with auth and session headers.
/// Automatically attempts token refresh on 401 errors (HTTP transports only).
async fn send_request(&self, request: McpRequest) -> Result<McpResponse, ToolError> {
@@ -297,13 +362,26 @@ impl McpClient {
return self.transport.send(&request, &headers).await;
}
// HTTP transport: try up to 2 times (first attempt, then retry after token refresh)
// HTTP transport: try up to 2 times (first attempt, then retry after token refresh
// or recoverable session reinitialization).
for attempt in 0..2 {
let headers = self.build_request_headers().await?;
let result = self.transport.send(&request, &headers).await;
match result {
Ok(response) => return Ok(response),
Err(ToolError::ExternalService(ref msg))
if attempt == 0
&& self.session_manager.is_some()
&& Self::is_session_expiry_error(msg) =>
{
tracing::debug!(
"MCP session expired, attempting reinitialize for '{}'",
self.server_name
);
self.reinitialize_session().await?;
continue;
}
Err(ToolError::ExternalService(ref msg))
if msg.contains("401")
|| msg.contains("Unauthorized")
@@ -362,47 +440,7 @@ impl McpClient {
{
return Ok(InitializeResult::default());
}
if let Some(ref session_manager) = self.session_manager {
session_manager
.get_or_create(&self.server_name, &self.server_url)
.await;
}
let request = McpRequest::initialize(self.next_request_id());
let response = self.send_request(request).await?;
if let Some(error) = response.error {
return Err(ToolError::ExternalService(format!(
"MCP initialization error: {} (code {})",
error.message, error.code
)));
}
let init_result: InitializeResult = response
.result
.ok_or_else(|| {
ToolError::ExternalService("No result in initialize response".to_string())
})
.and_then(|r| {
serde_json::from_value(r).map_err(|e| {
ToolError::ExternalService(format!("Invalid initialize result: {}", e))
})
})?;
if let Some(ref session_manager) = self.session_manager {
session_manager.mark_initialized(&self.server_name).await;
}
let notification = McpRequest::initialized_notification();
if let Err(e) = self.send_request(notification).await {
tracing::debug!(
"Failed to send initialized notification to '{}': {}",
self.server_name,
e
);
}
Ok(init_result)
self.reinitialize_session().await
})
.await?;
@@ -865,6 +903,54 @@ mod tests {
}
}
/// Mock transport that can return errors and successful responses in a
/// controlled sequence.
struct RetryMockTransport {
supports_http: bool,
outcomes: std::sync::Mutex<std::collections::VecDeque<Result<McpResponse, ToolError>>>,
recorded_headers: std::sync::Mutex<Vec<HashMap<String, String>>>,
}
impl RetryMockTransport {
fn new(supports_http: bool, outcomes: Vec<Result<McpResponse, ToolError>>) -> Self {
Self {
supports_http,
outcomes: std::sync::Mutex::new(outcomes.into()),
recorded_headers: std::sync::Mutex::new(Vec::new()),
}
}
fn recorded_headers(&self) -> Vec<HashMap<String, String>> {
self.recorded_headers.lock().unwrap().clone()
}
}
#[async_trait]
impl McpTransport for RetryMockTransport {
async fn send(
&self,
_request: &McpRequest,
headers: &HashMap<String, String>,
) -> Result<McpResponse, ToolError> {
self.recorded_headers.lock().unwrap().push(headers.clone());
let mut outcomes = self.outcomes.lock().unwrap();
if outcomes.is_empty() {
return Err(ToolError::ExternalService(
"No more mock outcomes".to_string(),
));
}
outcomes.pop_front().unwrap()
}
async fn shutdown(&self) -> Result<(), ToolError> {
Ok(())
}
fn supports_http_features(&self) -> bool {
self.supports_http
}
}
#[tokio::test]
async fn test_non_http_transport_skips_401_retry() {
// initialize response, then notification ack (consumed but ignored),
@@ -965,6 +1051,83 @@ mod tests {
assert_eq!(transport.recorded_headers().len(), 2); // no additional sends
}
#[tokio::test]
async fn test_http_session_error_triggers_reinitialize_and_retry() {
let init_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(1),
result: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "test", "version": "1.0"}
})),
error: None,
};
let notification_ack = McpResponse {
jsonrpc: "2.0".to_string(),
id: None,
result: None,
error: None,
};
let notification_ack2 = notification_ack.clone();
let session_error = Err(ToolError::ExternalService(
"[test] MCP server returned status: 400 - No valid session ID provided".to_string(),
));
let reinit_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(2),
result: Some(serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "test", "version": "1.0"}
})),
error: None,
};
let call_response = McpResponse {
jsonrpc: "2.0".to_string(),
id: Some(3),
result: Some(serde_json::json!({
"content": [{"type": "text", "text": "pong"}],
"is_error": false
})),
error: None,
};
let transport = Arc::new(RetryMockTransport::new(
true,
vec![
Ok(init_response),
Ok(notification_ack),
session_error,
Ok(reinit_response),
Ok(notification_ack2),
Ok(call_response),
],
));
let session_manager = Arc::new(McpSessionManager::new());
let client = McpClient::new_with_transport(
"test-http",
transport.clone(),
Some(session_manager),
None,
"default",
None,
);
client.initialize().await.expect("initial handshake");
let result = client
.call_tool("echo", serde_json::json!({"input": "hello"}))
.await
.expect("call should recover after session expiry");
assert!(!result.is_error);
assert_eq!(result.content.len(), 1);
assert_eq!(result.content[0].as_text(), Some("pong"));
let headers = transport.recorded_headers();
assert_eq!(headers.len(), 6);
}
#[test]
fn test_strip_top_level_nulls_removes_null_fields() {
let input = serde_json::json!({
+87 -27
View File
@@ -13,7 +13,9 @@ use crate::orchestrator::job_manager::ContainerJobManager;
use crate::secrets::SecretsStore;
use crate::skills::catalog::SkillCatalog;
use crate::skills::registry::SkillRegistry;
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
use crate::tools::builder::{
BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder,
};
use crate::tools::builtin::{
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
@@ -94,6 +96,15 @@ pub struct ToolRegistry {
}
impl ToolRegistry {
fn tool_definition(tool: &Arc<dyn Tool>) -> ToolDefinition {
let schema = tool.schema();
ToolDefinition {
name: schema.name,
description: schema.description,
parameters: schema.parameters,
}
}
/// Create a new empty registry.
pub fn new() -> Self {
Self {
@@ -206,11 +217,7 @@ impl ToolRegistry {
.read()
.await
.values()
.map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.map(Self::tool_definition)
.collect();
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
defs
@@ -221,13 +228,7 @@ impl ToolRegistry {
let tools = self.tools.read().await;
names
.iter()
.filter_map(|name| {
tools.get(*name).map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
})
.filter_map(|name| tools.get(*name).map(Self::tool_definition))
.collect()
}
@@ -282,11 +283,7 @@ impl ToolRegistry {
.await
.values()
.filter(|tool| tool.domain() == domain)
.map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.map(Self::tool_definition)
.collect()
}
@@ -312,11 +309,7 @@ impl ToolRegistry {
ApprovalRequirement::Never
)
})
.map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.map(Self::tool_definition)
.collect();
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
defs
@@ -585,22 +578,23 @@ impl ToolRegistry {
self: &Arc<Self>,
llm: Arc<dyn LlmProvider>,
config: Option<BuilderConfig>,
) {
) -> Arc<dyn SoftwareBuilder> {
// First register dev tools needed by the builder
self.register_dev_tools();
// Create the builder (arg order: config, llm, tools)
let builder = Arc::new(LlmSoftwareBuilder::new(
let builder: Arc<dyn SoftwareBuilder> = Arc::new(LlmSoftwareBuilder::new(
config.unwrap_or_default(),
llm,
Arc::clone(self),
));
// Register the build_software tool
self.register(Arc::new(BuildSoftwareTool::new(builder)))
self.register(Arc::new(BuildSoftwareTool::new(Arc::clone(&builder))))
.await;
tracing::debug!("Registered software builder tool");
tracing::info!("Registered software builder tool");
builder
}
/// Register a WASM tool from bytes.
@@ -788,6 +782,7 @@ impl std::fmt::Debug for ToolRegistry {
mod tests {
use super::*;
use crate::tools::registry::EchoTool;
use crate::tools::tool::ToolDiscoverySummary;
#[tokio::test]
async fn test_register_and_get() {
@@ -818,6 +813,71 @@ mod tests {
assert_eq!(defs[0].name, "echo");
}
#[tokio::test]
async fn test_tool_definitions_use_tool_schema() {
struct DiscoveryTool;
#[async_trait::async_trait]
impl Tool for DiscoveryTool {
fn name(&self) -> &str {
"discovery_tool"
}
fn description(&self) -> &str {
"Discovery test tool"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" }
}
})
}
fn discovery_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"extra": { "type": "string" }
}
})
}
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
Some(ToolDiscoverySummary {
notes: vec!["extra guidance".into()],
..ToolDiscoverySummary::default()
})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &crate::context::JobContext,
) -> Result<crate::tools::tool::ToolOutput, crate::tools::tool::ToolError> {
unreachable!()
}
}
let registry = ToolRegistry::new();
registry.register(Arc::new(DiscoveryTool)).await;
let defs = registry.tool_definitions().await;
let def = defs
.iter()
.find(|def| def.name == "discovery_tool")
.expect("tool definition should be present");
assert!(
def.description.contains("tool_info"),
"live tool definition should include schema hint: {}",
def.description
);
assert!(def.parameters.get("extra").is_none());
}
#[tokio::test]
async fn test_builtin_tool_cannot_be_shadowed() {
let registry = ToolRegistry::new();
+1 -9
View File
@@ -605,15 +605,7 @@ mod tests {
),
(
"event_emit",
serde_json::json!({
"type": "object",
"properties": {
"event_source": { "type": "string", "description": "Event source" },
"event_type": { "type": "string", "description": "Event type" },
"payload": { "type": "object", "description": "Event payload", "properties": {} }
},
"required": ["event_source", "event_type"]
}),
crate::tools::builtin::routine::event_emit_parameters_schema(),
),
// Job tools with complex deps
(
+35 -2
View File
@@ -231,6 +231,19 @@ impl ToolSchema {
}
}
/// Curated discovery guidance surfaced by `tool_info(detail: "summary")`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ToolDiscoverySummary {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub always_required: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conditional_requirements: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<serde_json::Value>,
}
/// Trait for tools that the agent can use.
#[async_trait]
pub trait Tool: Send + Sync {
@@ -347,12 +360,32 @@ pub trait Tool: Send + Sync {
self.parameters_schema()
}
/// Curated discovery guidance used by `tool_info(detail: "summary")`.
///
/// Default: no custom summary; callers may derive a minimal fallback from
/// `discovery_schema()`.
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
None
}
/// Get the tool schema for LLM function calling.
fn schema(&self) -> ToolSchema {
let parameters = self.parameters_schema();
let has_discovery_hint =
self.discovery_summary().is_some() || self.discovery_schema() != parameters;
let description = if has_discovery_hint {
format!(
"{} (call tool_info(name: \"{}\", detail: \"summary\") for rules/examples or detail: \"schema\" for the full discovery schema)",
self.description(),
self.name()
)
} else {
self.description().to_string()
};
ToolSchema {
name: self.name().to_string(),
description: self.description().to_string(),
parameters: self.parameters_schema(),
description,
parameters,
}
}
}
+149 -28
View File
@@ -196,6 +196,7 @@ impl Worker {
.get("session_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
fallback_deliverable: data.get("fallback_deliverable").cloned(),
}),
_ => None,
};
@@ -960,9 +961,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
async fn mark_failed(&self, reason: &str) -> Result<(), Error> {
// Build fallback deliverable from memory before transitioning.
let fallback = self.build_fallback(reason).await;
self.context_manager()
.update_context(self.job_id, |ctx| {
ctx.transition_to(JobState::Failed, Some(reason.to_string()))
ctx.transition_to(JobState::Failed, Some(reason.to_string()))?;
store_fallback_in_metadata(ctx, fallback.as_ref());
Ok(())
})
.await?
.map_err(|s| crate::error::JobError::ContextError {
@@ -983,8 +989,15 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
async fn mark_stuck(&self, reason: &str) -> Result<(), Error> {
// Build fallback deliverable from memory before transitioning.
let fallback = self.build_fallback(reason).await;
self.context_manager()
.update_context(self.job_id, |ctx| ctx.mark_stuck(reason))
.update_context(self.job_id, |ctx| {
ctx.mark_stuck(reason)?;
store_fallback_in_metadata(ctx, fallback.as_ref());
Ok(())
})
.await?
.map_err(|s| crate::error::JobError::ContextError {
id: self.job_id,
@@ -1002,6 +1015,57 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
self.persist_status(JobState::Stuck, Some(reason.to_string()));
Ok(())
}
/// Build a [`FallbackDeliverable`] from the current job context and memory.
async fn build_fallback(&self, reason: &str) -> Option<crate::context::FallbackDeliverable> {
let memory = match self.context_manager().get_memory(self.job_id).await {
Ok(memory) => memory,
Err(e) => {
tracing::warn!(
job_id = %self.job_id,
"Failed to load memory while building fallback deliverable: {e}"
);
return None;
}
};
let ctx = match self.context_manager().get_context(self.job_id).await {
Ok(ctx) => ctx,
Err(e) => {
tracing::warn!(
job_id = %self.job_id,
"Failed to load context while building fallback deliverable: {e}"
);
return None;
}
};
Some(crate::context::FallbackDeliverable::build(
&ctx, &memory, reason,
))
}
}
/// Store a fallback deliverable in the job context's metadata.
fn store_fallback_in_metadata(
ctx: &mut crate::context::JobContext,
fallback: Option<&crate::context::FallbackDeliverable>,
) {
let Some(fb) = fallback else {
return;
};
match serde_json::to_value(fb) {
Ok(val) => {
if !ctx.metadata.is_object() {
ctx.metadata = serde_json::json!({});
}
ctx.metadata["fallback_deliverable"] = val;
}
Err(e) => {
tracing::warn!(
"Failed to serialize fallback deliverable for job {}: {e}",
ctx.job_id
);
}
}
}
/// Job delegate: implements `LoopDelegate` for the background job context.
@@ -1440,7 +1504,7 @@ mod tests {
}
let cm = Arc::new(crate::context::ContextManager::new(5));
let job_id = cm.create_job("test", "test job").await.unwrap();
let job_id = cm.create_job("test", "test job").await.unwrap(); // safety: test
let deps = WorkerDeps {
context_manager: cm,
@@ -1472,8 +1536,9 @@ mod tests {
tool_call_id: "call_abc123".to_string(),
};
assert_eq!(selection.tool_call_id, "call_abc123");
assert_eq!(selection.tool_call_id, "call_abc123"); // safety: test
assert_ne!(
/* safety: test */
selection.tool_call_id, "tool_call_id",
"tool_call_id must not be the hardcoded placeholder string"
);
@@ -1509,11 +1574,12 @@ mod tests {
let results = worker.execute_tools_parallel(&selections).await;
let elapsed = start.elapsed();
assert_eq!(results.len(), 3);
assert_eq!(results.len(), 3); // safety: test
for r in &results {
assert!(r.result.is_ok(), "Tool should succeed");
assert!(r.result.is_ok(), "Tool should succeed"); // safety: test
}
assert!(
/* safety: test */
elapsed < Duration::from_millis(800),
"Parallel execution took {:?}, expected < 800ms (sequential would be ~600ms)",
elapsed
@@ -1565,9 +1631,9 @@ mod tests {
let results = worker.execute_tools_parallel(&selections).await;
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"));
assert!(results[0].result.as_ref().unwrap().contains("done_tool_a")); // safety: test
assert!(results[1].result.as_ref().unwrap().contains("done_tool_b")); // safety: test
assert!(results[2].result.as_ref().unwrap().contains("done_tool_c")); // safety: test
}
#[tokio::test]
@@ -1583,8 +1649,9 @@ mod tests {
}];
let results = worker.execute_tools_parallel(&selections).await;
assert_eq!(results.len(), 1);
assert_eq!(results.len(), 1); // safety: test
assert!(
/* safety: test */
results[0].result.is_err(),
"Missing tool should produce an error, not a panic"
);
@@ -1600,23 +1667,24 @@ mod tests {
ctx.transition_to(JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
.unwrap() // safety: test
.unwrap(); // safety: test
worker.mark_completed().await.unwrap();
worker.mark_completed().await.unwrap(); // safety: test
let ctx = worker
.context_manager()
.get_context(worker.job_id)
.await
.unwrap();
assert_eq!(ctx.state, JobState::Completed);
.unwrap(); // safety: test
assert_eq!(ctx.state, JobState::Completed); // safety: test
// Second mark_completed should succeed (idempotent) rather than
// erroring, matching the fix for the execution_loop / worker wrapper
// race condition.
let result = worker.mark_completed().await;
assert!(
/* safety: test */
result.is_ok(),
"Completed -> Completed transition should be idempotent"
);
@@ -1641,7 +1709,7 @@ mod tests {
}
let cm = Arc::new(crate::context::ContextManager::new(5));
let job_id = cm.create_job("test", "test job").await.unwrap();
let job_id = cm.create_job("test", "test job").await.unwrap(); // safety: test
let deps = WorkerDeps {
context_manager: cm,
@@ -1740,6 +1808,7 @@ mod tests {
.execute_tool("needs_approval", &serde_json::json!({}))
.await;
assert!(
/* safety: test */
result.is_err(),
"Should be blocked without approval context"
);
@@ -1752,7 +1821,7 @@ mod tests {
let result = worker_allowed
.execute_tool("needs_approval", &serde_json::json!({}))
.await;
assert!(result.is_ok(), "Should be allowed with autonomous context");
assert!(result.is_ok(), "Should be allowed with autonomous context"); // safety: test
}
#[tokio::test]
@@ -1766,6 +1835,7 @@ mod tests {
.execute_tool("always_approval", &serde_json::json!({}))
.await;
assert!(
/* safety: test */
result.is_err(),
"Always tool should be blocked without permission"
);
@@ -1781,6 +1851,7 @@ mod tests {
.execute_tool("always_approval", &serde_json::json!({}))
.await;
assert!(
/* safety: test */
result.is_ok(),
"Always tool should be allowed with permission"
);
@@ -1797,8 +1868,8 @@ mod tests {
ctx.transition_to(JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
.unwrap() // safety: test
.unwrap(); // safety: test
// Set a token budget
worker
@@ -1807,16 +1878,17 @@ mod tests {
ctx.max_tokens = 100;
})
.await
.unwrap();
.unwrap(); // safety: test
// Simulate adding tokens that exceed the budget
let budget_result = worker
.context_manager()
.update_context(worker.job_id, |ctx| ctx.add_tokens(200))
.await
.unwrap();
.unwrap(); // safety: test
assert!(
/* safety: test */
budget_result.is_err(),
"Should return error when token budget exceeded"
);
@@ -1825,13 +1897,13 @@ mod tests {
worker
.mark_failed(&budget_result.unwrap_err().to_string())
.await
.unwrap();
.unwrap(); // safety: test
let ctx = worker
.context_manager()
.get_context(worker.job_id)
.await
.unwrap();
assert_eq!(ctx.state, JobState::Failed);
.unwrap(); // safety: test
assert_eq!(ctx.state, JobState::Failed); // safety: test
}
#[tokio::test]
@@ -1845,21 +1917,22 @@ mod tests {
ctx.transition_to(JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
.unwrap() // safety: test
.unwrap(); // safety: test
// Simulate what the execution loop does when max_iterations is exceeded
worker
.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
.await
.unwrap();
.unwrap(); // safety: test
let ctx = worker
.context_manager()
.get_context(worker.job_id)
.await
.unwrap();
.unwrap(); // safety: test
assert_eq!(
/* safety: test */
ctx.state,
JobState::Failed,
"Iteration cap should transition to Failed, not Stuck"
@@ -1989,4 +2062,52 @@ mod tests {
"Should skip empty first reasoning and return the first non-empty one"
);
}
#[test]
fn test_store_fallback_in_metadata_roundtrip() {
use crate::context::FallbackDeliverable;
let mut ctx = JobContext::new("Test", "fallback roundtrip");
let memory = crate::context::Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "test failure");
// Store into metadata
store_fallback_in_metadata(&mut ctx, Some(&fb));
// Verify it's stored and can be deserialized back
let stored = ctx.metadata.get("fallback_deliverable");
assert!(stored.is_some(), "fallback missing from metadata"); // safety: test
let recovered: FallbackDeliverable =
serde_json::from_value(stored.unwrap().clone()).expect("deserialize fallback"); // safety: test
assert_eq!(recovered.failure_reason, "test failure"); // safety: test
assert!(!recovered.partial); // safety: test
}
#[test]
fn test_store_fallback_handles_non_object_metadata() {
use crate::context::FallbackDeliverable;
let mut ctx = JobContext::new("Test", "non-object metadata");
ctx.metadata = serde_json::json!("not an object");
let memory = crate::context::Memory::new(ctx.job_id);
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
store_fallback_in_metadata(&mut ctx, Some(&fb));
// Must normalize to object and store
assert!(ctx.metadata.is_object()); // safety: test
assert!(ctx.metadata.get("fallback_deliverable").is_some()); // safety: test
}
#[test]
fn test_store_fallback_none_is_noop() {
let mut ctx = JobContext::new("Test", "noop");
let original = ctx.metadata.clone();
store_fallback_in_metadata(&mut ctx, None);
assert_eq!(ctx.metadata, original); // safety: test
}
}
+6 -1
View File
@@ -38,12 +38,17 @@ workspace/
## Using the Workspace
```rust
use std::sync::Arc;
use crate::workspace::{Workspace, OpenAiEmbeddings, paths};
// Create workspace for a user
// Create workspace for a user (wraps embeddings in a default LRU cache)
let workspace = Workspace::new("user_123", pool)
.with_embeddings(Arc::new(OpenAiEmbeddings::new(api_key)));
// For tests: skip the cache layer (avoids unnecessary overhead with mocks)
// let workspace = Workspace::new("user_123", pool)
// .with_embeddings_uncached(Arc::new(MockEmbeddings::new(1536)));
// Read/write any path
let doc = workspace.read("projects/alpha/notes.md").await?;
workspace.write("context/priorities.md", "# Priorities\n\n1. Feature X").await?;
+613
View File
@@ -0,0 +1,613 @@
//! LRU embedding cache wrapping any [`EmbeddingProvider`].
//!
//! Avoids redundant HTTP calls for identical texts by caching embeddings
//! in memory keyed by `SHA-256(model_name + "\0" + text)`.
//!
//! Follows the same cache pattern as `llm::response_cache::CachedProvider`:
//! `HashMap` + `last_accessed` tracking + manual LRU eviction.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use crate::workspace::embeddings::{EmbeddingError, EmbeddingProvider};
/// Configuration for the embedding cache.
#[derive(Debug, Clone)]
pub struct EmbeddingCacheConfig {
/// Maximum number of cached embeddings (default 10,000).
///
/// Approximate raw embedding payload: `max_entries × dimension × 4 bytes`.
/// At 10,000 entries × 1536 floats ≈ 58 MB (payload only; actual memory
/// is higher due to HashMap buckets, `[u8; 32]` hash keys, `Vec`/`Instant`
/// per-entry overhead).
pub max_entries: usize,
}
impl Default for EmbeddingCacheConfig {
fn default() -> Self {
Self {
max_entries: crate::config::DEFAULT_EMBEDDING_CACHE_SIZE,
}
}
}
struct CacheEntry {
embedding: Vec<f32>,
last_accessed: Instant,
}
/// Embedding provider wrapper that caches results in memory.
///
/// Thread-safe via `std::sync::Mutex`. The lock is **never held**
/// across `.await` points (all critical sections are scoped blocks),
/// so a synchronous mutex is cheaper than `tokio::sync::Mutex`.
pub struct CachedEmbeddingProvider {
inner: Arc<dyn EmbeddingProvider>,
cache: Mutex<HashMap<[u8; 32], CacheEntry>>,
config: EmbeddingCacheConfig,
}
impl CachedEmbeddingProvider {
/// Wrap a provider with LRU caching.
///
/// `config.max_entries` is clamped to at least 1.
pub fn new(inner: Arc<dyn EmbeddingProvider>, config: EmbeddingCacheConfig) -> Self {
let config = EmbeddingCacheConfig {
max_entries: config.max_entries.max(1),
};
if config.max_entries > 100_000 {
tracing::warn!(
max_entries = config.max_entries,
"Embedding cache size exceeds 100,000 entries; memory usage may be significant"
);
}
Self {
inner,
cache: Mutex::new(HashMap::with_capacity(config.max_entries.min(1024))),
config,
}
}
/// Number of entries currently in the cache.
pub fn len(&self) -> usize {
self.cache.lock().unwrap_or_else(|e| e.into_inner()).len()
}
/// Whether the cache is empty.
pub fn is_empty(&self) -> bool {
self.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
}
/// Clear all cached entries.
pub fn clear(&self) {
self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
/// Build a deterministic cache key: `SHA-256(model_name + "\0" + text)`.
///
/// Returns raw 32-byte hash to avoid a 64-char hex String allocation per lookup.
fn cache_key(&self, text: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(self.inner.model_name().as_bytes());
hasher.update(b"\0");
hasher.update(text.as_bytes());
hasher.finalize().into()
}
/// Evict the least-recently-used entry if at capacity (single-entry path).
// TODO: O(n) scan per eviction. If max_entries grows large, switch to
// an ordered data structure (e.g. `IndexMap` with swap_remove, or a
// linked-list LRU like the `lru` crate).
fn evict_lru(cache: &mut HashMap<[u8; 32], CacheEntry>, max_entries: usize) {
while cache.len() >= max_entries {
let oldest_key = cache
.iter()
.min_by_key(|(_, entry)| entry.last_accessed)
.map(|(k, _)| *k);
if let Some(k) = oldest_key {
cache.remove(&k);
} else {
break;
}
}
}
/// Evict the `k` oldest entries in O(n) average time via partial selection.
///
/// Used by `embed_batch` to avoid the O(n×m) cost of calling
/// `evict_lru` per insert.
fn evict_k_oldest(cache: &mut HashMap<[u8; 32], CacheEntry>, k: usize) {
if k == 0 || cache.is_empty() {
return;
}
if k >= cache.len() {
cache.clear();
return;
}
// Partial selection: find the k oldest in O(n) average via
// select_nth_unstable_by_key, then remove the first k entries.
let mut entries: Vec<([u8; 32], Instant)> = cache
.iter()
.map(|(key, entry)| (*key, entry.last_accessed))
.collect();
entries.select_nth_unstable_by_key(k - 1, |(_, t)| *t);
for (key, _) in entries.into_iter().take(k) {
cache.remove(&key);
}
}
}
#[async_trait]
impl EmbeddingProvider for CachedEmbeddingProvider {
fn dimension(&self) -> usize {
self.inner.dimension()
}
fn model_name(&self) -> &str {
self.inner.model_name()
}
fn max_input_length(&self) -> usize {
self.inner.max_input_length()
}
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
let key = self.cache_key(text);
// Check cache (short critical section)
{
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.get_mut(&key) {
entry.last_accessed = Instant::now();
tracing::trace!("embedding cache hit");
return Ok(entry.embedding.clone());
}
}
// Lock released before HTTP call.
// NOTE: Thundering herd — multiple concurrent callers with the same
// uncached key will each call the inner provider. This is acceptable:
// embeddings are idempotent and the last writer wins in the HashMap.
let embedding = self.inner.embed(text).await?;
// Store result. Re-check under lock: another concurrent caller may
// have inserted this key while the lock was released for the HTTP call.
{
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.get_mut(&key) {
// Key already present (thundering herd) — just update, no eviction needed.
entry.embedding = embedding.clone();
entry.last_accessed = Instant::now();
} else {
Self::evict_lru(&mut guard, self.config.max_entries);
guard.insert(
key,
CacheEntry {
embedding: embedding.clone(),
last_accessed: Instant::now(),
},
);
}
}
tracing::trace!("embedding cache miss");
Ok(embedding)
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
if texts.is_empty() {
return Ok(Vec::new());
}
// Partition into hits and misses
let keys: Vec<[u8; 32]> = texts.iter().map(|t| self.cache_key(t)).collect();
let mut results: Vec<Option<Vec<f32>>> = vec![None; texts.len()];
let mut miss_indices: Vec<usize> = Vec::new();
{
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
for (i, key) in keys.iter().enumerate() {
if let Some(entry) = guard.get_mut(key) {
entry.last_accessed = now;
results[i] = Some(entry.embedding.clone());
} else {
miss_indices.push(i);
}
}
}
// Lock released before HTTP call
if miss_indices.is_empty() {
tracing::trace!(count = texts.len(), "embedding batch: all cache hits");
// All slots populated from cache hits
return results
.into_iter()
.enumerate()
.map(|(i, slot)| {
slot.ok_or_else(|| {
EmbeddingError::InvalidResponse(format!(
"embedding slot {i} was not populated"
))
})
})
.collect::<Result<Vec<_>, _>>();
}
// Fetch missing embeddings
let miss_texts: Vec<String> = miss_indices.iter().map(|&i| texts[i].clone()).collect();
let new_embeddings = self.inner.embed_batch(&miss_texts).await?;
if new_embeddings.len() != miss_indices.len() {
return Err(EmbeddingError::InvalidResponse(format!(
"embed_batch returned {} embeddings, expected {}",
new_embeddings.len(),
miss_indices.len()
)));
}
tracing::trace!(
hits = texts.len() - miss_indices.len(),
misses = miss_indices.len(),
"embedding batch: partial cache"
);
// Assemble results first (all misses, regardless of cache capacity).
for (orig_idx, emb) in miss_indices.iter().copied().zip(&new_embeddings) {
results[orig_idx] = Some(emb.clone());
}
// Cache the new embeddings, respecting max_entries.
{
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
// When misses exceed capacity, clear and only cache the tail.
let cacheable = miss_indices.len().min(self.config.max_entries);
let skip = miss_indices.len() - cacheable;
let need_to_evict = (guard.len() + cacheable).saturating_sub(self.config.max_entries);
if need_to_evict > 0 {
Self::evict_k_oldest(&mut guard, need_to_evict);
}
let now = Instant::now();
for (&orig_idx, emb) in miss_indices[skip..].iter().zip(&new_embeddings[skip..]) {
guard.insert(
keys[orig_idx],
CacheEntry {
embedding: emb.clone(),
last_accessed: now,
},
);
}
}
results
.into_iter()
.enumerate()
.map(|(i, slot)| {
slot.ok_or_else(|| {
EmbeddingError::InvalidResponse(format!("embedding slot {i} was not populated"))
})
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
/// Mock embedding provider that counts calls.
struct CountingMock {
dimension: usize,
model: String,
embed_calls: AtomicU32,
batch_calls: AtomicU32,
}
impl CountingMock {
fn new(dimension: usize, model: &str) -> Self {
Self {
dimension,
model: model.to_string(),
embed_calls: AtomicU32::new(0),
batch_calls: AtomicU32::new(0),
}
}
fn embed_calls(&self) -> u32 {
self.embed_calls.load(Ordering::SeqCst)
}
fn batch_calls(&self) -> u32 {
self.batch_calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl EmbeddingProvider for CountingMock {
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
&self.model
}
fn max_input_length(&self) -> usize {
10_000
}
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
self.embed_calls.fetch_add(1, Ordering::SeqCst);
// Simple deterministic embedding: val = text.len() / 100.0
let val = text.len() as f32 / 100.0;
Ok(vec![val; self.dimension])
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
self.batch_calls.fetch_add(1, Ordering::SeqCst);
texts
.iter()
.map(|t| {
let val = t.len() as f32 / 100.0;
Ok(vec![val; self.dimension])
})
.collect()
}
}
#[tokio::test]
async fn cache_hit_avoids_inner_call() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
let r1 = cached.embed("hello").await.unwrap();
assert_eq!(inner.embed_calls(), 1);
let r2 = cached.embed("hello").await.unwrap();
assert_eq!(inner.embed_calls(), 1); // still 1 -- cache hit
assert_eq!(r1, r2);
assert_eq!(cached.len(), 1);
}
#[tokio::test]
async fn cache_miss_calls_inner() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
cached.embed("hello").await.unwrap();
cached.embed("world").await.unwrap();
assert_eq!(inner.embed_calls(), 2);
assert_eq!(cached.len(), 2);
}
#[tokio::test]
async fn cache_key_includes_model() {
let inner_a = Arc::new(CountingMock::new(4, "model-a"));
let inner_b = Arc::new(CountingMock::new(4, "model-b"));
let cached_a = CachedEmbeddingProvider::new(
inner_a.clone(),
EmbeddingCacheConfig { max_entries: 100 },
);
let cached_b = CachedEmbeddingProvider::new(
inner_b.clone(),
EmbeddingCacheConfig { max_entries: 100 },
);
// Same text, different models -> different cache keys
let key_a = cached_a.cache_key("hello");
let key_b = cached_b.cache_key("hello");
assert_ne!(key_a, key_b);
}
#[tokio::test]
async fn lru_eviction() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 2 });
cached.embed("first").await.unwrap();
cached.embed("second").await.unwrap();
assert_eq!(cached.len(), 2);
// Third entry should evict the oldest ("first")
cached.embed("third").await.unwrap();
assert_eq!(cached.len(), 2);
assert_eq!(inner.embed_calls(), 3);
// "first" should be a cache miss now
cached.embed("first").await.unwrap();
assert_eq!(inner.embed_calls(), 4);
}
#[tokio::test]
async fn embed_batch_partial_hits() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
// Pre-cache one text
cached.embed("cached").await.unwrap();
assert_eq!(inner.embed_calls(), 1);
// Batch with 1 cached + 2 new
let texts = vec![
"cached".to_string(),
"new_one".to_string(),
"new_two".to_string(),
];
let results = cached.embed_batch(&texts).await.unwrap();
// Should have called embed_batch on inner for 2 misses
assert_eq!(inner.batch_calls(), 1);
assert_eq!(results.len(), 3);
assert_eq!(cached.len(), 3);
}
#[tokio::test]
async fn batch_preserves_order() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
// Pre-cache "bb" (len 2)
cached.embed("bb").await.unwrap();
// Batch: "a" (miss, len 1), "bb" (hit, len 2), "ccc" (miss, len 3)
let texts = vec!["a".to_string(), "bb".to_string(), "ccc".to_string()];
let results = cached.embed_batch(&texts).await.unwrap();
assert_eq!(results.len(), 3);
let expected_a = vec![1.0_f32 / 100.0; 4];
let expected_bb = vec![2.0_f32 / 100.0; 4];
let expected_ccc = vec![3.0_f32 / 100.0; 4];
assert_eq!(results[0], expected_a);
assert_eq!(results[1], expected_bb);
assert_eq!(results[2], expected_ccc);
}
#[tokio::test]
async fn batch_exceeding_capacity_respects_max_entries() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 3 });
// Batch with 5 misses but cache capacity is 3
let texts: Vec<String> = (0..5).map(|i| format!("text_{i}")).collect();
let results = cached.embed_batch(&texts).await.unwrap();
assert_eq!(results.len(), 5);
let len = cached.len();
assert!(len <= 3, "cache len {len} exceeds max 3");
}
/// Mock embedding provider that fails the first N calls, then succeeds.
struct FailThenSucceedMock {
dimension: usize,
model: String,
remaining_failures: AtomicU32,
}
impl FailThenSucceedMock {
fn new(dimension: usize, fail_count: u32) -> Self {
Self {
dimension,
model: "fail-mock".to_string(),
remaining_failures: AtomicU32::new(fail_count),
}
}
}
#[async_trait]
impl EmbeddingProvider for FailThenSucceedMock {
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
&self.model
}
fn max_input_length(&self) -> usize {
10_000
}
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
let prev =
self.remaining_failures
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| {
if v > 0 { Some(v - 1) } else { None }
});
if prev.is_ok() {
return Err(EmbeddingError::HttpError("simulated failure".to_string()));
}
let val = text.len() as f32 / 100.0;
Ok(vec![val; self.dimension])
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
let prev =
self.remaining_failures
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| {
if v > 0 { Some(v - 1) } else { None }
});
if prev.is_ok() {
return Err(EmbeddingError::HttpError("simulated failure".to_string()));
}
texts
.iter()
.map(|t| {
let val = t.len() as f32 / 100.0;
Ok(vec![val; self.dimension])
})
.collect()
}
}
#[tokio::test]
async fn error_does_not_pollute_cache() {
let inner = Arc::new(FailThenSucceedMock::new(4, 1));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
// First call fails
let err = cached.embed("hello").await;
assert!(err.is_err());
assert!(cached.is_empty(), "cache should be empty after error");
// Second call succeeds and should call the inner provider (not serve stale error)
let result = cached.embed("hello").await;
assert!(result.is_ok());
assert_eq!(cached.len(), 1);
}
#[tokio::test]
async fn embed_batch_empty_input() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
let results = cached.embed_batch(&[]).await.unwrap();
assert!(results.is_empty());
assert_eq!(inner.batch_calls(), 0);
}
#[tokio::test]
async fn embed_batch_all_misses() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 100 });
// Nothing cached — every text is a miss
let texts: Vec<String> = vec!["alpha".into(), "beta".into(), "gamma".into()];
let results = cached.embed_batch(&texts).await.unwrap();
assert_eq!(results.len(), 3);
assert_eq!(inner.batch_calls(), 1, "inner called once for misses");
assert_eq!(cached.len(), 3, "all results should be cached");
// Second call should be all hits — no new inner calls
let results2 = cached.embed_batch(&texts).await.unwrap();
assert_eq!(results2.len(), 3);
assert_eq!(inner.batch_calls(), 1, "no new inner calls");
}
#[tokio::test]
async fn zero_max_entries_clamped_to_one() {
let inner = Arc::new(CountingMock::new(4, "test-model"));
let cached =
CachedEmbeddingProvider::new(inner.clone(), EmbeddingCacheConfig { max_entries: 0 });
// Should behave as max_entries=1 (clamped in constructor)
cached.embed("hello").await.unwrap();
assert_eq!(cached.len(), 1);
// Second entry evicts the first
cached.embed("world").await.unwrap();
assert_eq!(cached.len(), 1);
assert_eq!(inner.embed_calls(), 2);
}
}
+5
View File
@@ -6,6 +6,8 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::llm::retry::cap_retry_after;
/// Error type for embedding operations.
#[derive(Debug, thiserror::Error)]
pub enum EmbeddingError {
@@ -232,6 +234,7 @@ impl EmbeddingProvider for OpenAiEmbeddings {
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
return Err(EmbeddingError::RateLimited { retry_after });
}
@@ -374,6 +377,7 @@ impl EmbeddingProvider for NearAiEmbeddings {
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
return Err(EmbeddingError::RateLimited { retry_after });
}
@@ -690,6 +694,7 @@ mod tests {
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}
+28
View File
@@ -42,6 +42,7 @@
mod chunker;
mod document;
mod embedding_cache;
mod embeddings;
pub mod hygiene;
#[cfg(feature = "postgres")]
@@ -50,6 +51,7 @@ mod search;
pub use chunker::{ChunkConfig, chunk_document};
pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
pub use embedding_cache::{CachedEmbeddingProvider, EmbeddingCacheConfig};
pub use embeddings::{
EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings,
};
@@ -371,7 +373,33 @@ impl Workspace {
}
/// Set the embedding provider for semantic search.
///
/// The provider is automatically wrapped in a [`CachedEmbeddingProvider`]
/// with the default cache size (10,000 entries; payload ~58 MB for 1536-dim,
/// actual memory higher due to per-entry overhead).
pub fn with_embeddings(mut self, provider: Arc<dyn EmbeddingProvider>) -> Self {
self.embeddings = Some(Arc::new(CachedEmbeddingProvider::new(
provider,
EmbeddingCacheConfig::default(),
)));
self
}
/// Set the embedding provider with a custom cache configuration.
pub fn with_embeddings_cached(
mut self,
provider: Arc<dyn EmbeddingProvider>,
cache_config: EmbeddingCacheConfig,
) -> Self {
self.embeddings = Some(Arc::new(CachedEmbeddingProvider::new(
provider,
cache_config,
)));
self
}
/// Set the embedding provider **without** caching (for tests).
pub fn with_embeddings_uncached(mut self, provider: Arc<dyn EmbeddingProvider>) -> Self {
self.embeddings = Some(provider);
self
}
+360
View File
@@ -0,0 +1,360 @@
//! Integration tests for dispatched routine run tracking (#1317).
//!
//! Verifies:
//! 1. list_dispatched_routine_runs returns only running runs with linked jobs
//! 2. Completed jobs cause linked routine runs to be finalized as Ok
//! 3. Failed jobs cause linked routine runs to be finalized as Failed
//! 4. Active (InProgress) jobs are not finalized
//! 5. Orphaned runs (job_id set but no job record) are handled
#[cfg(feature = "libsql")]
mod tests {
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use ironclaw::agent::routine::{
Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
};
use ironclaw::context::{JobContext, JobState};
use ironclaw::db::Database;
async fn create_test_db() -> (Arc<dyn Database>, tempfile::TempDir) {
use ironclaw::db::libsql::LibSqlBackend;
let temp_dir = tempfile::tempdir().expect("tempdir");
let db_path = temp_dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path)
.await
.expect("LibSqlBackend");
backend.run_migrations().await.expect("migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, temp_dir)
}
fn make_routine(id: Uuid) -> Routine {
Routine {
id,
name: format!("test-routine-{}", id),
description: "Test routine".to_string(),
user_id: "default".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
title: "Test job".to_string(),
description: "Test description".to_string(),
max_iterations: 5,
tool_permissions: vec![],
},
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: Default::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn make_run(routine_id: Uuid, job_id: Option<Uuid>) -> RoutineRun {
RoutineRun {
id: Uuid::new_v4(),
routine_id,
trigger_type: "manual".to_string(),
trigger_detail: None,
started_at: Utc::now(),
completed_at: None,
status: RunStatus::Running,
result_summary: None,
tokens_used: None,
job_id,
created_at: Utc::now(),
}
}
// -----------------------------------------------------------------------
// Test 1: list_dispatched_routine_runs returns only running runs with jobs
// -----------------------------------------------------------------------
#[tokio::test]
async fn list_dispatched_returns_only_running_with_job_id() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create jobs first (FK constraint requires job records to exist)
let job1 = JobContext::new("Job 1", "Dispatched job");
db.save_job(&job1).await.expect("save job1");
let job2 = JobContext::new("Job 2", "Completed job");
db.save_job(&job2).await.expect("save job2");
// Create a running run WITH job_id (dispatched full_job)
let dispatched_run = make_run(routine_id, Some(job1.job_id));
db.create_routine_run(&dispatched_run)
.await
.expect("create dispatched run");
// Create a running run WITHOUT job_id (lightweight in-progress)
let lightweight_run = make_run(routine_id, None);
db.create_routine_run(&lightweight_run)
.await
.expect("create lightweight run");
// Create a completed run WITH job_id (already finalized)
let mut completed_run = make_run(routine_id, Some(job2.job_id));
completed_run.status = RunStatus::Ok;
completed_run.completed_at = Some(Utc::now());
db.create_routine_run(&completed_run)
.await
.expect("create completed run");
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1, "Should return only the dispatched run");
assert_eq!(dispatched[0].id, dispatched_run.id);
assert_eq!(dispatched[0].job_id, Some(job1.job_id));
assert_eq!(dispatched[0].status, RunStatus::Running);
}
// -----------------------------------------------------------------------
// Test 2: Completed job linked to run can be detected
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_completed_job_can_be_finalized() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create and save a job in Completed state
let mut job = JobContext::new("Test job", "Test description");
job.state = JobState::Completed;
db.save_job(&job).await.expect("save job");
// Create a dispatched run linked to that job
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify the run is listed as dispatched
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1);
// Verify we can fetch the linked job and see it's completed
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert_eq!(fetched_job.state, JobState::Completed);
// Simulate sync: complete the run
db.complete_routine_run(run.id, RunStatus::Ok, Some("Job completed"), None)
.await
.expect("complete run");
// Run should no longer appear in dispatched list
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after");
assert!(
dispatched_after.is_empty(),
"Finalized run should not appear in dispatched list"
);
}
// -----------------------------------------------------------------------
// Test 3: Failed job causes run to be finalized as Failed
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_failed_job() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
let mut job = JobContext::new("Failing job", "Will fail");
job.state = JobState::Failed;
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify job is failed
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert_eq!(fetched_job.state, JobState::Failed);
// Simulate sync: complete the run as failed
db.complete_routine_run(run.id, RunStatus::Failed, Some("Job failed"), None)
.await
.expect("complete run as failed");
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert!(dispatched.is_empty(), "Failed run should be finalized");
}
// -----------------------------------------------------------------------
// Test 4: Active (InProgress) job leaves run as running
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_with_active_job_stays_running() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
let mut job = JobContext::new("Active job", "Still running");
job.state = JobState::InProgress;
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// Verify job is still active
let fetched_job = db
.get_job(job.job_id)
.await
.expect("get job")
.expect("job should exist");
assert!(!fetched_job.state.is_terminal());
// Run should still be in dispatched list (not finalized)
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(
dispatched.len(),
1,
"Run with active job should remain dispatched"
);
assert_eq!(dispatched[0].status, RunStatus::Running);
}
// -----------------------------------------------------------------------
// Test 5: Orphaned run (job_id set but job record missing)
// -----------------------------------------------------------------------
#[tokio::test]
async fn dispatched_run_orphan_detection() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create a real job so the FK constraint is satisfied
let job = JobContext::new("Will be orphaned", "Test orphan detection");
db.save_job(&job).await.expect("save job");
let run = make_run(routine_id, Some(job.job_id));
db.create_routine_run(&run).await.expect("create run");
// The run appears in dispatched list
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert_eq!(dispatched.len(), 1);
// Verify orphan detection: a random UUID returns None from get_job
let nonexistent_id = Uuid::new_v4();
let missing = db
.get_job(nonexistent_id)
.await
.expect("get_job should not error");
assert!(
missing.is_none(),
"get_job for nonexistent ID should return None"
);
// Simulate sync handling of an orphaned run: mark as failed
db.complete_routine_run(
run.id,
RunStatus::Failed,
Some(&format!("Linked job {} not found (orphaned)", job.job_id)),
None,
)
.await
.expect("complete orphaned run");
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after");
assert!(
dispatched_after.is_empty(),
"Finalized run should not appear in dispatched list"
);
}
// -----------------------------------------------------------------------
// Test 6: link_routine_run_to_job then list shows linked run
// -----------------------------------------------------------------------
#[tokio::test]
async fn link_and_list_dispatched_run() {
let (db, _tmp) = create_test_db().await;
let routine_id = Uuid::new_v4();
let routine = make_routine(routine_id);
db.create_routine(&routine).await.expect("create routine");
// Create job record (FK constraint)
let job = JobContext::new("Linked job", "Test linking");
db.save_job(&job).await.expect("save job");
// Create a running run without job_id initially
let run = make_run(routine_id, None);
db.create_routine_run(&run).await.expect("create run");
// Should not appear in dispatched list yet
let dispatched = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched");
assert!(
dispatched.is_empty(),
"Run without job_id should not be dispatched"
);
// Link the run to the job
db.link_routine_run_to_job(run.id, job.job_id)
.await
.expect("link run to job");
// Now it should appear
let dispatched_after = db
.list_dispatched_routine_runs()
.await
.expect("list dispatched after link");
assert_eq!(
dispatched_after.len(),
1,
"Linked run should appear in dispatched list"
);
assert_eq!(dispatched_after[0].job_id, Some(job.job_id));
}
}
+11 -4
View File
@@ -45,12 +45,13 @@ SEL = {
"approval_always_btn": ".approval-actions button.always",
"approval_deny_btn": ".approval-actions button.deny",
"approval_resolved": ".approval-resolved",
# Extensions tab sections
# Settings subtabs
"settings_subtab": '.settings-subtab[data-settings-subtab="{subtab}"]',
"settings_subpanel": "#settings-{subtab}",
# Extensions section
"extensions_list": "#extensions-list",
"available_wasm_list": "#available-wasm-list",
"mcp_servers_list": "#mcp-servers-list",
"tools_tbody": "#tools-tbody",
"tools_empty": "#tools-empty",
# Extensions tab cards
"ext_card_installed": "#extensions-list .ext-card",
"ext_card_available": "#available-wasm-list .ext-card.ext-available",
@@ -92,6 +93,12 @@ SEL = {
"ext_stepper": ".ext-stepper",
"stepper_step": ".stepper-step",
"stepper_circle": ".stepper-circle",
# Confirm modal (custom, replaces window.confirm)
"confirm_modal": "#confirm-modal",
"confirm_modal_btn": "#confirm-modal-btn",
"confirm_modal_cancel": "#confirm-modal-cancel-btn",
# Channels subtab cards
"channels_ext_card": "#settings-channels-content .ext-card",
# Toast notifications
"toast": ".toast",
"toast_success": ".toast.toast-success",
@@ -106,7 +113,7 @@ SEL = {
"routines_empty": "#routines-empty",
}
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
TABS = ["chat", "memory", "jobs", "routines", "settings"]
# Auth token used across all tests
AUTH_TOKEN = "e2e-test-token"
+14 -4
View File
@@ -267,14 +267,24 @@ async def _stream_tool_call(request: web.Request, cid: str, tc: dict) -> web.Str
async def oauth_exchange(request: web.Request) -> web.Response:
"""Mock OAuth token exchange proxy for E2E tests.
Accepts form params (code, redirect_uri, code_verifier) and returns
a fake token response. Called by ironclaw's exchange_via_proxy() when
IRONCLAW_OAUTH_EXCHANGE_URL is set.
Accepts the generic hosted OAuth proxy contract used by IronClaw and
returns a fake token response. MCP callback tests assert that provider-
specific token params such as RFC 8707 `resource` are forwarded here.
"""
data = await request.post()
code = data.get("code", "")
access_token_field = data.get("access_token_field", "access_token")
if code == "mock_mcp_code":
if not data.get("token_url", "").endswith("/oauth/token"):
return web.json_response({"error": "missing_token_url"}, status=400)
if not data.get("client_id"):
return web.json_response({"error": "missing_client_id"}, status=400)
if not data.get("resource"):
return web.json_response({"error": "missing_resource"}, status=400)
return web.json_response({
"access_token": f"mock-token-{code}",
access_token_field: f"mock-token-{code}",
"refresh_token": "mock-refresh-token",
"expires_in": 3600,
})
+71 -133
View File
@@ -87,23 +87,21 @@ _REGISTRY_MCP = {
"installed": False,
}
_SAMPLE_TOOL = {"name": "echo", "description": "Echo a message"}
_SAMPLE_TOOL_2 = {"name": "time", "description": "Get current time"}
# ─── Navigation helpers ────────────────────────────────────────────────────────
async def go_to_extensions(page):
"""Click the Extensions tab and wait for the panel to appear.
"""Navigate to Settings > Extensions subtab and wait for content.
Waits for loadExtensions() to finish rendering by polling for the first
content signal (empty-state div or an installed card) rather than sleeping.
"""
await page.locator(SEL["tab_button"].format(tab="extensions")).click()
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="extensions")).wait_for(
state="visible", timeout=5000
)
# loadExtensions() fires three parallel fetches then renders. Wait for the
# loadExtensions() fires parallel fetches then renders. Wait for the
# first concrete DOM signal instead of a hard sleep so the test is
# deterministic even under CI load.
await page.locator(
@@ -111,19 +109,39 @@ async def go_to_extensions(page):
).first.wait_for(state="visible", timeout=8000)
async def mock_ext_apis(page, *, installed=None, tools=None, registry=None):
"""Intercept the three extension list APIs with fixture data.
async def go_to_channels(page):
"""Navigate to Settings > Channels subtab and wait for content."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="channels")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="channels")).wait_for(
state="visible", timeout=5000
)
Must be called BEFORE navigating to the extensions tab.
async def go_to_mcp(page):
"""Navigate to Settings > MCP subtab and wait for content."""
await page.locator(SEL["tab_button"].format(tab="settings")).click()
await page.locator(SEL["settings_subtab"].format(subtab="mcp")).click()
await page.locator(SEL["settings_subpanel"].format(subtab="mcp")).wait_for(
state="visible", timeout=5000
)
await page.locator(
f"{SEL['mcp_servers_list']} .empty-state, {SEL['ext_card_mcp']}"
).first.wait_for(state="visible", timeout=8000)
async def mock_ext_apis(page, *, installed=None, registry=None):
"""Intercept the extension list APIs with fixture data.
Must be called BEFORE navigating to the extensions subtab.
"""
ext_body = json.dumps({"extensions": installed or []})
tools_body = json.dumps({"tools": tools or []})
registry_body = json.dumps({"entries": registry or []})
# Playwright evaluates route handlers in LIFO order (last-registered fires
# first). Register the broad handler first so it is checked last; the
# specific /tools and /registry handlers are registered after and therefore
# checked first — no continue_() fallthrough needed.
# specific /registry handler is registered after and therefore checked
# first — no continue_() fallthrough needed.
async def handle_ext_list(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
@@ -133,13 +151,9 @@ async def mock_ext_apis(page, *, installed=None, tools=None, registry=None):
await page.route("**/api/extensions*", handle_ext_list)
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body=tools_body)
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body=registry_body)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
@@ -151,46 +165,17 @@ async def wait_for_toast(page, text: str, *, timeout: int = 5000):
# ─── Group A: Structural / empty state ────────────────────────────────────────
async def test_extensions_empty_tab_layout(page):
"""Extensions tab with no data shows all three sections with correct empty-state messages."""
await mock_ext_apis(page, tools=[])
"""Extensions subtab with no data shows sections with correct empty-state messages."""
await mock_ext_apis(page)
await go_to_extensions(page)
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions"))
assert await panel.is_visible()
ext_list = page.locator(SEL["extensions_list"])
assert await ext_list.is_visible()
assert "No extensions installed" in await ext_list.text_content()
wasm_list = page.locator(SEL["available_wasm_list"])
assert await wasm_list.is_visible()
assert "No additional WASM extensions available" in await wasm_list.text_content()
mcp_list = page.locator(SEL["mcp_servers_list"])
assert await mcp_list.is_visible()
assert "No MCP servers available" in await mcp_list.text_content()
# Tools table should be empty
tbody = page.locator(SEL["tools_tbody"])
rows = await tbody.locator("tr").count()
empty_visible = await page.locator(SEL["tools_empty"]).is_visible()
assert empty_visible or rows == 0, "Expected tools table to be empty"
async def test_extensions_tools_table_populated(page):
"""Two mock tools produce two rows in the tools table."""
await mock_ext_apis(page, tools=[_SAMPLE_TOOL, _SAMPLE_TOOL_2])
await go_to_extensions(page)
tbody = page.locator(SEL["tools_tbody"])
rows = tbody.locator("tr")
await rows.first.wait_for(state="visible", timeout=5000)
assert await rows.count() == 2
text = await tbody.text_content()
assert "echo" in text
assert "time" in text
# ─── Group B: Installed WASM tool cards ───────────────────────────────────────
@@ -248,9 +233,9 @@ async def test_installed_wasm_tool_authed_shows_reconfigure_btn(page):
async def test_installed_mcp_server_active(page):
"""Active MCP server shows 'Active' label and no Activate button."""
await mock_ext_apis(page, installed=[_MCP_ACTIVE])
await go_to_extensions(page)
await go_to_mcp(page)
card = page.locator(SEL["ext_card_installed"]).first
card = page.locator(SEL["ext_card_mcp"]).first
await card.wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_active_label"]).count() == 1
assert await card.locator(SEL["ext_activate_btn"]).count() == 0
@@ -260,9 +245,9 @@ async def test_installed_mcp_server_active(page):
async def test_installed_mcp_server_inactive_shows_activate(page):
"""Inactive MCP server shows Activate button."""
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
await go_to_extensions(page)
await go_to_mcp(page)
card = page.locator(SEL["ext_card_installed"]).first
card = page.locator(SEL["ext_card_mcp"]).first
await card.wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_activate_btn"]).count() == 1
@@ -270,7 +255,7 @@ async def test_installed_mcp_server_inactive_shows_activate(page):
async def test_mcp_server_in_registry_not_installed(page):
"""Registry MCP entry (not installed) appears in the MCP section with Install button."""
await mock_ext_apis(page, registry=[_REGISTRY_MCP])
await go_to_extensions(page)
await go_to_mcp(page)
mcp_list = page.locator(SEL["mcp_servers_list"])
card = mcp_list.locator(".ext-card").first
@@ -285,7 +270,7 @@ async def test_mcp_server_installed_auth_dot(page):
installed_mcp = {**_MCP_ACTIVE, "name": "registry-mcp", "authenticated": False}
registry_mcp = {**_REGISTRY_MCP, "name": "registry-mcp"}
await mock_ext_apis(page, installed=[installed_mcp], registry=[registry_mcp])
await go_to_extensions(page)
await go_to_mcp(page)
mcp_list = page.locator(SEL["mcp_servers_list"])
card = mcp_list.locator(".ext-card").first
@@ -299,8 +284,9 @@ async def test_mcp_server_installed_auth_dot(page):
async def _load_wasm_channel(page, activation_status, activation_error=None):
ext = {**_WASM_CHANNEL, "activation_status": activation_status, "activation_error": activation_error}
await mock_ext_apis(page, installed=[ext])
await go_to_extensions(page)
card = page.locator(SEL["ext_card_installed"]).first
await go_to_channels(page)
# Find the WASM channel card specifically (not built-in channel cards)
card = page.locator(SEL["channels_ext_card"], has_text="Test Channel").first
await card.wait_for(state="visible", timeout=5000)
return card
@@ -446,9 +432,9 @@ async def test_install_wasm_channel_triggers_configure(page):
await page.route("**/api/extensions/test-channel/setup", handle_channel_setup)
await page.route("**/api/extensions/install", handle_channel_install)
await go_to_extensions(page)
await go_to_channels(page)
install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first
install_btn = page.locator(SEL["channels_ext_card"]).locator(SEL["ext_install_btn"]).first
await install_btn.wait_for(state="visible", timeout=5000)
await install_btn.click()
@@ -523,13 +509,14 @@ async def test_remove_installed_extension_confirmed(page):
# Override for subsequent calls
await page.route("**/api/extensions*", handle_ext_empty)
# Auto-accept confirm dialog
await page.evaluate("window.confirm = () => true")
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click()
# Confirm via custom modal
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
await page.locator(SEL["confirm_modal_btn"]).click()
# Card should disappear
await page.wait_for_function(
"() => document.querySelectorAll('#extensions-list .ext-card').length === 0",
@@ -543,13 +530,14 @@ async def test_remove_cancelled_keeps_card(page):
await mock_ext_apis(page, installed=[_WASM_TOOL])
await go_to_extensions(page)
# Reject the confirm dialog
await page.evaluate("window.confirm = () => false")
card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click()
# Cancel via custom modal
await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000)
await page.locator(SEL["confirm_modal_cancel"]).click()
assert await page.locator(SEL["ext_card_installed"]).count() >= 1, "Card should remain after cancel"
@@ -973,14 +961,10 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await go_to_extensions(page)
@@ -989,6 +973,9 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1
# Inject a counter to confirm refreshCurrentSettingsTab is called
await page.evaluate("window.__refreshCount = 0; var _origRefresh = refreshCurrentSettingsTab; refreshCurrentSettingsTab = function() { window.__refreshCount++; _origRefresh(); };")
await page.evaluate("""
handleAuthCompleted({
extension_name: 'gmail',
@@ -999,14 +986,11 @@ async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensio
await wait_for_toast(page, "OAuth flow expired. Please try again.")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0
assert (
await page.locator(
SEL["toast_error"], has_text="OAuth flow expired. Please try again."
).count()
>= 1
)
await page.wait_for_timeout(600)
# Wait for the refresh to complete
await page.wait_for_function("() => window.__refreshCount > 0", timeout=5000)
# Give the async fetch time to complete
await page.wait_for_timeout(1000)
assert len(reload_count) > count_before, "Extensions list did not reload after auth failure"
@@ -1026,9 +1010,9 @@ async def test_activate_mcp_server_success(page):
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
async with page.expect_response("**/api/extensions/test-mcp-inactive/activate", timeout=5000):
@@ -1051,9 +1035,9 @@ async def test_activate_awaiting_token_opens_configure(page):
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await page.route("**/api/extensions/test-mcp-inactive/setup", handle_setup)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1070,9 +1054,9 @@ async def test_activate_failure_shows_error_toast(page):
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "message": "Config missing"}))
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1088,9 +1072,9 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}))
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -1106,7 +1090,7 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
# ─── Group J: Tab reload behaviour ────────────────────────────────────────────
async def test_extensions_tab_reloads_on_revisit(page):
"""loadExtensions() is called again when re-navigating to the extensions tab."""
"""loadExtensions() is called again when re-navigating to the extensions subtab."""
call_count = []
async def counting_handler(route):
@@ -1121,14 +1105,10 @@ async def test_extensions_tab_reloads_on_revisit(page):
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
# First visit
@@ -1148,48 +1128,6 @@ async def test_extensions_tab_reloads_on_revisit(page):
assert count_after_second > count_after_first, "loadExtensions not called on return visit"
async def test_auth_completed_sse_triggers_extensions_reload(page):
"""auth_completed SSE event while on the extensions tab triggers a reload."""
reload_count = []
async def counting_handler(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
reload_count.append(1)
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"extensions": []}),
)
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await go_to_extensions(page)
count_before = len(reload_count)
# Simulate auth_completed via the shared handler.
await page.evaluate("""
handleAuthCompleted({
extension_name: 'reload-ext',
success: true,
message: 'Reloaded.',
});
""")
await page.wait_for_timeout(600)
assert len(reload_count) > count_before, "loadExtensions was not called after auth_completed"
# ─── Regression tests ─────────────────────────────────────────────────────────
# Each test below is a regression for a specific bug found after the initial
# test suite was written. The bug description is in the docstring.
@@ -1267,9 +1205,9 @@ async def test_oauth_url_injection_blocked(page):
)
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate)
await go_to_extensions(page)
await go_to_mcp(page)
activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"])
activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"])
await activate_btn.wait_for(state="visible", timeout=5000)
await activate_btn.click()
@@ -99,6 +99,10 @@ async def test_mcp_activate_triggers_auth(ironclaw_server):
assert auth_url is not None or awaiting_token, (
f"Activate should require auth, got: {data}"
)
if auth_url is not None:
assert _extract_state(auth_url).startswith("ic2."), (
f"Hosted MCP OAuth should emit versioned state, got: {auth_url}"
)
# ── Section C: OAuth Round-Trip ──────────────────────────────────────────

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