The HTTP tool returned `ApprovalRequirement::Always` for requests with
credentials, but `Always` is hardcoded to ignore the session auto-approve
set. This meant users who clicked "always" were re-prompted on every
subsequent HTTP call — the UI offered "always" but the backend ignored it.
Two fixes:
1. HTTP credentialed requests now return `UnlessAutoApproved` instead of
`Always`, so the session auto-approve set is respected.
2. `StatusUpdate::ApprovalNeeded` now carries `allow_always: bool`. All
channel UIs (Telegram, Slack, Signal, REPL, Web) conditionally hide
the "always" option when a tool truly requires per-invocation approval
(`ApprovalRequirement::Always`, e.g. destructive shell commands).
Also boxes `PendingApproval` in `AgenticLoopResult::NeedApproval` to fix
a pre-existing clippy `large_enum_variant` warning.
Regression tests included (test_credentialed_requests_respect_auto_approve,
test_allow_always_matches_approval_requirement) but CI heuristic cannot
detect them in cross-fork PR diffs.
[skip-regression-check]
Co-authored-by: Tyler <[email protected]>
* feat(self-repair): wire stuck_threshold, store, and builder (#647)
Wire the previously dead-code fields in DefaultSelfRepair:
- stuck_threshold: detect_stuck_jobs() now filters by duration, only
reporting jobs stuck longer than the configured threshold
- with_store(): wired in agent_loop.rs from AgentDeps.store for
tool failure tracking via Database trait
- with_builder(): wired from register_builder_tool() return value
through AppComponents and AgentDeps for automatic tool rebuilding
- tools: passed alongside builder for hot-reload logging
Remove all #[allow(dead_code)] annotations. Add regression tests for
threshold-based filtering (both above and below threshold).
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: add missing `builder` field to AgentDeps in gateway workflow harness
After rebase onto staging, AgentDeps gained a `builder` field for
self-repair tool rebuilding. The gateway workflow test harness was
missing this field, causing CI compilation failure.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* ci: retrigger CI
* fix: force CI refresh after path_routing_tests dedup
* test: add E2E test for stuck job repair and tool rebuild cycle
Tests the full self-repair flow requested in review:
1. Job transitions Pending -> InProgress -> Stuck
2. detect_stuck_jobs() finds it (zero threshold)
3. repair_stuck_job() recovers it back to InProgress
4. A broken tool is repaired via MockBuilder
5. Verify builder was invoked and repair succeeded
Uses a MockBuilder (impl SoftwareBuilder) that returns successful
BuildResult without requiring an LLM or filesystem. Uses libsql
test database for the store (increment_repair_attempts, mark_tool_repaired).
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(self-repair): measure stuck_duration from Stuck transition, not started_at
- Use ctx.transitions to find the most recent Stuck transition timestamp
instead of ctx.started_at (which reflects job start, not stuck time)
- Fix StuckJob.last_activity to use stuck transition timestamp
- Remove misleading "hot-reloaded into registry" log
- Remove stray "// ci fix" comment in memory.rs
- Add regression test: backdated started_at must not inflate stuck_duration
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* ci: re-trigger CI with latest changes
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: add type annotation to Ok(()) in test to resolve E0282
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* fix: remove debug_assert guards that panic on valid error paths (#1312)
Two debug_assert! calls added in #1312 fire on expected runtime error
paths (not programmer bugs), turning graceful error returns into panics
in debug/test builds:
- state.rs: Completed→Cancelled is a user-facing error handled by
transition_to() returning Err — not a bug
- execute.rs: empty tool_name from malformed LLM output is handled by
ToolError::NotFound — not a bug
Removes both asserts; keeps the circuit-breaker assert (genuinely guards
a caller invariant).
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: tighten empty tool name test to assert ToolError::NotFound variant
Address review feedback: assert the specific error variant instead of
just is_err() so the regression test actually enforces the expected
error path.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* fix: add debug_assert invariant guards to critical code paths (closes#1215)
Add three debug_assert! calls to catch impossible-in-correct-code states
early in debug builds without affecting release performance:
- execute_tool_with_safety: assert tool_name is non-empty at entry
- JobContext::transition_to: assert state machine transition is valid
- CircuitBreakerProvider::record_success: assert circuit is not Open
(check_allowed() must gate all calls before record_success())
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* test: add regression test for empty tool name invariant guard
Covers the debug_assert!(!tool_name.is_empty()) added in execute_tool_with_safety.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---------
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
The `__internal_job_monitor` metadata key that bypassed the entire
agent pipeline (hooks, safety checks, LLM processing) was spoofable
by external channels — WASM channel plugins could inject arbitrary
metadata including this key, causing attacker-controlled content to be
forwarded directly as assistant responses.
Replace the metadata-based check with a dedicated `is_internal` field
on `IncomingMessage` that can only be set via `into_internal()` by
trusted in-process code. Both the field and setter are `pub(crate)` to
prevent external crates from spoofing the flag. Also remove
`notify_metadata` forwarding (the monitor only needs channel/user/thread
routing) and the unused `__job_monitor_job_id` metadata key.
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
LLMs sometimes pass "" for optional parameters instead of omitting them.
Previously, passing url: "" to skill_install would match the explicit-URL
branch and attempt to fetch from an empty string, producing an invalid URL
error instead of falling back to the catalog lookup.
Fix by adding .filter(|s| !s.is_empty()) so an empty url is treated the
same as a missing field.
A unit test verifies the parameter filtering behaviour directly; the full
execute path (catalog lookup + install) requires a real catalog and database
and cannot be covered at the unit level.
* fix(mcp): cache oauth client init error as AuthError
* Update src/tools/mcp/auth.rs
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* fix(mcp): use AuthError::Http in oauth client cache and add regression test
* test(mcp): annotate test assert for no-panics CI matcher
---------
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* fix(mcp): handle 400 auth errors, clear auth mode after OAuth, trim tokens
Three bugs prevented MCP server authentication (e.g. GitHub MCP) from
working correctly:
1. **400 treated as auth-required**: GitHub's MCP endpoint returns 400
"Authorization header is badly formatted" instead of 401 when auth
is missing. Broadened auth detection in activate_mcp, send_request,
and discover_via_401 to also match 400+authorization errors.
2. **Auth mode not cleared after OAuth callback**: The OAuth callback
handler and setup submit handler did not call clear_auth_mode(),
leaving pending_auth on the thread. The next user message was
intercepted as a token instead of triggering an LLM turn.
3. **Token trimming**: Tokens with leading/trailing whitespace or
newlines produced malformed Authorization headers. Now trimmed
before storage (configure) and before use (build_request_headers).
Adds E2E tests with a mock MCP server (JSON-RPC + OAuth discovery +
DCR + token exchange) covering install -> activate -> OAuth callback ->
LLM turn lifecycle, plus a GitHub-style 400 error variant.
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(mcp): add TTL to PendingAuth and clear auth mode on all failure paths
Auth mode (pending_auth on a Thread) had no timeout and several code
paths that failed to clear it, causing user messages to be swallowed
indefinitely. This adds defense-in-depth:
- Add created_at + 5-minute TTL to PendingAuth; auto-clear on next
message if expired (safety net for edge cases like user closing
browser mid-OAuth)
- Clear auth mode on OAuth callback failure paths (unknown/consumed
state, expired flow)
- Move clear_auth_mode before configure() match in setup_submit so
it runs on failure too (addresses Copilot review feedback)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(ci): exclude test hunks from unwrap/assert pre-commit check
The pre-commit safety script only excluded files in tests/ but not
#[cfg(test)] mod tests blocks inside src/ files. Use the git diff @@
hunk header context (which includes the enclosing function name) to
detect and skip test hunks.
Also removes unnecessary // safety: comments from test assertions.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: restore formatting in test assertions
The replace_all edit that removed // safety: comments collapsed
newlines. Restore proper line breaks.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address Copilot review - tighten pre-commit filter, document TTL sync
- pre-commit-safety.sh: only exclude `mod tests` hunks (not `fn test_*`)
to avoid hiding unwrap/assert in production functions like test_server()
- session.rs: extract AUTH_MODE_TTL_SECS constant and add doc comment
linking to OAUTH_FLOW_EXPIRY to prevent silent drift
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(mcp): return error on expired auth input, clear auth on all OAuth paths
- When auth mode TTL expires and the user sends a message (possibly a
pasted token), return an explicit "expired, please retry" response
instead of forwarding the content to the LLM/history
- Add clear_auth_mode() to all early-return paths in oauth_callback_handler
(provider error, missing state/code, no extension manager)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* fix: eliminate panic paths in production code and document infallible operations
PolicyRule::new() now returns Result instead of panicking on invalid
caller-supplied regex. CreateJobTool returns ToolError when job_manager
is unconfigured instead of panicking. Remaining infallible unwrap/expect
calls (hardcoded regexes, compile-time constants, guarded accesses)
are annotated with SAFETY comments. Where possible, unwraps are replaced
with safer patterns: split_last(), if-let, match-destructure, and
reusing peek() values.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: use inline lowercase safety comments to match CI pattern
The no-panics CI check greps for '// safety:' (lowercase, inline)
to suppress false positives. Switch from block SAFETY comments to
inline safety comments on the .unwrap() lines.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* test: add regression tests for panic-path fixes
- PolicyRule::new returns Err on invalid regex (not panic)
- CreateJobTool::execute_sandbox returns ToolError when job_manager is None
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: add inline // safety: comments on all infallible unwrap/expect lines
The CI no-panics check requires '// safety:' on the same line as
unwrap()/expect() to suppress false positives. Move safety annotations
from block comments to inline comments on every infallible production
unwrap/expect across all touched files.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* chore: trigger CI with skip-regression-check label
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* refactor: remove redundant block-level SAFETY comments
Each unwrap/expect now carries its own inline // safety: annotation,
making the standalone block comments above them redundant.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* fix(mcp): address 14 audit findings across MCP module
- Replace panicking assert! in new_with_config with Result return (Critical)
- Fix initialize() race condition using tokio::sync::OnceCell (High)
- Fix localhost check bypass via proper URL parsing (High)
- Extract shared stream_transport_send() to deduplicate stdio/unix send logic
- Use atomic write (tmp+rename) for config file persistence
- Filter SSE responses by request_id to prevent wrong-response dispatch
- Share a single reqwest::Client for OAuth via fallible OnceLock
- Log notification send errors instead of silently discarding
- Fix unwrap_or(0) that could steal id=0 responses
- Store InitializeResult in OnceCell so callers can access server capabilities
- Add redirect logging in OAuth discovery
- Reuse is_localhost_url() in auth.rs
- Add McpToolWrapper unit tests and regression tests
- URL-encode PKCE challenge for consistency
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* chore: retrigger CI with skip-regression-check label
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
LLMs sometimes pass "" for optional parameters instead of omitting
them. Previously, passing timezone: "" or from_timezone: "" to the
time tool would trigger a parse error ("Unknown timezone ''") rather
than falling back to the context timezone or UTC.
Fix by adding .filter(|s| !s.is_empty()) after .as_str() in
resolve_timezone_for_output and optional_timezone, so empty strings
are treated the same as a missing field.
The same pattern exists in routine.rs (cron trigger timezone and
schedule fields), where "" produces "invalid IANA timezone: ''" or a
cron parse error. That will be addressed separately once routine.rs
has a test harness in place.
Regression tests added for the now and convert operations with
empty timezone strings.
Closes#1127
* fix: address 5 critical and high-priority bugs from issue tracker
- #1033: reject webhook requests when secret is cleared at runtime via
update_secret(None), preventing auth bypass through SIGHUP hot-swap
- #908: reset consecutive_failures counter on successful SSE stream
reconnection in relay channel, so circuit breaker counts truly
consecutive failures
- #975: add depth limit (16) to validate_tool_schema() to prevent
stack overflow on deeply nested schemas
- #974: add depth limit (8) to resolve_nested() to prevent stack
overflow on deeply nested capabilities wrappers
- #826: truncate oversized tool outputs (>8KB) in routine lightweight
loop to prevent unbounded context growth across iterations
Each fix includes a regression test.
Closes#1033, #908, #975, #974, #826
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: 5 more high-priority bugs (routine cache, job signals, input limits)
- #1077: recompute next_fire_at when re-enabling cron routines via web
toggle, mirroring CLI behavior so cron ticker picks them up
- #1076: refresh event trigger cache after web toggle/delete operations
so event/system_event routines reflect changes immediately
- #892: remove Stuck from check_signals() stop-states in JobDelegate
since Stuck is recoverable (Stuck -> InProgress via self-repair)
- #976: truncate oversized description strings in CapabilitiesFile to
4KB to prevent memory abuse from malicious capabilities files
- #977: drop oversized parameters schema JSON (>64KB) in
CapabilitiesFile to prevent unbounded memory growth
Each fix includes regression tests where applicable.
Closes#1077, #1076, #892, #976, #977
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: prevent ReDoS in event trigger regex patterns
- #825: use RegexBuilder with 64KB size limit when compiling
user-supplied event trigger patterns, both at creation time
(routine tool) and at cache refresh (routine engine)
Note: Rust's regex crate already guarantees O(n) matching, so the
size limit prevents excessive memory use during compilation rather
than catastrophic backtracking at match time.
Closes#825
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Harden HTTP SSRF IP filtering
* Apply rustfmt after staging merge
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
Two fixes from the review of #1086 (tool_info schema discovery):
1. Replace fragile description string mutation (append_schema_hint_if_permissive /
strip_schema_hint) with composition at display time. The raw description stays
clean; the tool_info hint is composed in the Tool::schema() override only when
the advertised schema is permissive. This also includes the tool name and
`include_schema: true` in the hint for better LLM guidance.
2. Make effective_for_coercion use the load-time extracted schema from
PreparedModule instead of re-calling the WASM schema() export on the
already-running instance mid-execution. This avoids potential state
contamination from calling schema() after linear memory is initialized
for execution.
Co-authored-by: Claude Opus 4.6 <[email protected]>
* fix: add tool_info schema discovery for WASM tools
* refactor: simplify WASM schema and hint state
* refactor: store tool_info registry reference as Weak
* fix: relax approval requirements for low-risk tools
Remove unnecessary UnlessAutoApproved friction from list_dir, image_gen,
image_analyze, image_edit, tool_install, tool_auth, tool_upgrade, and
build_tool — these operate on trusted inputs or are low-risk operations
so they now use the trait default (Never).
For the http tool, GET requests without credentials now return Never
instead of UnlessAutoApproved, while credential-bearing requests and
non-GET methods retain their existing approval levels.
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* style: apply cargo fmt
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: address review feedback on approval changes
Rename test_requires_approval_returns_unless_auto_approved to
test_requires_approval_returns_never to match the asserted behavior.
In http requires_approval(), treat missing method as unknown (falls
through to UnlessAutoApproved) instead of defaulting to GET, since
the schema requires method. Updated comment to reflect this.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: make http method optional, default to GET
Make method optional in schema (only url is required) and default to
GET in both execute() and requires_approval(). This aligns approval
logic with execution and reduces friction for simple GET requests.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: restore UnlessAutoApproved for build_tool, tool_install, tool_upgrade
Address review feedback: these tools modify the system's trust boundary
(shell execution, WASM installation, version mutation) and should retain
approval gating. tool_auth kept as Never per owner decision.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* feat: configurable hybrid search fusion strategy (#169)
Add WeightedScore fusion as an alternative to the default RRF algorithm.
Users can now tune search behavior via env vars (SEARCH_FUSION_STRATEGY,
SEARCH_FTS_WEIGHT, SEARCH_VECTOR_WEIGHT, SEARCH_RRF_K) or by passing
SearchConfig with the new fields. Default behavior (RRF, k=60) is
unchanged.
- Add FusionStrategy enum (Rrf/WeightedScore) to workspace::search
- Add weighted_score_fusion() and fuse_results() dispatcher
- Add config/search.rs with WorkspaceSearchConfig from env vars
- Wire search defaults through Workspace struct
- Update both postgres and libsql backends to use fuse_results()
- Add 7 new tests (4 fusion + 3 config)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: swap default search weights to match issue #169 spec (0.7 vector / 0.3 FTS)
The issue spec says "0.7/0.3 (vector/keyword) for weighted mode" but
our defaults had fts_weight=0.7, vector_weight=0.3 (inverted). Also
fixes the misleading docstring on weighted_score_fusion that claimed
1/rank normalizes to [0,1].
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: validate weight inputs and update stale doc comments
- Reject NaN, infinite, and negative values for SEARCH_FTS_WEIGHT and
SEARCH_VECTOR_WEIGHT with a clear ConfigError
- Fix module-level docs that incorrectly claimed WeightedScore
"normalizes per-method scores to [0,1]"
- Update SearchResult.score doc from "Combined RRF score" to
strategy-agnostic "Combined fusion score"
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: validate weight setters against NaN/inf/negative values
with_fts_weight() and with_vector_weight() now silently ignore
non-finite (NaN, ±inf) and negative values, matching the env var
validation already in place for SEARCH_FTS_WEIGHT / SEARCH_VECTOR_WEIGHT.
Values > 1.0 remain valid since weights are normalized internally.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: use crate-wide ENV_MUTEX in search config tests
Replace the module-local `ENV_MUTEX` in `search.rs` with a shared
`crate::config::helpers::ENV_MUTEX` to prevent cross-module env races
when `cargo test` runs tests in parallel.
Addresses copilot review comment. Tracked in #245.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: per-strategy weight defaults to match issue #169 spec
RRF mode now defaults to 0.5/0.5 (fts/vector) and WeightedScore
defaults to 0.3/0.7, matching the acceptance criteria in #169.
Previously both modes used 0.3/0.7 uniformly.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: reject both weights=0 in weighted fusion mode
When both SEARCH_FTS_WEIGHT and SEARCH_VECTOR_WEIGHT are 0.0 under
WeightedScore strategy, all scores would be 0.0, producing arbitrary
ordering. RRF mode is unaffected since it ignores weights entirely.
Addresses Copilot review comment. The other comment (rrf_k=0 division
by zero) is a false positive — ranks are 1-based, so k=0 just gives
inverse-rank scoring with no infinity.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: clarify weight doc comments and error key
- SearchConfig field docs: clarify that Default always uses 0.5,
per-strategy defaults only apply via WorkspaceSearchConfig::resolve()
- WorkspaceSearchConfig field docs: same clarification
- Error key for both-weights-zero now references both env vars
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: remove broken intra-doc links to pub(crate) resolve()
WorkspaceSearchConfig::resolve is pub(crate), so linking to it from
public field docs triggers rustdoc private_intra_doc_links warnings.
Switch to plain-text references.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: add document_path to weighted_score_fusion results
The weighted_score_fusion function was missing the document_path field
added in a recent main branch commit, causing a compile error after rebase.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* chore: trigger CI re-check after rebase
* fix: resolve pre-existing staging fmt and clippy issues
- Fix import ordering in cli/mod.rs (cargo fmt)
- Fix line wrapping in tools/mcp/auth.rs (cargo fmt)
- Move path_routing_tests before MemoryTreeTool to fix
clippy::items_after_test_module
[skip-regression-check]
* fix: remove duplicate path_routing_tests module after rebase
[skip-regression-check]
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* style: fix formatting in cli/mod.rs and mcp/auth.rs
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(cli): add missing use_tools and max_tool_rounds fields to routines create
The routines CLI create command was missing the new Lightweight fields
added after the cron->routines rename merged.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(clippy): move path_routing_tests after production code in memory.rs
Fixes items_after_test_module lint by moving the test module to the
end of the file, after all production structs and impls.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* feat: enhance HTTP tool parameter parsing
- Add support for stringified JSON arrays in headers parameter.
- Introduce timeout_secs parameter parsing to accept both numbers and string representations.
- Implement save_to parameter parsing to handle empty strings as None.
- Update HTTP request handling to incorporate timeout and save_to parameters.
- Add unit tests for new parsing functions to ensure correct behavior.
* feat(http): enhance HTTP tool with timeout and header parsing improvements
- Introduced default and maximum request timeout constants to manage resource usage.
- Refactored header parsing logic to separate functions for better readability and maintainability.
- Updated timeout handling to ensure it respects the maximum allowed value.
- Added unit tests to validate new header parsing functionality.
* refactor(http): replace hardcoded timeout with effective_timeout variable in HTTP tool error handling
* Rebase onto staging
* fix(routines): prevent autonomy-escalation in lightweight routines
- Add ROUTINE_TOOL_DENYLIST to block routine_create/update/delete/fire
and restart from being callable by lightweight routines
- Deduplicate sentinel logic by reusing handle_text_response() in the
no-tools path
- Filter tool definitions sent to LLM to only include callable tools,
avoiding wasted tokens on tools that would be rejected
Some MCP servers (e.g. Attio) require the `state` parameter in OAuth
authorization requests and reject requests without it:
{"error":"invalid_request","error_description":"Invalid value provided for: state"}
While OAuth 2.1 makes `state` optional when PKCE is used, the MCP
specification does not forbid servers from requiring it. This caused a
hard failure when authenticating with any MCP server that enforces the
state parameter.
Generate a 128-bit cryptographically random state (via OsRng, base64url
encoded without padding) and inject it into extra_params before building
the authorization URL. This covers both pre-configured OAuth and Dynamic
Client Registration (DCR) code paths.
The callback listener intentionally does not validate the echoed state
because: (1) PKCE already binds the authorization code to the token
exchange, preventing code injection attacks, and (2) not all MCP servers
echo state back — strict validation would break those servers. Other
OAuth flows in the codebase (tool.rs, extensions/manager.rs) that
generate and validate state are unaffected.
* fix(mcp): use gateway callback for MCP OAuth so auth opens in same browser
When MCP OAuth is triggered from the web gateway, the auth URL was being
opened via `open::that()` which launches the OS default browser instead
of the browser already running the gateway UI. This changes the MCP OAuth
flow to use the same gateway callback pattern as WASM extensions: in
gateway mode, the auth URL is returned to the frontend via SSE and opened
with `window.open()`, keeping the user in the same browser.
Also adds RFC 8707 `resource` parameter support to the gateway token
exchange path, scoping issued tokens to the correct MCP server.
Closes#299
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(mcp): persist DCR client_id in gateway OAuth callback for token refresh
The gateway callback handler stored access and refresh tokens but not
the DCR client_id. When the token expired, refresh failed with "No
client ID found" because get_client_id() could not find it in secrets.
Adds client_id_secret_name to PendingOAuthFlow so the gateway callback
handler persists the client_id alongside the tokens, matching the
behavior of the CLI flow in authorize_mcp_server().
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(mcp): return AuthRequired on 401 so activate triggers OAuth flow
activate_mcp() returned ActivationFailed for all errors including 401
auth responses, so the activate handler never triggered the OAuth flow.
Now 401/auth errors return AuthRequired, which the handler detects and
redirects to the OAuth flow — matching the WASM extension pattern.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(mcp): fix gateway OAuth flow, approval cards, and auto-activation
- Add explicit gateway_mode flag on ExtensionManager (set at startup by
web gateway) so MCP OAuth returns auth URLs to the frontend instead of
calling open::that() on the server machine.
- Auto-activate extensions after successful OAuth callback so the UI
transitions from "Activate" to "Active" without a second click.
- Send ApprovalNeeded status (not generic "Awaiting approval") from
thread_ops.rs for all three NeedApproval paths so the web UI shows
approval cards for deferred tool calls.
- Remove duplicate ApprovalNeeded send from agent_loop.rs (thread_ops.rs
is now the canonical sender).
- Skip approval for tool_auth in gateway mode since it only returns a URL.
- Revert fragile active-server detection heuristic from system prompt.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: address PR review findings
- Use Release/Acquire ordering for gateway_mode AtomicBool instead of
Relaxed to ensure visibility across threads.
- Report activation failure as error in OAuth callback SSE event instead
of silently falling back to the success message.
- Fix EnvGuard::drop to remove env var when original was unset.
- Replace hardcoded /tmp/ path with std::env::temp_dir() in test helper.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* test(mcp): add E2E trace test for MCP extension lifecycle with mock server
Add a full MCP extension lifecycle E2E test that exercises:
- Turn 1: tool_search → tool_install → text (extension discovery and install)
- Token injection + activate (simulating OAuth completion)
- Turn 2: MCP tool calls (notion-search → notion-fetch → text)
Includes a mock MCP server (tests/support/mock_mcp_server.rs) with OAuth
discovery, DCR, token exchange, and JSON-RPC endpoints. The mock server
validates Bearer auth and serves pre-configured tool responses.
Also adds inject_registry_entry() to ExtensionManager for test use and
exposes extension_manager from TestRig.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: address PR review findings (round 2)
- Only fall back to manual token entry on AuthNotSupported, propagate
real errors from auth_mcp_build_url() instead of masking them
- Use mcp:-prefixed provider string in PendingOAuthFlow for consistency
with CLI MCP auth token storage
- Only persist client_id_secret_name for DCR flows (not pre-configured OAuth)
- Fix gateway_callback_redirect_uri to use /oauth/callback path
- Bypass exchange proxy when flow has RFC 8707 resource parameter
- Remove client_id double-prefix in oauth callback handler
- Remove weak tests that didn't exercise production logic
- Add clarifying comments for exchange_oauth_code delegation
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: keep OAuth success independent of activation, fix wait_for_responses scoping
- OAuth success is now reported accurately even when auto-activation
fails (tokens are already stored, so auth succeeded)
- E2E test waits for turn1_count + 1 responses to ensure turn-2
behavior is actually observed
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* fix(mcp): header safety validation and Authorization conflict bug from #704
* fix(mcp): enforce RFC 9110 header validation on all config load paths
Replace hand-written CRLF checks with reqwest::header::HeaderName::from_bytes()
and HeaderValue::from_str(), catching spaces, colons, null bytes, and all
non-token characters that the previous validation missed.
Add validation to load_mcp_servers_from() and load_mcp_servers_from_db() so
corrupted configs from disk or DB are rejected at load time instead of silently
flowing through to McpClient. Improve app.rs error handling to distinguish
"no config" from "corrupted config" (including malformed JSON).
Also fix build_request_headers() to check self.custom_headers directly instead
of indirectly via server_config, and clarify the wire test comment about
HeaderMap::insert replacement semantics.
* fix ci issue
fixes#890
- Always call initialize() before list_tools()/call_tool(), removing
the session_manager.is_some() guard that caused stdio/unix clients
to skip the MCP protocol handshake entirely
- Add local AtomicBool flag for idempotent initialization when no
session manager is present
- Fire-and-forget JSON-RPC notifications (id=None) in stdio/unix
transports instead of registering a pending response that would
block for 30s waiting on a reply that never comes
- Fix mcp test panic on stdio/unix servers by using
create_client_from_config() instead of new_with_config() which
asserts HTTP-only transport
The extract_tool_description and extract_tool_schema stubs in runtime.rs
returned permissive fallbacks ("WASM sandboxed tool" and
additionalProperties:true) for every WASM tool, defeating parameter
validation and preventing the LLM from using tools correctly.
Add optional `description` and `parameters` fields to CapabilitiesFile so
tool authors can declare proper metadata in their sidecar JSON. The
WasmToolLoader now extracts these fields and passes them through to the
tool registry as overrides. Tools without a capabilities.json or without
these fields get a tracing::warn and fall back to the old stubs.
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
* fix(security): resolve DNS once and reuse for SSRF validation to prevent rebinding
The previous SSRF protection resolved DNS in validate_url() to check IPs
against a blocklist, but then reqwest independently re-resolved DNS when
making the actual HTTP connection. Between validation and connection, a
DNS rebinding attack could flip the record from a public IP (passes
validation) to a private IP like 169.254.169.254 (AWS metadata endpoint).
Fix: split URL validation into two phases:
- validate_url(): synchronous URL structure checks (scheme, localhost,
IP literals) -- no DNS resolution
- validate_and_resolve_url(): async DNS resolution via
tokio::net::lookup_host, validates all resolved IPs, returns
SocketAddrs
- build_pinned_client(): constructs a per-request reqwest Client with
resolve() pinning so reqwest connects to the pre-validated IPs without
a second DNS lookup
Applied to both HttpTool and WebFetchTool. WebFetchTool builds a fresh
pinned client per redirect hop, ensuring DNS rebinding cannot occur at
any point in a redirect chain.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* style: run cargo fmt
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---------
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
* feat(extensions): unify auth and configure into single entrypoint
Refactors the extension lifecycle to eliminate the divergence between
chat and gateway paths that caused Telegram setup via chat to fail
(missing webhook secret auto-generation, no token validation).
Key changes:
- Rename save_setup_secrets() → configure(): single entrypoint for
providing secrets to any extension (WasmChannel, WasmTool, MCP).
Validates, stores, auto-generates, and activates.
- Add configure_token(): convenience wrapper for single-token callers
(chat auth card, WebSocket, agent auth mode).
- Refactor auth() to pure status check: remove token parameter,
delete token-storing branches from auth_mcp/auth_wasm_tool,
rename auth_wasm_channel → auth_wasm_channel_status.
- Add ConfigureResult/MissingSecret types for structured responses.
- Replace hardcoded Telegram token validation with generic
validation_endpoint from capabilities.json.
- Update all callers (9 files) to use the new interface.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: use ValidationFailed error variant instead of string matching
Replace brittle msg.contains("Invalid token") checks with a proper
ExtensionError::ValidationFailed variant. configure() now returns
this variant for token validation failures, and callers match on it
directly instead of parsing error message strings.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address review — SSRF protection, error typing, missing-secret selection, WS auth
1. SSRF: call validate_fetch_url() before validation_endpoint HTTP request
2. Transport errors map to ExtensionError::Other (not ValidationFailed)
3. configure_token() picks first *missing* secret, not first non-optional
4. WebSocket error path re-emits AuthRequired on ValidationFailed
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* test: add regression tests for extension lifecycle refactoring
- test_configure_token_picks_first_missing_secret: verifies multi-secret
channels can be configured one secret at a time (commit ce106f4)
- test_auth_is_read_only_for_wasm_channel: verifies auth() has no side
effects and doesn't store secrets (commit 47f8eb6)
- test_validation_failed_is_distinct_error_variant: verifies the typed
error variant can be pattern-matched (commit a318161)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: address review comments — activation dispatch, dead code, caps consolidation
- Fix configure() fallthrough bug: dispatch activation by ExtensionKind
instead of unconditionally calling activate_wasm_channel() for all
non-WasmTool types (MCP servers and channel relays now use their
correct activation methods)
- Remove dead MissingSecret struct and missing_secrets field (never
populated, flagged by reviewer)
- Consolidate capabilities file parsing in configure(): parse once
and reuse for allowed names, validation_endpoint, and auto-generation
- Fix auth() doc comment: note MCP OAuth side effects
- Fix stale save_setup_secrets reference in server.rs comment
- Add regression test for activation dispatch bug
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* Add generic host-verified webhook ingress for tools
* Stabilize trace E2E test rig and approval behavior
* Fix webhook security issues from review feedback
- Reject tools without webhook_capability() (was unauthenticated RCE)
- Remove secret-in-query-string fallback (leak via logs/referrers)
- Require approval for event_emit tool (escalation via routine triggers)
- Simplify header_value() (HeaderMap already case-insensitive)
- Redact internal errors from webhook HTTP responses
- Remove unused hmac_timestamp_tolerance_secs field
- Add regression test for tool without webhook capability
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Harden webhook ingress: require auth mechanism, body limit layer, health check
- Reject webhook capabilities that declare no auth mechanism (empty
WebhookCapability would previously allow unauthenticated access)
- Add DefaultBodyLimit layer to reject oversized payloads before buffering
- Health check (GET) now verifies tool has webhook_capability(), not just
existence
- Add regression tests for all three fixes
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* Fix auto_approve_tools inconsistency between dispatcher and thread_ops
dispatcher.rs skips all approval checks (including Always) when
auto_approve_tools is true, but thread_ops.rs still required approval
for Always tools. This caused deferred tool calls to unexpectedly halt
in test rigs and auto-approve configurations.
Match dispatcher behavior: short-circuit all approval when
auto_approve_tools is enabled.
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* 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]>
* feat(setup): quick onboarding, preserve .env vars, clean up boot logging (#751, #674)
- Add upsert_bootstrap_vars() to preserve user-added .env vars on re-onboarding
- Add --quick mode: auto-defaults DB + security, asks only LLM provider (2 steps)
- Auto-triggered onboarding uses quick mode for near-instant first run
- Fix NEAR AI model fetch to use cloud-api.near.ai when API key is set
- Handle missing WASM tools/channels directories gracefully
- Downgrade all boot/shutdown tracing::info! to debug (boot screen shows user output)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(setup): gate env_backend variable behind postgres feature flag [skip-regression-check]
Clippy lint fix — not a behavioral change, just moving a variable declaration
inside the cfg(feature = "postgres") block where it's used.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(review): address PR review comments
- WASM loaders: use tokio::fs::metadata, only treat NotFound as empty,
propagate other IO errors, handle TOCTOU in read_dir
- bootstrap: only ignore NotFound in read_to_string, propagate other errors
- wizard: restore print_info/print_success for migrations in interactive
mode (gated by !config.quick), keep tracing::debug for diagnostics
- tests: use shared crate::config::helpers::ENV_MUTEX instead of separate
NEARAI_ENV_MUTEX to prevent cross-test env var races
- README: fix quick mode description to mention model selection, clarify
auto_setup_database may prompt when DATABASE_URL is set
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* feat(setup): skip prompts for DATABASE_URL in quick mode [skip-regression-check]
auto_setup_database() now uses DATABASE_URL directly without calling
step_database_postgres() (which prompts for confirmation). Quick mode
should be fully non-interactive when env vars are already set.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix(cli): update --quick help text to mention model selection [skip-regression-check]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>