Compare commits

..
54 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
2784cef4d7 fix: relax timing thresholds in policy adversarial tests (100ms -> 500ms) (#1294)
These tests guard against catastrophic regex backtracking (seconds/minutes),
not 12ms differences. CI runners with coverage instrumentation (cargo-llvm-cov)
consistently exceed the 100ms threshold due to overhead, causing flaky failures.
500ms still catches real regressions while tolerating CI variability.

[skip-regression-check]

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-16 22:29:41 -07:00
5c56032b88 fix: Rate limiter returns retry after None instead of a duration (#1269)
* fix: Rate limiter returns retry after None instead of a duration

linter fix

* review fixes

* fix: rate limiter returns None for retry_after duration

Add regression test to src/llm/retry.rs that verifies RateLimited errors
always have a fallback duration (never None) due to the 60-second fallback
applied in all rate limit error creation sites (nearai_chat.rs,
anthropic_oauth.rs, embeddings.rs).

The production code fix adds `.or(Some(Duration::from_secs(60)))` to ensure
the error message never displays "retry after None" to the user.

[skip-regression-check]

Co-Authored-By: Claude Haiku 4.5 <[email protected]>

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>
2026-03-16 20:51:49 -07:00
115 changed files with 10978 additions and 2612 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ NEARAI_AUTH_URL=https://private.near.ai
# === MiniMax === # === MiniMax ===
# LLM_BACKEND=minimax # LLM_BACKEND=minimax
# MINIMAX_API_KEY=... # 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 # MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China
# === Anthropic Direct === # === Anthropic Direct ===
+1 -1
View File
@@ -174,7 +174,7 @@ jobs:
- name: Run E2E tests - name: Run E2E tests
run: | run: |
pytest tests/e2e/ -v -x --timeout=120 pytest tests/e2e/ -v --timeout=120
env: env:
RUST_LOG: ironclaw=info RUST_LOG: ironclaw=info
RUST_BACKTRACE: "1" RUST_BACKTRACE: "1"
+41 -6
View File
@@ -43,12 +43,42 @@ jobs:
fi fi
fi fi
if [ "$IS_FIX" = false ]; then # --- 1b. Does this PR touch high-risk state machine or resilience code? ---
echo "Not a fix PR — skipping regression test check." 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 exit 0
fi 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 --- # --- 2. Skip label or commit message marker ---
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
@@ -63,8 +93,6 @@ jobs:
fi fi
# --- 3. Exempt static-only / docs-only changes --- # --- 3. Exempt static-only / docs-only changes ---
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
if [ -z "$CHANGED_FILES" ]; then if [ -z "$CHANGED_FILES" ]; then
echo "No changed files — skipping." echo "No changed files — skipping."
exit 0 exit 0
@@ -110,5 +138,12 @@ jobs:
fi fi
# --- 5. No tests found --- # --- 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 exit 1
+52
View File
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
### Added ### Added
Generated
+1 -1
View File
@@ -3436,7 +3436,7 @@ dependencies = [
[[package]] [[package]]
name = "ironclaw" name = "ironclaw"
version = "0.19.0" version = "0.21.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"aho-corasick", "aho-corasick",
+1 -1
View File
@@ -20,7 +20,7 @@ exclude = [
[package] [package]
name = "ironclaw" name = "ironclaw"
version = "0.19.0" version = "0.21.0"
edition = "2024" edition = "2024"
rust-version = "1.92" rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
+1 -1
View File
@@ -465,7 +465,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Device pairing | ✅ | ❌ | | | Device pairing | ✅ | ❌ | |
| Tailscale identity | ✅ | ❌ | | | Tailscale identity | ✅ | ❌ | |
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth | | 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 | | DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store | | Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Per-group tool policies | ✅ | ❌ | | | 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"> <p align="center">
<a href="README.md">English</a> | <a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</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>
<p align="center"> <p align="center">
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center"> <p align="center">
<a href="README.md">English</a> | <a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</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>
<p align="center"> <p align="center">
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center"> <p align="center">
<a href="README.md">English</a> | <a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</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>
<p align="center"> <p align="center">
+87 -13
View File
@@ -206,9 +206,17 @@ struct FeishuApiResponse<T> {
data: Option<T>, data: Option<T>,
} }
/// Tenant access token response. /// Tenant access token response (flat format).
#[derive(Debug, Default, Deserialize)] ///
struct TenantAccessTokenData { /// 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, tenant_access_token: String,
expire: i64, expire: i64,
} }
@@ -770,9 +778,8 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
)); ));
} }
let token_resp: FeishuApiResponse<TenantAccessTokenData> = let token_resp: TenantAccessTokenResponse = serde_json::from_slice(&response.body)
serde_json::from_slice(&response.body) .map_err(|e| format!("Failed to parse token response: {}", e))?;
.map_err(|e| format!("Failed to parse token response: {}", e))?;
if token_resp.code != 0 { if token_resp.code != 0 {
return Err(format!( return Err(format!(
@@ -781,23 +788,33 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
)); ));
} }
let data = token_resp if token_resp.tenant_access_token.is_empty() {
.data return Err("Token response missing tenant_access_token".to_string());
.ok_or_else(|| "Token response missing data".to_string())?; }
if token_resp.expire <= 0 {
return Err(format!(
"Token response has invalid expire value: {}",
token_resp.expire
));
}
// Cache the token with expiry. // Cache the token with expiry.
let now = channel_host::now_millis(); 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()); let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string());
channel_host::log( channel_host::log(
channel_host::LogLevel::Debug, 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)), 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, 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; 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 { fn truncate_status_message(input: &str, max_chars: usize) -> String {
let mut iter = input.chars(); 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> { fn status_message_for_user(update: &StatusUpdate) -> Option<String> {
let message = update.message.trim(); let message = update.message.trim();
if message.is_empty() { if message.is_empty() {
@@ -1242,26 +1311,64 @@ fn send_response(
return Ok(()); return Ok(());
} }
// Try Markdown, fall back to plain text on parse errors // Split large messages into chunks that fit Telegram's limit.
match send_message( let chunks = split_message(&response.content);
chat_id, let total = chunks.len();
&response.content,
reply_to_message_id, // The first chunk replies to the original message; subsequent chunks
Some("Markdown"), // reply to the previously sent chunk so they form a visual thread.
message_thread_id, let mut reply_to = reply_to_message_id;
) {
Ok(_) => Ok(()), for (i, chunk) in chunks.into_iter().enumerate() {
Err(SendError::ParseEntities(_)) => send_message( // Try Markdown, fall back to plain text on parse errors
chat_id, let result = send_message(chat_id, &chunk, reply_to, Some("Markdown"), message_thread_id);
&response.content,
reply_to_message_id, let msg_id = match result {
None, Ok(id) => {
message_thread_id, channel_host::log(
) channel_host::LogLevel::Debug,
.map(|_| ()) &format!(
.map_err(|e| format!("Plain-text retry also failed: {}", e)), "Sent message chunk {}/{} to chat {}: message_id={}",
Err(e) => Err(e.to_string()), 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. /// Send a single attachment, choosing sendPhoto or sendDocument based on MIME type.
@@ -2043,6 +2150,102 @@ export!(TelegramChannel);
mod tests { mod tests {
use super::*; 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] #[test]
fn test_clean_message_text() { fn test_clean_message_text() {
// Without bot_username: strips any leading @mention // Without bot_username: strips any leading @mention
+8 -4
View File
@@ -2,9 +2,13 @@ coverage:
status: status:
project: project:
default: default:
target: auto target: 80%
threshold: 1% threshold: 2%
patch: patch:
default: default:
target: 80% target: 90%
threshold: 5%
comment:
layout: "reach,diff,flags"
behavior: default
require_changes: true
+7 -7
View File
@@ -324,7 +324,7 @@ mod tests {
let violations = policy.check(&payload); let violations = policy.check(&payload);
let elapsed = start.elapsed(); let elapsed = start.elapsed();
assert!( assert!(
elapsed.as_millis() < 100, elapsed.as_millis() < 500,
"excessive_urls pattern took {}ms on 100KB near-miss", "excessive_urls pattern took {}ms on 100KB near-miss",
elapsed.as_millis() elapsed.as_millis()
); );
@@ -349,7 +349,7 @@ mod tests {
let violations = policy.check(&payload); let violations = policy.check(&payload);
let elapsed = start.elapsed(); let elapsed = start.elapsed();
assert!( assert!(
elapsed.as_millis() < 100, elapsed.as_millis() < 500,
"obfuscated_string pattern took {}ms on 100KB near-miss", "obfuscated_string pattern took {}ms on 100KB near-miss",
elapsed.as_millis() elapsed.as_millis()
); );
@@ -370,7 +370,7 @@ mod tests {
let _violations = policy.check(&payload); let _violations = policy.check(&payload);
let elapsed = start.elapsed(); let elapsed = start.elapsed();
assert!( assert!(
elapsed.as_millis() < 100, elapsed.as_millis() < 500,
"shell_injection pattern took {}ms on 100KB near-miss", "shell_injection pattern took {}ms on 100KB near-miss",
elapsed.as_millis() elapsed.as_millis()
); );
@@ -387,7 +387,7 @@ mod tests {
let _violations = policy.check(&payload); let _violations = policy.check(&payload);
let elapsed = start.elapsed(); let elapsed = start.elapsed();
assert!( assert!(
elapsed.as_millis() < 100, elapsed.as_millis() < 500,
"sql_pattern took {}ms on 100KB near-miss", "sql_pattern took {}ms on 100KB near-miss",
elapsed.as_millis() elapsed.as_millis()
); );
@@ -405,7 +405,7 @@ mod tests {
let _violations = policy.check(&payload); let _violations = policy.check(&payload);
let elapsed = start.elapsed(); let elapsed = start.elapsed();
assert!( assert!(
elapsed.as_millis() < 100, elapsed.as_millis() < 500,
"crypto_private_key pattern took {}ms on 100KB near-miss", "crypto_private_key pattern took {}ms on 100KB near-miss",
elapsed.as_millis() elapsed.as_millis()
); );
@@ -423,7 +423,7 @@ mod tests {
let _violations = policy.check(&payload); let _violations = policy.check(&payload);
let elapsed = start.elapsed(); let elapsed = start.elapsed();
assert!( assert!(
elapsed.as_millis() < 100, elapsed.as_millis() < 500,
"system_file_access pattern took {}ms on 100KB near-miss", "system_file_access pattern took {}ms on 100KB near-miss",
elapsed.as_millis() elapsed.as_millis()
); );
@@ -441,7 +441,7 @@ mod tests {
let _violations = policy.check(&payload); let _violations = policy.check(&payload);
let elapsed = start.elapsed(); let elapsed = start.elapsed();
assert!( assert!(
elapsed.as_millis() < 100, elapsed.as_millis() < 500,
"encoded_exploit pattern took {}ms on 100KB near-miss", "encoded_exploit pattern took {}ms on 100KB near-miss",
elapsed.as_millis() elapsed.as_millis()
); );
+2 -2
View File
@@ -15,7 +15,7 @@ configurations.
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API | | io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models | | Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT 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 | | Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
| Ollama | `ollama` | No | Local inference | | Ollama | `ollama` | No | Local inference |
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API | | AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
@@ -84,7 +84,7 @@ LLM_BACKEND=minimax
MINIMAX_API_KEY=... 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: To use the China mainland endpoint, set:
+2 -2
View File
@@ -393,8 +393,8 @@
"api_key_required": true, "api_key_required": true,
"base_url_env": "MINIMAX_BASE_URL", "base_url_env": "MINIMAX_BASE_URL",
"model_env": "MINIMAX_MODEL", "model_env": "MINIMAX_MODEL",
"default_model": "MiniMax-M2.5", "default_model": "MiniMax-M2.7",
"description": "MiniMax API (MiniMax-M2.5 and MiniMax-M2.5-highspeed models)", "description": "MiniMax API (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
"setup": { "setup": {
"kind": "api_key", "kind": "api_key",
"secret_name": "llm_minimax_api_key", "secret_name": "llm_minimax_api_key",
+2 -2
View File
@@ -18,8 +18,8 @@
}, },
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-discord-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69" "sha256": "6159cb54aa44a9d8219e29bf0aea9404213b20ff567506fe75f23d4698d6ec18"
} }
}, },
"auth_summary": { "auth_summary": {
+7 -2
View File
@@ -2,7 +2,7 @@
"name": "feishu", "name": "feishu",
"display_name": "Feishu / Lark Channel", "display_name": "Feishu / Lark Channel",
"kind": "channel", "kind": "channel",
"version": "0.1.1", "version": "0.1.2",
"wit_version": "0.3.0", "wit_version": "0.3.0",
"description": "Talk to your agent through a Feishu or Lark bot", "description": "Talk to your agent through a Feishu or Lark bot",
"keywords": [ "keywords": [
@@ -17,7 +17,12 @@
"capabilities": "feishu.capabilities.json", "capabilities": "feishu.capabilities.json",
"crate_name": "feishu-channel" "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": { "auth_summary": {
"method": "manual", "method": "manual",
"provider": "Feishu / Lark", "provider": "Feishu / Lark",
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "telegram", "name": "telegram",
"display_name": "Telegram Channel", "display_name": "Telegram Channel",
"kind": "channel", "kind": "channel",
"version": "0.2.4", "version": "0.2.5",
"wit_version": "0.3.0", "wit_version": "0.3.0",
"description": "Talk to your agent through a Telegram bot", "description": "Talk to your agent through a Telegram bot",
"keywords": [ "keywords": [
@@ -18,8 +18,8 @@
}, },
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/download/v0.20.0/channel-telegram-0.2.5-wasm32-wasip2.tar.gz",
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed" "sha256": "1ef20a538f55b379e049356e4d6758006251846bc3365ceaa1c87eba8379a329"
} }
}, },
"auth_summary": { "auth_summary": {
+2 -2
View File
@@ -19,8 +19,8 @@
}, },
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b" "sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758"
} }
}, },
"auth_summary": { "auth_summary": {
+2 -2
View File
@@ -21,8 +21,8 @@
}, },
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz",
"sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3" "sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e"
} }
}, },
"auth_summary": { "auth_summary": {
+2 -2
View File
@@ -17,8 +17,8 @@
}, },
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48" "sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb"
} }
}, },
"auth_summary": { "auth_summary": {
+2 -2
View File
@@ -18,8 +18,8 @@
}, },
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz",
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed" "sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4"
} }
}, },
"auth_summary": { "auth_summary": {
+2 -2
View File
@@ -18,8 +18,8 @@
}, },
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz",
"sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc" "sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218"
} }
}, },
"auth_summary": { "auth_summary": {
@@ -8,15 +8,21 @@ Replace `{{...}}` placeholders before use.
{ {
"name": "wf-issue-plan", "name": "wf-issue-plan",
"description": "Create implementation plan when a new issue arrives", "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.", "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}}", "name": "wf-maintainer-comment-gate-{{maintainer}}",
"description": "React to maintainer guidance comments on issues/PRs", "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.", "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", "name": "wf-pr-monitor-loop",
"description": "Keep PR healthy: address review comments and refresh branch", "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.", "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", "name": "wf-ci-fix-loop",
"description": "Fix failing CI checks on active PRs", "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.", "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", "name": "wf-staging-batch-review",
"description": "Batch correctness review through staging, then merge to main", "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.", "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", "name": "wf-learning-memory",
"description": "Capture merge learnings into shared 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.", "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 ```json
{ {
"source": "github", "event_source": "github",
"event_type": "issue.opened", "event_type": "issue.opened",
"payload": { "payload": {
"repository_name": "{{repository}}", "repository_name": "{{repository}}",
+41 -36
View File
@@ -146,6 +146,8 @@ pub struct AgentDeps {
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>, pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc. /// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>, pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
/// Software builder for self-repair tool rebuilding.
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
} }
/// The main agent that coordinates all components. /// The main agent that coordinates all components.
@@ -161,9 +163,10 @@ pub struct Agent {
pub(super) heartbeat_config: Option<HeartbeatConfig>, pub(super) heartbeat_config: Option<HeartbeatConfig>,
pub(super) hygiene_config: Option<crate::config::HygieneConfig>, pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
pub(super) routine_config: Option<RoutineConfig>, 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: 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 { impl Agent {
@@ -228,16 +231,21 @@ impl Agent {
heartbeat_config, heartbeat_config,
hygiene_config, hygiene_config,
routine_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( pub fn set_routine_engine_slot(
&mut self, &mut self,
slot: Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>, 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 // Convenience accessors
@@ -334,11 +342,18 @@ impl Agent {
let mut message_stream = self.channels.start_all().await?; let mut message_stream = self.channels.start_all().await?;
// Start self-repair task with notification forwarding // Start self-repair task with notification forwarding
let repair = Arc::new(DefaultSelfRepair::new( let mut self_repair = DefaultSelfRepair::new(
self.context_manager.clone(), self.context_manager.clone(),
self.config.stuck_threshold, self.config.stuck_threshold,
self.config.max_repair_attempts, 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_interval = self.config.repair_check_interval;
let repair_channels = self.channels.clone(); let repair_channels = self.channels.clone();
let repair_owner_id = self.owner_id().to_string(); 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. // via a local to use in the message loop below.
// Expose engine to gateway for manual triggering // Expose engine to gateway for manual triggering
if let Some(ref slot) = self.routine_engine_slot { *self.routine_engine_slot.write().await = Some(Arc::clone(&engine));
*slot.write().await = Some(Arc::clone(&engine));
}
tracing::debug!( tracing::debug!(
"Routines enabled: cron ticker every {}s, max {} concurrent", "Routines enabled: cron ticker every {}s, max {} concurrent",
@@ -655,9 +668,6 @@ impl Agent {
None 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 // Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name); tracing::debug!("Agent {} ready and listening", self.config.name);
@@ -693,29 +703,6 @@ impl Agent {
// Store successfully extracted document text in workspace for indexing // Store successfully extracted document text in workspace for indexing
self.store_extracted_documents(&message).await; 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 { match self.handle_message(&message).await {
Ok(Some(response)) if !response.is_empty() => { Ok(Some(response)) if !response.is_empty() => {
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound // Hook: BeforeOutbound — allow hooks to modify or suppress outbound
@@ -1032,6 +1019,24 @@ impl Agent {
message.content.len() 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 // Process based on submission type
let result = match submission { let result = match submission {
Submission::UserInput { content } => { Submission::UserInput { content } => {
+42 -7
View File
@@ -29,7 +29,7 @@ pub(super) enum AgenticLoopResult {
/// A tool requires approval before continuing. /// A tool requires approval before continuing.
NeedApproval { NeedApproval {
/// The pending approval request to store. /// 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})"), reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"),
} }
.into()), .into()),
LoopOutcome::NeedApproval(pending) => { LoopOutcome::NeedApproval(pending) => Ok(AgenticLoopResult::NeedApproval { pending }),
Ok(AgenticLoopResult::NeedApproval { pending: *pending })
}
} }
} }
@@ -482,6 +480,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
usize, usize,
crate::llm::ToolCall, crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>, Arc<dyn crate::tools::Tool>,
bool, // allow_always
)> = None; )> = None;
for (idx, original_tc) in tool_calls.iter().enumerate() { for (idx, original_tc) in tool_calls.iter().enumerate() {
@@ -551,7 +550,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
&& let Some(tool) = tool_opt && let Some(tool) = tool_opt
{ {
use crate::tools::ApprovalRequirement; 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::Never => false,
ApprovalRequirement::UnlessAutoApproved => { ApprovalRequirement::UnlessAutoApproved => {
let sess = self.session.lock().await; let sess = self.session.lock().await;
@@ -586,7 +586,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
continue; continue;
} }
approval_needed = Some((idx, tc, tool)); let allow_always = !matches!(requirement, ApprovalRequirement::Always);
approval_needed = Some((idx, tc, tool, allow_always));
break; break;
} }
} }
@@ -887,7 +888,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
} }
// Handle approval if a tool needed it // 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 display_params = redact_params(&tc.arguments, tool.sensitive_params());
let pending = PendingApproval { let pending = PendingApproval {
request_id: Uuid::new_v4(), request_id: Uuid::new_v4(),
@@ -899,6 +900,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
context_messages: reason_ctx.messages.clone(), context_messages: reason_ctx.messages.clone(),
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(), deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
user_timezone: Some(self.user_tz.name().to_string()), user_timezone: Some(self.user_tz.name().to_string()),
allow_always,
}; };
return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending)))); return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending))));
@@ -1197,6 +1199,7 @@ mod tests {
http_interceptor: None, http_interceptor: None,
transcription: None, transcription: None,
document_extraction: None, document_extraction: None,
builder: None,
}; };
Agent::new( Agent::new(
@@ -1364,6 +1367,35 @@ mod tests {
assert!(always_needs, "Always must always require approval"); 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] #[test]
fn test_pending_approval_serialization_backcompat_without_deferred_calls() { fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
// PendingApproval from before the deferred_tool_calls field was added // PendingApproval from before the deferred_tool_calls field was added
@@ -1409,6 +1441,7 @@ mod tests {
}, },
], ],
user_timezone: None, user_timezone: None,
allow_always: true,
}; };
let json = serde_json::to_string(&pending).expect("serialize"); let json = serde_json::to_string(&pending).expect("serialize");
@@ -2037,6 +2070,7 @@ mod tests {
http_interceptor: None, http_interceptor: None,
transcription: None, transcription: None,
document_extraction: None, document_extraction: None,
builder: None,
}; };
Agent::new( Agent::new(
@@ -2155,6 +2189,7 @@ mod tests {
http_interceptor: None, http_interceptor: None,
transcription: None, transcription: None,
document_extraction: None, document_extraction: None,
builder: None,
}; };
Agent::new( Agent::new(
+1
View File
@@ -211,6 +211,7 @@ mod tests {
job_id: job_id.to_string(), job_id: job_id.to_string(),
status: "completed".to_string(), status: "completed".to_string(),
session_id: None, session_id: None,
fallback_deliverable: None,
}, },
)) ))
.unwrap(); .unwrap();
+615 -74
View File
@@ -10,6 +10,7 @@
//! Lightweight routines execute inline (single LLM call, no scheduler slot). //! Lightweight routines execute inline (single LLM call, no scheduler slot).
//! Full-job routines are delegated to the existing `Scheduler`. //! Full-job routines are delegated to the existing `Scheduler`.
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration; use std::time::Duration;
@@ -23,19 +24,19 @@ use crate::agent::Scheduler;
use crate::agent::routine::{ use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire, NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
}; };
use crate::channels::{IncomingMessage, OutgoingResponse}; use crate::channels::OutgoingResponse;
use crate::config::RoutineConfig; use crate::config::RoutineConfig;
use crate::context::JobContext; use crate::context::{JobContext, JobState};
use crate::db::Database; use crate::db::Database;
use crate::error::RoutineError; use crate::error::RoutineError;
use crate::llm::{ use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest, ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
}; };
use crate::safety::SafetyLayer;
use crate::tools::{ use crate::tools::{
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params, ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
}; };
use crate::workspace::Workspace; use crate::workspace::Workspace;
use ironclaw_safety::SafetyLayer;
enum EventMatcher { enum EventMatcher {
Message { routine: Routine, regex: Regex }, Message { routine: Routine, regex: Regex },
@@ -60,6 +61,10 @@ pub struct RoutineEngine {
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
/// Safety layer for tool output sanitization. /// Safety layer for tool output sanitization.
safety: Arc<SafetyLayer>, 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 { impl RoutineEngine {
@@ -85,9 +90,16 @@ impl RoutineEngine {
scheduler, scheduler,
tools, tools,
safety, 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. /// Refresh the in-memory event trigger cache from DB.
pub async fn refresh_event_cache(&self) { pub async fn refresh_event_cache(&self) {
match self.store.list_event_routines().await { match self.store.list_event_routines().await {
@@ -135,10 +147,19 @@ impl RoutineEngine {
/// Check incoming message against event triggers. Returns number of routines fired. /// Check incoming message against event triggers. Returns number of routines fired.
/// ///
/// Called synchronously from the main loop after handle_message(). The actual /// Accepts only the three fields needed for matching (user scope, channel,
/// execution is spawned async so this returns quickly. /// message content) so callers never need to clone a full `IncomingMessage`.
pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize { pub async fn check_event_triggers(&self, user_id: &str, channel: &str, content: &str) -> usize {
let cache = self.event_cache.read().await; 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; let mut fired = 0;
// Collect routine IDs for batch query // Collect routine IDs for batch query
@@ -155,16 +176,9 @@ impl RoutineEngine {
} }
// Single batch query instead of N queries // Single batch query instead of N queries
let concurrent_counts = match self let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
.store Some(counts) => counts,
.count_running_routine_runs_batch(&routine_ids) None => return 0,
.await
{
Ok(counts) => counts,
Err(e) => {
tracing::error!("Failed to batch-load concurrent counts: {}", e);
return 0;
}
}; };
for matcher in cache.iter() { for matcher in cache.iter() {
@@ -173,7 +187,7 @@ impl RoutineEngine {
EventMatcher::System { .. } => continue, EventMatcher::System { .. } => continue,
}; };
if routine.user_id != message.user_id { if routine.user_id != user_id {
continue; continue;
} }
@@ -181,13 +195,13 @@ impl RoutineEngine {
if let Trigger::Event { if let Trigger::Event {
channel: Some(ch), .. channel: Some(ch), ..
} = &routine.trigger } = &routine.trigger
&& ch != &message.channel && ch != channel
{ {
continue; continue;
} }
// Regex match // Regex match
if !re.is_match(&message.content) { if !re.is_match(content) {
continue; continue;
} }
@@ -210,7 +224,7 @@ impl RoutineEngine {
continue; continue;
} }
let detail = truncate(&message.content, 200); let detail = truncate(content, 200);
self.spawn_fire(routine.clone(), "event", Some(detail)); self.spawn_fire(routine.clone(), "event", Some(detail));
fired += 1; fired += 1;
} }
@@ -229,6 +243,15 @@ impl RoutineEngine {
user_id: Option<&str>, user_id: Option<&str>,
) -> usize { ) -> usize {
let cache = self.event_cache.read().await; 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; let mut fired = 0;
// Collect routine IDs for batch query // Collect routine IDs for batch query
@@ -245,19 +268,9 @@ impl RoutineEngine {
} }
// Single batch query instead of N queries // Single batch query instead of N queries
let concurrent_counts = match self let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
.store Some(counts) => counts,
.count_running_routine_runs_batch(&routine_ids) None => return 0,
.await
{
Ok(counts) => counts,
Err(e) => {
tracing::error!(
"Failed to batch-load concurrent counts for system events: {}",
e
);
return 0;
}
}; };
for matcher in cache.iter() { for matcher in cache.iter() {
@@ -331,6 +344,23 @@ impl RoutineEngine {
fired 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. /// Check all due cron routines and fire them. Called by the cron ticker.
pub async fn check_cron_triggers(&self) { pub async fn check_cron_triggers(&self) {
let routines = match self.store.list_due_cron_routines().await { 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). /// Fire a routine manually (from tool call or CLI).
/// ///
/// Bypasses cooldown checks (those only apply to cron/event triggers). /// 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. /// Shared context passed to the execution function.
struct EngineContext { struct EngineContext {
config: RoutineConfig, 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 /// Fire-and-forget: creates a job via `Scheduler::dispatch_job` (which handles
/// creation, metadata, persistence, and scheduling), links the routine run to /// creation, metadata, persistence, and scheduling), links the routine run to
/// the job, and returns immediately. The job runs independently via the /// the job, then watches it via `FullJobWatcher` until it reaches a
/// existing Worker/Scheduler with full tool access. /// 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( async fn execute_full_job(
ctx: &EngineContext, ctx: &EngineContext,
routine: &Routine, routine: &Routine,
@@ -726,25 +1068,30 @@ async fn execute_full_job(
reason: format!("failed to dispatch job: {e}"), reason: format!("failed to dispatch job: {e}"),
})?; })?;
// Link the routine run to the dispatched job // Link the routine run to the dispatched job.
if let Err(e) = ctx.store.link_routine_run_to_job(run.id, job_id).await { // This MUST succeed — if it fails, sync_dispatched_runs() will never find
tracing::error!( // this run (it filters on job_id IS NOT NULL), leaving it stuck as 'running'
routine = %routine.name, // with running_count permanently elevated.
"Failed to link run to job: {}", e 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!( tracing::info!(
routine = %routine.name, routine = %routine.name,
job_id = %job_id, job_id = %job_id,
max_iterations = max_iterations, max_iterations = max_iterations,
"Dispatched full job for routine" "Dispatched full job for routine, watching for completion"
); );
let summary = format!( // Watch the job until it finishes — keeps the routine run active
"Dispatched job {job_id} for full execution with tool access (max_iterations: {max_iterations})" // so concurrency guardrails (running_count, routine_runs status)
); // remain enforced for the full job lifetime.
Ok((RunStatus::Ok, Some(summary), None)) 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. /// Execute a lightweight routine with optional tool support.
@@ -784,23 +1131,12 @@ async fn execute_lightweight(
Err(_) => None, Err(_) => None,
}; };
// Build the user-facing prompt let full_prompt = build_lightweight_prompt(
let mut full_prompt = String::new(); prompt,
full_prompt.push_str(prompt); &context_parts,
state_content.as_deref(),
if !context_parts.is_empty() { &routine.notify,
full_prompt.push_str("\n\n---\n\n# Context\n\n"); use_tools,
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.",
); );
// Get system prompt // 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). /// Execute a lightweight routine without tool support (original single-call behavior).
async fn execute_lightweight_no_tools( async fn execute_lightweight_no_tools(
ctx: &EngineContext, ctx: &EngineContext,
@@ -901,8 +1296,8 @@ fn handle_text_response(
}; };
} }
// Check for the "nothing to do" sentinel // Check for the "nothing to do" sentinel (exact match on trimmed content).
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") { if content == "ROUTINE_OK" {
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32); let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
return Ok((RunStatus::Ok, None, total_tokens)); return Ok((RunStatus::Ok, None, total_tokens));
} }
@@ -1268,14 +1663,22 @@ pub fn spawn_cron_ticker(
interval: Duration, interval: Duration,
) -> tokio::task::JoinHandle<()> { ) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move { tokio::spawn(async move {
// Run one check immediately so routines due at startup don't wait // Recover orphaned runs from a previous process crash before
// an extra full polling interval. // 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; engine.check_cron_triggers().await;
let mut ticker = tokio::time::interval(interval); let mut ticker = tokio::time::interval(interval);
loop { loop {
ticker.tick().await; 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; 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] #[test]
fn test_routine_sentinel_detection_exact_match() { fn test_routine_sentinel_detection_exact_match() {
// The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK") // Sentinel detection uses exact match on trimmed content to avoid
// After trim(), whitespace is removed // false positives from substrings like "NOT_ROUTINE_OK".
let test_cases = vec![ let test_cases = vec![
("ROUTINE_OK", true), ("ROUTINE_OK", true),
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches (" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
("something ROUTINE_OK something", true), ("something ROUTINE_OK something", false), // substring no longer matches
("ROUTINE_OK is done", true), ("ROUTINE_OK is done", false), // substring no longer matches
("done ROUTINE_OK", true), ("done ROUTINE_OK", false), // substring no longer matches
("NOT_ROUTINE_OK", false), // exact match prevents this
("no sentinel here", false), ("no sentinel here", false),
]; ];
for (content, should_match) in test_cases { for (content, should_match) in test_cases {
let trimmed = content.trim(); let trimmed = content.trim();
let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK"); let matches = trimmed == "ROUTINE_OK";
assert_eq!( assert_eq!(
matches, should_match, matches, should_match,
"Content '{}' sentinel detection should be {}, got {}", "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[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 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. /// Default self-repair implementation.
pub struct DefaultSelfRepair { pub struct DefaultSelfRepair {
context_manager: Arc<ContextManager>, context_manager: Arc<ContextManager>,
// TODO: use for time-based stuck detection (currently only max_repair_attempts is checked)
#[allow(dead_code)]
stuck_threshold: Duration, stuck_threshold: Duration,
max_repair_attempts: u32, max_repair_attempts: u32,
store: Option<Arc<dyn Database>>, store: Option<Arc<dyn Database>>,
builder: Option<Arc<dyn SoftwareBuilder>>, builder: Option<Arc<dyn SoftwareBuilder>>,
// TODO: use for tool hot-reload after repair
#[allow(dead_code)]
tools: Option<Arc<ToolRegistry>>, tools: Option<Arc<ToolRegistry>>,
} }
@@ -95,15 +91,13 @@ impl DefaultSelfRepair {
} }
/// Add a Store for tool failure tracking. /// Add a Store for tool failure tracking.
#[allow(dead_code)] // TODO: wire up in main.rs when persistence is needed pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
pub(crate) fn with_store(mut self, store: Arc<dyn Database>) -> Self {
self.store = Some(store); self.store = Some(store);
self self
} }
/// Add a Builder and ToolRegistry for automatic tool repair. /// Add a Builder and ToolRegistry for automatic tool repair.
#[allow(dead_code)] // TODO: wire up in main.rs when auto-repair is needed pub fn with_builder(
pub(crate) fn with_builder(
mut self, mut self,
builder: Arc<dyn SoftwareBuilder>, builder: Arc<dyn SoftwareBuilder>,
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
@@ -124,18 +118,30 @@ impl SelfRepair for DefaultSelfRepair {
if let Ok(ctx) = self.context_manager.get_context(job_id).await if let Ok(ctx) = self.context_manager.get_context(job_id).await
&& ctx.state == JobState::Stuck && ctx.state == JobState::Stuck
{ {
let stuck_duration = ctx // Measure stuck_duration from the most recent Stuck transition,
.started_at // not from started_at (which reflects when the job first ran).
.map(|start| { let stuck_since = ctx
let now = Utc::now(); .transitions
let duration = now.signed_duration_since(start); .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) Duration::from_secs(duration.num_seconds().max(0) as u64)
}) })
.unwrap_or_default(); .unwrap_or_default();
// Only report jobs that have been stuck long enough
if stuck_duration < self.stuck_threshold {
continue;
}
stuck_jobs.push(StuckJob { stuck_jobs.push(StuckJob {
job_id, job_id,
last_activity: ctx.started_at.unwrap_or(ctx.created_at), last_activity: stuck_since.unwrap_or(ctx.created_at),
stuck_duration, stuck_duration,
last_error: None, last_error: None,
repair_attempts: ctx.repair_attempts, repair_attempts: ctx.repair_attempts,
@@ -273,9 +279,8 @@ impl SelfRepair for DefaultSelfRepair {
tracing::warn!("Failed to mark tool as repaired: {}", e); tracing::warn!("Failed to mark tool as repaired: {}", e);
} }
// Log if the tool was auto-registered
if result.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 { Ok(RepairResult::Success {
@@ -417,7 +422,8 @@ mod tests {
.unwrap() .unwrap()
.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; let stuck = repair.detect_stuck_jobs().await;
assert_eq!(stuck.len(), 1); assert_eq!(stuck.len(), 1);
assert_eq!(stuck[0].job_id, job_id); 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] #[tokio::test]
async fn detect_broken_tools_returns_empty_without_store() { async fn detect_broken_tools_returns_empty_without_store() {
let cm = Arc::new(ContextManager::new(10)); let cm = Arc::new(ContextManager::new(10));
@@ -515,4 +613,148 @@ mod tests {
result 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. /// through the approval flow even if the approval message lacks timezone.
#[serde(default)] #[serde(default)]
pub user_timezone: Option<String>, 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. /// A conversation thread within a session.
@@ -1106,6 +1115,7 @@ mod tests {
context_messages: vec![ChatMessage::user("do it")], context_messages: vec![ChatMessage::user("do it")],
deferred_tool_calls: vec![], deferred_tool_calls: vec![],
user_timezone: None, user_timezone: None,
allow_always: false,
}; };
thread.await_approval(approval); thread.await_approval(approval);
@@ -1132,6 +1142,7 @@ mod tests {
context_messages: vec![], context_messages: vec![],
deferred_tool_calls: vec![], deferred_tool_calls: vec![],
user_timezone: None, user_timezone: None,
allow_always: true,
}; };
thread.await_approval(approval); thread.await_approval(approval);
+2
View File
@@ -382,6 +382,8 @@ pub enum SubmissionResult {
description: String, description: String,
/// Parameters being passed. /// Parameters being passed.
parameters: serde_json::Value, parameters: serde_json::Value,
/// Whether "always" auto-approve should be offered to the user.
allow_always: bool,
}, },
/// Successfully processed (for control commands). /// Successfully processed (for control commands).
+21 -8
View File
@@ -506,7 +506,8 @@ impl Agent {
let tool_name = pending.tool_name.clone(); let tool_name = pending.tool_name.clone();
let description = pending.description.clone(); let description = pending.description.clone();
let parameters = pending.display_parameters.clone(); let parameters = pending.display_parameters.clone();
thread.await_approval(pending); let allow_always = pending.allow_always;
thread.await_approval(*pending);
let _ = self let _ = self
.channels .channels
.send_status( .send_status(
@@ -516,6 +517,7 @@ impl Agent {
tool_name: tool_name.clone(), tool_name: tool_name.clone(),
description: description.clone(), description: description.clone(),
parameters: parameters.clone(), parameters: parameters.clone(),
allow_always,
}, },
&message.metadata, &message.metadata,
) )
@@ -525,6 +527,7 @@ impl Agent {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
}) })
} }
Err(e) => { Err(e) => {
@@ -1069,28 +1072,31 @@ impl Agent {
usize, usize,
crate::llm::ToolCall, crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>, Arc<dyn crate::tools::Tool>,
bool, // allow_always
)> = None; )> = None;
for (idx, tc) in deferred_tool_calls.iter().enumerate() { for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await { if let Some(tool) = self.tools().get(&tc.name).await {
// Match dispatcher.rs: when auto_approve_tools is true, skip // Match dispatcher.rs: when auto_approve_tools is true, skip
// all approval checks (including ApprovalRequirement::Always). // all approval checks (including ApprovalRequirement::Always).
let needs_approval = if self.config.auto_approve_tools { let (needs_approval, allow_always) = if self.config.auto_approve_tools {
false (false, true)
} else { } else {
use crate::tools::ApprovalRequirement; 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::Never => false,
ApprovalRequirement::UnlessAutoApproved => { ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await; let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name) !sess.is_tool_auto_approved(&tc.name)
} }
ApprovalRequirement::Always => true, ApprovalRequirement::Always => true,
} };
(needs, !matches!(requirement, ApprovalRequirement::Always))
}; };
if needs_approval { if needs_approval {
approval_needed = Some((idx, tc.clone(), tool)); approval_needed = Some((idx, tc.clone(), tool, allow_always));
break; // remaining tools stay deferred break; // remaining tools stay deferred
} }
} }
@@ -1298,7 +1304,7 @@ impl Agent {
} }
// Handle approval if a tool needed it // 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 { let new_pending = PendingApproval {
request_id: Uuid::new_v4(), request_id: Uuid::new_v4(),
tool_name: tc.name.clone(), tool_name: tc.name.clone(),
@@ -1310,6 +1316,7 @@ impl Agent {
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(), deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
// Carry forward the resolved timezone from the original pending approval // Carry forward the resolved timezone from the original pending approval
user_timezone: pending.user_timezone.clone(), user_timezone: pending.user_timezone.clone(),
allow_always,
}; };
let request_id = new_pending.request_id; let request_id = new_pending.request_id;
@@ -1333,6 +1340,7 @@ impl Agent {
tool_name: tool_name.clone(), tool_name: tool_name.clone(),
description: description.clone(), description: description.clone(),
parameters: parameters.clone(), parameters: parameters.clone(),
allow_always,
}, },
&message.metadata, &message.metadata,
) )
@@ -1343,6 +1351,7 @@ impl Agent {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
}); });
} }
@@ -1411,7 +1420,8 @@ impl Agent {
let tool_name = new_pending.tool_name.clone(); let tool_name = new_pending.tool_name.clone();
let description = new_pending.description.clone(); let description = new_pending.description.clone();
let parameters = new_pending.display_parameters.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 let _ = self
.channels .channels
.send_status( .send_status(
@@ -1421,6 +1431,7 @@ impl Agent {
tool_name: tool_name.clone(), tool_name: tool_name.clone(),
description: description.clone(), description: description.clone(),
parameters: parameters.clone(), parameters: parameters.clone(),
allow_always,
}, },
&message.metadata, &message.metadata,
) )
@@ -1430,6 +1441,7 @@ impl Agent {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
}) })
} }
Err(e) => { Err(e) => {
@@ -1949,6 +1961,7 @@ mod tests {
context_messages: vec![], context_messages: vec![],
deferred_tool_calls: vec![], deferred_tool_calls: vec![],
user_timezone: None, user_timezone: None,
allow_always: false,
}; };
thread.await_approval(pending); 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::mcp::{McpProcessManager, McpSessionManager};
use crate::tools::wasm::SharedCredentialRegistry; use crate::tools::wasm::SharedCredentialRegistry;
use crate::tools::wasm::WasmToolRuntime; use crate::tools::wasm::WasmToolRuntime;
use crate::workspace::{EmbeddingProvider, Workspace}; use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, Workspace};
/// Fully initialized application components, ready for channel wiring /// Fully initialized application components, ready for channel wiring
/// and agent construction. /// and agent construction.
@@ -56,6 +56,7 @@ pub struct AppComponents {
pub session: Arc<SessionManager>, pub session: Arc<SessionManager>,
pub catalog_entries: Vec<crate::extensions::RegistryEntry>, pub catalog_entries: Vec<crate::extensions::RegistryEntry>,
pub dev_loaded_tool_names: Vec<String>, pub dev_loaded_tool_names: Vec<String>,
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
} }
/// Options that control optional init phases. /// Options that control optional init phases.
@@ -280,6 +281,7 @@ impl AppBuilder {
Arc<ToolRegistry>, Arc<ToolRegistry>,
Option<Arc<dyn EmbeddingProvider>>, Option<Arc<dyn EmbeddingProvider>>,
Option<Arc<Workspace>>, Option<Arc<Workspace>>,
Option<Arc<dyn crate::tools::SoftwareBuilder>>,
), ),
anyhow::Error, anyhow::Error,
> { > {
@@ -311,10 +313,13 @@ impl AppBuilder {
// Register memory tools if database is available // Register memory tools if database is available
let workspace = if let Some(ref db) = self.db { 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()) let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
.with_search_config(&self.config.search); .with_search_config(&self.config.search);
if let Some(ref emb) = embeddings { 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); let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws)); tools.register_memory_tools(Arc::clone(&ws));
@@ -367,16 +372,19 @@ impl AppBuilder {
} }
// Register builder tool if enabled // 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) && (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())) .register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await; .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. /// Phase 5: Load WASM tools, MCP servers, and create extension manager.
@@ -699,7 +707,7 @@ impl AppBuilder {
} else { } else {
self.init_llm().await? 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. // Create hook registry early so runtime extension activation can register hooks.
let hooks = Arc::new(HookRegistry::new()); let hooks = Arc::new(HookRegistry::new());
@@ -819,6 +827,7 @@ impl AppBuilder {
session: self.session, session: self.session,
catalog_entries, catalog_entries,
dev_loaded_tool_names, dev_loaded_tool_names,
builder,
}) })
} }
} }
+5
View File
@@ -305,6 +305,11 @@ pub enum StatusUpdate {
tool_name: String, tool_name: String,
description: String, description: String,
parameters: serde_json::Value, 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). /// Extension needs user authentication (token or OAuth).
AuthRequired { AuthRequired {
+5
View File
@@ -239,6 +239,11 @@ impl ChannelManager {
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> { pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.read().await.get(name).cloned() 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 { 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 //! `RelayChannel` receives events from channel-relay via HTTP POST callbacks
//! incoming events to `IncomingMessage`s, and sends responses via the //! (pushed through an mpsc channel by the webhook handler), converts them
//! relay's provider-specific proxy API (Slack). //! to `IncomingMessage`s, and sends responses via the relay's provider-specific
//! proxy API (Slack).
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait; 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::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError; 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 { pub struct RelayChannel {
client: RelayClient, client: RelayClient,
provider: RelayProvider, provider: RelayProvider,
stream_token: Arc<RwLock<String>>,
team_id: String, team_id: String,
instance_id: String, instance_id: String,
user_id: String, /// Sender side of the event channel — shared with the webhook handler.
/// SSE stream long-poll timeout in seconds. event_tx: mpsc::Sender<ChannelEvent>,
stream_timeout_secs: u64, /// Receiver side — taken once by `start()`.
/// Initial exponential backoff in milliseconds. event_rx: tokio::sync::Mutex<Option<mpsc::Receiver<ChannelEvent>>>,
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,
} }
impl RelayChannel { impl RelayChannel {
/// Create a new relay channel for Slack (default provider). /// Create a new relay channel for Slack (default provider).
pub fn new( pub fn new(
client: RelayClient, client: RelayClient,
stream_token: String,
team_id: String, team_id: String,
instance_id: String, instance_id: String,
user_id: String, event_tx: mpsc::Sender<ChannelEvent>,
event_rx: mpsc::Receiver<ChannelEvent>,
) -> Self { ) -> Self {
Self::new_with_provider( Self::new_with_provider(
client, client,
RelayProvider::Slack, RelayProvider::Slack,
stream_token,
team_id, team_id,
instance_id, instance_id,
user_id, event_tx,
event_rx,
) )
} }
@@ -84,44 +74,24 @@ impl RelayChannel {
pub fn new_with_provider( pub fn new_with_provider(
client: RelayClient, client: RelayClient,
provider: RelayProvider, provider: RelayProvider,
stream_token: String,
team_id: String, team_id: String,
instance_id: String, instance_id: String,
user_id: String, event_tx: mpsc::Sender<ChannelEvent>,
event_rx: mpsc::Receiver<ChannelEvent>,
) -> Self { ) -> Self {
Self { Self {
client, client,
provider, provider,
stream_token: Arc::new(RwLock::new(stream_token)),
team_id, team_id,
instance_id, instance_id,
user_id, event_tx,
stream_timeout_secs: 86400, event_rx: tokio::sync::Mutex::new(Some(event_rx)),
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
reconnect_handle: RwLock::new(None),
parser_handle: Arc::new(RwLock::new(None)),
max_consecutive_failures: 50,
} }
} }
/// Set backoff/timeout parameters from relay config values. /// Get a clone of the event sender for wiring into the webhook endpoint.
pub fn with_timeouts( pub fn event_sender(&self) -> mpsc::Sender<ChannelEvent> {
mut self, self.event_tx.clone()
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
} }
/// Build a provider-appropriate proxy body for sending a message. /// Build a provider-appropriate proxy body for sending a message.
@@ -151,15 +121,9 @@ impl RelayChannel {
team_id: &str, team_id: &str,
method: &str, method: &str,
body: serde_json::Value, body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> { ) -> Result<serde_json::Value, crate::channels::relay::client::RelayError> {
self.client self.client
.proxy_provider( .proxy_provider(self.provider.as_str(), team_id, method, body)
self.provider.as_str(),
team_id,
method,
body,
Some(&self.instance_id),
)
.await .await
} }
} }
@@ -172,204 +136,82 @@ impl Channel for RelayChannel {
async fn start(&self) -> Result<MessageStream, ChannelError> { async fn start(&self) -> Result<MessageStream, ChannelError> {
let channel_name = self.name().to_string(); 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); 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 provider_str = self.provider.as_str().to_string();
let relay_name = channel_name.clone(); let relay_name = channel_name.clone();
let handle = tokio::spawn(async move { // Spawn a task that reads events from the webhook handler and converts to IncomingMessage
use futures::StreamExt; tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
let mut current_stream = stream; // Validate required fields
let mut backoff_ms = backoff_initial_ms; if event.sender_id.is_empty()
let mut consecutive_failures: u64 = 0; || event.channel_id.is_empty()
|| event.provider_scope.is_empty()
loop { {
// Read events from the current stream tracing::debug!(
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!(
event_type = %event.event_type, event_type = %event.event_type,
sender = %event.sender_id, sender_id = %event.sender_id,
channel = %event.channel_id, channel_id = %event.channel_id,
provider = %provider_str, "Relay: skipping event with missing required fields"
"Relay: received message from {}", provider_str
); );
continue;
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;
}
} }
// Stream ended, attempt reconnect with backoff // Skip non-message events
consecutive_failures += 1; if !event.is_message() {
if consecutive_failures >= max_consecutive_failures { tracing::debug!(
tracing::error!( event_type = %event.event_type,
channel = %relay_name, "Relay: skipping non-message event"
failures = consecutive_failures,
"Relay channel giving up after {} consecutive failures",
consecutive_failures
); );
break; continue;
} }
tracing::warn!( tracing::info!(
backoff_ms = backoff_ms, event_type = %event.event_type,
failures = consecutive_failures, sender = %event.sender_id,
"Relay SSE stream ended, reconnecting..." 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 msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
let token = stream_token.read().await.clone(); .with_user_name(event.display_name())
match client.connect_stream(&token, stream_timeout_secs).await { .with_metadata(serde_json::json!({
Ok((new_stream, new_parser)) => { "team_id": event.team_id(),
tracing::info!("Relay SSE stream reconnected"); "channel_id": event.channel_id,
consecutive_failures = 0; "sender_id": event.sender_id,
backoff_ms = backoff_initial_ms; "sender_name": event.display_name(),
current_stream = new_stream; "event_type": event.event_type,
// Abort old parser before replacing "thread_id": event.thread_id,
if let Some(old) = parser_handle.write().await.take() { "provider": event.provider,
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");
}
}
// Check if the team is still valid (skip when team_id is unknown, let msg = if let Some(ref thread_id) = event.thread_id {
// e.g. when no DB store was available at activation time) msg.with_thread(thread_id)
if !team_id.is_empty() { } else {
match client.list_connections(&instance_id).await { msg.with_thread(&event.channel_id)
Ok(conns) => { };
let has_team =
conns.iter().any(|c| c.team_id == team_id && c.connected); if tx.send(msg).await.is_err() {
if !has_team { tracing::info!("Relay channel receiver dropped, stopping");
tracing::warn!( return;
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"
);
}
}
} }
} }
});
*self.reconnect_handle.write().await = Some(handle); tracing::info!("Relay event channel closed");
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx); let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Ok(Box::pin(stream)) Ok(Box::pin(stream))
@@ -423,6 +265,7 @@ impl Channel for RelayChannel {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always: _,
} = status } = status
else { else {
return Ok(()); return Ok(());
@@ -450,28 +293,24 @@ impl Channel for RelayChannel {
name: self.name().to_string(), name: self.name().to_string(),
reason: "Missing channel_id for approval buttons".into(), 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 thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
let team_id = metadata let team_id = metadata
.get("team_id") .get("team_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(&self.team_id); .unwrap_or(&self.team_id);
// Button value payload (Slack limits button values to 2000 chars; // Register server-side approval record and get opaque token.
// safe with typical UUIDs but documented here as a constraint) // 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!({ let value_payload = serde_json::json!({
"instance_id": self.instance_id, "approval_token": approval_token,
"team_id": team_id,
"channel_id": channel_id,
"thread_ts": thread_id,
"request_id": request_id,
"sender_id": sender_id,
}); });
let value_str = value_payload.to_string(); let value_str = value_payload.to_string();
@@ -582,12 +421,8 @@ impl Channel for RelayChannel {
} }
async fn shutdown(&self) -> Result<(), ChannelError> { async fn shutdown(&self) -> Result<(), ChannelError> {
if let Some(handle) = self.reconnect_handle.write().await.take() { // Relay cleanup is driven by the extension manager dropping the shared
handle.abort(); // sender and removing the channel from the channel manager.
}
if let Some(handle) = self.parser_handle.write().await.take() {
handle.abort();
}
Ok(()) Ok(())
} }
} }
@@ -605,27 +440,20 @@ mod tests {
.expect("client") .expect("client")
} }
fn make_channel() -> RelayChannel {
let (tx, rx) = mpsc::channel(64);
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx, rx)
}
#[test] #[test]
fn relay_channel_name() { fn relay_channel_name() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.name(), DEFAULT_RELAY_NAME); assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
} }
#[test] #[test]
fn conversation_context_extracts_metadata() { fn conversation_context_extracts_metadata() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({ let metadata = serde_json::json!({
"sender_name": "bob", "sender_name": "bob",
@@ -640,8 +468,6 @@ mod tests {
#[test] #[test]
fn metadata_shape_includes_event_type_and_sender_name() { 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!({ let metadata = serde_json::json!({
"team_id": "T123", "team_id": "T123",
"channel_id": "C456", "channel_id": "C456",
@@ -651,43 +477,19 @@ mod tests {
"thread_id": null, "thread_id": null,
"provider": "slack", "provider": "slack",
}); });
// event_type must be present for DM-vs-channel routing
assert_eq!( assert_eq!(
metadata.get("event_type").and_then(|v| v.as_str()), metadata.get("event_type").and_then(|v| v.as_str()),
Some("direct_message") Some("direct_message")
); );
// sender_name must be present for conversation_context
assert_eq!( assert_eq!(
metadata.get("sender_name").and_then(|v| v.as_str()), metadata.get("sender_name").and_then(|v| v.as_str()),
Some("alice") 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] #[test]
fn build_send_body_slack() { fn build_send_body_slack() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890")); let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
assert_eq!(method, "chat.postMessage"); assert_eq!(method, "chat.postMessage");
assert_eq!(body["channel"], "C456"); assert_eq!(body["channel"], "C456");
@@ -695,72 +497,95 @@ mod tests {
assert_eq!(body["thread_ts"], "1234567.890"); assert_eq!(body["thread_ts"], "1234567.890");
} }
#[test] #[tokio::test]
fn parser_handle_is_shared_arc() { async fn start_processes_events() {
let channel = RelayChannel::new( let (tx, rx) = mpsc::channel(64);
test_client(), let channel =
"token".into(), RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
"T123".into(),
"inst1".into(), let mut stream = channel.start().await.unwrap();
"user1".into(),
); // Send an event
// parser_handle should be an Arc — cloning should give a second reference tx.send(ChannelEvent {
let handle_clone = Arc::clone(&channel.parser_handle); id: "1".into(),
// Both point to the same allocation event_type: "message".into(),
assert!(Arc::ptr_eq(&channel.parser_handle, &handle_clone)); 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] #[tokio::test]
fn with_max_failures_sets_value() { async fn start_skips_non_message_events() {
let channel = RelayChannel::new( let (tx, rx) = mpsc::channel(64);
test_client(), let channel =
"token".into(), RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_max_failures(10);
assert_eq!(channel.max_consecutive_failures, 10); let mut stream = channel.start().await.unwrap();
}
#[test] // Send a non-message event (should be skipped)
fn default_max_failures_is_50() { tx.send(ChannelEvent {
let channel = RelayChannel::new( id: "1".into(),
test_client(), event_type: "reaction".into(),
"token".into(), provider: "slack".into(),
"T123".into(), provider_scope: "T123".into(),
"inst1".into(), channel_id: "C456".into(),
"user1".into(), sender_id: "U789".into(),
); sender_name: None,
assert_eq!(channel.max_consecutive_failures, 50); content: None,
} thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
})
.await
.unwrap();
#[test] // Send a real message
fn empty_team_id_accepted_at_construction() { tx.send(ChannelEvent {
// Regression: empty team_id (when no DB store is available) must not id: "2".into(),
// prevent channel construction or cause immediate shutdown. event_type: "message".into(),
let channel = RelayChannel::new( provider: "slack".into(),
test_client(), provider_scope: "T123".into(),
"token".into(), channel_id: "C456".into(),
String::new(), // empty team_id sender_id: "U789".into(),
"inst1".into(), sender_name: None,
"user1".into(), content: Some("real message".into()),
); thread_id: None,
assert_eq!(channel.team_id, ""); raw: serde_json::Value::Null,
// The reconnect loop now skips team validation when team_id is empty, timestamp: None,
// so the channel remains alive. })
.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] #[tokio::test]
async fn test_send_status_non_approval_is_noop() { async fn test_send_status_non_approval_is_noop() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({}); let metadata = serde_json::json!({});
let result = channel let result = channel
.send_status( .send_status(
@@ -775,13 +600,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_send_status_approval_non_dm_skips() { async fn test_send_status_approval_non_dm_skips() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({ let metadata = serde_json::json!({
"event_type": "message", "event_type": "message",
"channel_id": "C456", "channel_id": "C456",
@@ -794,6 +613,7 @@ mod tests {
tool_name: "shell".into(), tool_name: "shell".into(),
description: "run command".into(), description: "run command".into(),
parameters: serde_json::json!({}), parameters: serde_json::json!({}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -804,13 +624,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_send_status_approval_dm_missing_channel_id_errors() { async fn test_send_status_approval_dm_missing_channel_id_errors() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({ let metadata = serde_json::json!({
"event_type": "direct_message", "event_type": "direct_message",
"sender_id": "U789", "sender_id": "U789",
@@ -822,6 +636,7 @@ mod tests {
tool_name: "shell".into(), tool_name: "shell".into(),
description: "run command".into(), description: "run command".into(),
parameters: serde_json::json!({}), parameters: serde_json::json!({}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -835,14 +650,8 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_send_status_approval_dm_missing_sender_id_errors() { async fn test_send_status_approval_dm_without_sender_id_is_ok() {
let channel = RelayChannel::new( let channel = make_channel();
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({ let metadata = serde_json::json!({
"event_type": "direct_message", "event_type": "direct_message",
"channel_id": "C456", "channel_id": "C456",
@@ -854,6 +663,7 @@ mod tests {
tool_name: "shell".into(), tool_name: "shell".into(),
description: "run command".into(), description: "run command".into(),
parameters: serde_json::json!({}), parameters: serde_json::json!({}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -861,8 +671,8 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
let err = result.unwrap_err().to_string(); let err = result.unwrap_err().to_string();
assert!( assert!(
err.contains("sender_id"), !err.contains("sender_id"),
"expected sender_id error, got: {err}" "sender_id should not be required anymore, got: {err}"
); );
} }
} }
+90 -205
View File
@@ -1,15 +1,10 @@
//! HTTP client for the channel-relay service. //! HTTP client for the channel-relay service.
//! //!
//! Wraps reqwest for all channel-relay API calls: OAuth initiation, //! 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 secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
/// Known relay event types. /// Known relay event types.
pub mod event_types { pub mod event_types {
@@ -18,7 +13,7 @@ pub mod event_types {
pub const MENTION: &str = "mention"; 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. /// Field names match the channel-relay `ChannelEvent` struct exactly.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -123,21 +118,19 @@ impl RelayClient {
/// ///
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and /// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
/// returns the `Location` header (Slack OAuth URL) without following it. /// returns the `Location` header (Slack OAuth URL) without following it.
pub async fn initiate_oauth( /// Initiate Slack OAuth. Channel-relay derives all URLs from the trusted
&self, /// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
instance_id: &str, /// for validating the callback — no URLs.
user_id: &str, pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
callback_url: &str, let mut query: Vec<(&str, &str)> = vec![];
) -> Result<String, RelayError> { if let Some(nonce) = state_nonce {
query.push(("state_nonce", nonce));
}
let resp = self let resp = self
.http .http
.get(format!("{}/oauth/slack/auth", self.base_url)) .get(format!("{}/oauth/slack/auth", self.base_url))
.header("X-API-Key", self.api_key.expose_secret()) .bearer_auth(self.api_key.expose_secret())
.query(&[ .query(&query)
("instance_id", instance_id),
("user_id", user_id),
("callback", callback_url),
])
.send() .send()
.await .await
.map_err(|e| RelayError::Network(e.to_string()))?; .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 /// Calls `POST /approvals` with the target team/channel/request identifiers.
/// background SSE parser task. The caller is responsible for reconnection /// The returned token is embedded in Slack button values instead of routing fields.
/// logic on stream end/error and for aborting the handle on shutdown. /// The relay derives the authorized approver from the connection's authed_user_id.
pub async fn connect_stream( pub async fn create_approval(
&self, &self,
stream_token: &str, team_id: &str,
stream_timeout_secs: u64, channel_id: &str,
) -> Result<(ChannelEventStream, tokio::task::JoinHandle<()>), RelayError> { thread_ts: Option<&str>,
let resp = self request_id: &str,
.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,
) -> Result<String, RelayError> { ) -> 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 let resp = self
.http .http
.post(format!("{}/stream/renew", self.base_url)) .post(format!("{}/approvals", self.base_url))
.header("X-API-Key", self.api_key.expose_secret()) .bearer_auth(self.api_key.expose_secret())
.json(&serde_json::json!({ .json(&body)
"instance_id": instance_id,
"user_id": user_id,
}))
.send() .send()
.await .await
.map_err(|e| RelayError::Network(e.to_string()))?; .map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status(); if !resp.status().is_success() {
if !status.is_success() { let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api { return Err(RelayError::Api {
status: status.as_u16(), status,
message: body, message: body,
}); });
} }
let body: serde_json::Value = resp let result: serde_json::Value = resp
.json() .json()
.await .await
.map_err(|e| RelayError::Protocol(e.to_string()))?; .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()) .and_then(|v| v.as_str())
.map(|s| s.to_string()) .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( pub async fn proxy_provider(
&self, &self,
provider: &str, provider: &str,
team_id: &str, team_id: &str,
method: &str, method: &str,
body: serde_json::Value, body: serde_json::Value,
instance_id: Option<&str>,
) -> Result<serde_json::Value, RelayError> { ) -> Result<serde_json::Value, RelayError> {
let mut query: Vec<(&str, &str)> = vec![("team_id", team_id)]; let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
if let Some(iid) = instance_id {
query.push(("instance_id", iid));
}
let resp = self let resp = self
.http .http
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method)) .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) .query(&query)
.json(&body) .json(&body)
.send() .send()
@@ -291,12 +249,58 @@ impl RelayClient {
.map_err(|e| RelayError::Protocol(e.to_string())) .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. /// List active connections for an instance.
pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> { pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> {
let resp = self let resp = self
.http .http
.get(format!("{}/connections", self.base_url)) .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)]) .query(&[("instance_id", instance_id)])
.send() .send()
.await .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. /// Errors from relay client operations.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum RelayError { pub enum RelayError {
@@ -413,9 +332,6 @@ pub enum RelayError {
#[error("Protocol error: {0}")] #[error("Protocol error: {0}")]
Protocol(String), Protocol(String),
#[error("Stream token expired")]
TokenExpired,
} }
#[cfg(test)] #[cfg(test)]
@@ -494,9 +410,6 @@ mod tests {
message: "unauthorized".into(), message: "unauthorized".into(),
}; };
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized"); assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
let err = RelayError::TokenExpired;
assert_eq!(err.to_string(), "Stream token expired");
} }
#[test] #[test]
@@ -518,32 +431,4 @@ mod tests {
assert!(make(event_types::DIRECT_MESSAGE).is_message()); assert!(make(event_types::DIRECT_MESSAGE).is_message());
assert!(make(event_types::MENTION).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 //! Channel-relay integration for connecting to external messaging platforms
//! (Slack) via the channel-relay service. //! (Slack) via the channel-relay service.
//! //!
//! The relay service handles OAuth, credential storage, webhook ingestion, //! The relay service handles OAuth, credential storage, and webhook ingestion.
//! and SSE event streaming. IronClaw consumes the SSE stream and sends //! IronClaw receives events via webhook callbacks and sends messages via the
//! messages via the relay's proxy API. //! relay's proxy API.
pub mod channel; pub mod channel;
pub mod client; pub mod client;
pub mod webhook;
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel}; pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
pub use client::RelayClient; 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, tool_name,
description, description,
parameters, parameters,
allow_always,
} => { } => {
let term_width = crossterm::terminal::size() let term_width = crossterm::terminal::size()
.map(|(w, _)| w as usize) .map(|(w, _)| w as usize)
@@ -582,9 +583,13 @@ impl Channel for ReplChannel {
} }
eprintln!(" \u{2502}"); eprintln!(" \u{2502}");
eprintln!( if allow_always {
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)" 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!(" {bot_border}");
eprintln!(); eprintln!();
} }
+11 -3
View File
@@ -915,20 +915,28 @@ impl Channel for SignalChannel {
tool_name, tool_name,
description: _, description: _,
parameters, parameters,
allow_always,
} = &status } = &status
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) && 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 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!( let message = format!(
"⚠️ *Approval Required*\n\n\ "⚠️ *Approval Required*\n\n\
*Request ID:* `{}`\n\ *Request ID:* `{}`\n\
*Tool:* {}\n\ *Tool:* {}\n\
*Parameters:*\n```\n{}\n```\n\n\ *Parameters:*\n```\n{}\n```\n\n\
Reply with:\n\ Reply with:\n\
`yes` or `y` - Approve this request\n\ `yes` or `y` - Approve this request{}\n\
`always` or `a` - Approve and auto-approve future {} requests\n\
`no` or `n` - Deny", `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; 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"); tracing::debug!(body = %truncated, "Response body");
} }
// Leak detection on response body (best-effort) // Leak detection on response body (best-effort).
if let Ok(body_str) = std::str::from_utf8(&body) { //
// 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 leak_detector
.scan_and_clean(body_str) .scan_and_clean(body_str)
.map_err(|e| format!("Potential secret leak in response: {}", e))?; .map_err(|e| format!("Potential secret leak in response: {}", e))?;
@@ -2035,6 +2043,7 @@ impl WasmChannel {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
.. ..
} => { } => {
// WASM channels (Telegram, Slack, etc.) cannot render // WASM channels (Telegram, Slack, etc.) cannot render
@@ -2073,6 +2082,11 @@ impl WasmChannel {
}) })
.unwrap_or_default(); .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!( let prompt = format!(
"Approval needed: {tool_name}\n\ "Approval needed: {tool_name}\n\
{description}\n\ {description}\n\
@@ -2080,7 +2094,7 @@ impl WasmChannel {
Parameters:\n\ Parameters:\n\
{params_preview}\n\ {params_preview}\n\
\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(); let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
@@ -2973,15 +2987,23 @@ fn status_to_wit(
request_id, request_id,
tool_name, tool_name,
description, description,
allow_always,
.. ..
} => wit_channel::StatusUpdate { } => {
status: wit_channel::StatusType::ApprovalNeeded, let reply_hint = if *allow_always {
message: format!( "yes (or /approve), no (or /deny), or always (or /always)"
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: yes (or /approve), no (or /deny), or always (or /always).", } else {
tool_name, description, request_id "yes (or /approve) or no (or /deny)"
), };
metadata_json, 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 { StatusUpdate::JobStarted {
job_id, job_id,
title, 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. /// Pre-resolve host credentials for all HTTP capability mappings.
/// ///
/// Called once per callback (in async context, before spawn_blocking) so the /// Called once per callback (in async context, before spawn_blocking) so the
@@ -3649,6 +3684,7 @@ mod tests {
tool_name: "http_request".into(), tool_name: "http_request".into(),
description: "Fetch weather".into(), description: "Fetch weather".into(),
parameters: serde_json::json!({"url": "https://wttr.in"}), parameters: serde_json::json!({"url": "https://wttr.in"}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -4110,6 +4146,7 @@ mod tests {
tool_name: "http_request".to_string(), tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(), description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}), parameters: serde_json::json!({"url": "https://api.weather.test"}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -4135,6 +4172,7 @@ mod tests {
tool_name: "http_request".to_string(), tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(), description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}), parameters: serde_json::json!({"url": "https://api.weather.test"}),
allow_always: true,
}, },
&metadata, &metadata,
) )
@@ -4386,6 +4424,22 @@ mod tests {
assert_eq!(store.redact_credentials(input), input); 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 /// Verify that WASM HTTP host functions work using a dedicated
/// current-thread runtime inside spawn_blocking. /// current-thread runtime inside spawn_blocking.
#[tokio::test] #[tokio::test]
+10
View File
@@ -102,6 +102,7 @@ impl GatewayChannel {
cost_guard: None, cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: server::ActiveConfigSnapshot::default(),
}); });
Self { Self {
@@ -139,6 +140,7 @@ impl GatewayChannel {
cost_guard: self.state.cost_guard.clone(), cost_guard: self.state.cost_guard.clone(),
routine_engine: Arc::clone(&self.state.routine_engine), routine_engine: Arc::clone(&self.state.routine_engine),
startup_time: self.state.startup_time, startup_time: self.state.startup_time,
active_config: self.state.active_config.clone(),
}; };
mutate(&mut new_state); mutate(&mut new_state);
self.state = Arc::new(new_state); self.state = Arc::new(new_state);
@@ -250,6 +252,12 @@ impl GatewayChannel {
self 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). /// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str { pub fn auth_token(&self) -> &str {
&self.auth_token &self.auth_token
@@ -366,6 +374,7 @@ impl Channel for GatewayChannel {
tool_name, tool_name,
description, description,
parameters, parameters,
allow_always,
} => SseEvent::ApprovalNeeded { } => SseEvent::ApprovalNeeded {
request_id, request_id,
tool_name, tool_name,
@@ -373,6 +382,7 @@ impl Channel for GatewayChannel {
parameters: serde_json::to_string_pretty(&parameters) parameters: serde_json::to_string_pretty(&parameters)
.unwrap_or_else(|_| parameters.to_string()), .unwrap_or_else(|_| parameters.to_string()),
thread_id, thread_id,
allow_always,
}, },
StatusUpdate::AuthRequired { StatusUpdate::AuthRequired {
extension_name, extension_name,
+253 -81
View File
@@ -19,6 +19,7 @@ use axum::{
routing::{get, post}, routing::{get, post},
}; };
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256};
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tower_http::cors::{AllowHeaders, CorsLayer}; use tower_http::cors::{AllowHeaders, CorsLayer};
@@ -63,6 +64,16 @@ pub type PromptQueue = Arc<
pub type RoutineEngineSlot = pub type RoutineEngineSlot =
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>; 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. /// Simple sliding-window rate limiter.
/// ///
/// Tracks the number of requests in the current window. Resets when the window expires. /// 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. /// Shared state for all gateway handlers.
pub struct GatewayState { pub struct GatewayState {
/// Channel to send messages to the agent loop. /// Channel to send messages to the agent loop.
@@ -177,6 +196,8 @@ pub struct GatewayState {
pub routine_engine: RoutineEngineSlot, pub routine_engine: RoutineEngineSlot,
/// Server startup time for uptime calculation. /// Server startup time for uptime calculation.
pub startup_time: std::time::Instant, pub startup_time: std::time::Instant,
/// Snapshot of active (resolved) configuration for the frontend.
pub active_config: ActiveConfigSnapshot,
} }
/// Start the gateway HTTP server. /// Start the gateway HTTP server.
@@ -208,7 +229,8 @@ pub async fn start_server(
.route( .route(
"/oauth/slack/callback", "/oauth/slack/callback",
get(slack_relay_oauth_callback_handler), get(slack_relay_oauth_callback_handler),
); )
.route("/relay/events", post(relay_events_handler));
// Protected routes (require auth) // Protected routes (require auth)
let auth_state = AuthState { token: auth_token }; let auth_state = AuthState { token: auth_token };
@@ -555,22 +577,35 @@ async fn oauth_callback_handler(
} }
}; };
// Strip instance prefix from state for registry lookup. let decoded_state = match oauth_defaults::decode_hosted_oauth_state(&state_param) {
// Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only. Ok(decoded) => decoded,
let lookup_key = oauth_defaults::strip_instance_prefix(&state_param); 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 let flow = ext_mgr
.pending_oauth_flows() .pending_oauth_flows()
.write() .write()
.await .await
.remove(lookup_key); .remove(&lookup_key);
let flow = match flow { let flow = match flow {
Some(f) => f, Some(f) => f,
None => { None => {
let redacted_state = redact_oauth_state_for_logs(&state_param);
let redacted_lookup_key = redact_oauth_state_for_logs(&lookup_key);
tracing::warn!( tracing::warn!(
state = %state_param, state = %redacted_state,
lookup_key = %lookup_key, lookup_key = %redacted_lookup_key,
"OAuth callback received with unknown or expired state" "OAuth callback received with unknown or expired state"
); );
clear_auth_mode(&state).await; clear_auth_mode(&state).await;
@@ -597,33 +632,29 @@ async fn oauth_callback_handler(
} }
// Exchange the authorization code for tokens. // Exchange the authorization code for tokens.
// Use the platform exchange proxy when configured (keeps client_secret off container), // Use the platform exchange proxy when configured, otherwise call the
// otherwise call the provider's token URL directly. // provider's token URL directly.
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok(); let exchange_proxy_url = oauth_defaults::exchange_proxy_url();
let result: Result<(), String> = async { let result: Result<(), String> = async {
let token_response = if let (Some(proxy_url), None) = (&exchange_proxy_url, &flow.resource) let token_response = if let Some(proxy_url) = &exchange_proxy_url {
{
// 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 gateway_token = flow.gateway_token.as_deref().unwrap_or_default(); 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, proxy_url,
gateway_token, gateway_token,
&code, token_url: &flow.token_url,
&flow.redirect_uri, client_id: &flow.client_id,
flow.code_verifier.as_deref(), client_secret: flow.client_secret.as_deref(),
&flow.access_token_field, 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 .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
} else { } else {
// Direct token exchange: uses exchange_oauth_code_with_resource so MCP oauth_defaults::exchange_oauth_code_with_params(
// 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(
&flow.token_url, &flow.token_url,
&flow.client_id, &flow.client_id,
flow.client_secret.as_deref(), flow.client_secret.as_deref(),
@@ -631,7 +662,7 @@ async fn oauth_callback_handler(
&flow.redirect_uri, &flow.redirect_uri,
flow.code_verifier.as_deref(), flow.code_verifier.as_deref(),
&flow.access_token_field, &flow.access_token_field,
flow.resource.as_deref(), &flow.token_exchange_extra_params,
) )
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
@@ -658,10 +689,8 @@ async fn oauth_callback_handler(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// For MCP OAuth flows (identified by resource field), persist the // Persist the client_id for flows that need it after the session ends
// client_id so token refresh works without re-authentication. // (for example DCR-based MCP refresh).
// The CLI flow stores this in authorize_mcp_server(); the gateway
// callback must do the same.
if let Some(ref client_id_secret) = flow.client_id_secret_name { if let Some(ref client_id_secret) = flow.client_id_secret_name {
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id) let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default()); .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() 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. /// OAuth callback for Slack via channel-relay.
/// ///
/// This is a PUBLIC route (no Bearer token required) because channel-relay /// This is a PUBLIC route (no Bearer token required) because channel-relay
/// redirects the user's browser here after Slack OAuth completes. /// 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( async fn slack_relay_oauth_callback_handler(
State(state): State<Arc<GatewayState>>, State(state): State<Arc<GatewayState>>,
Query(params): Query<std::collections::HashMap<String, String>>, Query(params): Query<std::collections::HashMap<String, String>>,
@@ -763,27 +884,6 @@ async fn slack_relay_oauth_callback_handler(
.into_response(); .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) // Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
let team_id = params.get("team_id").cloned().unwrap_or_default(); let team_id = params.get("team_id").cloned().unwrap_or_default();
if !team_id.is_empty() { 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 _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
let result: Result<(), String> = async { let result: Result<(), String> = async {
// Store the stream token as a secret let store = state.store.as_ref().ok_or_else(|| {
let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME); "Relay activation requires persistent settings storage; no-db mode is unsupported."
let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await; .to_string()
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))?;
// Store team_id in settings // Store team_id in settings
if let Some(ref store) = state.store { let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME); let _ = store
let _ = store .set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id)) .await;
.await;
}
// Activate the relay channel // Activate the relay channel
ext_mgr ext_mgr
@@ -2669,6 +2755,9 @@ async fn gateway_status_handler(
daily_cost, daily_cost,
actions_this_hour, actions_this_hour,
model_usage, 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>, actions_this_hour: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
model_usage: Option<Vec<ModelUsageEntry>>, model_usage: Option<Vec<ModelUsageEntry>>,
llm_backend: String,
llm_model: String,
enabled_channels: Vec<String>,
} }
#[cfg(test)] #[cfg(test)]
@@ -2890,6 +2982,7 @@ mod tests {
cost_guard: None, cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(),
}) })
} }
@@ -3236,7 +3329,7 @@ mod tests {
secrets, secrets,
sse_sender: None, sse_sender: None,
gateway_token: None, gateway_token: None,
resource: None, token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None, client_id_secret_name: None,
created_at, created_at,
}; };
@@ -3304,7 +3397,7 @@ mod tests {
secrets, secrets,
sse_sender: Some(sender), sse_sender: Some(sender),
gateway_token: None, gateway_token: None,
resource: None, token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None, client_id_secret_name: None,
created_at, created_at,
}; };
@@ -3407,7 +3500,7 @@ mod tests {
secrets, secrets,
sse_sender: None, sse_sender: None,
gateway_token: None, gateway_token: None,
resource: None, token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None, client_id_secret_name: None,
// Expired — handler will reject after lookup (no network I/O) // Expired — handler will reject after lookup (no network I/O)
created_at, 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 --- // --- Slack relay OAuth CSRF tests ---
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router { fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
@@ -3516,7 +3688,7 @@ mod tests {
// Callback without state param should be rejected // Callback without state param should be rejected
let req = axum::http::Request::builder() 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()) .body(Body::empty())
.expect("request"); .expect("request");
@@ -3560,7 +3732,7 @@ mod tests {
// Callback with wrong state param // Callback with wrong state param
let req = axum::http::Request::builder() 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()) .body(Body::empty())
.expect("request"); .expect("request");
@@ -3608,7 +3780,7 @@ mod tests {
// we just verify it doesn't return a CSRF error. // we just verify it doesn't return a CSRF error.
let req = axum::http::Request::builder() let req = axum::http::Request::builder()
.uri(format!( .uri(format!(
"/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}", "/oauth/slack/callback?team_id=T123&provider=slack&state={}",
nonce nonce
)) ))
.body(Body::empty()) .body(Body::empty())
File diff suppressed because it is too large Load Diff
+168 -8
View File
@@ -29,9 +29,15 @@ I18n.register('en', {
'tab.memory': 'Memory', 'tab.memory': 'Memory',
'tab.jobs': 'Jobs', 'tab.jobs': 'Jobs',
'tab.routines': 'Routines', 'tab.routines': 'Routines',
'tab.settings': 'Settings',
'tab.extensions': 'Extensions', 'tab.extensions': 'Extensions',
'tab.skills': 'Skills', 'tab.skills': 'Skills',
'tab.logs': 'Logs', 'tab.logs': 'Logs',
'settings.inference': 'Inference',
'settings.agent': 'Agent',
'settings.channels': 'Channels',
'settings.networking': 'Networking',
'settings.mcp': 'MCP',
// Status // Status
'status.connected': 'Connected', 'status.connected': 'Connected',
@@ -131,10 +137,10 @@ I18n.register('en', {
// Extensions Tab // Extensions Tab
'extensions.installed': 'Installed Extensions', 'extensions.installed': 'Installed Extensions',
'extensions.available': 'Available WASM Extensions', 'extensions.available': 'Available Extensions',
'extensions.installWasm': 'Install WASM Extension', 'extensions.installWasm': 'Install Extension',
'extensions.noInstalled': 'No extensions installed', 'extensions.noInstalled': 'No extensions installed',
'extensions.noAvailable': 'No additional WASM extensions available', 'extensions.noAvailable': 'No additional extensions available',
'extensions.loading': 'Loading...', 'extensions.loading': 'Loading...',
'extensions.install': 'Install', 'extensions.install': 'Install',
'extensions.installing': 'Installing...', 'extensions.installing': 'Installing...',
@@ -157,11 +163,6 @@ I18n.register('en', {
'mcp.add': 'Add', 'mcp.add': 'Add',
'mcp.addedSuccess': 'Added MCP server {name}', '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 Tab
'skills.installed': 'Installed Skills', 'skills.installed': 'Installed Skills',
@@ -302,6 +303,7 @@ I18n.register('en', {
// Common // Common
'common.loading': 'Loading...', 'common.loading': 'Loading...',
'common.loadFailed': 'Failed to load',
'common.noData': 'No data', 'common.noData': 'No data',
'common.search': 'Search', 'common.search': 'Search',
'common.add': 'Add', 'common.add': 'Add',
@@ -328,6 +330,8 @@ I18n.register('en', {
// Extensions // Extensions
'ext.active': 'Active', 'ext.active': 'Active',
'ext.inactive': 'Inactive',
'ext.builtin': 'Built-in',
'ext.remove': 'Remove', 'ext.remove': 'Remove',
'ext.install': 'Install', 'ext.install': 'Install',
'ext.installing': 'Installing...', 'ext.installing': 'Installing...',
@@ -355,4 +359,160 @@ I18n.register('en', {
'config.autoGenerate': 'Auto-generated if empty', 'config.autoGenerate': 'Auto-generated if empty',
'config.save': 'Save', 'config.save': 'Save',
'config.cancel': 'Cancel', '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}',
}); });
+168 -8
View File
@@ -29,9 +29,15 @@ I18n.register('zh-CN', {
'tab.memory': '记忆', 'tab.memory': '记忆',
'tab.jobs': '任务', 'tab.jobs': '任务',
'tab.routines': '定时任务', 'tab.routines': '定时任务',
'tab.settings': '设置',
'tab.extensions': '扩展', 'tab.extensions': '扩展',
'tab.skills': '技能', 'tab.skills': '技能',
'tab.logs': '日志', 'tab.logs': '日志',
'settings.inference': '推理',
'settings.agent': '代理',
'settings.channels': '频道',
'settings.networking': '网络',
'settings.mcp': 'MCP',
// 状态 // 状态
'status.connected': '已连接', 'status.connected': '已连接',
@@ -131,10 +137,10 @@ I18n.register('zh-CN', {
// 扩展标签页 // 扩展标签页
'extensions.installed': '已安装扩展', 'extensions.installed': '已安装扩展',
'extensions.available': '可用 WASM 扩展', 'extensions.available': '可用扩展',
'extensions.installWasm': '安装 WASM 扩展', 'extensions.installWasm': '安装扩展',
'extensions.noInstalled': '没有安装扩展', 'extensions.noInstalled': '没有安装扩展',
'extensions.noAvailable': '没有其他可用的 WASM 扩展', 'extensions.noAvailable': '没有其他可用扩展',
'extensions.loading': '加载中...', 'extensions.loading': '加载中...',
'extensions.install': '安装', 'extensions.install': '安装',
'extensions.installing': '安装中...', 'extensions.installing': '安装中...',
@@ -157,11 +163,6 @@ I18n.register('zh-CN', {
'mcp.add': '添加', 'mcp.add': '添加',
'mcp.addedSuccess': '已添加 MCP 服务器 {name}', 'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
// 注册工具
'tools.registered': '注册工具',
'tools.name': '名称',
'tools.description': '描述',
'tools.empty': '没有注册工具',
// 技能标签页 // 技能标签页
'skills.installed': '已安装技能', 'skills.installed': '已安装技能',
@@ -302,6 +303,7 @@ I18n.register('zh-CN', {
// 通用 // 通用
'common.loading': '加载中...', 'common.loading': '加载中...',
'common.loadFailed': '加载失败',
'common.noData': '暂无数据', 'common.noData': '暂无数据',
'common.search': '搜索', 'common.search': '搜索',
'common.add': '添加', 'common.add': '添加',
@@ -328,6 +330,8 @@ I18n.register('zh-CN', {
// 扩展 // 扩展
'ext.active': '已激活', 'ext.active': '已激活',
'ext.inactive': '未激活',
'ext.builtin': '内置',
'ext.remove': '移除', 'ext.remove': '移除',
'ext.install': '安装', 'ext.install': '安装',
'ext.installing': '安装中...', 'ext.installing': '安装中...',
@@ -354,4 +358,160 @@ I18n.register('zh-CN', {
'config.autoGenerate': '如果为空则自动生成', 'config.autoGenerate': '如果为空则自动生成',
'config.save': '保存', 'config.save': '保存',
'config.cancel': '取消', '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="memory" data-i18n="tab.memory">Memory</button>
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button> <button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
<button data-tab="routines" data-i18n="tab.routines">Routines</button> <button data-tab="routines" data-i18n="tab.routines">Routines</button>
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button> <button data-tab="settings" data-i18n="tab.settings">Settings</button>
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
<div class="spacer"></div> <div class="spacer"></div>
<!-- Language Switcher --> <!-- Language Switcher -->
@@ -271,77 +270,125 @@
</div> </div>
</div> </div>
<!-- Extensions Tab --> <!-- Settings Tab -->
<div class="tab-panel" id="tab-extensions"> <div class="tab-panel" id="tab-settings">
<div class="extensions-container"> <div class="settings-layout">
<div class="extensions-section"> <div class="settings-sidebar">
<h3 data-i18n="extensions.installed">Installed Extensions</h3> <button class="settings-subtab active" data-settings-subtab="inference" data-i18n="settings.inference">Inference</button>
<div class="extensions-list" id="extensions-list"> <button class="settings-subtab" data-settings-subtab="agent" data-i18n="settings.agent">Agent</button>
<div class="empty-state" data-i18n="common.loading">Loading...</div> <button class="settings-subtab" data-settings-subtab="channels" data-i18n="settings.channels">Channels</button>
</div> <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>
<div class="extensions-section" id="available-wasm-section"> <div class="settings-content">
<h3 data-i18n="extensions.available">Available WASM Extensions</h3> <div class="settings-toolbar">
<div class="extensions-list" id="available-wasm-list"> <div class="settings-search">
<div class="empty-state" data-i18n="common.loading">Loading...</div> <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> <div class="settings-subpanel active" id="settings-inference">
<div class="extensions-section"> <div class="extensions-container" id="settings-inference-content">
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3> <div class="empty-state" data-i18n="common.loading">Loading settings...</div>
<div class="ext-install-form"> </div>
<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 class="settings-subpanel" id="settings-agent">
<div class="extensions-section"> <div class="extensions-container" id="settings-agent-content">
<h3 data-i18n="mcp.servers">MCP Servers</h3> <div class="empty-state" data-i18n="common.loading">Loading settings...</div>
<div class="extensions-list" id="mcp-servers-list"> </div>
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div> </div>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4> <div class="settings-subpanel" id="settings-channels">
<div class="ext-install-form"> <div class="extensions-container" id="settings-channels-content">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name"> <div class="empty-state" data-i18n="common.loading">Loading channels...</div>
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)"> </div>
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button> </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>
<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>
</div> </div>
</div>
<!-- Skills Tab --> <!-- Confirmation Modal -->
<div class="tab-panel" id="tab-skills"> <div id="confirm-modal" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
<div class="extensions-container"> <div class="modal">
<div class="extensions-section"> <h3 id="confirm-modal-title"></h3>
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3> <p id="confirm-modal-message"></p>
<div class="skill-search-box"> <div class="modal-actions">
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search..."> <button id="confirm-modal-cancel-btn" class="btn-secondary" data-i18n="btn.cancel">Cancel</button>
<button id="skill-search-btn" data-i18n="skills.search">Search</button> <button id="confirm-modal-btn" class="btn-danger">Confirm</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>
</div> </div>
+545 -61
View File
@@ -18,6 +18,12 @@
--radius-lg: 12px; --radius-lg: 12px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4); --shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace; --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 { .restart-loader-content {
position: relative; position: relative;
z-index: 10000; z-index: 10000;
background-color: #1a1a1a; background-color: var(--bg-secondary);
border: 1px solid #333; border: 1px solid var(--border);
border-radius: 0.75rem; 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%; width: 100%;
max-width: 28rem; max-width: 28rem;
margin: 0 1rem; margin: 0 1rem;
@@ -352,7 +358,7 @@ body {
} }
.restart-title { .restart-title {
color: #e0e0e0; color: var(--text);
font-size: 0.85rem; font-size: 0.85rem;
margin-bottom: 1rem; margin-bottom: 1rem;
margin-top: 0; margin-top: 0;
@@ -388,10 +394,10 @@ body {
.restart-modal-content { .restart-modal-content {
position: relative; position: relative;
z-index: 10000; z-index: 10000;
background-color: #1a1a1a; background-color: var(--bg-secondary);
border: 1px solid #333; border: 1px solid var(--border);
border-radius: 0.75rem; 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%; width: 100%;
max-width: 28rem; max-width: 28rem;
margin: 0 1rem; margin: 0 1rem;
@@ -403,11 +409,11 @@ body {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border-bottom: 1px solid #2a2a2a; border-bottom: 1px solid var(--border);
} }
.restart-modal-header h2 { .restart-modal-header h2 {
color: #e0e0e0; color: var(--text);
font-size: 0.95rem; font-size: 0.95rem;
margin: 0; margin: 0;
} }
@@ -426,8 +432,8 @@ body {
} }
.restart-modal-close:hover { .restart-modal-close:hover {
color: #ccc; color: var(--text-secondary);
background-color: #2a2a2a; background-color: var(--bg-tertiary);
} }
.restart-modal-body { .restart-modal-body {
@@ -435,21 +441,21 @@ body {
} }
.restart-modal-description { .restart-modal-description {
color: #aaa; color: var(--text-secondary);
font-size: 0.85rem; font-size: 0.85rem;
margin: 0; margin: 0;
} }
.restart-modal-warning { .restart-modal-warning {
margin-top: 1rem; margin-top: 1rem;
background-color: #1e1400; background-color: var(--warning-soft);
border: 1px solid #3a2a00; border: 1px solid rgba(245, 166, 35, 0.25);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
.restart-modal-warning p { .restart-modal-warning p {
color: #facc15; color: var(--warning);
font-size: 0.8rem; font-size: 0.8rem;
margin: 0; margin: 0;
} }
@@ -460,7 +466,7 @@ body {
justify-content: flex-end; justify-content: flex-end;
gap: 0.75rem; gap: 0.75rem;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border-top: 1px solid #2a2a2a; border-top: 1px solid var(--border);
} }
.restart-modal-btn { .restart-modal-btn {
@@ -473,28 +479,28 @@ body {
} }
.restart-modal-btn.cancel { .restart-modal-btn.cancel {
color: #ccc; color: var(--text-secondary);
background-color: transparent; background-color: transparent;
} }
.restart-modal-btn.cancel:hover { .restart-modal-btn.cancel:hover {
background-color: #2a2a2a; background-color: var(--bg-tertiary);
} }
.restart-modal-btn.confirm { .restart-modal-btn.confirm {
background-color: #00D894; background-color: var(--accent);
color: #111; color: #09090b;
} }
.restart-modal-btn.confirm:hover { .restart-modal-btn.confirm:hover {
background-color: #00be82; background-color: var(--accent-hover);
} }
/* Progress Bar for Restart */ /* Progress Bar for Restart */
.restart-progress-bar { .restart-progress-bar {
width: 100%; width: 100%;
height: 0.375rem; height: 0.375rem;
background-color: #2a2a2a; background-color: var(--bg-tertiary);
border-radius: 9999px; border-radius: 9999px;
overflow: hidden; overflow: hidden;
} }
@@ -502,7 +508,7 @@ body {
.restart-progress-fill { .restart-progress-fill {
height: 100%; height: 100%;
border-radius: 9999px; border-radius: 9999px;
background-color: #00D894; background-color: var(--accent);
width: 40%; width: 40%;
animation: indeterminate 1.5s ease-in-out infinite; animation: indeterminate 1.5s ease-in-out infinite;
} }
@@ -523,14 +529,14 @@ body {
} }
.restart-modal-info { .restart-modal-info {
color: #666; color: var(--text-secondary);
font-size: 0.8rem; font-size: 0.8rem;
margin-top: 1.25rem; margin-top: 1.25rem;
margin-bottom: 0; margin-bottom: 0;
} }
.restart-modal-info a { .restart-modal-info a {
color: #00D894; color: var(--accent);
text-decoration: none; text-decoration: none;
} }
@@ -2522,17 +2528,21 @@ body {
} }
.extensions-section h3 { .extensions-section h3 {
font-size: 15px; font-size: 11px;
font-weight: 600; font-weight: 600;
margin-bottom: 12px; margin-bottom: 12px;
color: var(--text); color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.extensions-section h4 { .extensions-section h4 {
font-size: 13px; font-size: 11px;
font-weight: 600; font-weight: 600;
margin: 16px 0 8px; margin: 16px 0 8px;
color: var(--text-secondary); color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.extensions-list { .extensions-list {
@@ -2544,12 +2554,29 @@ body {
.ext-card { .ext-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border); border: 1px solid var(--border);
border-left: 3px solid transparent;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 14px; padding: 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; 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 { .ext-card:hover {
@@ -2592,6 +2619,11 @@ body {
color: var(--warning); color: var(--warning);
} }
.ext-kind.kind-builtin {
background: rgba(161, 161, 170, 0.15);
color: var(--text-secondary);
}
.ext-version { .ext-version {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
@@ -2767,13 +2799,20 @@ body {
border-radius: var(--radius); border-radius: var(--radius);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
font-weight: 500;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text); color: var(--text);
transition: all var(--transition-fast);
} }
.btn-ext:hover { .btn-ext:hover {
background: var(--border); background: var(--border);
transform: translateY(-1px);
}
.btn-ext:active {
transform: scale(0.97);
} }
.btn-ext.activate { .btn-ext.activate {
@@ -2873,6 +2912,7 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 1000; z-index: 1000;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2893,7 +2933,7 @@ body {
.configure-modal h3 { .configure-modal h3 {
margin: 0 0 16px 0; margin: 0 0 16px 0;
font-size: 16px; font-size: 16px;
color: var(--text-primary); color: var(--text);
} }
.configure-hint { .configure-hint {
@@ -3036,31 +3076,6 @@ body {
justify-content: flex-end; 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 tab (unified sandbox job events) --- */
.activity-terminal { .activity-terminal {
@@ -3714,10 +3729,14 @@ mark {
gap: 8px; gap: 8px;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
} }
.ext-install-form input { .ext-install-form input {
padding: 6px 10px; padding: 8px 12px;
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
@@ -3759,6 +3778,10 @@ mark {
gap: 8px; gap: 8px;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 14px;
} }
.skill-search-box input { .skill-search-box input {
@@ -3795,10 +3818,10 @@ mark {
} }
.skill-trust { .skill-trust {
font-size: 10px; font-size: 11px;
padding: 2px 6px; padding: 3px 8px;
border-radius: 8px; border-radius: 9999px;
font-weight: 500; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
@@ -3942,6 +3965,27 @@ mark {
border-bottom: 1px solid var(--border); 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 */ /* Extension install form */
.ext-install-form { .ext-install-form {
flex-direction: column; 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 command autocomplete dropdown */
.slash-autocomplete { .slash-autocomplete {
position: relative; position: relative;
@@ -4156,3 +4432,211 @@ mark {
padding: 4px 8px; padding: 4px 8px;
background: var(--bg-secondary); 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, cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), 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, parameters: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>, thread_id: Option<String>,
/// Whether the "always" auto-approve option should be shown.
allow_always: bool,
}, },
#[serde(rename = "auth_required")] #[serde(rename = "auth_required")]
AuthRequired { AuthRequired {
@@ -230,6 +232,8 @@ pub enum SseEvent {
status: String, status: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>, session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
fallback_deliverable: Option<serde_json::Value>,
}, },
/// An image was generated by a tool. /// An image was generated by a tool.
@@ -1080,6 +1084,7 @@ mod tests {
description: "Run ls".to_string(), description: "Run ls".to_string(),
parameters: "{}".to_string(), parameters: "{}".to_string(),
thread_id: Some("t1".to_string()), thread_id: Some("t1".to_string()),
allow_always: true,
}; };
let ws = WsServerMessage::from_sse_event(&sse); let ws = WsServerMessage::from_sse_event(&sse);
match ws { match ws {
+1
View File
@@ -521,6 +521,7 @@ mod tests {
cost_guard: None, cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), 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( check(
"NEAR AI session", "NEAR AI session",
check_nearai_session().await, check_nearai_session(&settings).await,
&mut passed, &mut passed,
&mut failed, &mut failed,
&mut skipped, &mut skipped,
@@ -215,7 +215,22 @@ fn check_settings_file() -> CheckResult {
// ── NEAR AI session ───────────────────────────────────────── // ── 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 // Check if session file exists
let session_path = crate::config::llm::default_session_path(); let session_path = crate::config::llm::default_session_path();
if !session_path.exists() { if !session_path.exists() {
@@ -620,12 +635,53 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn check_nearai_session_does_not_panic() { 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 { match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {} 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] #[test]
fn check_settings_file_handles_missing() { fn check_settings_file_handles_missing() {
// Settings::default_path() might or might not exist, but must not panic // 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 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). /// Run a memory command using the Database trait (works with any backend).
pub async fn run_memory_command_with_db( pub async fn run_memory_command_with_db(
cmd: MemoryCommand, cmd: MemoryCommand,
db: std::sync::Arc<dyn crate::db::Database>, db: std::sync::Arc<dyn crate::db::Database>,
embeddings: Option<Arc<dyn EmbeddingProvider>>, embeddings: Option<Arc<dyn EmbeddingProvider>>,
cache_config: EmbeddingCacheConfig,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut workspace = Workspace::new_with_db("default", db); let mut workspace = Workspace::new_with_db("default", db);
if let Some(emb) = embeddings { if let Some(emb) = embeddings {
workspace = workspace.with_embeddings(emb); workspace = workspace.with_embeddings_cached(emb, cache_config);
} }
match cmd { match cmd {
@@ -85,10 +86,11 @@ pub async fn run_memory_command(
cmd: MemoryCommand, cmd: MemoryCommand,
pool: deadpool_postgres::Pool, pool: deadpool_postgres::Pool,
embeddings: Option<Arc<dyn EmbeddingProvider>>, embeddings: Option<Arc<dyn EmbeddingProvider>>,
cache_config: EmbeddingCacheConfig,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut workspace = Workspace::new("default", pool); let mut workspace = Workspace::new("default", pool);
if let Some(emb) = embeddings { if let Some(emb) = embeddings {
workspace = workspace.with_embeddings(emb); workspace = workspace.with_embeddings_cached(emb, cache_config);
} }
match cmd { match cmd {
+4 -1
View File
@@ -336,7 +336,10 @@ pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
.await .await
.map_err(|e| anyhow::anyhow!("{}", e))?; .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)] #[cfg(test)]
+264 -74
View File
@@ -5,17 +5,10 @@
//! //!
//! # Built-in Credentials //! # Built-in Credentials
//! //!
//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials //! Some providers ship with built-in OAuth credentials so users don't need to
//! so users don't need to register their own OAuth app. Google explicitly //! register their own OAuth app just to get started. Today this module only
//! documents that client_secret for "Desktop App" / "Installed App" types //! includes built-in defaults for Google-family tools, and those defaults can
//! is NOT actually secret. //! be overridden by provider-specific environment variables when needed.
//!
//! 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.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -23,6 +16,7 @@ use std::time::Duration;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::sync::RwLock; 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 ────────────────────────────────────────────── // ── Shared callback server ──────────────────────────────────────────────
// Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers` // 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>, code_verifier: Option<&str>,
access_token_field: &str, access_token_field: &str,
) -> Result<OAuthTokenResponse, OAuthCallbackError> { ) -> Result<OAuthTokenResponse, OAuthCallbackError> {
// Delegates to exchange_oauth_code_with_resource with resource=None. let extra_token_params = HashMap::new();
// Non-MCP OAuth flows don't need the RFC 8707 resource parameter. exchange_oauth_code_with_params(
exchange_oauth_code_with_resource(
token_url, token_url,
client_id, client_id,
client_secret, client_secret,
@@ -183,16 +184,14 @@ pub async fn exchange_oauth_code(
redirect_uri, redirect_uri,
code_verifier, code_verifier,
access_token_field, access_token_field,
None, &extra_token_params,
) )
.await .await
} }
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter. /// Exchange an OAuth authorization code for tokens with generic extra form parameters.
///
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn exchange_oauth_code_with_resource( pub async fn exchange_oauth_code_with_params(
token_url: &str, token_url: &str,
client_id: &str, client_id: &str,
client_secret: Option<&str>, client_secret: Option<&str>,
@@ -200,7 +199,7 @@ pub async fn exchange_oauth_code_with_resource(
redirect_uri: &str, redirect_uri: &str,
code_verifier: Option<&str>, code_verifier: Option<&str>,
access_token_field: &str, access_token_field: &str,
resource: Option<&str>, extra_token_params: &HashMap<String, String>,
) -> Result<OAuthTokenResponse, OAuthCallbackError> { ) -> Result<OAuthTokenResponse, OAuthCallbackError> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let mut token_params = vec![ 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())); token_params.push(("code_verifier", verifier.to_string()));
} }
// RFC 8707: include the `resource` parameter so the authorization server for (key, value) in extra_token_params {
// scopes the issued token to the specific MCP server (protected resource). token_params.push((key.as_str(), value.clone()));
if let Some(resource) = resource {
token_params.push(("resource", resource.to_string()));
} }
let mut request = client.post(token_url); 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. /// Store OAuth tokens (access + refresh) in the secrets store.
/// ///
/// Also stores the granted scopes as `{secret_name}_scopes` so that scope /// 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>>, pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
/// Gateway auth token for authenticating with the platform token exchange proxy. /// Gateway auth token for authenticating with the platform token exchange proxy.
pub gateway_token: Option<String>, pub gateway_token: Option<String>,
/// RFC 8707 resource parameter (MCP OAuth only). /// Additional form params for the token exchange request.
/// Sent during token exchange to scope the token to a specific MCP server. /// Used for provider-specific requirements such as RFC 8707 `resource`.
pub resource: Option<String>, pub token_exchange_extra_params: HashMap<String, String>,
/// Secret name for persisting the client ID (MCP OAuth only). /// Secret name for persisting the client ID (MCP OAuth only).
/// Needed so token refresh can find the client_id after the session ends. /// Needed so token refresh can find the client_id after the session ends.
pub client_id_secret_name: Option<String>, 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 /// URL, meaning the user's browser will redirect to a hosted gateway rather than
/// localhost. /// localhost.
pub fn use_gateway_callback() -> bool { pub fn use_gateway_callback() -> bool {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL") crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
.map(|raw| { .map(|raw| {
url::Url::parse(&raw) url::Url::parse(&raw)
.ok() .ok()
@@ -472,6 +498,13 @@ pub fn use_gateway_callback() -> bool {
.unwrap_or(false) .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). /// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300); 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 ──────────────────────────────────────── // ── 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 /// The encoded value is opaque to providers and can be decoded by both
/// from the `state` query parameter (format: `instance:nonce`) to route the /// IronClaw and the external auth proxy for routing and callback lookup.
/// OAuth callback to the correct container. pub fn encode_hosted_oauth_state(flow_id: &str, instance_name: Option<&str>) -> String {
/// let payload = HostedOAuthStatePayload {
/// Returns the nonce unchanged when `IRONCLAW_INSTANCE_NAME` is not set flow_id: flow_id.to_string(),
/// (local/non-platform mode). instance_name: instance_name
pub fn build_platform_state(nonce: &str) -> String { .map(str::trim)
let instance = std::env::var("IRONCLAW_INSTANCE_NAME") .filter(|v| !v.is_empty())
.or_else(|_| std::env::var("OPENCLAW_INSTANCE_NAME")) .map(str::to_string),
.ok() issued_at: std::time::SystemTime::now()
.filter(|v| !v.is_empty()); .duration_since(std::time::UNIX_EPOCH)
match instance { .unwrap_or_default()
Some(name) => format!("{}:{}", name, nonce), .as_secs(),
None => nonce.to_string(), };
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. /// 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) .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. /// 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). The caller may
/// Authenticated via the gateway auth token (Bearer header). /// 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 /// The proxy expects standard OAuth form params plus optional provider-specific
/// returns a standard Google token response `{access_token, refresh_token, expires_in}`. /// token params and returns a standard token response such as
/// `{access_token, refresh_token, expires_in}`.
pub async fn exchange_via_proxy( pub async fn exchange_via_proxy(
proxy_url: &str, request: ProxyTokenExchangeRequest<'_>,
gateway_token: &str,
code: &str,
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
) -> Result<OAuthTokenResponse, OAuthCallbackError> { ) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if gateway_token.is_empty() { if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io( return Err(OAuthCallbackError::Io(
"Gateway auth token is required for proxy token exchange".to_string(), "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() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60)) .timeout(Duration::from_secs(60))
.build() .build()
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?; .map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
let mut params = vec![ let mut params = vec![
("code", code.to_string()), ("code", request.code.to_string()),
("redirect_uri", redirect_uri.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())); 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 let response = client
.post(&exchange_url) .post(&exchange_url)
.bearer_auth(gateway_token) .bearer_auth(request.gateway_token)
.form(&params) .form(&params)
.send() .send()
.await .await
@@ -576,7 +722,7 @@ pub async fn exchange_via_proxy(
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?; .map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
let access_token = token_data let access_token = token_data
.get(access_token_field) .get(request.access_token_field)
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| { .ok_or_else(|| {
let fields: Vec<&str> = token_data let fields: Vec<&str> = token_data
@@ -585,7 +731,7 @@ pub async fn exchange_via_proxy(
.unwrap_or_default(); .unwrap_or_default();
OAuthCallbackError::Io(format!( OAuthCallbackError::Io(format!(
"No '{}' field in proxy response (fields present: {:?})", "No '{}' field in proxy response (fields present: {:?})",
access_token_field, fields request.access_token_field, fields
)) ))
})? })?
.to_string(); .to_string();
@@ -605,14 +751,10 @@ pub async fn exchange_via_proxy(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Mutex;
use crate::cli::oauth_defaults::{ use crate::cli::oauth_defaults::{
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html, builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
}; };
use crate::config::helpers::ENV_MUTEX;
/// Serializes env-mutating tests to prevent parallel races.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test] #[test]
fn test_is_loopback_host() { fn test_is_loopback_host() {
@@ -935,7 +1077,7 @@ mod tests {
#[test] #[test]
fn test_build_platform_state_with_instance() { 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 _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok(); let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -943,7 +1085,11 @@ mod tests {
unsafe { unsafe {
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer"); 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 { unsafe {
if let Some(val) = original { if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val); std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -955,7 +1101,7 @@ mod tests {
#[test] #[test]
fn test_build_platform_state_without_instance() { 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 _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok(); 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("IRONCLAW_INSTANCE_NAME");
std::env::remove_var("OPENCLAW_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 { unsafe {
if let Some(val) = original { if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val); std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -978,7 +1128,7 @@ mod tests {
#[test] #[test]
fn test_build_platform_state_with_openclaw_instance() { 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 _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok(); 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::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion"); 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 { unsafe {
if let Some(val) = original_ic { if let Some(val) = original_ic {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val); std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -1017,6 +1171,42 @@ mod tests {
assert_eq!(strip_instance_prefix(""), ""); 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 /// Verify that `build_oauth_url` includes the RFC 8707 `resource` parameter
/// when passed through `extra_params`, which is how MCP OAuth gateway mode /// when passed through `extra_params`, which is how MCP OAuth gateway mode
/// scopes tokens to a specific MCP server. /// 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 // Check for OAuth configuration
if let Some(ref oauth) = auth.oauth { if let Some(ref oauth) = auth.oauth {
// For providers with shared tokens (e.g., all Google tools share google_oauth_token), // For providers with shared tokens, combine scopes from all installed
// combine scopes from all installed tools so one auth covers everything. // tools so one auth covers everything.
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await; let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
if combined.scopes.len() > oauth.scopes.len() { if combined.scopes.len() > oauth.scopes.len() {
let extra = 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 /// 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 /// and combine their OAuth scopes so one authorization covers the full shared
/// for ALL installed Google tools, so one login covers everything. /// credential set.
async fn combine_provider_scopes( async fn combine_provider_scopes(
tools_dir: &Path, tools_dir: &Path,
secret_name: &str, secret_name: &str,
@@ -736,11 +736,18 @@ async fn auth_tool_oauth(
}) })
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string())) .or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
.ok_or_else(|| { .ok_or_else(|| {
anyhow::anyhow!( let mut message = format!(
"OAuth client_id not configured.\n\ "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") 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 // 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::settings::Settings;
use crate::workspace::EmbeddingProvider; use crate::workspace::EmbeddingProvider;
/// Default maximum number of cached embeddings.
pub const DEFAULT_EMBEDDING_CACHE_SIZE: usize = 10_000;
/// Embeddings provider configuration. /// Embeddings provider configuration.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EmbeddingsConfig { pub struct EmbeddingsConfig {
@@ -26,6 +29,12 @@ pub struct EmbeddingsConfig {
/// Custom base URL for OpenAI-compatible embedding providers. /// Custom base URL for OpenAI-compatible embedding providers.
/// When set, overrides the default `https://api.openai.com`. /// When set, overrides the default `https://api.openai.com`.
pub openai_base_url: Option<String>, 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 { impl Default for EmbeddingsConfig {
@@ -40,6 +49,7 @@ impl Default for EmbeddingsConfig {
ollama_base_url: "http://localhost:11434".to_string(), ollama_base_url: "http://localhost:11434".to_string(),
dimension, dimension,
openai_base_url: None, 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 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 { Ok(Self {
enabled, enabled,
provider, provider,
@@ -88,6 +107,7 @@ impl EmbeddingsConfig {
ollama_base_url, ollama_base_url,
dimension, dimension,
openai_base_url, openai_base_url,
cache_size,
}) })
} }
@@ -183,13 +203,13 @@ mod tests {
std::env::remove_var("EMBEDDING_MODEL"); std::env::remove_var("EMBEDDING_MODEL");
std::env::remove_var("OPENAI_API_KEY"); std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("EMBEDDING_BASE_URL"); std::env::remove_var("EMBEDDING_BASE_URL");
std::env::remove_var("EMBEDDING_CACHE_SIZE");
} }
} }
#[test] #[test]
fn embeddings_disabled_not_overridden_by_openai_key() { fn embeddings_disabled_not_overridden_by_openai_key() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env(); clear_embedding_env();
// SAFETY: Under ENV_MUTEX, no concurrent env access. // SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe { unsafe {
@@ -240,7 +260,6 @@ mod tests {
#[test] #[test]
fn embeddings_env_override_takes_precedence() { fn embeddings_env_override_takes_precedence() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_embedding_env(); clear_embedding_env();
// SAFETY: Under ENV_MUTEX. // SAFETY: Under ENV_MUTEX.
unsafe { unsafe {
@@ -281,10 +300,8 @@ mod tests {
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed"); let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
assert_eq!( assert_eq!(
config.openai_base_url.as_deref(), config.openai_base_url.as_deref(),
Some("https://custom.example.com"), Some("https://custom.example.com")
"EMBEDDING_BASE_URL env var should be parsed into openai_base_url"
); );
// SAFETY: Under ENV_MUTEX. // SAFETY: Under ENV_MUTEX.
unsafe { unsafe {
std::env::remove_var("EMBEDDING_BASE_URL"); 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" "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, ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig,
}; };
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path}; 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::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig; pub use self::hygiene::HygieneConfig;
pub use self::llm::default_session_path; pub use self::llm::default_session_path;
+29 -35
View File
@@ -7,7 +7,7 @@ use secrecy::SecretString;
pub struct RelayConfig { pub struct RelayConfig {
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`). /// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
pub url: String, pub url: String,
/// API key for authenticated channel-relay endpoints. /// Bearer token for authenticated channel-relay endpoints (`sk-agent-*`).
pub api_key: SecretString, pub api_key: SecretString,
/// Override for the OAuth callback URL (e.g., a tunnel URL). /// Override for the OAuth callback URL (e.g., a tunnel URL).
pub callback_url: Option<String>, pub callback_url: Option<String>,
@@ -15,12 +15,8 @@ pub struct RelayConfig {
pub instance_id: Option<String>, pub instance_id: Option<String>,
/// HTTP request timeout in seconds (default: 30). /// HTTP request timeout in seconds (default: 30).
pub request_timeout_secs: u64, pub request_timeout_secs: u64,
/// SSE stream long-poll timeout in seconds (default: 86400 = 24 h). /// Path for the webhook callback endpoint (default: `/relay/events`).
pub stream_timeout_secs: u64, pub webhook_path: String,
/// Initial exponential backoff in milliseconds (default: 1000).
pub backoff_initial_ms: u64,
/// Maximum exponential backoff in milliseconds (default: 60000).
pub backoff_max_ms: u64,
} }
impl std::fmt::Debug for RelayConfig { impl std::fmt::Debug for RelayConfig {
@@ -31,9 +27,7 @@ impl std::fmt::Debug for RelayConfig {
.field("callback_url", &self.callback_url) .field("callback_url", &self.callback_url)
.field("instance_id", &self.instance_id) .field("instance_id", &self.instance_id)
.field("request_timeout_secs", &self.request_timeout_secs) .field("request_timeout_secs", &self.request_timeout_secs)
.field("stream_timeout_secs", &self.stream_timeout_secs) .field("webhook_path", &self.webhook_path)
.field("backoff_initial_ms", &self.backoff_initial_ms)
.field("backoff_max_ms", &self.backoff_max_ms)
.finish() .finish()
} }
} }
@@ -41,8 +35,10 @@ impl std::fmt::Debug for RelayConfig {
impl RelayConfig { impl RelayConfig {
/// Load relay config from environment variables. /// Load relay config from environment variables.
/// ///
/// Returns `None` if either `CHANNEL_RELAY_URL` or `CHANNEL_RELAY_API_KEY` /// Returns `None` if either of the required env vars (`CHANNEL_RELAY_URL`,
/// is not set, making the relay integration opt-in. /// `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> { pub fn from_env() -> Option<Self> {
Self::from_env_reader(|key| std::env::var(key).ok()) Self::from_env_reader(|key| std::env::var(key).ok())
} }
@@ -55,9 +51,7 @@ impl RelayConfig {
callback_url: None, callback_url: None,
instance_id: None, instance_id: None,
request_timeout_secs: 30, request_timeout_secs: 30,
stream_timeout_secs: 86400, webhook_path: "/relay/events".into(),
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
} }
} }
@@ -73,15 +67,7 @@ impl RelayConfig {
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS") request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(30), .unwrap_or(30),
stream_timeout_secs: env("RELAY_STREAM_TIMEOUT_SECS") webhook_path: env("RELAY_WEBHOOK_PATH").unwrap_or_else(|| "/relay/events".into()),
.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),
}) })
} }
} }
@@ -97,7 +83,21 @@ mod tests {
} }
#[test] #[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 { let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()), "CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".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.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30); assert_eq!(config.request_timeout_secs, 30);
assert_eq!(config.stream_timeout_secs, 86400); assert_eq!(config.webhook_path, "/relay/events");
assert_eq!(config.backoff_initial_ms, 1000);
assert_eq!(config.backoff_max_ms, 60000);
assert!(config.callback_url.is_none()); assert!(config.callback_url.is_none());
assert!(config.instance_id.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_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()), "IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()), "RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
"RELAY_STREAM_TIMEOUT_SECS" => Some("43200".into()), "RELAY_WEBHOOK_PATH" => Some("/custom/events".into()),
"RELAY_BACKOFF_INITIAL_MS" => Some("2000".into()),
"RELAY_BACKOFF_MAX_MS" => Some("120000".into()),
_ => None, _ => None,
}) })
.expect("config should be Some"); .expect("config should be Some");
@@ -135,9 +131,7 @@ mod tests {
); );
assert_eq!(config.instance_id.as_deref(), Some("my-instance")); assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
assert_eq!(config.request_timeout_secs, 60); assert_eq!(config.request_timeout_secs, 60);
assert_eq!(config.stream_timeout_secs, 43200); assert_eq!(config.webhook_path, "/custom/events");
assert_eq!(config.backoff_initial_ms, 2000);
assert_eq!(config.backoff_max_ms, 120000);
} }
#[test] #[test]
@@ -148,7 +142,7 @@ mod tests {
} }
#[test] #[test]
fn debug_redacts_api_key() { fn debug_redacts_secrets() {
let config = RelayConfig::from_values("http://localhost:3001", "super-secret"); let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
let debug = format!("{:?}", config); let debug = format!("{:?}", config);
assert!(debug.contains("[REDACTED]")); 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. /// 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( pub fn succeed(
mut self, mut self,
output_raw: Option<String>, output_sanitized: Option<String>,
output_sanitized: serde_json::Value, output_raw: serde_json::Value,
duration: Duration, duration: Duration,
) -> Self { ) -> Self {
self.success = true; self.success = true;
self.output_raw = output_raw; self.output_raw = Some(serde_json::to_string_pretty(&output_raw).unwrap_or_default());
self.output_sanitized = Some(output_sanitized); self.output_sanitized = output_sanitized.map(serde_json::Value::String);
self.duration = duration; self.duration = duration;
self self
} }
@@ -248,15 +252,15 @@ mod tests {
#[test] #[test]
fn test_action_record() { fn test_action_record() {
let action = ActionRecord::new(0, "test", serde_json::json!({"key": "value"})); let action = ActionRecord::new(0, "test", serde_json::json!({"key": "value"}));
assert_eq!(action.sequence, 0); assert_eq!(action.sequence, 0); // safety: test
assert!(!action.success); assert!(!action.success); // safety: test
let action = action.succeed( let action = action.succeed(
Some("raw".to_string()), Some("raw".to_string()),
serde_json::json!({"result": "ok"}), serde_json::json!({"result": "ok"}),
Duration::from_millis(100), Duration::from_millis(100),
); );
assert!(action.success); assert!(action.success); // safety: test
} }
#[test] #[test]
@@ -267,7 +271,7 @@ mod tests {
memory.add(ChatMessage::user("How are you?")); memory.add(ChatMessage::user("How are you?"));
memory.add(ChatMessage::assistant("Good!")); memory.add(ChatMessage::assistant("Good!"));
assert_eq!(memory.len(), 3); // Oldest removed assert_eq!(memory.len(), 3); // Oldest removed // safety: test
} }
#[test] #[test]
@@ -286,9 +290,9 @@ mod tests {
.with_cost(Decimal::new(20, 1)); .with_cost(Decimal::new(20, 1));
memory.record_action(action2); memory.record_action(action2);
assert_eq!(memory.total_cost(), Decimal::new(30, 1)); assert_eq!(memory.total_cost(), Decimal::new(30, 1)); // safety: test
assert_eq!(memory.total_duration(), Duration::from_secs(3)); assert_eq!(memory.total_duration(), Duration::from_secs(3)); // safety: test
assert_eq!(memory.successful_actions(), 2); assert_eq!(memory.successful_actions(), 2); // safety: test
} }
#[test] #[test]
@@ -296,11 +300,11 @@ mod tests {
let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1})); let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1}));
let action = action.fail("something went wrong", Duration::from_millis(50)); let action = action.fail("something went wrong", Duration::from_millis(50));
assert!(!action.success); assert!(!action.success); // safety: test
assert_eq!(action.error.as_deref(), Some("something went wrong")); assert_eq!(action.error.as_deref(), Some("something went wrong")); // safety: test
assert_eq!(action.duration, Duration::from_millis(50)); assert_eq!(action.duration, Duration::from_millis(50)); // safety: test
assert!(action.output_raw.is_none()); assert!(action.output_raw.is_none()); // safety: test
assert!(action.output_sanitized.is_none()); assert!(action.output_sanitized.is_none()); // safety: test
} }
#[test] #[test]
@@ -308,9 +312,9 @@ mod tests {
let action = ActionRecord::new(0, "risky_tool", serde_json::json!({})); let action = ActionRecord::new(0, "risky_tool", serde_json::json!({}));
let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]); let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]);
assert_eq!(action.sanitization_warnings.len(), 2); assert_eq!(action.sanitization_warnings.len(), 2); // safety: test
assert_eq!(action.sanitization_warnings[0], "suspicious pattern"); assert_eq!(action.sanitization_warnings[0], "suspicious pattern"); // safety: test
assert_eq!(action.sanitization_warnings[1], "possible xss"); assert_eq!(action.sanitization_warnings[1], "possible xss"); // safety: test
} }
#[test] #[test]
@@ -319,41 +323,46 @@ mod tests {
let cost = Decimal::new(42, 2); // 0.42 let cost = Decimal::new(42, 2); // 0.42
let action = action.with_cost(cost); 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] #[test]
fn test_action_record_new_defaults() { fn test_action_record_new_defaults() {
let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"})); let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"}));
assert_eq!(action.sequence, 5); assert_eq!(action.sequence, 5); // safety: test
assert_eq!(action.tool_name, "my_tool"); assert_eq!(action.tool_name, "my_tool"); // safety: test
assert_eq!(action.input, serde_json::json!({"key": "val"})); assert_eq!(action.input, serde_json::json!({"key": "val"})); // safety: test
assert!(!action.success); assert!(!action.success); // safety: test
assert!(action.output_raw.is_none()); assert!(action.output_raw.is_none()); // safety: test
assert!(action.output_sanitized.is_none()); assert!(action.output_sanitized.is_none()); // safety: test
assert!(action.sanitization_warnings.is_empty()); assert!(action.sanitization_warnings.is_empty()); // safety: test
assert!(action.cost.is_none()); assert!(action.cost.is_none()); // safety: test
assert_eq!(action.duration, Duration::ZERO); assert_eq!(action.duration, Duration::ZERO); // safety: test
assert!(action.error.is_none()); assert!(action.error.is_none()); // safety: test
} }
#[test] #[test]
fn test_action_record_succeed_sets_fields() { fn test_action_record_succeed_sets_fields() {
let action = ActionRecord::new(0, "tool", serde_json::json!({})); let action = ActionRecord::new(0, "tool", serde_json::json!({}));
let action = action.succeed( let action = action.succeed(
Some("raw output here".into()), Some("sanitized output".into()),
serde_json::json!({"clean": true}), serde_json::json!({"clean": true}),
Duration::from_secs(7), Duration::from_secs(7),
); );
assert!(action.success); assert!(action.success); // safety: test
assert_eq!(action.output_raw.as_deref(), Some("raw output here")); // 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!( assert_eq!(
/* safety: test */
action.output_sanitized, 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] #[test]
@@ -361,13 +370,13 @@ mod tests {
let mut mem = ConversationMemory::new(10); let mut mem = ConversationMemory::new(10);
mem.add(ChatMessage::user("hello")); mem.add(ChatMessage::user("hello"));
mem.add(ChatMessage::assistant("hi")); mem.add(ChatMessage::assistant("hi"));
assert_eq!(mem.len(), 2); assert_eq!(mem.len(), 2); // safety: test
assert!(!mem.is_empty()); assert!(!mem.is_empty()); // safety: test
mem.clear(); mem.clear();
assert_eq!(mem.len(), 0); assert_eq!(mem.len(), 0); // safety: test
assert!(mem.is_empty()); assert!(mem.is_empty()); // safety: test
assert!(mem.messages().is_empty()); assert!(mem.messages().is_empty()); // safety: test
} }
#[test] #[test]
@@ -379,20 +388,20 @@ mod tests {
mem.add(ChatMessage::assistant("four")); mem.add(ChatMessage::assistant("four"));
let last_2 = mem.last_n(2); let last_2 = mem.last_n(2);
assert_eq!(last_2.len(), 2); assert_eq!(last_2.len(), 2); // safety: test
assert_eq!(last_2[0].content, "three"); assert_eq!(last_2[0].content, "three"); // safety: test
assert_eq!(last_2[1].content, "four"); assert_eq!(last_2[1].content, "four"); // safety: test
// Requesting more than available returns all // Requesting more than available returns all
let last_100 = mem.last_n(100); let last_100 = mem.last_n(100);
assert_eq!(last_100.len(), 4); assert_eq!(last_100.len(), 4); // safety: test
} }
#[test] #[test]
fn test_conversation_memory_last_n_empty() { fn test_conversation_memory_last_n_empty() {
let mem = ConversationMemory::new(10); let mem = ConversationMemory::new(10);
let result = mem.last_n(5); let result = mem.last_n(5);
assert!(result.is_empty()); assert!(result.is_empty()); // safety: test
} }
#[test] #[test]
@@ -405,13 +414,13 @@ mod tests {
// At capacity (3). Adding one more should trim, but keep system. // At capacity (3). Adding one more should trim, but keep system.
mem.add(ChatMessage::user("msg3")); mem.add(ChatMessage::user("msg3"));
assert_eq!(mem.len(), 3); assert_eq!(mem.len(), 3); // safety: test
// System message must survive // System message must survive
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
assert_eq!(mem.messages()[0].content, "You are helpful"); assert_eq!(mem.messages()[0].content, "You are helpful"); // safety: test
// Oldest non-system message (msg1) should be gone // Oldest non-system message (msg1) should be gone
assert_eq!(mem.messages()[1].content, "msg2"); assert_eq!(mem.messages()[1].content, "msg2"); // safety: test
assert_eq!(mem.messages()[2].content, "msg3"); assert_eq!(mem.messages()[2].content, "msg3"); // safety: test
} }
#[test] #[test]
@@ -422,9 +431,9 @@ mod tests {
// Now at capacity. Add another. // Now at capacity. Add another.
mem.add(ChatMessage::user("b")); mem.add(ChatMessage::user("b"));
assert_eq!(mem.len(), 2); assert_eq!(mem.len(), 2); // safety: test
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
assert_eq!(mem.messages()[1].content, "b"); assert_eq!(mem.messages()[1].content, "b"); // safety: test
} }
#[test] #[test]
@@ -440,7 +449,7 @@ mod tests {
mem.add(ChatMessage::user("hello")); mem.add(ChatMessage::user("hello"));
// Should have broken out rather than looping forever. // Should have broken out rather than looping forever.
// The system message is protected, so len may exceed max. // The system message is protected, so len may exceed max.
assert!(mem.len() <= 2); assert!(mem.len() <= 2); // safety: test
} }
#[test] #[test]
@@ -459,14 +468,14 @@ mod tests {
.fail("oops", Duration::from_millis(2)); .fail("oops", Duration::from_millis(2));
memory.record_action(err); memory.record_action(err);
assert_eq!(memory.successful_actions(), 1); assert_eq!(memory.successful_actions(), 1); // safety: test
assert_eq!(memory.failed_actions(), 1); assert_eq!(memory.failed_actions(), 1); // safety: test
} }
#[test] #[test]
fn test_memory_last_action() { fn test_memory_last_action() {
let mut memory = Memory::new(Uuid::new_v4()); 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 let a1 = memory
.create_action("first", serde_json::json!({})) .create_action("first", serde_json::json!({}))
@@ -478,8 +487,8 @@ mod tests {
.fail("nope", Duration::ZERO); .fail("nope", Duration::ZERO);
memory.record_action(a2); memory.record_action(a2);
let last = memory.last_action().unwrap(); let last = memory.last_action().unwrap(); // safety: test
assert_eq!(last.tool_name, "second"); assert_eq!(last.tool_name, "second"); // safety: test
} }
#[test] #[test]
@@ -499,9 +508,9 @@ mod tests {
); );
memory.record_action(a); memory.record_action(a);
assert_eq!(memory.actions_by_tool("shell").len(), 3); assert_eq!(memory.actions_by_tool("shell").len(), 3); // safety: test
assert_eq!(memory.actions_by_tool("http").len(), 1); assert_eq!(memory.actions_by_tool("http").len(), 1); // safety: test
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0); assert_eq!(memory.actions_by_tool("nonexistent").len(), 0); // safety: test
} }
#[test] #[test]
@@ -509,25 +518,25 @@ mod tests {
let mut memory = Memory::new(Uuid::new_v4()); let mut memory = Memory::new(Uuid::new_v4());
let a0 = memory.create_action("t", serde_json::json!({})); 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!({})); 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!({})); let a2 = memory.create_action("t", serde_json::json!({}));
assert_eq!(a2.sequence, 2); assert_eq!(a2.sequence, 2); // safety: test
} }
#[test] #[test]
fn test_memory_add_message_delegates_to_conversation() { fn test_memory_add_message_delegates_to_conversation() {
let mut memory = Memory::new(Uuid::new_v4()); 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::user("hello"));
memory.add_message(ChatMessage::assistant("hi")); memory.add_message(ChatMessage::assistant("hi"));
assert_eq!(memory.conversation.len(), 2); assert_eq!(memory.conversation.len(), 2); // safety: test
assert_eq!(memory.conversation.messages()[0].content, "hello"); assert_eq!(memory.conversation.messages()[0].content, "hello"); // safety: test
} }
#[test] #[test]
@@ -540,7 +549,7 @@ mod tests {
.succeed(None, serde_json::json!({}), Duration::ZERO); .succeed(None, serde_json::json!({}), Duration::ZERO);
memory.record_action(a); memory.record_action(a);
assert_eq!(memory.total_cost(), Decimal::ZERO); assert_eq!(memory.total_cost(), Decimal::ZERO); // safety: test
} }
#[test] #[test]
@@ -560,6 +569,6 @@ mod tests {
memory.record_action(a2); memory.record_action(a2);
// Both successful and failed actions contribute to total duration // 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 //! - State machine
//! - Resource tracking //! - Resource tracking
pub mod fallback;
mod manager; mod manager;
mod memory; mod memory;
mod state; mod state;
pub use fallback::FallbackDeliverable;
pub use manager::ContextManager; pub use manager::ContextManager;
pub use memory::{ActionRecord, ConversationMemory, Memory}; pub use memory::{ActionRecord, ConversationMemory, Memory};
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded}; 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()))?; .map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(()) 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, run_id: Uuid,
job_id: Uuid, job_id: Uuid,
) -> Result<(), DatabaseError>; ) -> 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] #[async_trait]
+4
View File
@@ -503,6 +503,10 @@ impl RoutineStore for PgBackend {
) -> Result<(), DatabaseError> { ) -> Result<(), DatabaseError> {
self.store.link_routine_run_to_job(run_id, job_id).await 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 ==================== // ==================== 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?; .await?;
Ok(()) 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")] #[cfg(feature = "postgres")]
+85 -1
View File
@@ -22,6 +22,7 @@ use crate::llm::provider::{
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params, ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
strip_unsupported_tool_params, strip_unsupported_tool_params,
}; };
use crate::llm::retry::cap_retry_after;
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages"; 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. /// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
@@ -143,12 +144,15 @@ impl AnthropicOAuthProvider {
if !status.is_success() { if !status.is_success() {
// Parse Retry-After header before consuming the body. // Parse Retry-After header before consuming the body.
// Falls back to 60s if header is missing or unparseable (prevents "retry after None" errors).
let retry_after = response let retry_after = response
.headers() .headers()
.get("retry-after") .get("retry-after")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok()) .and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs); .map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
let response_text = response let response_text = response
.text() .text()
@@ -705,4 +709,84 @@ mod tests {
// Subsequent reads see the updated token // Subsequent reads see the updated token
assert_eq!(token.read().unwrap().expose_secret(), "new_token"); assert_eq!(token.read().unwrap().expose_secret(), "new_token");
} }
// -- Retry-After header parsing tests (regression for rate limit "None" bug) --
#[test]
fn test_retry_after_parsing_delay_seconds() {
// Verify delay-seconds format is parsed correctly
let header_value = "45";
let duration = parse_retry_after_anthropic_for_test(header_value);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(45)),
"Should parse delay-seconds format"
);
}
#[test]
fn test_retry_after_fallback_missing_header() {
// Regression test: When Retry-After header is missing,
// should fall back to 60s instead of None
let duration = parse_retry_after_anthropic_for_test("");
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Missing header should fallback to 60s"
);
}
#[test]
fn test_retry_after_fallback_invalid_format() {
// Regression test: When Retry-After header is in unexpected format,
// should fall back to 60s instead of None
let invalid_formats = vec![
"invalid",
"not-a-number",
"30.5", // float instead of int
"abc123",
"Mon, 02 Mar 2026 18:00:00 GMT", // RFC2822 not supported in anthropic version
];
for format in invalid_formats {
let duration = parse_retry_after_anthropic_for_test(format);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Invalid format '{}' should fallback to 60s",
format
);
}
}
#[test]
fn test_retry_after_zero_seconds_accepted() {
// Verify zero seconds is a valid retry delay
let duration = parse_retry_after_anthropic_for_test("0");
assert_eq!(duration, Some(std::time::Duration::ZERO));
}
#[test]
fn test_retry_after_large_number() {
// 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(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
}
/// Helper function to test Retry-After header parsing logic for Anthropic
/// (simulates the parsing done in send_request without actual HTTP, including fallback)
fn parse_retry_after_anthropic_for_test(header_value: &str) -> Option<std::time::Duration> {
header_value
.trim()
.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 => { 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 // Shouldn't get here (check_allowed blocks Open), but recover
state.state = CircuitState::Closed; state.state = CircuitState::Closed;
state.consecutive_failures = 0; state.consecutive_failures = 0;
+127 -6
View File
@@ -22,7 +22,7 @@ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall, ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse, 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. /// Information about an available model from NEAR AI API.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -244,6 +244,7 @@ impl NearAiChatProvider {
let status = response.status(); let status = response.status();
// Extract Retry-After header before consuming the response body. // Extract Retry-After header before consuming the response body.
// Supports both delay-seconds (RFC 7231 §7.1.3) and HTTP-date formats. // Supports both delay-seconds (RFC 7231 §7.1.3) and HTTP-date formats.
// Falls back to 60s if header is missing or unparseable (prevents "retry after None" errors).
let retry_after_header = response let retry_after_header = response
.headers() .headers()
.get("retry-after") .get("retry-after")
@@ -251,7 +252,7 @@ impl NearAiChatProvider {
.and_then(|v| { .and_then(|v| {
// Try delay-seconds first (most common from API providers) // Try delay-seconds first (most common from API providers)
if let Ok(secs) = v.trim().parse::<u64>() { 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") // Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) { if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
@@ -259,12 +260,13 @@ impl NearAiChatProvider {
let delta = dt.signed_duration_since(now); let delta = dt.signed_duration_since(now);
// Use max(0) so past/present dates yield Duration::ZERO // Use max(0) so past/present dates yield Duration::ZERO
// rather than None (which would cause an immediate retry). // rather than None (which would cause an immediate retry).
return Some(std::time::Duration::from_secs( return Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64 delta.num_seconds().max(0) as u64,
)); )));
} }
None None
}); })
.or(Some(std::time::Duration::from_secs(60)));
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed { let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(), provider: "nearai_chat".to_string(),
reason: format!("Failed to read response body: {}", e), reason: format!("Failed to read response body: {}", e),
@@ -2216,4 +2218,123 @@ mod tests {
"http://example.com/api/proxy/v1/chat/completions" "http://example.com/api/proxy/v1/chat/completions"
); );
} }
// -- Retry-After header parsing tests (regression for rate limit "None" bug) --
#[test]
fn test_retry_after_parsing_delay_seconds() {
// Verify delay-seconds format (most common) is parsed correctly
let header_value = "30";
let duration = parse_retry_after_for_test(header_value);
assert_eq!(duration, Some(std::time::Duration::from_secs(30)));
}
#[test]
fn test_retry_after_parsing_rfc2822_date() {
// Verify HTTP-date (RFC 2822) format is parsed correctly
// Use a date 60 seconds in the future
let now = chrono::Utc::now();
let future = now + chrono::Duration::seconds(60);
let date_str = future.to_rfc2822();
let duration = parse_retry_after_for_test(&date_str);
assert!(duration.is_some());
let d = duration.unwrap();
// Allow ±5 seconds of drift due to processing time
assert!(
d.as_secs() >= 55 && d.as_secs() <= 65,
"Expected ~60s, got {}s",
d.as_secs()
);
}
#[test]
fn test_retry_after_fallback_missing_header() {
// Regression test: When Retry-After header is missing,
// should fall back to 60s instead of None
let duration = parse_retry_after_for_test("");
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Missing header should fallback to 60s"
);
}
#[test]
fn test_retry_after_fallback_invalid_format() {
// Regression test: When Retry-After header is in unexpected format,
// should fall back to 60s instead of None
let invalid_formats = vec![
"invalid",
"not-a-number",
"30.5", // float instead of int
"abc123",
];
for format in invalid_formats {
let duration = parse_retry_after_for_test(format);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Invalid format '{}' should fallback to 60s",
format
);
}
}
#[test]
fn test_retry_after_past_date_returns_zero() {
// When HTTP-date is in the past, should return Duration::ZERO
// (not None, which would trigger immediate retry)
let past = chrono::Utc::now() - chrono::Duration::seconds(60);
let past_date_str = past.to_rfc2822();
let duration = parse_retry_after_for_test(&past_date_str);
assert_eq!(
duration,
Some(std::time::Duration::ZERO),
"Past date should return Duration::ZERO, not None"
);
}
#[test]
fn test_retry_after_zero_seconds_accepted() {
// Verify zero seconds is a valid retry delay
let duration = parse_retry_after_for_test("0");
assert_eq!(duration, Some(std::time::Duration::ZERO));
}
#[test]
fn test_retry_after_large_number() {
// 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
/// (simulates the parsing done in send_request without actual HTTP, including fallback)
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(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(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)))
} else {
None
};
// Apply fallback to 60s if parsing failed (matches actual code behavior)
parsed.or(Some(std::time::Duration::from_secs(60)))
}
} }
+3 -4
View File
@@ -39,9 +39,7 @@ pub enum OAuthCallbackError {
/// deployments where `127.0.0.1` is unreachable from the user's browser), /// deployments where `127.0.0.1` is unreachable from the user's browser),
/// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`. /// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`.
pub fn callback_url() -> String { pub fn callback_url() -> String {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL") crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT)) .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 /// 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. /// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible.
pub fn callback_host() -> String { 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. /// 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("nanbeige-4.1-3b"));
assert!(has_native_thinking("step-3.5-flash-197b")); assert!(has_native_thinking("step-3.5-flash-197b"));
assert!(has_native_thinking("minimax-m2.5-139b")); assert!(has_native_thinking("minimax-m2.5-139b"));
assert!(has_native_thinking("MiniMax-M2.7"));
assert!(has_native_thinking("MiniMax-M2.7-highspeed"));
} }
#[test] #[test]
+50
View File
@@ -19,6 +19,12 @@ use crate::llm::provider::{
ToolCompletionResponse, 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. /// Returns `true` if the `LlmError` is transient and the request should be retried.
/// ///
/// Used by `RetryProvider` (retry the same provider) and `FailoverProvider` /// 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) 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. /// Configuration for the retry decorator.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RetryConfig { pub struct RetryConfig {
@@ -394,4 +405,43 @@ mod tests {
assert_eq!(retry.cost_per_token(), (Decimal::ZERO, Decimal::ZERO)); assert_eq!(retry.cost_per_token(), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(retry.calculate_cost(100, 50), Decimal::ZERO); assert_eq!(retry.calculate_cost(100, 50), Decimal::ZERO);
} }
// Regression test: Rate limiter fallback when Retry-After header is missing
//
// Verifies that RateLimited errors always have a duration (never None)
// due to the 60-second fallback applied in all rate limit error creation sites
// (nearai_chat.rs, anthropic_oauth.rs, embeddings.rs).
#[test]
fn rate_limited_error_always_has_duration() {
let err = LlmError::RateLimited {
provider: "test".to_string(),
retry_after: Some(std::time::Duration::from_secs(60)),
};
if let LlmError::RateLimited { retry_after, .. } = err {
assert!(
retry_after.is_some(),
"Rate limited error should always have retry_after duration"
);
assert_eq!(
retry_after,
Some(std::time::Duration::from_secs(60)),
"Fallback should be 60 seconds"
);
} else {
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. // 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() { if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels( let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
&config, &config,
@@ -511,6 +522,16 @@ async fn async_main() -> anyhow::Result<()> {
gw = gw.with_skill_catalog(Arc::clone(sc)); gw = gw.with_skill_catalog(Arc::clone(sc));
} }
gw = gw.with_cost_guard(Arc::clone(&components.cost_guard)); 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 { if config.sandbox.enabled {
gw = gw.with_prompt_queue(Arc::clone(&prompt_queue)); gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
@@ -727,6 +748,7 @@ async fn async_main() -> anyhow::Result<()> {
document_extraction: Some(Arc::new( document_extraction: Some(Arc::new(
ironclaw::document_extraction::DocumentExtractionMiddleware::new(), ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
)), )),
builder: components.builder,
}; };
let mut agent = Agent::new( let mut agent = Agent::new(
+6
View File
@@ -333,6 +333,12 @@ async fn job_event_handler(
.get("session_id") .get("session_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()), .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 { _ => SseEvent::JobStatus {
job_id: job_id_str, 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)) .save_secret("http_webhook_secret", &SecretString::from(secret))
.await?; .await?;
print_success("Webhook secret generated and saved to database"); 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)); 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) 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> { fn validate_e164(account: &str) -> Result<(), String> {
if !account.starts_with('+') { if !account.starts_with('+') {
return Err("E.164 account must start with '+'".to_string()); return Err("E.164 account must start with '+'".to_string());
@@ -1136,8 +1140,9 @@ mod tests {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore}; use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
use crate::setup::channels::{ use crate::setup::channels::{
SecretsContext, generate_webhook_secret, substitute_validation_placeholders, SecretsContext, generate_webhook_secret, http_webhook_secret_hint,
validate_cloudflare_token_format, validate_public_https_url, substitute_validation_placeholders, validate_cloudflare_token_format,
validate_public_https_url,
}; };
fn test_secrets_context() -> SecretsContext { fn test_secrets_context() -> SecretsContext {
@@ -1337,4 +1342,12 @@ mod tests {
.to_string(); .to_string();
assert!(err.contains("DNS resolution failed")); 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 credentials;
pub mod fault_injection;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use async_trait::async_trait; use async_trait::async_trait;
@@ -84,6 +86,9 @@ pub struct StubLlm {
call_count: AtomicU32, call_count: AtomicU32,
should_fail: AtomicBool, should_fail: AtomicBool,
error_kind: StubErrorKind, 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 { impl StubLlm {
@@ -95,6 +100,7 @@ impl StubLlm {
call_count: AtomicU32::new(0), call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(false), should_fail: AtomicBool::new(false),
error_kind: StubErrorKind::Transient, error_kind: StubErrorKind::Transient,
fault_injector: None,
} }
} }
@@ -106,6 +112,7 @@ impl StubLlm {
call_count: AtomicU32::new(0), call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(true), should_fail: AtomicBool::new(true),
error_kind: StubErrorKind::Transient, error_kind: StubErrorKind::Transient,
fault_injector: None,
} }
} }
@@ -117,6 +124,7 @@ impl StubLlm {
call_count: AtomicU32::new(0), call_count: AtomicU32::new(0),
should_fail: AtomicBool::new(true), should_fail: AtomicBool::new(true),
error_kind: StubErrorKind::NonTransient, error_kind: StubErrorKind::NonTransient,
fault_injector: None,
} }
} }
@@ -131,11 +139,39 @@ impl StubLlm {
self.call_count.load(Ordering::Relaxed) 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. /// Toggle whether calls should fail at runtime.
pub fn set_failing(&self, fail: bool) { pub fn set_failing(&self, fail: bool) {
self.should_fail.store(fail, Ordering::Relaxed); 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 { fn make_error(&self) -> LlmError {
match self.error_kind { match self.error_kind {
StubErrorKind::Transient => LlmError::RequestFailed { StubErrorKind::Transient => LlmError::RequestFailed {
@@ -168,8 +204,8 @@ impl LlmProvider for StubLlm {
async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, LlmError> { async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
self.call_count.fetch_add(1, Ordering::Relaxed); self.call_count.fetch_add(1, Ordering::Relaxed);
if self.should_fail.load(Ordering::Relaxed) { if let Some(err) = self.check_faults().await {
return Err(self.make_error()); return Err(err);
} }
Ok(CompletionResponse { Ok(CompletionResponse {
content: self.response.clone(), content: self.response.clone(),
@@ -186,8 +222,8 @@ impl LlmProvider for StubLlm {
_request: ToolCompletionRequest, _request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> { ) -> Result<ToolCompletionResponse, LlmError> {
self.call_count.fetch_add(1, Ordering::Relaxed); self.call_count.fetch_add(1, Ordering::Relaxed);
if self.should_fail.load(Ordering::Relaxed) { if let Some(err) = self.check_faults().await {
return Err(self.make_error()); return Err(err);
} }
Ok(ToolCompletionResponse { Ok(ToolCompletionResponse {
content: Some(self.response.clone()), content: Some(self.response.clone()),
@@ -456,6 +492,7 @@ impl TestHarnessBuilder {
http_interceptor: None, http_interceptor: None,
transcription: None, transcription: None,
document_extraction: None, document_extraction: None,
builder: None,
}; };
TestHarness { TestHarness {
@@ -1508,4 +1545,29 @@ mod tests {
.await .await
.expect("update actuals"); .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 { if has_credentials {
return ApprovalRequirement::Always; return ApprovalRequirement::UnlessAutoApproved;
} }
// GET requests (or missing method, since GET is the default) are low-risk // GET requests (or missing method, since GET is the default) are low-risk
@@ -1093,25 +1093,31 @@ mod tests {
} }
#[test] #[test]
fn test_auth_header_object_format_returns_always() { fn test_auth_header_object_format_returns_unless_auto_approved() {
let tool = HttpTool::new(); let tool = HttpTool::new();
let params = serde_json::json!({ let params = serde_json::json!({
"method": "GET", "method": "GET",
"url": "https://api.example.com/data", "url": "https://api.example.com/data",
"headers": {"Authorization": "Bearer token123"} "headers": {"Authorization": "Bearer token123"}
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
} }
#[test] #[test]
fn test_auth_header_array_format_returns_always() { fn test_auth_header_array_format_returns_unless_auto_approved() {
let tool = HttpTool::new(); let tool = HttpTool::new();
let params = serde_json::json!({ let params = serde_json::json!({
"method": "GET", "method": "GET",
"url": "https://api.example.com/data", "url": "https://api.example.com/data",
"headers": [{"name": "Authorization", "value": "Bearer token123"}] "headers": [{"name": "Authorization", "value": "Bearer token123"}]
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
} }
#[test] #[test]
@@ -1124,7 +1130,10 @@ mod tests {
"url": "https://example.com", "url": "https://example.com",
"headers": {"AUTHORIZATION": "Bearer x"} "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 // Array format with mixed case
let params = serde_json::json!({ let params = serde_json::json!({
@@ -1132,7 +1141,10 @@ mod tests {
"url": "https://example.com", "url": "https://example.com",
"headers": [{"name": "X-Api-Key", "value": "key123"}] "headers": [{"name": "X-Api-Key", "value": "key123"}]
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
} }
#[test] #[test]
@@ -1161,8 +1173,8 @@ mod tests {
}); });
assert_eq!( assert_eq!(
tool.requires_approval(&params), tool.requires_approval(&params),
ApprovalRequirement::Always, ApprovalRequirement::UnlessAutoApproved,
"Header '{}' should trigger Always approval", "Header '{}' should trigger UnlessAutoApproved approval",
header_name header_name
); );
} }
@@ -1203,7 +1215,7 @@ mod tests {
// ── Credential registry approval tests ───────────────────────────── // ── Credential registry approval tests ─────────────────────────────
#[test] #[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::secrets::CredentialMapping;
use crate::tools::wasm::SharedCredentialRegistry; use crate::tools::wasm::SharedCredentialRegistry;
@@ -1223,7 +1235,10 @@ mod tests {
"method": "GET", "method": "GET",
"url": "https://api.openai.com/v1/models" "url": "https://api.openai.com/v1/models"
}); });
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Always); assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
} }
#[test] #[test]
@@ -1243,24 +1258,55 @@ mod tests {
} }
#[test] #[test]
fn test_url_query_param_credential_returns_always() { fn test_url_query_param_credential_returns_unless_auto_approved() {
let tool = HttpTool::new(); let tool = HttpTool::new();
let params = serde_json::json!({ let params = serde_json::json!({
"method": "GET", "method": "GET",
"url": "https://api.example.com/data?api_key=secret123" "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] #[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 tool = HttpTool::new();
let params = serde_json::json!({ let params = serde_json::json!({
"method": "GET", "method": "GET",
"url": "https://example.com", "url": "https://example.com",
"headers": {"X-Custom": format!("Bearer {TEST_OPENAI_API_KEY}")} "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] #[test]
+181 -119
View File
@@ -1005,7 +1005,8 @@ impl Tool for JobStatusTool {
"created_at": job_ctx.created_at.to_rfc3339(), "created_at": job_ctx.created_at.to_rfc3339(),
"started_at": job_ctx.started_at.map(|t| t.to_rfc3339()), "started_at": job_ctx.started_at.map(|t| t.to_rfc3339()),
"completed_at": job_ctx.completed_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())) Ok(ToolOutput::success(result, start.elapsed()))
} }
@@ -1384,7 +1385,7 @@ mod tests {
let tool = CreateJobTool::new(manager.clone()); let tool = CreateJobTool::new(manager.clone());
// Without sandbox deps, it should use the local path // Without sandbox deps, it should use the local path
assert!(!tool.sandbox_enabled()); assert!(!tool.sandbox_enabled()); // safety: test
let params = serde_json::json!({ let params = serde_json::json!({
"title": "Test Job", "title": "Test Job",
@@ -1392,12 +1393,13 @@ mod tests {
}); });
let ctx = JobContext::default(); 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(); let job_id = result.result.get("job_id").unwrap().as_str().unwrap(); // safety: test
assert!(!job_id.is_empty()); assert!(!job_id.is_empty()); // safety: test
assert_eq!( assert_eq!(
result.result.get("status").unwrap().as_str().unwrap(), /* safety: test */
result.result.get("status").unwrap().as_str().unwrap(), // safety: test
"pending" "pending"
); );
} }
@@ -1409,11 +1411,11 @@ mod tests {
// Without sandbox // Without sandbox
let tool = CreateJobTool::new(Arc::clone(&manager)); let tool = CreateJobTool::new(Arc::clone(&manager));
let schema = tool.parameters_schema(); 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!(props.contains_key("title")); assert!(props.contains_key("title")); // safety: test
assert!(props.contains_key("description")); assert!(props.contains_key("description")); // safety: test
assert!(!props.contains_key("wait")); assert!(!props.contains_key("wait")); // safety: test
assert!(!props.contains_key("mode")); assert!(!props.contains_key("mode")); // safety: test
} }
#[test] #[test]
@@ -1422,7 +1424,7 @@ mod tests {
// Without sandbox: default timeout // Without sandbox: default timeout
let tool = CreateJobTool::new(Arc::clone(&manager)); 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] #[tokio::test]
@@ -1455,23 +1457,23 @@ mod tests {
let manager = Arc::new(ContextManager::new(5)); let manager = Arc::new(ContextManager::new(5));
// Create some jobs // Create some jobs
manager.create_job("Job 1", "Desc 1").await.unwrap(); manager.create_job("Job 1", "Desc 1").await.unwrap(); // safety: test
manager.create_job("Job 2", "Desc 2").await.unwrap(); manager.create_job("Job 2", "Desc 2").await.unwrap(); // safety: test
let tool = ListJobsTool::new(manager); let tool = ListJobsTool::new(manager);
let params = serde_json::json!({}); let params = serde_json::json!({});
let ctx = JobContext::default(); 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(); let jobs = result.result.get("jobs").unwrap().as_array().unwrap(); // safety: test
assert_eq!(jobs.len(), 2); assert_eq!(jobs.len(), 2); // safety: test
} }
#[tokio::test] #[tokio::test]
async fn test_job_status_tool() { async fn test_job_status_tool() {
let manager = Arc::new(ContextManager::new(5)); 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); let tool = JobStatusTool::new(manager);
@@ -1479,10 +1481,11 @@ mod tests {
"job_id": job_id.to_string() "job_id": job_id.to_string()
}); });
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap(); let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
assert_eq!( assert_eq!(
result.result.get("title").unwrap().as_str().unwrap(), /* safety: test */
result.result.get("title").unwrap().as_str().unwrap(), // safety: test
"Test Job" "Test Job"
); );
} }
@@ -1496,8 +1499,9 @@ mod tests {
let missing_title = tool let missing_title = tool
.execute(serde_json::json!({ "description": "A test job" }), &ctx) .execute(serde_json::json!({ "description": "A test job" }), &ctx)
.await; .await;
assert!(missing_title.is_err()); assert!(missing_title.is_err()); // safety: test
assert!( assert!(
/* safety: test */
missing_title missing_title
.unwrap_err() .unwrap_err()
.to_string() .to_string()
@@ -1507,8 +1511,9 @@ mod tests {
let missing_description = tool let missing_description = tool
.execute(serde_json::json!({ "title": "Test Job" }), &ctx) .execute(serde_json::json!({ "title": "Test Job" }), &ctx)
.await; .await;
assert!(missing_description.is_err()); assert!(missing_description.is_err()); // safety: test
assert!( assert!(
/* safety: test */
missing_description missing_description
.unwrap_err() .unwrap_err()
.to_string() .to_string()
@@ -1522,19 +1527,19 @@ mod tests {
let pending_id = manager let pending_id = manager
.create_job_for_user("default", "Pending Job", "Todo") .create_job_for_user("default", "Pending Job", "Todo")
.await .await
.unwrap(); .unwrap(); // safety: test
let completed_id = manager let completed_id = manager
.create_job_for_user("default", "Completed Job", "Done") .create_job_for_user("default", "Completed Job", "Done")
.await .await
.unwrap(); .unwrap(); // safety: test
let failed_id = manager let failed_id = manager
.create_job_for_user("default", "Failed Job", "Oops") .create_job_for_user("default", "Failed Job", "Oops")
.await .await
.unwrap(); .unwrap(); // safety: test
manager manager
.create_job_for_user("other-user", "Other User Job", "Ignore") .create_job_for_user("other-user", "Other User Job", "Ignore")
.await .await
.unwrap(); .unwrap(); // safety: test
manager manager
.update_context(completed_id, |ctx| { .update_context(completed_id, |ctx| {
@@ -1542,41 +1547,44 @@ mod tests {
ctx.transition_to(JobState::Completed, Some("done".to_string())) ctx.transition_to(JobState::Completed, Some("done".to_string()))
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
manager manager
.update_context(failed_id, |ctx| { .update_context(failed_id, |ctx| {
ctx.transition_to(JobState::InProgress, None)?; ctx.transition_to(JobState::InProgress, None)?;
ctx.transition_to(JobState::Failed, Some("boom".to_string())) ctx.transition_to(JobState::Failed, Some("boom".to_string()))
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
let tool = ListJobsTool::new(Arc::clone(&manager)); let tool = ListJobsTool::new(Arc::clone(&manager));
let ctx = JobContext::default(); 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(); let jobs = result.result.get("jobs").unwrap().as_array().unwrap(); // safety: test
assert_eq!(jobs.len(), 3); assert_eq!(jobs.len(), 3); // safety: test
assert!(jobs.iter().any(|job| { assert!(jobs.iter().any(|job| {
// safety: test
job.get("job_id").and_then(|v| v.as_str()) == Some(&pending_id.to_string()) 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") && job.get("status").and_then(|v| v.as_str()) == Some("Pending")
})); }));
assert!(jobs.iter().any(|job| { assert!(jobs.iter().any(|job| {
// safety: test
job.get("job_id").and_then(|v| v.as_str()) == Some(&completed_id.to_string()) 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") && job.get("status").and_then(|v| v.as_str()) == Some("Completed")
})); }));
assert!(jobs.iter().any(|job| { assert!(jobs.iter().any(|job| {
// safety: test
job.get("job_id").and_then(|v| v.as_str()) == Some(&failed_id.to_string()) 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") && job.get("status").and_then(|v| v.as_str()) == Some("Failed")
})); }));
let summary = result.result.get("summary").unwrap(); let summary = result.result.get("summary").unwrap(); // safety: test
assert_eq!(summary.get("total").and_then(|v| v.as_u64()), Some(3)); 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)); 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)); 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)); assert_eq!(summary.get("failed").and_then(|v| v.as_u64()), Some(1)); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1585,29 +1593,30 @@ mod tests {
let job_id = manager let job_id = manager
.create_job_for_user("default", "Transition Job", "Track me") .create_job_for_user("default", "Transition Job", "Track me")
.await .await
.unwrap(); .unwrap(); // safety: test
manager manager
.update_context(job_id, |ctx| { .update_context(job_id, |ctx| {
ctx.transition_to(JobState::InProgress, Some("started".to_string()))?; ctx.transition_to(JobState::InProgress, Some("started".to_string()))?;
ctx.transition_to(JobState::Completed, Some("finished".to_string())) ctx.transition_to(JobState::Completed, Some("finished".to_string()))
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
let tool = JobStatusTool::new(Arc::clone(&manager)); let tool = JobStatusTool::new(Arc::clone(&manager));
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool let result = tool
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx) .execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
.await .await
.unwrap(); .unwrap(); // safety: test
assert_eq!( assert_eq!(
/* safety: test */
result.result.get("status").and_then(|v| v.as_str()), result.result.get("status").and_then(|v| v.as_str()),
Some("Completed") Some("Completed")
); );
assert!(result.result.get("started_at").unwrap().is_string()); assert!(result.result.get("started_at").unwrap().is_string()); // safety: test
assert!(result.result.get("completed_at").unwrap().is_string()); assert!(result.result.get("completed_at").unwrap().is_string()); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1616,26 +1625,27 @@ mod tests {
let job_id = manager let job_id = manager
.create_job_for_user("default", "Running Job", "In progress") .create_job_for_user("default", "Running Job", "In progress")
.await .await
.unwrap(); .unwrap(); // safety: test
manager manager
.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None)) .update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
let tool = CancelJobTool::new(Arc::clone(&manager)); let tool = CancelJobTool::new(Arc::clone(&manager));
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool let result = tool
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx) .execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
.await .await
.unwrap(); .unwrap(); // safety: test
assert_eq!( assert_eq!(
/* safety: test */
result.result.get("status").and_then(|v| v.as_str()), result.result.get("status").and_then(|v| v.as_str()),
Some("cancelled") Some("cancelled")
); );
let updated = manager.get_context(job_id).await.unwrap(); let updated = manager.get_context(job_id).await.unwrap(); // safety: test
assert_eq!(updated.state, JobState::Cancelled); assert_eq!(updated.state, JobState::Cancelled); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1644,39 +1654,81 @@ mod tests {
let job_id = manager let job_id = manager
.create_job_for_user("default", "Completed Job", "Already done") .create_job_for_user("default", "Completed Job", "Already done")
.await .await
.unwrap(); .unwrap(); // safety: test
manager manager
.update_context(job_id, |ctx| { .update_context(job_id, |ctx| {
ctx.transition_to(JobState::InProgress, None)?; ctx.transition_to(JobState::InProgress, None)?;
ctx.transition_to(JobState::Completed, Some("done".to_string())) ctx.transition_to(JobState::Completed, Some("done".to_string()))
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
let tool = CancelJobTool::new(Arc::clone(&manager)); let tool = CancelJobTool::new(Arc::clone(&manager));
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool let result = tool
.execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx) .execute(serde_json::json!({ "job_id": job_id.to_string() }), &ctx)
.await .await
.unwrap(); .unwrap(); // safety: test
let error = result.result.get("error").and_then(|v| v.as_str()).unwrap(); let error = result.result.get("error").and_then(|v| v.as_str()).unwrap(); // safety: test
assert!(error.contains("Cannot cancel job")); assert!(error.contains("Cannot cancel job")); // safety: test
assert!(error.contains("completed")); 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] #[test]
fn test_resolve_project_dir_auto() { fn test_resolve_project_dir_auto() {
let project_id = Uuid::new_v4(); let project_id = Uuid::new_v4();
let (dir, browse_id) = resolve_project_dir(None, project_id).unwrap(); let (dir, browse_id) = resolve_project_dir(None, project_id).unwrap(); // safety: test
assert!(dir.exists()); assert!(dir.exists()); // safety: test
assert!(dir.ends_with(project_id.to_string())); assert!(dir.ends_with(project_id.to_string())); // safety: test
assert_eq!(browse_id, project_id.to_string()); assert_eq!(browse_id, project_id.to_string()); // safety: test
// Must be under the projects base // Must be under the projects base
let base = projects_base().canonicalize().unwrap(); let base = projects_base().canonicalize().unwrap(); // safety: test
assert!(dir.starts_with(&base)); assert!(dir.starts_with(&base)); // safety: test
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
@@ -1684,33 +1736,34 @@ mod tests {
#[test] #[test]
fn test_resolve_project_dir_explicit_under_base() { fn test_resolve_project_dir_explicit_under_base() {
let base = projects_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"); let explicit = base.join("test_explicit_project");
// Explicit paths must already exist (no auto-create). // 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 project_id = Uuid::new_v4();
let (dir, browse_id) = resolve_project_dir(Some(explicit.clone()), project_id).unwrap(); let (dir, browse_id) = resolve_project_dir(Some(explicit.clone()), project_id).unwrap(); // safety: test
assert!(dir.exists()); assert!(dir.exists()); // safety: test
assert_eq!(browse_id, "test_explicit_project"); assert_eq!(browse_id, "test_explicit_project"); // safety: test
let canonical_base = base.canonicalize().unwrap(); let canonical_base = base.canonicalize().unwrap(); // safety: test
assert!(dir.starts_with(&canonical_base)); assert!(dir.starts_with(&canonical_base)); // safety: test
let _ = std::fs::remove_dir_all(&explicit); let _ = std::fs::remove_dir_all(&explicit);
} }
#[test] #[test]
fn test_resolve_project_dir_rejects_outside_base() { 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"); let escape_attempt = tmp.path().join("evil_project");
// Don't create it: explicit paths that don't exist are rejected // Don't create it: explicit paths that don't exist are rejected
// before the prefix check even runs. // before the prefix check even runs.
let result = resolve_project_dir(Some(escape_attempt), Uuid::new_v4()); 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(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("does not exist"), err.contains("does not exist"),
"expected 'does not exist' error, got: {}", "expected 'does not exist' error, got: {}",
err err
@@ -1720,13 +1773,14 @@ mod tests {
#[test] #[test]
fn test_resolve_project_dir_rejects_outside_base_existing() { fn test_resolve_project_dir_rejects_outside_base_existing() {
// A directory that exists but is outside the projects base. // 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 outside = tmp.path().to_path_buf();
let result = resolve_project_dir(Some(outside), Uuid::new_v4()); 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(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("must be under"), err.contains("must be under"),
"expected 'must be under' error, got: {}", "expected 'must be under' error, got: {}",
err err
@@ -1740,7 +1794,7 @@ mod tests {
let traversal = base.join("legit").join("..").join("..").join(".ssh"); let traversal = base.join("legit").join("..").join("..").join(".ssh");
let result = resolve_project_dir(Some(traversal), Uuid::new_v4()); 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. // Traversal path that actually resolves gets the prefix check.
// `base/../` resolves to the parent of projects base, which is outside. // `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(); std::fs::create_dir_all(&base_parent).ok();
if base_parent.exists() { if base_parent.exists() {
let result = resolve_project_dir(Some(base_parent.clone()), Uuid::new_v4()); 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); let _ = std::fs::remove_dir_all(&base_parent);
} }
} }
@@ -1762,8 +1816,9 @@ mod tests {
)); ));
let tool = CreateJobTool::new(manager).with_sandbox(jm, None); let tool = CreateJobTool::new(manager).with_sandbox(jm, None);
let schema = tool.parameters_schema(); 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!( assert!(
/* safety: test */
props.contains_key("project_dir"), props.contains_key("project_dir"),
"sandbox schema must expose project_dir" "sandbox schema must expose project_dir"
); );
@@ -1778,8 +1833,9 @@ mod tests {
)); ));
let tool = CreateJobTool::new(manager).with_sandbox(jm, None); let tool = CreateJobTool::new(manager).with_sandbox(jm, None);
let schema = tool.parameters_schema(); 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!( assert!(
/* safety: test */
props.contains_key("credentials"), props.contains_key("credentials"),
"sandbox schema must expose credentials" "sandbox schema must expose credentials"
); );
@@ -1792,13 +1848,13 @@ mod tests {
// No credentials parameter // No credentials parameter
let params = serde_json::json!({"title": "t", "description": "d"}); let params = serde_json::json!({"title": "t", "description": "d"});
let grants = tool.parse_credentials(&params, "user1").await.unwrap(); let grants = tool.parse_credentials(&params, "user1").await.unwrap(); // safety: test
assert!(grants.is_empty()); assert!(grants.is_empty()); // safety: test
// Empty credentials object // Empty credentials object
let params = serde_json::json!({"credentials": {}}); let params = serde_json::json!({"credentials": {}});
let grants = tool.parse_credentials(&params, "user1").await.unwrap(); let grants = tool.parse_credentials(&params, "user1").await.unwrap(); // safety: test
assert!(grants.is_empty()); assert!(grants.is_empty()); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1808,9 +1864,10 @@ mod tests {
let params = serde_json::json!({"credentials": {"my_secret": "MY_SECRET"}}); let params = serde_json::json!({"credentials": {"my_secret": "MY_SECRET"}});
let result = tool.parse_credentials(&params, "user1").await; 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(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("no secrets store"), err.contains("no secrets store"),
"expected 'no secrets store' error, got: {}", "expected 'no secrets store' error, got: {}",
err err
@@ -1828,9 +1885,10 @@ mod tests {
let params = serde_json::json!({"credentials": {"nonexistent_secret": "SOME_VAR"}}); let params = serde_json::json!({"credentials": {"nonexistent_secret": "SOME_VAR"}});
let result = tool.parse_credentials(&params, "user1").await; 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(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("not found"), err.contains("not found"),
"expected 'not found' error, got: {}", "expected 'not found' error, got: {}",
err err
@@ -1852,17 +1910,17 @@ mod tests {
CreateSecretParams::new("github_token", TEST_GITHUB_TOKEN), CreateSecretParams::new("github_token", TEST_GITHUB_TOKEN),
) )
.await .await
.unwrap(); .unwrap(); // safety: test
let tool = CreateJobTool::new(manager).with_secrets(Arc::clone(&secrets)); let tool = CreateJobTool::new(manager).with_secrets(Arc::clone(&secrets));
let params = serde_json::json!({ let params = serde_json::json!({
"credentials": {"github_token": "GITHUB_TOKEN"} "credentials": {"github_token": "GITHUB_TOKEN"}
}); });
let grants = tool.parse_credentials(&params, "user1").await.unwrap(); let grants = tool.parse_credentials(&params, "user1").await.unwrap(); // safety: test
assert_eq!(grants.len(), 1); assert_eq!(grants.len(), 1); // safety: test
assert_eq!(grants[0].secret_name, "github_token"); assert_eq!(grants[0].secret_name, "github_token"); // safety: test
assert_eq!(grants[0].env_var, "GITHUB_TOKEN"); assert_eq!(grants[0].env_var, "GITHUB_TOKEN"); // safety: test
} }
fn test_prompt_tool(queue: PromptQueue) -> JobPromptTool { fn test_prompt_tool(queue: PromptQueue) -> JobPromptTool {
@@ -1876,7 +1934,7 @@ mod tests {
let job_id = cm let job_id = cm
.create_job_for_user("default", "Test Job", "desc") .create_job_for_user("default", "Test Job", "desc")
.await .await
.unwrap(); .unwrap(); // safety: test
let queue: PromptQueue = let queue: PromptQueue =
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
@@ -1889,18 +1947,19 @@ mod tests {
}); });
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await.unwrap(); let result = tool.execute(params, &ctx).await.unwrap(); // safety: test
assert_eq!( assert_eq!(
result.result.get("status").unwrap().as_str().unwrap(), /* safety: test */
result.result.get("status").unwrap().as_str().unwrap(), // safety: test
"queued" "queued"
); );
let q = queue.lock().await; let q = queue.lock().await;
let prompts = q.get(&job_id).unwrap(); let prompts = q.get(&job_id).unwrap(); // safety: test
assert_eq!(prompts.len(), 1); assert_eq!(prompts.len(), 1); // safety: test
assert_eq!(prompts[0].content, "What's the status?"); assert_eq!(prompts[0].content, "What's the status?"); // safety: test
assert!(!prompts[0].done); assert!(!prompts[0].done); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1910,6 +1969,7 @@ mod tests {
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
let tool = test_prompt_tool(queue); let tool = test_prompt_tool(queue);
assert_eq!( assert_eq!(
/* safety: test */
tool.requires_approval(&serde_json::json!({})), tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::UnlessAutoApproved ApprovalRequirement::UnlessAutoApproved
); );
@@ -1928,7 +1988,7 @@ mod tests {
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await; let result = tool.execute(params, &ctx).await;
assert!(result.is_err()); assert!(result.is_err()); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1943,7 +2003,7 @@ mod tests {
let ctx = JobContext::default(); let ctx = JobContext::default();
let result = tool.execute(params, &ctx).await; let result = tool.execute(params, &ctx).await;
assert!(result.is_err()); assert!(result.is_err()); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1958,7 +2018,7 @@ mod tests {
let job_id = cm let job_id = cm
.create_job_for_user("owner-user", "Secret Job", "classified") .create_job_for_user("owner-user", "Secret Job", "classified")
.await .await
.unwrap(); .unwrap(); // safety: test
// We need a Store to construct the tool, but creating one requires // We need a Store to construct the tool, but creating one requires
// a database URL. Instead, test the ownership logic directly: // a database URL. Instead, test the ownership logic directly:
@@ -1968,9 +2028,9 @@ mod tests {
..Default::default() ..Default::default()
}; };
let job_ctx = cm.get_context(job_id).await.unwrap(); let job_ctx = cm.get_context(job_id).await.unwrap(); // safety: test
assert_ne!(job_ctx.user_id, attacker_ctx.user_id); assert_ne!(job_ctx.user_id, attacker_ctx.user_id); // safety: test
assert_eq!(job_ctx.user_id, "owner-user"); assert_eq!(job_ctx.user_id, "owner-user"); // safety: test
} }
#[test] #[test]
@@ -1991,12 +2051,12 @@ mod tests {
"required": ["job_id"] "required": ["job_id"]
}); });
let props = schema.get("properties").unwrap().as_object().unwrap(); let props = schema.get("properties").unwrap().as_object().unwrap(); // safety: test
assert!(props.contains_key("job_id")); assert!(props.contains_key("job_id")); // safety: test
assert!(props.contains_key("limit")); assert!(props.contains_key("limit")); // safety: test
let required = schema.get("required").unwrap().as_array().unwrap(); let required = schema.get("required").unwrap().as_array().unwrap(); // safety: test
assert_eq!(required.len(), 1); assert_eq!(required.len(), 1); // safety: test
assert_eq!(required[0].as_str().unwrap(), "job_id"); assert_eq!(required[0].as_str().unwrap(), "job_id"); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -2005,7 +2065,7 @@ mod tests {
let job_id = cm let job_id = cm
.create_job_for_user("owner-user", "Test Job", "desc") .create_job_for_user("owner-user", "Test Job", "desc")
.await .await
.unwrap(); .unwrap(); // safety: test
let queue: PromptQueue = let queue: PromptQueue =
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
@@ -2023,9 +2083,10 @@ mod tests {
}; };
let result = tool.execute(params, &ctx).await; let result = tool.execute(params, &ctx).await;
assert!(result.is_err()); assert!(result.is_err()); // safety: test
let err = result.unwrap_err().to_string(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("does not belong to current user"), err.contains("does not belong to current user"),
"expected ownership error, got: {}", "expected ownership error, got: {}",
err err
@@ -2035,33 +2096,34 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_resolve_job_id_full_uuid() { async fn test_resolve_job_id_full_uuid() {
let cm = ContextManager::new(5); 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(); let resolved = resolve_job_id(&job_id.to_string(), &cm).await.unwrap(); // safety: test
assert_eq!(resolved, job_id); assert_eq!(resolved, job_id); // safety: test
} }
#[tokio::test] #[tokio::test]
async fn test_resolve_job_id_short_prefix() { async fn test_resolve_job_id_short_prefix() {
let cm = ContextManager::new(5); 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) // Use first 8 hex chars (without dashes)
let hex = job_id.to_string().replace('-', ""); let hex = job_id.to_string().replace('-', "");
let prefix = &hex[..8]; let prefix = &hex[..8];
let resolved = resolve_job_id(prefix, &cm).await.unwrap(); let resolved = resolve_job_id(prefix, &cm).await.unwrap(); // safety: test
assert_eq!(resolved, job_id); assert_eq!(resolved, job_id); // safety: test
} }
#[tokio::test] #[tokio::test]
async fn test_resolve_job_id_no_match() { async fn test_resolve_job_id_no_match() {
let cm = ContextManager::new(5); 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; 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(); let err = result.unwrap_err().to_string();
assert!( assert!(
/* safety: test */
err.contains("no job found"), err.contains("no job found"),
"expected 'no job found', got: {}", "expected 'no job found', got: {}",
err err
@@ -2072,6 +2134,6 @@ mod tests {
async fn test_resolve_job_id_invalid_input() { async fn test_resolve_job_id_invalid_input() {
let cm = ContextManager::new(5); let cm = ContextManager::new(5);
let result = resolve_job_id("not-hex-at-all!", &cm).await; 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`). //! On-demand tool discovery (like CLI `--help`).
//! //!
//! Two levels of detail: //! Three levels of detail:
//! - Default: name, description, parameter names (compact ~150 bytes) //! - 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) //! Keeps the tools array compact (WASM tools use permissive schemas)
//! while allowing precise discovery when needed. //! while allowing precise discovery when needed.
@@ -13,7 +14,59 @@ use async_trait::async_trait;
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::registry::ToolRegistry; 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 { pub struct ToolInfoTool {
registry: Weak<ToolRegistry>, registry: Weak<ToolRegistry>,
@@ -32,8 +85,7 @@ impl Tool for ToolInfoTool {
} }
fn description(&self) -> &str { fn description(&self) -> &str {
"Get info about any tool: description and parameter names. \ "Get info about any tool: description, parameter names, curated summary guidance, or full discovery schema."
Set include_schema to true for the full typed parameter schema."
} }
fn parameters_schema(&self) -> serde_json::Value { fn parameters_schema(&self) -> serde_json::Value {
@@ -44,9 +96,15 @@ impl Tool for ToolInfoTool {
"type": "string", "type": "string",
"description": "Name of the tool to get info about" "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": { "include_schema": {
"type": "boolean", "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 "default": false
} }
}, },
@@ -61,10 +119,7 @@ impl Tool for ToolInfoTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = require_str(&params, "name")?;
let include_schema = params let detail = ToolInfoDetail::parse(&params)?;
.get("include_schema")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let registry = self.registry.upgrade().ok_or_else(|| { let registry = self.registry.upgrade().ok_or_else(|| {
ToolError::ExecutionFailed( ToolError::ExecutionFailed(
@@ -77,13 +132,7 @@ impl Tool for ToolInfoTool {
})?; })?;
let schema = tool.discovery_schema(); let schema = tool.discovery_schema();
let param_names = schema_param_names(&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 mut info = serde_json::json!({ let mut info = serde_json::json!({
"name": tool.name(), "name": tool.name(),
@@ -91,8 +140,21 @@ impl Tool for ToolInfoTool {
"parameters": param_names, "parameters": param_names,
}); });
if include_schema { match detail {
info["schema"] = schema; 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())) Ok(ToolOutput::success(info, start.elapsed()))
@@ -135,6 +197,30 @@ mod tests {
assert!(info.get("schema").is_none()); 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] #[tokio::test]
async fn test_tool_info_with_schema() { async fn test_tool_info_with_schema() {
let registry = Arc::new(ToolRegistry::new()); let registry = Arc::new(ToolRegistry::new());
@@ -157,6 +243,22 @@ mod tests {
assert!(info["schema"]["properties"].is_object()); 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] #[tokio::test]
async fn test_tool_info_unknown_tool() { async fn test_tool_info_unknown_tool() {
let registry = Arc::new(ToolRegistry::new()); let registry = Arc::new(ToolRegistry::new());
+27
View File
@@ -291,6 +291,33 @@ mod tests {
registry 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] #[tokio::test]
async fn test_execute_success() { async fn test_execute_success() {
let registry = registry_with(vec![Arc::new(EchoTool)]).await; let registry = registry_with(vec![Arc::new(EchoTool)]).await;
+205 -42
View File
@@ -288,6 +288,71 @@ impl McpClient {
Ok(headers) 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. /// Send a request to the MCP server with auth and session headers.
/// Automatically attempts token refresh on 401 errors (HTTP transports only). /// Automatically attempts token refresh on 401 errors (HTTP transports only).
async fn send_request(&self, request: McpRequest) -> Result<McpResponse, ToolError> { async fn send_request(&self, request: McpRequest) -> Result<McpResponse, ToolError> {
@@ -297,13 +362,26 @@ impl McpClient {
return self.transport.send(&request, &headers).await; 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 { for attempt in 0..2 {
let headers = self.build_request_headers().await?; let headers = self.build_request_headers().await?;
let result = self.transport.send(&request, &headers).await; let result = self.transport.send(&request, &headers).await;
match result { match result {
Ok(response) => return Ok(response), 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)) Err(ToolError::ExternalService(ref msg))
if msg.contains("401") if msg.contains("401")
|| msg.contains("Unauthorized") || msg.contains("Unauthorized")
@@ -362,47 +440,7 @@ impl McpClient {
{ {
return Ok(InitializeResult::default()); return Ok(InitializeResult::default());
} }
if let Some(ref session_manager) = self.session_manager { self.reinitialize_session().await
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)
}) })
.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] #[tokio::test]
async fn test_non_http_transport_skips_401_retry() { async fn test_non_http_transport_skips_401_retry() {
// initialize response, then notification ack (consumed but ignored), // initialize response, then notification ack (consumed but ignored),
@@ -965,6 +1051,83 @@ mod tests {
assert_eq!(transport.recorded_headers().len(), 2); // no additional sends 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] #[test]
fn test_strip_top_level_nulls_removes_null_fields() { fn test_strip_top_level_nulls_removes_null_fields() {
let input = serde_json::json!({ 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::secrets::SecretsStore;
use crate::skills::catalog::SkillCatalog; use crate::skills::catalog::SkillCatalog;
use crate::skills::registry::SkillRegistry; use crate::skills::registry::SkillRegistry;
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder}; use crate::tools::builder::{
BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder,
};
use crate::tools::builtin::{ use crate::tools::builtin::{
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool, ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool, JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
@@ -94,6 +96,15 @@ pub struct ToolRegistry {
} }
impl 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. /// Create a new empty registry.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -206,11 +217,7 @@ impl ToolRegistry {
.read() .read()
.await .await
.values() .values()
.map(|tool| ToolDefinition { .map(Self::tool_definition)
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.collect(); .collect();
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name)); defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
defs defs
@@ -221,13 +228,7 @@ impl ToolRegistry {
let tools = self.tools.read().await; let tools = self.tools.read().await;
names names
.iter() .iter()
.filter_map(|name| { .filter_map(|name| tools.get(*name).map(Self::tool_definition))
tools.get(*name).map(|tool| ToolDefinition {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
})
.collect() .collect()
} }
@@ -282,11 +283,7 @@ impl ToolRegistry {
.await .await
.values() .values()
.filter(|tool| tool.domain() == domain) .filter(|tool| tool.domain() == domain)
.map(|tool| ToolDefinition { .map(Self::tool_definition)
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.collect() .collect()
} }
@@ -312,11 +309,7 @@ impl ToolRegistry {
ApprovalRequirement::Never ApprovalRequirement::Never
) )
}) })
.map(|tool| ToolDefinition { .map(Self::tool_definition)
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
})
.collect(); .collect();
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name)); defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
defs defs
@@ -585,22 +578,23 @@ impl ToolRegistry {
self: &Arc<Self>, self: &Arc<Self>,
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
config: Option<BuilderConfig>, config: Option<BuilderConfig>,
) { ) -> Arc<dyn SoftwareBuilder> {
// First register dev tools needed by the builder // First register dev tools needed by the builder
self.register_dev_tools(); self.register_dev_tools();
// Create the builder (arg order: config, llm, 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(), config.unwrap_or_default(),
llm, llm,
Arc::clone(self), Arc::clone(self),
)); ));
// Register the build_software tool // Register the build_software tool
self.register(Arc::new(BuildSoftwareTool::new(builder))) self.register(Arc::new(BuildSoftwareTool::new(Arc::clone(&builder))))
.await; .await;
tracing::debug!("Registered software builder tool"); tracing::info!("Registered software builder tool");
builder
} }
/// Register a WASM tool from bytes. /// Register a WASM tool from bytes.
@@ -788,6 +782,7 @@ impl std::fmt::Debug for ToolRegistry {
mod tests { mod tests {
use super::*; use super::*;
use crate::tools::registry::EchoTool; use crate::tools::registry::EchoTool;
use crate::tools::tool::ToolDiscoverySummary;
#[tokio::test] #[tokio::test]
async fn test_register_and_get() { async fn test_register_and_get() {
@@ -818,6 +813,71 @@ mod tests {
assert_eq!(defs[0].name, "echo"); 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] #[tokio::test]
async fn test_builtin_tool_cannot_be_shadowed() { async fn test_builtin_tool_cannot_be_shadowed() {
let registry = ToolRegistry::new(); let registry = ToolRegistry::new();
+1 -9
View File
@@ -605,15 +605,7 @@ mod tests {
), ),
( (
"event_emit", "event_emit",
serde_json::json!({ crate::tools::builtin::routine::event_emit_parameters_schema(),
"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"]
}),
), ),
// Job tools with complex deps // 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. /// Trait for tools that the agent can use.
#[async_trait] #[async_trait]
pub trait Tool: Send + Sync { pub trait Tool: Send + Sync {
@@ -347,12 +360,32 @@ pub trait Tool: Send + Sync {
self.parameters_schema() 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. /// Get the tool schema for LLM function calling.
fn schema(&self) -> ToolSchema { 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 { ToolSchema {
name: self.name().to_string(), name: self.name().to_string(),
description: self.description().to_string(), description,
parameters: self.parameters_schema(), parameters,
} }
} }
} }
+149 -28
View File
@@ -196,6 +196,7 @@ impl Worker {
.get("session_id") .get("session_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()), .map(|s| s.to_string()),
fallback_deliverable: data.get("fallback_deliverable").cloned(),
}), }),
_ => None, _ => 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> { 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() self.context_manager()
.update_context(self.job_id, |ctx| { .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? .await?
.map_err(|s| crate::error::JobError::ContextError { .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> { 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() 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? .await?
.map_err(|s| crate::error::JobError::ContextError { .map_err(|s| crate::error::JobError::ContextError {
id: self.job_id, 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())); self.persist_status(JobState::Stuck, Some(reason.to_string()));
Ok(()) 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. /// 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 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 { let deps = WorkerDeps {
context_manager: cm, context_manager: cm,
@@ -1472,8 +1536,9 @@ mod tests {
tool_call_id: "call_abc123".to_string(), 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!( assert_ne!(
/* safety: test */
selection.tool_call_id, "tool_call_id", selection.tool_call_id, "tool_call_id",
"tool_call_id must not be the hardcoded placeholder string" "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 results = worker.execute_tools_parallel(&selections).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
assert_eq!(results.len(), 3); assert_eq!(results.len(), 3); // safety: test
for r in &results { for r in &results {
assert!(r.result.is_ok(), "Tool should succeed"); assert!(r.result.is_ok(), "Tool should succeed"); // safety: test
} }
assert!( assert!(
/* safety: test */
elapsed < Duration::from_millis(800), elapsed < Duration::from_millis(800),
"Parallel execution took {:?}, expected < 800ms (sequential would be ~600ms)", "Parallel execution took {:?}, expected < 800ms (sequential would be ~600ms)",
elapsed elapsed
@@ -1565,9 +1631,9 @@ mod tests {
let results = worker.execute_tools_parallel(&selections).await; let results = worker.execute_tools_parallel(&selections).await;
assert!(results[0].result.as_ref().unwrap().contains("done_tool_a")); assert!(results[0].result.as_ref().unwrap().contains("done_tool_a")); // safety: test
assert!(results[1].result.as_ref().unwrap().contains("done_tool_b")); assert!(results[1].result.as_ref().unwrap().contains("done_tool_b")); // safety: test
assert!(results[2].result.as_ref().unwrap().contains("done_tool_c")); assert!(results[2].result.as_ref().unwrap().contains("done_tool_c")); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1583,8 +1649,9 @@ mod tests {
}]; }];
let results = worker.execute_tools_parallel(&selections).await; let results = worker.execute_tools_parallel(&selections).await;
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1); // safety: test
assert!( assert!(
/* safety: test */
results[0].result.is_err(), results[0].result.is_err(),
"Missing tool should produce an error, not a panic" "Missing tool should produce an error, not a panic"
); );
@@ -1600,23 +1667,24 @@ mod tests {
ctx.transition_to(JobState::InProgress, None) ctx.transition_to(JobState::InProgress, None)
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
worker.mark_completed().await.unwrap(); worker.mark_completed().await.unwrap(); // safety: test
let ctx = worker let ctx = worker
.context_manager() .context_manager()
.get_context(worker.job_id) .get_context(worker.job_id)
.await .await
.unwrap(); .unwrap(); // safety: test
assert_eq!(ctx.state, JobState::Completed); assert_eq!(ctx.state, JobState::Completed); // safety: test
// Second mark_completed should succeed (idempotent) rather than // Second mark_completed should succeed (idempotent) rather than
// erroring, matching the fix for the execution_loop / worker wrapper // erroring, matching the fix for the execution_loop / worker wrapper
// race condition. // race condition.
let result = worker.mark_completed().await; let result = worker.mark_completed().await;
assert!( assert!(
/* safety: test */
result.is_ok(), result.is_ok(),
"Completed -> Completed transition should be idempotent" "Completed -> Completed transition should be idempotent"
); );
@@ -1641,7 +1709,7 @@ mod tests {
} }
let cm = Arc::new(crate::context::ContextManager::new(5)); 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 { let deps = WorkerDeps {
context_manager: cm, context_manager: cm,
@@ -1740,6 +1808,7 @@ mod tests {
.execute_tool("needs_approval", &serde_json::json!({})) .execute_tool("needs_approval", &serde_json::json!({}))
.await; .await;
assert!( assert!(
/* safety: test */
result.is_err(), result.is_err(),
"Should be blocked without approval context" "Should be blocked without approval context"
); );
@@ -1752,7 +1821,7 @@ mod tests {
let result = worker_allowed let result = worker_allowed
.execute_tool("needs_approval", &serde_json::json!({})) .execute_tool("needs_approval", &serde_json::json!({}))
.await; .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] #[tokio::test]
@@ -1766,6 +1835,7 @@ mod tests {
.execute_tool("always_approval", &serde_json::json!({})) .execute_tool("always_approval", &serde_json::json!({}))
.await; .await;
assert!( assert!(
/* safety: test */
result.is_err(), result.is_err(),
"Always tool should be blocked without permission" "Always tool should be blocked without permission"
); );
@@ -1781,6 +1851,7 @@ mod tests {
.execute_tool("always_approval", &serde_json::json!({})) .execute_tool("always_approval", &serde_json::json!({}))
.await; .await;
assert!( assert!(
/* safety: test */
result.is_ok(), result.is_ok(),
"Always tool should be allowed with permission" "Always tool should be allowed with permission"
); );
@@ -1797,8 +1868,8 @@ mod tests {
ctx.transition_to(JobState::InProgress, None) ctx.transition_to(JobState::InProgress, None)
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
// Set a token budget // Set a token budget
worker worker
@@ -1807,16 +1878,17 @@ mod tests {
ctx.max_tokens = 100; ctx.max_tokens = 100;
}) })
.await .await
.unwrap(); .unwrap(); // safety: test
// Simulate adding tokens that exceed the budget // Simulate adding tokens that exceed the budget
let budget_result = worker let budget_result = worker
.context_manager() .context_manager()
.update_context(worker.job_id, |ctx| ctx.add_tokens(200)) .update_context(worker.job_id, |ctx| ctx.add_tokens(200))
.await .await
.unwrap(); .unwrap(); // safety: test
assert!( assert!(
/* safety: test */
budget_result.is_err(), budget_result.is_err(),
"Should return error when token budget exceeded" "Should return error when token budget exceeded"
); );
@@ -1825,13 +1897,13 @@ mod tests {
worker worker
.mark_failed(&budget_result.unwrap_err().to_string()) .mark_failed(&budget_result.unwrap_err().to_string())
.await .await
.unwrap(); .unwrap(); // safety: test
let ctx = worker let ctx = worker
.context_manager() .context_manager()
.get_context(worker.job_id) .get_context(worker.job_id)
.await .await
.unwrap(); .unwrap(); // safety: test
assert_eq!(ctx.state, JobState::Failed); assert_eq!(ctx.state, JobState::Failed); // safety: test
} }
#[tokio::test] #[tokio::test]
@@ -1845,21 +1917,22 @@ mod tests {
ctx.transition_to(JobState::InProgress, None) ctx.transition_to(JobState::InProgress, None)
}) })
.await .await
.unwrap() .unwrap() // safety: test
.unwrap(); .unwrap(); // safety: test
// Simulate what the execution loop does when max_iterations is exceeded // Simulate what the execution loop does when max_iterations is exceeded
worker worker
.mark_failed("Maximum iterations exceeded: job hit the iteration cap") .mark_failed("Maximum iterations exceeded: job hit the iteration cap")
.await .await
.unwrap(); .unwrap(); // safety: test
let ctx = worker let ctx = worker
.context_manager() .context_manager()
.get_context(worker.job_id) .get_context(worker.job_id)
.await .await
.unwrap(); .unwrap(); // safety: test
assert_eq!( assert_eq!(
/* safety: test */
ctx.state, ctx.state,
JobState::Failed, JobState::Failed,
"Iteration cap should transition to Failed, not Stuck" "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" "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 ## Using the Workspace
```rust ```rust
use std::sync::Arc;
use crate::workspace::{Workspace, OpenAiEmbeddings, paths}; 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) let workspace = Workspace::new("user_123", pool)
.with_embeddings(Arc::new(OpenAiEmbeddings::new(api_key))); .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 // Read/write any path
let doc = workspace.read("projects/alpha/notes.md").await?; let doc = workspace.read("projects/alpha/notes.md").await?;
workspace.write("context/priorities.md", "# Priorities\n\n1. Feature X").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);
}
}
+53 -2
View File
@@ -6,6 +6,8 @@
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::llm::retry::cap_retry_after;
/// Error type for embedding operations. /// Error type for embedding operations.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum EmbeddingError { pub enum EmbeddingError {
@@ -231,7 +233,9 @@ impl EmbeddingProvider for OpenAiEmbeddings {
.get("retry-after") .get("retry-after")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok()) .and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs); .map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
return Err(EmbeddingError::RateLimited { retry_after }); return Err(EmbeddingError::RateLimited { retry_after });
} }
@@ -372,7 +376,9 @@ impl EmbeddingProvider for NearAiEmbeddings {
.get("retry-after") .get("retry-after")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok()) .and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs); .map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
return Err(EmbeddingError::RateLimited { retry_after }); return Err(EmbeddingError::RateLimited { retry_after });
} }
@@ -646,4 +652,49 @@ mod tests {
let provider = OpenAiEmbeddings::new("test-key").with_base_url("custom.example.com/v1"); let provider = OpenAiEmbeddings::new("test-key").with_base_url("custom.example.com/v1");
assert_eq!(provider.base_url, "https://custom.example.com/v1"); assert_eq!(provider.base_url, "https://custom.example.com/v1");
} }
// -- Retry-After header parsing tests (regression for rate limit "None" bug) --
#[test]
fn test_retry_after_parsing_delay_seconds() {
// Verify delay-seconds format is parsed correctly
let header_value = "120";
let duration = parse_retry_after_embeddings_for_test(header_value);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(120)),
"Should parse delay-seconds format"
);
}
#[test]
fn test_retry_after_fallback_missing_header() {
// Regression test: When Retry-After header is missing,
// should fall back to 60s instead of None
let duration = parse_retry_after_embeddings_for_test("");
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Missing header should fallback to 60s"
);
}
#[test]
fn test_retry_after_zero_seconds_accepted() {
// Verify zero seconds is a valid retry delay
let duration = parse_retry_after_embeddings_for_test("0");
assert_eq!(duration, Some(std::time::Duration::ZERO));
}
/// Helper function to test Retry-After header parsing logic for embeddings
/// (simulates the parsing done in embed without actual HTTP, including fallback)
fn parse_retry_after_embeddings_for_test(header_value: &str) -> Option<std::time::Duration> {
header_value
.trim()
.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 chunker;
mod document; mod document;
mod embedding_cache;
mod embeddings; mod embeddings;
pub mod hygiene; pub mod hygiene;
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
@@ -50,6 +51,7 @@ mod search;
pub use chunker::{ChunkConfig, chunk_document}; pub use chunker::{ChunkConfig, chunk_document};
pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths}; pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
pub use embedding_cache::{CachedEmbeddingProvider, EmbeddingCacheConfig};
pub use embeddings::{ pub use embeddings::{
EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings, EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings,
}; };
@@ -371,7 +373,33 @@ impl Workspace {
} }
/// Set the embedding provider for semantic search. /// 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 { 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.embeddings = Some(provider);
self 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_always_btn": ".approval-actions button.always",
"approval_deny_btn": ".approval-actions button.deny", "approval_deny_btn": ".approval-actions button.deny",
"approval_resolved": ".approval-resolved", "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", "extensions_list": "#extensions-list",
"available_wasm_list": "#available-wasm-list", "available_wasm_list": "#available-wasm-list",
"mcp_servers_list": "#mcp-servers-list", "mcp_servers_list": "#mcp-servers-list",
"tools_tbody": "#tools-tbody",
"tools_empty": "#tools-empty",
# Extensions tab cards # Extensions tab cards
"ext_card_installed": "#extensions-list .ext-card", "ext_card_installed": "#extensions-list .ext-card",
"ext_card_available": "#available-wasm-list .ext-card.ext-available", "ext_card_available": "#available-wasm-list .ext-card.ext-available",
@@ -92,6 +93,12 @@ SEL = {
"ext_stepper": ".ext-stepper", "ext_stepper": ".ext-stepper",
"stepper_step": ".stepper-step", "stepper_step": ".stepper-step",
"stepper_circle": ".stepper-circle", "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 notifications
"toast": ".toast", "toast": ".toast",
"toast_success": ".toast.toast-success", "toast_success": ".toast.toast-success",
@@ -106,7 +113,7 @@ SEL = {
"routines_empty": "#routines-empty", "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 used across all tests
AUTH_TOKEN = "e2e-test-token" 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: async def oauth_exchange(request: web.Request) -> web.Response:
"""Mock OAuth token exchange proxy for E2E tests. """Mock OAuth token exchange proxy for E2E tests.
Accepts form params (code, redirect_uri, code_verifier) and returns Accepts the generic hosted OAuth proxy contract used by IronClaw and
a fake token response. Called by ironclaw's exchange_via_proxy() when returns a fake token response. MCP callback tests assert that provider-
IRONCLAW_OAUTH_EXCHANGE_URL is set. specific token params such as RFC 8707 `resource` are forwarded here.
""" """
data = await request.post() data = await request.post()
code = data.get("code", "") 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({ return web.json_response({
"access_token": f"mock-token-{code}", access_token_field: f"mock-token-{code}",
"refresh_token": "mock-refresh-token", "refresh_token": "mock-refresh-token",
"expires_in": 3600, "expires_in": 3600,
}) })
+71 -133
View File
@@ -87,23 +87,21 @@ _REGISTRY_MCP = {
"installed": False, "installed": False,
} }
_SAMPLE_TOOL = {"name": "echo", "description": "Echo a message"}
_SAMPLE_TOOL_2 = {"name": "time", "description": "Get current time"}
# ─── Navigation helpers ──────────────────────────────────────────────────────── # ─── Navigation helpers ────────────────────────────────────────────────────────
async def go_to_extensions(page): 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 Waits for loadExtensions() to finish rendering by polling for the first
content signal (empty-state div or an installed card) rather than sleeping. 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_button"].format(tab="settings")).click()
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for( 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 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 # first concrete DOM signal instead of a hard sleep so the test is
# deterministic even under CI load. # deterministic even under CI load.
await page.locator( await page.locator(
@@ -111,19 +109,39 @@ async def go_to_extensions(page):
).first.wait_for(state="visible", timeout=8000) ).first.wait_for(state="visible", timeout=8000)
async def mock_ext_apis(page, *, installed=None, tools=None, registry=None): async def go_to_channels(page):
"""Intercept the three extension list APIs with fixture data. """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 []}) ext_body = json.dumps({"extensions": installed or []})
tools_body = json.dumps({"tools": tools or []})
registry_body = json.dumps({"entries": registry or []}) registry_body = json.dumps({"entries": registry or []})
# Playwright evaluates route handlers in LIFO order (last-registered fires # Playwright evaluates route handlers in LIFO order (last-registered fires
# first). Register the broad handler first so it is checked last; the # first). Register the broad handler first so it is checked last; the
# specific /tools and /registry handlers are registered after and therefore # specific /registry handler is registered after and therefore checked
# checked first — no continue_() fallthrough needed. # first — no continue_() fallthrough needed.
async def handle_ext_list(route): async def handle_ext_list(route):
path = route.request.url.split("?")[0] path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"): 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) 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): async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body=registry_body) 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) 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 ──────────────────────────────────────── # ─── Group A: Structural / empty state ────────────────────────────────────────
async def test_extensions_empty_tab_layout(page): async def test_extensions_empty_tab_layout(page):
"""Extensions tab with no data shows all three sections with correct empty-state messages.""" """Extensions subtab with no data shows sections with correct empty-state messages."""
await mock_ext_apis(page, tools=[]) await mock_ext_apis(page)
await go_to_extensions(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() assert await panel.is_visible()
ext_list = page.locator(SEL["extensions_list"]) ext_list = page.locator(SEL["extensions_list"])
assert await ext_list.is_visible() assert await ext_list.is_visible()
assert "No extensions installed" in await ext_list.text_content() 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 ─────────────────────────────────────── # ─── 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): async def test_installed_mcp_server_active(page):
"""Active MCP server shows 'Active' label and no Activate button.""" """Active MCP server shows 'Active' label and no Activate button."""
await mock_ext_apis(page, installed=[_MCP_ACTIVE]) 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) await card.wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_active_label"]).count() == 1 assert await card.locator(SEL["ext_active_label"]).count() == 1
assert await card.locator(SEL["ext_activate_btn"]).count() == 0 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): async def test_installed_mcp_server_inactive_shows_activate(page):
"""Inactive MCP server shows Activate button.""" """Inactive MCP server shows Activate button."""
await mock_ext_apis(page, installed=[_MCP_INACTIVE]) 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) await card.wait_for(state="visible", timeout=5000)
assert await card.locator(SEL["ext_activate_btn"]).count() == 1 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): async def test_mcp_server_in_registry_not_installed(page):
"""Registry MCP entry (not installed) appears in the MCP section with Install button.""" """Registry MCP entry (not installed) appears in the MCP section with Install button."""
await mock_ext_apis(page, registry=[_REGISTRY_MCP]) 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"]) mcp_list = page.locator(SEL["mcp_servers_list"])
card = mcp_list.locator(".ext-card").first 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} installed_mcp = {**_MCP_ACTIVE, "name": "registry-mcp", "authenticated": False}
registry_mcp = {**_REGISTRY_MCP, "name": "registry-mcp"} registry_mcp = {**_REGISTRY_MCP, "name": "registry-mcp"}
await mock_ext_apis(page, installed=[installed_mcp], registry=[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"]) mcp_list = page.locator(SEL["mcp_servers_list"])
card = mcp_list.locator(".ext-card").first 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): async def _load_wasm_channel(page, activation_status, activation_error=None):
ext = {**_WASM_CHANNEL, "activation_status": activation_status, "activation_error": activation_error} ext = {**_WASM_CHANNEL, "activation_status": activation_status, "activation_error": activation_error}
await mock_ext_apis(page, installed=[ext]) await mock_ext_apis(page, installed=[ext])
await go_to_extensions(page) await go_to_channels(page)
card = page.locator(SEL["ext_card_installed"]).first # 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) await card.wait_for(state="visible", timeout=5000)
return card 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/test-channel/setup", handle_channel_setup)
await page.route("**/api/extensions/install", handle_channel_install) 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.wait_for(state="visible", timeout=5000)
await install_btn.click() await install_btn.click()
@@ -523,13 +509,14 @@ async def test_remove_installed_extension_confirmed(page):
# Override for subsequent calls # Override for subsequent calls
await page.route("**/api/extensions*", handle_ext_empty) 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 card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000) await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click() 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 # Card should disappear
await page.wait_for_function( await page.wait_for_function(
"() => document.querySelectorAll('#extensions-list .ext-card').length === 0", "() => 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 mock_ext_apis(page, installed=[_WASM_TOOL])
await go_to_extensions(page) await go_to_extensions(page)
# Reject the confirm dialog
await page.evaluate("window.confirm = () => false")
card = page.locator(SEL["ext_card_installed"]).first card = page.locator(SEL["ext_card_installed"]).first
await card.wait_for(state="visible", timeout=5000) await card.wait_for(state="visible", timeout=5000)
await card.locator(SEL["ext_remove_btn"]).click() 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" 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: else:
await route.continue_() await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route): async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler) await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry) await page.route("**/api/extensions/registry", handle_registry)
await go_to_extensions(page) 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") 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 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(""" await page.evaluate("""
handleAuthCompleted({ handleAuthCompleted({
extension_name: 'gmail', 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.") 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["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" 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 mock_ext_apis(page, installed=[_MCP_INACTIVE])
await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) 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.wait_for(state="visible", timeout=5000)
async with page.expect_response("**/api/extensions/test-mcp-inactive/activate", 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/activate", handle_activate)
await page.route("**/api/extensions/test-mcp-inactive/setup", handle_setup) 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.wait_for(state="visible", timeout=5000)
await activate_btn.click() 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 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 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.wait_for(state="visible", timeout=5000)
await activate_btn.click() 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 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 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.wait_for(state="visible", timeout=5000)
await activate_btn.click() 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 ──────────────────────────────────────────── # ─── Group J: Tab reload behaviour ────────────────────────────────────────────
async def test_extensions_tab_reloads_on_revisit(page): 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 = [] call_count = []
async def counting_handler(route): async def counting_handler(route):
@@ -1121,14 +1105,10 @@ async def test_extensions_tab_reloads_on_revisit(page):
else: else:
await route.continue_() await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route): async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler) await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry) await page.route("**/api/extensions/registry", handle_registry)
# First visit # 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" 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 ───────────────────────────────────────────────────────── # ─── Regression tests ─────────────────────────────────────────────────────────
# Each test below is a regression for a specific bug found after the initial # 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. # 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 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.wait_for(state="visible", timeout=5000)
await activate_btn.click() 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, ( assert auth_url is not None or awaiting_token, (
f"Activate should require auth, got: {data}" 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 ────────────────────────────────────────── # ── Section C: OAuth Round-Trip ──────────────────────────────────────────

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