When LLMs pass the schedule parameter as a JSON string instead of a JSON
object, serde fails with "invalid type: string, expected internally
tagged enum Schedule". Add a deserialize_maybe_stringified helper that
detects stringified JSON values and parses the inner string before
deserializing, providing backward compatibility for both object and
string representations.
Fixes#3860
Handle Schedule::At jobs in reschedule_after_run by disabling them
instead of rescheduling to a past timestamp. Also add a fallback in
persist_job_result to disable one-shot jobs if removal fails.
Closes#3868
Wrap all crate::agent::run() calls with Box::pin() across scheduler,
daemon, gateway tests, and main.rs to satisfy clippy::large_futures.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(security): add Nevis IAM integration for SSO/MFA authentication
Add NevisAuthProvider supporting OAuth2/OIDC token validation (local JWKS +
remote introspection), FIDO2/passkey/OTP MFA verification, session management,
and health checks. Add IamPolicy engine mapping Nevis roles to ZeroClaw tool
and workspace permissions with deny-by-default enforcement and audit logging.
Add NevisConfig and NevisRoleMappingConfig to config schema with client_secret
wired through SecretStore encrypt/decrypt. All features disabled by default.
Rebased on latest master to resolve merge conflicts in security/mod.rs (redact
function) and config/schema.rs (test section).
Original work by @rareba. Supersedes #3593.
Co-Authored-By: rareba <5985289+rareba@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: cargo fmt Box::pin calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: rareba <5985289+rareba@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add an optional `allowed_tools` parameter that restricts which tools are
available to the agent. When `Some(list)`, only tools whose name appears
in the list are retained; when `None`, all tools remain available
(backward compatible). This enables fine-grained capability control for
cron jobs, heartbeat tasks, and CLI invocations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The CLI `cron add` command always routed the second positional argument
through shell security policy validation, which blocked natural language
prompts like "Check server health: disk space, memory, CPU load". This
adds an `--agent` flag to `cron add`, `cron add-at`, `cron add-every`,
and `cron once` so that natural language prompts are correctly stored as
agent jobs without shell command validation.
Closes#3563
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add SignalChannel import and match arm in deliver_announcement() so
cron jobs with delivery.channel = "signal" are handled instead of
rejected as unsupported.
Closes#3476
A single cron job with a malformed `next_run` timestamp in the database
was silently stopping all scheduled jobs. The `due_jobs` query matched
rows whose `next_run` was lexicographically past-due (including
non-RFC3339 values like "2026-03-12 03:11:13" which sort before valid
RFC3339 strings), then `map_cron_job_row` failed to parse the timestamp,
the `row?` propagation caused `due_jobs` to return `Err`, and the
scheduler marked itself as `error` and skipped every subsequent tick —
taking down all other healthy jobs with it.
The fix changes the row iteration in `due_jobs` to log a warning and
skip unparseable rows rather than aborting the entire result set. Valid
jobs continue to fire; the broken row is surfaced in the logs without
collateral damage to the scheduler.
Co-authored-by: ZeroClaw <zeroclaw@users.noreply.github.com>
Co-authored-by: Argenis <theonlyhennygod@gmail.com>
Centralize cron shell command validation so all entrypoints enforce the
same security policy (allowlist + risk gate + approval) before
persistence and execution.
Changes:
- Add validate_shell_command() and validate_shell_command_with_security()
as the single validation gate for all cron shell paths
- Add add_shell_job_with_approval() and update_shell_job_with_approval()
that validate before persisting
- Add add_once_validated() and add_once_at_validated() for one-shot jobs
- Make raw add_shell_job/add_job/add_once/add_once_at pub(crate) to
prevent unvalidated writes from outside the cron module
- Route gateway API through validated creation path
- Route schedule tool through validated helpers (single validation)
- Route cron_add/cron_update tools through validated helpers
- Unify scheduler execution validation via validate_shell_command_with_security
- CLI update handler uses full validate_command_execution instead of
just is_command_allowed
- Add focused tests for validation parity across entrypoints
- Standardize error format to "blocked by security policy: {reason}"
Closes#2741Closes#2742
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Return output string from 'execute_and_persist_job' alongside job id and success flag.
- Include failure reason in 'tracing::warn' when a scheduler job fails.
- Makes failed cron job errors visible in logs without inspecting the database.
Daemon heartbeat and cron tasks called agent::run() which hardcoded
channel_name as "cli" and always created an ApprovalManager, causing
[Y]es / [N]o / [A]lways stdin prompts on the unattended daemon terminal.
Add interactive parameter to agent::run(): CLI passes true (preserving
approval flow), daemon/cron pass false (no ApprovalManager, channel
marked as "daemon").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove duplicate `chat` method in reliable.rs (E0201)
- Fix `futures` → `futures_util` imports in agent.rs and loop_.rs (E0433)
- Gate PostgresMemory behind `memory-postgres` feature in cli.rs (E0433)
- Fix regex backreference in XML tool parser (unsupported by regex crate)
- Add missing `skills_prompt_mode` argument in test
- Apply rustfmt to files with formatting issues on main
Add Update variant to CronCommands in both main.rs and lib.rs, with
handler in cron/mod.rs that constructs a CronJobPatch and calls
update_job(). Includes security policy check for command changes.
Fixes from review feedback:
- --tz alone now correctly updates timezone (fetches existing schedule)
- --expression alone preserves existing timezone instead of clearing it
- All-None patch (no flags) now returns an error
- Output uses consistent emoji prefix
Tests exercise handle_command directly to cover schedule construction.
Closes#809
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add mention_only support for the Mattermost channel, matching the existing
Discord implementation. When enabled, the bot only processes messages that
contain an @-mention of the bot username, reducing noise in busy channels.
- Add mention_only field to MattermostConfig schema (Option<bool>, default false)
- Rename get_bot_user_id() to get_bot_identity() returning (user_id, username)
- Add contains_bot_mention_mm() with case-insensitive word-boundary matching
and metadata.mentions array support
- Add normalize_mattermost_content() to strip @-mentions from processed text
- Wire mention_only through channel and cron factory constructors
- Add 23 new tests covering mention detection, stripping, case-insensitivity,
word boundaries, metadata mentions, empty-after-strip, and disabled passthrough
Adds mention_only config option to Telegram channel, allowing the bot
to only respond to messages that @-mention the bot in group chats.
Direct messages are always processed regardless of this setting.
Behavior:
- When mention_only = true: Bot only responds to group messages containing @botname
- When mention_only = false (default): Bot responds to all allowed messages
- DM/private chats always work regardless of mention_only setting
Implementation:
- Fetch and cache bot username from Telegram API on startup
- Check for @botname mention in group messages
- Strip mention from message content before processing
Config example:
[channels.telegram]
bot_token = "your_token"
mention_only = true
Changes:
- src/config/schema.rs: Add mention_only to TelegramConfig
- src/channels/telegram.rs: Implement mention_only logic + 6 new tests
- src/channels/mod.rs: Update factory calls
- src/cron/scheduler.rs: Update constructor call
- src/onboard/wizard.rs: Update wizard config
- src/daemon/mod.rs: Update test config
- src/integrations/registry.rs: Update test config
- TESTING_TELEGRAM.md: Add mention_only test section
- CHANGELOG.md: Document feature
Risk: medium
Backward compatible: Yes (default: false)
Add two Mattermost channel enhancements:
1. thread_replies config option (default: false)
- When false, replies go to the channel root instead of threading.
- When true, replies thread on the original post.
- Existing thread replies always stay in-thread regardless of setting.
2. Typing indicator (start_typing/stop_typing)
- Implements the Channel trait's typing methods for Mattermost.
- Fires POST /api/v4/users/me/typing every 4s in a background task.
- Supports parent_id for threaded typing indicators.
- Aborts cleanly on stop_typing via JoinHandle.
Updated all MattermostChannel::new call sites (start_channels, scheduler)
and added 9 unit tests covering thread routing and edge cases.
Wire the existing provider-layer streaming infrastructure through the
channel trait and agent loop so Telegram users see tokens arrive
progressively via editMessageText, instead of waiting for the full
response.
Changes:
- Add StreamMode enum (off/partial/block) and draft_update_interval_ms
to TelegramConfig (backward-compatible defaults: off, 1000ms)
- Add supports_draft_updates/send_draft/update_draft/finalize_draft to
Channel trait with no-op defaults (zero impact on existing channels)
- Implement draft methods on TelegramChannel using sendMessage +
editMessageText with rate limiting and Markdown fallback
- Add on_delta mpsc::Sender<String> parameter to run_tool_call_loop
(None preserves existing behavior)
- Wire streaming in process_channel_message: when channel supports
drafts, send initial draft, spawn updater task, finalize on completion
Edge cases handled:
- 4096-char limit: finalize draft and fall back to chunked send
- Broken Markdown: use no parse_mode during streaming, apply on finalize
- Edit failures: fall back to sending complete response as new message
- Rate limiting: configurable draft_update_interval_ms (default 1s)
## Problem
The test suite contained several categories of latent brittleness
identified in docs/testing-brittle-tests.md that would surface during
refactoring or cross-platform (Windows) CI execution:
1. Hardcoded Unix paths: \Path::new("/tmp")\ and \PathBuf::from("/tmp")\
used as workspace directories in agent tests, which fail on Windows
where /tmp does not exist.
2. Exact string match assertions: ~20 \ssert_eq!(response, "exact text")\
assertions in agent unit and e2e tests that break on any mock wording
change, even when the underlying orchestration behavior is correct.
3. Fragile error message string matching: \.contains("specific message")\
assertions coupled to internal error wording rather than testing the
error category or behavioral outcome.
## What Changed
### Hardcoded paths → platform-agnostic temp dirs (4 files, 7 locations)
- \src/agent/tests.rs\: Replaced all 4 instances of \Path::new("/tmp")\
and \PathBuf::from("/tmp")\ with \std::env::temp_dir()\ in
\make_memory()\, \uild_agent_with()\, \uild_agent_with_memory()\,
and \uild_agent_with_config()\ helpers.
- \ ests/agent_e2e.rs\: Replaced all 3 instances in \make_memory()\,
\uild_agent()\, and \uild_agent_xml()\ helpers.
### Exact string assertions → behavioral checks (2 files, ~20 locations)
- \src/agent/tests.rs\: Converted 10 \ssert_eq!(response, "...")\ to
\ssert!(!response.is_empty(), "descriptive message")\ across tests for
text pass-through, tool execution, tool failure recovery, XML dispatch,
mixed text+tool responses, multi-tool batch, and run_single delegation.
- \ ests/agent_e2e.rs\: Converted 9 exact-match assertions to behavioral
checks. Multi-turn test now uses \ssert_ne!(r1, r2)\ to verify
sequential responses are distinct without coupling to exact wording.
- Provider error propagation test simplified to \ssert!(result.is_err())\
without asserting on the error message string.
### Fragile error message assertions → structural checks (2 files)
- \src/tools/git_operations.rs\: Replaced fragile OR-branch string match
(\contains("git repository") || contains("Git command failed")\) with
structural assertions: checks \!result.success\, error is non-empty,
and error does NOT mention autonomy/read-only (verifying the failure
is git-related, not permission-related).
- \src/cron/scheduler.rs\: Replaced \contains("agent job failed:")\ with
\!success\ and \!output.is_empty()\ checks that verify failure behavior
without coupling to exact log format.
## What Was NOT Changed (and why)
- \src/agent/loop_.rs\ parser tests: Exact string assertions are the
contract for XML tool call parsing — the exact output IS the spec.
- \src/providers/reliable.rs\: Error message assertions test the error
format contract (provider/model attribution in failure messages).
- \src/service/mod.rs\: Already platform-gated with \#[cfg]\; XML escape
test is a formatting contract where exact match is appropriate.
- \src/config/schema.rs\: TOML test strings use /tmp as data values for
deserialization tests, not filesystem access; HOME tests already use
\std::env::temp_dir()\.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Problem:
In record_run(), an INSERT into cron_runs followed by a pruning DELETE
ran as separate implicit transactions. If the INSERT succeeded but the
DELETE failed (e.g., due to disk pressure or lock contention), the run
table would grow unboundedly since the pruning step was lost while the
new row persisted.
Fix:
Wrap both statements in an explicit transaction using
conn.unchecked_transaction(). If either statement fails, the entire
transaction is rolled back, maintaining the invariant that the run
history stays bounded by max_run_history.
Ref: zeroclaw-labs/zeroclaw#710 (Item 5)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Problem:
The pruning query in record_run uses WHERE job_id = ?1 with
ORDER BY started_at DESC, but only single-column indexes exist
for job_id and started_at separately. SQLite must scan one index
and then sort or scan the other, which is suboptimal for the
combined filter + sort pattern used during pruning.
Fix:
Add a composite index CREATE INDEX IF NOT EXISTS
idx_cron_runs_job_started ON cron_runs(job_id, started_at).
This lets SQLite satisfy the WHERE job_id = ?1 ORDER BY
started_at DESC subquery in a single index scan without a
separate sort step. The existing single-column indexes are
retained for other queries that filter on only one column.
Ref: zeroclaw-labs/zeroclaw#710 (Item 7)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Problem: add_column_if_missing() checks PRAGMA table_info for column existence, then issues ALTER TABLE ADD COLUMN if not found. When two concurrent processes both pass the check before either executes the ALTER, the second process fails with a 'duplicate column name' error.
Fix: Catch the 'duplicate column name' SQLite error after the ALTER TABLE and treat it as a benign no-op. Also explicitly drop statement/rows handles before ALTER to release locks.
Ref: #710 (Item 8)
When mention_only is true, the bot only responds to messages that
@-mention the bot. Other messages in the guild are silently ignored.
Also strips the bot mention from content before processing.
Co-authored-by: Will Sarg <12886992+willsarg@users.noreply.github.com>