Compare commits

..
Author SHA1 Message Date
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
7e8c0fbed6 chore: release v0.18.0 (#885)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-11 17:19:51 +00:00
Henry ParkandGitHub 6aae1f8a9e Merge pull request #907 from nearai/staging-promote/b0214fef-22930316561
chore: promote staging to main (2026-03-11 00:16 UTC)
2026-03-11 10:09:44 -07:00
Henry ParkandGitHub 7a9396f081 Merge pull request #904 from nearai/staging-promote/3a841b30-22928320566
chore: promote staging to main (2026-03-10 23:06 UTC)
2026-03-11 09:57:35 -07:00
Henry ParkandClaude Opus 4.6 6116c885e3 merge: resolve main into staging-promote (ChannelSecretUpdater import)
Keep ChannelSecretUpdater as a local import inside #[cfg(unix)] block
to avoid unused-import warnings on non-unix targets.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-10 22:27:16 -07:00
+7 a677b20701 chore: promote staging to main (2026-03-10 15:19 UTC) (#865)
* fix: Channel HTTP: server doesn't start after config change (no hot-r… (#779)

* fix: Channel HTTP: server doesn't start after config change (no hot-reload)

* review fixes

* review fixes

* fix linter

* fix code style

* fix: prevent session lock contention blocking message processing (#783)

* fix: prevent session lock contention blocking message processing

## Problem
After container restart, POST /api/chat/send returns 202 ACCEPTED but messages
don't appear in conversation_messages and agent never responds. Messages get
stuck in "stale state" after restart.

Root cause: Session lock was held for entire duration of chat_threads_handler
and chat_history_handler, including during slow database queries. This blocked
the agent loop from acquiring the session lock to process incoming messages,
causing them to hang indefinitely.

## Solution
1. **Release session lock early in chat_threads_handler**: Only acquire lock
   when reading active_thread at response time, not during DB queries for
   thread list. DB operations no longer block message processing.

2. **Release session lock early in chat_history_handler**: Only acquire lock
   when accessing in-memory thread state, not during paginated DB queries or
   thread ownership checks. DB operations no longer block message processing.

3. **Add comprehensive logging**: Track message flow from receipt through
   session resolution, thread hydration, and state transitions. Helps diagnose
   future issues:
   - Message queued to agent loop (chat_send_handler)
   - Processing message from channel (handle_message)
   - Hydrating thread from DB (maybe_hydrate_thread)
   - Resolving session and thread (resolve_thread)
   - Checking thread state (process_user_input)
   - Persisting user message (persist_user_message)

## Impact
- Message processing no longer blocks on session lock contention
- API response times for thread list/history queries unaffected (DB queries
  still happen, but lock is not held)
- Better diagnostics for future debugging

## Testing
- All 2756 tests pass
- Code compiles with zero clippy warnings
- No changes to user-facing API or behavior, only lock timing

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

* security: redact PII from info-level logs

Downgrade user_id and channel logging to debug level to prevent exposing
Personally Identifiable Information (PII) in production logs.

The user_id field can contain sensitive information such as phone numbers
(e.g., for Signal messages). Logging PII in cleartext at the info level
creates a security and privacy risk, as these logs may be stored in
persistent storage, indexed by log management systems, or accessible to
unauthorized personnel.

Changes:
- Info level: logs only message_id (UUID) for tracking
- Debug level: logs user_id, channel, thread_id for troubleshooting

This maintains debugging capability for developers while protecting user
privacy in production logs.

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

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>

* chore: sync main into staging (#855)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>

* fix: Chat input is hidden in mobile browser mode (#877)

* fix: stop XML-escaping tool output content (#598) (#874)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix: stop XML-escaping tool output content in wrap_for_llm (#598)

Remove content escaping that corrupted JSON in tool output. The
<tool_output> structural boundary is preserved but content now passes
through raw, fixing downstream parse failures.

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

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(safety): allow empty string tool params (#848)

* fix(safety): allow empty string tool params

* fix(safety): preserve heuristic checks and add path context to tool validation

This follow-up refactor addresses PR review feedback by restoring
heuristic checks (whitespace ratio, character repetition) for tool
parameter validation and improving error reporting.

Changes:
- Restored heuristic warnings in validate_non_empty_input so they apply
  to both user input and tool parameters (when non-empty).
- Refactored check_strings to recursively build and pass JSON paths
  (e.g., "metadata.tags[1]").
- Updated validation errors to use the specific JSON path as the field
  name instead of the generic "input".
- Added regression tests for whitespace/repetition warnings and JSON
  path reporting in tool parameters.

This ensures the safety layer remains semantically neutral about empty
strings (fixing the memory_tree path: "" issue) while maintaining
rigorous protection and providing better developer ergonomics.

* style: run cargo fmt

* perf: optimize release and dist build profiles (#843)

* perf: optimize release and dist build profiles

Add [profile.release] with strip=true and panic="abort" for smaller,
faster release binaries. Upgrade [profile.dist] from lto="thin" to
lto="fat" with codegen-units=1 for maximum optimization in CI releases.

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

* fix: remove panic=abort from release profile

Reviewers (zmanian, Copilot, Gemini) correctly flagged that panic=abort
in the release profile would kill the entire process on any tokio task
panic, breaking fault isolation for the long-running server. Removed
from release profile entirely.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: add PR template with risk assessment (#837)

* feat: add PR template with risk assessment and review tracks

Add a pull request template that includes summary, change type,
validation checklist, security/database impact sections, blast radius,
and rollback plan. Update CONTRIBUTING.md with review track definitions
(A/B/C) based on change risk level.

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

* fix: expand CONTRIBUTING.md with setup, workflow, and guidelines

Add getting started, development workflow, code style summary,
database change guidance, and dependency management sections.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: add fuzzing targets for untrusted input parsers (#835)

* feat: add fuzzing targets for untrusted input parsers

Add cargo-fuzz infrastructure with 5 fuzz targets exercising
security-critical code paths:

- fuzz_safety_sanitizer: Aho-Corasick + regex injection detection
- fuzz_safety_validator: Input validation (length, encoding, patterns)
- fuzz_leak_detector: Secret leak scanning (API keys, tokens)
- fuzz_tool_params: Tool parameter JSON validation
- fuzz_config_env: TOML/JSON config parsing

Each target exercises real IronClaw business logic with invariant
assertions. Includes corpus directories and setup documentation.

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

* fix: improve fuzz targets to exercise real IronClaw code paths

- fuzz_config_env: exercise SafetyLayer end-to-end (sanitize, validate,
  policy check) instead of generic TOML/JSON parsing
- fuzz_tool_params: add validate_tool_schema coverage alongside
  validate_tool_params
- Add "fuzz" to workspace exclude in root Cargo.toml
- Update README descriptions to match actual target behavior

[skip-regression-check]

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

* fix: replace redundant detect() call with meaningful invariant assertion

Replace the double sanitize()+detect() call with an assertion that
critical severity warnings always trigger content modification.

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

* fix: rewrite fuzz_config_env to exercise IronClaw safety code directly

Replace SafetyLayer wrapper usage with direct Sanitizer, Validator, and
LeakDetector instantiation and invocation. Adds meaningful consistency
assertions (non-empty output, valid-means-no-errors, scan/clean agreement).
Removes the config construction that was only exercising struct instantiation.

[skip-regression-check]

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(wasm): run leak scan before credential injection in tools wrapper (#791)

* fix(wasm): run leak scan before credential injection in tools wrapper

The tools WASM wrapper runs the LeakDetector on HTTP request headers
AFTER inject_host_credentials() has already substituted real secrets
(e.g., xoxb- Slack bot tokens). This causes the leak detector to
flag the tool's own legitimate outbound API calls as secret exfiltration.

Move the scan to run on raw_headers before any credential injection,
matching the fix already applied to the channels wrapper in #421.

Fixes the same class of bug as #421 (which only fixed channels/wasm/wrapper.rs).

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

* perf: inline leak scan to avoid Vec allocation on every HTTP request

Address review feedback: instead of cloning all header keys/values into
a Vec to pass to scan_http_request(), iterate over raw_headers directly
using scan_and_clean(). This also provides more specific error messages
(URL vs header vs body).

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

* style: fix cargo fmt formatting in leak scan loop

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(setup): drain residual terminal events before secret input (#747) (#849)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix: skip the regression check
[skip-regression-check]

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>

* feat(agent): add context size logging before LLM prompt (#810)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat(agent): add context size logging before LLM prompt

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>

* fix: preserve text before tool-call XML in forced-text responses (#852)

* fix: preserve text before tool-call XML in forced-text responses (#789)

Local models (Qwen3, DeepSeek, GLM) emit <tool_call> XML even when no
tools are available (force_text mode). The existing strip_xml_tag()
discards everything from an unclosed opening tag onward, producing an
empty string that triggers the "I'm not sure how to respond" fallback.

Add truncate_at_tool_tags() — a code-region-aware pre-processing step
that truncates at the first tool-call XML tag BEFORE clean_response()
runs, preserving all useful text before the tag. Protect all 7
clean_response() call sites. Case-insensitive matching handles models
that emit <TOOL_CALL> or <Tool_Call> variants.

Secondary fix: add has_native_thinking() model detection to skip
<think>/<final> system prompt injection for models with built-in
reasoning (Qwen3, QwQ, DeepSeek-R1, GLM-Z1, etc.), preventing
thinking-only responses that clean to empty.

Wire with_model_name(active_model_name()) at all 9 production sites
that construct Reasoning, so the runtime model name (not static config)
drives system prompt generation.

126 new/updated tests covering truncation edge cases, code-block
awareness, Unicode, case-insensitivity, StubLlm integration for
complete/plan/evaluate_success/respond_with_tools paths, model
detection, and conditional system prompt generation.

Closes #789

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

* fix: address Copilot review — unclosed-only truncation, ASCII case folding

- truncate_at_tool_tags() now only truncates at UNCLOSED tool tags;
  properly closed tags (e.g. <tool_call>...</tool_call>) are left intact
  for clean_response() to strip normally, preserving any text after them
- Switch from to_lowercase() to to_ascii_lowercase() to prevent byte
  offset misalignment with non-ASCII characters whose lowercase form
  has different byte length (e.g. Kelvin sign U+212A)
- Add closing_tag_for() helper to derive closing tags from open patterns
- Fix doc comment: "fenced markdown code blocks or inline code spans"
  (not "indented", which find_code_regions() doesn't detect)
- Add regression tests: closed vs unclosed for each tag variant,
  Unicode + case-insensitive offset safety, and mixed closed/unclosed

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

* fix: minor review items — consistent ascii_lowercase, closing_tag_for tests

- Switch has_native_thinking() from to_lowercase() to to_ascii_lowercase()
  for consistency with truncate_at_tool_tags() approach
- Add unit tests for closing_tag_for(): standard tags, space-suffixed
  patterns, pipe-delimited tags, and exhaustive coverage of all
  TOOL_TAG_PATTERNS entries
- Add test for mixed closed+unclosed tags of different types

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* Feat/docker shell edition (#804)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(mcp): strip top-level null params before forwarding to MCP servers (#795)

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(mcp): strip top-level null params before forwarding to MCP servers

LLMs frequently emit `"field": null` for optional parameters in tool
calls. Many MCP servers reject explicit nulls for fields that should
simply be absent — e.g. Notion returns 400 for `"sort": null` in a
search call, expecting the field to be omitted entirely.

Strip top-level null keys from the params object before calling
`call_tool()`. Only top-level keys are stripped; nested nulls are
preserved since they may be semantically meaningful.

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

---------

Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>

* Add event-triggered routines and workflow skill templates (#756)

* Add event-triggered routines and workflow skill templates

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback for event_emit security and quality

Security fixes:
- Require approval (UnlessAutoApproved) for event_emit, matching routine_fire
- Enable sanitization on event_emit payload (external JSON reaches LLM)
- Remove user_id parameter from event_emit to prevent IDOR — always use ctx.user_id

Correctness fixes:
- Rename source → event_source in event_emit for consistency with routine_create
- Use json_value_as_filter_string for filter parsing (handles numbers/booleans)
- Case-insensitive matching for event source and event_type
- Add debug logging for missing filter keys in payload
- Fix skill_install_routine_webhook_sim test missing .with_skills()
- Fix schema_validator test for event_emit payload properties

Code quality:
- Move EventEmitTool struct/impl after RoutineHistoryTool (fix split layout)
- Deduplicate routine_to_info into RoutineInfo::from_routine in types.rs
- Add test section headers in e2e_routine_heartbeat.rs
- Clarify event_emit description to specify system_event routines only

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

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix: make routine_system_event_emit test create routine before emitting

- Add routine_create step to trace fixture so event_emit has a matching
  routine to fire
- Assert fired_routines > 0, not just key presence (Copilot review)
- Add .with_auto_approve_tools(true) since event_emit now requires approval

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

* fix: renumber test headers after system_event test insertion

Test 4 was duplicated (routine_cooldown and heartbeat_findings).
Renumber heartbeat_findings to Test 5 and heartbeat_empty_skip to Test 6.

[skip-regression-check]

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

* fix: merge staging and add missing RoutineEngine args in test

RoutineEngine::new on staging requires `tools` and `safety` params.
Update system_event_trigger_matches_and_filters test to pass them.

[skip-regression-check]

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

* fix: address new Copilot review comments

- Add .with_auto_approve_tools(true) to skill_install_routine_webhook_sim
  test so event_emit doesn't block on approval
- Fix module-level doc comment for event_emit to specify system_event trigger

[skip-regression-check]

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

* fix: deduplicate json_value_as_string helper

Remove private `json_value_as_string` from routine_engine.rs and use
the identical public `json_value_as_filter_string` from routine.rs,
eliminating divergence risk. (Copilot review)

[skip-regression-check]

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

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix: enable WASM credential injection in No-DB environments (#845)

* fix(wasm): enable credential injection in no-DB environments via env var fallback

When a secrets store is unavailable (e.g. no-DB mode), WASM channel
credentials were silently not injected, causing channels to start without
credentials. Fix by:

- Changing `inject_channel_credentials_from_secrets` to accept
  `Option<&dyn SecretsStore>` — secrets store is tried first when present
- Adding env var fallback (`inject_env_credentials`) for credentials not
  covered by the secrets store
- Enforcing a channel-name prefix security check on env var names to
  prevent WASM channels from reading unrelated host credentials
  (e.g. `AWS_SECRET_ACCESS_KEY`)
- Extracting pure `resolve_env_credentials` helper for testability
- Adding case-insensitive prefix matching for secrets store lookup

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

* fix(wasm): inject credentials at startup when no secrets store (setup.rs path)

The startup path (setup_wasm_channels -> register_channel) was guarded by
`if let Some(secrets) = secrets_store`, so in No-DB mode credentials were
never injected and the channel started without them.

Fix by:
- Changing inject_channel_credentials to accept Option<&dyn SecretsStore>
- Always calling it (removing the if-let guard) — env var fallback runs
  even when secrets_store is None
- Adding channel-name prefix security check to the env var fallback path
  (e.g. TELEGRAM_ for channel "telegram"), consistent with manager.rs

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

* fix(test): correct misleading comment on ICTEST1_UNRELATED_OTHER placeholder

* fix(wasm): guard against empty channel name in credential injection

An empty channel_name would produce prefix "_", allowing any env var
starting with "_" to pass the security check and be injected. Add an
early-return guard in resolve_env_credentials, inject_env_credentials,
and inject_channel_credentials. Add a test to cover this path.

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

---------

Co-authored-by: lizican123 <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix: promote to main (#878)

* fix: replace unsafe env::set_var with thread-safe inject_single_var in SIGHUP handler

Fixes race condition where SIGHUP handler modifies global environment variables
while other threads may be reading them via Config::from_env().

Changes:
- Replace unsafe { std::env::set_var() } with ironclaw::config::inject_single_var()
- Uses INJECTED_VARS mutex instead of unsafe global state modification
- All reads via optional_env() check the thread-safe overlay first
- Prevents data races between SIGHUP reload and concurrent config reads

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* fix: spawn webhook restart as background task to avoid blocking I/O across lock

Prevents holding Mutex lock during async I/O operations (TcpListener::bind,
task shutdown). The SIGHUP handler no longer blocks webhook processing during
listener restart.

Changes:
- Read old_addr and drop lock immediately
- Spawn restart_with_addr() as background task via tokio::spawn
- Lock is only held during the actual restart operation, not the signal handler

Benefits:
- SIGHUP handler returns immediately without blocking
- Webhook requests not delayed by listener restart I/O
- Lock contention significantly reduced

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* fix: add graceful shutdown mechanism for SIGHUP handler background task

Prevents unbounded loop without cancellation token. The SIGHUP handler now
listens for a shutdown signal and exits cleanly during graceful termination.

Changes:
- Create broadcast channel for shutdown signaling
- SIGHUP handler uses tokio::select! to wait for shutdown or SIGHUP
- Send shutdown signal to all background tasks after agent.run() completes
- Ensures clean task lifecycle and no orphaned background tasks

Benefits:
- Proper task cancellation during graceful shutdown
- Follows Tokio best practices for background task management
- No background tasks orphaned when runtime shuts down

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* refactor: replace stringly-typed parameter filtering with typed enum and single helper

Fixes DRY violation where unsupported parameter filtering was duplicated across
rig_adapter.rs and anthropic_oauth.rs using string contains checks.

Changes:
- Add UnsupportedParam typed enum in provider.rs (Temperature, MaxTokens, StopSequences)
- Create strip_unsupported_completion_params() helper function
- Create strip_unsupported_tool_params() helper function
- Update rig_adapter.rs to use shared helpers
- Update anthropic_oauth.rs to use shared helpers
- Replace 60+ lines of duplicate stringly-typed logic

Benefits:
- Type safety: parameter names checked at compile time
- Single source of truth: adding a new param updates one place
- Reduced maintenance burden: no duplicate logic to keep in sync
- Better code clarity: named enum variant is self-documenting

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* docs: clarify intentional parameter asymmetry between completion and tool requests

Add documentation explaining why strip_unsupported_tool_params does not handle
StopSequences: the field doesn't exist in ToolCompletionRequest.

Changes:
- Add clarifying comments to strip_unsupported_tool_params()
- Explain why StopSequences is only in CompletionRequest
- Note that ToolCompletionRequest only supports Temperature and MaxTokens
- Inline comment confirms no action needed for StopSequences

This addresses the appearance of incomplete implementation without changing logic,
as the asymmetry is intentional and correct (ToolCompletionRequest lacks the field).

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* perf: isolate webhook_secret to reduce lock contention on hot path

Move webhook_secret from shared HttpChannelState RwLock into its own Arc<RwLock<>>.
This eliminates contention between secret validation and other state operations.

Changes:
- Change webhook_secret field type from RwLock<Option<SecretString>> to Arc<RwLock<Option<SecretString>>>
- Update initialization in HttpChannel::new()
- Update comments to explain isolation rationale

Benefits:
- Reduce lock contention on webhook request hot path (secret validation)
- Rarely-changing field (SIGHUP only) isolated from frequent state accesses
- Other state operations (tx, pending_responses) no longer wait behind secret reads
- Minimal code change: only field declaration and initialization

The Arc wrapper allows cloning the RwLock handle to separate concerns. With this
change, every webhook request acquires its own isolated lock for secret validation,
not the shared HttpChannelState lock. This scales better under high request volume.

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* fix: prevent partial state corruption on SIGHUP restart failure

Ensure atomicity of configuration reload: if webhook listener restart fails,
secret update is skipped to prevent inconsistent state.

Changes:
- Wait for restart_with_addr() to complete (don't spawn background task)
- Track restart result with restart_failed flag
- Only update secret if restart succeeded or wasn't needed
- Ensure listener and secret stay synchronized

Problem addressed:
- Before: restart spawned as background task, secret updated immediately
- If restart failed, secret was changed but listener still on old address
- This left system in inconsistent state (partial corruption)

Solution:
- Make restart blocking (SIGHUP handler can wait, it's not on request hot path)
- Atomically update secret only after successful restart
- Flag prevents race between restart and secret update

Benefits:
- Configuration changes are atomic (both succeed or both fail together)
- No partial state corruption on restart failure
- Failed restarts don't silently leave inconsistent state
- Secret and listener address stay in sync

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* refactor: generalize hot-secret-swapping with ChannelSecretUpdater trait

Decouple SIGHUP handler from HTTP channel internals by introducing a trait
for channels that support zero-downtime secret updates.

Changes:
- Add ChannelSecretUpdater trait in channels/channel.rs
- Implement ChannelSecretUpdater for HttpChannelState
- Export trait from channels module
- Update SIGHUP handler to use trait-based secret updater collection
- Replace explicit HTTP channel knowledge with generic updater loop

Benefits:
- SIGHUP handler no longer depends on HttpChannelState details
- Tight coupling removed: main.rs doesn't need HTTP channel imports
- Extensible: new channels can opt-in by implementing the trait
- Scalable: multiple channels supported without main.rs changes
- Maintainable: adding channels requires only trait implementation, not SIGHUP handler edits

Pattern:
- ChannelSecretUpdater trait defines the interface for all updaters
- Channels that support hot-secret-swapping implement the trait
- SIGHUP handler loops through all registered updaters generically

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* feat: validate parameter names at deserialization time, not just tests

Add custom serde deserializer for unsupported_params that validates parameter
names at runtime when loading providers.json (or user overrides).

Changes:
- Add unsupported_params_de module with custom deserializer
- Only allows: "temperature", "max_tokens", "stop_sequences"
- Invalid parameter names cause immediate deserialization error
- Update ProviderDefinition to use custom deserializer
- Enhanced test with explicit parameter name validation
- Add new test that verifies invalid parameters are rejected

Problem solved:
- Before: Invalid param names (e.g., "temperrature") silently ignored
- Now: Rejected at deserialization time with clear error message
- Prevents runtime failures caused by typos in configuration

Example error:
  unsupported parameter name 'temperrature': must be one of: temperature, max_tokens, stop_sequences

Benefits:
- Fail-fast: errors caught when loading config, not at runtime
- Clear feedback: error message lists valid parameter names
- Type safety: validators run during deserialization
- Configuration errors detected immediately, not silently ignored

Verification:
- All 2,788 tests pass (including new validation test)
- Zero clippy warnings
- Code compiles successfully

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

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>

* merge: resolve conflicts for PR #800 and #822 into staging (#881)

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* refactor: unify three agentic loops into single AgenticLoop engine (#654)

Replace three independent copy-pasted agentic loops (dispatcher, worker,
container runtime) with a single shared engine in `agentic_loop.rs` that
all consumers customize via the `LoopDelegate` trait.

Phase 1 — Shared engine (`src/agent/agentic_loop.rs`, 205 lines):
  - `run_agentic_loop()` owns the core LLM → tool exec → repeat cycle
  - `LoopDelegate` trait (Send + Sync, &dyn dispatch) with 6 hook points
  - Tool intent nudge logic consolidated (was duplicated in 3 files)
  - Iteration limit + force-text behavior preserved

Phase 2 — Three delegate implementations:
  - `ChatDelegate` (dispatcher.rs): 3-phase approval flow, hooks, cost
    guard, context compaction, skill attenuation, interruption
  - `JobDelegate` (worker/job.rs): planning pre-loop phase, parallel
    JoinSet exec, mark_completed/stuck/failed, SSE streaming, self-repair
  - `ContainerDelegate` (worker/container.rs): sequential tool exec,
    HTTP-proxied LLM, container-safe tools, credential injection

Phase 3 — File moves and cleanup:
  - Delete `src/agent/worker.rs` — job logic moved to `src/worker/job.rs`
  - Rename `src/worker/runtime.rs` → `src/worker/container.rs`
  - Re-export `Worker`/`WorkerDeps` from `crate::worker` in `agent/mod.rs`
  - Update `scheduler.rs` imports to new worker location

Shared helpers (`src/tools/execute.rs`):
  - `execute_tool_with_safety()` replaces 4 copies of validate → timeout
    → execute → serialize
  - `process_tool_result()` replaces 3 copies of sanitize → wrap →
    ChatMessage (also used by thread_ops.rs approval resume paths)

Net result: -2,408 lines, zero duplicated loop logic, single code path
for tool intent nudge and completion detection.

Closes #654

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

* fix: address review feedback from Copilot

1. scheduler.rs: Replace `unwrap_or` fallback with proper error
   propagation when parsing tool output JSON — surfaces bugs instead
   of silently changing the output type.

2. worker/job.rs: Drop MutexGuard before the cancellation `.await` in
   `check_signals()` to avoid holding a lock across an async I/O call
   (prevents `await_holding_lock` lint).

3. worker/job.rs: Restore consecutive rate-limit counter
   (MAX_CONSECUTIVE_RATE_LIMITS = 10) so sustained rate limiting marks
   the job stuck with "Persistent rate limiting" instead of silently
   burning through max_iterations.

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

* fix: incorporate staging changes — token budget tracking + mark_failed

Merge staging's changes into the refactored JobDelegate:
- Add token budget tracking in call_llm (update_context/add_tokens)
- mark_stuck → mark_failed for iteration cap and rate-limit exhaustion
  (aligns with staging's #788 fix)

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

* fix: address zmanian's PR review — eliminate type erasure, clean up

Address all 6 review points from zmanian on PR #800:

1. Replace LoopOutcome::Custom(Box<dyn Any>) with typed
   LoopOutcome::NeedApproval(Box<PendingApproval>) — eliminates
   type erasure and downcast, resolves clippy large_enum_variant.

2. Remove dead max_tool_iterations field from ChatDelegate struct.

3. Add on_tool_intent_nudge() hook to LoopDelegate trait with
   implementations in Job and Container delegates for observability.

4. Fix SSE events in job worker to emit raw sanitized content
   instead of XML-wrapped <tool_output> tags.

5. Remove 4 duplicate completion tests from job.rs that were
   already covered by the shared util module.

6. Avoid logging full tool results — use result_size_bytes in
   debug logs (execute.rs, job.rs).

Also updates path references in CLAUDE.md, COVERAGE_PLAN.md,
and add-sse-event.md command.

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

* feat(doctor): expand diagnostics from 7 to 16 health checks

* test: add unit tests for agentic_loop and execute shared modules

Add 16 tests covering the two new critical shared modules:

agentic_loop.rs (10 tests):
- Text response exits loop immediately
- Tool call → text response continuation
- LoopSignal::Stop exits before LLM call
- LoopSignal::InjectMessage adds user message to context
- Max iterations terminates with LoopOutcome::MaxIterations
- Tool intent nudge fires twice then caps
- before_llm_call early exit bypasses LLM
- truncate_for_preview: short string, long string, multibyte safety

execute.rs (6 tests):
- execute_tool_with_safety success path
- Missing tool returns ToolError::NotFound
- Tool execution failure propagates
- Per-tool timeout enforcement (50ms)
- process_tool_result XML wrapping on success
- process_tool_result error formatting

All 2,777 unit tests pass, 0 clippy warnings.

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

* style: cargo fmt

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

* fix: address code review — 9 issues across agentic loop, job worker, container

CRITICAL fixes:
- Rate-limit exhaustion now returns Err(LlmError::RateLimited) instead of
  Ok(Text("")), stopping the loop immediately with no ghost iteration.
  Below-threshold retries still use Text("") with an explicit empty-string
  guard in handle_text_response to skip injection.
- check_signals drains the entire message channel before returning,
  prioritizing Stop over UserMessage. Previously returned early on first
  UserMessage, silently dropping any queued Stop or additional messages.
- check_signals now detects all non-progressing job states (Cancelled,
  Failed, Stuck, Completed, Submitted, Accepted) instead of only
  Cancelled and Failed.

HIGH fixes:
- Error path in process_tool_result_job applies truncate_for_preview to
  bound error strings in SSE/DB events (was unbounded).
- Document Send+Sync lifetime constraint on LoopDelegate trait.
- Test mock before_llm_call refactored from double-lock to single lock
  acquisition, eliminating deadlock risk on refactor.

MEDIUM fixes:
- CompletionReport includes actual iteration count via shared
  Arc<Mutex<u32>> tracker (was hardcoded 0).
- process_tool_result_job return type changed from Result<bool> to
  Result<()> — the bool was always false (dead API).
- Deduplicate truncate in container.rs; now uses truncate_for_preview
  from agentic_loop.

Verified: 0 clippy warnings, 2781 tests pass, cargo fmt clean.

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

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Umesh Kumar Singh <[email protected]>
Co-authored-by: reidliu41 <[email protected]>

* Revert "Feat/docker shell edition" + fix fmt/clippy (#886)

* Revert "Feat/docker shell edition (#804)"

This reverts commit c566faf28f.

* style: fix formatting issues from revert

Run cargo fmt to fix formatting across 7 files after the revert of
the docker shell edition feature.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* refactor: centralize test credential constants into testing::credentials (#829)

* refactor: centralize test credential constants into testing::credentials

Scattered test credential strings (API keys, OAuth tokens, crypto keys,
Telegram tokens, session tokens) across ~25 files made security auditing
harder and created unnecessary duplication. Centralize all test-only fake
credentials into a new `src/testing/credentials.rs` module with named
constants and a shared `test_secrets_store()` helper.

- Convert `src/testing.rs` to directory module (`src/testing/mod.rs`)
- Add `src/testing/credentials.rs` with ~30 named constants
- Replace hardcoded literals in 24 source files
- Deduplicate `test_store()` helper (was copy-pasted in 3 files)
- Leave leak_detector/shell/signature tests as-is (inline values
  aid readability for pattern detection tests)

[skip-regression-check]

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

* refactor: replace real Telegram bot token with obviously fake test stub

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

* Update src/testing/credentials.rs

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

* Update src/testing/credentials.rs

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

* refactor: address PR review feedback on test credentials

- Fix TEST_CRYPTO_KEY doc comment ("32-byte hex" → "32-character key string")
- Rename confusing "real"/"fake" Anthropic constant names and values
- Change TEST_STRIPE_KEY from "sk-live" to "sk_test_fake123" to avoid scanners
- Use test_secrets_store() helper in orchestrator and http tool tests
- Clarify config_round_trip.rs doc comment about integration test visibility

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

---------

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

* fix(registry): version-pinned WASM artifact URLs + ChecksumMismatch source fallback (#832)

* fix(registry): version-pinned WASM artifact URLs + ChecksumMismatch fallback (#439)

Root cause: all artifact URLs used releases/latest/download/, which is a
moving target. Every release rebuilds all WASM extensions non-deterministically,
so sha256 baked into an older binary diverges from the content at 'latest'.
ChecksumMismatch was also a hard block with no source-build fallback.

Three-layer fix:

1. src/registry/installer.rs — allow source-build fallback for ChecksumMismatch
   on releases/latest URLs (moving-target artifact rotation, not tampering).
   Version-pinned URLs (releases/download/vX.Y.Z/) remain a hard block.
   Adds regression test (test_source_fallback_on_latest_url_mismatch) and
   updates test_should_attempt_source_fallback_policy to cover both URL types.

2. .github/workflows/release.yml — three CI changes:
   - build-wasm-extensions: version-detect, skip-if-unchanged, versioned filenames
     (name-{version}-wasm32-wasip2.tar.gz). Skip rebuild when manifest already has
     a non-null sha256 and the URL embeds the current version — stable checksums
     until source actually changes.
   - build-local-artifacts: patch manifests with version-pinned URL + sha256 (for
     binary embedding via build.rs).
   - update-registry-checksums: same URL patching for the main-branch PR.
   All three sed patterns use '.*' (greedy) to correctly handle pre-release
   version strings like 0.1.0-alpha.1.

3. registry/{tools,channels}/*.json — null out all 14 stale sha256 values.
   Null sha256 -> MissingChecksum -> source-build fallback (works on all binaries).
   Next release CI will populate version-pinned URLs + stable checksums.

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

* style: cargo fmt

* fix(ci): use JSON filename stem for WASM bundle names to fix manifest lookup

Manifests like registry/tools/slack.json have name='slack-tool', causing
the patching step to look for registry/tools/slack-tool.json (missing).

Introduce file_stem (JSON filename without .json) for the bundle filename
and checksums.txt entry, while keeping ext_name (manifest .name) for archive
contents — the installer extracts files by manifest.name so those must still
match. The patching step strips -{version}-wasm32-wasip2.tar.gz from the
filename stem and looks up registry/tools/slack.json correctly.

* fix(registry): tighten fallback URL check + deduplicate tests

Address PR review feedback:

1. Make should_attempt_source_fallback check repo-specific
   (github.com/nearai/ironclaw/releases/latest/) instead of a
   generic substring (/releases/latest/download/).

2. Remove duplicate ChecksumMismatch cases from
   test_should_attempt_source_fallback_policy — that coverage
   lives in the dedicated regression test
   test_source_fallback_on_latest_url_mismatch.

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

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix: agent logging (#888)

* fix: optimize agent logging to reduce DataDog bill

* fix: log permanent repair failures as ERROR not WARN

RepairResult::Failed is permanent failure requiring attention (ERROR level)
not a temporary/retryable condition (WARN level).

[skip-regression-check]

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

* security: remove user message content from trace logs

Never log user message content at any log level (includes TRACE).
Log only safe metadata: content length, message ID, image count.

This prevents accidental exposure of sensitive user data in logs
even at the most verbose logging level.

[skip-regression-check]

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

* security: move LLM response body logging to TRACE level

Response bodies can contain user-generated content, tool outputs, and
leaked secrets. Moving to TRACE (not enabled in production) prevents
exposure in DEBUG logs. Status log remains at DEBUG.

[skip-regression-check]

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

* refactor: simplify URL sanitization using url::Url API

Use set_query, set_fragment, set_username, set_password methods
instead of manual string reconstruction. Cleaner, handles edge cases,
eliminates port branching complexity.

[skip-regression-check]

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

* test: add comprehensive unit tests for sanitize_url_for_logging

Add 9 test cases covering:
- URL with query parameters
- URL with credentials (user:pass@host)
- URL with fragment
- URL with port
- URL with all components combined
- Malformed URL fallback behavior
- Short strings (pass-through)
- Non-URL-like strings
- Path preservation

Tests verify that sanitization correctly removes sensitive components
while preserving safe components like host, port, and path.

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

* fix: libsql per-migration logs should be DEBUG, not TRACE

Individual migration logs are now visible with standard debug logging
(RUST_LOG=ironclaw=debug), improving debuggability when troubleshooting
migration issues. Summary log remains at INFO level.

Fixes behavioral change that made it harder to identify which specific
migration ran or failed without enabling full TRACE logging.

[skip-regression-check]

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>

* fix: staging CI review issues (batch 1) (#883)

* fix: address staging-ci-review issues (batch 1)

- #811: Fix unreachable error handling in worker — restructure .await?
  to explicit match on nested Result so token budget errors are properly
  logged and marked as failed
- #813: Combine metadata + token budget into single update_context()
  call to prevent concurrent worker observing partial state
- #814: Persist max_tokens and total_tokens_used to both PostgreSQL and
  libSQL backends — add V12 migration, update save_job/get_job
- #815: Cap user-supplied max_tokens at configured max_tokens_per_job
  to prevent budget bypass via metadata injection
- #869: Release locks before async I/O in webhook handler (http.rs) and
  SIGHUP handler (main.rs) to prevent blocking concurrent requests

Fixes: #811, #813, #814, #815, #869

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

* fix: address PR #883 review feedback

- Fix min(user_val, 0) bug: guard for unlimited config (max_tokens_per_job == 0)
- Remove duplicate columns from libSQL base SCHEMA (v12 migration is sole source)
- Use get_i64() helper for consistency in libsql/jobs.rs
- Add regression tests for scheduler token budget capping

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix: gate ChannelSecretUpdater import behind #[cfg(unix)] for Windows clippy

The import was unconditional but all usages are inside a #[cfg(unix)]
block, causing unused-import errors on Windows CI.

[skip-regression-check]

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

* style: cargo fmt

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

---------

Co-authored-by: Nick Pismenkov <[email protected]>
Co-authored-by: Claude Haiku 4.5 <[email protected]>
Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Xing Ji <[email protected]>
Co-authored-by: Nick Stebbings <[email protected]>
Co-authored-by: Reid <[email protected]>
Co-authored-by: Umesh Kumar Singh <[email protected]>
Co-authored-by: 智方云cubecloud-io <[email protected]>
Co-authored-by: lizican <[email protected]>
Co-authored-by: lizican123 <[email protected]>
Co-authored-by: Zaki Manian <[email protected]>
Co-authored-by: reidliu41 <[email protected]>
Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <[email protected]>
2026-03-10 22:19:14 -07:00
b0214fef41 feat: add channel-relay integration for Slack (#790)
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* feat: add channel-relay integration for Slack via external relay service

- Add RelayChannel and RelayClient for connecting to channel-relay SSE streams
- Add RelayConfig with env-based configuration (CHANNEL_RELAY_URL, CHANNEL_RELAY_API_KEY)
- Add channel-relay extension lifecycle: install, OAuth auth, activate with hot-add
- Add proxy message sending through channel-relay for Slack chat.postMessage
- Add extension registry entry for Slack relay with OAuth auth hint
- Add relay integration test with mock SSE server
- Wire relay channel into app startup with reconnect on stored credentials
- Add AuthRequired extension error variant for cleaner auth flow detection

[skip-regression-check]

* chore: apply cargo fmt

* fix: remove remaining Telegram test references in relay channel

* fix: address PR #790 review feedback — parser handle leak, CSRF, circuit breaker

- Fix parser handle leak on reconnect by sharing Arc<RwLock> instead of
  creating a local copy in start() (shutdown now aborts the correct task)
- Add CSRF state nonce to OAuth flow: generate in auth_channel_relay,
  validate in slack_relay_oauth_callback_handler, one-time use
- Remove dead proxy_slack method, update integration test to use
  proxy_provider
- Add reconnect circuit breaker (max_consecutive_failures, default 50)
- Fix stale docs (Telegram refs), extract event_types constants

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-10 16:34:54 -07:00
Henry ParkandGitHub 3a841b30d8 Merge pull request #898 from nearai/merge/main-into-staging
merge: resolve main -> staging conflicts
2026-03-10 15:34:08 -07:00
Henry ParkandGitHub 8c094aec63 Merge pull request #830 from nearai/staging-promote/3a2989d0-22888378864
chore: promote staging to main (2026-03-10 05:21 UTC)
2026-03-10 14:14:14 -07:00
Henry ParkandClaude Sonnet 4.6 54a70639e6 merge: resolve main -> staging conflicts (sha256: null)
Keep staging versions for all registry JSON files (sha256: null) and
LLM module helpers. CHANGELOG.md and Cargo updates from main applied.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 14:09:25 -07:00
873322f2fb fix: staging CI review issues (batch 1) (#883)
* fix: address staging-ci-review issues (batch 1)

- #811: Fix unreachable error handling in worker — restructure .await?
  to explicit match on nested Result so token budget errors are properly
  logged and marked as failed
- #813: Combine metadata + token budget into single update_context()
  call to prevent concurrent worker observing partial state
- #814: Persist max_tokens and total_tokens_used to both PostgreSQL and
  libSQL backends — add V12 migration, update save_job/get_job
- #815: Cap user-supplied max_tokens at configured max_tokens_per_job
  to prevent budget bypass via metadata injection
- #869: Release locks before async I/O in webhook handler (http.rs) and
  SIGHUP handler (main.rs) to prevent blocking concurrent requests

Fixes: #811, #813, #814, #815, #869

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

* fix: address PR #883 review feedback

- Fix min(user_val, 0) bug: guard for unlimited config (max_tokens_per_job == 0)
- Remove duplicate columns from libSQL base SCHEMA (v12 migration is sole source)
- Use get_i64() helper for consistency in libsql/jobs.rs
- Add regression tests for scheduler token budget capping

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 14:01:07 -07:00
1f5b582c5f fix: agent logging (#888)
* fix: optimize agent logging to reduce DataDog bill

* fix: log permanent repair failures as ERROR not WARN

RepairResult::Failed is permanent failure requiring attention (ERROR level)
not a temporary/retryable condition (WARN level).

[skip-regression-check]

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

* security: remove user message content from trace logs

Never log user message content at any log level (includes TRACE).
Log only safe metadata: content length, message ID, image count.

This prevents accidental exposure of sensitive user data in logs
even at the most verbose logging level.

[skip-regression-check]

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

* security: move LLM response body logging to TRACE level

Response bodies can contain user-generated content, tool outputs, and
leaked secrets. Moving to TRACE (not enabled in production) prevents
exposure in DEBUG logs. Status log remains at DEBUG.

[skip-regression-check]

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

* refactor: simplify URL sanitization using url::Url API

Use set_query, set_fragment, set_username, set_password methods
instead of manual string reconstruction. Cleaner, handles edge cases,
eliminates port branching complexity.

[skip-regression-check]

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

* test: add comprehensive unit tests for sanitize_url_for_logging

Add 9 test cases covering:
- URL with query parameters
- URL with credentials (user:pass@host)
- URL with fragment
- URL with port
- URL with all components combined
- Malformed URL fallback behavior
- Short strings (pass-through)
- Non-URL-like strings
- Path preservation

Tests verify that sanitization correctly removes sensitive components
while preserving safe components like host, port, and path.

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

* fix: libsql per-migration logs should be DEBUG, not TRACE

Individual migration logs are now visible with standard debug logging
(RUST_LOG=ironclaw=debug), improving debuggability when troubleshooting
migration issues. Summary log remains at INFO level.

Fixes behavioral change that made it harder to identify which specific
migration ran or failed without enabling full TRACE logging.

[skip-regression-check]

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>
2026-03-10 13:55:06 -07:00
5635384e51 fix(registry): version-pinned WASM artifact URLs + ChecksumMismatch source fallback (#832)
* fix(registry): version-pinned WASM artifact URLs + ChecksumMismatch fallback (#439)

Root cause: all artifact URLs used releases/latest/download/, which is a
moving target. Every release rebuilds all WASM extensions non-deterministically,
so sha256 baked into an older binary diverges from the content at 'latest'.
ChecksumMismatch was also a hard block with no source-build fallback.

Three-layer fix:

1. src/registry/installer.rs — allow source-build fallback for ChecksumMismatch
   on releases/latest URLs (moving-target artifact rotation, not tampering).
   Version-pinned URLs (releases/download/vX.Y.Z/) remain a hard block.
   Adds regression test (test_source_fallback_on_latest_url_mismatch) and
   updates test_should_attempt_source_fallback_policy to cover both URL types.

2. .github/workflows/release.yml — three CI changes:
   - build-wasm-extensions: version-detect, skip-if-unchanged, versioned filenames
     (name-{version}-wasm32-wasip2.tar.gz). Skip rebuild when manifest already has
     a non-null sha256 and the URL embeds the current version — stable checksums
     until source actually changes.
   - build-local-artifacts: patch manifests with version-pinned URL + sha256 (for
     binary embedding via build.rs).
   - update-registry-checksums: same URL patching for the main-branch PR.
   All three sed patterns use '.*' (greedy) to correctly handle pre-release
   version strings like 0.1.0-alpha.1.

3. registry/{tools,channels}/*.json — null out all 14 stale sha256 values.
   Null sha256 -> MissingChecksum -> source-build fallback (works on all binaries).
   Next release CI will populate version-pinned URLs + stable checksums.

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

* style: cargo fmt

* fix(ci): use JSON filename stem for WASM bundle names to fix manifest lookup

Manifests like registry/tools/slack.json have name='slack-tool', causing
the patching step to look for registry/tools/slack-tool.json (missing).

Introduce file_stem (JSON filename without .json) for the bundle filename
and checksums.txt entry, while keeping ext_name (manifest .name) for archive
contents — the installer extracts files by manifest.name so those must still
match. The patching step strips -{version}-wasm32-wasip2.tar.gz from the
filename stem and looks up registry/tools/slack.json correctly.

* fix(registry): tighten fallback URL check + deduplicate tests

Address PR review feedback:

1. Make should_attempt_source_fallback check repo-specific
   (github.com/nearai/ironclaw/releases/latest/) instead of a
   generic substring (/releases/latest/download/).

2. Remove duplicate ChecksumMismatch cases from
   test_should_attempt_source_fallback_policy — that coverage
   lives in the dedicated regression test
   test_source_fallback_on_latest_url_mismatch.

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

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-10 13:51:17 -07:00
76375f2eaa refactor: centralize test credential constants into testing::credentials (#829)
* refactor: centralize test credential constants into testing::credentials

Scattered test credential strings (API keys, OAuth tokens, crypto keys,
Telegram tokens, session tokens) across ~25 files made security auditing
harder and created unnecessary duplication. Centralize all test-only fake
credentials into a new `src/testing/credentials.rs` module with named
constants and a shared `test_secrets_store()` helper.

- Convert `src/testing.rs` to directory module (`src/testing/mod.rs`)
- Add `src/testing/credentials.rs` with ~30 named constants
- Replace hardcoded literals in 24 source files
- Deduplicate `test_store()` helper (was copy-pasted in 3 files)
- Leave leak_detector/shell/signature tests as-is (inline values
  aid readability for pattern detection tests)

[skip-regression-check]

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

* refactor: replace real Telegram bot token with obviously fake test stub

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

* Update src/testing/credentials.rs

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

* Update src/testing/credentials.rs

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

* refactor: address PR review feedback on test credentials

- Fix TEST_CRYPTO_KEY doc comment ("32-byte hex" → "32-character key string")
- Rename confusing "real"/"fake" Anthropic constant names and values
- Change TEST_STRIPE_KEY from "sk-live" to "sk_test_fake123" to avoid scanners
- Use test_secrets_store() helper in orchestrator and http tool tests
- Clarify config_round_trip.rs doc comment about integration test visibility

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Copilot <[email protected]>
2026-03-10 13:25:32 -07:00
Henry ParkandGitHub 1e7950eb1a Merge pull request #820 from nearai/staging-promote/a868b142-22886164216
chore: promote staging to main (2026-03-10 03:47 UTC)
2026-03-10 13:22:22 -07:00
24d4fbb8a7 Revert "Feat/docker shell edition" + fix fmt/clippy (#886)
* Revert "Feat/docker shell edition (#804)"

This reverts commit c566faf28f.

* style: fix formatting issues from revert

Run cargo fmt to fix formatting across 7 files after the revert of
the docker shell edition feature.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 11:57:50 -07:00
Henry ParkandGitHub b442a1f5ca Merge pull request #807 from nearai/staging-promote/83950d11-22884429853
chore: promote staging to main (2026-03-10 02:35 UTC)
2026-03-10 11:40:37 -07:00
Henry ParkandGitHub 9c35c2a4ba Merge branch 'main' into staging-promote/83950d11-22884429853 2026-03-10 11:21:32 -07:00
88f4894a18 merge: resolve conflicts for PR #800 and #822 into staging (#881)
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* refactor: unify three agentic loops into single AgenticLoop engine (#654)

Replace three independent copy-pasted agentic loops (dispatcher, worker,
container runtime) with a single shared engine in `agentic_loop.rs` that
all consumers customize via the `LoopDelegate` trait.

Phase 1 — Shared engine (`src/agent/agentic_loop.rs`, 205 lines):
  - `run_agentic_loop()` owns the core LLM → tool exec → repeat cycle
  - `LoopDelegate` trait (Send + Sync, &dyn dispatch) with 6 hook points
  - Tool intent nudge logic consolidated (was duplicated in 3 files)
  - Iteration limit + force-text behavior preserved

Phase 2 — Three delegate implementations:
  - `ChatDelegate` (dispatcher.rs): 3-phase approval flow, hooks, cost
    guard, context compaction, skill attenuation, interruption
  - `JobDelegate` (worker/job.rs): planning pre-loop phase, parallel
    JoinSet exec, mark_completed/stuck/failed, SSE streaming, self-repair
  - `ContainerDelegate` (worker/container.rs): sequential tool exec,
    HTTP-proxied LLM, container-safe tools, credential injection

Phase 3 — File moves and cleanup:
  - Delete `src/agent/worker.rs` — job logic moved to `src/worker/job.rs`
  - Rename `src/worker/runtime.rs` → `src/worker/container.rs`
  - Re-export `Worker`/`WorkerDeps` from `crate::worker` in `agent/mod.rs`
  - Update `scheduler.rs` imports to new worker location

Shared helpers (`src/tools/execute.rs`):
  - `execute_tool_with_safety()` replaces 4 copies of validate → timeout
    → execute → serialize
  - `process_tool_result()` replaces 3 copies of sanitize → wrap →
    ChatMessage (also used by thread_ops.rs approval resume paths)

Net result: -2,408 lines, zero duplicated loop logic, single code path
for tool intent nudge and completion detection.

Closes #654

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

* fix: address review feedback from Copilot

1. scheduler.rs: Replace `unwrap_or` fallback with proper error
   propagation when parsing tool output JSON — surfaces bugs instead
   of silently changing the output type.

2. worker/job.rs: Drop MutexGuard before the cancellation `.await` in
   `check_signals()` to avoid holding a lock across an async I/O call
   (prevents `await_holding_lock` lint).

3. worker/job.rs: Restore consecutive rate-limit counter
   (MAX_CONSECUTIVE_RATE_LIMITS = 10) so sustained rate limiting marks
   the job stuck with "Persistent rate limiting" instead of silently
   burning through max_iterations.

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

* fix: incorporate staging changes — token budget tracking + mark_failed

Merge staging's changes into the refactored JobDelegate:
- Add token budget tracking in call_llm (update_context/add_tokens)
- mark_stuck → mark_failed for iteration cap and rate-limit exhaustion
  (aligns with staging's #788 fix)

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

* fix: address zmanian's PR review — eliminate type erasure, clean up

Address all 6 review points from zmanian on PR #800:

1. Replace LoopOutcome::Custom(Box<dyn Any>) with typed
   LoopOutcome::NeedApproval(Box<PendingApproval>) — eliminates
   type erasure and downcast, resolves clippy large_enum_variant.

2. Remove dead max_tool_iterations field from ChatDelegate struct.

3. Add on_tool_intent_nudge() hook to LoopDelegate trait with
   implementations in Job and Container delegates for observability.

4. Fix SSE events in job worker to emit raw sanitized content
   instead of XML-wrapped <tool_output> tags.

5. Remove 4 duplicate completion tests from job.rs that were
   already covered by the shared util module.

6. Avoid logging full tool results — use result_size_bytes in
   debug logs (execute.rs, job.rs).

Also updates path references in CLAUDE.md, COVERAGE_PLAN.md,
and add-sse-event.md command.

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

* feat(doctor): expand diagnostics from 7 to 16 health checks

* test: add unit tests for agentic_loop and execute shared modules

Add 16 tests covering the two new critical shared modules:

agentic_loop.rs (10 tests):
- Text response exits loop immediately
- Tool call → text response continuation
- LoopSignal::Stop exits before LLM call
- LoopSignal::InjectMessage adds user message to context
- Max iterations terminates with LoopOutcome::MaxIterations
- Tool intent nudge fires twice then caps
- before_llm_call early exit bypasses LLM
- truncate_for_preview: short string, long string, multibyte safety

execute.rs (6 tests):
- execute_tool_with_safety success path
- Missing tool returns ToolError::NotFound
- Tool execution failure propagates
- Per-tool timeout enforcement (50ms)
- process_tool_result XML wrapping on success
- process_tool_result error formatting

All 2,777 unit tests pass, 0 clippy warnings.

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

* style: cargo fmt

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

* fix: address code review — 9 issues across agentic loop, job worker, container

CRITICAL fixes:
- Rate-limit exhaustion now returns Err(LlmError::RateLimited) instead of
  Ok(Text("")), stopping the loop immediately with no ghost iteration.
  Below-threshold retries still use Text("") with an explicit empty-string
  guard in handle_text_response to skip injection.
- check_signals drains the entire message channel before returning,
  prioritizing Stop over UserMessage. Previously returned early on first
  UserMessage, silently dropping any queued Stop or additional messages.
- check_signals now detects all non-progressing job states (Cancelled,
  Failed, Stuck, Completed, Submitted, Accepted) instead of only
  Cancelled and Failed.

HIGH fixes:
- Error path in process_tool_result_job applies truncate_for_preview to
  bound error strings in SSE/DB events (was unbounded).
- Document Send+Sync lifetime constraint on LoopDelegate trait.
- Test mock before_llm_call refactored from double-lock to single lock
  acquisition, eliminating deadlock risk on refactor.

MEDIUM fixes:
- CompletionReport includes actual iteration count via shared
  Arc<Mutex<u32>> tracker (was hardcoded 0).
- process_tool_result_job return type changed from Result<bool> to
  Result<()> — the bool was always false (dead API).
- Deduplicate truncate in container.rs; now uses truncate_for_preview
  from agentic_loop.

Verified: 0 clippy warnings, 2781 tests pass, cargo fmt clean.

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

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Umesh Kumar Singh <[email protected]>
Co-authored-by: reidliu41 <[email protected]>
2026-03-10 11:19:23 -07:00
ebb22094a5 fix: promote to main (#878)
* fix: replace unsafe env::set_var with thread-safe inject_single_var in SIGHUP handler

Fixes race condition where SIGHUP handler modifies global environment variables
while other threads may be reading them via Config::from_env().

Changes:
- Replace unsafe { std::env::set_var() } with ironclaw::config::inject_single_var()
- Uses INJECTED_VARS mutex instead of unsafe global state modification
- All reads via optional_env() check the thread-safe overlay first
- Prevents data races between SIGHUP reload and concurrent config reads

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* fix: spawn webhook restart as background task to avoid blocking I/O across lock

Prevents holding Mutex lock during async I/O operations (TcpListener::bind,
task shutdown). The SIGHUP handler no longer blocks webhook processing during
listener restart.

Changes:
- Read old_addr and drop lock immediately
- Spawn restart_with_addr() as background task via tokio::spawn
- Lock is only held during the actual restart operation, not the signal handler

Benefits:
- SIGHUP handler returns immediately without blocking
- Webhook requests not delayed by listener restart I/O
- Lock contention significantly reduced

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* fix: add graceful shutdown mechanism for SIGHUP handler background task

Prevents unbounded loop without cancellation token. The SIGHUP handler now
listens for a shutdown signal and exits cleanly during graceful termination.

Changes:
- Create broadcast channel for shutdown signaling
- SIGHUP handler uses tokio::select! to wait for shutdown or SIGHUP
- Send shutdown signal to all background tasks after agent.run() completes
- Ensures clean task lifecycle and no orphaned background tasks

Benefits:
- Proper task cancellation during graceful shutdown
- Follows Tokio best practices for background task management
- No background tasks orphaned when runtime shuts down

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* refactor: replace stringly-typed parameter filtering with typed enum and single helper

Fixes DRY violation where unsupported parameter filtering was duplicated across
rig_adapter.rs and anthropic_oauth.rs using string contains checks.

Changes:
- Add UnsupportedParam typed enum in provider.rs (Temperature, MaxTokens, StopSequences)
- Create strip_unsupported_completion_params() helper function
- Create strip_unsupported_tool_params() helper function
- Update rig_adapter.rs to use shared helpers
- Update anthropic_oauth.rs to use shared helpers
- Replace 60+ lines of duplicate stringly-typed logic

Benefits:
- Type safety: parameter names checked at compile time
- Single source of truth: adding a new param updates one place
- Reduced maintenance burden: no duplicate logic to keep in sync
- Better code clarity: named enum variant is self-documenting

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* docs: clarify intentional parameter asymmetry between completion and tool requests

Add documentation explaining why strip_unsupported_tool_params does not handle
StopSequences: the field doesn't exist in ToolCompletionRequest.

Changes:
- Add clarifying comments to strip_unsupported_tool_params()
- Explain why StopSequences is only in CompletionRequest
- Note that ToolCompletionRequest only supports Temperature and MaxTokens
- Inline comment confirms no action needed for StopSequences

This addresses the appearance of incomplete implementation without changing logic,
as the asymmetry is intentional and correct (ToolCompletionRequest lacks the field).

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* perf: isolate webhook_secret to reduce lock contention on hot path

Move webhook_secret from shared HttpChannelState RwLock into its own Arc<RwLock<>>.
This eliminates contention between secret validation and other state operations.

Changes:
- Change webhook_secret field type from RwLock<Option<SecretString>> to Arc<RwLock<Option<SecretString>>>
- Update initialization in HttpChannel::new()
- Update comments to explain isolation rationale

Benefits:
- Reduce lock contention on webhook request hot path (secret validation)
- Rarely-changing field (SIGHUP only) isolated from frequent state accesses
- Other state operations (tx, pending_responses) no longer wait behind secret reads
- Minimal code change: only field declaration and initialization

The Arc wrapper allows cloning the RwLock handle to separate concerns. With this
change, every webhook request acquires its own isolated lock for secret validation,
not the shared HttpChannelState lock. This scales better under high request volume.

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* fix: prevent partial state corruption on SIGHUP restart failure

Ensure atomicity of configuration reload: if webhook listener restart fails,
secret update is skipped to prevent inconsistent state.

Changes:
- Wait for restart_with_addr() to complete (don't spawn background task)
- Track restart result with restart_failed flag
- Only update secret if restart succeeded or wasn't needed
- Ensure listener and secret stay synchronized

Problem addressed:
- Before: restart spawned as background task, secret updated immediately
- If restart failed, secret was changed but listener still on old address
- This left system in inconsistent state (partial corruption)

Solution:
- Make restart blocking (SIGHUP handler can wait, it's not on request hot path)
- Atomically update secret only after successful restart
- Flag prevents race between restart and secret update

Benefits:
- Configuration changes are atomic (both succeed or both fail together)
- No partial state corruption on restart failure
- Failed restarts don't silently leave inconsistent state
- Secret and listener address stay in sync

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* refactor: generalize hot-secret-swapping with ChannelSecretUpdater trait

Decouple SIGHUP handler from HTTP channel internals by introducing a trait
for channels that support zero-downtime secret updates.

Changes:
- Add ChannelSecretUpdater trait in channels/channel.rs
- Implement ChannelSecretUpdater for HttpChannelState
- Export trait from channels module
- Update SIGHUP handler to use trait-based secret updater collection
- Replace explicit HTTP channel knowledge with generic updater loop

Benefits:
- SIGHUP handler no longer depends on HttpChannelState details
- Tight coupling removed: main.rs doesn't need HTTP channel imports
- Extensible: new channels can opt-in by implementing the trait
- Scalable: multiple channels supported without main.rs changes
- Maintainable: adding channels requires only trait implementation, not SIGHUP handler edits

Pattern:
- ChannelSecretUpdater trait defines the interface for all updaters
- Channels that support hot-secret-swapping implement the trait
- SIGHUP handler loops through all registered updaters generically

Verification:
- All 2,787 tests pass
- Zero clippy warnings
- Code compiles successfully

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

* feat: validate parameter names at deserialization time, not just tests

Add custom serde deserializer for unsupported_params that validates parameter
names at runtime when loading providers.json (or user overrides).

Changes:
- Add unsupported_params_de module with custom deserializer
- Only allows: "temperature", "max_tokens", "stop_sequences"
- Invalid parameter names cause immediate deserialization error
- Update ProviderDefinition to use custom deserializer
- Enhanced test with explicit parameter name validation
- Add new test that verifies invalid parameters are rejected

Problem solved:
- Before: Invalid param names (e.g., "temperrature") silently ignored
- Now: Rejected at deserialization time with clear error message
- Prevents runtime failures caused by typos in configuration

Example error:
  unsupported parameter name 'temperrature': must be one of: temperature, max_tokens, stop_sequences

Benefits:
- Fail-fast: errors caught when loading config, not at runtime
- Clear feedback: error message lists valid parameter names
- Type safety: validators run during deserialization
- Configuration errors detected immediately, not silently ignored

Verification:
- All 2,788 tests pass (including new validation test)
- Zero clippy warnings
- Code compiles successfully

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

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>
2026-03-10 11:15:49 -07:00
8da202e0d2 fix: enable WASM credential injection in No-DB environments (#845)
* fix(wasm): enable credential injection in no-DB environments via env var fallback

When a secrets store is unavailable (e.g. no-DB mode), WASM channel
credentials were silently not injected, causing channels to start without
credentials. Fix by:

- Changing `inject_channel_credentials_from_secrets` to accept
  `Option<&dyn SecretsStore>` — secrets store is tried first when present
- Adding env var fallback (`inject_env_credentials`) for credentials not
  covered by the secrets store
- Enforcing a channel-name prefix security check on env var names to
  prevent WASM channels from reading unrelated host credentials
  (e.g. `AWS_SECRET_ACCESS_KEY`)
- Extracting pure `resolve_env_credentials` helper for testability
- Adding case-insensitive prefix matching for secrets store lookup

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

* fix(wasm): inject credentials at startup when no secrets store (setup.rs path)

The startup path (setup_wasm_channels -> register_channel) was guarded by
`if let Some(secrets) = secrets_store`, so in No-DB mode credentials were
never injected and the channel started without them.

Fix by:
- Changing inject_channel_credentials to accept Option<&dyn SecretsStore>
- Always calling it (removing the if-let guard) — env var fallback runs
  even when secrets_store is None
- Adding channel-name prefix security check to the env var fallback path
  (e.g. TELEGRAM_ for channel "telegram"), consistent with manager.rs

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

* fix(test): correct misleading comment on ICTEST1_UNRELATED_OTHER placeholder

* fix(wasm): guard against empty channel name in credential injection

An empty channel_name would produce prefix "_", allowing any env var
starting with "_" to pass the security check and be injected. Add an
early-return guard in resolve_env_credentials, inject_env_credentials,
and inject_channel_credentials. Add a test to cover this path.

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

---------

Co-authored-by: lizican123 <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-10 11:08:50 -07:00
6e1ed939cc Add event-triggered routines and workflow skill templates (#756)
* Add event-triggered routines and workflow skill templates

* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback for event_emit security and quality

Security fixes:
- Require approval (UnlessAutoApproved) for event_emit, matching routine_fire
- Enable sanitization on event_emit payload (external JSON reaches LLM)
- Remove user_id parameter from event_emit to prevent IDOR — always use ctx.user_id

Correctness fixes:
- Rename source → event_source in event_emit for consistency with routine_create
- Use json_value_as_filter_string for filter parsing (handles numbers/booleans)
- Case-insensitive matching for event source and event_type
- Add debug logging for missing filter keys in payload
- Fix skill_install_routine_webhook_sim test missing .with_skills()
- Fix schema_validator test for event_emit payload properties

Code quality:
- Move EventEmitTool struct/impl after RoutineHistoryTool (fix split layout)
- Deduplicate routine_to_info into RoutineInfo::from_routine in types.rs
- Add test section headers in e2e_routine_heartbeat.rs
- Clarify event_emit description to specify system_event routines only

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

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix: make routine_system_event_emit test create routine before emitting

- Add routine_create step to trace fixture so event_emit has a matching
  routine to fire
- Assert fired_routines > 0, not just key presence (Copilot review)
- Add .with_auto_approve_tools(true) since event_emit now requires approval

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

* fix: renumber test headers after system_event test insertion

Test 4 was duplicated (routine_cooldown and heartbeat_findings).
Renumber heartbeat_findings to Test 5 and heartbeat_empty_skip to Test 6.

[skip-regression-check]

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

* fix: merge staging and add missing RoutineEngine args in test

RoutineEngine::new on staging requires `tools` and `safety` params.
Update system_event_trigger_matches_and_filters test to pass them.

[skip-regression-check]

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

* fix: address new Copilot review comments

- Add .with_auto_approve_tools(true) to skill_install_routine_webhook_sim
  test so event_emit doesn't block on approval
- Fix module-level doc comment for event_emit to specify system_event trigger

[skip-regression-check]

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

* fix: deduplicate json_value_as_string helper

Remove private `json_value_as_string` from routine_engine.rs and use
the identical public `json_value_as_filter_string` from routine.rs,
eliminating divergence risk. (Copilot review)

[skip-regression-check]

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

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-10 11:08:04 -07:00
e8f8ec06e3 fix(mcp): strip top-level null params before forwarding to MCP servers (#795)
* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(mcp): strip top-level null params before forwarding to MCP servers

LLMs frequently emit `"field": null` for optional parameters in tool
calls. Many MCP servers reject explicit nulls for fields that should
simply be absent — e.g. Notion returns 400 for `"sort": null` in a
search call, expecting the field to be omitted entirely.

Strip top-level null keys from the params object before calling
`call_tool()`. Only top-level keys are stripped; nested nulls are
preserved since they may be semantically meaningful.

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

---------

Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 11:08:01 -07:00
c566faf28f Feat/docker shell edition (#804)
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-10 11:07:56 -07:00
46c01cb841 fix: preserve text before tool-call XML in forced-text responses (#852)
* fix: preserve text before tool-call XML in forced-text responses (#789)

Local models (Qwen3, DeepSeek, GLM) emit <tool_call> XML even when no
tools are available (force_text mode). The existing strip_xml_tag()
discards everything from an unclosed opening tag onward, producing an
empty string that triggers the "I'm not sure how to respond" fallback.

Add truncate_at_tool_tags() — a code-region-aware pre-processing step
that truncates at the first tool-call XML tag BEFORE clean_response()
runs, preserving all useful text before the tag. Protect all 7
clean_response() call sites. Case-insensitive matching handles models
that emit <TOOL_CALL> or <Tool_Call> variants.

Secondary fix: add has_native_thinking() model detection to skip
<think>/<final> system prompt injection for models with built-in
reasoning (Qwen3, QwQ, DeepSeek-R1, GLM-Z1, etc.), preventing
thinking-only responses that clean to empty.

Wire with_model_name(active_model_name()) at all 9 production sites
that construct Reasoning, so the runtime model name (not static config)
drives system prompt generation.

126 new/updated tests covering truncation edge cases, code-block
awareness, Unicode, case-insensitivity, StubLlm integration for
complete/plan/evaluate_success/respond_with_tools paths, model
detection, and conditional system prompt generation.

Closes #789

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

* fix: address Copilot review — unclosed-only truncation, ASCII case folding

- truncate_at_tool_tags() now only truncates at UNCLOSED tool tags;
  properly closed tags (e.g. <tool_call>...</tool_call>) are left intact
  for clean_response() to strip normally, preserving any text after them
- Switch from to_lowercase() to to_ascii_lowercase() to prevent byte
  offset misalignment with non-ASCII characters whose lowercase form
  has different byte length (e.g. Kelvin sign U+212A)
- Add closing_tag_for() helper to derive closing tags from open patterns
- Fix doc comment: "fenced markdown code blocks or inline code spans"
  (not "indented", which find_code_regions() doesn't detect)
- Add regression tests: closed vs unclosed for each tag variant,
  Unicode + case-insensitive offset safety, and mixed closed/unclosed

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

* fix: minor review items — consistent ascii_lowercase, closing_tag_for tests

- Switch has_native_thinking() from to_lowercase() to to_ascii_lowercase()
  for consistency with truncate_at_tool_tags() approach
- Add unit tests for closing_tag_for(): standard tags, space-suffixed
  patterns, pipe-delimited tags, and exhaustive coverage of all
  TOOL_TAG_PATTERNS entries
- Add test for mixed closed+unclosed tags of different types

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 11:07:52 -07:00
60881d6888 feat(agent): add context size logging before LLM prompt (#810)
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat(agent): add context size logging before LLM prompt

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-03-10 11:07:29 -07:00
63afbaa6c5 fix(setup): drain residual terminal events before secret input (#747) (#849)
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix: skip the regression check
[skip-regression-check]

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-03-10 11:07:26 -07:00
66e834d9d7 fix(wasm): run leak scan before credential injection in tools wrapper (#791)
* fix(wasm): run leak scan before credential injection in tools wrapper

The tools WASM wrapper runs the LeakDetector on HTTP request headers
AFTER inject_host_credentials() has already substituted real secrets
(e.g., xoxb- Slack bot tokens). This causes the leak detector to
flag the tool's own legitimate outbound API calls as secret exfiltration.

Move the scan to run on raw_headers before any credential injection,
matching the fix already applied to the channels wrapper in #421.

Fixes the same class of bug as #421 (which only fixed channels/wasm/wrapper.rs).

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

* perf: inline leak scan to avoid Vec allocation on every HTTP request

Address review feedback: instead of cloning all header keys/values into
a Vec to pass to scan_http_request(), iterate over raw_headers directly
using scan_and_clean(). This also provides more specific error messages
(URL vs header vs body).

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

* style: fix cargo fmt formatting in leak scan loop

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 11:06:53 -07:00
c148dd2b5b feat: add fuzzing targets for untrusted input parsers (#835)
* feat: add fuzzing targets for untrusted input parsers

Add cargo-fuzz infrastructure with 5 fuzz targets exercising
security-critical code paths:

- fuzz_safety_sanitizer: Aho-Corasick + regex injection detection
- fuzz_safety_validator: Input validation (length, encoding, patterns)
- fuzz_leak_detector: Secret leak scanning (API keys, tokens)
- fuzz_tool_params: Tool parameter JSON validation
- fuzz_config_env: TOML/JSON config parsing

Each target exercises real IronClaw business logic with invariant
assertions. Includes corpus directories and setup documentation.

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

* fix: improve fuzz targets to exercise real IronClaw code paths

- fuzz_config_env: exercise SafetyLayer end-to-end (sanitize, validate,
  policy check) instead of generic TOML/JSON parsing
- fuzz_tool_params: add validate_tool_schema coverage alongside
  validate_tool_params
- Add "fuzz" to workspace exclude in root Cargo.toml
- Update README descriptions to match actual target behavior

[skip-regression-check]

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

* fix: replace redundant detect() call with meaningful invariant assertion

Replace the double sanitize()+detect() call with an assertion that
critical severity warnings always trigger content modification.

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

* fix: rewrite fuzz_config_env to exercise IronClaw safety code directly

Replace SafetyLayer wrapper usage with direct Sanitizer, Validator, and
LeakDetector instantiation and invocation. Adds meaningful consistency
assertions (non-empty output, valid-means-no-errors, scan/clean agreement).
Removes the config construction that was only exercising struct instantiation.

[skip-regression-check]

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 11:06:50 -07:00
9d8817646d feat: add PR template with risk assessment (#837)
* feat: add PR template with risk assessment and review tracks

Add a pull request template that includes summary, change type,
validation checklist, security/database impact sections, blast radius,
and rollback plan. Update CONTRIBUTING.md with review track definitions
(A/B/C) based on change risk level.

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

* fix: expand CONTRIBUTING.md with setup, workflow, and guidelines

Add getting started, development workflow, code style summary,
database change guidance, and dependency management sections.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 11:06:47 -07:00
bf8102a8d6 perf: optimize release and dist build profiles (#843)
* perf: optimize release and dist build profiles

Add [profile.release] with strip=true and panic="abort" for smaller,
faster release binaries. Upgrade [profile.dist] from lto="thin" to
lto="fat" with codegen-units=1 for maximum optimization in CI releases.

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

* fix: remove panic=abort from release profile

Reviewers (zmanian, Copilot, Gemini) correctly flagged that panic=abort
in the release profile would kill the entire process on any tokio task
panic, breaking fault isolation for the long-running server. Removed
from release profile entirely.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 18:06:36 +00:00
Xing JiandGitHub d9dffeac26 fix(safety): allow empty string tool params (#848)
* fix(safety): allow empty string tool params

* fix(safety): preserve heuristic checks and add path context to tool validation

This follow-up refactor addresses PR review feedback by restoring
heuristic checks (whitespace ratio, character repetition) for tool
parameter validation and improving error reporting.

Changes:
- Restored heuristic warnings in validate_non_empty_input so they apply
  to both user input and tool parameters (when non-empty).
- Refactored check_strings to recursively build and pass JSON paths
  (e.g., "metadata.tags[1]").
- Updated validation errors to use the specific JSON path as the field
  name instead of the generic "input".
- Added regression tests for whitespace/repetition warnings and JSON
  path reporting in tool parameters.

This ensures the safety layer remains semantically neutral about empty
strings (fixing the memory_tree path: "" issue) while maintaining
rigorous protection and providing better developer ergonomics.

* style: run cargo fmt
2026-03-10 11:06:02 -07:00
0e04123188 fix: stop XML-escaping tool output content (#598) (#874)
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix: stop XML-escaping tool output content in wrap_for_llm (#598)

Remove content escaping that corrupted JSON in tool output. The
<tool_output> structural boundary is preserved but content now passes
through raw, fixing downstream parse failures.

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

---------

Co-authored-by: Henry Park <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-10 11:05:59 -07:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
9d4cf308ef chore: update WASM artifact SHA256 checksums [skip ci] (#876)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-10 17:55:38 +00:00
Nick PismenkovandGitHub 1b85fe827c fix: Chat input is hidden in mobile browser mode (#877) 2026-03-10 10:40:17 -07:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
be57a7684d chore: release v0.17.0 (#842)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-10 16:30:41 +00:00
8cd9b4bcfd chore: sync main into staging (#855)
* fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)

GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>

* fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)

- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)

- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat: persist user_id in save_job and expose job_id on routine runs (#709)

* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)

Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-03-10 08:14:27 -07:00
34f69b31dc fix: prevent session lock contention blocking message processing (#783)
* fix: prevent session lock contention blocking message processing

## Problem
After container restart, POST /api/chat/send returns 202 ACCEPTED but messages
don't appear in conversation_messages and agent never responds. Messages get
stuck in "stale state" after restart.

Root cause: Session lock was held for entire duration of chat_threads_handler
and chat_history_handler, including during slow database queries. This blocked
the agent loop from acquiring the session lock to process incoming messages,
causing them to hang indefinitely.

## Solution
1. **Release session lock early in chat_threads_handler**: Only acquire lock
   when reading active_thread at response time, not during DB queries for
   thread list. DB operations no longer block message processing.

2. **Release session lock early in chat_history_handler**: Only acquire lock
   when accessing in-memory thread state, not during paginated DB queries or
   thread ownership checks. DB operations no longer block message processing.

3. **Add comprehensive logging**: Track message flow from receipt through
   session resolution, thread hydration, and state transitions. Helps diagnose
   future issues:
   - Message queued to agent loop (chat_send_handler)
   - Processing message from channel (handle_message)
   - Hydrating thread from DB (maybe_hydrate_thread)
   - Resolving session and thread (resolve_thread)
   - Checking thread state (process_user_input)
   - Persisting user message (persist_user_message)

## Impact
- Message processing no longer blocks on session lock contention
- API response times for thread list/history queries unaffected (DB queries
  still happen, but lock is not held)
- Better diagnostics for future debugging

## Testing
- All 2756 tests pass
- Code compiles with zero clippy warnings
- No changes to user-facing API or behavior, only lock timing

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

* security: redact PII from info-level logs

Downgrade user_id and channel logging to debug level to prevent exposing
Personally Identifiable Information (PII) in production logs.

The user_id field can contain sensitive information such as phone numbers
(e.g., for Signal messages). Logging PII in cleartext at the info level
creates a security and privacy risk, as these logs may be stored in
persistent storage, indexed by log management systems, or accessible to
unauthorized personnel.

Changes:
- Info level: logs only message_id (UUID) for tracking
- Debug level: logs user_id, channel, thread_id for troubleshooting

This maintains debugging capability for developers while protecting user
privacy in production logs.

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

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>
2026-03-10 08:11:30 -07:00
Nick PismenkovandGitHub f8c56727c6 fix: Channel HTTP: server doesn't start after config change (no hot-r… (#779)
* fix: Channel HTTP: server doesn't start after config change (no hot-reload)

* review fixes

* review fixes

* fix linter

* fix code style
2026-03-10 08:11:21 -07:00
Henry ParkandGitHub c6ca2b7f58 Merge branch 'main' into staging-promote/83950d11-22884429853 2026-03-10 07:46:51 -07:00
2016693b0c feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)
Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 07:11:26 +00:00
bcef04b821 feat: persist user_id in save_job and expose job_id on routine runs (#709)
* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

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

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

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

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

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

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

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

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

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

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

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

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 01:51:43 +00:00
6e12ce6f2d fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)
- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-09 18:43:16 -07:00
b53986f00b fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)
- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-09 17:35:06 -07:00
1440ec7422 fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)
GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-09 23:58:56 +00:00
131 changed files with 10814 additions and 2516 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ If the event needs custom UI (cards, badges, etc.), add styles. Follow the exist
Identify where in the backend this event should be triggered. Common locations: Identify where in the backend this event should be triggered. Common locations:
- `src/agent/agent_loop.rs` - During message processing or tool execution - `src/agent/agent_loop.rs` - During message processing or tool execution
- `src/agent/worker.rs` - During job execution - `src/worker/job.rs` - During job execution
- `src/agent/heartbeat.rs` - During periodic execution - `src/agent/heartbeat.rs` - During periodic execution
Use the existing pattern: Use the existing pattern:
+50
View File
@@ -0,0 +1,50 @@
## Summary
<!-- 2-5 bullet points: what changed and why -->
-
## Change Type
<!-- Check one -->
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor
- [ ] Documentation
- [ ] CI/Infrastructure
- [ ] Security
- [ ] Dependencies
## Linked Issue
<!-- Closes #N, or "None" -->
## Validation
<!-- How did you verify this works? -->
- [ ] `cargo fmt`
- [ ] `cargo clippy --all --benches --tests --examples --all-features`
- [ ] Relevant tests pass: <!-- list specific tests -->
- [ ] Manual testing: <!-- describe what you tested -->
## Security Impact
<!-- Does this change affect: permissions, network calls, secrets, file access, tool execution, sandbox policy? If yes, describe. If no, write "None". -->
## Database Impact
<!-- Does this add/modify migrations, change schema, or affect both PostgreSQL and libSQL? If yes, describe. If no, write "None". -->
## Blast Radius
<!-- What subsystems does this touch? What could break? -->
## Rollback Plan
<!-- How to revert if this causes problems? For Track C changes, this is mandatory. -->
---
**Review track**: <!-- A (docs/tests/chore) | B (feature/refactor) | C (security/runtime/DB/CI) -->
+63 -23
View File
@@ -144,6 +144,8 @@ jobs:
- name: Patch manifests with WASM checksums - name: Patch manifests with WASM checksums
if: ${{ needs.plan.outputs.publishing == 'true' }} if: ${{ needs.plan.outputs.publishing == 'true' }}
shell: bash shell: bash
env:
RELEASE_TAG: ${{ github.ref_name }}
run: | run: |
CHECKSUMS="target/distrib/checksums.txt" CHECKSUMS="target/distrib/checksums.txt"
if [ ! -f "$CHECKSUMS" ]; then if [ ! -f "$CHECKSUMS" ]; then
@@ -154,12 +156,17 @@ jobs:
while IFS= read -r line; do while IFS= read -r line; do
sha256=$(echo "$line" | awk '{print $1}') sha256=$(echo "$line" | awk '{print $1}')
filename=$(echo "$line" | awk '{print $2}') filename=$(echo "$line" | awk '{print $2}')
name=$(echo "$filename" | sed 's/-wasm32-wasip2\.tar\.gz$//') # Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
if [ -f "$manifest" ]; then if [ -f "$manifest" ]; then
jq --arg sha "$sha256" '.artifacts["wasm32-wasip2"].sha256 = $sha' "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest" jq --arg sha "$sha256" --arg url "$url" \
echo "Patched $manifest with sha256=$sha256" '.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256 url=$url"
fi fi
done done
done < "$CHECKSUMS" done < "$CHECKSUMS"
@@ -268,21 +275,41 @@ jobs:
for manifest in registry/tools/*.json registry/channels/*.json; do for manifest in registry/tools/*.json registry/channels/*.json; do
[ -f "$manifest" ] || continue [ -f "$manifest" ] || continue
name=$(jq -r '.name' "$manifest") # file_stem: JSON filename without extension (e.g. "slack" for slack.json).
# Used for the bundle filename and CI manifest lookup, so patching always
# finds the right file regardless of whether manifest.name matches the filename.
file_stem=$(basename "$manifest" .json)
# ext_name: the manifest's .name field (e.g. "slack-tool").
# Used for file names *inside* the archive — the installer extracts by manifest.name.
ext_name=$(jq -r '.name' "$manifest")
source_dir=$(jq -r '.source.dir' "$manifest") source_dir=$(jq -r '.source.dir' "$manifest")
caps_file=$(jq -r '.source.capabilities' "$manifest") caps_file=$(jq -r '.source.capabilities' "$manifest")
crate_name=$(jq -r '.source.crate_name' "$manifest") crate_name=$(jq -r '.source.crate_name' "$manifest")
ext_version=$(jq -r '.version // ""' "$manifest")
if [ ! -d "$source_dir" ]; then if [ ! -d "$source_dir" ]; then
echo "::warning::Source dir '$source_dir' not found for '$name', skipping" echo "::warning::Source dir '$source_dir' not found for '$file_stem', skipping"
continue continue
fi fi
echo "=== Building $name from $source_dir ===" # Skip rebuild if this exact version was already built and checksummed.
# Checks that (1) the manifest already has a sha256, and (2) the version
# embedded in the existing artifact URL matches the current manifest version.
# This ensures stable checksums: only rebuild when the source version changes.
existing_sha=$(jq -r '.artifacts["wasm32-wasip2"].sha256 // ""' "$manifest")
existing_url=$(jq -r '.artifacts["wasm32-wasip2"].url // ""' "$manifest")
url_version=$(echo "$existing_url" | sed -n 's/.*-\([0-9].*\)-wasm32-wasip2\.tar\.gz$/\1/p')
if [[ -n "$ext_version" && "$url_version" == "$ext_version" && -n "$existing_sha" ]]; then
echo "=== Skipping $file_stem v$ext_version — already checksummed at $existing_url ==="
continue
fi
echo "=== Building $file_stem ($ext_name) v$ext_version from $source_dir ==="
# Build WASM component # Build WASM component
cargo component build --release --manifest-path "$source_dir/Cargo.toml" || { cargo component build --release --manifest-path "$source_dir/Cargo.toml" || {
echo "::warning::Build failed for '$name', skipping" echo "::warning::Build failed for '$file_stem', skipping"
continue continue
} }
@@ -298,30 +325,36 @@ jobs:
done done
if [ -z "$wasm_path" ]; then if [ -z "$wasm_path" ]; then
echo "::warning::No WASM output found for '$name', skipping" echo "::warning::No WASM output found for '$file_stem', skipping"
continue continue
fi fi
# Copy files with standardized names for the archive # Archive contents use ext_name (manifest .name) — the installer extracts
cp "$wasm_path" "target/wasm-bundles/${name}.wasm" # files by manifest.name, so these must match even when file_stem differs.
cp "$wasm_path" "target/wasm-bundles/${ext_name}.wasm"
caps_path="$source_dir/$caps_file" caps_path="$source_dir/$caps_file"
if [ -f "$caps_path" ]; then if [ -f "$caps_path" ]; then
cp "$caps_path" "target/wasm-bundles/${name}.capabilities.json" cp "$caps_path" "target/wasm-bundles/${ext_name}.capabilities.json"
else else
echo "::warning::No capabilities file at '$caps_path' for '$name'" echo "::warning::No capabilities file at '$caps_path' for '$file_stem'"
fi fi
# Create tar.gz bundle # Bundle filename uses file_stem so CI patching can find the manifest by
bundle="target/wasm-bundles/${name}-wasm32-wasip2.tar.gz" # filename (e.g. slack-0.1.0-wasm32-wasip2.tar.gz → registry/tools/slack.json).
(cd target/wasm-bundles && if [ -f "${name}.capabilities.json" ]; then tar czf "${name}-wasm32-wasip2.tar.gz" "${name}.wasm" "${name}.capabilities.json"; else tar czf "${name}-wasm32-wasip2.tar.gz" "${name}.wasm"; fi) bundle="target/wasm-bundles/${file_stem}-${ext_version}-wasm32-wasip2.tar.gz"
(cd target/wasm-bundles && if [ -f "${ext_name}.capabilities.json" ]; then
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm" "${ext_name}.capabilities.json"
else
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm"
fi)
# Compute SHA256 # Compute SHA256
sha256=$(sha256sum "$bundle" | cut -d' ' -f1) sha256=$(sha256sum "$bundle" | cut -d' ' -f1)
echo "$sha256 ${name}-wasm32-wasip2.tar.gz" >> target/wasm-bundles/checksums.txt echo "$sha256 ${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" >> target/wasm-bundles/checksums.txt
# Clean up intermediate files # Clean up intermediate files
rm -f "target/wasm-bundles/${name}.wasm" "target/wasm-bundles/${name}.capabilities.json" rm -f "target/wasm-bundles/${ext_name}.wasm" "target/wasm-bundles/${ext_name}.capabilities.json"
echo " -> $bundle ($sha256)" echo " -> $bundle ($sha256)"
done done
@@ -427,8 +460,10 @@ jobs:
with: with:
name: artifacts-wasm-extensions name: artifacts-wasm-extensions
path: target/wasm-bundles/ path: target/wasm-bundles/
- name: Patch manifests with SHA256 - name: Patch manifests with SHA256 and version-pinned URL
shell: bash shell: bash
env:
RELEASE_TAG: ${{ github.ref_name }}
run: | run: |
CHECKSUMS="target/wasm-bundles/checksums.txt" CHECKSUMS="target/wasm-bundles/checksums.txt"
if [ ! -f "$CHECKSUMS" ]; then if [ ! -f "$CHECKSUMS" ]; then
@@ -439,12 +474,17 @@ jobs:
while IFS= read -r line; do while IFS= read -r line; do
sha256=$(echo "$line" | awk '{print $1}') sha256=$(echo "$line" | awk '{print $1}')
filename=$(echo "$line" | awk '{print $2}') filename=$(echo "$line" | awk '{print $2}')
name=$(echo "$filename" | sed 's/-wasm32-wasip2\.tar\.gz$//') # Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
if [ -f "$manifest" ]; then if [ -f "$manifest" ]; then
jq --arg sha "$sha256" '.artifacts["wasm32-wasip2"].sha256 = $sha' "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest" jq --arg sha "$sha256" --arg url "$url" \
echo "Patched $manifest with sha256=$sha256" '.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256 url=$url"
fi fi
done done
done < "$CHECKSUMS" done < "$CHECKSUMS"
@@ -461,8 +501,8 @@ jobs:
git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]" git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]"
git push origin "$BRANCH" git push origin "$BRANCH"
gh pr create \ gh pr create \
--title "chore: update WASM artifact SHA256 checksums" \ --title "chore: update WASM artifact checksums and version-pinned URLs" \
--body "Auto-generated by release CI. Updates SHA256 checksums in registry manifests to match the released WASM artifacts." \ --body "Auto-generated by release CI. Updates SHA256 checksums and version-pinned artifact URLs in registry manifests to match the released WASM artifacts. Only extensions whose version changed since the last release are included." \
--base main \ --base main \
--head "$BRANCH" --head "$BRANCH"
fi fi
+1
View File
@@ -28,3 +28,4 @@ trace_*.json
# Local Claude Code settings (machine-specific, should not be committed) # Local Claude Code settings (machine-specific, should not be committed)
.claude/settings.local.json .claude/settings.local.json
.worktrees/
+84
View File
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
### Other
- Merge pull request #907 from nearai/staging-promote/b0214fef-22930316561
- promote staging to main (2026-03-10 15:19 UTC) ([#865](https://github.com/nearai/ironclaw/pull/865))
- Merge pull request #830 from nearai/staging-promote/3a2989d0-22888378864
- update WASM artifact SHA256 checksums [skip ci] ([#876](https://github.com/nearai/ironclaw/pull/876))
## [0.17.0](https://github.com/nearai/ironclaw/compare/v0.16.1...v0.17.0) - 2026-03-10
### Added
- *(llm)* per-provider unsupported parameter filtering (#749, #728) ([#809](https://github.com/nearai/ironclaw/pull/809))
- persist user_id in save_job and expose job_id on routine runs ([#709](https://github.com/nearai/ironclaw/pull/709))
- *(ci)* chained promotion PRs with multi-agent Claude review ([#776](https://github.com/nearai/ironclaw/pull/776))
- add background sandbox reaper for orphaned Docker containers ([#634](https://github.com/nearai/ironclaw/pull/634))
- *(wasm)* lazy schema injection on WASM tool errors ([#638](https://github.com/nearai/ironclaw/pull/638))
- add AWS Bedrock LLM provider via native Converse API ([#713](https://github.com/nearai/ironclaw/pull/713))
- full image support across all channels ([#725](https://github.com/nearai/ironclaw/pull/725))
- *(skills)* exclude_keywords veto in skill activation scoring ([#688](https://github.com/nearai/ironclaw/pull/688))
- *(mcp)* transport abstraction, stdio/UDS transports, and OAuth fixes ([#721](https://github.com/nearai/ironclaw/pull/721))
- add PID-based gateway lock to prevent multiple instances ([#717](https://github.com/nearai/ironclaw/pull/717))
- configurable LLM request timeout via LLM_REQUEST_TIMEOUT_SECS ([#615](https://github.com/nearai/ironclaw/pull/615)) ([#630](https://github.com/nearai/ironclaw/pull/630))
- *(timezone)* add timezone-aware session context ([#671](https://github.com/nearai/ironclaw/pull/671))
- *(setup)* Anthropic OAuth onboarding with setup-token support ([#384](https://github.com/nearai/ironclaw/pull/384))
- *(llm)* add Google Gemini, AWS Bedrock, io.net, Mistral, Yandex, and Cloudflare WS AI providers ([#676](https://github.com/nearai/ironclaw/pull/676))
- unified thread model for web gateway ([#607](https://github.com/nearai/ironclaw/pull/607))
- WASM channel attachments with LLM pipeline integration ([#596](https://github.com/nearai/ironclaw/pull/596))
- enable Anthropic prompt caching via automatic cache_control injection ([#660](https://github.com/nearai/ironclaw/pull/660))
- *(routines)* approval context for autonomous job execution ([#577](https://github.com/nearai/ironclaw/pull/577))
- *(llm)* declarative provider registry ([#618](https://github.com/nearai/ironclaw/pull/618))
- *(gateway)* show IronClaw version in status popover [skip-regression-check] ([#636](https://github.com/nearai/ironclaw/pull/636))
- Wire memory hygiene retention policy into heartbeat loop ([#629](https://github.com/nearai/ironclaw/pull/629))
### Fixed
- *(ci)* run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] ([#802](https://github.com/nearai/ironclaw/pull/802))
- *(ci)* clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] ([#794](https://github.com/nearai/ironclaw/pull/794))
- *(ci)* secrets can't be used in step if conditions [skip-regression-check] ([#787](https://github.com/nearai/ironclaw/pull/787))
- prevent irreversible context loss when compaction archive write fails ([#754](https://github.com/nearai/ironclaw/pull/754))
- button styles ([#637](https://github.com/nearai/ironclaw/pull/637))
- *(mcp)* JSON-RPC spec compliance — flexible id, correct notification format ([#685](https://github.com/nearai/ironclaw/pull/685))
- preserve tool-call history across thread hydration ([#568](https://github.com/nearai/ironclaw/pull/568)) ([#670](https://github.com/nearai/ironclaw/pull/670))
- CLI commands ignore runtime DATABASE_BACKEND when both features compiled ([#740](https://github.com/nearai/ironclaw/pull/740))
- *(web)* prevent fetch error when hostname is an IP address in TEE check ([#672](https://github.com/nearai/ironclaw/pull/672))
- add timezone conversion support to time tool ([#687](https://github.com/nearai/ironclaw/pull/687))
- standardize libSQL timestamps as RFC 3339 UTC ([#683](https://github.com/nearai/ironclaw/pull/683))
- *(docker)* bind postgres to localhost only ([#686](https://github.com/nearai/ironclaw/pull/686))
- *(repl)* skip /quit on EOF when stdin is not a TTY ([#724](https://github.com/nearai/ironclaw/pull/724))
- *(web)* prevent Enter key from sending message during IME composition ([#715](https://github.com/nearai/ironclaw/pull/715))
- *(config)* init_secrets no longer overwrites entire config ([#726](https://github.com/nearai/ironclaw/pull/726))
- *(cli)* status command ignores config.toml and settings.json ([#354](https://github.com/nearai/ironclaw/pull/354)) ([#734](https://github.com/nearai/ironclaw/pull/734))
- *(setup)* preserve model name when re-running onboarding with same provider ([#600](https://github.com/nearai/ironclaw/pull/600)) ([#694](https://github.com/nearai/ironclaw/pull/694))
- *(setup)* initialize secrets crypto for env-var security option ([#666](https://github.com/nearai/ironclaw/pull/666)) ([#706](https://github.com/nearai/ironclaw/pull/706))
- persist /model selection across restarts ([#707](https://github.com/nearai/ironclaw/pull/707))
- *(routines)* resolve message tool channel/target from per-job metadata ([#708](https://github.com/nearai/ironclaw/pull/708))
- sanitize HTML error bodies from MCP servers to prevent web UI white screen ([#263](https://github.com/nearai/ironclaw/pull/263)) ([#656](https://github.com/nearai/ironclaw/pull/656))
- prevent Instant duration overflow on Windows ([#657](https://github.com/nearai/ironclaw/pull/657)) ([#664](https://github.com/nearai/ironclaw/pull/664))
- enable libsql remote + tls features for Turso cloud sync ([#587](https://github.com/nearai/ironclaw/pull/587))
- *(tests)* replace hardcoded /tmp paths with tempdir + add 300 unit tests ([#659](https://github.com/nearai/ironclaw/pull/659))
- *(llm)* nudge LLM when it expresses tool intent without calling tools ([#653](https://github.com/nearai/ironclaw/pull/653))
- *(llm)* report zero cost for OpenRouter free-tier models ([#463](https://github.com/nearai/ironclaw/pull/463)) ([#613](https://github.com/nearai/ironclaw/pull/613))
- reliable network tests and improved tool error messages ([#626](https://github.com/nearai/ironclaw/pull/626))
- *(wasm)* use per-engine cache dirs on Windows to avoid file lock error ([#624](https://github.com/nearai/ironclaw/pull/624))
- *(libsql)* support flexible embedding dimensions ([#534](https://github.com/nearai/ironclaw/pull/534))
### Other
- Restructure CLAUDE.md into modular rules + add pr-shepherd command ([#750](https://github.com/nearai/ironclaw/pull/750))
- make src/llm/ self-contained for crate extraction ([#767](https://github.com/nearai/ironclaw/pull/767))
- add simplified Chinese (zh-CN) README translation ([#488](https://github.com/nearai/ironclaw/pull/488))
- *(job)* cover job tool validation and state transitions ([#681](https://github.com/nearai/ironclaw/pull/681))
- *(agent)* wire TestRig job tools through the scheduler ([#716](https://github.com/nearai/ironclaw/pull/716))
- Fix single-message mode to exit after one turn when background channels are enabled ([#719](https://github.com/nearai/ironclaw/pull/719))
- remove dead code ([#648](https://github.com/nearai/ironclaw/pull/648)) ([#703](https://github.com/nearai/ironclaw/pull/703))
- add reviewer-feedback guardrails (CLAUDE.md, pre-commit hook, skill) ([#665](https://github.com/nearai/ironclaw/pull/665))
- update WASM artifact SHA256 checksums [skip ci] ([#631](https://github.com/nearai/ironclaw/pull/631))
- add explanatory comments to coverage workflow ([#610](https://github.com/nearai/ironclaw/pull/610))
- build system prompt once per turn, skip tools on force-text ([#583](https://github.com/nearai/ironclaw/pull/583))
- add comprehensive subdirectory CLAUDE.md files and update root ([#589](https://github.com/nearai/ironclaw/pull/589))
- Improve test infrastructure: StubChannel, gateway helpers, security tests, search edge cases ([#623](https://github.com/nearai/ironclaw/pull/623))
- *(workspace)* regression test for document_path in search results ([#509](https://github.com/nearai/ironclaw/pull/509))
### Added ### Added
- AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`) - AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`)
+2 -1
View File
@@ -99,7 +99,8 @@ src/
│ └── job_manager.rs # Container lifecycle (create, stop, cleanup) │ └── job_manager.rs # Container lifecycle (create, stop, cleanup)
├── worker/ # Runs inside Docker containers ├── worker/ # Runs inside Docker containers
│ ├── runtime.rs # Worker execution loop (tool calls, LLM) │ ├── container.rs # Container worker runtime (ContainerDelegate + shared agentic loop)
│ ├── job.rs # Background job worker (JobDelegate + shared agentic loop)
│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI) │ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI)
│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator │ └── proxy_llm.rs # LlmProvider that proxies through orchestrator
+49
View File
@@ -1,5 +1,34 @@
# Contributing # Contributing
## Getting Started
```bash
git clone https://github.com/nearai/ironclaw.git
cd ironclaw
./scripts/dev-setup.sh
```
This installs the Rust toolchain, WASM targets, git hooks, and runs initial checks.
## Development Workflow
```bash
cargo fmt # format
cargo clippy --all --benches --tests --examples --all-features # lint (zero warnings)
cargo test # unit tests
cargo test --features integration # + PostgreSQL tests
```
## Code Style
- Zero clippy warnings policy
- No `.unwrap()` or `.expect()` in production code (tests are fine)
- Use `thiserror` for error types, map errors with context
- Prefer `crate::` for cross-module imports
- Comments for non-obvious logic only
See `CLAUDE.md` for full style guidelines.
## Feature Parity Requirement ## Feature Parity Requirement
When your change affects a tracked capability, update `FEATURE_PARITY.md` in the same branch. When your change affects a tracked capability, update `FEATURE_PARITY.md` in the same branch.
@@ -9,3 +38,23 @@ When your change affects a tracked capability, update `FEATURE_PARITY.md` in the
1. Review the relevant parity rows in `FEATURE_PARITY.md`. 1. Review the relevant parity rows in `FEATURE_PARITY.md`.
2. Update status/notes if behavior changed. 2. Update status/notes if behavior changed.
3. Include the `FEATURE_PARITY.md` diff in your commit when applicable. 3. Include the `FEATURE_PARITY.md` diff in your commit when applicable.
## Review Tracks
All PRs follow a risk-based review process:
| Track | Scope | Requirements |
|-------|-------|-------------|
| **A** | Docs, tests, chore, dependency bumps | 1 approval + CI green |
| **B** | Features, refactors, new tools/channels | 1 approval + CI green + test evidence |
| **C** | Security (`src/safety/`, `src/secrets/`), runtime (`src/agent/`, `src/worker/`), database schema, CI workflows | 2 approvals + rollback plan documented |
Select the appropriate track in the PR template based on what your changes touch.
## Database Changes
IronClaw uses dual-backend persistence (PostgreSQL + libSQL). All new persistence features must support both backends. See `src/db/CLAUDE.md`.
## Adding Dependencies
Run `cargo deny check` before adding new dependencies to verify license compatibility and check for known advisories.
+4 -4
View File
@@ -63,12 +63,12 @@ These files account for the vast majority of the coverage gap:
| `src/main.rs` | 740 | 522 | 29.4% | 485 | | `src/main.rs` | 740 | 522 | 29.4% | 485 |
| `src/channels/web/handlers/jobs.rs` | 513 | 456 | 11.1% | 430 | | `src/channels/web/handlers/jobs.rs` | 513 | 456 | 11.1% | 430 |
| `src/tools/builder/core.rs` | 524 | 456 | 13.0% | 429 | | `src/tools/builder/core.rs` | 524 | 456 | 13.0% | 429 |
| `src/agent/worker.rs` | 1,078 | 467 | 56.7% | 413 | | `src/worker/job.rs` | 1,078 | 467 | 56.7% | 413 |
| `src/channels/web/handlers/chat.rs` | 564 | 417 | 26.1% | 388 | | `src/channels/web/handlers/chat.rs` | 564 | 417 | 26.1% | 388 |
| `src/tools/wasm/wrapper.rs` | 1,005 | 436 | 56.6% | 385 | | `src/tools/wasm/wrapper.rs` | 1,005 | 436 | 56.6% | 385 |
| `src/channels/signal.rs` | 1,814 | 472 | 74.0% | 381 | | `src/channels/signal.rs` | 1,814 | 472 | 74.0% | 381 |
| `src/tools/mcp/auth.rs` | 472 | 378 | 19.9% | 354 | | `src/tools/mcp/auth.rs` | 472 | 378 | 19.9% | 354 |
| `src/worker/runtime.rs` | 350 | 330 | 5.7% | 312 | | `src/worker/container.rs` | 350 | 330 | 5.7% | 312 |
| `src/tools/builtin/job.rs` | 1,014 | 359 | 64.6% | 308 | | `src/tools/builtin/job.rs` | 1,014 | 359 | 64.6% | 308 |
| `src/cli/mcp.rs` | 322 | 319 | 0.9% | 302 | | `src/cli/mcp.rs` | 322 | 319 | 0.9% | 302 |
| `src/cli/oauth_defaults.rs` | 730 | 335 | 54.1% | 298 | | `src/cli/oauth_defaults.rs` | 730 | 335 | 54.1% | 298 |
@@ -346,7 +346,7 @@ Test slash commands through the agent loop.
### Trace: Worker Multi-Turn Execution ### Trace: Worker Multi-Turn Execution
**Covers:** `agent/worker.rs` (+413 lines), `agent/agent_loop.rs` (+207 lines) **Covers:** `worker/job.rs` (+413 lines), `agent/agent_loop.rs` (+207 lines)
Test multi-turn tool calling, error recovery, and completion flows. Test multi-turn tool calling, error recovery, and completion flows.
@@ -769,7 +769,7 @@ HTTP proxy for container network access.
- `test_proxy_connect_tunnel` -- HTTPS CONNECT method handling - `test_proxy_connect_tunnel` -- HTTPS CONNECT method handling
- `test_proxy_logging` -- request/response logging - `test_proxy_logging` -- request/response logging
### `src/worker/runtime.rs` -- 5.7% -> 95% (+312 lines) ### `src/worker/container.rs` -- 5.7% -> 95% (+312 lines)
Worker execution loop (runs inside containers). Worker execution loop (runs inside containers).
Generated
+1 -1
View File
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]] [[package]]
name = "ironclaw" name = "ironclaw"
version = "0.16.1" version = "0.18.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"aho-corasick", "aho-corasick",
+7 -2
View File
@@ -14,11 +14,12 @@ exclude = [
"tools-src/google-slides", "tools-src/google-slides",
"tools-src/slack", "tools-src/slack",
"tools-src/telegram", "tools-src/telegram",
"fuzz",
] ]
[package] [package]
name = "ironclaw" name = "ironclaw"
version = "0.16.1" version = "0.18.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"
@@ -214,10 +215,14 @@ bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types
name = "html_to_markdown" name = "html_to_markdown"
required-features = ["html-to-markdown"] required-features = ["html-to-markdown"]
[profile.release]
strip = true # Remove debug symbols from release binaries
# The profile that 'cargo dist' will build with # The profile that 'cargo dist' will build with
[profile.dist] [profile.dist]
inherits = "release" inherits = "release"
lto = "thin" lto = "fat" # Full cross-crate LTO (slow build, better codegen)
codegen-units = 1 # Single codegen unit for maximum optimization
# Config for 'dist' # Config for 'dist'
[workspace.metadata.dist] [workspace.metadata.dist]
+3 -3
View File
@@ -46,14 +46,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Bonjour/mDNS discovery | ✅ | ❌ | | | Bonjour/mDNS discovery | ✅ | ❌ | |
| Tailscale integration | ✅ | ❌ | | | Tailscale integration | ✅ | ❌ | |
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status + /healthz + /readyz, with channel-backed readiness probes | | Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status + /healthz + /readyz, with channel-backed readiness probes |
| `doctor` diagnostics | ✅ | | | | `doctor` diagnostics | ✅ | 🚧 | 16 checks: settings, LLM, DB, embeddings, routines, gateway, MCP, skills, secrets, service, Docker daemon, tunnel binaries |
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired | | Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval | | Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval |
| Presence system | ✅ | ❌ | Beacons on connect, system presence for agents | | Presence system | ✅ | ❌ | Beacons on connect, system presence for agents |
| Trusted-proxy auth mode | ✅ | ❌ | Header-based auth for reverse proxies | | Trusted-proxy auth mode | ✅ | ❌ | Header-based auth for reverse proxies |
| APNs push pipeline | ✅ | ❌ | Wake disconnected iOS nodes via push | | APNs push pipeline | ✅ | ❌ | Wake disconnected iOS nodes via push |
| Oversized payload guard | ✅ | 🚧 | HTTP webhook has 64KB body limit + Content-Length check; no chat.history cap | | Oversized payload guard | ✅ | 🚧 | HTTP webhook has 64KB body limit + Content-Length check; no chat.history cap |
| Pre-prompt context diagnostics | ✅ | | Context size logging before prompt | | Pre-prompt context diagnostics | ✅ | 🚧 | Token breakdown logged before LLM call (conversational dispatcher path); other LLM entry points not yet covered |
### Owner: _Unassigned_ ### Owner: _Unassigned_
@@ -175,7 +175,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `message send` | ✅ | ❌ | P2 | Send to channels | | `message send` | ✅ | ❌ | P2 | Send to channels |
| `browser` | ✅ | ❌ | P3 | Browser automation | | `browser` | ✅ | ❌ | P3 | Browser automation |
| `sandbox` | ✅ | ✅ | - | WASM sandbox | | `sandbox` | ✅ | ✅ | - | WASM sandbox |
| `doctor` | ✅ | | P2 | Diagnostics | | `doctor` | ✅ | 🚧 | P2 | 16 subsystem checks |
| `logs` | ✅ | ❌ | P3 | Query logs | | `logs` | ✅ | ❌ | P3 | Query logs |
| `update` | ✅ | ❌ | P3 | Self-update | | `update` | ✅ | ❌ | P3 | Self-update |
| `completion` | ✅ | ✅ | - | Shell completion | | `completion` | ✅ | ✅ | - | Shell completion |
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "ironclaw-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
serde_json = "1"
[dependencies.ironclaw]
path = ".."
[[bin]]
name = "fuzz_safety_sanitizer"
path = "fuzz_targets/fuzz_safety_sanitizer.rs"
doc = false
[[bin]]
name = "fuzz_safety_validator"
path = "fuzz_targets/fuzz_safety_validator.rs"
doc = false
[[bin]]
name = "fuzz_leak_detector"
path = "fuzz_targets/fuzz_leak_detector.rs"
doc = false
[[bin]]
name = "fuzz_tool_params"
path = "fuzz_targets/fuzz_tool_params.rs"
doc = false
[[bin]]
name = "fuzz_config_env"
path = "fuzz_targets/fuzz_config_env.rs"
doc = false
+43
View File
@@ -0,0 +1,43 @@
# IronClaw Fuzz Targets
Fuzz testing for security-critical input parsing paths using [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) (libFuzzer).
## Targets
| Target | What it exercises |
|--------|-------------------|
| `fuzz_safety_sanitizer` | Prompt injection pattern detection (Aho-Corasick + regex) |
| `fuzz_safety_validator` | Input validation (length, encoding, forbidden patterns) |
| `fuzz_leak_detector` | Secret leak detection (API keys, tokens, credentials) |
| `fuzz_tool_params` | Tool parameter and schema JSON validation |
| `fuzz_config_env` | SafetyLayer end-to-end (sanitize, validate, policy check) |
## Setup
```bash
cargo install cargo-fuzz
rustup install nightly
```
## Running
```bash
# Run a specific target (runs until stopped or crash found)
cargo +nightly fuzz run fuzz_safety_sanitizer
# Run with a time limit (5 minutes)
cargo +nightly fuzz run fuzz_leak_detector -- -max_total_time=300
# Run all targets for 60 seconds each
for target in fuzz_safety_sanitizer fuzz_safety_validator fuzz_leak_detector fuzz_tool_params fuzz_config_env; do
echo "==> $target"
cargo +nightly fuzz run "$target" -- -max_total_time=60
done
```
## Adding New Targets
1. Create `fuzz/fuzz_targets/fuzz_<name>.rs` following the existing pattern
2. Add a `[[bin]]` entry in `fuzz/Cargo.toml`
3. Create `fuzz/corpus/fuzz_<name>/` for seed inputs
4. Exercise real IronClaw code paths, not just generic serde
+55
View File
@@ -0,0 +1,55 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::{LeakDetector, Sanitizer, Validator};
fuzz_target!(|data: &[u8]| {
if let Ok(input) = std::str::from_utf8(data) {
// Exercise Sanitizer: detect and neutralize prompt injection attempts.
let sanitizer = Sanitizer::new();
let sanitized = sanitizer.sanitize(input);
// The sanitized content must never be empty when input is non-empty,
// because sanitization wraps/escapes rather than deleting.
if !input.is_empty() {
assert!(
!sanitized.content.is_empty(),
"sanitize() produced empty content for non-empty input"
);
}
// If no modification occurred, content must equal input.
if !sanitized.was_modified {
assert_eq!(sanitized.content, input);
}
// Exercise Validator: input validation (length, encoding, patterns).
let validator = Validator::new();
let result = validator.validate(input);
// ValidationResult must always be well-formed: if valid, no errors.
if result.is_valid {
assert!(
result.errors.is_empty(),
"valid result should have no errors"
);
}
// Exercise LeakDetector: secret detection (API keys, tokens, etc.).
let detector = LeakDetector::new();
let scan = detector.scan(input);
// scan_and_clean must not panic and must return valid UTF-8.
let cleaned = detector.scan_and_clean(input);
if let Ok(ref clean_str) = cleaned {
// Cleaned output must never be longer than original + redaction markers.
// At minimum it should be valid UTF-8 (guaranteed by String type).
let _ = clean_str.len();
}
// If scan found no matches, scan_and_clean should return the input unchanged.
if scan.matches.is_empty() {
if let Ok(ref clean_str) = cleaned {
assert_eq!(
clean_str, input,
"scan_and_clean changed content despite no matches"
);
}
}
}
});
+23
View File
@@ -0,0 +1,23 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::LeakDetector;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let detector = LeakDetector::new();
// Exercise scan path
let result = detector.scan(s);
// Invariant: if should_block, there must be matches
if result.should_block {
assert!(!result.matches.is_empty());
}
// Invariant: match locations must be valid
for m in &result.matches {
assert!(m.location.end <= s.len());
}
// Exercise scan_and_clean path
let _ = detector.scan_and_clean(s);
}
});
@@ -0,0 +1,23 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::Sanitizer;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let sanitizer = Sanitizer::new();
// Exercise the main sanitization path
let result = sanitizer.sanitize(s);
// Verify invariant: warnings should have valid ranges
for w in &result.warnings {
assert!(w.location.end <= s.len());
}
// Verify invariant: critical severity triggers modification
let has_critical = result.warnings.iter().any(|w| {
w.severity == ironclaw::safety::Severity::Critical
});
if has_critical {
assert!(result.was_modified);
}
}
});
@@ -0,0 +1,21 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::Validator;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let validator = Validator::new();
// Exercise input validation
let result = validator.validate(s);
// Invariant: empty input is always invalid
if s.is_empty() {
assert!(!result.is_valid);
}
// Exercise tool parameter validation with arbitrary JSON
if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
let _ = validator.validate_tool_params(&value);
}
}
});
+22
View File
@@ -0,0 +1,22 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::Validator;
use ironclaw::tools::validate_tool_schema;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
// Try parsing as JSON and validating as tool parameters
if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
// Exercise Validator::validate_tool_params with arbitrary JSON
let validator = Validator::new();
let result = validator.validate_tool_params(&value);
// Invariant: result should always be well-formed
if !result.is_valid {
assert!(!result.errors.is_empty());
}
// Exercise validate_tool_schema with arbitrary JSON as a schema
let _ = validate_tool_schema(&value, "fuzz");
}
}
});
+7
View File
@@ -0,0 +1,7 @@
-- Add token budget tracking columns to agent_jobs.
--
-- Tracks max_tokens (configured limit per job) and total_tokens_used (running total)
-- to enforce job-level token budgets and prevent budget bypass via user-supplied metadata.
ALTER TABLE agent_jobs ADD COLUMN max_tokens BIGINT NOT NULL DEFAULT 0;
ALTER TABLE agent_jobs ADD COLUMN total_tokens_used BIGINT NOT NULL DEFAULT 0;
+3 -1
View File
@@ -9,8 +9,9 @@
"api_key_required": true, "api_key_required": true,
"base_url_env": "OPENAI_BASE_URL", "base_url_env": "OPENAI_BASE_URL",
"model_env": "OPENAI_MODEL", "model_env": "OPENAI_MODEL",
"default_model": "gpt-4o", "default_model": "gpt-5-mini",
"description": "OpenAI GPT models (direct API)", "description": "OpenAI GPT models (direct API)",
"unsupported_params": ["temperature"],
"setup": { "setup": {
"kind": "api_key", "kind": "api_key",
"secret_name": "llm_openai_api_key", "secret_name": "llm_openai_api_key",
@@ -86,6 +87,7 @@
"model_env": "TINFOIL_MODEL", "model_env": "TINFOIL_MODEL",
"default_model": "kimi-k2-5", "default_model": "kimi-k2-5",
"description": "Tinfoil private inference (hardware-attested TEE)", "description": "Tinfoil private inference (hardware-attested TEE)",
"unsupported_params": ["temperature"],
"setup": { "setup": {
"kind": "api_key", "kind": "api_key",
"secret_name": "llm_tinfoil_api_key", "secret_name": "llm_tinfoil_api_key",
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
"sha256": "030707431717bca3411a48f311c6ab5f92a45c747de26cafe4f6e3e23a8b3b2d" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
"sha256": "bd35cad18d87292ea8d2f52db9b514ed9f814a414de910f59073d475c26c4c14" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -20,7 +20,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
"sha256": "6fcd32719a4ff15641a4b50fff8984686550f0c491dce60518f4126857d0c544" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
"sha256": "023da7000b17568bf0e64b2e5013c8a042b2f323c85f1632339231c73d500e39" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
"sha256": "fc42277b65881d6e9bcc5403dc54c7f5b3ddeaaaf04617fce2c5da05d76325f0" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
"sha256": "385c04abd1e6b8011ccc330e1f4bd7ce58577e488959b51594aa04eb26cbe7cc" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
"sha256": "1b107d575a5d52cc8c76d9a681802190f4373fb485f7f54f445533f097fa37c0" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
"sha256": "c4f6b1e8c5126ac2c8a4b98e4283a3afa32223d2488fc3c3a609758c0c9beb90" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
"sha256": "7110b8565340c888e51f99e9c013bf4de8f8a7f7b33bace00eb8fc47831ff20b" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": { "artifacts": {
"wasm32-wasip2": { "wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz", "url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
"sha256": "66cb2b9b00652385e9f30f17c74902b9222c17c53e9d3bd1ef42f5cab705bcf6" "sha256": null
} }
}, },
"auth_summary": { "auth_summary": {
@@ -0,0 +1,80 @@
---
name: ironclaw-workflow-orchestrator
description: "Install and operate a full GitHub issue-to-merge workflow in IronClaw using event-driven and cron routines. Use when setting up or tuning autonomous project orchestration: issue intake, planning, maintainer feedback handling, branch/PR execution, CI/comment follow-up, batched staging review every 8 hours, and memory updates from merge outcomes."
---
# IronClaw Workflow Orchestrator
## Overview
Use this skill to install and maintain a complete project workflow as routines, not core code changes. It maps GitHub webhook events plus scheduled checks into plan/update/implement/review/merge loops with explicit staging-batch analysis.
## Workflow
1. Gather workflow parameters.
2. Verify runtime prerequisites.
3. Install or update routine set from templates.
4. Run a dry test with `event_emit`.
5. Monitor outcomes and tune prompts/filters.
## Parameters
Collect these values before creating routines:
- `repository`: `owner/repo` (required)
- `maintainers`: GitHub handles allowed to trigger implement/replan actions
- `staging_branch`: default `staging`
- `main_branch`: default `main`
- `batch_interval_hours`: default `8`
- `implementation_label`: default `autonomous-impl`
## Prerequisites
Before installing routines, verify:
- Routines system enabled.
- GitHub tool authenticated (for issue/PR/comment/status operations).
- Events are emitted via `event_emit` tool calls (a future HTTP webhook ingestion endpoint is planned but not yet available).
## Install Procedure
1. Open [`workflow-routines.md`](references/workflow-routines.md).
2. For each template block:
- replace placeholders (`{{repository}}`, `{{maintainers}}`, branch names)
- call `routine_create`
3. If a routine already exists:
- use `routine_update` instead of creating duplicates
- keep names stable so long-lived metrics/history stay intact
4. Confirm install with `routine_list` and `routine_history`.
## Routine Set
Install these routines:
- `wf-issue-plan`: on `issue.opened` or `issue.reopened`, generate implementation plan comment/checklist.
- `wf-maintainer-comment-gate`: on maintainer comments, decide update-plan vs start implementation.
- `wf-pr-monitor-loop`: on PR open/sync/review-comment/review, address feedback and refresh branch.
- `wf-ci-fix-loop`: on CI status/check failures, apply fixes and push updates.
- `wf-staging-batch-review`: every 8h, review ready PRs, merge into staging, run deep batch correctness analysis, fix findings, then merge staging -> main.
- `wf-learning-memory`: on merged PRs, extract mistakes/lessons and write to shared memory.
## Event Filters
Prefer top-level filters for stability:
- `repository` (string)
- `sender` (string)
- `issue_number` / `pr_number`
- `ci_status`, `ci_conclusion`
- `review_state`, `comment_author`
Use narrow filters to avoid accidental triggers across repos.
## Operating Rules
- All implementation work must occur on non-main branches.
- PR loop must resolve both human and AI review comments.
- On conflicts with `origin/main`, refresh branch before continuing.
- Staging-batch routine is the only path for bulk correctness verification before mainline merge.
- Memory update routine runs only after successful merge.
## Validation
After install, run:
1. `event_emit` with a synthetic `issue.opened` payload for the target repo.
2. Confirm at least one routine fired.
3. Check corresponding `routine_history` entries.
4. Confirm no unrelated routines fired.
## When To Update Templates
Update this skill when:
- GitHub event names/payload fields change.
- Team review policy changes (e.g., staging cadence, maintainer gates).
- New CI policy requires different failure routing.
@@ -0,0 +1,4 @@
interface:
display_name: "IronClaw Workflow Orchestrator"
short_description: "Install and run event-driven GitHub workflow routines"
default_prompt: "Set up the full issue-to-merge workflow using routines and event triggers."
@@ -0,0 +1,128 @@
# Workflow Routine Templates
Replace `{{...}}` placeholders before use.
## 1) Issue -> Plan
```json
{
"name": "wf-issue-plan",
"description": "Create implementation plan when a new issue arrives",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "issue.opened",
"event_filters": {
"repository": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
"cooldown_secs": 30
}
```
## 2) Maintainer Comment Gate (Update Plan vs Implement)
Trigger per-maintainer by creating one routine per handle, or maintain a shared author convention.
```json
{
"name": "wf-maintainer-comment-gate-{{maintainer}}",
"description": "React to maintainer guidance comments on issues/PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.comment.created",
"event_filters": {
"repository": "{{repository}}",
"comment_author": "{{maintainer}}"
},
"action_type": "full_job",
"prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.",
"cooldown_secs": 20
}
```
## 3) PR Monitor Loop
```json
{
"name": "wf-pr-monitor-loop",
"description": "Keep PR healthy: address review comments and refresh branch",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.synchronize",
"event_filters": {
"repository": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
"cooldown_secs": 20
}
```
## 4) CI Failure Fix Loop
```json
{
"name": "wf-ci-fix-loop",
"description": "Fix failing CI checks on active PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "ci.check_run.completed",
"event_filters": {
"repository": "{{repository}}",
"ci_conclusion": "failure"
},
"action_type": "full_job",
"prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.",
"cooldown_secs": 20
}
```
## 5) Staging Batch Review (Every 8h)
```json
{
"name": "wf-staging-batch-review",
"description": "Batch correctness review through staging, then merge to main",
"trigger_type": "cron",
"schedule": "0 0 */{{batch_interval_hours}} * * *",
"action_type": "full_job",
"prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.",
"cooldown_secs": 120
}
```
## 6) Post-Merge Learning -> Common Memory
```json
{
"name": "wf-learning-memory",
"description": "Capture merge learnings into shared memory",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.closed",
"event_filters": {
"repository": "{{repository}}",
"pr_merged": "true"
},
"action_type": "full_job",
"prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.",
"cooldown_secs": 30
}
```
## Optional: Synthetic Event Test
```json
{
"source": "github",
"event_type": "issue.opened",
"payload": {
"repository": "{{repository}}",
"issue_number": 99999,
"sender": "test-bot"
}
}
```
Use with `event_emit` after routine install.
+20 -17
View File
@@ -14,14 +14,15 @@ Core agent logic. This is the most complex subsystem — read this before workin
| `session_manager.rs` | Lifecycle: create/lookup sessions, map external thread IDs to internal UUIDs, prune stale sessions, manage undo managers. | | `session_manager.rs` | Lifecycle: create/lookup sessions, map external thread IDs to internal UUIDs, prune stale sessions, manage undo managers. |
| `router.rs` | Routes explicit `/commands` to `MessageIntent`. Natural language bypasses the router entirely. | | `router.rs` | Routes explicit `/commands` to `MessageIntent`. Natural language bypasses the router entirely. |
| `scheduler.rs` | Parallel job scheduling. Maintains `jobs` map (full LLM-driven) and `subtasks` map (tool-exec/background). | | `scheduler.rs` | Parallel job scheduling. Maintains `jobs` map (full LLM-driven) and `subtasks` map (tool-exec/background). |
| `worker.rs` | Per-job execution for background scheduler jobs: calls LLM, runs tools, handles the reasoning loop. Distinct from `dispatcher.rs`. | | *(moved to `src/worker/job.rs`)* | Per-job execution now lives in `src/worker/job.rs` as `JobDelegate`, using the shared `run_agentic_loop()` engine. |
| `agentic_loop.rs` | Shared agentic loop engine: `run_agentic_loop()`, `LoopDelegate` trait, `LoopOutcome`, `LoopSignal`, `TextAction`. All three execution paths (chat, job, container) delegate to this. |
| `compaction.rs` | Context window management: summarize old turns, write to workspace daily log, trim context. Three strategies. | | `compaction.rs` | Context window management: summarize old turns, write to workspace daily log, trim context. Three strategies. |
| `context_monitor.rs` | Detects memory pressure. Suggests `CompactionStrategy` based on usage level. | | `context_monitor.rs` | Detects memory pressure. Suggests `CompactionStrategy` based on usage level. |
| `self_repair.rs` | Detects stuck jobs and broken tools, attempts recovery. | | `self_repair.rs` | Detects stuck jobs and broken tools, attempts recovery. |
| `heartbeat.rs` | Proactive periodic execution. Reads `HEARTBEAT.md`, notifies via channel if findings. | | `heartbeat.rs` | Proactive periodic execution. Reads `HEARTBEAT.md`, notifies via channel if findings. |
| `submission.rs` | Parses all user submissions into typed variants before routing. | | `submission.rs` | Parses all user submissions into typed variants before routing. |
| `undo.rs` | Turn-based undo/redo with checkpoints. Checkpoints store message lists (max 20 by default). | | `undo.rs` | Turn-based undo/redo with checkpoints. Checkpoints store message lists (max 20 by default). |
| `routine.rs` | `Routine` types: `Trigger` (cron/event/webhook/manual) + `RoutineAction` (lightweight/full_job) + `RoutineGuardrails`. | | `routine.rs` | `Routine` types: `Trigger` (cron/event/system_event/manual) + `RoutineAction` (lightweight/full_job) + `RoutineGuardrails`. |
| `routine_engine.rs` | Cron ticker and event matcher. Fires routines when triggers match. Lightweight runs inline; full_job dispatches to `Scheduler`. | | `routine_engine.rs` | Cron ticker and event matcher. Fires routines when triggers match. Lightweight runs inline; full_job dispatches to `Scheduler`. |
| `task.rs` | Task types for the scheduler: `Job`, `ToolExec`, `Background`. Used by `spawn_subtask` and `spawn_batch`. | | `task.rs` | Task types for the scheduler: `Job`, `ToolExec`, `Background`. Used by `spawn_subtask` and `spawn_batch`. |
| `cost_guard.rs` | LLM spend and action-rate enforcement. Tracks daily budget (cents) and hourly call rate. Lives in `AgentDeps`. | | `cost_guard.rs` | LLM spend and action-rate enforcement. Tracks daily budget (cents) and hourly call rate. Lives in `AgentDeps`. |
@@ -49,26 +50,28 @@ Session (per user)
## Agentic Loop (dispatcher.rs) ## Agentic Loop (dispatcher.rs)
The `dispatcher.rs` module handles **direct conversational turns** (user messages processed inline by the main agent). Background scheduler jobs use `worker.rs` instead — these are two separate execution paths. All three execution paths (chat, job, container) now use the shared `run_agentic_loop()` engine in `agentic_loop.rs`, each providing their own `LoopDelegate` implementation:
- **`ChatDelegate`** (`dispatcher.rs`) — conversational turns, tool approval, skill context injection
- **`JobDelegate`** (`src/worker/job.rs`) — background scheduler jobs, planning support, completion detection
- **`ContainerDelegate`** (`src/worker/container.rs`) — Docker container worker, sequential tool exec, HTTP event streaming
``` ```
run_agentic_loop() [dispatcher.rs — conversational turns] run_agentic_loop(delegate, reasoning, reason_ctx, config)
1. Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.) 1. Check signals (stop/cancel) via delegate.check_signals()
2. Detect group chat from metadata; exclude MEMORY.md if group chat 2. Pre-LLM hook via delegate.before_llm_call()
3. Select active skills (keyword/pattern scoring against message content) 3. LLM call via delegate.call_llm()
4. Build skill context block (injected before user message) 4. If text response → delegate.handle_text_response() → Continue or Return
5. LLM call → text response OR tool calls 5. If tool callsdelegate.execute_tool_calls() → Continue or Return
6. If tool calls: 6. Post-iteration hook via delegate.after_iteration()
a. Check tool approval (session auto-approvals, pending approval queue) 7. Repeat until LoopOutcome returned or max_iterations reached
b. Execute tools (parallel via JoinSet)
c. Sanitize results through SafetyLayer
d. Feed results back → goto 5
7. Return AgenticLoopResult::Response or NeedApproval
``` ```
**Tool approval:** Tools flagged `requires_approval` pause the loop and return `NeedApproval`. The web gateway stores the `PendingApproval` in session state and sends an `approval_needed` SSE event. The user's approval/deny resumes the loop. **Tool approval:** Tools flagged `requires_approval` pause the loop `ChatDelegate` returns `LoopOutcome::NeedApproval(pending)`. The web gateway stores the `PendingApproval` in session state and sends an `approval_needed` SSE event. The user's approval/deny resumes the loop.
**worker.rs vs dispatcher.rs:** `dispatcher.rs` runs the agentic loop for user-initiated conversational turns (holds session lock, tracks turns). `worker.rs` is spawned by the `Scheduler` for background jobs created via `CreateJob` / `/job` — it runs independently of the session and has its own LLM reasoning loop with planning support (`use_planning` flag). **Shared tool execution:** `tools/execute.rs` provides `execute_tool_with_safety()` (validate → timeout → execute → serialize) and `process_tool_result()` (sanitize → wrap → ChatMessage), used by all three delegates.
**ChatDelegate vs JobDelegate:** `ChatDelegate` runs for user-initiated conversational turns (holds session lock, tracks turns). `JobDelegate` is spawned by the `Scheduler` for background jobs created via `CreateJob` / `/job` — it runs independently of the session and has planning support (`use_planning` flag).
## Command Routing (router.rs) ## Command Routing (router.rs)
+28 -2
View File
@@ -738,6 +738,18 @@ impl Agent {
} }
async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> { async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> {
// Log at info level only for tracking without exposing PII (user_id can be a phone number)
tracing::info!(message_id = %message.id, "Processing message");
// Log sensitive details at debug level for troubleshooting
tracing::debug!(
message_id = %message.id,
user_id = %message.user_id,
channel = %message.channel,
thread_id = ?message.thread_id,
"Message details"
);
// Set message tool context for this turn (current channel and target) // Set message tool context for this turn (current channel and target)
// For Signal, use signal_target from metadata (group:ID or phone number), // For Signal, use signal_target from metadata (group:ID or phone number),
// otherwise fall back to user_id // otherwise fall back to user_id
@@ -753,7 +765,7 @@ impl Agent {
// Parse submission type first // Parse submission type first
let mut submission = SubmissionParser::parse(&message.content); let mut submission = SubmissionParser::parse(&message.content);
tracing::debug!( tracing::trace!(
"[agent_loop] Parsed submission: {:?}", "[agent_loop] Parsed submission: {:?}",
std::any::type_name_of_val(&submission) std::any::type_name_of_val(&submission)
); );
@@ -786,10 +798,19 @@ impl Agent {
// Hydrate thread from DB if it's a historical thread not in memory // Hydrate thread from DB if it's a historical thread not in memory
if let Some(ref external_thread_id) = message.thread_id { if let Some(ref external_thread_id) = message.thread_id {
tracing::trace!(
message_id = %message.id,
thread_id = %external_thread_id,
"Hydrating thread from DB"
);
self.maybe_hydrate_thread(message, external_thread_id).await; self.maybe_hydrate_thread(message, external_thread_id).await;
} }
// Resolve session and thread // Resolve session and thread
tracing::debug!(
message_id = %message.id,
"Resolving session and thread"
);
let (session, thread_id) = self let (session, thread_id) = self
.session_manager .session_manager
.resolve_thread( .resolve_thread(
@@ -798,6 +819,11 @@ impl Agent {
message.thread_id.as_deref(), message.thread_id.as_deref(),
) )
.await; .await;
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
"Resolved session and thread"
);
// Auth mode interception: if the thread is awaiting a token, route // Auth mode interception: if the thread is awaiting a token, route
// the message directly to the credential store. Nothing touches // the message directly to the credential store. Nothing touches
@@ -827,7 +853,7 @@ impl Agent {
} }
} }
tracing::debug!( tracing::trace!(
"Received message from {} on {} ({} chars)", "Received message from {} on {} ({} chars)",
message.user_id, message.user_id,
message.channel, message.channel,
+587
View File
@@ -0,0 +1,587 @@
//! Unified agentic loop engine.
//!
//! Provides a single implementation of the core LLM call → tool execution →
//! result processing → context update → repeat cycle. Three consumers
//! (chat dispatcher, job worker, container runtime) customize behavior
//! via the `LoopDelegate` trait.
use async_trait::async_trait;
use crate::agent::session::PendingApproval;
use crate::error::Error;
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
/// Signal from the delegate indicating how the loop should proceed.
pub enum LoopSignal {
/// Continue normally.
Continue,
/// Stop the loop gracefully.
Stop,
/// Inject a user message into context and continue.
InjectMessage(String),
}
/// Outcome of a text response from the LLM.
pub enum TextAction {
/// Return this as the final loop result.
Return(LoopOutcome),
/// Continue the loop (text was handled but loop should proceed).
Continue,
}
/// Final outcome of the agentic loop.
pub enum LoopOutcome {
/// Completed with a text response.
Response(String),
/// Loop was stopped by a signal.
Stopped,
/// Max iterations exceeded.
MaxIterations,
/// A tool requires user approval before continuing (chat delegate only).
NeedApproval(Box<PendingApproval>),
}
/// Configuration for the agentic loop.
pub struct AgenticLoopConfig {
pub max_iterations: usize,
pub enable_tool_intent_nudge: bool,
pub max_tool_intent_nudges: u32,
}
impl Default for AgenticLoopConfig {
fn default() -> Self {
Self {
max_iterations: 50,
enable_tool_intent_nudge: true,
max_tool_intent_nudges: 2,
}
}
}
/// Strategy trait — each consumer implements this to customize I/O and lifecycle.
///
/// The shared loop calls these methods at well-defined points. Consumers
/// implement only the behavior that differs between chat, job, and container
/// contexts. The loop itself handles the common logic: tool intent nudge,
/// iteration counting, tool definition refresh, and the respond → execute → process cycle.
///
/// # `Send + Sync` requirement
///
/// This trait requires `Send + Sync` because the loop accepts `&dyn LoopDelegate`.
/// Delegates using borrowed references (e.g. `ChatDelegate<'a>`) must ensure all
/// borrowed fields are `Send + Sync`. This is a load-bearing constraint: if a
/// delegate needs to be spawned into a detached task, it must use `Arc`-based
/// ownership instead of borrows (as `JobDelegate` and `ContainerDelegate` do).
#[async_trait]
pub trait LoopDelegate: Send + Sync {
/// Called at the start of each iteration. Check for external signals
/// (cancellation, user messages, stop requests).
async fn check_signals(&self) -> LoopSignal;
/// Called before the LLM call. Allows the delegate to refresh tool
/// definitions, enforce cost guards, or inject messages.
/// Return `Some(outcome)` to break the loop early.
async fn before_llm_call(
&self,
reason_ctx: &mut ReasoningContext,
iteration: usize,
) -> Option<LoopOutcome>;
/// Call the LLM and return the result. Delegates own the LLM call
/// to handle consumer-specific concerns (rate limiting, auto-compaction,
/// cost tracking, force_text mode).
async fn call_llm(
&self,
reasoning: &Reasoning,
reason_ctx: &mut ReasoningContext,
iteration: usize,
) -> Result<crate::llm::RespondOutput, Error>;
/// Handle a text-only response from the LLM.
/// Return `TextAction::Return` to exit the loop, `TextAction::Continue` to proceed.
async fn handle_text_response(
&self,
text: &str,
reason_ctx: &mut ReasoningContext,
) -> TextAction;
/// Execute tool calls and add results to context.
/// Return `Some(outcome)` to break the loop (e.g. approval needed).
async fn execute_tool_calls(
&self,
tool_calls: Vec<crate::llm::ToolCall>,
content: Option<String>,
reason_ctx: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, Error>;
/// Called when the LLM expresses tool intent without actually calling a tool.
/// Delegates can use this to emit events or log the nudge for observability.
async fn on_tool_intent_nudge(&self, _text: &str, _reason_ctx: &mut ReasoningContext) {}
/// Called after each successful iteration (no error, no early return).
async fn after_iteration(&self, _iteration: usize) {}
}
/// Run the unified agentic loop.
///
/// This is the single implementation used by all three consumers (chat, job, container).
/// The `delegate` provides consumer-specific behavior via the `LoopDelegate` trait.
pub async fn run_agentic_loop(
delegate: &dyn LoopDelegate,
reasoning: &Reasoning,
reason_ctx: &mut ReasoningContext,
config: &AgenticLoopConfig,
) -> Result<LoopOutcome, Error> {
let mut consecutive_tool_intent_nudges: u32 = 0;
for iteration in 1..=config.max_iterations {
// Check for external signals (stop, cancellation, user messages)
match delegate.check_signals().await {
LoopSignal::Continue => {}
LoopSignal::Stop => return Ok(LoopOutcome::Stopped),
LoopSignal::InjectMessage(msg) => {
reason_ctx.messages.push(ChatMessage::user(&msg));
}
}
// Pre-LLM call hook (cost guard, tool refresh, iteration limit nudge)
if let Some(outcome) = delegate.before_llm_call(reason_ctx, iteration).await {
return Ok(outcome);
}
// Call LLM
let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?;
match output.result {
RespondResult::Text(text) => {
// Tool intent nudge: if the LLM says "let me search..." without
// actually calling a tool, inject a nudge message.
if config.enable_tool_intent_nudge
&& !reason_ctx.available_tools.is_empty()
&& !reason_ctx.force_text
&& consecutive_tool_intent_nudges < config.max_tool_intent_nudges
&& crate::llm::llm_signals_tool_intent(&text)
{
consecutive_tool_intent_nudges += 1;
tracing::info!(
iteration,
"LLM expressed tool intent without calling a tool, nudging"
);
delegate.on_tool_intent_nudge(&text, reason_ctx).await;
reason_ctx.messages.push(ChatMessage::assistant(&text));
reason_ctx
.messages
.push(ChatMessage::user(crate::llm::TOOL_INTENT_NUDGE));
delegate.after_iteration(iteration).await;
continue;
}
// Reset nudge counter since we got a non-intent text response
if !crate::llm::llm_signals_tool_intent(&text) {
consecutive_tool_intent_nudges = 0;
}
match delegate.handle_text_response(&text, reason_ctx).await {
TextAction::Return(outcome) => return Ok(outcome),
TextAction::Continue => {}
}
}
RespondResult::ToolCalls {
tool_calls,
content,
} => {
consecutive_tool_intent_nudges = 0;
if let Some(outcome) = delegate
.execute_tool_calls(tool_calls, content, reason_ctx)
.await?
{
return Ok(outcome);
}
}
}
delegate.after_iteration(iteration).await;
}
Ok(LoopOutcome::MaxIterations)
}
/// Truncate a string for log/status previews.
///
/// `max` is a byte budget. The result is truncated at the last valid char
/// boundary at or before `max` bytes, so it is always valid UTF-8.
pub fn truncate_for_preview(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let end = crate::util::floor_char_boundary(s, max);
format!("{}...", &s[..end])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::{RespondOutput, TokenUsage, ToolCall};
use crate::testing::StubLlm;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Mutex;
fn stub_reasoning() -> Reasoning {
Reasoning::new(Arc::new(StubLlm::default()))
}
fn zero_usage() -> TokenUsage {
TokenUsage {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
}
}
fn text_output(text: &str) -> RespondOutput {
RespondOutput {
result: RespondResult::Text(text.to_string()),
usage: zero_usage(),
}
}
fn tool_calls_output(calls: Vec<ToolCall>) -> RespondOutput {
RespondOutput {
result: RespondResult::ToolCalls {
tool_calls: calls,
content: None,
},
usage: zero_usage(),
}
}
/// Configurable mock delegate for testing run_agentic_loop.
struct MockDelegate {
signal: Mutex<LoopSignal>,
llm_responses: Mutex<Vec<RespondOutput>>,
tool_exec_count: AtomicUsize,
tool_exec_outcome: Mutex<Option<LoopOutcome>>,
iterations_seen: Mutex<Vec<usize>>,
early_exit: Mutex<Option<(usize, LoopOutcome)>>,
nudge_count: AtomicUsize,
}
impl MockDelegate {
fn new(responses: Vec<RespondOutput>) -> Self {
Self {
signal: Mutex::new(LoopSignal::Continue),
llm_responses: Mutex::new(responses),
tool_exec_count: AtomicUsize::new(0),
tool_exec_outcome: Mutex::new(None),
iterations_seen: Mutex::new(Vec::new()),
early_exit: Mutex::new(None),
nudge_count: AtomicUsize::new(0),
}
}
fn with_signal(mut self, signal: LoopSignal) -> Self {
self.signal = Mutex::new(signal);
self
}
fn with_early_exit(mut self, iteration: usize, outcome: LoopOutcome) -> Self {
self.early_exit = Mutex::new(Some((iteration, outcome)));
self
}
}
#[async_trait]
impl LoopDelegate for MockDelegate {
async fn check_signals(&self) -> LoopSignal {
let mut sig = self.signal.lock().await;
std::mem::replace(&mut *sig, LoopSignal::Continue)
}
async fn before_llm_call(
&self,
_reason_ctx: &mut ReasoningContext,
iteration: usize,
) -> Option<LoopOutcome> {
let mut guard = self.early_exit.lock().await;
let should_take = guard
.as_ref()
.is_some_and(|(target, _)| *target == iteration);
if should_take {
guard.take().map(|(_, o)| o)
} else {
None
}
}
async fn call_llm(
&self,
_reasoning: &Reasoning,
_reason_ctx: &mut ReasoningContext,
_iteration: usize,
) -> Result<crate::llm::RespondOutput, crate::error::Error> {
let mut responses = self.llm_responses.lock().await;
if responses.is_empty() {
panic!("MockDelegate: no more LLM responses queued");
}
Ok(responses.remove(0))
}
async fn handle_text_response(
&self,
text: &str,
_reason_ctx: &mut ReasoningContext,
) -> TextAction {
TextAction::Return(LoopOutcome::Response(text.to_string()))
}
async fn execute_tool_calls(
&self,
_tool_calls: Vec<ToolCall>,
_content: Option<String>,
reason_ctx: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, crate::error::Error> {
self.tool_exec_count.fetch_add(1, Ordering::SeqCst);
reason_ctx
.messages
.push(ChatMessage::user("tool result stub"));
let outcome = self.tool_exec_outcome.lock().await.take();
Ok(outcome)
}
async fn on_tool_intent_nudge(&self, _text: &str, _reason_ctx: &mut ReasoningContext) {
self.nudge_count.fetch_add(1, Ordering::SeqCst);
}
async fn after_iteration(&self, iteration: usize) {
self.iterations_seen.lock().await.push(iteration);
}
}
// --- Tests ---
#[tokio::test]
async fn test_text_response_returns_immediately() {
let delegate = MockDelegate::new(vec![text_output("Hello, world!")]);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
match outcome {
LoopOutcome::Response(text) => assert_eq!(text, "Hello, world!"),
_ => panic!("Expected LoopOutcome::Response"),
}
// after_iteration is NOT called when handle_text_response returns Return
// (the loop exits before reaching after_iteration).
assert!(delegate.iterations_seen.lock().await.is_empty());
}
#[tokio::test]
async fn test_tool_call_then_text_response() {
let tool_call = ToolCall {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
};
let delegate = MockDelegate::new(vec![
tool_calls_output(vec![tool_call]),
text_output("Done!"),
]);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
match outcome {
LoopOutcome::Response(text) => assert_eq!(text, "Done!"),
_ => panic!("Expected LoopOutcome::Response"),
}
assert_eq!(delegate.tool_exec_count.load(Ordering::SeqCst), 1);
// after_iteration called for iteration 1 (tool call), but not 2
// (text response exits before after_iteration).
assert_eq!(*delegate.iterations_seen.lock().await, vec![1]);
}
#[tokio::test]
async fn test_stop_signal_exits_immediately() {
let delegate =
MockDelegate::new(vec![text_output("unreachable")]).with_signal(LoopSignal::Stop);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Stopped));
assert!(delegate.iterations_seen.lock().await.is_empty());
}
#[tokio::test]
async fn test_inject_message_adds_user_message() {
let delegate = MockDelegate::new(vec![text_output("Got it")])
.with_signal(LoopSignal::InjectMessage("injected prompt".to_string()));
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Response(_)));
assert!(
ctx.messages
.iter()
.any(|m| m.role == crate::llm::Role::User && m.content.contains("injected prompt")),
"Injected message should appear in context"
);
}
#[tokio::test]
async fn test_max_iterations_reached() {
struct ContinueDelegate;
#[async_trait]
impl LoopDelegate for ContinueDelegate {
async fn check_signals(&self) -> LoopSignal {
LoopSignal::Continue
}
async fn before_llm_call(
&self,
_: &mut ReasoningContext,
_: usize,
) -> Option<LoopOutcome> {
None
}
async fn call_llm(
&self,
_: &Reasoning,
_: &mut ReasoningContext,
_: usize,
) -> Result<crate::llm::RespondOutput, crate::error::Error> {
Ok(text_output("still working"))
}
async fn handle_text_response(
&self,
_: &str,
ctx: &mut ReasoningContext,
) -> TextAction {
ctx.messages.push(ChatMessage::assistant("still working"));
TextAction::Continue
}
async fn execute_tool_calls(
&self,
_: Vec<ToolCall>,
_: Option<String>,
_: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, crate::error::Error> {
Ok(None)
}
}
let delegate = ContinueDelegate;
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig {
max_iterations: 3,
..Default::default()
};
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::MaxIterations));
let assistant_count = ctx
.messages
.iter()
.filter(|m| m.role == crate::llm::Role::Assistant)
.count();
assert_eq!(assistant_count, 3);
}
#[tokio::test]
async fn test_tool_intent_nudge_fires_and_caps() {
let delegate = MockDelegate::new(vec![
text_output("Let me search for that file"),
text_output("Let me search for that file"),
text_output("Let me search for that file"),
]);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
ctx.available_tools.push(crate::llm::ToolDefinition {
name: "search".to_string(),
description: "Search files".to_string(),
parameters: serde_json::json!({"type": "object"}),
});
let config = AgenticLoopConfig {
max_iterations: 10,
enable_tool_intent_nudge: true,
max_tool_intent_nudges: 2,
};
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Response(_)));
assert_eq!(delegate.nudge_count.load(Ordering::SeqCst), 2);
let nudge_messages = ctx
.messages
.iter()
.filter(|m| {
m.role == crate::llm::Role::User
&& m.content.contains("you did not include any tool calls")
})
.count();
assert_eq!(
nudge_messages, 2,
"Should have exactly 2 nudge messages in context"
);
}
#[tokio::test]
async fn test_before_llm_call_early_exit() {
let delegate = MockDelegate::new(vec![text_output("unreachable")])
.with_early_exit(1, LoopOutcome::Stopped);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Stopped));
assert!(delegate.iterations_seen.lock().await.is_empty());
}
#[test]
fn test_truncate_short_string_unchanged() {
assert_eq!(truncate_for_preview("hello", 10), "hello");
}
#[test]
fn test_truncate_long_string_adds_ellipsis() {
let result = truncate_for_preview("hello world", 5);
assert_eq!(result, "hello...");
}
#[test]
fn test_truncate_multibyte_safe() {
let result = truncate_for_preview("café", 4);
assert_eq!(result, "caf...");
}
}
+4 -2
View File
@@ -405,7 +405,8 @@ impl Agent {
.with_max_tokens(512) .with_max_tokens(512)
.with_temperature(0.3); .with_temperature(0.3);
let reasoning = Reasoning::new(self.llm().clone()); let reasoning =
Reasoning::new(self.llm().clone()).with_model_name(self.llm().active_model_name());
match reasoning.complete(request).await { match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!( Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Thread Summary:\n\n{}", "Thread Summary:\n\n{}",
@@ -453,7 +454,8 @@ impl Agent {
.with_max_tokens(512) .with_max_tokens(512)
.with_temperature(0.5); .with_temperature(0.5);
let reasoning = Reasoning::new(self.llm().clone()); let reasoning =
Reasoning::new(self.llm().clone()).with_model_name(self.llm().active_model_name());
match reasoning.complete(request).await { match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!( Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Suggested Next Steps:\n\n{}", "Suggested Next Steps:\n\n{}",
+2 -1
View File
@@ -227,7 +227,8 @@ Be brief but capture all important details. Use bullet points."#,
.with_max_tokens(1024) .with_max_tokens(1024)
.with_temperature(0.3); .with_temperature(0.3);
let reasoning = Reasoning::new(self.llm.clone()); let reasoning =
Reasoning::new(self.llm.clone()).with_model_name(self.llm.active_model_name());
let (text, _) = reasoning.complete(request).await?; let (text, _) = reasoning.complete(request).await?;
Ok(text) Ok(text)
} }
+711 -782
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -189,7 +189,7 @@ impl HeartbeatRunner {
// Skip during quiet hours // Skip during quiet hours
if self.config.is_quiet_hours() { if self.config.is_quiet_hours() {
tracing::debug!("Heartbeat skipped: quiet hours"); tracing::trace!("Heartbeat skipped: quiet hours");
continue; continue;
} }
@@ -212,7 +212,7 @@ impl HeartbeatRunner {
match self.check_heartbeat().await { match self.check_heartbeat().await {
HeartbeatResult::Ok => { HeartbeatResult::Ok => {
tracing::debug!("Heartbeat OK"); tracing::trace!("Heartbeat OK");
self.consecutive_failures = 0; self.consecutive_failures = 0;
} }
HeartbeatResult::NeedsAttention(message) => { HeartbeatResult::NeedsAttention(message) => {
@@ -221,7 +221,7 @@ impl HeartbeatRunner {
self.send_notification(&message).await; self.send_notification(&message).await;
} }
HeartbeatResult::Skipped => { HeartbeatResult::Skipped => {
tracing::debug!("Heartbeat skipped"); tracing::trace!("Heartbeat skipped");
} }
HeartbeatResult::Failed(error) => { HeartbeatResult::Failed(error) => {
tracing::error!("Heartbeat failed: {}", error); tracing::error!("Heartbeat failed: {}", error);
@@ -303,7 +303,8 @@ impl HeartbeatRunner {
.with_max_tokens(max_tokens) .with_max_tokens(max_tokens)
.with_temperature(0.3); .with_temperature(0.3);
let reasoning = Reasoning::new(self.llm.clone()); let reasoning =
Reasoning::new(self.llm.clone()).with_model_name(self.llm.active_model_name());
let (content, _usage) = match reasoning.complete(request).await { let (content, _usage) = match reasoning.complete(request).await {
Ok(r) => r, Ok(r) => r,
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)), Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
+3 -3
View File
@@ -11,6 +11,7 @@
//! - Context compaction for long conversations //! - Context compaction for long conversations
mod agent_loop; mod agent_loop;
pub mod agentic_loop;
mod attachments; mod attachments;
mod commands; mod commands;
pub mod compaction; pub mod compaction;
@@ -22,7 +23,7 @@ pub mod job_monitor;
mod router; mod router;
pub mod routine; pub mod routine;
pub mod routine_engine; pub mod routine_engine;
mod scheduler; pub(crate) mod scheduler;
mod self_repair; mod self_repair;
pub mod session; pub mod session;
mod session_manager; mod session_manager;
@@ -30,8 +31,8 @@ pub mod submission;
pub mod task; pub mod task;
mod thread_ops; mod thread_ops;
pub mod undo; pub mod undo;
pub mod worker;
pub use crate::worker::{Worker, WorkerDeps};
pub(crate) use agent_loop::truncate_for_preview; pub(crate) use agent_loop::truncate_for_preview;
pub use agent_loop::{Agent, AgentDeps}; pub use agent_loop::{Agent, AgentDeps};
pub use compaction::{CompactionResult, ContextCompactor}; pub use compaction::{CompactionResult, ContextCompactor};
@@ -47,4 +48,3 @@ pub use session_manager::SessionManager;
pub use submission::{Submission, SubmissionParser, SubmissionResult}; pub use submission::{Submission, SubmissionParser, SubmissionResult};
pub use task::{Task, TaskContext, TaskHandler, TaskOutput}; pub use task::{Task, TaskContext, TaskHandler, TaskOutput};
pub use undo::{Checkpoint, UndoManager}; pub use undo::{Checkpoint, UndoManager};
pub use worker::{Worker, WorkerDeps};
+86 -23
View File
@@ -8,7 +8,7 @@
//! ┌──────────┐ ┌─────────┐ ┌──────────────────┐ //! ┌──────────┐ ┌─────────┐ ┌──────────────────┐
//! │ Trigger │────▶│ Engine │────▶│ Execution Mode │ //! │ Trigger │────▶│ Engine │────▶│ Execution Mode │
//! │ cron/event│ │guardrail│ │lightweight│full_job│ //! │ cron/event│ │guardrail│ │lightweight│full_job│
//! │ webhook │ │ check │ └──────────────────┘ //! │ system │ │ check │ └──────────────────┘
//! │ manual │ └─────────┘ │ //! │ manual │ └─────────┘ │
//! └──────────┘ ▼ //! └──────────┘ ▼
//! ┌──────────────┐ //! ┌──────────────┐
@@ -69,12 +69,15 @@ pub enum Trigger {
/// Regex pattern to match against message content. /// Regex pattern to match against message content.
pattern: String, pattern: String,
}, },
/// Fire on incoming webhook POST to /hooks/routine/{id}. /// Fire when a structured system event is emitted.
Webhook { SystemEvent {
/// Optional webhook path suffix (defaults to routine id). /// Event source namespace (e.g. "github", "workflow", "tool").
path: Option<String>, source: String,
/// Optional shared secret for HMAC validation. /// Event type within the source (e.g. "issue.opened").
secret: Option<String>, event_type: String,
/// Optional exact-match filters against payload top-level fields.
#[serde(default)]
filters: std::collections::HashMap<String, String>,
}, },
/// Only fires via tool call or CLI. /// Only fires via tool call or CLI.
Manual, Manual,
@@ -86,7 +89,7 @@ impl Trigger {
match self { match self {
Trigger::Cron { .. } => "cron", Trigger::Cron { .. } => "cron",
Trigger::Event { .. } => "event", Trigger::Event { .. } => "event",
Trigger::Webhook { .. } => "webhook", Trigger::SystemEvent { .. } => "system_event",
Trigger::Manual => "manual", Trigger::Manual => "manual",
} }
} }
@@ -134,16 +137,39 @@ impl Trigger {
.map(String::from); .map(String::from);
Ok(Trigger::Event { channel, pattern }) Ok(Trigger::Event { channel, pattern })
} }
"webhook" => { "system_event" => {
let path = config let source = config
.get("path") .get("source")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from); .ok_or_else(|| RoutineError::MissingField {
let secret = config context: "system_event trigger".into(),
.get("secret") field: "source".into(),
})?
.to_string();
let event_type = config
.get("event_type")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from); .ok_or_else(|| RoutineError::MissingField {
Ok(Trigger::Webhook { path, secret }) context: "system_event trigger".into(),
field: "event_type".into(),
})?
.to_string();
let filters = config
.get("filters")
.and_then(|v| v.as_object())
.map(|m| {
m.iter()
.filter_map(|(k, v)| {
json_value_as_filter_string(v).map(|s| (k.clone(), s))
})
.collect()
})
.unwrap_or_default();
Ok(Trigger::SystemEvent {
source,
event_type,
filters,
})
} }
"manual" => Ok(Trigger::Manual), "manual" => Ok(Trigger::Manual),
other => Err(RoutineError::UnknownTriggerType { other => Err(RoutineError::UnknownTriggerType {
@@ -163,9 +189,14 @@ impl Trigger {
"pattern": pattern, "pattern": pattern,
"channel": channel, "channel": channel,
}), }),
Trigger::Webhook { path, secret } => serde_json::json!({ Trigger::SystemEvent {
"path": path, source,
"secret": secret, event_type,
filters,
} => serde_json::json!({
"source": source,
"event_type": event_type,
"filters": filters,
}), }),
Trigger::Manual => serde_json::json!({}), Trigger::Manual => serde_json::json!({}),
} }
@@ -428,6 +459,19 @@ pub struct RoutineRun {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// Convert a JSON value to a string for filter storage.
///
/// Handles strings, numbers, and booleans — consistent with the matching
/// logic in `routine_engine::json_value_as_string`.
pub fn json_value_as_filter_string(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
/// Compute a content hash for event dedup. /// Compute a content hash for event dedup.
pub fn content_hash(content: &str) -> u64 { pub fn content_hash(content: &str) -> u64 {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
@@ -486,6 +530,24 @@ mod tests {
if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+")); if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+"));
} }
#[test]
fn test_system_event_trigger_roundtrip() {
let mut filters = std::collections::HashMap::new();
filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
filters.insert("action".to_string(), "opened".to_string());
let trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue".to_string(),
filters: filters.clone(),
};
let json = trigger.to_config_json();
let parsed = Trigger::from_db("system_event", json).expect("parse system_event");
assert!(
matches!(parsed, Trigger::SystemEvent { source, event_type, filters: f }
if source == "github" && event_type == "issue" && f == filters)
);
}
#[test] #[test]
fn test_action_lightweight_roundtrip() { fn test_action_lightweight_roundtrip() {
let action = RoutineAction::Lightweight { let action = RoutineAction::Lightweight {
@@ -623,12 +685,13 @@ mod tests {
"event" "event"
); );
assert_eq!( assert_eq!(
Trigger::Webhook { Trigger::SystemEvent {
path: None, source: String::new(),
secret: None event_type: String::new(),
filters: std::collections::HashMap::new(),
} }
.type_tag(), .type_tag(),
"webhook" "system_event"
); );
assert_eq!(Trigger::Manual.type_tag(), "manual"); assert_eq!(Trigger::Manual.type_tag(), "manual");
} }
+117 -20
View File
@@ -32,9 +32,14 @@ use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest, ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
}; };
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, redact_params}; use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry};
use crate::workspace::Workspace; use crate::workspace::Workspace;
enum EventMatcher {
Message { routine: Routine, regex: Regex },
System { routine: Routine },
}
/// The routine execution engine. /// The routine execution engine.
pub struct RoutineEngine { pub struct RoutineEngine {
config: RoutineConfig, config: RoutineConfig,
@@ -45,8 +50,8 @@ pub struct RoutineEngine {
notify_tx: mpsc::Sender<OutgoingResponse>, notify_tx: mpsc::Sender<OutgoingResponse>,
/// Currently running routine count (across all routines). /// Currently running routine count (across all routines).
running_count: Arc<AtomicUsize>, running_count: Arc<AtomicUsize>,
/// Compiled event regex cache: routine_id -> compiled regex. /// Cached matchers for all event-driven routines.
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>, event_cache: Arc<RwLock<Vec<EventMatcher>>>,
/// Scheduler for dispatching jobs (FullJob mode). /// Scheduler for dispatching jobs (FullJob mode).
scheduler: Option<Arc<Scheduler>>, scheduler: Option<Arc<Scheduler>>,
/// Tool registry for lightweight routine tool execution. /// Tool registry for lightweight routine tool execution.
@@ -87,9 +92,12 @@ impl RoutineEngine {
Ok(routines) => { Ok(routines) => {
let mut cache = Vec::new(); let mut cache = Vec::new();
for routine in routines { for routine in routines {
if let Trigger::Event { ref pattern, .. } = routine.trigger { match &routine.trigger {
match Regex::new(pattern) { Trigger::Event { pattern, .. } => match Regex::new(pattern) {
Ok(re) => cache.push((routine.id, routine.clone(), re)), Ok(re) => cache.push(EventMatcher::Message {
routine: routine.clone(),
regex: re,
}),
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
routine = %routine.name, routine = %routine.name,
@@ -97,12 +105,18 @@ impl RoutineEngine {
pattern, e pattern, e
); );
} }
},
Trigger::SystemEvent { .. } => {
cache.push(EventMatcher::System {
routine: routine.clone(),
});
} }
_ => {}
} }
} }
let count = cache.len(); let count = cache.len();
*self.event_cache.write().await = cache; *self.event_cache.write().await = cache;
tracing::debug!("Refreshed event cache: {} routines", count); tracing::trace!("Refreshed event cache: {} routines", count);
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to refresh event cache: {}", e); tracing::error!("Failed to refresh event cache: {}", e);
@@ -118,7 +132,11 @@ impl RoutineEngine {
let cache = self.event_cache.read().await; let cache = self.event_cache.read().await;
let mut fired = 0; let mut fired = 0;
for (_, routine, re) in cache.iter() { for matcher in cache.iter() {
let (routine, re) = match matcher {
EventMatcher::Message { routine, regex } => (routine, regex),
EventMatcher::System { .. } => continue,
};
// Channel filter // Channel filter
if let Trigger::Event { if let Trigger::Event {
channel: Some(ch), .. channel: Some(ch), ..
@@ -135,13 +153,13 @@ impl RoutineEngine {
// Cooldown check // Cooldown check
if !self.check_cooldown(routine) { if !self.check_cooldown(routine) {
tracing::debug!(routine = %routine.name, "Skipped: cooldown active"); tracing::trace!(routine = %routine.name, "Skipped: cooldown active");
continue; continue;
} }
// Concurrent run check // Concurrent run check
if !self.check_concurrent(routine).await { if !self.check_concurrent(routine).await {
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached"); tracing::trace!(routine = %routine.name, "Skipped: max concurrent reached");
continue; continue;
} }
@@ -159,6 +177,88 @@ impl RoutineEngine {
fired fired
} }
/// Emit a structured event to system-event routines.
///
/// Returns the number of routines that were fired.
pub async fn emit_system_event(
&self,
source: &str,
event_type: &str,
payload: &serde_json::Value,
user_id: Option<&str>,
) -> usize {
let cache = self.event_cache.read().await;
let mut fired = 0;
for matcher in cache.iter() {
let routine = match matcher {
EventMatcher::System { routine } => routine,
EventMatcher::Message { .. } => continue,
};
let Trigger::SystemEvent {
source: expected_source,
event_type: expected_event,
filters,
} = &routine.trigger
else {
continue;
};
if !expected_source.eq_ignore_ascii_case(source)
|| !expected_event.eq_ignore_ascii_case(event_type)
{
continue;
}
if let Some(uid) = user_id
&& routine.user_id != uid
{
continue;
}
let mut matched = true;
for (key, expected) in filters {
let Some(actual) = payload
.get(key)
.and_then(crate::agent::routine::json_value_as_filter_string)
else {
tracing::debug!(routine = %routine.name, filter_key = %key, "Filter key not found in payload");
matched = false;
break;
};
if !actual.eq_ignore_ascii_case(expected) {
matched = false;
break;
}
}
if !matched {
continue;
}
if !self.check_cooldown(routine) {
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
continue;
}
if !self.check_concurrent(routine).await {
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
continue;
}
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached");
continue;
}
let detail = truncate(&format!("{source}:{event_type}"), 200);
self.spawn_fire(routine.clone(), "system_event", Some(detail));
fired += 1;
}
fired
}
/// 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 {
@@ -913,13 +1013,6 @@ async fn execute_routine_tool(
return Err(format!("Invalid tool parameters: {}", details).into()); return Err(format!("Invalid tool parameters: {}", details).into());
} }
let safe_params = redact_params(&tc.arguments, tool.sensitive_params());
tracing::debug!(
tool = %tc.name,
params = %safe_params,
"Lightweight routine tool call started"
);
// Execute with per-tool timeout // Execute with per-tool timeout
let timeout = tool.execution_timeout(); let timeout = tool.execution_timeout();
let start = std::time::Instant::now(); let start = std::time::Instant::now();
@@ -929,12 +1022,14 @@ async fn execute_routine_tool(
.await; .await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
// Log tool execution result (single consolidated log)
match &result { match &result {
Ok(Ok(_)) => { Ok(Ok(_)) => {
tracing::debug!( tracing::debug!(
tool = %tc.name, tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64, elapsed_ms = elapsed.as_millis() as u64,
"Lightweight routine tool call succeeded" status = "succeeded",
"Lightweight routine tool execution completed"
); );
} }
Ok(Err(e)) => { Ok(Err(e)) => {
@@ -942,7 +1037,8 @@ async fn execute_routine_tool(
tool = %tc.name, tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64, elapsed_ms = elapsed.as_millis() as u64,
error = %e, error = %e,
"Lightweight routine tool call failed" status = "failed",
"Lightweight routine tool execution completed"
); );
} }
Err(_) => { Err(_) => {
@@ -950,7 +1046,8 @@ async fn execute_routine_tool(
tool = %tc.name, tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64, elapsed_ms = elapsed.as_millis() as u64,
timeout_secs = timeout.as_secs(), timeout_secs = timeout.as_secs(),
"Lightweight routine tool call timed out" status = "timeout",
"Lightweight routine tool execution completed"
); );
} }
} }
+173 -43
View File
@@ -9,7 +9,6 @@ use tokio::task::JoinHandle;
use uuid::Uuid; use uuid::Uuid;
use crate::agent::task::{Task, TaskContext, TaskOutput}; use crate::agent::task::{Task, TaskContext, TaskOutput};
use crate::agent::worker::{Worker, WorkerDeps};
use crate::channels::web::types::SseEvent; use crate::channels::web::types::SseEvent;
use crate::config::AgentConfig; use crate::config::AgentConfig;
use crate::context::{ContextManager, JobContext, JobState}; use crate::context::{ContextManager, JobContext, JobState};
@@ -19,6 +18,7 @@ use crate::hooks::HookRegistry;
use crate::llm::LlmProvider; use crate::llm::LlmProvider;
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::tools::{ApprovalContext, ToolRegistry}; use crate::tools::{ApprovalContext, ToolRegistry};
use crate::worker::job::{Worker, WorkerDeps};
/// Message to send to a worker. /// Message to send to a worker.
#[derive(Debug)] #[derive(Debug)]
@@ -160,24 +160,36 @@ impl Scheduler {
.create_job_for_user(user_id, title, description) .create_job_for_user(user_id, title, description)
.await?; .await?;
// Apply token budget from config, allowing per-job metadata override. // Apply metadata and token budget in a single atomic update.
let max_tokens = metadata // This prevents concurrent workers from observing partial state.
// Cap user-supplied max_tokens at the configured limit (Issue #815).
let user_max_tokens = metadata
.as_ref() .as_ref()
.and_then(|m| m.get("max_tokens")) .and_then(|m| m.get("max_tokens"))
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64());
let max_tokens = user_max_tokens
.map(|user_val| {
if self.config.max_tokens_per_job == 0 {
// Config is "unlimited": use the user-supplied value directly.
user_val
} else {
std::cmp::min(user_val, self.config.max_tokens_per_job)
}
})
.unwrap_or(self.config.max_tokens_per_job); .unwrap_or(self.config.max_tokens_per_job);
// Apply metadata if provided // Apply both metadata and token budget in one closure (Issue #813: atomic update)
if let Some(meta) = metadata { if let Some(meta) = metadata {
self.context_manager self.context_manager
.update_context(job_id, |ctx| { .update_context(job_id, |ctx| {
ctx.metadata = meta; ctx.metadata = meta;
if max_tokens > 0 {
ctx.max_tokens = max_tokens;
}
}) })
.await?; .await?;
} } else if max_tokens > 0 {
// Set token budget (separate update to avoid overwriting metadata)
if max_tokens > 0 {
self.context_manager self.context_manager
.update_context(job_id, |ctx| { .update_context(job_id, |ctx| {
ctx.max_tokens = max_tokens; ctx.max_tokens = max_tokens;
@@ -462,6 +474,9 @@ impl Scheduler {
} }
/// Execute a single tool as a subtask. /// Execute a single tool as a subtask.
///
/// Performs scheduler-specific checks (approval, cancellation) then
/// delegates to the shared `execute_tool_with_safety` pipeline.
async fn execute_tool_task( async fn execute_tool_task(
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
context_manager: Arc<ContextManager>, context_manager: Arc<ContextManager>,
@@ -473,7 +488,7 @@ impl Scheduler {
) -> Result<TaskOutput, Error> { ) -> Result<TaskOutput, Error> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
// Get the tool // Get the tool for approval check
let tool = tools.get(tool_name).await.ok_or_else(|| { let tool = tools.get(tool_name).await.ok_or_else(|| {
Error::Tool(crate::error::ToolError::NotFound { Error::Tool(crate::error::ToolError::NotFound {
name: tool_name.to_string(), name: tool_name.to_string(),
@@ -490,6 +505,7 @@ impl Scheduler {
.into()); .into());
} }
// Scheduler-specific approval check
let requirement = tool.requires_approval(&params); let requirement = tool.requires_approval(&params);
let blocked = let blocked =
ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement); ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement);
@@ -500,41 +516,23 @@ impl Scheduler {
.into()); .into());
} }
// Validate tool parameters // Delegate to shared tool execution pipeline
let validation = safety.validator().validate_tool_params(&params); let output_str = crate::tools::execute::execute_tool_with_safety(
if !validation.is_valid { &tools, &safety, tool_name, &params, &job_ctx,
let details = validation )
.errors .await?;
.iter()
.map(|e| format!("{}: {}", e.field, e.message)) // Parse back to Value for TaskOutput; this should be infallible given
.collect::<Vec<_>>() // `execute_tool_with_safety` uses `serde_json::to_string_pretty`, but if it
.join("; "); // ever fails we surface a clear error instead of silently changing types.
return Err(crate::error::ToolError::InvalidParameters { let result_value: serde_json::Value = serde_json::from_str(&output_str).map_err(|e| {
Error::Tool(crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(), name: tool_name.to_string(),
reason: format!("Invalid tool parameters: {}", details), reason: format!("Failed to parse tool output as JSON: {}", e),
} })
.into()); })?;
}
// Execute with per-tool timeout Ok(TaskOutput::new(result_value, start.elapsed()))
let tool_timeout = tool.execution_timeout();
let result =
tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await })
.await
.map_err(|_| {
Error::Tool(crate::error::ToolError::Timeout {
name: tool_name.to_string(),
timeout: tool_timeout,
})
})?
.map_err(|e| {
Error::Tool(crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: e.to_string(),
})
})?;
Ok(TaskOutput::new(result.result, start.elapsed()))
} }
/// Stop a running job. /// Stop a running job.
@@ -699,8 +697,140 @@ impl Scheduler {
mod tests { mod tests {
use super::*; use super::*;
use crate::config::SafetyConfig; use crate::config::SafetyConfig;
use crate::llm::{
CompletionRequest, CompletionResponse, LlmError, LlmProvider, ToolCompletionRequest,
ToolCompletionResponse,
};
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput}; use crate::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput};
use rust_decimal_macros::dec;
/// Minimal LLM provider stub for scheduler tests that don't exercise LLM calls.
struct StubLlm;
#[async_trait::async_trait]
impl LlmProvider for StubLlm {
fn model_name(&self) -> &str {
"stub"
}
fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) {
(dec!(0), dec!(0))
}
async fn complete(&self, _req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
Err(LlmError::RequestFailed {
provider: "stub".into(),
reason: "not implemented".into(),
})
}
async fn complete_with_tools(
&self,
_req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
Err(LlmError::RequestFailed {
provider: "stub".into(),
reason: "not implemented".into(),
})
}
}
/// Create a Scheduler for token-budget tests. The LLM stub will fail if a
/// worker actually tries to call it, but `dispatch_job` sets the token
/// budget *before* spawning the worker so we can inspect the context
/// immediately after dispatch.
fn make_test_scheduler(max_tokens_per_job: u64) -> Scheduler {
let config = AgentConfig {
name: "test".to_string(),
max_parallel_jobs: 5,
job_timeout: std::time::Duration::from_secs(30),
stuck_threshold: std::time::Duration::from_secs(300),
repair_check_interval: std::time::Duration::from_secs(3600),
max_repair_attempts: 0,
use_planning: false,
session_idle_timeout: std::time::Duration::from_secs(3600),
allow_local_tools: true,
max_cost_per_day_cents: None,
max_actions_per_hour: None,
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_tokens_per_job,
};
let cm = Arc::new(ContextManager::new(5));
let llm: Arc<dyn LlmProvider> = Arc::new(StubLlm);
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let tools = Arc::new(ToolRegistry::new());
let hooks = Arc::new(HookRegistry::default());
Scheduler::new(config, cm, llm, safety, tools, None, hooks)
}
#[tokio::test]
async fn test_dispatch_job_caps_user_max_tokens() {
let sched = make_test_scheduler(1000);
let meta = serde_json::json!({ "max_tokens": 5000 });
let job_id = sched
.dispatch_job("user1", "test", "desc", Some(meta))
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(ctx.max_tokens, 1000, "should cap at configured limit");
}
#[tokio::test]
async fn test_dispatch_job_unlimited_config_preserves_user_tokens() {
let sched = make_test_scheduler(0); // 0 = unlimited
let meta = serde_json::json!({ "max_tokens": 5000 });
let job_id = sched
.dispatch_job("user1", "test", "desc", Some(meta))
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(
ctx.max_tokens, 5000,
"unlimited config should preserve user value"
);
}
#[tokio::test]
async fn test_dispatch_job_no_user_tokens_uses_config() {
let sched = make_test_scheduler(2000);
let job_id = sched
.dispatch_job("user1", "test", "desc", None)
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(
ctx.max_tokens, 2000,
"should use config default when no user value"
);
}
#[tokio::test]
async fn test_dispatch_job_atomic_metadata_and_tokens() {
let sched = make_test_scheduler(10_000);
let meta = serde_json::json!({
"max_tokens": 3000,
"custom_key": "custom_value"
});
let job_id = sched
.dispatch_job("user1", "test", "desc", Some(meta))
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(ctx.max_tokens, 3000, "should use user value within limit");
assert_eq!(
ctx.metadata.get("custom_key").and_then(|v| v.as_str()),
Some("custom_value"),
"metadata should be set atomically with token budget"
);
}
#[test] #[test]
fn test_scheduler_creation() { fn test_scheduler_creation() {
+7 -9
View File
@@ -334,22 +334,21 @@ impl RepairTask {
// Check for stuck jobs // Check for stuck jobs
let stuck_jobs = self.repair.detect_stuck_jobs().await; let stuck_jobs = self.repair.detect_stuck_jobs().await;
for job in stuck_jobs { for job in stuck_jobs {
tracing::info!("Attempting to repair stuck job {}", job.job_id);
match self.repair.repair_stuck_job(&job).await { match self.repair.repair_stuck_job(&job).await {
Ok(RepairResult::Success { message }) => { Ok(RepairResult::Success { message }) => {
tracing::info!("Repair succeeded: {}", message); tracing::info!(job = %job.job_id, status = "success", "Stuck job repair completed: {}", message);
} }
Ok(RepairResult::Retry { message }) => { Ok(RepairResult::Retry { message }) => {
tracing::warn!("Repair needs retry: {}", message); tracing::debug!(job = %job.job_id, status = "retry", "Stuck job repair needs retry: {}", message);
} }
Ok(RepairResult::Failed { message }) => { Ok(RepairResult::Failed { message }) => {
tracing::error!("Repair failed: {}", message); tracing::error!(job = %job.job_id, status = "failed", "Stuck job repair failed: {}", message);
} }
Ok(RepairResult::ManualRequired { message }) => { Ok(RepairResult::ManualRequired { message }) => {
tracing::warn!("Manual intervention needed: {}", message); tracing::warn!(job = %job.job_id, status = "manual", "Stuck job repair requires manual intervention: {}", message);
} }
Err(e) => { Err(e) => {
tracing::error!("Repair error: {}", e); tracing::error!(job = %job.job_id, "Stuck job repair error: {}", e);
} }
} }
} }
@@ -357,13 +356,12 @@ impl RepairTask {
// Check for broken tools // Check for broken tools
let broken_tools = self.repair.detect_broken_tools().await; let broken_tools = self.repair.detect_broken_tools().await;
for tool in broken_tools { for tool in broken_tools {
tracing::info!("Attempting to repair broken tool: {}", tool.name);
match self.repair.repair_broken_tool(&tool).await { match self.repair.repair_broken_tool(&tool).await {
Ok(result) => { Ok(result) => {
tracing::info!("Tool repair result: {:?}", result); tracing::debug!(tool = %tool.name, status = "completed", "Tool repair completed: {:?}", result);
} }
Err(e) => { Err(e) => {
tracing::error!("Tool repair error: {}", e); tracing::error!(tool = %tool.name, "Tool repair error: {}", e);
} }
} }
} }
+52 -24
View File
@@ -113,6 +113,13 @@ impl Agent {
thread_id: Uuid, thread_id: Uuid,
content: &str, content: &str,
) -> Result<SubmissionResult, Error> { ) -> Result<SubmissionResult, Error> {
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
content_len = content.len(),
"Processing user input"
);
// First check thread state without holding lock during I/O // First check thread state without holding lock during I/O
let thread_state = { let thread_state = {
let sess = session.lock().await; let sess = session.lock().await;
@@ -123,19 +130,41 @@ impl Agent {
thread.state thread.state
}; };
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
thread_state = ?thread_state,
"Checked thread state"
);
// Check thread state // Check thread state
match thread_state { match thread_state {
ThreadState::Processing => { ThreadState::Processing => {
tracing::warn!(
message_id = %message.id,
thread_id = %thread_id,
"Thread is processing, rejecting new input"
);
return Ok(SubmissionResult::error( return Ok(SubmissionResult::error(
"Turn in progress. Use /interrupt to cancel.", "Turn in progress. Use /interrupt to cancel.",
)); ));
} }
ThreadState::AwaitingApproval => { ThreadState::AwaitingApproval => {
tracing::warn!(
message_id = %message.id,
thread_id = %thread_id,
"Thread awaiting approval, rejecting new input"
);
return Ok(SubmissionResult::error( return Ok(SubmissionResult::error(
"Waiting for approval. Use /interrupt to cancel.", "Waiting for approval. Use /interrupt to cancel.",
)); ));
} }
ThreadState::Completed => { ThreadState::Completed => {
tracing::warn!(
message_id = %message.id,
thread_id = %thread_id,
"Thread completed, rejecting new input"
);
return Ok(SubmissionResult::error( return Ok(SubmissionResult::error(
"Thread completed. Use /thread new.", "Thread completed. Use /thread new.",
)); ));
@@ -269,9 +298,20 @@ impl Agent {
}; };
// Persist user message to DB immediately so it survives crashes // Persist user message to DB immediately so it survives crashes
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
"Persisting user message to DB"
);
self.persist_user_message(thread_id, &message.user_id, effective_content) self.persist_user_message(thread_id, &message.user_id, effective_content)
.await; .await;
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
"User message persisted, starting agentic loop"
);
// Send thinking status // Send thinking status
let _ = self let _ = self
.channels .channels
@@ -812,19 +852,12 @@ impl Agent {
// Sanitize tool result, then record the cleaned version in the // Sanitize tool result, then record the cleaned version in the
// thread. Must happen before auth intercept check which may return early. // thread. Must happen before auth intercept check which may return early.
let is_tool_error = tool_result.is_err(); let is_tool_error = tool_result.is_err();
let result_content = match &tool_result { let (result_content, _) = crate::tools::execute::process_tool_result(
Ok(output) => { self.safety(),
let sanitized = self &pending.tool_name,
.safety() &pending.tool_call_id,
.sanitize_tool_output(&pending.tool_name, output); &tool_result,
self.safety().wrap_for_llm( );
&pending.tool_name,
&sanitized.content,
sanitized.was_modified,
)
}
Err(e) => format!("Error: {}", e),
};
// Record sanitized result in thread // Record sanitized result in thread
{ {
@@ -1064,17 +1097,12 @@ impl Agent {
// Sanitize first, then record the cleaned version in thread. // Sanitize first, then record the cleaned version in thread.
// Must happen before auth detection which may set deferred_auth. // Must happen before auth detection which may set deferred_auth.
let is_deferred_error = deferred_result.is_err(); let is_deferred_error = deferred_result.is_err();
let deferred_content = match &deferred_result { let (deferred_content, _) = crate::tools::execute::process_tool_result(
Ok(output) => { self.safety(),
let sanitized = self.safety().sanitize_tool_output(&tc.name, output); &tc.name,
self.safety().wrap_for_llm( &tc.id,
&tc.name, &deferred_result,
&sanitized.content, );
sanitized.was_modified,
)
}
Err(e) => format!("Error: {}", e),
};
// Record sanitized result in thread // Record sanitized result in thread
{ {
+10 -1
View File
@@ -572,7 +572,7 @@ impl AppBuilder {
let (dev_loaded_tool_names, _) = tokio::join!(wasm_tools_future, mcp_servers_future); let (dev_loaded_tool_names, _) = tokio::join!(wasm_tools_future, mcp_servers_future);
// Load registry catalog entries for extension discovery // Load registry catalog entries for extension discovery
let catalog_entries = match crate::registry::RegistryCatalog::load_or_embedded() { let mut catalog_entries = match crate::registry::RegistryCatalog::load_or_embedded() {
Ok(catalog) => { Ok(catalog) => {
let entries: Vec<_> = catalog let entries: Vec<_> = catalog
.all() .all()
@@ -591,6 +591,15 @@ impl AppBuilder {
} }
}; };
// Append builtin entries (e.g. channel-relay integrations) so they appear
// in the web UI's available extensions list.
let builtin = crate::extensions::registry::builtin_entries();
for entry in builtin {
if !catalog_entries.iter().any(|e| e.name == entry.name) {
catalog_entries.push(entry);
}
}
// Create extension manager. Use ephemeral in-memory secrets if no // Create extension manager. Use ephemeral in-memory secrets if no
// persistent store is configured (listing/install/activate still work). // persistent store is configured (listing/install/activate still work).
let ext_secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> = if let Some(ref s) = let ext_secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> = if let Some(ref s) =
+18
View File
@@ -344,6 +344,24 @@ pub trait Channel: Send + Sync {
} }
} }
/// Trait for channels that support hot-secret-swapping during SIGHUP reload.
///
/// This allows channels to update authentication credentials without restarting,
/// enabling zero-downtime configuration reloads. Channels that don't support
/// secret updates can simply not implement this trait.
#[async_trait]
pub trait ChannelSecretUpdater: Send + Sync {
/// Update the secret for this channel.
///
/// Called during SIGHUP configuration reload. Implementation should:
/// - Apply the new secret atomically
/// - Not fail the entire reload if secret update fails
/// - Log appropriate errors/info messages
///
/// The secret is optional (may be None if secret is no longer configured).
async fn update_secret(&self, new_secret: Option<secrecy::SecretString>);
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+202 -13
View File
@@ -10,7 +10,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
routing::{get, post}, routing::{get, post},
}; };
use secrecy::ExposeSecret; use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use tokio::sync::{RwLock, mpsc, oneshot}; use tokio::sync::{RwLock, mpsc, oneshot};
@@ -18,7 +18,8 @@ use tokio_stream::wrappers::ReceiverStream;
use uuid::Uuid; use uuid::Uuid;
use crate::channels::{ use crate::channels::{
AttachmentKind, Channel, IncomingAttachment, IncomingMessage, MessageStream, OutgoingResponse, AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
MessageStream, OutgoingResponse,
}; };
use crate::config::HttpConfig; use crate::config::HttpConfig;
use crate::error::ChannelError; use crate::error::ChannelError;
@@ -29,13 +30,16 @@ pub struct HttpChannel {
state: Arc<HttpChannelState>, state: Arc<HttpChannelState>,
} }
struct HttpChannelState { pub struct HttpChannelState {
/// Sender for incoming messages. /// Sender for incoming messages.
tx: RwLock<Option<mpsc::Sender<IncomingMessage>>>, tx: RwLock<Option<mpsc::Sender<IncomingMessage>>>,
/// Pending responses keyed by message ID. /// Pending responses keyed by message ID.
pending_responses: RwLock<std::collections::HashMap<Uuid, oneshot::Sender<String>>>, pending_responses: RwLock<std::collections::HashMap<Uuid, oneshot::Sender<String>>>,
/// Expected webhook secret for authentication (if configured). /// Expected webhook secret for authentication (if configured).
webhook_secret: Option<String>, /// Stored in a separate Arc<RwLock<>> to avoid contending with other state operations.
/// Rarely changes (only on SIGHUP), so isolated from hot-path state accesses.
/// Uses SecretString to prevent accidental logging and memory dump exposure.
webhook_secret: Arc<RwLock<Option<SecretString>>>,
/// Fixed user ID for this HTTP channel. /// Fixed user ID for this HTTP channel.
user_id: String, user_id: String,
/// Rate limiting state. /// Rate limiting state.
@@ -48,6 +52,14 @@ struct RateLimitState {
request_count: u32, request_count: u32,
} }
impl HttpChannelState {
/// Update the webhook secret in-place without restarting the listener.
/// Called during SIGHUP to hot-swap credentials.
pub async fn update_secret(&self, new_secret: Option<SecretString>) {
*self.webhook_secret.write().await = new_secret;
}
}
/// Maximum JSON body size for webhook requests (15 MB, to support base64 image attachments /// Maximum JSON body size for webhook requests (15 MB, to support base64 image attachments
/// with ~33% overhead from base64 encoding). /// with ~33% overhead from base64 encoding).
const MAX_BODY_BYTES: usize = 15 * 1024 * 1024; const MAX_BODY_BYTES: usize = 15 * 1024 * 1024;
@@ -67,7 +79,7 @@ impl HttpChannel {
let webhook_secret = config let webhook_secret = config
.webhook_secret .webhook_secret
.as_ref() .as_ref()
.map(|s| s.expose_secret().to_string()); .map(|s| SecretString::from(s.expose_secret().to_string()));
let user_id = config.user_id.clone(); let user_id = config.user_id.clone();
Self { Self {
@@ -75,7 +87,7 @@ impl HttpChannel {
state: Arc::new(HttpChannelState { state: Arc::new(HttpChannelState {
tx: RwLock::new(None), tx: RwLock::new(None),
pending_responses: RwLock::new(std::collections::HashMap::new()), pending_responses: RwLock::new(std::collections::HashMap::new()),
webhook_secret, webhook_secret: Arc::new(RwLock::new(webhook_secret)),
user_id, user_id,
rate_limit: tokio::sync::Mutex::new(RateLimitState { rate_limit: tokio::sync::Mutex::new(RateLimitState {
window_start: std::time::Instant::now(), window_start: std::time::Instant::now(),
@@ -102,6 +114,16 @@ impl HttpChannel {
pub fn addr(&self) -> (&str, u16) { pub fn addr(&self) -> (&str, u16) {
(&self.config.host, self.config.port) (&self.config.host, self.config.port)
} }
/// Return a shared handle to the channel state for out-of-band updates.
pub fn shared_state(&self) -> Arc<HttpChannelState> {
Arc::clone(&self.state)
}
/// Update the webhook secret in-place without restarting the listener.
pub async fn update_secret(&self, new_secret: Option<SecretString>) {
self.state.update_secret(new_secret).await;
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -201,9 +223,10 @@ async fn webhook_handler(
}); });
// Validate secret if configured // Validate secret if configured
if let Some(ref expected_secret) = state.webhook_secret { if let Some(ref expected_secret) = *state.webhook_secret.read().await {
let expected_bytes = expected_secret.expose_secret().as_bytes();
match &req.secret { match &req.secret {
Some(provided) if bool::from(provided.as_bytes().ct_eq(expected_secret.as_bytes())) => { Some(provided) if bool::from(provided.as_bytes().ct_eq(expected_bytes)) => {
// Secret matches, continue // Secret matches, continue
} }
Some(_) => { Some(_) => {
@@ -372,9 +395,14 @@ async fn process_message(
None None
}; };
// Send message to the channel // Clone sender while holding read lock, then release lock before async send.
let tx_guard = state.tx.read().await; // This prevents blocking other webhook handlers during the async I/O.
if let Some(tx) = tx_guard.as_ref() { let tx = {
let guard = state.tx.read().await;
guard.as_ref().cloned()
};
if let Some(tx) = tx {
if tx.send(msg).await.is_err() { if tx.send(msg).await.is_err() {
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -395,7 +423,6 @@ async fn process_message(
}), }),
); );
} }
drop(tx_guard);
// Wait for response if requested // Wait for response if requested
let response = if let Some(rx) = response_rx { let response = if let Some(rx) = response_rx {
@@ -428,7 +455,7 @@ impl Channel for HttpChannel {
} }
async fn start(&self) -> Result<MessageStream, ChannelError> { async fn start(&self) -> Result<MessageStream, ChannelError> {
if self.state.webhook_secret.is_none() { if self.state.webhook_secret.read().await.is_none() {
return Err(ChannelError::StartupFailed { return Err(ChannelError::StartupFailed {
name: "http".to_string(), name: "http".to_string(),
reason: "HTTP webhook secret is required (set HTTP_WEBHOOK_SECRET)".to_string(), reason: "HTTP webhook secret is required (set HTTP_WEBHOOK_SECRET)".to_string(),
@@ -475,6 +502,16 @@ impl Channel for HttpChannel {
} }
} }
/// Implement secret update for HTTP channel state.
/// This allows SIGHUP handler to update secrets generically via the trait.
#[async_trait]
impl ChannelSecretUpdater for HttpChannelState {
async fn update_secret(&self, new_secret: Option<SecretString>) {
*self.webhook_secret.write().await = new_secret;
tracing::info!("HTTP webhook secret updated");
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use axum::body::Body; use axum::body::Body;
@@ -562,4 +599,156 @@ mod tests {
let resp = app.oneshot(req).await.unwrap(); let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn test_update_secret_hot_swap() {
let channel = test_channel(Some("old-secret"));
let _stream = channel.start().await.unwrap();
let app1 = channel.routes();
// Request with old-secret should succeed
let body_old = serde_json::json!({
"content": "hello",
"secret": "old-secret"
});
let req1 = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body_old).unwrap()))
.unwrap();
let resp1 = app1.oneshot(req1).await.unwrap();
assert_eq!(
resp1.status(),
StatusCode::OK,
"old secret should work initially"
);
// Update secret to new-secret
channel
.update_secret(Some(SecretString::from("new-secret".to_string())))
.await;
let app2 = channel.routes();
// Request with old-secret should fail
let req2 = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body_old).unwrap()))
.unwrap();
let resp2 = app2.oneshot(req2).await.unwrap();
assert_eq!(
resp2.status(),
StatusCode::UNAUTHORIZED,
"old secret should fail after update"
);
let app3 = channel.routes();
// Request with new-secret should succeed
let body_new = serde_json::json!({
"content": "hello",
"secret": "new-secret"
});
let req3 = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body_new).unwrap()))
.unwrap();
let resp3 = app3.oneshot(req3).await.unwrap();
assert_eq!(
resp3.status(),
StatusCode::OK,
"new secret should work after update"
);
}
#[tokio::test]
async fn test_concurrent_requests_during_secret_update() {
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
let channel = test_channel(Some("initial-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
// Counters for request outcomes
let success_count = StdArc::new(AtomicUsize::new(0));
let mut handles = vec![];
// Spawn 5 concurrent tasks that keep making requests with the initial secret
for i in 0..5 {
let app = app.clone();
let success = StdArc::clone(&success_count);
let handle = tokio::spawn(async move {
let body = serde_json::json!({
"content": format!("test-{}", i),
"secret": "initial-secret"
});
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success.fetch_add(1, Ordering::SeqCst);
}
});
handles.push(handle);
}
// Update secret mid-flight (tests that RwLock allows readers while writer holds lock)
tokio::time::sleep(Duration::from_millis(5)).await;
channel
.update_secret(Some(SecretString::from("updated-secret".to_string())))
.await;
// Spawn 5 more tasks that use the new secret
for i in 5..10 {
let app = app.clone();
let success = StdArc::clone(&success_count);
let handle = tokio::spawn(async move {
let body = serde_json::json!({
"content": format!("test-{}", i),
"secret": "updated-secret"
});
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success.fetch_add(1, Ordering::SeqCst);
}
});
handles.push(handle);
}
// Wait for all tasks to complete
for handle in handles {
let _ = handle.await;
}
// Verify all requests succeeded with their respective secrets
assert_eq!(
success_count.load(Ordering::SeqCst),
10,
"All concurrent requests should succeed with correct secrets after update"
);
}
} }
+37
View File
@@ -56,6 +56,17 @@ impl ChannelManager {
/// the agent loop. /// the agent loop.
pub async fn hot_add(&self, channel: Box<dyn Channel>) -> Result<(), ChannelError> { pub async fn hot_add(&self, channel: Box<dyn Channel>) -> Result<(), ChannelError> {
let name = channel.name().to_string(); let name = channel.name().to_string();
// Shut down any existing channel with the same name to avoid parallel consumers.
// The old forwarding task will stop when the channel's stream ends after shutdown.
{
let channels = self.channels.read().await;
if let Some(existing) = channels.get(&name) {
tracing::debug!(channel = %name, "Shutting down existing channel before hot-add replacement");
let _ = existing.shutdown().await;
}
}
let stream = channel.start().await?; let stream = channel.start().await?;
// Register for respond/broadcast/send_status // Register for respond/broadcast/send_status
@@ -337,4 +348,30 @@ mod tests {
let msg = stream.next().await.expect("stream ended"); let msg = stream.next().await.expect("stream ended");
assert_eq!(msg.content, "background alert"); assert_eq!(msg.content, "background alert");
} }
#[tokio::test]
async fn test_hot_add_replaces_existing_channel() {
// Regression: hot_add must shut down the existing channel before replacing it,
// to prevent duplicate SSE consumers from running in parallel.
let manager = ChannelManager::new();
let (stub1, _tx1) = StubChannel::new("relay");
manager.add(Box::new(stub1)).await;
let mut stream = manager.start_all().await.expect("start_all");
// Hot-add a replacement channel with the same name
let (stub2, tx2) = StubChannel::new("relay");
manager.hot_add(Box::new(stub2)).await.expect("hot_add");
// Send through the new channel — should arrive in the merged stream
tx2.send(IncomingMessage::new("relay", "u1", "from new"))
.await
.expect("send");
let msg = stream.next().await.expect("stream");
assert_eq!(msg.content, "from new");
// Verify only one channel entry exists
let channels = manager.channels.read().await;
assert_eq!(channels.len(), 1);
assert!(channels.contains_key("relay"));
}
} }
+4 -3
View File
@@ -30,6 +30,7 @@
mod channel; mod channel;
mod http; mod http;
mod manager; mod manager;
pub mod relay;
mod repl; mod repl;
mod signal; mod signal;
pub mod wasm; pub mod wasm;
@@ -37,10 +38,10 @@ pub mod web;
mod webhook_server; mod webhook_server;
pub use channel::{ pub use channel::{
AttachmentKind, Channel, IncomingAttachment, IncomingMessage, MessageStream, OutgoingResponse, AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
StatusUpdate, MessageStream, OutgoingResponse, StatusUpdate,
}; };
pub use http::HttpChannel; pub use http::{HttpChannel, HttpChannelState};
pub use manager::ChannelManager; pub use manager::ChannelManager;
pub use repl::ReplChannel; pub use repl::ReplChannel;
pub use signal::SignalChannel; pub use signal::SignalChannel;
+642
View File
@@ -0,0 +1,642 @@
//! Channel trait implementation for channel-relay SSE streams.
//!
//! `RelayChannel` connects to a channel-relay service via SSE, converts
//! incoming events to `IncomingMessage`s, and sends responses via the
//! relay's provider-specific proxy API (Slack).
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::{RwLock, mpsc};
use crate::channels::relay::client::{RelayClient, RelayError};
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
/// Default channel name for the Slack relay integration.
pub const DEFAULT_RELAY_NAME: &str = "slack-relay";
/// The messaging provider backing a relay channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayProvider {
Slack,
}
impl RelayProvider {
/// Provider string used in proxy API routes and metadata.
pub fn as_str(&self) -> &'static str {
match self {
Self::Slack => "slack",
}
}
/// The default channel name for this provider.
pub fn channel_name(&self) -> &'static str {
match self {
Self::Slack => DEFAULT_RELAY_NAME,
}
}
}
/// Channel implementation that connects to a channel-relay SSE stream.
pub struct RelayChannel {
client: RelayClient,
provider: RelayProvider,
stream_token: Arc<RwLock<String>>,
team_id: String,
instance_id: String,
user_id: String,
/// SSE stream long-poll timeout in seconds.
stream_timeout_secs: u64,
/// Initial exponential backoff in milliseconds.
backoff_initial_ms: u64,
/// Maximum exponential backoff in milliseconds.
backoff_max_ms: u64,
/// Handle to the reconnect task for clean shutdown.
reconnect_handle: RwLock<Option<tokio::task::JoinHandle<()>>>,
/// Handle to the SSE parser task for clean shutdown.
parser_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
/// Maximum consecutive reconnect failures before giving up.
max_consecutive_failures: u64,
}
impl RelayChannel {
/// Create a new relay channel for Slack (default provider).
pub fn new(
client: RelayClient,
stream_token: String,
team_id: String,
instance_id: String,
user_id: String,
) -> Self {
Self::new_with_provider(
client,
RelayProvider::Slack,
stream_token,
team_id,
instance_id,
user_id,
)
}
/// Create a new relay channel with a specific provider.
pub fn new_with_provider(
client: RelayClient,
provider: RelayProvider,
stream_token: String,
team_id: String,
instance_id: String,
user_id: String,
) -> Self {
Self {
client,
provider,
stream_token: Arc::new(RwLock::new(stream_token)),
team_id,
instance_id,
user_id,
stream_timeout_secs: 86400,
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
reconnect_handle: RwLock::new(None),
parser_handle: Arc::new(RwLock::new(None)),
max_consecutive_failures: 50,
}
}
/// Set backoff/timeout parameters from relay config values.
pub fn with_timeouts(
mut self,
stream_timeout_secs: u64,
backoff_initial_ms: u64,
backoff_max_ms: u64,
) -> Self {
self.stream_timeout_secs = stream_timeout_secs;
self.backoff_initial_ms = backoff_initial_ms;
self.backoff_max_ms = backoff_max_ms;
self
}
/// Set the maximum number of consecutive reconnect failures before giving up.
pub fn with_max_failures(mut self, max: u64) -> Self {
self.max_consecutive_failures = max;
self
}
/// Build a provider-appropriate proxy body for sending a message.
fn build_send_body(
&self,
channel_id: &str,
text: &str,
thread_id: Option<&str>,
) -> (String, serde_json::Value) {
match self.provider {
RelayProvider::Slack => {
let mut body = serde_json::json!({
"channel": channel_id,
"text": text,
});
if let Some(tid) = thread_id {
body["thread_ts"] = serde_json::Value::String(tid.to_string());
}
("chat.postMessage".to_string(), body)
}
}
}
/// Send a message via the provider proxy.
async fn proxy_send(
&self,
team_id: &str,
method: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> {
self.client
.proxy_provider(
self.provider.as_str(),
team_id,
method,
body,
Some(&self.instance_id),
)
.await
}
}
#[async_trait]
impl Channel for RelayChannel {
fn name(&self) -> &str {
self.provider.channel_name()
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
let channel_name = self.name().to_string();
let token = self.stream_token.read().await.clone();
let (stream, initial_parser_handle) = self
.client
.connect_stream(&token, self.stream_timeout_secs)
.await
.map_err(|e| ChannelError::StartupFailed {
name: channel_name.clone(),
reason: e.to_string(),
})?;
*self.parser_handle.write().await = Some(initial_parser_handle);
let (tx, rx) = mpsc::channel(64);
// Spawn the stream reader + reconnect task
let client = self.client.clone();
let stream_token = Arc::clone(&self.stream_token);
let instance_id = self.instance_id.clone();
let user_id = self.user_id.clone();
let team_id = self.team_id.clone();
let stream_timeout_secs = self.stream_timeout_secs;
let backoff_initial_ms = self.backoff_initial_ms;
let backoff_max_ms = self.backoff_max_ms;
let max_consecutive_failures = self.max_consecutive_failures;
let parser_handle = Arc::clone(&self.parser_handle);
let provider_str = self.provider.as_str().to_string();
let relay_name = channel_name.clone();
let handle = tokio::spawn(async move {
use futures::StreamExt;
let mut current_stream = stream;
let mut backoff_ms = backoff_initial_ms;
let mut consecutive_failures: u64 = 0;
loop {
// Read events from the current stream
while let Some(event) = current_stream.next().await {
// Reset backoff and failure count on successful event
backoff_ms = backoff_initial_ms;
consecutive_failures = 0;
// Validate required fields
if event.sender_id.is_empty()
|| event.channel_id.is_empty()
|| event.provider_scope.is_empty()
{
tracing::debug!(
event_type = %event.event_type,
sender_id = %event.sender_id,
channel_id = %event.channel_id,
"Relay: skipping event with missing required fields"
);
continue;
}
// Skip non-message events
if !event.is_message() {
tracing::debug!(
event_type = %event.event_type,
"Relay: skipping non-message event"
);
continue;
}
tracing::info!(
event_type = %event.event_type,
sender = %event.sender_id,
channel = %event.channel_id,
provider = %provider_str,
"Relay: received message from {}", provider_str
);
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
consecutive_failures += 1;
if consecutive_failures >= max_consecutive_failures {
tracing::error!(
channel = %relay_name,
failures = consecutive_failures,
"Relay channel giving up after {} consecutive failures",
consecutive_failures
);
break;
}
tracing::warn!(
backoff_ms = backoff_ms,
failures = consecutive_failures,
"Relay SSE stream ended, reconnecting..."
);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
backoff_ms = (backoff_ms * 2).min(backoff_max_ms);
// Try to reconnect
let token = stream_token.read().await.clone();
match client.connect_stream(&token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!("Relay SSE stream reconnected");
current_stream = new_stream;
// Abort old parser before replacing
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(RelayError::TokenExpired) => {
// Attempt token renewal
tracing::info!("Relay stream token expired, renewing...");
match client.renew_token(&instance_id, &user_id).await {
Ok(new_token) => {
*stream_token.write().await = new_token.clone();
match client.connect_stream(&new_token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!(
"Relay SSE stream reconnected with new token"
);
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,
// e.g. when no DB store was available at activation time)
if !team_id.is_empty() {
match client.list_connections(&instance_id).await {
Ok(conns) => {
let has_team =
conns.iter().any(|c| c.team_id == team_id && c.connected);
if !has_team {
tracing::warn!(
team_id = %team_id,
"Team no longer connected, stopping relay channel"
);
return;
}
}
Err(e) => {
tracing::warn!(
error = %e,
"Could not verify team connection, will retry next iteration"
);
}
}
}
}
});
*self.reconnect_handle.write().await = Some(handle);
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Ok(Box::pin(stream))
}
async fn respond(
&self,
msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let channel_name = self.name().to_string();
let metadata = &msg.metadata;
let team_id = metadata
.get("team_id")
.and_then(|v| v.as_str())
.unwrap_or(&self.team_id);
let channel_id = metadata
.get("channel_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::SendFailed {
name: channel_name.clone(),
reason: "Missing channel_id in message metadata".to_string(),
})?;
// Determine thread_id from response or metadata
let thread_id = response
.thread_id
.as_deref()
.or_else(|| metadata.get("thread_id").and_then(|v| v.as_str()));
let (method, body) = self.build_send_body(channel_id, &response.content, thread_id);
self.proxy_send(team_id, &method, body)
.await
.map_err(|e| ChannelError::SendFailed {
name: channel_name,
reason: e.to_string(),
})?;
Ok(())
}
/// Status updates are not forwarded to messaging providers to avoid noise.
async fn send_status(
&self,
_status: StatusUpdate,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
Ok(())
}
async fn broadcast(
&self,
target: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let channel_name = self.name().to_string();
// Determine thread_id from response or metadata
let thread_id = response
.thread_id
.as_deref()
.or_else(|| response.metadata.get("thread_ts").and_then(|v| v.as_str()));
let (method, body) = self.build_send_body(target, &response.content, thread_id);
self.proxy_send(&self.team_id, &method, body)
.await
.map_err(|e| ChannelError::SendFailed {
name: channel_name,
reason: e.to_string(),
})?;
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
self.client
.list_connections(&self.instance_id)
.await
.map_err(|_| ChannelError::HealthCheckFailed {
name: self.name().to_string(),
})?;
Ok(())
}
fn conversation_context(&self, metadata: &serde_json::Value) -> HashMap<String, String> {
let mut ctx = HashMap::new();
if let Some(sender) = metadata.get("sender_name").and_then(|v| v.as_str()) {
ctx.insert("sender".to_string(), sender.to_string());
}
if let Some(sender_id) = metadata.get("sender_id").and_then(|v| v.as_str()) {
ctx.insert("sender_uuid".to_string(), sender_id.to_string());
}
if let Some(channel_id) = metadata.get("channel_id").and_then(|v| v.as_str()) {
ctx.insert("group".to_string(), channel_id.to_string());
}
ctx.insert("platform".to_string(), self.provider.as_str().to_string());
ctx
}
async fn shutdown(&self) -> Result<(), ChannelError> {
if let Some(handle) = self.reconnect_handle.write().await.take() {
handle.abort();
}
if let Some(handle) = self.parser_handle.write().await.take() {
handle.abort();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_client() -> RelayClient {
RelayClient::new(
"http://localhost:3001".into(),
secrecy::SecretString::from("key".to_string()),
30,
)
.expect("client")
}
#[test]
fn relay_channel_name() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
}
#[test]
fn conversation_context_extracts_metadata() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({
"sender_name": "bob",
"sender_id": "U123",
"channel_id": "C456",
});
let ctx = channel.conversation_context(&metadata);
assert_eq!(ctx.get("sender"), Some(&"bob".to_string()));
assert_eq!(ctx.get("sender_uuid"), Some(&"U123".to_string()));
assert_eq!(ctx.get("platform"), Some(&"slack".to_string()));
}
#[test]
fn metadata_shape_includes_event_type_and_sender_name() {
// Regression: metadata JSON must include event_type and sender_name
// for downstream routing (DM vs channel) and conversation_context().
let metadata = serde_json::json!({
"team_id": "T123",
"channel_id": "C456",
"sender_id": "U789",
"sender_name": "alice",
"event_type": "direct_message",
"thread_id": null,
"provider": "slack",
});
// event_type must be present for DM-vs-channel routing
assert_eq!(
metadata.get("event_type").and_then(|v| v.as_str()),
Some("direct_message")
);
// sender_name must be present for conversation_context
assert_eq!(
metadata.get("sender_name").and_then(|v| v.as_str()),
Some("alice")
);
}
#[test]
fn with_timeouts_sets_values() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_timeouts(43200, 2000, 120000);
assert_eq!(channel.stream_timeout_secs, 43200);
assert_eq!(channel.backoff_initial_ms, 2000);
assert_eq!(channel.backoff_max_ms, 120000);
}
#[test]
fn build_send_body_slack() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
assert_eq!(method, "chat.postMessage");
assert_eq!(body["channel"], "C456");
assert_eq!(body["text"], "hello");
assert_eq!(body["thread_ts"], "1234567.890");
}
#[test]
fn parser_handle_is_shared_arc() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
// parser_handle should be an Arc — cloning should give a second reference
let handle_clone = Arc::clone(&channel.parser_handle);
// Both point to the same allocation
assert!(Arc::ptr_eq(&channel.parser_handle, &handle_clone));
}
#[test]
fn with_max_failures_sets_value() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_max_failures(10);
assert_eq!(channel.max_consecutive_failures, 10);
}
#[test]
fn default_max_failures_is_50() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.max_consecutive_failures, 50);
}
#[test]
fn empty_team_id_accepted_at_construction() {
// Regression: empty team_id (when no DB store is available) must not
// prevent channel construction or cause immediate shutdown.
let channel = RelayChannel::new(
test_client(),
"token".into(),
String::new(), // empty team_id
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.team_id, "");
// The reconnect loop now skips team validation when team_id is empty,
// so the channel remains alive.
}
}
+549
View File
@@ -0,0 +1,549 @@
//! HTTP client for the channel-relay service.
//!
//! Wraps reqwest for all channel-relay API calls: OAuth initiation,
//! SSE streaming, token renewal, and Slack API proxy.
use std::pin::Pin;
use std::task::{Context, Poll};
use futures::Stream;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
/// Known relay event types.
pub mod event_types {
pub const MESSAGE: &str = "message";
pub const DIRECT_MESSAGE: &str = "direct_message";
pub const MENTION: &str = "mention";
}
/// A parsed SSE event from the channel-relay stream.
///
/// Field names match the channel-relay `ChannelEvent` struct exactly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelEvent {
/// Unique event ID.
#[serde(default)]
pub id: String,
/// Event type enum from channel-relay (e.g., "direct_message", "message", "mention").
pub event_type: String,
/// Provider (e.g., "slack").
#[serde(default)]
pub provider: String,
/// Team/workspace ID (called `provider_scope` in channel-relay).
#[serde(alias = "team_id", default)]
pub provider_scope: String,
/// Channel or DM conversation ID.
#[serde(default)]
pub channel_id: String,
/// Sender user ID.
#[serde(default)]
pub sender_id: String,
/// Sender display name.
#[serde(default)]
pub sender_name: Option<String>,
/// Message text content (called `content` in channel-relay).
#[serde(alias = "text", default)]
pub content: Option<String>,
/// Thread ID (for threaded replies, called `thread_id` in channel-relay).
#[serde(alias = "thread_ts", default)]
pub thread_id: Option<String>,
/// Full raw event data.
#[serde(default)]
pub raw: serde_json::Value,
/// Event timestamp (ISO 8601 from channel-relay).
#[serde(default)]
pub timestamp: Option<String>,
}
impl ChannelEvent {
/// Get the team_id (provider_scope).
pub fn team_id(&self) -> &str {
&self.provider_scope
}
/// Get the message text content.
pub fn text(&self) -> &str {
self.content.as_deref().unwrap_or("")
}
/// Get the sender name or fallback to sender_id.
pub fn display_name(&self) -> &str {
self.sender_name.as_deref().unwrap_or(&self.sender_id)
}
/// Check if this is a message-like event that should be forwarded to the agent.
pub fn is_message(&self) -> bool {
matches!(
self.event_type.as_str(),
event_types::MESSAGE | event_types::DIRECT_MESSAGE | event_types::MENTION
)
}
}
/// Connection info returned by list_connections.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Connection {
pub provider: String,
pub team_id: String,
pub team_name: Option<String>,
pub connected: bool,
}
/// HTTP client for the channel-relay service.
#[derive(Clone)]
pub struct RelayClient {
http: reqwest::Client,
base_url: String,
api_key: SecretString,
}
impl RelayClient {
/// Create a new relay client.
pub fn new(
base_url: String,
api_key: SecretString,
request_timeout_secs: u64,
) -> Result<Self, RelayError> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(request_timeout_secs))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| RelayError::Network(format!("Failed to build HTTP client: {e}")))?;
Ok(Self {
http,
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
})
}
/// Initiate Slack OAuth flow via channel-relay.
///
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
/// returns the `Location` header (Slack OAuth URL) without following it.
pub async fn initiate_oauth(
&self,
instance_id: &str,
user_id: &str,
callback_url: &str,
) -> Result<String, RelayError> {
let resp = self
.http
.get(format!("{}/oauth/slack/auth", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.query(&[
("instance_id", instance_id),
("user_id", user_id),
("callback", callback_url),
])
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status.is_redirection() {
let location = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| {
RelayError::Protocol("Redirect response missing Location header".to_string())
})?;
Ok(location)
} else if status.is_success() {
// Some relay implementations return the URL in JSON body instead
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("auth_url")
.or_else(|| body.get("url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| RelayError::Protocol("Response missing auth_url field".to_string()))
} else {
let body = resp.text().await.unwrap_or_default();
Err(RelayError::Api {
status: status.as_u16(),
message: body,
})
}
}
/// Connect to the SSE event stream.
///
/// Returns a stream of parsed `ChannelEvent`s and the `JoinHandle` of the
/// background SSE parser task. The caller is responsible for reconnection
/// logic on stream end/error and for aborting the handle on shutdown.
pub async fn connect_stream(
&self,
stream_token: &str,
stream_timeout_secs: u64,
) -> Result<(ChannelEventStream, tokio::task::JoinHandle<()>), RelayError> {
let resp = self
.http
.get(format!("{}/stream", self.base_url))
.query(&[("token", stream_token)])
.timeout(std::time::Duration::from_secs(stream_timeout_secs))
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(RelayError::TokenExpired);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status: status.as_u16(),
message: body,
});
}
// Spawn a background task that reads the SSE stream and sends parsed events
let (tx, rx) = mpsc::channel(64);
let byte_stream = resp.bytes_stream();
let handle = tokio::spawn(parse_sse_stream(byte_stream, tx));
Ok((ChannelEventStream { rx }, handle))
}
/// Renew an expired stream token.
///
/// Calls `POST /stream/renew` with API key auth, returns a new stream token.
pub async fn renew_token(
&self,
instance_id: &str,
user_id: &str,
) -> Result<String, RelayError> {
let resp = self
.http
.post(format!("{}/stream/renew", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.json(&serde_json::json!({
"instance_id": instance_id,
"user_id": user_id,
}))
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status: status.as_u16(),
message: body,
});
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("stream_token")
.or_else(|| body.get("token"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| RelayError::Protocol("Response missing stream_token field".to_string()))
}
/// Proxy an API call through channel-relay for any provider.
///
/// Calls `POST /proxy/{provider}/{method}?team_id=X&instance_id=Y` with the given JSON body.
pub async fn proxy_provider(
&self,
provider: &str,
team_id: &str,
method: &str,
body: serde_json::Value,
instance_id: Option<&str>,
) -> Result<serde_json::Value, RelayError> {
let mut query: Vec<(&str, &str)> = vec![("team_id", team_id)];
if let Some(iid) = instance_id {
query.push(("instance_id", iid));
}
let resp = self
.http
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.header("X-API-Key", self.api_key.expose_secret())
.query(&query)
.json(&body)
.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,
});
}
resp.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))
}
/// List active connections for an instance.
pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> {
let resp = self
.http
.get(format!("{}/connections", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.query(&[("instance_id", instance_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,
});
}
resp.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))
}
}
/// Async stream of parsed channel events from SSE.
pub struct ChannelEventStream {
rx: mpsc::Receiver<ChannelEvent>,
}
impl Stream for ChannelEventStream {
type Item = ChannelEvent;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.rx.poll_recv(cx)
}
}
/// Parse SSE format from a reqwest bytes stream.
///
/// SSE format:
/// ```text
/// event: message
/// data: {"key": "value"}
///
/// ```
/// Blank line terminates an event.
async fn parse_sse_stream(
byte_stream: impl futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
tx: mpsc::Sender<ChannelEvent>,
) {
use futures::StreamExt;
let mut buffer = Vec::<u8>::new();
let mut event_type = String::new();
let mut data_lines = Vec::new();
let mut byte_stream = std::pin::pin!(byte_stream);
while let Some(chunk_result) = byte_stream.next().await {
let chunk = match chunk_result {
Ok(c) => c,
Err(e) => {
tracing::debug!(error = %e, "SSE stream chunk error");
break;
}
};
buffer.extend_from_slice(&chunk);
// Process complete lines (decode UTF-8 only on full lines to avoid
// corruption when multi-byte characters span chunk boundaries)
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
let line = String::from_utf8_lossy(&buffer[..newline_pos])
.trim_end_matches('\r')
.to_string();
buffer.drain(..=newline_pos);
if line.is_empty() {
// Blank line = end of event
if !data_lines.is_empty() {
let data = data_lines.join("\n");
if let Ok(mut event) = serde_json::from_str::<ChannelEvent>(&data) {
if event.event_type.is_empty() && !event_type.is_empty() {
event.event_type = event_type.clone();
}
if tx.send(event).await.is_err() {
return; // receiver dropped
}
} else {
tracing::debug!(
event_type = %event_type,
data_len = data.len(),
"Failed to parse SSE event data as ChannelEvent"
);
}
}
event_type.clear();
data_lines.clear();
} else if let Some(value) = line.strip_prefix("event:") {
event_type = value.trim().to_string();
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim().to_string());
}
// Ignore other fields (id:, retry:, comments)
}
}
tracing::debug!("SSE stream ended");
}
/// Errors from relay client operations.
#[derive(Debug, thiserror::Error)]
pub enum RelayError {
#[error("Network error: {0}")]
Network(String),
#[error("API error (HTTP {status}): {message}")]
Api { status: u16, message: String },
#[error("Protocol error: {0}")]
Protocol(String),
#[error("Stream token expired")]
TokenExpired,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_event_deserialize_minimal() {
let json = r#"{"event_type": "message", "content": "hello"}"#;
let event: ChannelEvent = serde_json::from_str(json).expect("parse failed");
assert_eq!(event.event_type, "message");
assert_eq!(event.text(), "hello");
assert!(event.provider_scope.is_empty());
}
#[test]
fn channel_event_deserialize_relay_format() {
// Matches the actual channel-relay ChannelEvent serialization format.
let json = r#"{
"id": "evt_123",
"event_type": "direct_message",
"provider": "slack",
"provider_scope": "T123",
"channel_id": "D456",
"sender_id": "U789",
"sender_name": "bob",
"content": "hi there",
"thread_id": "1234567890.123456",
"raw": {},
"timestamp": "2026-03-09T21:00:00Z"
}"#;
let event: ChannelEvent = serde_json::from_str(json).expect("parse failed");
assert_eq!(event.provider, "slack");
assert_eq!(event.team_id(), "T123");
assert_eq!(event.display_name(), "bob");
assert_eq!(event.thread_id, Some("1234567890.123456".to_string()));
assert!(event.is_message());
}
#[test]
fn channel_event_is_message() {
let make = |et: &str| ChannelEvent {
id: String::new(),
event_type: et.to_string(),
provider: String::new(),
provider_scope: String::new(),
channel_id: String::new(),
sender_id: String::new(),
sender_name: None,
content: None,
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
};
assert!(make("message").is_message());
assert!(make("direct_message").is_message());
assert!(make("mention").is_message());
assert!(!make("reaction").is_message());
}
#[test]
fn connection_deserialize() {
let json = r#"{"provider": "slack", "team_id": "T123", "team_name": "My Team", "connected": true}"#;
let conn: Connection = serde_json::from_str(json).expect("parse failed");
assert_eq!(conn.provider, "slack");
assert!(conn.connected);
}
#[test]
fn relay_error_display() {
let err = RelayError::Network("timeout".into());
assert_eq!(err.to_string(), "Network error: timeout");
let err = RelayError::Api {
status: 401,
message: "unauthorized".into(),
};
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
let err = RelayError::TokenExpired;
assert_eq!(err.to_string(), "Stream token expired");
}
#[test]
fn event_type_constants_match_is_message() {
let make = |et: &str| ChannelEvent {
id: String::new(),
event_type: et.to_string(),
provider: String::new(),
provider_scope: String::new(),
channel_id: String::new(),
sender_id: String::new(),
sender_name: None,
content: None,
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
};
assert!(make(event_types::MESSAGE).is_message());
assert!(make(event_types::DIRECT_MESSAGE).is_message());
assert!(make(event_types::MENTION).is_message());
}
#[tokio::test]
async fn parse_sse_handles_multibyte_utf8_across_chunks() {
// The crab emoji (🦀) is 4 bytes: [0xF0, 0x9F, 0xA6, 0x80].
// Split it across two chunks to verify no U+FFFD corruption.
let event_json = r#"{"event_type":"message","content":"hello 🦀 world","provider_scope":"T1","channel_id":"C1","sender_id":"U1"}"#;
let full = format!("event: message\ndata: {}\n\n", event_json);
let bytes = full.as_bytes();
// Find the crab emoji and split mid-character
let crab_pos = bytes
.windows(4)
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
.expect("crab emoji not found");
let split_at = crab_pos + 2; // split in the middle of the 4-byte emoji
let chunk1 = bytes::Bytes::copy_from_slice(&bytes[..split_at]);
let chunk2 = bytes::Bytes::copy_from_slice(&bytes[split_at..]);
let chunks: Vec<Result<bytes::Bytes, reqwest::Error>> = vec![Ok(chunk1), Ok(chunk2)];
let stream = futures::stream::iter(chunks);
let (tx, mut rx) = mpsc::channel(8);
parse_sse_stream(stream, tx).await;
let event = rx.recv().await.expect("should receive event");
assert_eq!(event.text(), "hello 🦀 world");
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Channel-relay integration for connecting to external messaging platforms
//! (Slack) via the channel-relay service.
//!
//! The relay service handles OAuth, credential storage, webhook ingestion,
//! and SSE event streaming. IronClaw consumes the SSE stream and sends
//! messages via the relay's proxy API.
pub mod channel;
pub mod client;
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
pub use client::RelayClient;
+77 -51
View File
@@ -218,25 +218,31 @@ async fn register_channel(
} }
// Inject credentials from secrets store / environment. // Inject credentials from secrets store / environment.
if let Some(secrets) = secrets_store { match inject_channel_credentials(
match inject_channel_credentials(&channel_arc, secrets.as_ref(), &channel_name).await { &channel_arc,
Ok(count) => { secrets_store
if count > 0 { .as_ref()
tracing::info!( .map(|s| s.as_ref() as &dyn SecretsStore),
channel = %channel_name, &channel_name,
credentials_injected = count, )
"Channel credentials injected" .await
); {
} Ok(count) => {
} if count > 0 {
Err(e) => { tracing::info!(
tracing::error!(
channel = %channel_name, channel = %channel_name,
error = %e, credentials_injected = count,
"Failed to inject channel credentials" "Channel credentials injected"
); );
} }
} }
Err(e) => {
tracing::error!(
channel = %channel_name,
error = %e,
"Failed to inject channel credentials"
);
}
} }
(channel_name, Box::new(SharedWasmChannel::new(channel_arc))) (channel_name, Box::new(SharedWasmChannel::new(channel_arc)))
@@ -247,58 +253,70 @@ async fn register_channel(
/// Looks for secrets matching the pattern `{channel_name}_*` and injects them /// Looks for secrets matching the pattern `{channel_name}_*` and injects them
/// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`). /// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`).
/// ///
/// Falls back to environment variables with the uppercase name if not found /// Falls back to environment variables starting with the uppercase channel name
/// in the secrets store (e.g., `TELEGRAM_BOT_TOKEN`). /// prefix (e.g., `TELEGRAM_` for channel `telegram`) for missing credentials.
///
/// Returns the number of credentials injected.
pub async fn inject_channel_credentials( pub async fn inject_channel_credentials(
channel: &Arc<WasmChannel>, channel: &Arc<WasmChannel>,
secrets: &dyn SecretsStore, secrets: Option<&dyn SecretsStore>,
channel_name: &str, channel_name: &str,
) -> anyhow::Result<usize> { ) -> anyhow::Result<usize> {
let all_secrets = secrets if channel_name.trim().is_empty() {
.list("default") return Ok(0);
.await }
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
let prefix = format!("{}_", channel_name);
let mut count = 0; let mut count = 0;
let mut injected_placeholders = HashSet::new(); let mut injected_placeholders = HashSet::new();
for secret_meta in all_secrets { // 1. Try injecting from persistent secrets store if available
if !secret_meta.name.starts_with(&prefix) { if let Some(secrets) = secrets {
continue; let all_secrets = secrets
} .list("default")
.await
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
let decrypted = match secrets.get_decrypted("default", &secret_meta.name).await { let prefix = format!("{}_", channel_name.to_ascii_lowercase());
Ok(d) => d,
Err(e) => { for secret_meta in all_secrets {
tracing::warn!( if !secret_meta.name.to_ascii_lowercase().starts_with(&prefix) {
secret = %secret_meta.name,
error = %e,
"Failed to decrypt secret for channel credential injection"
);
continue; continue;
} }
};
let placeholder = secret_meta.name.to_uppercase(); let decrypted = match secrets.get_decrypted("default", &secret_meta.name).await {
Ok(d) => d,
Err(e) => {
tracing::warn!(
secret = %secret_meta.name,
error = %e,
"Failed to decrypt secret for channel credential injection"
);
continue;
}
};
tracing::debug!( let placeholder = secret_meta.name.to_uppercase();
channel = %channel_name,
secret = %secret_meta.name,
placeholder = %placeholder,
"Injecting credential"
);
channel tracing::debug!(
.set_credential(&placeholder, decrypted.expose().to_string()) channel = %channel_name,
.await; secret = %secret_meta.name,
injected_placeholders.insert(placeholder); placeholder = %placeholder,
count += 1; "Injecting credential"
);
channel
.set_credential(&placeholder, decrypted.expose().to_string())
.await;
injected_placeholders.insert(placeholder);
count += 1;
}
} }
// Fall back to environment variables for required secrets not found in the store. // 2. Fall back to environment variables for credentials not in the secrets store.
// This allows channels to work when configured via env vars (e.g., TELEGRAM_BOT_TOKEN) // Only env vars starting with the channel's uppercase prefix are allowed
// without requiring the setup wizard to have run. // (e.g., TELEGRAM_ for channel "telegram") to prevent reading unrelated host
// credentials like AWS_SECRET_ACCESS_KEY.
let prefix = format!("{}_", channel_name.to_ascii_uppercase());
let caps = channel.capabilities(); let caps = channel.capabilities();
if let Some(ref http_cap) = caps.tool_capabilities.http { if let Some(ref http_cap) = caps.tool_capabilities.http {
for cred_mapping in http_cap.credentials.values() { for cred_mapping in http_cap.credentials.values() {
@@ -306,6 +324,14 @@ pub async fn inject_channel_credentials(
if injected_placeholders.contains(&placeholder) { if injected_placeholders.contains(&placeholder) {
continue; continue;
} }
if !placeholder.starts_with(&prefix) {
tracing::warn!(
channel = %channel_name,
placeholder = %placeholder,
"Ignoring non-prefixed credential placeholder in environment fallback"
);
continue;
}
if let Ok(env_value) = std::env::var(&placeholder) if let Ok(env_value) = std::env::var(&placeholder)
&& !env_value.is_empty() && !env_value.is_empty()
{ {
+78 -54
View File
@@ -35,6 +35,7 @@ pub async fn chat_send_handler(
} }
let msg_id = msg.id; let msg_id = msg.id;
let thread_id = msg.thread_id.clone();
let tx_guard = state.msg_tx.read().await; let tx_guard = state.msg_tx.read().await;
let tx = tx_guard.as_ref().ok_or(( let tx = tx_guard.as_ref().ok_or((
@@ -49,6 +50,13 @@ pub async fn chat_send_handler(
) )
})?; })?;
tracing::debug!(
message_id = %msg_id,
thread_id = ?thread_id,
content_len = req.content.len(),
"Message queued to agent loop"
);
Ok(( Ok((
StatusCode::ACCEPTED, StatusCode::ACCEPTED,
Json(SendMessageResponse { Json(SendMessageResponse {
@@ -263,7 +271,6 @@ pub async fn chat_history_handler(
))?; ))?;
let session = session_manager.get_or_create_session(&state.user_id).await; let session = session_manager.get_or_create_session(&state.user_id).await;
let sess = session.lock().await;
let limit = query.limit.unwrap_or(50); let limit = query.limit.unwrap_or(50);
let before_cursor = query let before_cursor = query
@@ -281,11 +288,12 @@ pub async fn chat_history_handler(
}) })
.transpose()?; .transpose()?;
// Find the thread // Find the thread (lock only briefly to get active_thread if needed)
let thread_id = if let Some(ref tid) = query.thread_id { let thread_id = if let Some(ref tid) = query.thread_id {
Uuid::parse_str(tid) Uuid::parse_str(tid)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid thread_id".to_string()))? .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid thread_id".to_string()))?
} else { } else {
let sess = session.lock().await;
sess.active_thread sess.active_thread
.ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))? .ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))?
}; };
@@ -298,8 +306,11 @@ pub async fn chat_history_handler(
.conversation_belongs_to_user(thread_id, &state.user_id) .conversation_belongs_to_user(thread_id, &state.user_id)
.await .await
.unwrap_or(false); .unwrap_or(false);
if !owned && !sess.threads.contains_key(&thread_id) { if !owned {
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string())); let sess = session.lock().await;
if !sess.threads.contains_key(&thread_id) {
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
}
} }
} }
@@ -324,56 +335,60 @@ pub async fn chat_history_handler(
} }
// Try in-memory first (freshest data for active threads) // Try in-memory first (freshest data for active threads)
if let Some(thread) = sess.threads.get(&thread_id) // Lock only when checking in-memory state
&& (!thread.turns.is_empty() || thread.pending_approval.is_some())
{ {
let turns: Vec<TurnInfo> = thread let sess = session.lock().await;
.turns if let Some(thread) = sess.threads.get(&thread_id)
.iter() && (!thread.turns.is_empty() || thread.pending_approval.is_some())
.map(|t| TurnInfo { {
turn_number: t.turn_number, let turns: Vec<TurnInfo> = thread
user_input: t.user_input.clone(), .turns
response: t.response.clone(), .iter()
state: format!("{:?}", t.state), .map(|t| TurnInfo {
started_at: t.started_at.to_rfc3339(), turn_number: t.turn_number,
completed_at: t.completed_at.map(|dt| dt.to_rfc3339()), user_input: t.user_input.clone(),
tool_calls: t response: t.response.clone(),
.tool_calls state: format!("{:?}", t.state),
.iter() started_at: t.started_at.to_rfc3339(),
.map(|tc| ToolCallInfo { completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
name: tc.name.clone(), tool_calls: t
has_result: tc.result.is_some(), .tool_calls
has_error: tc.error.is_some(), .iter()
result_preview: tc.result.as_ref().map(|r| { .map(|tc| ToolCallInfo {
let s = match r { name: tc.name.clone(),
serde_json::Value::String(s) => s.clone(), has_result: tc.result.is_some(),
other => other.to_string(), has_error: tc.error.is_some(),
}; result_preview: tc.result.as_ref().map(|r| {
truncate_preview(&s, 500) let s = match r {
}), serde_json::Value::String(s) => s.clone(),
error: tc.error.clone(), other => other.to_string(),
}) };
.collect(), truncate_preview(&s, 500)
}) }),
.collect(); error: tc.error.clone(),
})
.collect(),
})
.collect();
let pending_approval = thread let pending_approval = thread
.pending_approval .pending_approval
.as_ref() .as_ref()
.map(|pa| PendingApprovalInfo { .map(|pa| PendingApprovalInfo {
request_id: pa.request_id.to_string(), request_id: pa.request_id.to_string(),
tool_name: pa.tool_name.clone(), tool_name: pa.tool_name.clone(),
description: pa.description.clone(), description: pa.description.clone(),
parameters: serde_json::to_string_pretty(&pa.parameters).unwrap_or_default(), parameters: serde_json::to_string_pretty(&pa.parameters).unwrap_or_default(),
}); });
return Ok(Json(HistoryResponse { return Ok(Json(HistoryResponse {
thread_id, thread_id,
turns, turns,
has_more: false, has_more: false,
oldest_timestamp: None, oldest_timestamp: None,
pending_approval, pending_approval,
})); }));
}
} }
// Fall back to DB for historical threads not in memory (paginated) // Fall back to DB for historical threads not in memory (paginated)
@@ -415,7 +430,6 @@ pub async fn chat_threads_handler(
))?; ))?;
let session = session_manager.get_or_create_session(&state.user_id).await; let session = session_manager.get_or_create_session(&state.user_id).await;
let sess = session.lock().await;
// Try DB first for persistent thread list // Try DB first for persistent thread list
if let Some(ref store) = state.store { if let Some(ref store) = state.store {
@@ -465,15 +479,22 @@ pub async fn chat_threads_handler(
}); });
} }
// Read active thread while holding minimal lock (just before return)
let active_thread = {
let sess = session.lock().await;
sess.active_thread
};
return Ok(Json(ThreadListResponse { return Ok(Json(ThreadListResponse {
assistant_thread, assistant_thread,
threads, threads,
active_thread: sess.active_thread, active_thread,
})); }));
} }
} }
// Fallback: in-memory only (no assistant thread without DB) // Fallback: in-memory only (no assistant thread without DB)
let sess = session.lock().await;
let mut sorted_threads: Vec<_> = sess.threads.values().collect(); let mut sorted_threads: Vec<_> = sess.threads.values().collect();
sorted_threads.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); sorted_threads.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
let threads: Vec<ThreadInfo> = sorted_threads let threads: Vec<ThreadInfo> = sorted_threads
@@ -490,10 +511,13 @@ pub async fn chat_threads_handler(
}) })
.collect(); .collect();
let active_thread = sess.active_thread;
drop(sess); // Explicit drop to release lock
Ok(Json(ThreadListResponse { Ok(Json(ThreadListResponse {
assistant_thread: None, assistant_thread: None,
threads, threads,
active_thread: sess.active_thread, active_thread,
})) }))
} }
+9 -56
View File
@@ -46,6 +46,14 @@ pub async fn extensions_list_handler(
} else { } else {
"configured".to_string() "configured".to_string()
}) })
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
Some(if ext.active {
"active".to_string()
} else if ext.authenticated {
"configured".to_string()
} else {
"installed".to_string()
})
} else { } else {
None None
}; };
@@ -103,6 +111,7 @@ pub async fn extensions_install_handler(
"mcp_server" => Some(crate::extensions::ExtensionKind::McpServer), "mcp_server" => Some(crate::extensions::ExtensionKind::McpServer),
"wasm_tool" => Some(crate::extensions::ExtensionKind::WasmTool), "wasm_tool" => Some(crate::extensions::ExtensionKind::WasmTool),
"wasm_channel" => Some(crate::extensions::ExtensionKind::WasmChannel), "wasm_channel" => Some(crate::extensions::ExtensionKind::WasmChannel),
"channel_relay" => Some(crate::extensions::ExtensionKind::ChannelRelay),
_ => None, _ => None,
}); });
@@ -115,62 +124,6 @@ pub async fn extensions_install_handler(
} }
} }
pub async fn extensions_activate_handler(
State(state): State<Arc<GatewayState>>,
Path(name): Path<String>,
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Extension manager not available (secrets store required)".to_string(),
))?;
match ext_mgr.activate(&name).await {
Ok(result) => {
// Activation just loads the WASM module. Auth (OAuth/manual) is
// triggered separately via save_setup_secrets or the auth endpoint.
Ok(Json(ActionResponse::ok(result.message)))
}
Err(activate_err) => {
let err_str = activate_err.to_string();
let needs_auth = err_str.contains("authentication")
|| err_str.contains("401")
|| err_str.contains("Unauthorized");
if !needs_auth {
return Ok(Json(ActionResponse::fail(err_str)));
}
// Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name, None).await {
Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded, retry activation.
match ext_mgr.activate(&name).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
}
Ok(auth_result) => {
// Auth in progress (OAuth URL or awaiting manual token).
let mut resp = ActionResponse::fail(
auth_result
.instructions()
.map(String::from)
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
);
resp.auth_url = auth_result.auth_url().map(String::from);
resp.awaiting_token = Some(auth_result.is_awaiting_token());
resp.instructions = auth_result.instructions().map(String::from);
Ok(Json(resp))
}
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
"Authentication failed: {}",
auth_err
)))),
}
}
}
}
pub async fn extensions_remove_handler( pub async fn extensions_remove_handler(
State(state): State<Arc<GatewayState>>, State(state): State<Arc<GatewayState>>,
Path(name): Path<String>, Path(name): Path<String>,
+1 -49
View File
@@ -27,7 +27,7 @@ pub async fn routines_list_handler(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines.iter().map(routine_to_info).collect(); let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
Ok(Json(RoutineListResponse { routines: items })) Ok(Json(RoutineListResponse { routines: items }))
} }
@@ -263,54 +263,6 @@ pub async fn routines_runs_handler(
}))) })))
} }
/// Convert a Routine to the trimmed RoutineInfo for list display.
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
pattern, channel, ..
} => {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::Webhook { path, .. } => {
let p = path.as_deref().unwrap_or("/");
("webhook".to_string(), format!("webhook: {}", p))
}
crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()),
};
let action_type = match &r.action {
crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight",
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
};
let status = if !r.enabled {
"disabled"
} else if r.consecutive_failures > 0 {
"failing"
} else {
"active"
};
RoutineInfo {
id: r.id,
name: r.name.clone(),
description: r.description.clone(),
enabled: r.enabled,
trigger_type,
trigger_summary,
action_type: action_type.to_string(),
last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()),
next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: r.run_count,
consecutive_failures: r.consecutive_failures,
status: status.to_string(),
}
}
/// Map `RoutineError` variants to appropriate HTTP status codes. /// Map `RoutineError` variants to appropriate HTTP status codes.
fn routine_error_status(err: &RoutineError) -> StatusCode { fn routine_error_status(err: &RoutineError) -> StatusCode {
match err { match err {
+2
View File
@@ -97,6 +97,7 @@ impl GatewayChannel {
skill_registry: None, skill_registry: None,
skill_catalog: None, skill_catalog: None,
chat_rate_limiter: server::RateLimiter::new(30, 60), chat_rate_limiter: server::RateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: Vec::new(), registry_entries: Vec::new(),
cost_guard: None, cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -133,6 +134,7 @@ impl GatewayChannel {
skill_registry: self.state.skill_registry.clone(), skill_registry: self.state.skill_registry.clone(),
skill_catalog: self.state.skill_catalog.clone(), skill_catalog: self.state.skill_catalog.clone(),
chat_rate_limiter: server::RateLimiter::new(30, 60), chat_rate_limiter: server::RateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: self.state.registry_entries.clone(), registry_entries: self.state.registry_entries.clone(),
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),
+396 -61
View File
@@ -28,6 +28,7 @@ use uuid::Uuid;
use crate::agent::SessionManager; use crate::agent::SessionManager;
use crate::bootstrap::ironclaw_base_dir; use crate::bootstrap::ironclaw_base_dir;
use crate::channels::IncomingMessage; use crate::channels::IncomingMessage;
use crate::channels::relay::DEFAULT_RELAY_NAME;
use crate::channels::web::auth::{AuthState, auth_middleware}; use crate::channels::web::auth::{AuthState, auth_middleware};
use crate::channels::web::handlers::jobs::{ use crate::channels::web::handlers::jobs::{
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler, job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
@@ -164,6 +165,8 @@ pub struct GatewayState {
pub scheduler: Option<crate::tools::builtin::SchedulerSlot>, pub scheduler: Option<crate::tools::builtin::SchedulerSlot>,
/// Rate limiter for chat endpoints (30 messages per 60 seconds). /// Rate limiter for chat endpoints (30 messages per 60 seconds).
pub chat_rate_limiter: RateLimiter, pub chat_rate_limiter: RateLimiter,
/// Rate limiter for OAuth callback endpoints (10 requests per 60 seconds).
pub oauth_rate_limiter: RateLimiter,
/// Registry catalog entries for the available extensions API. /// Registry catalog entries for the available extensions API.
/// Populated at startup from `registry/` manifests, independent of extension manager. /// Populated at startup from `registry/` manifests, independent of extension manager.
pub registry_entries: Vec<crate::extensions::RegistryEntry>, pub registry_entries: Vec<crate::extensions::RegistryEntry>,
@@ -200,7 +203,11 @@ pub async fn start_server(
// Public routes (no auth) // Public routes (no auth)
let public = Router::new() let public = Router::new()
.route("/api/health", get(health_handler)) .route("/api/health", get(health_handler))
.route("/oauth/callback", get(oauth_callback_handler)); .route("/oauth/callback", get(oauth_callback_handler))
.route(
"/oauth/slack/callback",
get(slack_relay_oauth_callback_handler),
);
// Protected routes (require auth) // Protected routes (require auth)
let auth_state = AuthState { token: auth_token }; let auth_state = AuthState { token: auth_token };
@@ -606,6 +613,208 @@ async fn oauth_callback_handler(
axum::response::Html(html).into_response() axum::response::Html(html).into_response()
} }
/// OAuth callback for Slack via channel-relay.
///
/// This is a PUBLIC route (no Bearer token required) because channel-relay
/// redirects the user's browser here after Slack OAuth completes.
/// Query params: `stream_token`, `provider`, `team_id`.
async fn slack_relay_oauth_callback_handler(
State(state): State<Arc<GatewayState>>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
// Rate limit
if !state.oauth_rate_limiter.check() {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Too Many Requests</h2>\
<p>Please try again later.</p>\
</body></html>"
.to_string(),
)
.into_response();
}
// Validate stream_token: required, non-empty, max 2048 bytes
let stream_token = match params.get("stream_token") {
Some(t) if !t.is_empty() && t.len() <= 2048 => t.clone(),
Some(t) if t.len() > 2048 => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
_ => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
};
// Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
let team_id = params.get("team_id").cloned().unwrap_or_default();
if !team_id.is_empty() {
let valid_team_id = team_id.len() <= 21
&& team_id.starts_with('T')
&& team_id[1..].chars().all(|c| c.is_ascii_alphanumeric());
if !valid_team_id {
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 provider: must be "slack" (only supported provider)
let provider = params
.get("provider")
.cloned()
.unwrap_or_else(|| "slack".into());
if provider != "slack" {
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();
}
let ext_mgr = match state.extension_manager.as_ref() {
Some(mgr) => mgr,
None => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Extension manager not available.</p></body></html>"
.to_string(),
)
.into_response();
}
};
// Validate CSRF state parameter
let state_param = match params.get("state") {
Some(s) if !s.is_empty() && s.len() <= 128 => s.clone(),
_ => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
.to_string(),
)
.into_response();
}
};
let state_key = format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME);
let stored_state = match ext_mgr
.secrets()
.get_decrypted(&state.user_id, &state_key)
.await
{
Ok(secret) => secret.expose().to_string(),
Err(_) => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
.to_string(),
)
.into_response();
}
};
if state_param != stored_state {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
.to_string(),
)
.into_response();
}
// Delete the nonce (one-time use)
let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
let result: Result<(), String> = async {
// Store the stream token as a secret
let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME);
let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await;
ext_mgr
.secrets()
.create(
&state.user_id,
crate::secrets::CreateSecretParams {
name: token_key,
value: secrecy::SecretString::from(stream_token),
provider: Some(provider.clone()),
expires_at: None,
},
)
.await
.map_err(|e| format!("Failed to store stream token: {}", e))?;
// Store team_id in settings
if let Some(ref store) = state.store {
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
let _ = store
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
.await;
}
// Activate the relay channel
ext_mgr
.activate_stored_relay(DEFAULT_RELAY_NAME)
.await
.map_err(|e| format!("Failed to activate relay channel: {}", e))?;
Ok(())
}
.await;
let (success, message) = match &result {
Ok(()) => (true, "Slack connected successfully!".to_string()),
Err(e) => {
tracing::error!(error = %e, "Slack relay OAuth callback failed");
(
false,
"Connection failed. Check server logs for details.".to_string(),
)
}
};
// Broadcast SSE event to notify the web UI
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: DEFAULT_RELAY_NAME.to_string(),
success,
message: message.clone(),
});
if success {
axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Slack Connected!</h2>\
<p>You can close this tab and return to IronClaw.</p>\
<script>window.close()</script>\
</body></html>"
.to_string(),
)
.into_response()
} else {
axum::response::Html(format!(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Connection Failed</h2>\
<p>{}</p>\
</body></html>",
message
))
.into_response()
}
}
// --- Chat handlers --- // --- Chat handlers ---
/// Convert web gateway `ImageData` to `IncomingAttachment` objects. /// Convert web gateway `ImageData` to `IncomingAttachment` objects.
@@ -663,9 +872,9 @@ async fn chat_send_handler(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(req): Json<SendMessageRequest>, Json(req): Json<SendMessageRequest>,
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
tracing::debug!( tracing::trace!(
"[chat_send_handler] Received message: content={:?}, thread_id={:?}", "[chat_send_handler] Received message: content_len={}, thread_id={:?}",
req.content, req.content.len(),
req.thread_id req.thread_id
); );
@@ -698,10 +907,10 @@ async fn chat_send_handler(
} }
let msg_id = msg.id; let msg_id = msg.id;
tracing::debug!( tracing::trace!(
"[chat_send_handler] Created message id={}, content={:?}, images={}", "[chat_send_handler] Created message id={}, content_len={}, images={}",
msg_id, msg_id,
req.content, req.content.len(),
req.images.len() req.images.len()
); );
@@ -1639,13 +1848,13 @@ async fn extensions_activate_handler(
Ok(Json(resp)) Ok(Json(resp))
} }
Err(activate_err) => { Err(activate_err) => {
let err_str = activate_err.to_string(); let needs_auth = matches!(
let needs_auth = err_str.contains("authentication") &activate_err,
|| err_str.contains("401") crate::extensions::ExtensionError::AuthRequired
|| err_str.contains("Unauthorized"); );
if !needs_auth { if !needs_auth {
return Ok(Json(ActionResponse::fail(err_str))); return Ok(Json(ActionResponse::fail(activate_err.to_string())));
} }
// Activation failed due to auth; try authenticating first. // Activation failed due to auth; try authenticating first.
@@ -1936,7 +2145,7 @@ async fn routines_list_handler(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines.iter().map(routine_to_info).collect(); let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
Ok(Json(RoutineListResponse { routines: items })) Ok(Json(RoutineListResponse { routines: items }))
} }
@@ -2180,54 +2389,6 @@ async fn routines_runs_handler(
}))) })))
} }
/// Convert a Routine to the trimmed RoutineInfo for list display.
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
pattern, channel, ..
} => {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::Webhook { path, .. } => {
let p = path.as_deref().unwrap_or("/");
("webhook".to_string(), format!("webhook: {}", p))
}
crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()),
};
let action_type = match &r.action {
crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight",
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
};
let status = if !r.enabled {
"disabled"
} else if r.consecutive_failures > 0 {
"failing"
} else {
"active"
};
RoutineInfo {
id: r.id,
name: r.name.clone(),
description: r.description.clone(),
enabled: r.enabled,
trigger_type,
trigger_summary,
action_type: action_type.to_string(),
last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()),
next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: r.run_count,
consecutive_failures: r.consecutive_failures,
status: status.to_string(),
}
}
// --- Settings handlers --- // --- Settings handlers ---
async fn settings_list_handler( async fn settings_list_handler(
@@ -2529,6 +2690,7 @@ mod tests {
skill_catalog: None, skill_catalog: None,
scheduler: None, scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60), chat_rate_limiter: RateLimiter::new(30, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
registry_entries: vec![], registry_entries: vec![],
cost_guard: None, cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -2851,4 +3013,177 @@ mod tests {
.is_none() .is_none()
); );
} }
// --- Slack relay OAuth CSRF tests ---
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
Router::new()
.route(
"/oauth/slack/callback",
get(slack_relay_oauth_callback_handler),
)
.with_state(state)
}
fn test_secrets_store() -> Arc<dyn crate::secrets::SecretsStore + Send + Sync> {
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)))
}
fn test_ext_mgr(
secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
) -> Arc<ExtensionManager> {
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
let mcp_pm = Arc::new(crate::tools::mcp::process::McpProcessManager::new());
Arc::new(ExtensionManager::new(
mcp_sm,
mcp_pm,
secrets,
tool_registry,
None,
None,
std::path::PathBuf::from("/tmp/wasm_tools"),
std::path::PathBuf::from("/tmp/wasm_channels"),
None,
"test".to_string(),
None,
vec![],
))
}
#[tokio::test]
async fn test_relay_oauth_callback_missing_state_param() {
use axum::body::Body;
use tower::ServiceExt;
let secrets = test_secrets_store();
let ext_mgr = test_ext_mgr(secrets);
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
// Callback without state param should be rejected
let req = axum::http::Request::builder()
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("Invalid or expired authorization"),
"Expected CSRF error, got: {}",
&html[..html.len().min(300)]
);
}
#[tokio::test]
async fn test_relay_oauth_callback_wrong_state_param() {
use axum::body::Body;
use tower::ServiceExt;
let secrets = test_secrets_store();
// Store a valid nonce
secrets
.create(
"test",
crate::secrets::CreateSecretParams::new(
format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME),
"correct-nonce-value",
),
)
.await
.expect("store nonce");
let ext_mgr = test_ext_mgr(secrets);
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
// Callback with wrong state param
let req = axum::http::Request::builder()
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state=wrong-nonce")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("Invalid or expired authorization"),
"Expected CSRF error for wrong nonce, got: {}",
&html[..html.len().min(300)]
);
}
#[tokio::test]
async fn test_relay_oauth_callback_correct_state_proceeds() {
use axum::body::Body;
use tower::ServiceExt;
let secrets = test_secrets_store();
let nonce = "valid-test-nonce-12345";
// Store the correct nonce
secrets
.create(
"test",
crate::secrets::CreateSecretParams::new(
format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME),
nonce,
),
)
.await
.expect("store nonce");
let ext_mgr = test_ext_mgr(secrets.clone());
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
// Callback with correct state param — will pass CSRF check
// but may fail downstream (no real relay service) — that's OK,
// we just verify it doesn't return a CSRF error.
let req = axum::http::Request::builder()
.uri(format!(
"/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}",
nonce
))
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
// Should NOT contain the CSRF error message
assert!(
!html.contains("Invalid or expired authorization"),
"Should have passed CSRF check, got: {}",
&html[..html.len().min(300)]
);
// Verify the nonce was consumed (deleted)
let state_key = format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME);
let exists = secrets.exists("test", &state_key).await.unwrap_or(true);
assert!(!exists, "CSRF nonce should be deleted after use");
}
} }
+2 -2
View File
@@ -2350,8 +2350,8 @@ function renderExtensionCard(ext) {
activeLabel.textContent = ext.active ? 'Active' : 'Installed'; activeLabel.textContent = ext.active ? 'Active' : 'Installed';
actions.appendChild(activeLabel); actions.appendChild(activeLabel);
// MCP servers may be installed but inactive — show Activate button // MCP servers and channel-relay extensions may be installed but inactive — show Activate button
if (ext.kind === 'mcp_server' && !ext.active) { if ((ext.kind === 'mcp_server' || ext.kind === 'channel_relay') && !ext.active) {
const activateBtn = document.createElement('button'); const activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate'; activateBtn.className = 'btn-ext activate';
activateBtn.textContent = 'Activate'; activateBtn.textContent = 'Activate';
+17
View File
@@ -1277,6 +1277,8 @@ body {
gap: 8px; gap: 8px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
flex-shrink: 0;
min-height: 56px;
} }
.chat-input textarea { .chat-input textarea {
@@ -3720,6 +3722,21 @@ mark {
.ext-install-form input { .ext-install-form input {
width: 100%; width: 100%;
} }
/* Chat input: ensure visibility on mobile */
.chat-input {
min-height: 52px;
}
.chat-input textarea {
min-height: 36px;
max-height: 100px;
}
.chat-input button {
padding: 6px 16px;
font-size: 14px;
}
} }
/* Slash command autocomplete dropdown */ /* Slash command autocomplete dropdown */
+1
View File
@@ -82,6 +82,7 @@ impl TestGatewayBuilder {
skill_catalog: None, skill_catalog: None,
scheduler: None, scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60), chat_rate_limiter: RateLimiter::new(30, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(), registry_entries: Vec::new(),
cost_guard: None, cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
+54
View File
@@ -735,6 +735,60 @@ pub struct RoutineInfo {
pub status: String, pub status: String,
} }
impl RoutineInfo {
/// Convert a `Routine` to the trimmed `RoutineInfo` for list display.
pub fn from_routine(r: &crate::agent::routine::Routine) -> Self {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
pattern, channel, ..
} => {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::SystemEvent {
source, event_type, ..
} => (
"system_event".to_string(),
format!("event: {}.{}", source, event_type),
),
crate::agent::routine::Trigger::Manual => {
("manual".to_string(), "manual only".to_string())
}
};
let action_type = match &r.action {
crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight",
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
};
let status = if !r.enabled {
"disabled"
} else if r.consecutive_failures > 0 {
"failing"
} else {
"active"
};
RoutineInfo {
id: r.id,
name: r.name.clone(),
description: r.description.clone(),
enabled: r.enabled,
trigger_type,
trigger_summary,
action_type: action_type.to_string(),
last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()),
next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: r.run_count,
consecutive_failures: r.consecutive_failures,
status: status.to_string(),
}
}
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct RoutineListResponse { pub struct RoutineListResponse {
pub routines: Vec<RoutineInfo>, pub routines: Vec<RoutineInfo>,
+1
View File
@@ -509,6 +509,7 @@ mod tests {
skill_registry: None, skill_registry: None,
skill_catalog: None, skill_catalog: None,
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60), chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
oauth_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
registry_entries: Vec::new(), registry_entries: Vec::new(),
cost_guard: None, cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
+236
View File
@@ -24,6 +24,8 @@ pub struct WebhookServerConfig {
pub struct WebhookServer { pub struct WebhookServer {
config: WebhookServerConfig, config: WebhookServerConfig,
routes: Vec<Router>, routes: Vec<Router>,
/// Merged router saved after start() for restart_with_addr().
merged_router: Option<Router>,
shutdown_tx: Option<oneshot::Sender<()>>, shutdown_tx: Option<oneshot::Sender<()>>,
handle: Option<JoinHandle<()>>, handle: Option<JoinHandle<()>>,
} }
@@ -34,6 +36,7 @@ impl WebhookServer {
Self { Self {
config, config,
routes: Vec::new(), routes: Vec::new(),
merged_router: None,
shutdown_tx: None, shutdown_tx: None,
handle: None, handle: None,
} }
@@ -51,7 +54,13 @@ impl WebhookServer {
for fragment in self.routes.drain(..) { for fragment in self.routes.drain(..) {
app = app.merge(fragment); app = app.merge(fragment);
} }
self.merged_router = Some(app.clone());
self.bind_and_spawn(app).await
}
/// Bind a listener to the configured address and spawn the server task.
/// Private helper used by both start() and restart_with_addr().
async fn bind_and_spawn(&mut self, app: Router) -> Result<(), ChannelError> {
let listener = tokio::net::TcpListener::bind(self.config.addr) let listener = tokio::net::TcpListener::bind(self.config.addr)
.await .await
.map_err(|e| ChannelError::StartupFailed { .map_err(|e| ChannelError::StartupFailed {
@@ -80,6 +89,54 @@ impl WebhookServer {
Ok(()) Ok(())
} }
/// Gracefully shut down the current listener and rebind to a new address.
/// The merged router from the original `start()` call is reused.
///
/// If binding to the new address fails, the old listener remains active and
/// state is restored. This prevents a denial-of-service if the new address
/// is invalid or already in use.
pub async fn restart_with_addr(&mut self, new_addr: SocketAddr) -> Result<(), ChannelError> {
let app = self
.merged_router
.clone()
.ok_or_else(|| ChannelError::StartupFailed {
name: "webhook_server".to_string(),
reason: "restart_with_addr called before start()".to_string(),
})?;
// Save old state for rollback if new bind fails
let old_addr = self.config.addr;
let old_shutdown_tx = self.shutdown_tx.take();
let old_handle = self.handle.take();
// Update config to new address and try to bind
self.config.addr = new_addr;
match self.bind_and_spawn(app).await {
Ok(()) => {
// New listener is running, gracefully shut down the old one
if let Some(tx) = old_shutdown_tx {
let _ = tx.send(());
}
if let Some(handle) = old_handle {
let _ = handle.await;
}
Ok(())
}
Err(e) => {
// Restore old state; old listener remains active
self.config.addr = old_addr;
self.shutdown_tx = old_shutdown_tx;
self.handle = old_handle;
Err(e)
}
}
}
/// Return the current bind address.
pub fn current_addr(&self) -> SocketAddr {
self.config.addr
}
/// Signal graceful shutdown and wait for the server task to finish. /// Signal graceful shutdown and wait for the server task to finish.
pub async fn shutdown(&mut self) { pub async fn shutdown(&mut self) {
if let Some(tx) = self.shutdown_tx.take() { if let Some(tx) = self.shutdown_tx.take() {
@@ -90,3 +147,182 @@ impl WebhookServer {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use axum::Json;
use serde_json::json;
#[tokio::test]
async fn test_restart_with_addr_rebinds_listener() {
use std::net::TcpListener as StdTcpListener;
// Find two available ports by binding and immediately closing
let port1 = {
let listener =
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port 1");
listener
.local_addr()
.expect("Failed to get local addr")
.port()
};
let port2 = {
let listener =
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port 2");
listener
.local_addr()
.expect("Failed to get local addr")
.port()
};
assert_ne!(port1, port2, "Should have different ports");
assert_ne!(port1, 0, "Port 1 should be non-zero");
assert_ne!(port2, 0, "Port 2 should be non-zero");
// Start server on first port
let addr1 = format!("127.0.0.1:{}", port1).parse().unwrap();
let mut server = WebhookServer::new(WebhookServerConfig { addr: addr1 });
// Create a test router that responds to health checks
let test_router = axum::Router::new().route(
"/health",
axum::routing::get(|| async { Json(json!({"status": "ok"})) }),
);
server.add_routes(test_router);
// Start the server on first port
server.start().await.expect("Failed to start server");
assert_eq!(
server.current_addr(),
addr1,
"Server should be bound to initial address"
);
// Verify the first server is actually listening
let client = reqwest::Client::new();
let response = client
.get(format!("http://{}/health", addr1))
.send()
.await
.expect("Failed to send request to first server");
assert_eq!(
response.status(),
200,
"First server should respond to health check"
);
// Restart on second port
let addr2 = format!("127.0.0.1:{}", port2).parse().unwrap();
server
.restart_with_addr(addr2)
.await
.expect("Failed to restart with new addr");
// Assert the address changed
assert_eq!(
server.current_addr(),
addr2,
"Server address should be updated after restart"
);
assert_ne!(
addr1, addr2,
"Address should change after restart_with_addr"
);
// Verify the new server is actually listening on the new address
let response = client
.get(format!("http://{}/health", addr2))
.send()
.await
.expect("Failed to send request to restarted server");
assert_eq!(
response.status(),
200,
"Restarted server should respond to health check on new address"
);
// Verify the old address is no longer responding
let old_result = tokio::time::timeout(
std::time::Duration::from_millis(200),
client.get(format!("http://{}/health", addr1)).send(),
)
.await;
assert!(
old_result.is_err() || old_result.as_ref().unwrap().is_err(),
"Old address should not respond after server restarts"
);
// Clean up
server.shutdown().await;
}
#[tokio::test]
async fn test_restart_with_addr_rollback_on_bind_failure() {
use std::net::TcpListener as StdTcpListener;
// Find an available port
let port1 = {
let listener =
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port");
listener
.local_addr()
.expect("Failed to get local addr")
.port()
};
// Start server on first port
let addr1 = format!("127.0.0.1:{}", port1).parse().unwrap();
let mut server = WebhookServer::new(WebhookServerConfig { addr: addr1 });
// Create a test router
let test_router = axum::Router::new().route(
"/health",
axum::routing::get(|| async { Json(json!({"status": "ok"})) }),
);
server.add_routes(test_router);
// Start the server on first port
server.start().await.expect("Failed to start server");
// Verify the server is listening
let client = reqwest::Client::new();
let response = client
.get(format!("http://{}/health", addr1))
.send()
.await
.expect("Failed to send request");
assert_eq!(response.status(), 200, "Server should be listening");
// Try to restart on an invalid address (port 0 is reserved, won't bind)
// Use port 1 which typically requires elevated privileges
let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
// Attempt restart (should fail)
let result = server.restart_with_addr(invalid_addr).await;
assert!(result.is_err(), "Restart with invalid address should fail");
// Verify the old address is still responding (rollback succeeded)
let response = client
.get(format!("http://{}/health", addr1))
.send()
.await
.expect("Failed to send request to old address");
assert_eq!(
response.status(),
200,
"Old listener should still be running after failed restart"
);
// Verify the server address is unchanged
assert_eq!(
server.current_addr(),
addr1,
"Server address should be restored after failed restart"
);
// Clean up
server.shutdown().await;
}
}
+541 -5
View File
@@ -7,6 +7,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use crate::bootstrap::ironclaw_base_dir; use crate::bootstrap::ironclaw_base_dir;
use crate::settings::Settings;
/// Run all diagnostic checks and print results. /// Run all diagnostic checks and print results.
pub async fn run_doctor_command() -> anyhow::Result<()> { pub async fn run_doctor_command() -> anyhow::Result<()> {
@@ -15,14 +16,35 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
let mut passed = 0u32; let mut passed = 0u32;
let mut failed = 0u32; let mut failed = 0u32;
let mut skipped = 0u32;
// ── Configuration checks ────────────────────────────────── // Load settings once for checks that need them.
let settings = Settings::load();
// ── Settings & core config ─────────────────────────────────
check(
"Settings file",
check_settings_file(),
&mut passed,
&mut failed,
&mut skipped,
);
check( check(
"NEAR AI session", "NEAR AI session",
check_nearai_session().await, check_nearai_session().await,
&mut passed, &mut passed,
&mut failed, &mut failed,
&mut skipped,
);
check(
"LLM configuration",
check_llm_config(&settings),
&mut passed,
&mut failed,
&mut skipped,
); );
check( check(
@@ -30,6 +52,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_database().await, check_database().await,
&mut passed, &mut passed,
&mut failed, &mut failed,
&mut skipped,
); );
check( check(
@@ -37,15 +60,75 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_workspace_dir(), check_workspace_dir(),
&mut passed, &mut passed,
&mut failed, &mut failed,
&mut skipped,
);
// ── Subsystem configuration checks ─────────────────────────
check(
"Embeddings",
check_embeddings(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Routines config",
check_routines_config(),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Gateway config",
check_gateway_config(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"MCP servers",
check_mcp_config().await,
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Skills",
check_skills().await,
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Secrets",
check_secrets(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Service",
check_service_installed(),
&mut passed,
&mut failed,
&mut skipped,
); );
// ── External binary checks ──────────────────────────────── // ── External binary checks ────────────────────────────────
check( check(
"Docker", "Docker daemon",
check_binary("docker", &["--version"]), check_docker_daemon().await,
&mut passed, &mut passed,
&mut failed, &mut failed,
&mut skipped,
); );
check( check(
@@ -53,6 +136,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_binary("cloudflared", &["--version"]), check_binary("cloudflared", &["--version"]),
&mut passed, &mut passed,
&mut failed, &mut failed,
&mut skipped,
); );
check( check(
@@ -60,6 +144,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_binary("ngrok", &["version"]), check_binary("ngrok", &["version"]),
&mut passed, &mut passed,
&mut failed, &mut failed,
&mut skipped,
); );
check( check(
@@ -67,12 +152,13 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_binary("tailscale", &["version"]), check_binary("tailscale", &["version"]),
&mut passed, &mut passed,
&mut failed, &mut failed,
&mut skipped,
); );
// ── Summary ─────────────────────────────────────────────── // ── Summary ───────────────────────────────────────────────
println!(); println!();
println!(" {passed} passed, {failed} failed"); println!(" {passed} passed, {failed} failed, {skipped} skipped");
if failed > 0 { if failed > 0 {
println!("\n Some checks failed. This is normal if you don't use those features."); println!("\n Some checks failed. This is normal if you don't use those features.");
@@ -83,7 +169,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
// ── Individual checks ─────────────────────────────────────── // ── Individual checks ───────────────────────────────────────
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32) { fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32, skipped: &mut u32) {
match result { match result {
CheckResult::Pass(detail) => { CheckResult::Pass(detail) => {
*passed += 1; *passed += 1;
@@ -94,6 +180,7 @@ fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32) {
println!(" [FAIL] {name}: {detail}"); println!(" [FAIL] {name}: {detail}");
} }
CheckResult::Skip(reason) => { CheckResult::Skip(reason) => {
*skipped += 1;
println!(" [skip] {name}: {reason}"); println!(" [skip] {name}: {reason}");
} }
} }
@@ -105,6 +192,29 @@ enum CheckResult {
Skip(String), Skip(String),
} }
// ── Settings file ───────────────────────────────────────────
fn check_settings_file() -> CheckResult {
let path = Settings::default_path();
if !path.exists() {
return CheckResult::Pass("no settings file (defaults will be used)".into());
}
match std::fs::read_to_string(&path) {
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
Ok(_) => CheckResult::Pass(format!("valid ({})", path.display())),
Err(e) => CheckResult::Fail(format!(
"settings.json is malformed: {}. Fix or delete {}",
e,
path.display()
)),
},
Err(e) => CheckResult::Fail(format!("cannot read {}: {}", path.display(), e)),
}
}
// ── NEAR AI session ─────────────────────────────────────────
async fn check_nearai_session() -> CheckResult { async fn check_nearai_session() -> CheckResult {
// 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();
@@ -129,6 +239,27 @@ async fn check_nearai_session() -> CheckResult {
} }
} }
// ── LLM configuration ──────────────────────────────────────
fn check_llm_config(settings: &Settings) -> CheckResult {
match crate::llm::LlmConfig::resolve(settings) {
Ok(config) => {
// Show the model for the active backend, not always nearai.model.
let model = if let Some(ref bedrock) = config.bedrock {
&bedrock.model
} else if let Some(ref provider) = config.provider {
&provider.model
} else {
&config.nearai.model
};
CheckResult::Pass(format!("backend={}, model={}", config.backend, model))
}
Err(e) => CheckResult::Fail(format!("LLM config error: {e}")),
}
}
// ── Database ────────────────────────────────────────────────
async fn check_database() -> CheckResult { async fn check_database() -> CheckResult {
let backend = std::env::var("DATABASE_BACKEND") let backend = std::env::var("DATABASE_BACKEND")
.ok() .ok()
@@ -192,6 +323,8 @@ async fn try_pg_connect() -> Result<(), String> {
Err("postgres feature not compiled in".into()) Err("postgres feature not compiled in".into())
} }
// ── Workspace directory ─────────────────────────────────────
fn check_workspace_dir() -> CheckResult { fn check_workspace_dir() -> CheckResult {
let dir = ironclaw_base_dir(); let dir = ironclaw_base_dir();
@@ -206,6 +339,222 @@ fn check_workspace_dir() -> CheckResult {
} }
} }
// ── Embeddings ──────────────────────────────────────────────
fn check_embeddings(settings: &Settings) -> CheckResult {
match crate::config::EmbeddingsConfig::resolve(settings) {
Ok(config) => {
if !config.enabled {
return CheckResult::Skip("disabled (set EMBEDDING_ENABLED=true)".into());
}
let has_creds = match config.provider.as_str() {
"openai" => config.openai_api_key().is_some(),
"nearai" => {
// NearAiEmbeddings uses SessionManager::get_token() which
// only returns session tokens, NOT NEARAI_API_KEY
// (src/workspace/embeddings.rs:309, src/llm/session.rs:132).
let session_path = crate::config::llm::default_session_path();
session_path.exists()
&& std::fs::read_to_string(&session_path)
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
"ollama" => true, // local, no creds needed
_ => config.openai_api_key().is_some(),
};
if has_creds {
CheckResult::Pass(format!(
"provider={}, model={}",
config.provider, config.model
))
} else {
let hint = match config.provider.as_str() {
"nearai" => "run `ironclaw onboard` to create a session",
_ => "set OPENAI_API_KEY",
};
CheckResult::Fail(format!(
"provider={} but credentials missing ({})",
config.provider, hint
))
}
}
Err(e) => CheckResult::Fail(format!("config error: {e}")),
}
}
// ── Routines config ─────────────────────────────────────────
fn check_routines_config() -> CheckResult {
match crate::config::RoutineConfig::resolve() {
Ok(config) => {
if config.enabled {
CheckResult::Pass(format!(
"enabled (interval={}s, max_concurrent={})",
config.cron_check_interval_secs, config.max_concurrent_routines
))
} else {
CheckResult::Skip("disabled".into())
}
}
Err(e) => CheckResult::Fail(format!("config error: {e}")),
}
}
// ── Gateway config ──────────────────────────────────────────
fn check_gateway_config(settings: &Settings) -> CheckResult {
// Use the same resolve() path as runtime so invalid env values
// (e.g. GATEWAY_PORT=abc) are caught here too.
match crate::config::ChannelsConfig::resolve(settings) {
Ok(channels) => match channels.gateway {
Some(gw) => {
if gw.auth_token.is_some() {
CheckResult::Pass(format!(
"enabled at {}:{} (auth token set)",
gw.host, gw.port
))
} else {
CheckResult::Pass(format!(
"enabled at {}:{} (no auth token — random token will be generated)",
gw.host, gw.port
))
}
}
None => CheckResult::Skip("disabled (GATEWAY_ENABLED=false)".into()),
},
Err(e) => CheckResult::Fail(format!("config error: {e}")),
}
}
// ── MCP servers ─────────────────────────────────────────────
async fn check_mcp_config() -> CheckResult {
match crate::tools::mcp::config::load_mcp_servers().await {
Ok(file) => {
let servers: Vec<_> = file.enabled_servers().collect();
if servers.is_empty() {
return CheckResult::Skip("no MCP servers configured".into());
}
let mut invalid = Vec::new();
for server in &servers {
if let Err(e) = server.validate() {
invalid.push(format!("{}: {}", server.name, e));
}
}
if invalid.is_empty() {
CheckResult::Pass(format!("{} server(s) configured, all valid", servers.len()))
} else {
CheckResult::Fail(format!(
"{} server(s), {} invalid: {}",
servers.len(),
invalid.len(),
invalid.join("; ")
))
}
}
Err(e) => {
// Distinguish no config from corrupted config
let msg = e.to_string();
if msg.contains("not found") || msg.contains("No such file") {
CheckResult::Skip("no MCP config file".into())
} else {
CheckResult::Fail(format!("config error: {e}"))
}
}
}
}
// ── Skills ──────────────────────────────────────────────────
async fn check_skills() -> CheckResult {
let user_dir = ironclaw_base_dir().join("skills");
let installed_dir = ironclaw_base_dir().join("installed_skills");
let mut registry = crate::skills::SkillRegistry::new(user_dir.clone());
registry = registry.with_installed_dir(installed_dir);
// discover_all() returns loaded skill names (not warnings).
let _loaded_names = registry.discover_all().await;
let count = registry.count();
if count == 0 {
return CheckResult::Skip("no skills discovered".into());
}
CheckResult::Pass(format!("{count} skill(s) loaded"))
}
// ── Secrets ─────────────────────────────────────────────────
fn check_secrets(settings: &Settings) -> CheckResult {
match settings.secrets_master_key_source {
crate::settings::KeySource::Keychain => {
CheckResult::Pass("master key source: OS keychain".into())
}
crate::settings::KeySource::Env => {
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
CheckResult::Pass("master key source: env var (set)".into())
} else {
CheckResult::Fail(
"master key source: env var but SECRETS_MASTER_KEY not set".into(),
)
}
}
crate::settings::KeySource::None => {
CheckResult::Skip("secrets not configured (run `ironclaw onboard`)".into())
}
}
}
// ── Service ─────────────────────────────────────────────────
fn check_service_installed() -> CheckResult {
if cfg!(target_os = "macos") {
let plist =
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.ironclaw.daemon.plist"));
match plist {
Some(path) if path.exists() => {
CheckResult::Pass(format!("launchd plist installed ({})", path.display()))
}
Some(_) => CheckResult::Skip("not installed (run `ironclaw service install`)".into()),
None => CheckResult::Skip("cannot determine home directory".into()),
}
} else if cfg!(target_os = "linux") {
let unit = dirs::home_dir().map(|h| h.join(".config/systemd/user/ironclaw.service"));
match unit {
Some(path) if path.exists() => {
CheckResult::Pass(format!("systemd unit installed ({})", path.display()))
}
Some(_) => CheckResult::Skip("not installed (run `ironclaw service install`)".into()),
None => CheckResult::Skip("cannot determine home directory".into()),
}
} else {
CheckResult::Skip("service management not supported on this platform".into())
}
}
// ── Docker daemon ───────────────────────────────────────────
async fn check_docker_daemon() -> CheckResult {
let detection = crate::sandbox::check_docker().await;
match detection.status {
crate::sandbox::DockerStatus::Available => CheckResult::Pass("running".into()),
crate::sandbox::DockerStatus::NotInstalled => CheckResult::Skip(format!(
"not installed. {}",
detection.platform.install_hint()
)),
crate::sandbox::DockerStatus::NotRunning => CheckResult::Fail(format!(
"installed but not running. {}",
detection.platform.start_hint()
)),
crate::sandbox::DockerStatus::Disabled => CheckResult::Skip("sandbox disabled".into()),
}
}
// ── External binary ─────────────────────────────────────────
fn check_binary(name: &str, args: &[&str]) -> CheckResult { fn check_binary(name: &str, args: &[&str]) -> CheckResult {
match std::process::Command::new(name) match std::process::Command::new(name)
.args(args) .args(args)
@@ -273,6 +622,193 @@ mod tests {
} }
} }
#[test]
fn check_settings_file_handles_missing() {
// Settings::default_path() might or might not exist, but must not panic
let result = check_settings_file();
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_llm_config_does_not_panic() {
let settings = Settings::default();
let result = check_llm_config(&settings);
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_routines_config_does_not_panic() {
let result = check_routines_config();
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_gateway_config_does_not_panic() {
let settings = Settings::default();
let result = check_gateway_config(&settings);
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_embeddings_does_not_panic() {
let settings = Settings::default();
let result = check_embeddings(&settings);
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_secrets_none_returns_skip() {
let settings = Settings::default();
match check_secrets(&settings) {
CheckResult::Skip(msg) => {
assert!(
msg.contains("not configured"),
"expected 'not configured' in skip message, got: {msg}"
);
}
other => panic!(
"expected Skip for default settings, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_service_installed_does_not_panic() {
let result = check_service_installed();
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[tokio::test]
async fn check_docker_daemon_does_not_panic() {
let result = check_docker_daemon().await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[tokio::test]
async fn check_mcp_config_does_not_panic() {
let result = check_mcp_config().await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[tokio::test]
async fn check_skills_does_not_panic() {
let result = check_skills().await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_llm_config_shows_nearai_model_for_nearai_backend() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("LLM_BACKEND");
}
let settings = Settings::default();
match check_llm_config(&settings) {
CheckResult::Pass(msg) => {
assert!(
msg.contains("backend=nearai"),
"expected nearai backend, got: {msg}"
);
// Must NOT show a bedrock or registry model when backend is nearai
assert!(
!msg.contains("anthropic.claude"),
"should not show bedrock model for nearai backend: {msg}"
);
}
other => panic!(
"expected Pass for default LLM config, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_embeddings_disabled_by_default_returns_skip() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
}
let settings = Settings::default();
match check_embeddings(&settings) {
CheckResult::Skip(msg) => {
assert!(
msg.contains("disabled"),
"expected 'disabled' in skip message, got: {msg}"
);
}
other => panic!(
"expected Skip for disabled embeddings, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_routines_enabled_by_default() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("ROUTINES_ENABLED");
}
match check_routines_config() {
CheckResult::Pass(msg) => {
assert!(
msg.contains("enabled"),
"routines should be enabled by default, got: {msg}"
);
}
other => panic!(
"expected Pass for default routines, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_secrets_env_without_var_returns_fail() {
let settings = Settings {
secrets_master_key_source: crate::settings::KeySource::Env,
..Default::default()
};
match check_secrets(&settings) {
CheckResult::Fail(msg) => {
assert!(
msg.contains("SECRETS_MASTER_KEY not set"),
"expected mention of missing env var, got: {msg}"
);
}
CheckResult::Pass(_) => {
// If SECRETS_MASTER_KEY happens to be set in the environment,
// Pass is correct — don't fail the test.
}
other => panic!(
"expected Fail or Pass for env key source, got: {}",
format_result(&other)
),
}
}
fn format_result(r: &CheckResult) -> String { fn format_result(r: &CheckResult) -> String {
match r { match r {
CheckResult::Pass(s) => format!("Pass({s})"), CheckResult::Pass(s) => format!("Pass({s})"),
+10
View File
@@ -209,6 +209,7 @@ impl LlmConfig {
extra_headers_env, extra_headers_env,
api_key_required, api_key_required,
base_url_required, base_url_required,
unsupported_params,
) = if let Some(def) = def { ) = if let Some(def) = def {
( (
def.id.as_str(), def.id.as_str(),
@@ -221,6 +222,7 @@ impl LlmConfig {
def.extra_headers_env.as_deref(), def.extra_headers_env.as_deref(),
def.api_key_required, def.api_key_required,
def.base_url_required, def.base_url_required,
def.unsupported_params.clone(),
) )
} else { } else {
// Absolute fallback: treat as generic openai_completions // Absolute fallback: treat as generic openai_completions
@@ -235,6 +237,7 @@ impl LlmConfig {
Some("LLM_EXTRA_HEADERS"), Some("LLM_EXTRA_HEADERS"),
false, false,
true, true,
Vec::new(),
) )
}; };
@@ -338,6 +341,7 @@ impl LlmConfig {
extra_headers, extra_headers,
oauth_token, oauth_token,
cache_retention, cache_retention,
unsupported_params,
}) })
} }
} }
@@ -625,6 +629,12 @@ mod tests {
let provider = cfg.provider.expect("provider config should be present"); let provider = cfg.provider.expect("provider config should be present");
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1"); assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
assert_eq!(provider.model, "kimi-k2-5"); assert_eq!(provider.model, "kimi-k2-5");
assert!(
provider
.unsupported_params
.contains(&"temperature".to_string()),
"tinfoil should propagate unsupported_params from registry"
);
} }
#[test] #[test]
+7
View File
@@ -14,6 +14,7 @@ mod heartbeat;
pub(crate) mod helpers; pub(crate) mod helpers;
mod hygiene; mod hygiene;
pub(crate) mod llm; pub(crate) mod llm;
pub mod relay;
mod routines; mod routines;
mod safety; mod safety;
mod sandbox; mod sandbox;
@@ -38,6 +39,7 @@ pub use self::embeddings::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;
pub use self::relay::RelayConfig;
pub use self::routines::RoutineConfig; pub use self::routines::RoutineConfig;
pub use self::safety::SafetyConfig; pub use self::safety::SafetyConfig;
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig}; pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
@@ -85,6 +87,9 @@ pub struct Config {
pub skills: SkillsConfig, pub skills: SkillsConfig,
pub transcription: TranscriptionConfig, pub transcription: TranscriptionConfig,
pub observability: crate::observability::ObservabilityConfig, pub observability: crate::observability::ObservabilityConfig,
/// Channel-relay integration (Slack via external relay service).
/// Present only when both `CHANNEL_RELAY_URL` and `CHANNEL_RELAY_API_KEY` are set.
pub relay: Option<RelayConfig>,
} }
impl Config { impl Config {
@@ -157,6 +162,7 @@ impl Config {
}, },
transcription: TranscriptionConfig::default(), transcription: TranscriptionConfig::default(),
observability: crate::observability::ObservabilityConfig::default(), observability: crate::observability::ObservabilityConfig::default(),
relay: None,
} }
} }
@@ -310,6 +316,7 @@ impl Config {
observability: crate::observability::ObservabilityConfig { observability: crate::observability::ObservabilityConfig {
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()), backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
}, },
relay: RelayConfig::from_env(),
}) })
} }
} }
+157
View File
@@ -0,0 +1,157 @@
//! Channel-relay service configuration.
use secrecy::SecretString;
/// Configuration for connecting to a channel-relay service.
#[derive(Clone)]
pub struct RelayConfig {
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
pub url: String,
/// API key for authenticated channel-relay endpoints.
pub api_key: SecretString,
/// Override for the OAuth callback URL (e.g., a tunnel URL).
pub callback_url: Option<String>,
/// Override for the instance identifier.
pub instance_id: Option<String>,
/// HTTP request timeout in seconds (default: 30).
pub request_timeout_secs: u64,
/// SSE stream long-poll timeout in seconds (default: 86400 = 24 h).
pub stream_timeout_secs: u64,
/// Initial exponential backoff in milliseconds (default: 1000).
pub backoff_initial_ms: u64,
/// Maximum exponential backoff in milliseconds (default: 60000).
pub backoff_max_ms: u64,
}
impl std::fmt::Debug for RelayConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RelayConfig")
.field("url", &self.url)
.field("api_key", &"[REDACTED]")
.field("callback_url", &self.callback_url)
.field("instance_id", &self.instance_id)
.field("request_timeout_secs", &self.request_timeout_secs)
.field("stream_timeout_secs", &self.stream_timeout_secs)
.field("backoff_initial_ms", &self.backoff_initial_ms)
.field("backoff_max_ms", &self.backoff_max_ms)
.finish()
}
}
impl RelayConfig {
/// Load relay config from environment variables.
///
/// Returns `None` if either `CHANNEL_RELAY_URL` or `CHANNEL_RELAY_API_KEY`
/// is not set, making the relay integration opt-in.
pub fn from_env() -> Option<Self> {
Self::from_env_reader(|key| std::env::var(key).ok())
}
/// Build a config for tests without touching the process environment.
pub fn from_values(url: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
url: url.into(),
api_key: SecretString::from(api_key.into()),
callback_url: None,
instance_id: None,
request_timeout_secs: 30,
stream_timeout_secs: 86400,
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
}
}
/// Internal constructor that reads values through a closure, enabling safe testing.
fn from_env_reader(env: impl Fn(&str) -> Option<String>) -> Option<Self> {
let url = env("CHANNEL_RELAY_URL")?;
let api_key = SecretString::from(env("CHANNEL_RELAY_API_KEY")?);
Some(Self {
url,
api_key,
callback_url: env("IRONCLAW_OAUTH_CALLBACK_URL"),
instance_id: env("IRONCLAW_INSTANCE_ID"),
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
.and_then(|v| v.parse().ok())
.unwrap_or(30),
stream_timeout_secs: env("RELAY_STREAM_TIMEOUT_SECS")
.and_then(|v| v.parse().ok())
.unwrap_or(86400),
backoff_initial_ms: env("RELAY_BACKOFF_INITIAL_MS")
.and_then(|v| v.parse().ok())
.unwrap_or(1000),
backoff_max_ms: env("RELAY_BACKOFF_MAX_MS")
.and_then(|v| v.parse().ok())
.unwrap_or(60000),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_env_reader_returns_none_when_unset() {
let config = RelayConfig::from_env_reader(|_| None);
assert!(config.is_none());
}
#[test]
fn from_env_reader_loads_defaults() {
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,
})
.expect("config should be Some");
assert_eq!(config.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30);
assert_eq!(config.stream_timeout_secs, 86400);
assert_eq!(config.backoff_initial_ms, 1000);
assert_eq!(config.backoff_max_ms, 60000);
assert!(config.callback_url.is_none());
assert!(config.instance_id.is_none());
}
#[test]
fn from_env_reader_loads_overrides() {
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://relay:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("secret".into()),
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
"RELAY_STREAM_TIMEOUT_SECS" => Some("43200".into()),
"RELAY_BACKOFF_INITIAL_MS" => Some("2000".into()),
"RELAY_BACKOFF_MAX_MS" => Some("120000".into()),
_ => None,
})
.expect("config should be Some");
assert_eq!(
config.callback_url.as_deref(),
Some("https://tunnel.example.com")
);
assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
assert_eq!(config.request_timeout_secs, 60);
assert_eq!(config.stream_timeout_secs, 43200);
assert_eq!(config.backoff_initial_ms, 2000);
assert_eq!(config.backoff_max_ms, 120000);
}
#[test]
fn from_values_builds_with_defaults() {
let config = RelayConfig::from_values("http://localhost:3001", "key");
assert_eq!(config.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30);
}
#[test]
fn debug_redacts_api_key() {
let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
let debug = format!("{:?}", config);
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("super-secret"));
}
}
+14 -8
View File
@@ -30,8 +30,9 @@ impl JobStore for LibSqlBackend {
id, conversation_id, title, description, category, status, source, id, conversation_id, title, description, category, status, source,
user_id, user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs, budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, created_at, started_at, completed_at actual_cost, repair_attempts, max_tokens, total_tokens_used,
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) created_at, started_at, completed_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
title = excluded.title, title = excluded.title,
description = excluded.description, description = excluded.description,
@@ -42,6 +43,8 @@ impl JobStore for LibSqlBackend {
estimated_time_secs = excluded.estimated_time_secs, estimated_time_secs = excluded.estimated_time_secs,
actual_cost = excluded.actual_cost, actual_cost = excluded.actual_cost,
repair_attempts = excluded.repair_attempts, repair_attempts = excluded.repair_attempts,
max_tokens = excluded.max_tokens,
total_tokens_used = excluded.total_tokens_used,
started_at = excluded.started_at, started_at = excluded.started_at,
completed_at = excluded.completed_at completed_at = excluded.completed_at
"#, "#,
@@ -61,6 +64,8 @@ impl JobStore for LibSqlBackend {
estimated_time_secs, estimated_time_secs,
ctx.actual_cost.to_string(), ctx.actual_cost.to_string(),
ctx.repair_attempts as i64, ctx.repair_attempts as i64,
ctx.max_tokens as i64,
ctx.total_tokens_used as i64,
fmt_ts(&ctx.created_at), fmt_ts(&ctx.created_at),
fmt_opt_ts(&ctx.started_at), fmt_opt_ts(&ctx.started_at),
fmt_opt_ts(&ctx.completed_at), fmt_opt_ts(&ctx.completed_at),
@@ -78,7 +83,8 @@ impl JobStore for LibSqlBackend {
r#" r#"
SELECT id, conversation_id, title, description, category, status, user_id, SELECT id, conversation_id, title, description, category, status, user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs, budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, created_at, started_at, completed_at actual_cost, repair_attempts, max_tokens, total_tokens_used,
created_at, started_at, completed_at
FROM agent_jobs WHERE id = ?1 FROM agent_jobs WHERE id = ?1
"#, "#,
params![id.to_string()], params![id.to_string()],
@@ -111,12 +117,12 @@ impl JobStore for LibSqlBackend {
estimated_duration: estimated_time_secs estimated_duration: estimated_time_secs
.map(|s| std::time::Duration::from_secs(s as u64)), .map(|s| std::time::Duration::from_secs(s as u64)),
actual_cost: get_decimal(&row, 12), actual_cost: get_decimal(&row, 12),
total_tokens_used: 0, max_tokens: get_i64(&row, 14) as u64,
max_tokens: 0, total_tokens_used: get_i64(&row, 15) as u64,
repair_attempts: get_i64(&row, 13) as u32, repair_attempts: get_i64(&row, 13) as u32,
created_at: get_ts(&row, 14), created_at: get_ts(&row, 16),
started_at: get_opt_ts(&row, 15), started_at: get_opt_ts(&row, 17),
completed_at: get_opt_ts(&row, 16), completed_at: get_opt_ts(&row, 18),
transitions: Vec::new(), transitions: Vec::new(),
metadata: serde_json::Value::Null, metadata: serde_json::Value::Null,
extra_env: std::sync::Arc::new(std::collections::HashMap::new()), extra_env: std::sync::Arc::new(std::collections::HashMap::new()),
+1 -1
View File
@@ -167,7 +167,7 @@ impl RoutineStore for LibSqlBackend {
let mut rows = conn let mut rows = conn
.query( .query(
&format!( &format!(
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'event'", "SELECT {} FROM routines WHERE enabled = 1 AND trigger_type IN ('event', 'system_event')",
ROUTINE_COLUMNS ROUTINE_COLUMNS
), ),
(), (),
+34 -18
View File
@@ -583,20 +583,21 @@ INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, acti
/// ///
/// Each entry is `(version, name, sql)`. Migrations are idempotent: the /// Each entry is `(version, name, sql)`. Migrations are idempotent: the
/// `_migrations` table tracks which versions have been applied. /// `_migrations` table tracks which versions have been applied.
pub const INCREMENTAL_MIGRATIONS: &[(i64, &str, &str)] = &[( pub const INCREMENTAL_MIGRATIONS: &[(i64, &str, &str)] = &[
9, (
"flexible_embedding_dimension", 9,
// Rebuild memory_chunks to remove the fixed F32_BLOB(1536) type "flexible_embedding_dimension",
// constraint so any embedding dimension works. Existing embeddings // Rebuild memory_chunks to remove the fixed F32_BLOB(1536) type
// are preserved; users only need to re-embed if they change models. // constraint so any embedding dimension works. Existing embeddings
// // are preserved; users only need to re-embed if they change models.
// The vector index (libsql_vector_idx) requires a fixed-dimension //
// F32_BLOB(N), so we drop it entirely. Vector search falls back to // The vector index (libsql_vector_idx) requires a fixed-dimension
// brute-force cosine distance which is fast enough for personal // F32_BLOB(N), so we drop it entirely. Vector search falls back to
// assistant workspaces. This matches PostgreSQL after its V9 migration. // brute-force cosine distance which is fast enough for personal
// // assistant workspaces. This matches PostgreSQL after its V9 migration.
// SQLite cannot ALTER COLUMN types, so we recreate the table. //
r#" // SQLite cannot ALTER COLUMN types, so we recreate the table.
r#"
-- Drop vector index (requires fixed F32_BLOB(N), incompatible with flexible dimensions) -- Drop vector index (requires fixed F32_BLOB(N), incompatible with flexible dimensions)
DROP INDEX IF EXISTS idx_memory_chunks_embedding; DROP INDEX IF EXISTS idx_memory_chunks_embedding;
@@ -644,7 +645,18 @@ CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chu
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content); INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
END; END;
"#, "#,
)]; ),
(
12,
"job_token_budget",
// Add token budget tracking columns to agent_jobs.
// SQLite supports ALTER TABLE ADD COLUMN, so no table rebuild needed.
r#"
ALTER TABLE agent_jobs ADD COLUMN max_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE agent_jobs ADD COLUMN total_tokens_used INTEGER NOT NULL DEFAULT 0;
"#,
),
];
/// Run incremental migrations that haven't been applied yet. /// Run incremental migrations that haven't been applied yet.
/// ///
@@ -653,6 +665,7 @@ END;
pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::error::DatabaseError> { pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::error::DatabaseError> {
use crate::error::DatabaseError; use crate::error::DatabaseError;
let mut applied_count = 0;
for &(version, name, sql) in INCREMENTAL_MIGRATIONS { for &(version, name, sql) in INCREMENTAL_MIGRATIONS {
// Check if already applied // Check if already applied
let mut rows = conn let mut rows = conn
@@ -669,8 +682,6 @@ pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::err
continue; // Already applied continue; // Already applied
} }
tracing::info!(version, name, "libSQL: applying incremental migration");
// Wrap migration + recording in a transaction for atomicity. // Wrap migration + recording in a transaction for atomicity.
// If the process crashes mid-migration, the transaction rolls back // If the process crashes mid-migration, the transaction rolls back
// and the migration will be retried on next startup. // and the migration will be retried on next startup.
@@ -702,7 +713,12 @@ pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::err
)) ))
})?; })?;
tracing::info!(version, name, "libSQL: migration applied successfully"); applied_count += 1;
tracing::debug!(version, name, "libSQL: migration applied");
}
if applied_count > 0 {
tracing::info!("libSQL: applied {} incremental migrations", applied_count);
} }
Ok(()) Ok(())
+1
View File
@@ -250,6 +250,7 @@ fn extract_source(source: &ExtensionSource) -> String {
ExtensionSource::Discovered { url } => url.clone(), ExtensionSource::Discovered { url } => url.clone(),
ExtensionSource::WasmDownload { wasm_url, .. } => wasm_url.clone(), ExtensionSource::WasmDownload { wasm_url, .. } => wasm_url.clone(),
ExtensionSource::WasmBuildable { source_dir, .. } => source_dir.clone(), ExtensionSource::WasmBuildable { source_dir, .. } => source_dir.clone(),
ExtensionSource::ChannelRelay { relay_url } => relay_url.clone(),
} }
} }
+790 -37
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -37,6 +37,8 @@ pub enum ExtensionKind {
WasmTool, WasmTool,
/// WASM channel module with hot-activation support. /// WASM channel module with hot-activation support.
WasmChannel, WasmChannel,
/// External channel via channel-relay service (Slack, etc.).
ChannelRelay,
} }
impl std::fmt::Display for ExtensionKind { impl std::fmt::Display for ExtensionKind {
@@ -45,6 +47,7 @@ impl std::fmt::Display for ExtensionKind {
ExtensionKind::McpServer => write!(f, "mcp_server"), ExtensionKind::McpServer => write!(f, "mcp_server"),
ExtensionKind::WasmTool => write!(f, "wasm_tool"), ExtensionKind::WasmTool => write!(f, "wasm_tool"),
ExtensionKind::WasmChannel => write!(f, "wasm_channel"), ExtensionKind::WasmChannel => write!(f, "wasm_channel"),
ExtensionKind::ChannelRelay => write!(f, "channel_relay"),
} }
} }
} }
@@ -99,6 +102,8 @@ pub enum ExtensionSource {
}, },
/// Discovered online (not yet validated for a specific source type). /// Discovered online (not yet validated for a specific source type).
Discovered { url: String }, Discovered { url: String },
/// External channel via channel-relay service.
ChannelRelay { relay_url: String },
} }
/// Hint about what authentication method is needed. /// Hint about what authentication method is needed.
@@ -116,6 +121,8 @@ pub enum AuthHint {
CapabilitiesAuth, CapabilitiesAuth,
/// No authentication needed. /// No authentication needed.
None, None,
/// OAuth via channel-relay service.
ChannelRelayOAuth,
} }
/// Where a search result came from. /// Where a search result came from.
@@ -499,6 +506,9 @@ pub enum ExtensionError {
#[error("Activation failed: {0}")] #[error("Activation failed: {0}")]
ActivationFailed(String), ActivationFailed(String),
#[error("Authentication required")]
AuthRequired,
#[error("Installation failed: {0}")] #[error("Installation failed: {0}")]
InstallFailed(String), InstallFailed(String),
@@ -976,6 +986,7 @@ mod tests {
ExtensionError::Config("missing key".into()), ExtensionError::Config("missing key".into()),
"Config error: missing key", "Config error: missing key",
), ),
(ExtensionError::AuthRequired, "Authentication required"),
( (
ExtensionError::Other("something broke".into()), ExtensionError::Other("something broke".into()),
"something broke", "something broke",
+59 -3
View File
@@ -224,8 +224,16 @@ fn score_entry(entry: &RegistryEntry, tokens: &[String]) -> u32 {
} }
/// Well-known extensions that ship with ironclaw. /// Well-known extensions that ship with ironclaw.
fn builtin_entries() -> Vec<RegistryEntry> { ///
vec![ /// If `relay_url` is provided, a channel-relay Slack entry is included in the list.
/// Pass `None` when the relay is not configured.
pub fn builtin_entries() -> Vec<RegistryEntry> {
builtin_entries_with_relay(std::env::var("CHANNEL_RELAY_URL").ok())
}
/// Well-known extensions, with an optional relay URL for the channel-relay entry.
pub fn builtin_entries_with_relay(relay_url: Option<String>) -> Vec<RegistryEntry> {
let mut entries = vec![
// -- MCP Servers -- // -- MCP Servers --
RegistryEntry { RegistryEntry {
name: "notion".to_string(), name: "notion".to_string(),
@@ -415,7 +423,29 @@ fn builtin_entries() -> Vec<RegistryEntry> {
// WASM channels (telegram, slack, discord, whatsapp) come from the embedded // WASM channels (telegram, slack, discord, whatsapp) come from the embedded
// registry catalog (registry/channels/*.json) with WasmDownload URLs pointing // registry catalog (registry/channels/*.json) with WasmDownload URLs pointing
// to GitHub release artifacts. See new_with_catalog() for merging. // to GitHub release artifacts. See new_with_catalog() for merging.
] ];
// Conditionally add channel-relay entries when relay URL is configured
if let Some(relay_url) = relay_url {
entries.push(RegistryEntry {
name: crate::channels::relay::DEFAULT_RELAY_NAME.to_string(),
display_name: "Slack".to_string(),
kind: ExtensionKind::ChannelRelay,
description: "Connect Slack workspace via channel relay".to_string(),
keywords: vec![
"slack".into(),
"chat".into(),
"messaging".into(),
"relay".into(),
],
source: ExtensionSource::ChannelRelay { relay_url },
fallback_source: None,
auth_hint: AuthHint::ChannelRelayOAuth,
version: None,
});
}
entries
} }
#[cfg(test)] #[cfg(test)]
@@ -935,4 +965,30 @@ mod tests {
// The first catalog entry added is the channel. // The first catalog entry added is the channel.
assert_eq!(entry.unwrap().kind, ExtensionKind::WasmChannel); assert_eq!(entry.unwrap().kind, ExtensionKind::WasmChannel);
} }
#[test]
fn test_builtin_entries_with_relay_none_excludes_relay() {
let entries = super::builtin_entries_with_relay(None);
assert!(
!entries
.iter()
.any(|e| e.kind == ExtensionKind::ChannelRelay),
"No ChannelRelay entry when relay URL is None"
);
}
#[test]
fn test_builtin_entries_with_relay_some_includes_relay() {
let entries =
super::builtin_entries_with_relay(Some("http://relay.example.com".to_string()));
let relay = entries
.iter()
.find(|e| e.kind == ExtensionKind::ChannelRelay);
assert!(relay.is_some(), "ChannelRelay entry should be present");
if let ExtensionSource::ChannelRelay { relay_url } = &relay.unwrap().source {
assert_eq!(relay_url, "http://relay.example.com");
} else {
panic!("Expected ChannelRelay source");
}
}
} }
+13 -6
View File
@@ -151,8 +151,9 @@ impl Store {
id, conversation_id, title, description, category, status, source, id, conversation_id, title, description, category, status, source,
user_id, user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs, budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, created_at, started_at, completed_at actual_cost, repair_attempts, max_tokens, total_tokens_used,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) created_at, started_at, completed_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title, title = EXCLUDED.title,
description = EXCLUDED.description, description = EXCLUDED.description,
@@ -163,6 +164,8 @@ impl Store {
estimated_time_secs = EXCLUDED.estimated_time_secs, estimated_time_secs = EXCLUDED.estimated_time_secs,
actual_cost = EXCLUDED.actual_cost, actual_cost = EXCLUDED.actual_cost,
repair_attempts = EXCLUDED.repair_attempts, repair_attempts = EXCLUDED.repair_attempts,
max_tokens = EXCLUDED.max_tokens,
total_tokens_used = EXCLUDED.total_tokens_used,
started_at = EXCLUDED.started_at, started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at completed_at = EXCLUDED.completed_at
"#, "#,
@@ -182,6 +185,8 @@ impl Store {
&estimated_time_secs, &estimated_time_secs,
&ctx.actual_cost, &ctx.actual_cost,
&(ctx.repair_attempts as i32), &(ctx.repair_attempts as i32),
&(ctx.max_tokens as i64),
&(ctx.total_tokens_used as i64),
&ctx.created_at, &ctx.created_at,
&ctx.started_at, &ctx.started_at,
&ctx.completed_at, &ctx.completed_at,
@@ -201,7 +206,8 @@ impl Store {
r#" r#"
SELECT id, conversation_id, title, description, category, status, user_id, SELECT id, conversation_id, title, description, category, status, user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs, budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, created_at, started_at, completed_at actual_cost, repair_attempts, max_tokens, total_tokens_used,
created_at, started_at, completed_at
FROM agent_jobs WHERE id = $1 FROM agent_jobs WHERE id = $1
"#, "#,
&[&id], &[&id],
@@ -237,8 +243,9 @@ impl Store {
completed_at: row.get("completed_at"), completed_at: row.get("completed_at"),
transitions: Vec::new(), // Not loaded from DB for now transitions: Vec::new(), // Not loaded from DB for now
metadata: serde_json::Value::Null, metadata: serde_json::Value::Null,
total_tokens_used: 0, max_tokens: row.get::<_, Option<i64>>("max_tokens").unwrap_or(0) as u64,
max_tokens: 0, total_tokens_used: row.get::<_, Option<i64>>("total_tokens_used").unwrap_or(0)
as u64,
extra_env: std::sync::Arc::new(std::collections::HashMap::new()), extra_env: std::sync::Arc::new(std::collections::HashMap::new()),
http_interceptor: None, http_interceptor: None,
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new( tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
@@ -1087,7 +1094,7 @@ impl Store {
let conn = self.conn().await?; let conn = self.conn().await?;
let rows = conn let rows = conn
.query( .query(
"SELECT * FROM routines WHERE enabled AND trigger_type = 'event'", "SELECT * FROM routines WHERE enabled AND trigger_type IN ('event', 'system_event')",
&[], &[],
) )
.await?; .await?;
+26 -5
View File
@@ -6,6 +6,8 @@
//! //!
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`. //! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
use std::collections::HashSet;
use async_trait::async_trait; use async_trait::async_trait;
use reqwest::Client; use reqwest::Client;
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -17,7 +19,8 @@ use crate::llm::costs;
use crate::llm::error::LlmError; use crate::llm::error::LlmError;
use crate::llm::provider::{ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall, ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse, ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
strip_unsupported_tool_params,
}; };
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages"; const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
@@ -35,6 +38,8 @@ pub struct AnthropicOAuthProvider {
model: String, model: String,
base_url: Option<String>, base_url: Option<String>,
active_model: std::sync::RwLock<String>, active_model: std::sync::RwLock<String>,
/// Parameter names that this provider does not support.
unsupported_params: HashSet<String>,
} }
impl AnthropicOAuthProvider { impl AnthropicOAuthProvider {
@@ -61,15 +66,29 @@ impl AnthropicOAuthProvider {
Some(config.base_url.clone()) Some(config.base_url.clone())
}; };
let unsupported_params: HashSet<String> =
config.unsupported_params.iter().cloned().collect();
Ok(Self { Ok(Self {
client, client,
token, token,
model: config.model.clone(), model: config.model.clone(),
base_url, base_url,
active_model, active_model,
unsupported_params,
}) })
} }
/// Strip unsupported fields from a `CompletionRequest` in place.
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
strip_unsupported_completion_params(&self.unsupported_params, req);
}
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
strip_unsupported_tool_params(&self.unsupported_params, req);
}
fn api_url(&self) -> String { fn api_url(&self) -> String {
if let Some(ref base) = self.base_url { if let Some(ref base) = self.base_url {
let base = base.trim_end_matches('/'); let base = base.trim_end_matches('/');
@@ -197,8 +216,9 @@ impl AnthropicOAuthProvider {
#[async_trait] #[async_trait]
impl LlmProvider for AnthropicOAuthProvider { impl LlmProvider for AnthropicOAuthProvider {
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> { async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let model = req.model.unwrap_or_else(|| self.active_model_name()); let model = req.model.take().unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_completion_params(&mut req);
let (system, messages) = convert_messages(req.messages); let (system, messages) = convert_messages(req.messages);
let request = AnthropicRequest { let request = AnthropicRequest {
@@ -233,9 +253,10 @@ impl LlmProvider for AnthropicOAuthProvider {
async fn complete_with_tools( async fn complete_with_tools(
&self, &self,
req: ToolCompletionRequest, mut req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> { ) -> Result<ToolCompletionResponse, LlmError> {
let model = req.model.unwrap_or_else(|| self.active_model_name()); let model = req.model.take().unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_tool_params(&mut req);
let (system, messages) = convert_messages(req.messages); let (system, messages) = convert_messages(req.messages);
let tools: Vec<AnthropicTool> = req let tools: Vec<AnthropicTool> = req
+4
View File
@@ -87,6 +87,10 @@ pub struct RegistryProviderConfig {
pub oauth_token: Option<SecretString>, pub oauth_token: Option<SecretString>,
/// Prompt cache retention (Anthropic-specific). /// Prompt cache retention (Anthropic-specific).
pub cache_retention: CacheRetention, pub cache_retention: CacheRetention,
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
/// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
/// Listed parameters are stripped from requests before sending to avoid 400 errors.
pub unsupported_params: Vec<String>,
} }
/// Configuration for AWS Bedrock (native Converse API). /// Configuration for AWS Bedrock (native Converse API).
+10 -3
View File
@@ -29,6 +29,7 @@ pub mod session;
pub mod smart_routing; pub mod smart_routing;
pub mod image_models; pub mod image_models;
pub mod reasoning_models;
pub mod vision_models; pub mod vision_models;
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider}; pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
@@ -228,7 +229,9 @@ fn create_openai_compat_from_registry(
"Using OpenAI-compatible provider" "Using OpenAI-compatible provider"
); );
Ok(Arc::new(RigAdapter::new(model, &config.model))) let adapter = RigAdapter::new(model, &config.model)
.with_unsupported_params(config.unsupported_params.clone());
Ok(Arc::new(adapter))
} }
fn create_anthropic_from_registry( fn create_anthropic_from_registry(
@@ -296,7 +299,9 @@ fn create_anthropic_from_registry(
); );
Ok(Arc::new( Ok(Arc::new(
RigAdapter::new(model, &config.model).with_cache_retention(cache_retention), RigAdapter::new(model, &config.model)
.with_cache_retention(cache_retention)
.with_unsupported_params(config.unsupported_params.clone()),
)) ))
} }
@@ -324,7 +329,9 @@ fn create_ollama_from_registry(
"Using Ollama provider" "Using Ollama provider"
); );
Ok(Arc::new(RigAdapter::new(model, &config.model))) let adapter = RigAdapter::new(model, &config.model)
.with_unsupported_params(config.unsupported_params.clone());
Ok(Arc::new(adapter))
} }
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation). /// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
+9 -2
View File
@@ -270,8 +270,15 @@ impl NearAiChatProvider {
reason: format!("Failed to read response body: {}", e), reason: format!("Failed to read response body: {}", e),
})?; })?;
tracing::debug!("NEAR AI Chat response status: {}", status); if tracing::enabled!(tracing::Level::DEBUG) {
tracing::debug!("NEAR AI Chat response body: {}", response_text); tracing::debug!("NEAR AI Chat response status: {}", status);
}
// Log response body only at TRACE level to avoid exposing sensitive content
// (user-generated data, tool outputs, leaked secrets) in DEBUG logs
if tracing::enabled!(tracing::Level::TRACE) {
tracing::trace!("NEAR AI Chat response body: {}", response_text);
}
if !status.is_success() { if !status.is_success() {
let status_code = status.as_u16(); let status_code = status.as_u16();
+67
View File
@@ -455,6 +455,73 @@ pub fn sanitize_tool_messages(messages: &mut [ChatMessage]) {
} }
} }
/// Represents a request parameter that may not be supported by all LLM providers.
///
/// This typed enum replaces stringly-typed parameter names across the codebase,
/// providing type safety and single-point-of-maintenance for parameter handling.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UnsupportedParam {
Temperature,
MaxTokens,
StopSequences,
}
impl UnsupportedParam {
/// Get the string name of this parameter for config/error messages.
pub fn name(&self) -> &'static str {
match self {
UnsupportedParam::Temperature => "temperature",
UnsupportedParam::MaxTokens => "max_tokens",
UnsupportedParam::StopSequences => "stop_sequences",
}
}
}
/// Strip unsupported parameters from a `CompletionRequest` in place.
///
/// This is the single helper function used by all providers to remove
/// parameters they don't support, replacing duplicate stringly-typed logic.
pub fn strip_unsupported_completion_params(
unsupported: &std::collections::HashSet<String>,
req: &mut CompletionRequest,
) {
if unsupported.is_empty() {
return;
}
if unsupported.contains(UnsupportedParam::Temperature.name()) {
req.temperature = None;
}
if unsupported.contains(UnsupportedParam::MaxTokens.name()) {
req.max_tokens = None;
}
if unsupported.contains(UnsupportedParam::StopSequences.name()) {
req.stop_sequences = None;
}
}
/// Strip unsupported parameters from a `ToolCompletionRequest` in place.
///
/// This is the single helper function used by all providers to remove
/// parameters they don't support from tool calls, replacing duplicate stringly-typed logic.
///
/// Note: Only `Temperature` and `MaxTokens` are supported in `ToolCompletionRequest`.
/// `StopSequences` is only available in `CompletionRequest` and is not applicable to tool calls.
pub fn strip_unsupported_tool_params(
unsupported: &std::collections::HashSet<String>,
req: &mut ToolCompletionRequest,
) {
if unsupported.is_empty() {
return;
}
if unsupported.contains(UnsupportedParam::Temperature.name()) {
req.temperature = None;
}
if unsupported.contains(UnsupportedParam::MaxTokens.name()) {
req.max_tokens = None;
}
// Note: StopSequences is not a field in ToolCompletionRequest, so no action needed
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+732 -18
View File
@@ -450,7 +450,8 @@ impl Reasoning {
cache_read_input_tokens: response.cache_read_input_tokens, cache_read_input_tokens: response.cache_read_input_tokens,
cache_creation_input_tokens: response.cache_creation_input_tokens, cache_creation_input_tokens: response.cache_creation_input_tokens,
}; };
Ok((clean_response(&response.content), usage)) let pre_truncated = truncate_at_tool_tags(&response.content);
Ok((clean_response(&pre_truncated), usage))
} }
/// Generate a plan for completing a goal. /// Generate a plan for completing a goal.
@@ -480,8 +481,11 @@ impl Reasoning {
let response = self.llm.complete(request).await?; let response = self.llm.complete(request).await?;
// Clean reasoning model artifacts before parsing JSON // Clean reasoning model artifacts before parsing JSON.
let cleaned = clean_response(&response.content); // Pre-truncate at tool tags to avoid strip_xml_tag discarding
// content after unclosed tags (issue #789).
let pre_truncated = truncate_at_tool_tags(&response.content);
let cleaned = clean_response(&pre_truncated);
self.parse_plan(&cleaned) self.parse_plan(&cleaned)
} }
@@ -575,8 +579,11 @@ Respond in JSON format:
let response = self.llm.complete(request).await?; let response = self.llm.complete(request).await?;
// Clean reasoning model artifacts before parsing JSON // Clean reasoning model artifacts before parsing JSON.
let cleaned = clean_response(&response.content); // Pre-truncate at tool tags to avoid strip_xml_tag discarding
// content after unclosed tags (issue #789).
let pre_truncated = truncate_at_tool_tags(&response.content);
let cleaned = clean_response(&pre_truncated);
self.parse_evaluation(&cleaned) self.parse_evaluation(&cleaned)
} }
@@ -653,7 +660,10 @@ Respond in JSON format:
return Ok(RespondOutput { return Ok(RespondOutput {
result: RespondResult::ToolCalls { result: RespondResult::ToolCalls {
tool_calls: response.tool_calls, tool_calls: response.tool_calls,
content: response.content.map(|c| clean_response(&c)), content: response.content.map(|c| {
let pre_truncated = truncate_at_tool_tags(&c);
clean_response(&pre_truncated)
}),
}, },
usage, usage,
}); });
@@ -666,9 +676,13 @@ Respond in JSON format:
// Some models (e.g. GLM-4.7) emit tool calls as XML tags in content // Some models (e.g. GLM-4.7) emit tool calls as XML tags in content
// instead of using the structured tool_calls field. Try to recover // instead of using the structured tool_calls field. Try to recover
// them before giving up and returning plain text. // them before giving up and returning plain text.
// NOTE: Recovery runs on the raw content (before truncation) so it can
// parse tool-call JSON from the XML tags. Truncation only applies to the
// remaining *text* content returned alongside the recovered tool calls.
let recovered = recover_tool_calls_from_content(&content, &context.available_tools); let recovered = recover_tool_calls_from_content(&content, &context.available_tools);
if !recovered.is_empty() { if !recovered.is_empty() {
let cleaned = clean_response(&content); let pre_truncated = truncate_at_tool_tags(&content);
let cleaned = clean_response(&pre_truncated);
return Ok(RespondOutput { return Ok(RespondOutput {
result: RespondResult::ToolCalls { result: RespondResult::ToolCalls {
tool_calls: recovered, tool_calls: recovered,
@@ -682,12 +696,16 @@ Respond in JSON format:
}); });
} }
// Guard against empty text after cleaning. This can happen // Guard against empty text after cleaning. This can happen when:
// when reasoning models (e.g. GLM-5) return chain-of-thought // 1. Reasoning models (e.g. GLM-5) return chain-of-thought in
// in reasoning_content wrapped in <think> tags and content is // reasoning_content wrapped in <think> tags — clean_response
// null — the .or(reasoning_content) fallback picks it up, then // strips the think tags leaving an empty string.
// clean_response strips the think tags leaving an empty string. // 2. Local models (Qwen3, DeepSeek) emit <tool_call> XML in text
let cleaned = clean_response(&content); // responses even in force_text mode — strip_xml_tag discards
// from unclosed opening tag onward (issue #789).
// Pre-truncate at tool tags to preserve text before the tag.
let pre_truncated = truncate_at_tool_tags(&content);
let cleaned = clean_response(&pre_truncated);
let final_text = if cleaned.trim().is_empty() { let final_text = if cleaned.trim().is_empty() {
tracing::warn!( tracing::warn!(
"LLM response was empty after cleaning (original len={}), using fallback", "LLM response was empty after cleaning (original len={}), using fallback",
@@ -709,7 +727,8 @@ Respond in JSON format:
request.metadata = context.metadata.clone(); request.metadata = context.metadata.clone();
let response = self.llm.complete(request).await?; let response = self.llm.complete(request).await?;
let cleaned = clean_response(&response.content); let pre_truncated = truncate_at_tool_tags(&response.content);
let cleaned = clean_response(&pre_truncated);
let final_text = if cleaned.trim().is_empty() { let final_text = if cleaned.trim().is_empty() {
tracing::warn!( tracing::warn!(
"LLM response was empty after cleaning (original len={}), using fallback", "LLM response was empty after cleaning (original len={}), using fallback",
@@ -847,10 +866,22 @@ Respond with a JSON plan in this format:
.to_string() .to_string()
}; };
format!( // Models with native thinking (Qwen3, DeepSeek-R1, etc.) produce their
r#"You are IronClaw Agent, a secure autonomous assistant. // own <think> tags or reasoning_content. Injecting our <think>/<final>
// format collides with their native behavior, causing thinking-only
// responses that clean to empty strings. See issue #789.
let has_native_thinking = self
.model_name
.as_ref()
.is_some_and(|n| crate::llm::reasoning_models::has_native_thinking(n));
## Response Format CRITICAL let response_format = if has_native_thinking {
r#"## Response Format
Respond directly with your answer. Do not wrap your response in any special tags.
Your reasoning process is handled natively just provide the final user-facing answer."#
} else {
r#"## Response Format — CRITICAL
ALL internal reasoning MUST be inside <think>...</think> tags. ALL internal reasoning MUST be inside <think>...</think> tags.
Do not output any analysis, planning, or self-talk outside <think>. Do not output any analysis, planning, or self-talk outside <think>.
@@ -860,7 +891,13 @@ Only text inside <final> is shown to the user; everything else is discarded.
Example: Example:
<think>The user is asking about X.</think> <think>The user is asking about X.</think>
<final>Here is the answer about X.</final> <final>Here is the answer about X.</final>"#
};
format!(
r#"You are IronClaw Agent, a secure autonomous assistant.
{response_format}
## Guidelines ## Guidelines
- Be concise and direct - Be concise and direct
@@ -1442,6 +1479,99 @@ fn strip_bracket_tool_calls(text: &str) -> String {
/// Tool-related tags stripped with simple string matching (no code-awareness needed). /// Tool-related tags stripped with simple string matching (no code-awareness needed).
const TOOL_TAGS: &[&str] = &["tool_call", "function_call", "tool_calls"]; const TOOL_TAGS: &[&str] = &["tool_call", "function_call", "tool_calls"];
/// Patterns that indicate tool-call XML in model output.
const TOOL_TAG_PATTERNS: &[&str] = &[
"<tool_call>",
"<tool_call ",
"<function_call>",
"<function_call ",
"<tool_calls>",
"<tool_calls ",
"<|tool_call|>",
"<|function_call|>",
"<|tool_calls|>",
];
/// Truncate text at the first **unclosed** tool-call XML tag, preserving content
/// before it.
///
/// Local models (Qwen3, DeepSeek, etc.) often emit `<tool_call>` XML in text
/// responses even when no tools are available. The downstream `clean_response()`
/// → `strip_xml_tag()` pipeline discards everything from an unclosed opening
/// tag onward, which can leave an empty string and trigger the fallback message.
///
/// This function truncates at the first *unclosed* tool tag BEFORE
/// `clean_response()` runs, so the useful text before the tag is preserved.
/// Properly closed tags (e.g. `<tool_call>...</tool_call>`) are left intact for
/// `clean_response()` to strip normally. Tags inside fenced markdown code blocks
/// or inline code spans are ignored. See issue #789.
fn truncate_at_tool_tags(text: &str) -> String {
let code_regions = find_code_regions(text);
// Use ASCII-only lowercasing so byte offsets stay valid for the original
// string. Full `to_lowercase()` can change byte lengths for non-ASCII
// chars (e.g. the Kelvin sign), making positions unreliable.
let lower = text.to_ascii_lowercase();
let first_unclosed = TOOL_TAG_PATTERNS
.iter()
.filter_map(|p| {
let mut search_from = 0;
loop {
match lower[search_from..].find(p) {
Some(offset) => {
let pos = search_from + offset;
if is_inside_code(pos, &code_regions) {
search_from = pos + 1;
continue;
}
// Check if this tag has a matching closing tag after it.
// If so, clean_response() can handle it — skip to next.
let after_open = pos + p.len();
if closing_tag_for(p)
.is_some_and(|close| lower[after_open..].contains(close.as_str()))
{
search_from = after_open;
continue;
}
// Unclosed tag — truncate here
return Some(pos);
}
None => return None,
}
}
})
.min();
match first_unclosed {
Some(pos) => {
tracing::debug!(
original_len = text.len(),
truncated_at = pos,
"Truncated response at unclosed tool-call XML tag (issue #789)"
);
text[..pos].to_string()
}
None => text.to_string(),
}
}
/// Derive the closing tag for a tool-call opening pattern.
///
/// Examples: `<tool_call>` → `</tool_call>`, `<|tool_call|>` → `<|/tool_call|>`.
fn closing_tag_for(open_pattern: &str) -> Option<String> {
if let Some(name) = open_pattern
.strip_prefix("<|")
.and_then(|s| s.strip_suffix("|>"))
{
// Pipe-delimited: <|tool_call|> → <|/tool_call|>
Some(format!("<|/{name}|>"))
} else if let Some(rest) = open_pattern.strip_prefix('<') {
// Standard XML: <tool_call> or <tool_call → </tool_call>
let name = rest.trim_end_matches('>').trim();
Some(format!("</{name}>"))
} else {
None
}
}
/// Strip thinking/reasoning tags using regex, respecting code regions. /// Strip thinking/reasoning tags using regex, respecting code regions.
/// ///
/// Strict mode: an unclosed opening tag discards all trailing text after it. /// Strict mode: an unclosed opening tag discards all trailing text after it.
@@ -2414,4 +2544,588 @@ That's my plan."#;
let text = "I said let me be clear, then let me fetch the data."; let text = "I said let me be clear, then let me fetch the data.";
assert!(llm_signals_tool_intent(text)); assert!(llm_signals_tool_intent(text));
} }
// ---- Issue #789: truncate_at_tool_tags tests ----
#[test]
fn test_truncate_preserves_text_before_tool_tag() {
let input = "Here is my answer about the topic.\n<tool_call>{\"name\": \"search\"}";
assert_eq!(
truncate_at_tool_tags(input),
"Here is my answer about the topic.\n"
);
}
#[test]
fn test_truncate_no_tool_tags_unchanged() {
let input = "Just a normal response with no tool tags.";
assert_eq!(truncate_at_tool_tags(input), input);
}
#[test]
fn test_truncate_empty_string() {
assert_eq!(truncate_at_tool_tags(""), "");
}
#[test]
fn test_truncate_tool_tag_at_start() {
assert_eq!(
truncate_at_tool_tags("<tool_call>{\"name\": \"search\"}"),
""
);
}
#[test]
fn test_truncate_picks_earliest_unclosed_tag() {
// <function_call>...</function_call> is closed — skipped.
// <tool_call>second is unclosed — truncated here.
let input = "Text before <function_call>first</function_call> and <tool_call>second";
assert_eq!(
truncate_at_tool_tags(input),
"Text before <function_call>first</function_call> and "
);
}
#[test]
fn test_truncate_pipe_delimited_tags() {
let input = "Answer here\n<|tool_call|>{\"name\": \"fetch\"}";
assert_eq!(truncate_at_tool_tags(input), "Answer here\n");
}
#[test]
fn test_truncate_closed_tag_with_attributes_preserved() {
// Closed tag (even with attributes) is left for clean_response()
let input = "Some text <tool_call id=\"123\">{\"name\": \"test\"}</tool_call>";
assert_eq!(truncate_at_tool_tags(input), input);
}
#[test]
fn test_truncate_unclosed_tag_with_attributes() {
let input = "Some text <tool_call id=\"123\">{\"name\": \"test\"}";
assert_eq!(truncate_at_tool_tags(input), "Some text ");
}
#[test]
fn test_truncate_whitespace_only_before_tag() {
assert_eq!(truncate_at_tool_tags(" \n\n<tool_call>{}"), " \n\n");
}
#[test]
fn test_truncate_ignores_tags_inside_code_blocks() {
let input = "Here's the XML format:\n\n```xml\n<tool_call>{\"name\": \"search\"}</tool_call>\n```\n\nYou can use this to call tools.";
assert_eq!(truncate_at_tool_tags(input), input);
}
#[test]
fn test_truncate_finds_tag_after_code_block() {
let input = "Example:\n\n```\n<tool_call>example</tool_call>\n```\n\nReal output:\n<tool_call>{\"name\": \"x\"}";
assert_eq!(
truncate_at_tool_tags(input),
"Example:\n\n```\n<tool_call>example</tool_call>\n```\n\nReal output:\n"
);
}
// ---- Issue #789: full pipeline (truncate + clean_response) tests ----
#[test]
fn test_issue_789_force_text_unclosed_tool_tag() {
let model_output = "The file contains a main function that initializes the server.\n<tool_call>{\"name\": \"read_file\", \"arguments\": {\"path\": \"src/main.rs\"}}";
let pre_truncated = truncate_at_tool_tags(model_output);
let cleaned = clean_response(&pre_truncated);
assert_eq!(
cleaned,
"The file contains a main function that initializes the server."
);
}
#[test]
fn test_issue_789_only_tool_tag_produces_empty() {
let model_output = "<tool_call>{\"name\": \"search\", \"arguments\": {\"q\": \"test\"}}";
let pre_truncated = truncate_at_tool_tags(model_output);
let cleaned = clean_response(&pre_truncated);
assert!(cleaned.trim().is_empty());
}
#[test]
fn test_issue_789_thinking_then_tool_tag() {
let model_output =
"<think>I should search for this</think>Let me help you.\n<tool_call>{\"name\": \"s\"}";
let pre_truncated = truncate_at_tool_tags(model_output);
let cleaned = clean_response(&pre_truncated);
assert_eq!(cleaned, "Let me help you.");
}
#[test]
fn test_issue_789_closed_tool_tag_preserved_for_clean_response() {
// Closed tags are left intact — clean_response() strips them normally,
// preserving any text after the tag.
let model_output = "Info here.\n<tool_call>{\"name\": \"x\"}</tool_call>\nMore text.";
let pre_truncated = truncate_at_tool_tags(model_output);
assert_eq!(
pre_truncated, model_output,
"Closed tag should not be truncated"
);
let cleaned = clean_response(&pre_truncated);
assert_eq!(cleaned, "Info here.\n\nMore text.");
}
// ---- Issue #789: conditional system prompt tests ----
fn make_reasoning_with_model(model: &str) -> Reasoning {
use crate::testing::StubLlm;
Reasoning::new(Arc::new(StubLlm::new("test"))).with_model_name(model.to_string())
}
#[test]
fn test_system_prompt_skips_think_final_for_native_thinking() {
let reasoning = make_reasoning_with_model("qwen3-8b");
let prompt = reasoning.build_system_prompt_with_tools(&[]);
assert!(
!prompt.contains("<think>"),
"Native thinking model should NOT have <think> in system prompt"
);
assert!(prompt.contains("Respond directly with your answer"));
}
#[test]
fn test_system_prompt_includes_think_final_for_regular_model() {
let reasoning = make_reasoning_with_model("llama-3.1-70b");
let prompt = reasoning.build_system_prompt_with_tools(&[]);
assert!(prompt.contains("<think>"));
assert!(prompt.contains("<final>"));
}
#[test]
fn test_system_prompt_defaults_to_think_final_when_no_model() {
use crate::testing::StubLlm;
let reasoning = Reasoning::new(Arc::new(StubLlm::new("test")));
let prompt = reasoning.build_system_prompt_with_tools(&[]);
assert!(prompt.contains("<think>"));
assert!(prompt.contains("<final>"));
}
#[test]
fn test_system_prompt_deepseek_r1_skips_think_final() {
let reasoning = make_reasoning_with_model("deepseek-r1-distill-qwen-32b");
let prompt = reasoning.build_system_prompt_with_tools(&[]);
assert!(!prompt.contains("CRITICAL"));
assert!(prompt.contains("Respond directly"));
}
// ---- Issue #789: additional edge case tests for truncate_at_tool_tags ----
#[test]
fn test_truncate_unicode_content_before_tool_tag() {
let input = "こんにちは世界!素晴らしい結果です。\n<tool_call>{\"name\": \"search\"}";
assert_eq!(
truncate_at_tool_tags(input),
"こんにちは世界!素晴らしい結果です。\n"
);
}
#[test]
fn test_truncate_emoji_content_preserved() {
let input = "The answer is 42 🎉🚀\n<function_call>{\"name\": \"x\"}";
assert_eq!(truncate_at_tool_tags(input), "The answer is 42 🎉🚀\n");
}
#[test]
fn test_truncate_very_long_text_before_tag() {
let long_text = "A".repeat(10_000);
let input = format!("{}\n<tool_call>{{\"name\": \"x\"}}", long_text);
let result = truncate_at_tool_tags(&input);
assert_eq!(result.len(), long_text.len() + 1); // +1 for \n
assert!(result.starts_with("AAAA"));
}
#[test]
fn test_truncate_multiple_code_blocks_with_tags() {
let input = "Explanation:\n\n```python\n# <tool_call> in comment\nprint('hi')\n```\n\nAnd also:\n\n```xml\n<function_call>example</function_call>\n```\n\nFinal answer here.";
// Both tags are inside code blocks, so nothing is truncated
assert_eq!(truncate_at_tool_tags(input), input);
}
#[test]
fn test_truncate_inline_code_with_tool_tag() {
let input = "Use `<tool_call>` to invoke tools.\n<tool_call>{\"name\": \"real\"}";
// First occurrence is in inline code, second is real
assert_eq!(
truncate_at_tool_tags(input),
"Use `<tool_call>` to invoke tools.\n"
);
}
#[test]
fn test_truncate_tag_immediately_after_code_block() {
let input = "```\nexample\n```\n<tool_call>{\"name\": \"x\"}";
assert_eq!(truncate_at_tool_tags(input), "```\nexample\n```\n");
}
#[test]
fn test_truncate_interleaved_thinking_and_tool_tags() {
// Simulate: thinking tag + text + tool tag
let input = "<think>reasoning</think>Here's the answer.\n<tool_call>{\"name\": \"y\"}";
let truncated = truncate_at_tool_tags(input);
let cleaned = clean_response(&truncated);
assert_eq!(cleaned, "Here's the answer.");
}
#[test]
fn test_truncate_closed_tool_calls_plural_preserved() {
// Closed <tool_calls>...</tool_calls> left for clean_response()
let input = "Answer.\n<tool_calls>[{\"name\": \"a\"}, {\"name\": \"b\"}]</tool_calls>";
assert_eq!(truncate_at_tool_tags(input), input);
}
#[test]
fn test_truncate_unclosed_tool_calls_plural() {
let input = "Answer.\n<tool_calls>[{\"name\": \"a\"}, {\"name\": \"b\"}]";
assert_eq!(truncate_at_tool_tags(input), "Answer.\n");
}
#[test]
fn test_truncate_closed_pipe_function_call_preserved() {
let input = "Done!\n<|function_call|>{\"name\": \"x\"}<|/function_call|>";
assert_eq!(truncate_at_tool_tags(input), input);
}
#[test]
fn test_truncate_unclosed_pipe_function_call() {
let input = "Done!\n<|function_call|>{\"name\": \"x\"}";
assert_eq!(truncate_at_tool_tags(input), "Done!\n");
}
#[test]
fn test_truncate_adversarial_nested_code_blocks() {
// Adversarial: code block inside another structure
let input = "```\nouter\n```\n\nReal text.\n\n```\n<tool_call>inside</tool_call>\n```\n\n<tool_call>{\"name\": \"real\"}";
let result = truncate_at_tool_tags(input);
assert!(result.contains("Real text."));
assert!(!result.contains("{\"name\": \"real\"}"));
}
// ---- Issue #789: StubLlm integration tests ----
#[tokio::test]
async fn test_complete_truncates_tool_tags_from_response() {
use crate::testing::StubLlm;
let response = "The server has 3 endpoints.\n<tool_call>{\"name\": \"read_file\"}";
let llm = Arc::new(StubLlm::new(response));
let reasoning = Reasoning::new(llm);
let request = CompletionRequest::new(vec![ChatMessage::user("describe the server")]);
let (result, _usage) = reasoning.complete(request).await.unwrap();
assert_eq!(result, "The server has 3 endpoints.");
}
#[tokio::test]
async fn test_complete_with_only_tool_tag_returns_empty() {
use crate::testing::StubLlm;
let response = "<tool_call>{\"name\": \"search\", \"arguments\": {}}";
let llm = Arc::new(StubLlm::new(response));
let reasoning = Reasoning::new(llm);
let request = CompletionRequest::new(vec![ChatMessage::user("hello")]);
let (result, _usage) = reasoning.complete(request).await.unwrap();
assert!(result.trim().is_empty());
}
#[tokio::test]
async fn test_respond_with_tools_force_text_truncates_tool_tags() {
use crate::testing::StubLlm;
let response = "Here is my analysis of the code.\n<tool_call>{\"name\": \"read_file\", \"arguments\": {\"path\": \"main.rs\"}}";
let llm = Arc::new(StubLlm::new(response));
let reasoning = Reasoning::new(llm);
let mut context =
ReasoningContext::new().with_message(ChatMessage::user("analyze the code"));
context.force_text = true;
let output = reasoning.respond_with_tools(&context).await.unwrap();
match output.result {
RespondResult::Text(text) => {
assert_eq!(text, "Here is my analysis of the code.");
}
RespondResult::ToolCalls { .. } => {
panic!("Expected text result in force_text mode");
}
}
}
#[tokio::test]
async fn test_respond_with_tools_force_text_only_tag_uses_fallback() {
use crate::testing::StubLlm;
let response = "<tool_call>{\"name\": \"search\"}";
let llm = Arc::new(StubLlm::new(response));
let reasoning = Reasoning::new(llm);
let mut context = ReasoningContext::new().with_message(ChatMessage::user("hi"));
context.force_text = true;
let output = reasoning.respond_with_tools(&context).await.unwrap();
match output.result {
RespondResult::Text(text) => {
assert_eq!(text, "I'm not sure how to respond to that.");
}
RespondResult::ToolCalls { .. } => {
panic!("Expected fallback text, not tool calls");
}
}
}
#[tokio::test]
async fn test_plan_truncates_tool_tags_before_json() {
use crate::testing::StubLlm;
let response = r#"<think>Let me plan</think>{"goal": "Test goal", "actions": [{"tool_name": "search", "parameters": {}, "reasoning": "find files", "expected_outcome": "results"}], "confidence": 0.9}
<tool_call>{"name": "search"}"#;
let llm = Arc::new(StubLlm::new(response));
let reasoning = Reasoning::new(llm);
let context = ReasoningContext::new()
.with_message(ChatMessage::user("plan a search"))
.with_job("Search for relevant files");
let plan = reasoning.plan(&context).await.unwrap();
assert_eq!(plan.goal, "Test goal");
assert!(!plan.actions.is_empty());
}
// ---- Issue #789: model name propagation test ----
#[tokio::test]
async fn test_with_model_name_affects_system_prompt() {
use crate::testing::StubLlm;
// StubLlm model_name is "stub-model" by default, but Reasoning.model_name
// is what matters for system prompt building.
let llm = Arc::new(StubLlm::new("test").with_model_name("qwen3-8b"));
let reasoning = Reasoning::new(llm.clone()).with_model_name("qwen3-8b".to_string());
let prompt = reasoning.build_system_prompt_with_tools(&[]);
assert!(
!prompt.contains("<think>"),
"Qwen3 model should get native thinking system prompt"
);
assert!(prompt.contains("Respond directly"));
// Now create reasoning WITHOUT with_model_name — should get default prompt
let reasoning_no_model = Reasoning::new(llm);
let prompt2 = reasoning_no_model.build_system_prompt_with_tools(&[]);
assert!(
prompt2.contains("<think>"),
"Without model name, should get default think/final prompt"
);
}
// ---- Issue #789: case-insensitive truncation ----
#[test]
fn test_truncate_case_insensitive_upper() {
let input = "Some answer.\n<TOOL_CALL>{\"name\": \"search\"}";
assert_eq!(truncate_at_tool_tags(input), "Some answer.\n");
}
#[test]
fn test_truncate_case_insensitive_mixed() {
let input = "Result here.\n<Tool_Call>{\"name\": \"x\"}";
assert_eq!(truncate_at_tool_tags(input), "Result here.\n");
}
#[test]
fn test_truncate_unicode_before_case_insensitive_tag_no_panic() {
// Regression: to_lowercase() can change byte lengths for non-ASCII chars
// (e.g. Kelvin sign U+212A is 3 bytes, lowercases to 'k' which is 1 byte).
// Using to_ascii_lowercase() keeps byte offsets stable.
let input = "Ответ: 42\n<TOOL_CALL>{\"name\": \"x\"}";
assert_eq!(truncate_at_tool_tags(input), "Ответ: 42\n");
}
#[test]
fn test_truncate_case_insensitive_function_call_closed() {
// Closed tag (case-insensitive) preserved for clean_response()
let input = "Done.\n<FUNCTION_CALL>{\"name\": \"y\"}</FUNCTION_CALL>";
assert_eq!(truncate_at_tool_tags(input), input);
}
#[test]
fn test_truncate_case_insensitive_function_call_unclosed() {
let input = "Done.\n<FUNCTION_CALL>{\"name\": \"y\"}";
assert_eq!(truncate_at_tool_tags(input), "Done.\n");
}
// ---- Issue #789: evaluate_success integration test ----
#[tokio::test]
async fn test_evaluate_success_truncates_tool_tags() {
use crate::testing::StubLlm;
let response = r#"<think>evaluating</think>{"success": true, "confidence": 0.85, "reasoning": "Task completed", "issues": [], "suggestions": []}
<tool_call>{"name": "verify"}"#;
let llm = Arc::new(StubLlm::new(response));
let reasoning = Reasoning::new(llm);
let context = ReasoningContext::new().with_job("Test task");
let eval = reasoning
.evaluate_success(&context, "The job is done")
.await
.unwrap();
assert!(eval.success);
assert_eq!(eval.confidence, 0.85);
}
// ---- Issue #789: respond_with_tools recovered tool calls path ----
#[tokio::test]
async fn test_respond_with_tools_recovered_tool_calls_preserves_text() {
use crate::testing::StubLlm;
// StubLlm returns empty tool_calls + content with XML tool tags.
// The recovery path should parse the tool call AND preserve text before it.
let response = "Let me search for that.\n<tool_call>{\"name\": \"tool_list\", \"arguments\": {}}</tool_call>";
let llm = Arc::new(StubLlm::new(response));
let reasoning = Reasoning::new(llm);
let context = ReasoningContext::new()
.with_message(ChatMessage::user("list tools"))
.with_tools(vec![ToolDefinition {
name: "tool_list".to_string(),
description: "Lists tools".to_string(),
parameters: serde_json::json!({}),
}]);
let output = reasoning.respond_with_tools(&context).await.unwrap();
match output.result {
RespondResult::ToolCalls {
tool_calls,
content,
} => {
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].name, "tool_list");
// Text before the tag should be preserved
assert_eq!(content.as_deref(), Some("Let me search for that."));
}
RespondResult::Text(_) => {
panic!("Expected recovered tool calls, got text");
}
}
}
#[tokio::test]
async fn test_respond_with_tools_recovered_only_tag_content_is_none() {
use crate::testing::StubLlm;
// Content is ONLY a tool call tag — after truncation+cleaning, content should be None
let response = "<tool_call>{\"name\": \"tool_list\", \"arguments\": {}}</tool_call>";
let llm = Arc::new(StubLlm::new(response));
let reasoning = Reasoning::new(llm);
let context = ReasoningContext::new()
.with_message(ChatMessage::user("list tools"))
.with_tools(vec![ToolDefinition {
name: "tool_list".to_string(),
description: "Lists tools".to_string(),
parameters: serde_json::json!({}),
}]);
let output = reasoning.respond_with_tools(&context).await.unwrap();
match output.result {
RespondResult::ToolCalls {
tool_calls,
content,
} => {
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].name, "tool_list");
assert!(
content.is_none(),
"Content should be None when only tool tags present"
);
}
RespondResult::Text(_) => {
panic!("Expected recovered tool calls, got text");
}
}
}
// ---- Issue #789: OpenAI reasoning models negative test ----
#[test]
fn test_openai_reasoning_models_not_detected() {
use crate::llm::reasoning_models::has_native_thinking;
assert!(!has_native_thinking("o1"));
assert!(!has_native_thinking("o1-mini"));
assert!(!has_native_thinking("o1-preview"));
assert!(!has_native_thinking("o3-mini"));
assert!(!has_native_thinking("o4-mini"));
}
// ---- closing_tag_for() unit tests ----
#[test]
fn test_closing_tag_for_standard_tags() {
assert_eq!(
closing_tag_for("<tool_call>").as_deref(),
Some("</tool_call>")
);
assert_eq!(
closing_tag_for("<function_call>").as_deref(),
Some("</function_call>")
);
assert_eq!(
closing_tag_for("<tool_calls>").as_deref(),
Some("</tool_calls>")
);
}
#[test]
fn test_closing_tag_for_space_suffixed_patterns() {
// Patterns with trailing space (for attribute matching)
assert_eq!(
closing_tag_for("<tool_call ").as_deref(),
Some("</tool_call>")
);
assert_eq!(
closing_tag_for("<function_call ").as_deref(),
Some("</function_call>")
);
assert_eq!(
closing_tag_for("<tool_calls ").as_deref(),
Some("</tool_calls>")
);
}
#[test]
fn test_closing_tag_for_pipe_delimited() {
assert_eq!(
closing_tag_for("<|tool_call|>").as_deref(),
Some("<|/tool_call|>")
);
assert_eq!(
closing_tag_for("<|function_call|>").as_deref(),
Some("<|/function_call|>")
);
assert_eq!(
closing_tag_for("<|tool_calls|>").as_deref(),
Some("<|/tool_calls|>")
);
}
#[test]
fn test_closing_tag_for_covers_all_patterns() {
// Every entry in TOOL_TAG_PATTERNS must produce a closing tag
for pattern in TOOL_TAG_PATTERNS {
assert!(
closing_tag_for(pattern).is_some(),
"closing_tag_for({:?}) returned None",
pattern
);
}
}
// ---- truncation with multiple tags: first closed, second unclosed ----
#[test]
fn test_truncate_mixed_closed_then_unclosed_different_types() {
let input = "Text <function_call>{}</function_call> middle <tool_call>{\"name\": \"x\"}";
// function_call is closed → skipped. tool_call is unclosed → truncated.
assert_eq!(
truncate_at_tool_tags(input),
"Text <function_call>{}</function_call> middle "
);
}
} }
+134
View File
@@ -0,0 +1,134 @@
//! Reasoning/thinking model detection utilities.
//!
//! Models with native thinking support produce structured chain-of-thought
//! via `reasoning_content` fields or built-in `<think>` tags. Injecting
//! IronClaw's own `<think>/<final>` format instructions into the system
//! prompt collides with these models' native behavior, causing:
//! - Thinking-only responses with no visible content
//! - Double-wrapped thinking tags that confuse response cleaning
//!
//! When a model has native thinking, we skip the `<think>/<final>` prompt
//! injection and let the model use its own format. The response cleaning
//! pipeline already handles stripping all known thinking tag variants.
//!
//! ## Design note: why match broadly (e.g. all Qwen3)?
//!
//! Some families (Qwen3) have ALL variants trained with native `<think>` tags,
//! even tiny models like 0.6B. Thinking can be disabled at inference time via
//! `enable_thinking=false`, but we can't detect that from the model name alone.
//! We err on the safe side: skip injection for all variants because:
//! - False negative (inject when model thinks natively) = broken responses
//! - False positive (skip injection for non-thinking model) = less structured
//! but working responses
//!
//! For families where only SOME variants reason (GLM-4), we match specific
//! sub-families (glm-z1, glm-4-plus) to avoid false positives.
/// Known model families with native thinking/reasoning support.
///
/// These models produce chain-of-thought reasoning either via a dedicated
/// `reasoning_content` response field or via built-in `<think>` tags that
/// the model was trained to emit without prompt injection.
const NATIVE_THINKING_PATTERNS: &[&str] = &[
// Qwen3 family — ALL variants (0.6B through 235B) emit native <think> tags
// by default. Thinking can be toggled via `enable_thinking` parameter or
// `/think` `/no_think` soft switches, but the default is ON and we can't
// detect the runtime setting from the model name.
"qwen3",
// QwQ is Qwen's dedicated reasoning model (based on Qwen2.5-32B + RL).
// Always thinks, no disable toggle.
"qwq",
// DeepSeek reasoning models — native reasoning_content field
"deepseek-r1",
"deepseek-reasoner",
// GLM reasoning variants only (glm-4-flash, glm-4-air, glm-4v do NOT reason)
"glm-z1",
"glm-4-plus",
"glm-5",
// Nanbeige reasoning models
"nanbeige",
// Step reasoning models (3.5+ have native thinking; step-3 base does not)
"step-3.5",
// MiniMax reasoning models
"minimax-m2",
];
/// Check if a model name indicates native thinking/reasoning support.
///
/// Models that return `true` should NOT have IronClaw's `<think>/<final>`
/// format instructions injected into their system prompt, as this collides
/// with their built-in reasoning behavior.
///
/// Note: this is a best-effort heuristic based on model name. Some models
/// support toggling thinking at runtime (e.g. Qwen3's `enable_thinking`),
/// which we cannot detect here. We default to assuming thinking is ON for
/// models that have it, since that's the default behavior.
pub fn has_native_thinking(model: &str) -> bool {
let lower = model.to_ascii_lowercase();
NATIVE_THINKING_PATTERNS.iter().any(|p| lower.contains(p))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_qwen3_models() {
// All Qwen3 variants have native thinking (even small ones)
assert!(has_native_thinking("qwen3-coder-next-80b"));
assert!(has_native_thinking("Qwen3.5-35B"));
assert!(has_native_thinking("qwen3-0.6b"));
assert!(has_native_thinking("qwen3:8b"));
assert!(has_native_thinking("qwen3-30b-a3b"));
// Ollama-style tag format
assert!(has_native_thinking("qwen3-coder:latest"));
}
#[test]
fn detects_qwq() {
assert!(has_native_thinking("qwq-32b"));
assert!(has_native_thinking("QwQ-32B-Preview"));
}
#[test]
fn detects_deepseek_reasoning() {
assert!(has_native_thinking("deepseek-r1-distill-qwen-32b"));
assert!(has_native_thinking("deepseek-reasoner"));
}
#[test]
fn detects_glm_reasoning_variants() {
assert!(has_native_thinking("glm-z1-airx"));
assert!(has_native_thinking("glm-4-plus"));
assert!(has_native_thinking("GLM-5"));
}
#[test]
fn detects_other_reasoning_models() {
assert!(has_native_thinking("nanbeige-4.1-3b"));
assert!(has_native_thinking("step-3.5-flash-197b"));
assert!(has_native_thinking("minimax-m2.5-139b"));
}
#[test]
fn rejects_non_reasoning_models() {
assert!(!has_native_thinking("gpt-4o"));
assert!(!has_native_thinking("claude-3-5-sonnet"));
assert!(!has_native_thinking("llama-3.1-70b"));
assert!(!has_native_thinking("mistral-7b"));
assert!(!has_native_thinking("gemini-2.0-flash"));
}
#[test]
fn rejects_non_reasoning_variants_in_same_family() {
// Qwen2.5 does NOT have native thinking (only Qwen3/QwQ do)
assert!(!has_native_thinking("qwen2.5:7b"));
assert!(!has_native_thinking("qwen2.5-instruct"));
// GLM-4 base variants do NOT have reasoning_content
assert!(!has_native_thinking("glm-4-flash"));
assert!(!has_native_thinking("glm-4-air"));
assert!(!has_native_thinking("glm-4v"));
// step-3 base does not reason (only 3.5+)
assert!(!has_native_thinking("step-3-mini"));
}
}
+117
View File
@@ -113,6 +113,33 @@ impl SetupHint {
} }
} }
/// Validates unsupported_params during deserialization.
///
/// Only allows: "temperature", "max_tokens", "stop_sequences".
/// Invalid parameter names cause a deserialization error.
mod unsupported_params_de {
use serde::{Deserialize, Deserializer};
const VALID_PARAMS: &[&str] = &["temperature", "max_tokens", "stop_sequences"];
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let params: Vec<String> = Deserialize::deserialize(deserializer)?;
for param in &params {
if !VALID_PARAMS.contains(&param.as_str()) {
return Err(serde::de::Error::custom(format!(
"unsupported parameter name '{}': must be one of: {}",
param,
VALID_PARAMS.join(", ")
)));
}
}
Ok(params)
}
}
/// Declarative definition of an LLM provider. /// Declarative definition of an LLM provider.
/// ///
/// One JSON object in `providers.json` maps to one `ProviderDefinition`. /// One JSON object in `providers.json` maps to one `ProviderDefinition`.
@@ -152,6 +179,12 @@ pub struct ProviderDefinition {
/// Setup wizard hints. /// Setup wizard hints.
#[serde(default)] #[serde(default)]
pub setup: Option<SetupHint>, pub setup: Option<SetupHint>,
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
/// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
/// Listed parameters are stripped from requests before sending to avoid 400 errors.
/// Invalid parameter names cause a deserialization error.
#[serde(default, deserialize_with = "unsupported_params_de::deserialize")]
pub unsupported_params: Vec<String>,
} }
/// Registry of known LLM providers. /// Registry of known LLM providers.
@@ -378,6 +411,7 @@ mod tests {
description: "Custom tinfoil".to_string(), description: "Custom tinfoil".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, setup: None,
unsupported_params: vec![],
}); });
let registry = ProviderRegistry::new(all); let registry = ProviderRegistry::new(all);
let tf = registry.find("tinfoil").expect("tinfoil should exist"); let tf = registry.find("tinfoil").expect("tinfoil should exist");
@@ -517,6 +551,7 @@ mod tests {
description: "No setup".to_string(), description: "No setup".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, // no setup hint setup: None, // no setup hint
unsupported_params: vec![],
}]; }];
let registry = ProviderRegistry::new(providers.clone()); let registry = ProviderRegistry::new(providers.clone());
@@ -546,6 +581,7 @@ mod tests {
can_list_models: false, can_list_models: false,
models_filter: None, models_filter: None,
}), }),
unsupported_params: vec![],
}); });
let registry = ProviderRegistry::new(providers); let registry = ProviderRegistry::new(providers);
@@ -587,6 +623,7 @@ mod tests {
can_list_models: false, can_list_models: false,
models_filter: None, models_filter: None,
}), }),
unsupported_params: vec![],
}, },
// User override removes setup // User override removes setup
ProviderDefinition { ProviderDefinition {
@@ -603,6 +640,7 @@ mod tests {
description: "No setup now".to_string(), description: "No setup now".to_string(),
extra_headers_env: None, extra_headers_env: None,
setup: None, setup: None,
unsupported_params: vec![],
}, },
]; ];
@@ -640,6 +678,7 @@ mod tests {
display_name: "A".to_string(), display_name: "A".to_string(),
can_list_models: false, can_list_models: false,
}), }),
unsupported_params: vec![],
}, },
ProviderDefinition { ProviderDefinition {
id: "bbb".to_string(), id: "bbb".to_string(),
@@ -658,6 +697,7 @@ mod tests {
display_name: "B".to_string(), display_name: "B".to_string(),
can_list_models: false, can_list_models: false,
}), }),
unsupported_params: vec![],
}, },
ProviderDefinition { ProviderDefinition {
id: "ccc".to_string(), id: "ccc".to_string(),
@@ -676,6 +716,7 @@ mod tests {
display_name: "C".to_string(), display_name: "C".to_string(),
can_list_models: false, can_list_models: false,
}), }),
unsupported_params: vec![],
}, },
// User override for B // User override for B
ProviderDefinition { ProviderDefinition {
@@ -695,6 +736,7 @@ mod tests {
display_name: "B".to_string(), display_name: "B".to_string(),
can_list_models: false, can_list_models: false,
}), }),
unsupported_params: vec![],
}, },
]; ];
@@ -708,6 +750,81 @@ mod tests {
); );
} }
#[test]
fn test_unsupported_params_deserialized() {
let providers: Vec<ProviderDefinition> =
serde_json::from_str(include_str!("../../providers.json")).unwrap();
// Tinfoil should have temperature in unsupported_params
let tinfoil = providers.iter().find(|p| p.id == "tinfoil").unwrap();
assert!(
tinfoil
.unsupported_params
.contains(&"temperature".to_string()),
"tinfoil should have 'temperature' in unsupported_params"
);
// OpenAI should also have temperature in unsupported_params
let openai = providers.iter().find(|p| p.id == "openai").unwrap();
assert!(
openai
.unsupported_params
.contains(&"temperature".to_string()),
"openai should have 'temperature' in unsupported_params"
);
// Providers without the field in JSON should deserialize to empty vec
let groq = providers.iter().find(|p| p.id == "groq").unwrap();
assert!(
groq.unsupported_params.is_empty(),
"groq should have empty unsupported_params (field absent in JSON)"
);
// All entries should only contain valid param names
// (Invalid names should be rejected at deserialization time)
for def in &providers {
for param in &def.unsupported_params {
assert!(
!param.is_empty(),
"{}: unsupported_params contains empty string",
def.id
);
assert!(
matches!(
param.as_str(),
"temperature" | "max_tokens" | "stop_sequences"
),
"{}: unsupported_params contains invalid parameter '{}'",
def.id,
param
);
}
}
}
#[test]
fn test_unsupported_params_validation_rejects_invalid() {
// Invalid parameter names should cause deserialization error
let invalid_json = r#"[{
"id": "test",
"protocol": "open_ai_completions",
"model_env": "TEST_MODEL",
"default_model": "test-model",
"description": "Test provider",
"unsupported_params": ["temperrature"]
}]"#;
let result: Result<Vec<ProviderDefinition>, _> = serde_json::from_str(invalid_json);
assert!(
result.is_err(),
"should reject invalid parameter name 'temperrature'"
);
assert!(
result.err().unwrap().to_string().contains("temperrature"),
"error message should mention the invalid parameter"
);
}
#[test] #[test]
fn test_all_builtin_api_key_providers_have_api_key_env() { fn test_all_builtin_api_key_providers_have_api_key_env() {
// Every built-in provider with SetupHint::ApiKey must have api_key_env // Every built-in provider with SetupHint::ApiKey must have api_key_env
+1 -1
View File
@@ -205,7 +205,7 @@ impl LlmProvider for CachedProvider {
let hit_count = entry.hit_count; let hit_count = entry.hit_count;
// Clone now so we can release the mutable borrow before stats. // Clone now so we can release the mutable borrow before stats.
let cached_response = entry.response.clone(); let cached_response = entry.response.clone();
tracing::debug!(hits = hit_count, "response cache hit"); tracing::trace!(hits = hit_count, "response cache hit");
// Drop the mutable borrow of `entry` before reading `guard` immutably. // Drop the mutable borrow of `entry` before reading `guard` immutably.
let _ = entry; let _ = entry;
let total_hits = self.total_hit_count.fetch_add(1, Ordering::Relaxed) + 1; let total_hits = self.total_hit_count.fetch_add(1, Ordering::Relaxed) + 1;
+127 -3
View File
@@ -28,7 +28,8 @@ use crate::llm::error::LlmError;
use crate::llm::provider::{ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider,
ToolCall as IronToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolCall as IronToolCall, ToolCompletionRequest, ToolCompletionResponse,
ToolDefinition as IronToolDefinition, ToolDefinition as IronToolDefinition, strip_unsupported_completion_params,
strip_unsupported_tool_params,
}; };
/// Adapter that wraps a rig-core `CompletionModel` and implements `LlmProvider`. /// Adapter that wraps a rig-core `CompletionModel` and implements `LlmProvider`.
@@ -42,6 +43,9 @@ pub struct RigAdapter<M: CompletionModel> {
/// via `additional_params` for Anthropic automatic caching. Also controls /// via `additional_params` for Anthropic automatic caching. Also controls
/// the cost multiplier for cache-creation tokens. /// the cost multiplier for cache-creation tokens.
cache_retention: CacheRetention, cache_retention: CacheRetention,
/// Parameter names that this provider does not support (e.g., `"temperature"`).
/// These are stripped from requests before sending to avoid 400 errors.
unsupported_params: HashSet<String>,
} }
impl<M: CompletionModel> RigAdapter<M> { impl<M: CompletionModel> RigAdapter<M> {
@@ -56,6 +60,7 @@ impl<M: CompletionModel> RigAdapter<M> {
input_cost, input_cost,
output_cost, output_cost,
cache_retention: CacheRetention::None, cache_retention: CacheRetention::None,
unsupported_params: HashSet::new(),
} }
} }
@@ -84,6 +89,25 @@ impl<M: CompletionModel> RigAdapter<M> {
} }
self self
} }
/// Set the list of unsupported parameter names for this provider.
///
/// Parameters in this set are stripped from requests before sending.
/// Supported parameter names: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
pub fn with_unsupported_params(mut self, params: Vec<String>) -> Self {
self.unsupported_params = params.into_iter().collect();
self
}
/// Strip unsupported fields from a `CompletionRequest` in place.
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
strip_unsupported_completion_params(&self.unsupported_params, req);
}
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
strip_unsupported_tool_params(&self.unsupported_params, req);
}
} }
// -- Type conversion helpers -- // -- Type conversion helpers --
@@ -539,7 +563,10 @@ where
} }
} }
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> { async fn complete(
&self,
mut request: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
if let Some(requested_model) = request.model.as_deref() if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str() && requested_model != self.model_name.as_str()
{ {
@@ -550,6 +577,8 @@ where
); );
} }
self.strip_unsupported_completion_params(&mut request);
let mut messages = request.messages; let mut messages = request.messages;
crate::llm::provider::sanitize_tool_messages(&mut messages); crate::llm::provider::sanitize_tool_messages(&mut messages);
let (preamble, history) = convert_messages(&messages); let (preamble, history) = convert_messages(&messages);
@@ -599,7 +628,7 @@ where
async fn complete_with_tools( async fn complete_with_tools(
&self, &self,
request: ToolCompletionRequest, mut request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> { ) -> Result<ToolCompletionResponse, LlmError> {
if let Some(requested_model) = request.model.as_deref() if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str() && requested_model != self.model_name.as_str()
@@ -611,6 +640,8 @@ where
); );
} }
self.strip_unsupported_tool_params(&mut request);
let known_tool_names: HashSet<String> = let known_tool_names: HashSet<String> =
request.tools.iter().map(|t| t.name.clone()).collect(); request.tools.iter().map(|t| t.name.clone()).collect();
@@ -1156,4 +1187,97 @@ mod tests {
assert!(!supports_prompt_cache("gpt-4o")); assert!(!supports_prompt_cache("gpt-4o"));
assert!(!supports_prompt_cache("llama3")); assert!(!supports_prompt_cache("llama3"));
} }
#[test]
fn test_with_unsupported_params_populates_set() {
use rig::client::CompletionClient;
use rig::providers::openai;
let client: openai::Client = openai::Client::builder()
.api_key("test-key")
.base_url("http://localhost:0")
.build()
.unwrap();
let client = client.completions_api();
let model = client.completion_model("test-model");
let adapter = RigAdapter::new(model, "test-model")
.with_unsupported_params(vec!["temperature".to_string()]);
assert!(adapter.unsupported_params.contains("temperature"));
assert!(!adapter.unsupported_params.contains("max_tokens"));
}
#[test]
fn test_strip_unsupported_completion_params() {
use rig::client::CompletionClient;
use rig::providers::openai;
let client: openai::Client = openai::Client::builder()
.api_key("test-key")
.base_url("http://localhost:0")
.build()
.unwrap();
let client = client.completions_api();
let model = client.completion_model("test-model");
let adapter = RigAdapter::new(model, "test-model").with_unsupported_params(vec![
"temperature".to_string(),
"stop_sequences".to_string(),
]);
let mut req = CompletionRequest::new(vec![ChatMessage::user("hi")]);
req.temperature = Some(0.7);
req.max_tokens = Some(100);
req.stop_sequences = Some(vec!["STOP".to_string()]);
adapter.strip_unsupported_completion_params(&mut req);
assert!(req.temperature.is_none(), "temperature should be stripped");
assert_eq!(req.max_tokens, Some(100), "max_tokens should be preserved");
assert!(
req.stop_sequences.is_none(),
"stop_sequences should be stripped"
);
}
#[test]
fn test_strip_unsupported_tool_params() {
use rig::client::CompletionClient;
use rig::providers::openai;
let client: openai::Client = openai::Client::builder()
.api_key("test-key")
.base_url("http://localhost:0")
.build()
.unwrap();
let client = client.completions_api();
let model = client.completion_model("test-model");
let adapter = RigAdapter::new(model, "test-model")
.with_unsupported_params(vec!["temperature".to_string(), "max_tokens".to_string()]);
let mut req = ToolCompletionRequest::new(vec![ChatMessage::user("hi")], vec![]);
req.temperature = Some(0.5);
req.max_tokens = Some(200);
adapter.strip_unsupported_tool_params(&mut req);
assert!(req.temperature.is_none(), "temperature should be stripped");
assert!(req.max_tokens.is_none(), "max_tokens should be stripped");
}
#[test]
fn test_unsupported_params_empty_by_default() {
use rig::client::CompletionClient;
use rig::providers::openai;
let client: openai::Client = openai::Client::builder()
.api_key("test-key")
.base_url("http://localhost:0")
.build()
.unwrap();
let client = client.completions_api();
let model = client.completion_model("test-model");
let adapter = RigAdapter::new(model, "test-model");
assert!(adapter.unsupported_params.is_empty());
}
} }
+8 -8
View File
@@ -770,7 +770,7 @@ impl SmartRoutingProvider {
} }
}; };
let complexity = TaskComplexity::from(tier); let complexity = TaskComplexity::from(tier);
tracing::debug!( tracing::trace!(
%tier, %tier,
?complexity, ?complexity,
"Smart routing: explicit tier hint" "Smart routing: explicit tier hint"
@@ -782,7 +782,7 @@ impl SmartRoutingProvider {
for po in DEFAULT_OVERRIDES.iter() { for po in DEFAULT_OVERRIDES.iter() {
if po.regex.is_match(last_user_msg) { if po.regex.is_match(last_user_msg) {
let complexity = TaskComplexity::from(po.tier); let complexity = TaskComplexity::from(po.tier);
tracing::debug!( tracing::trace!(
tier = %po.tier, tier = %po.tier,
?complexity, ?complexity,
"Smart routing: pattern override matched" "Smart routing: pattern override matched"
@@ -798,7 +798,7 @@ impl SmartRoutingProvider {
&self.domain_regex, &self.domain_regex,
); );
let complexity = TaskComplexity::from(breakdown.tier); let complexity = TaskComplexity::from(breakdown.tier);
tracing::debug!( tracing::trace!(
score = breakdown.total, score = breakdown.total,
tier = %breakdown.tier, tier = %breakdown.tier,
?complexity, ?complexity,
@@ -872,7 +872,7 @@ impl LlmProvider for SmartRoutingProvider {
match complexity { match complexity {
TaskComplexity::Simple => { TaskComplexity::Simple => {
tracing::debug!( tracing::trace!(
model = %self.cheap.model_name(), model = %self.cheap.model_name(),
"Smart routing: Simple task -> cheap model" "Smart routing: Simple task -> cheap model"
); );
@@ -880,7 +880,7 @@ impl LlmProvider for SmartRoutingProvider {
self.cheap.complete(request).await self.cheap.complete(request).await
} }
TaskComplexity::Complex => { TaskComplexity::Complex => {
tracing::debug!( tracing::trace!(
model = %self.primary.model_name(), model = %self.primary.model_name(),
"Smart routing: Complex task -> primary model" "Smart routing: Complex task -> primary model"
); );
@@ -889,7 +889,7 @@ impl LlmProvider for SmartRoutingProvider {
} }
TaskComplexity::Moderate => { TaskComplexity::Moderate => {
if self.config.cascade_enabled { if self.config.cascade_enabled {
tracing::debug!( tracing::trace!(
model = %self.cheap.model_name(), model = %self.cheap.model_name(),
"Smart routing: Moderate task -> cheap model (cascade enabled)" "Smart routing: Moderate task -> cheap model (cascade enabled)"
); );
@@ -913,7 +913,7 @@ impl LlmProvider for SmartRoutingProvider {
} }
} else { } else {
// Without cascade, moderate tasks go to cheap model // Without cascade, moderate tasks go to cheap model
tracing::debug!( tracing::trace!(
model = %self.cheap.model_name(), model = %self.cheap.model_name(),
"Smart routing: Moderate task -> cheap model (cascade disabled)" "Smart routing: Moderate task -> cheap model (cascade disabled)"
); );
@@ -931,7 +931,7 @@ impl LlmProvider for SmartRoutingProvider {
) -> Result<ToolCompletionResponse, LlmError> { ) -> Result<ToolCompletionResponse, LlmError> {
self.stats.total_requests.fetch_add(1, Ordering::Relaxed); self.stats.total_requests.fetch_add(1, Ordering::Relaxed);
self.stats.primary_requests.fetch_add(1, Ordering::Relaxed); self.stats.primary_requests.fetch_add(1, Ordering::Relaxed);
tracing::debug!( tracing::trace!(
model = %self.primary.model_name(), model = %self.primary.model_name(),
"Smart routing: Tool use -> primary model (always)" "Smart routing: Tool use -> primary model (always)"
); );
+194 -21
View File
@@ -322,10 +322,16 @@ async fn async_main() -> anyhow::Result<()> {
// Add HTTP channel if configured and not CLI-only mode. // Add HTTP channel if configured and not CLI-only mode.
let mut webhook_server_addr: Option<std::net::SocketAddr> = None; let mut webhook_server_addr: Option<std::net::SocketAddr> = None;
#[cfg(unix)]
let mut http_channel_state: Option<Arc<ironclaw::channels::HttpChannelState>> = None;
if !cli.cli_only if !cli.cli_only
&& let Some(ref http_config) = config.channels.http && let Some(ref http_config) = config.channels.http
{ {
let http_channel = HttpChannel::new(http_config.clone()); let http_channel = HttpChannel::new(http_config.clone());
#[cfg(unix)]
{
http_channel_state = Some(http_channel.shared_state());
}
webhook_routes.push(http_channel.routes()); webhook_routes.push(http_channel.routes());
let (host, port) = http_channel.addr(); let (host, port) = http_channel.addr();
webhook_server_addr = Some( webhook_server_addr = Some(
@@ -343,7 +349,9 @@ async fn async_main() -> anyhow::Result<()> {
} }
// Start the unified webhook server if any routes were registered. // Start the unified webhook server if any routes were registered.
let mut webhook_server = if !webhook_routes.is_empty() { let webhook_server: Option<Arc<tokio::sync::Mutex<WebhookServer>>> = if !webhook_routes
.is_empty()
{
let addr = let addr =
webhook_server_addr.unwrap_or_else(|| std::net::SocketAddr::from(([0, 0, 0, 0], 8080))); webhook_server_addr.unwrap_or_else(|| std::net::SocketAddr::from(([0, 0, 0, 0], 8080)));
if addr.ip().is_unspecified() { if addr.ip().is_unspecified() {
@@ -358,7 +366,7 @@ async fn async_main() -> anyhow::Result<()> {
server.add_routes(routes); server.add_routes(routes);
} }
server.start().await?; server.start().await?;
Some(server) Some(Arc::new(tokio::sync::Mutex::new(server)))
} else { } else {
None None
}; };
@@ -556,30 +564,41 @@ async fn async_main() -> anyhow::Result<()> {
.await; .await;
tracing::debug!("Channel runtime wired into extension manager for hot-activation"); tracing::debug!("Channel runtime wired into extension manager for hot-activation");
// Auto-activate channels that were active in a previous session. // Auto-activate WASM channels that were active in a previous session.
// Relay channels are handled separately below via restore_relay_channels().
let persisted = ext_mgr.load_persisted_active_channels().await; let persisted = ext_mgr.load_persisted_active_channels().await;
for name in &persisted { for name in &persisted {
if !active_at_startup.contains(name) { if active_at_startup.contains(name) || ext_mgr.is_relay_channel(name).await {
match ext_mgr.activate(name).await { continue;
Ok(result) => { }
tracing::debug!( match ext_mgr.activate(name).await {
channel = %name, Ok(result) => {
message = %result.message, tracing::debug!(
"Auto-activated persisted channel" channel = %name,
); message = %result.message,
} "Auto-activated persisted WASM channel"
Err(e) => { );
tracing::warn!( }
channel = %name, Err(e) => {
error = %e, tracing::warn!(
"Failed to auto-activate persisted channel" channel = %name,
); error = %e,
} "Failed to auto-activate persisted WASM channel"
);
} }
} }
} }
} }
// Ensure the relay channel manager is always set (even without WASM runtime),
// then restore any persisted relay channels.
if let Some(ref ext_mgr) = components.extension_manager {
ext_mgr
.set_relay_channel_manager(Arc::clone(&channels))
.await;
ext_mgr.restore_relay_channels().await;
}
// Wire SSE sender into extension manager for broadcasting status events. // Wire SSE sender into extension manager for broadcasting status events.
if let Some(ref ext_mgr) = components.extension_manager if let Some(ref ext_mgr) = components.extension_manager
&& let Some(ref sender) = sse_sender && let Some(ref sender) = sse_sender
@@ -601,6 +620,13 @@ async fn async_main() -> anyhow::Result<()> {
// Clone context_manager for the reaper before it's moved into Agent::new() // Clone context_manager for the reaper before it's moved into Agent::new()
let reaper_context_manager = Arc::clone(&components.context_manager); let reaper_context_manager = Arc::clone(&components.context_manager);
// Capture db reference for SIGHUP handler before it's moved into AgentDeps (Unix only)
#[cfg(unix)]
let sighup_settings_store: Option<Arc<dyn ironclaw::db::SettingsStore>> = components
.db
.as_ref()
.map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>);
let deps = AgentDeps { let deps = AgentDeps {
store: components.db, store: components.db,
llm: components.llm, llm: components.llm,
@@ -661,10 +687,157 @@ async fn async_main() -> anyhow::Result<()> {
agent.set_routine_engine_slot(slot); agent.set_routine_engine_slot(slot);
} }
// Prepare SIGHUP handler for hot-reloading HTTP webhook config
// Broadcast channel for clean shutdown of background tasks
let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1);
#[cfg(unix)]
{
use ironclaw::channels::ChannelSecretUpdater;
// Collect all channels that support secret updates
let mut secret_updaters: Vec<Arc<dyn ChannelSecretUpdater>> = Vec::new();
if let Some(ref state) = http_channel_state {
secret_updaters.push(Arc::clone(state) as Arc<dyn ChannelSecretUpdater>);
}
let sighup_webhook_server = webhook_server.clone();
let sighup_settings_store_clone = sighup_settings_store.clone();
let sighup_secrets_store = components.secrets_store.clone();
let mut shutdown_rx = shutdown_tx.subscribe();
tokio::spawn(async move {
use tokio::signal::unix::{SignalKind, signal};
let mut sighup = match signal(SignalKind::hangup()) {
Ok(s) => s,
Err(e) => {
tracing::warn!("Failed to register SIGHUP handler: {}", e);
return;
}
};
loop {
// Exit loop on shutdown signal or when SIGHUP is received
tokio::select! {
_ = shutdown_rx.recv() => {
tracing::debug!("SIGHUP handler shutting down");
break;
}
_ = sighup.recv() => {
// Handle SIGHUP signal
}
}
tracing::info!("SIGHUP received — reloading HTTP webhook config");
// Inject channel secrets from database into thread-safe overlay
// (similar to inject_llm_keys_from_secrets for LLM providers)
if let Some(ref secrets_store) = sighup_secrets_store {
// Inject HTTP webhook secret from encrypted store
if let Ok(webhook_secret) = secrets_store
.get_decrypted("default", "http_webhook_secret")
.await
{
// Thread-safe: Uses INJECTED_VARS mutex instead of unsafe std::env::set_var
// Config::from_env() will read from the overlay via optional_env()
ironclaw::config::inject_single_var(
"HTTP_WEBHOOK_SECRET",
webhook_secret.expose(),
);
tracing::debug!("Injected HTTP_WEBHOOK_SECRET from secrets store");
}
}
// Reload config (now with secrets injected into environment)
let new_config = match &sighup_settings_store_clone {
Some(store) => {
ironclaw::config::Config::from_db(store.as_ref(), "default").await
}
None => ironclaw::config::Config::from_env().await,
};
let new_config = match new_config {
Ok(c) => c,
Err(e) => {
tracing::error!("SIGHUP config reload failed: {}", e);
continue;
}
};
let new_http = match new_config.channels.http {
Some(c) => c,
None => {
tracing::warn!("SIGHUP: HTTP channel no longer configured, skipping");
continue;
}
};
// Compute new socket addr
let new_addr: std::net::SocketAddr =
match format!("{}:{}", new_http.host, new_http.port).parse() {
Ok(a) => a,
Err(e) => {
tracing::error!("SIGHUP: invalid addr in config: {}", e);
continue;
}
};
// Restart listener if addr changed.
// Minimize lock scope: acquire, read old addr, release, then restart.
let mut restart_failed = false;
if let Some(ref ws_arc) = sighup_webhook_server {
let old_addr = {
let ws = ws_arc.lock().await;
ws.current_addr()
}; // Lock released here
if old_addr != new_addr {
tracing::info!(
"SIGHUP: HTTP addr {} -> {}, restarting listener",
old_addr,
new_addr
);
// NOTE: Lock is held across restart_with_addr().await. This is
// acceptable because SIGHUP is infrequent and restart is fast. A full
// fix would require refactoring restart_with_addr to separate state
// mutation from async I/O.
let mut ws = ws_arc.lock().await;
match ws.restart_with_addr(new_addr).await {
Ok(()) => {
tracing::info!("SIGHUP: webhook server restarted on {}", new_addr);
}
Err(e) => {
tracing::error!("SIGHUP: listener restart failed: {}", e);
restart_failed = true;
}
}
} else {
tracing::debug!("SIGHUP: addr unchanged ({})", old_addr);
}
}
// Update secrets in all configured channels (if restart succeeded or wasn't needed)
if !restart_failed {
use secrecy::{ExposeSecret, SecretString};
let new_secret = new_http
.webhook_secret
.as_ref()
.map(|s| SecretString::from(s.expose_secret().to_string()));
// Update all channels that support secret swapping
for updater in &secret_updaters {
updater.update_secret(new_secret.clone()).await;
}
}
}
});
}
agent.run().await?; agent.run().await?;
// ── Shutdown ──────────────────────────────────────────────────────── // ── Shutdown ────────────────────────────────────────────────────────
// Signal background tasks (SIGHUP handler, etc.) to gracefully shut down
let _ = shutdown_tx.send(());
// Shut down all stdio MCP server child processes. // Shut down all stdio MCP server child processes.
components.mcp_process_manager.shutdown_all().await; components.mcp_process_manager.shutdown_all().await;
@@ -675,8 +848,8 @@ async fn async_main() -> anyhow::Result<()> {
tracing::warn!("Failed to write LLM trace: {}", e); tracing::warn!("Failed to write LLM trace: {}", e);
} }
if let Some(ref mut server) = webhook_server { if let Some(ref ws_arc) = webhook_server {
server.shutdown().await; ws_arc.lock().await.shutdown().await;
} }
if let Some(tunnel) = active_tunnel { if let Some(tunnel) = active_tunnel {
+42 -18
View File
@@ -20,16 +20,22 @@ const ALLOWED_ARTIFACT_HOSTS: &[&str] = &[
]; ];
fn should_attempt_source_fallback(err: &RegistryError) -> bool { fn should_attempt_source_fallback(err: &RegistryError) -> bool {
// MissingChecksum is intentionally allowed here — it's a bootstrapping issue match err {
// (no release has populated checksums yet), not a security concern. Source // `releases/latest` is a moving target: every new release rebuilds WASM
// builds use local trusted code. ChecksumMismatch (tampered artifact) and // extensions, so a mismatch against a `latest` URL just means the binary
// InvalidManifest (structural problem) remain blocked. // was compiled against an older release's checksum. Not a security concern
!matches!( // — fall back to building from source.
err, //
RegistryError::AlreadyInstalled { .. } // Version-pinned URLs (`releases/download/vX.Y.Z/`) point to an immutable
| RegistryError::ChecksumMismatch { .. } // asset; a mismatch there is genuinely suspicious and remains a hard block.
| RegistryError::InvalidManifest { .. } RegistryError::ChecksumMismatch { url, .. } => {
) url.contains("github.com/nearai/ironclaw/releases/latest/")
}
// Never fall back for these — they signal a structural problem or a
// deliberate "already done" state, not a transient artifact issue.
RegistryError::AlreadyInstalled { .. } | RegistryError::InvalidManifest { .. } => false,
_ => true,
}
} }
fn is_allowed_artifact_host(host: &str) -> bool { fn is_allowed_artifact_host(host: &str) -> bool {
@@ -931,14 +937,6 @@ mod tests {
}; };
assert!(!should_attempt_source_fallback(&already)); assert!(!should_attempt_source_fallback(&already));
let checksum = RegistryError::ChecksumMismatch {
url: "https://github.com/nearai/ironclaw/releases/latest/download/demo.wasm"
.to_string(),
expected_sha256: "deadbeef".to_string(),
actual_sha256: "feedface".to_string(),
};
assert!(!should_attempt_source_fallback(&checksum));
let invalid = RegistryError::InvalidManifest { let invalid = RegistryError::InvalidManifest {
name: "demo".to_string(), name: "demo".to_string(),
field: "artifacts.wasm32-wasip2.url", field: "artifacts.wasm32-wasip2.url",
@@ -1088,4 +1086,30 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
} }
// Regression test for issue #439: ChecksumMismatch on a `releases/latest` URL
// must allow source-build fallback (moving-target URL, not a security concern),
// while a mismatch on a version-pinned URL must remain a hard block.
#[test]
fn test_source_fallback_on_latest_url_mismatch() {
let latest_mismatch = RegistryError::ChecksumMismatch {
url: "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz".to_string(),
expected_sha256: "aaa".to_string(),
actual_sha256: "bbb".to_string(),
};
assert!(
should_attempt_source_fallback(&latest_mismatch),
"ChecksumMismatch on releases/latest URL should allow source fallback"
);
let pinned_mismatch = RegistryError::ChecksumMismatch {
url: "https://github.com/nearai/ironclaw/releases/download/v0.7.0/github-0.2.0-wasm32-wasip2.tar.gz".to_string(),
expected_sha256: "aaa".to_string(),
actual_sha256: "bbb".to_string(),
};
assert!(
!should_attempt_source_fallback(&pinned_mismatch),
"ChecksumMismatch on version-pinned URL must remain a hard block"
);
}
} }

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