Merge origin/master into issue-3952-full-autonomy-channel-prompt

Resolve conflict in src/channels/mod.rs Safety section. Keeps the
PR's AutonomyConfig-based prompt construction (build_system_prompt_with_mode_and_autonomy)
while incorporating master's granular safety rules (conditional
destructive-command and ask-before-acting lines based on autonomy level).
Also fixes missing autonomy_level arg in refresh-skills test and removes
duplicate autonomy.level args from auto-merged call sites.
This commit is contained in:
argenis de la rosa 2026-03-19 15:27:43 -04:00
commit 6292cdfe1c
80 changed files with 2982 additions and 259 deletions

View File

@ -74,4 +74,4 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
cargo build --release --locked --features channel-matrix,channel-lark,memory-postgres --target ${{ matrix.target }}

View File

@ -134,15 +134,27 @@ jobs:
exit 1
fi
# Set up SSH key — normalize line endings and ensure trailing newline
mkdir -p ~/.ssh
echo "$AUR_SSH_KEY" > ~/.ssh/aur
chmod 700 ~/.ssh
printf '%s\n' "$AUR_SSH_KEY" | tr -d '\r' > ~/.ssh/aur
chmod 600 ~/.ssh/aur
cat >> ~/.ssh/config <<SSH_CONFIG
cat > ~/.ssh/config <<'SSH_CONFIG'
Host aur.archlinux.org
IdentityFile ~/.ssh/aur
User aur
StrictHostKeyChecking accept-new
SSH_CONFIG
chmod 600 ~/.ssh/config
# Verify key is valid and print fingerprint for debugging
echo "::group::SSH key diagnostics"
ssh-keygen -l -f ~/.ssh/aur || { echo "::error::AUR_SSH_KEY is not a valid SSH private key"; exit 1; }
echo "::endgroup::"
# Test SSH connectivity before attempting clone
ssh -T -o BatchMode=yes -o ConnectTimeout=10 aur@aur.archlinux.org 2>&1 || true
tmp_dir="$(mktemp -d)"
git clone ssh://aur@aur.archlinux.org/zeroclaw.git "$tmp_dir/aur"

View File

@ -146,6 +146,12 @@ jobs:
perl -0pi -e "s|^ sha256 \".*\"| sha256 \"${tarball_sha}\"|m" "$formula_file"
perl -0pi -e "s|^ license \".*\"| license \"Apache-2.0 OR MIT\"|m" "$formula_file"
# Ensure Node.js build dependency is declared so that build.rs can
# run `npm ci && npm run build` to produce the web frontend assets.
if ! grep -q 'depends_on "node" => :build' "$formula_file"; then
perl -0pi -e 's|( depends_on "rust" => :build\n)|\1 depends_on "node" => :build\n|m' "$formula_file"
fi
git -C "$repo_dir" diff -- "$FORMULA_PATH" > "$tmp_repo/formula.diff"
if [[ ! -s "$tmp_repo/formula.diff" ]]; then
echo "::error::No formula changes generated. Nothing to publish."

View File

@ -16,6 +16,7 @@ env:
CARGO_TERM_COLOR: always
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,memory-postgres
jobs:
version:
@ -213,7 +214,7 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
cargo build --release --locked --features "${{ env.RELEASE_CARGO_FEATURES }}" --target ${{ matrix.target }}
- name: Package (Unix)
if: runner.os != 'Windows'
@ -345,8 +346,6 @@ jobs:
with:
context: docker-ctx
push: true
build-args: |
ZEROCLAW_CARGO_FEATURES=channel-matrix
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta

View File

@ -20,6 +20,7 @@ env:
CARGO_TERM_COLOR: always
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,memory-postgres
jobs:
validate:
@ -214,7 +215,7 @@ jobs:
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
fi
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
cargo build --release --locked --features "${{ env.RELEASE_CARGO_FEATURES }}" --target ${{ matrix.target }}
- name: Package (Unix)
if: runner.os != 'Windows'
@ -388,8 +389,6 @@ jobs:
with:
context: docker-ctx
push: true
build-args: |
ZEROCLAW_CARGO_FEATURES=channel-matrix
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

2
Cargo.lock generated
View File

@ -9164,7 +9164,7 @@ dependencies = [
[[package]]
name = "zeroclawlabs"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"async-imap",

View File

@ -4,7 +4,7 @@ resolver = "2"
[package]
name = "zeroclawlabs"
version = "0.5.0"
version = "0.5.1"
edition = "2021"
authors = ["theonlyhennygod"]
license = "MIT OR Apache-2.0"

View File

@ -12,7 +12,7 @@ RUN npm run build
FROM rust:1.94-slim@sha256:da9dab7a6b8dd428e71718402e97207bb3e54167d37b5708616050b1e8f60ed6 AS builder
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES=""
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres"
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \

View File

@ -27,7 +27,7 @@ RUN npm run build
FROM rust:1.94-bookworm AS builder
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES=""
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres"
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \

View File

@ -1,5 +1,5 @@
<p align="center" dir="rtl">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -18,6 +18,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -16,6 +16,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -16,6 +16,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X : @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit : r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀(日本語)</h1>
@ -15,6 +15,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -16,6 +16,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀(Русский)</h1>
@ -15,6 +15,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -19,6 +19,7 @@
<a href="https://t.me/zeroclawlabs"><img src="https://img.shields.io/badge/Telegram-%40zeroclawlabs-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
</p>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -16,6 +16,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀(简体中文)</h1>
@ -15,6 +15,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -76,7 +76,7 @@ runtime_trace_max_entries = 200
| 键 | 默认值 | 用途 |
|---|---|---|
| `compact_context` | `false` | 为 true 时bootstrap_max_chars=6000rag_chunk_limit=2。适用于 13B 或更小的模型 |
| `compact_context` | `true` | 为 true 时bootstrap_max_chars=6000rag_chunk_limit=2。适用于 13B 或更小的模型 |
| `max_tool_iterations` | `10` | 跨 CLI、网关和渠道的每条用户消息的最大工具调用循环轮次 |
| `max_history_messages` | `50` | 每个会话保留的最大对话历史消息数 |
| `parallel_tools` | `false` | 在单次迭代中启用并行工具执行 |

View File

@ -76,7 +76,7 @@ Operational note for container users:
| Key | Default | Purpose |
|---|---|---|
| `compact_context` | `false` | When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models |
| `compact_context` | `true` | When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models |
| `max_tool_iterations` | `10` | Maximum tool-call loop turns per user message across CLI, gateway, and channels |
| `max_history_messages` | `50` | Maximum conversation history messages retained per session |
| `parallel_tools` | `false` | Enable parallel tool execution within a single iteration |

View File

@ -65,7 +65,7 @@ Lưu ý cho người dùng container:
| Khóa | Mặc định | Mục đích |
|---|---|---|
| `compact_context` | `false` | Khi bật: bootstrap_max_chars=6000, rag_chunk_limit=2. Dùng cho model 13B trở xuống |
| `compact_context` | `true` | Khi bật: bootstrap_max_chars=6000, rag_chunk_limit=2. Dùng cho model 13B trở xuống |
| `max_tool_iterations` | `10` | Số vòng lặp tool-call tối đa mỗi tin nhắn trên CLI, gateway và channels |
| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |

View File

@ -448,46 +448,32 @@ bool_to_word() {
fi
}
guided_input_stream() {
# Some constrained containers report interactive stdin (-t 0) but deny
# opening /dev/stdin directly. Probe readability before selecting it.
if [[ -t 0 ]] && (: </dev/stdin) 2>/dev/null; then
echo "/dev/stdin"
guided_open_input() {
# Use stdin directly when it is an interactive terminal (e.g. SSH into LXC).
# Subshell probing of /dev/stdin fails in some constrained containers even
# when FD 0 is perfectly usable, so skip the probe and trust -t 0.
if [[ -t 0 ]]; then
GUIDED_FD=0
return 0
fi
if [[ -t 0 ]] && (: </proc/self/fd/0) 2>/dev/null; then
echo "/proc/self/fd/0"
return 0
fi
if (: </dev/tty) 2>/dev/null; then
echo "/dev/tty"
return 0
fi
return 1
# Non-interactive stdin: try to open /dev/tty as an explicit fd.
exec {GUIDED_FD}</dev/tty 2>/dev/null || return 1
}
guided_read() {
local __target_var="$1"
local __prompt="$2"
local __silent="${3:-false}"
local __input_source=""
local __value=""
if ! __input_source="$(guided_input_stream)"; then
return 1
fi
[[ -n "${GUIDED_FD:-}" ]] || guided_open_input || return 1
if [[ "$__silent" == true ]]; then
if ! read -r -s -p "$__prompt" __value <"$__input_source"; then
return 1
fi
read -r -s -u "$GUIDED_FD" -p "$__prompt" __value || return 1
echo
else
if ! read -r -p "$__prompt" __value <"$__input_source"; then
return 1
fi
read -r -u "$GUIDED_FD" -p "$__prompt" __value || return 1
fi
printf -v "$__target_var" '%s' "$__value"
@ -708,7 +694,7 @@ prompt_model() {
run_guided_installer() {
local os_name="$1"
if ! guided_input_stream >/dev/null; then
if ! guided_open_input >/dev/null; then
error "guided installer requires an interactive terminal."
error "Run from a terminal, or pass --no-guided with explicit flags."
exit 1

View File

@ -8,7 +8,7 @@ use crate::providers::{
self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall,
};
use crate::runtime;
use crate::security::SecurityPolicy;
use crate::security::{AutonomyLevel, SecurityPolicy};
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::Result;
@ -2181,8 +2181,10 @@ pub(crate) async fn agent_turn(
temperature: f64,
silent: bool,
channel_name: &str,
channel_reply_target: Option<&str>,
multimodal_config: &crate::config::MultimodalConfig,
max_tool_iterations: usize,
approval: Option<&ApprovalManager>,
excluded_tools: &[String],
dedup_exempt_tools: &[String],
activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
@ -2197,8 +2199,9 @@ pub(crate) async fn agent_turn(
model,
temperature,
silent,
None,
approval,
channel_name,
channel_reply_target,
multimodal_config,
max_tool_iterations,
None,
@ -2212,6 +2215,100 @@ pub(crate) async fn agent_turn(
.await
}
fn maybe_inject_channel_delivery_defaults(
tool_name: &str,
tool_args: &mut serde_json::Value,
channel_name: &str,
channel_reply_target: Option<&str>,
) {
if tool_name != "cron_add" {
return;
}
if !matches!(
channel_name,
"telegram" | "discord" | "slack" | "mattermost" | "matrix"
) {
return;
}
let Some(reply_target) = channel_reply_target
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let Some(args) = tool_args.as_object_mut() else {
return;
};
let is_agent_job = args
.get("job_type")
.and_then(serde_json::Value::as_str)
.is_some_and(|job_type| job_type.eq_ignore_ascii_case("agent"))
|| args
.get("prompt")
.and_then(serde_json::Value::as_str)
.is_some_and(|prompt| !prompt.trim().is_empty());
if !is_agent_job {
return;
}
let default_delivery = || {
serde_json::json!({
"mode": "announce",
"channel": channel_name,
"to": reply_target,
})
};
match args.get_mut("delivery") {
None => {
args.insert("delivery".to_string(), default_delivery());
}
Some(serde_json::Value::Null) => {
*args.get_mut("delivery").expect("delivery key exists") = default_delivery();
}
Some(serde_json::Value::Object(delivery)) => {
if delivery
.get("mode")
.and_then(serde_json::Value::as_str)
.is_some_and(|mode| mode.eq_ignore_ascii_case("none"))
{
return;
}
delivery
.entry("mode".to_string())
.or_insert_with(|| serde_json::Value::String("announce".to_string()));
let needs_channel = delivery
.get("channel")
.and_then(serde_json::Value::as_str)
.is_none_or(|value| value.trim().is_empty());
if needs_channel {
delivery.insert(
"channel".to_string(),
serde_json::Value::String(channel_name.to_string()),
);
}
let needs_target = delivery
.get("to")
.and_then(serde_json::Value::as_str)
.is_none_or(|value| value.trim().is_empty());
if needs_target {
delivery.insert(
"to".to_string(),
serde_json::Value::String(reply_target.to_string()),
);
}
}
Some(_) => {}
}
}
async fn execute_one_tool(
call_name: &str,
call_arguments: serde_json::Value,
@ -2405,6 +2502,7 @@ pub(crate) async fn run_tool_call_loop(
silent: bool,
approval: Option<&ApprovalManager>,
channel_name: &str,
channel_reply_target: Option<&str>,
multimodal_config: &crate::config::MultimodalConfig,
max_tool_iterations: usize,
cancellation_token: Option<CancellationToken>,
@ -2815,6 +2913,13 @@ pub(crate) async fn run_tool_call_loop(
}
}
maybe_inject_channel_delivery_defaults(
&tool_name,
&mut tool_args,
channel_name,
channel_reply_target,
);
// ── Approval hook ────────────────────────────────
if let Some(mgr) = approval {
if mgr.needs_approval(&tool_name) {
@ -3369,6 +3474,15 @@ pub async fn run(
"Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
),
];
if matches!(
config.skills.prompt_injection_mode,
crate::config::SkillsPromptInjectionMode::Compact
) {
tool_descs.push((
"read_skill",
"Load the full source for an available skill by name. Use when: compact mode only shows a summary and you need the complete skill instructions.",
));
}
tool_descs.push((
"cron_add",
"Create a cron job. Supports schedule kinds: cron, at, every; and job types: shell or agent.",
@ -3557,6 +3671,7 @@ pub async fn run(
false,
approval_manager.as_ref(),
channel_name,
None,
&config.multimodal,
config.agent.max_tool_iterations,
None,
@ -3783,6 +3898,7 @@ pub async fn run(
false,
approval_manager.as_ref(),
channel_name,
None,
&config.multimodal,
config.agent.max_tool_iterations,
None,
@ -3895,6 +4011,7 @@ pub async fn process_message(
&config.autonomy,
&config.workspace_dir,
));
let approval_manager = ApprovalManager::for_non_interactive(&config.autonomy);
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
&config.memory,
&config.embedding_routes,
@ -4053,6 +4170,15 @@ pub async fn process_message(
("screenshot", "Capture a screenshot."),
("image_info", "Read image metadata."),
];
if matches!(
config.skills.prompt_injection_mode,
crate::config::SkillsPromptInjectionMode::Compact
) {
tool_descs.push((
"read_skill",
"Load the full source for an available skill by name.",
));
}
if config.browser.enabled {
tool_descs.push(("browser_open", "Open approved URLs in browser."));
}
@ -4086,6 +4212,16 @@ pub async fn process_message(
"Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
));
}
// Filter out tools excluded for non-CLI channels (gateway counts as non-CLI).
// Skip when autonomy is `Full` — full-autonomy agents keep all tools.
if config.autonomy.level != AutonomyLevel::Full {
let excluded = &config.autonomy.non_cli_excluded_tools;
if !excluded.is_empty() {
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
}
}
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
@ -4135,8 +4271,11 @@ pub async fn process_message(
ChatMessage::system(&system_prompt),
ChatMessage::user(&enriched),
];
let excluded_tools =
let mut excluded_tools =
compute_excluded_mcp_tools(&tools_registry, &config.agent.tool_filter_groups, message);
if config.autonomy.level != AutonomyLevel::Full {
excluded_tools.extend(config.autonomy.non_cli_excluded_tools.iter().cloned());
}
agent_turn(
provider.as_ref(),
@ -4148,8 +4287,10 @@ pub async fn process_message(
config.default_temperature,
true,
"daemon",
None,
&config.multimodal,
config.agent.max_tool_iterations,
Some(&approval_manager),
&excluded_tools,
&config.agent.tool_call_dedup_exempt,
activated_handle_pm.as_ref(),
@ -4465,6 +4606,57 @@ mod tests {
}
}
struct RecordingArgsTool {
name: String,
recorded_args: Arc<Mutex<Vec<serde_json::Value>>>,
}
impl RecordingArgsTool {
fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
Self {
name: name.to_string(),
recorded_args,
}
}
}
#[async_trait]
impl Tool for RecordingArgsTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Records tool arguments for regression tests"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string" },
"schedule": { "type": "object" },
"delivery": { "type": "object" }
}
})
}
async fn execute(
&self,
args: serde_json::Value,
) -> anyhow::Result<crate::tools::ToolResult> {
self.recorded_args
.lock()
.expect("recorded args lock should be valid")
.push(args.clone());
Ok(crate::tools::ToolResult {
success: true,
output: args.to_string(),
error: None,
})
}
}
struct DelayTool {
name: String,
delay_ms: u64,
@ -4603,6 +4795,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
3,
None,
@ -4652,6 +4845,7 @@ mod tests {
true,
None,
"cli",
None,
&multimodal,
3,
None,
@ -4695,6 +4889,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
3,
None,
@ -4824,6 +5019,7 @@ mod tests {
true,
Some(&approval_mgr),
"telegram",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@ -4861,6 +5057,122 @@ mod tests {
);
}
#[tokio::test]
async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() {
let provider = ScriptedProvider::from_text_responses(vec![
r#"<tool_call>
{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
</tool_call>"#,
"done",
]);
let recorded_args = Arc::new(Mutex::new(Vec::new()));
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
"cron_add",
Arc::clone(&recorded_args),
))];
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("schedule a reminder"),
];
let observer = NoopObserver;
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"telegram",
Some("chat-42"),
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
&[],
None,
None,
)
.await
.expect("cron_add delivery defaults should be injected");
assert_eq!(result, "done");
let recorded = recorded_args
.lock()
.expect("recorded args lock should be valid");
let delivery = recorded[0]["delivery"].clone();
assert_eq!(
delivery,
serde_json::json!({
"mode": "announce",
"channel": "telegram",
"to": "chat-42",
})
);
}
#[tokio::test]
async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() {
let provider = ScriptedProvider::from_text_responses(vec![
r#"<tool_call>
{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
</tool_call>"#,
"done",
]);
let recorded_args = Arc::new(Mutex::new(Vec::new()));
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
"cron_add",
Arc::clone(&recorded_args),
))];
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("schedule a quiet cron job"),
];
let observer = NoopObserver;
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"telegram",
Some("chat-42"),
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
&[],
None,
None,
)
.await
.expect("explicit delivery mode should be preserved");
assert_eq!(result, "done");
let recorded = recorded_args
.lock()
.expect("recorded args lock should be valid");
assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"}));
}
#[tokio::test]
async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
let provider = ScriptedProvider::from_text_responses(vec![
@ -4896,6 +5208,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@ -4964,6 +5277,7 @@ mod tests {
true,
Some(&approval_mgr),
"telegram",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@ -5023,6 +5337,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@ -5102,6 +5417,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@ -5158,6 +5474,7 @@ mod tests {
true,
None,
"cli",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
@ -5230,8 +5547,10 @@ mod tests {
0.0,
true,
"daemon",
None,
&crate::config::MultimodalConfig::default(),
4,
None,
&[],
&[],
Some(&activated),
@ -6674,6 +6993,7 @@ Let me check the result."#;
None, // no bootstrap_max_chars
true, // native_tools
crate::config::SkillsPromptInjectionMode::Full,
crate::security::AutonomyLevel::default(),
);
// Must contain zero XML protocol artifacts
@ -7119,6 +7439,7 @@ Let me check the result."#;
true,
None,
"telegram",
None,
&crate::config::MultimodalConfig::default(),
4,
None,

View File

@ -436,6 +436,7 @@ mod tests {
assert!(output.contains("<available_skills>"));
assert!(output.contains("<name>deploy</name>"));
assert!(output.contains("<location>skills/deploy/SKILL.md</location>"));
assert!(output.contains("read_skill(name)"));
assert!(!output.contains("<instruction>Run smoke tests before deploy.</instruction>"));
assert!(!output.contains("<tools>"));
}

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,7 @@ pub struct SlackChannel {
channel_id: Option<String>,
channel_ids: Vec<String>,
allowed_users: Vec<String>,
thread_replies: bool,
mention_only: bool,
group_reply_allowed_sender_ids: Vec<String>,
user_display_name_cache: Mutex<HashMap<String, CachedSlackDisplayName>>,
@ -75,6 +76,7 @@ impl SlackChannel {
channel_id,
channel_ids,
allowed_users,
thread_replies: true,
mention_only: false,
group_reply_allowed_sender_ids: Vec::new(),
user_display_name_cache: Mutex::new(HashMap::new()),
@ -94,6 +96,12 @@ impl SlackChannel {
self
}
/// Configure whether outbound replies stay in the originating Slack thread.
pub fn with_thread_replies(mut self, thread_replies: bool) -> Self {
self.thread_replies = thread_replies;
self
}
/// Configure workspace directory used for persisting inbound Slack attachments.
pub fn with_workspace_dir(mut self, dir: PathBuf) -> Self {
self.workspace_dir = Some(dir);
@ -122,6 +130,14 @@ impl SlackChannel {
.any(|entry| entry == "*" || entry == user_id)
}
fn outbound_thread_ts<'a>(&self, message: &'a SendMessage) -> Option<&'a str> {
if self.thread_replies {
message.thread_ts.as_deref()
} else {
None
}
}
/// Get the bot's own user ID so we can ignore our own messages
async fn get_bot_user_id(&self) -> Option<String> {
let resp: serde_json::Value = self
@ -2149,7 +2165,7 @@ impl Channel for SlackChannel {
"text": message.content
});
if let Some(ref ts) = message.thread_ts {
if let Some(ts) = self.outbound_thread_ts(message) {
body["thread_ts"] = serde_json::json!(ts);
}
@ -2484,10 +2500,30 @@ mod tests {
#[test]
fn slack_group_reply_policy_defaults_to_all_messages() {
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec!["*".into()]);
assert!(ch.thread_replies);
assert!(!ch.mention_only);
assert!(ch.group_reply_allowed_sender_ids.is_empty());
}
#[test]
fn with_thread_replies_sets_flag() {
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![])
.with_thread_replies(false);
assert!(!ch.thread_replies);
}
#[test]
fn outbound_thread_ts_respects_thread_replies_setting() {
let msg = SendMessage::new("hello", "C123").in_thread(Some("1741234567.100001".into()));
let threaded = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![]);
assert_eq!(threaded.outbound_thread_ts(&msg), Some("1741234567.100001"));
let channel_root = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![])
.with_thread_replies(false);
assert_eq!(channel_root.outbound_thread_ts(&msg), None);
}
#[test]
fn with_workspace_dir_sets_field() {
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec![], vec![])

View File

@ -137,7 +137,12 @@ pub struct Config {
pub cloud_ops: CloudOpsConfig,
/// Conversational AI agent builder configuration (`[conversational_ai]`).
#[serde(default)]
///
/// Experimental / future feature — not yet wired into the agent runtime.
/// Omitted from generated config files when disabled (the default).
/// Existing configs that already contain this section will continue to
/// deserialize correctly thanks to `#[serde(default)]`.
#[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
pub conversational_ai: ConversationalAiConfig,
/// Managed cybersecurity service configuration (`[security_ops]`).
@ -1136,7 +1141,7 @@ fn default_agent_tool_dispatcher() -> String {
impl Default for AgentConfig {
fn default() -> Self {
Self {
compact_context: false,
compact_context: true,
max_tool_iterations: default_agent_max_tool_iterations(),
max_history_messages: default_agent_max_history_messages(),
max_context_tokens: default_agent_max_context_tokens(),
@ -4045,7 +4050,8 @@ pub struct ClassificationRule {
pub struct HeartbeatConfig {
/// Enable periodic heartbeat pings. Default: `false`.
pub enabled: bool,
/// Interval in minutes between heartbeat pings. Default: `30`.
/// Interval in minutes between heartbeat pings. Default: `5`.
#[serde(default = "default_heartbeat_interval")]
pub interval_minutes: u32,
/// Enable two-phase heartbeat: Phase 1 asks LLM whether to run, Phase 2
/// executes only when the LLM decides there is work to do. Saves API cost
@ -4089,6 +4095,10 @@ pub struct HeartbeatConfig {
pub max_run_history: u32,
}
fn default_heartbeat_interval() -> u32 {
5
}
fn default_two_phase() -> bool {
true
}
@ -4109,7 +4119,7 @@ impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 30,
interval_minutes: default_heartbeat_interval(),
two_phase: true,
message: None,
target: None,
@ -4133,6 +4143,15 @@ pub struct CronConfig {
/// Enable the cron subsystem. Default: `true`.
#[serde(default = "default_true")]
pub enabled: bool,
/// Run all overdue jobs at scheduler startup. Default: `true`.
///
/// When the machine boots late or the daemon restarts, jobs whose
/// `next_run` is in the past are considered "missed". With this
/// option enabled the scheduler fires them once before entering
/// the normal polling loop. Disable if you prefer missed jobs to
/// simply wait for their next scheduled occurrence.
#[serde(default = "default_true")]
pub catch_up_on_startup: bool,
/// Maximum number of historical cron run records to retain. Default: `50`.
#[serde(default = "default_max_run_history")]
pub max_run_history: u32,
@ -4146,6 +4165,7 @@ impl Default for CronConfig {
fn default() -> Self {
Self {
enabled: true,
catch_up_on_startup: true,
max_run_history: default_max_run_history(),
}
}
@ -4620,6 +4640,10 @@ pub struct SlackConfig {
/// cancels the in-flight request and starts a fresh response with preserved history.
#[serde(default)]
pub interrupt_on_new_message: bool,
/// When true (default), replies stay in the originating Slack thread.
/// When false, replies go to the channel root instead.
#[serde(default)]
pub thread_replies: Option<bool>,
/// When true, only respond to messages that @-mention the bot in groups.
/// Direct messages remain allowed.
#[serde(default)]
@ -5872,8 +5896,8 @@ fn default_conversational_ai_timeout_secs() -> u64 {
/// Conversational AI agent builder configuration (`[conversational_ai]` section).
///
/// Controls language detection, escalation behavior, conversation limits, and
/// analytics for conversational agent workflows. Disabled by default.
/// **Status: Reserved for future use.** This configuration is parsed but not yet
/// consumed by the runtime. Setting `enabled = true` will produce a startup warning.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ConversationalAiConfig {
/// Enable conversational AI features. Default: false.
@ -5905,6 +5929,17 @@ pub struct ConversationalAiConfig {
pub knowledge_base_tool: Option<String>,
}
impl ConversationalAiConfig {
/// Returns `true` when the feature is disabled (the default).
///
/// Used by `#[serde(skip_serializing_if)]` to omit the entire
/// `[conversational_ai]` section from newly-generated config files,
/// avoiding user confusion over an undocumented / experimental section.
pub fn is_disabled(&self) -> bool {
!self.enabled
}
}
impl Default for ConversationalAiConfig {
fn default() -> Self {
Self {
@ -6884,7 +6919,7 @@ impl Config {
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
source = resolution_source.as_str(),
initialized = false,
initialized = true,
"Config loaded"
);
Ok(config)
@ -7728,6 +7763,13 @@ impl Config {
}
set_runtime_proxy_config(self.proxy.clone());
if self.conversational_ai.enabled {
tracing::warn!(
"conversational_ai.enabled = true but conversational AI features are not yet \
implemented; this section is reserved for future use and will be ignored"
);
}
}
async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
@ -8195,9 +8237,11 @@ async fn sync_directory(path: &Path) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
#[cfg(unix)]
use tempfile::TempDir;
use tokio::sync::{Mutex, MutexGuard};
@ -8207,6 +8251,37 @@ mod tests {
// ── Defaults ─────────────────────────────────────────────
fn has_test_table(raw: &str, table: &str) -> bool {
let exact = format!("[{table}]");
let nested = format!("[{table}.");
raw.lines()
.map(str::trim)
.any(|line| line == exact || line.starts_with(&nested))
}
fn parse_test_config(raw: &str) -> Config {
let mut merged = raw.trim().to_string();
for table in [
"data_retention",
"cloud_ops",
"conversational_ai",
"security",
"security_ops",
] {
if has_test_table(&merged, table) {
continue;
}
if !merged.is_empty() {
merged.push_str("\n\n");
}
merged.push('[');
merged.push_str(table);
merged.push(']');
}
merged.push('\n');
toml::from_str(&merged).unwrap()
}
#[test]
async fn http_request_config_default_has_correct_values() {
let cfg = HttpRequestConfig::default();
@ -8233,6 +8308,36 @@ mod tests {
assert!(c.config_path.to_string_lossy().contains("config.toml"));
}
#[derive(Clone, Default)]
struct SharedLogBuffer(Arc<StdMutex<Vec<u8>>>);
struct SharedLogWriter(Arc<StdMutex<Vec<u8>>>);
impl SharedLogBuffer {
fn captured(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedLogBuffer {
type Writer = SharedLogWriter;
fn make_writer(&'a self) -> Self::Writer {
SharedLogWriter(self.0.clone())
}
}
impl io::Write for SharedLogWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
async fn config_dir_creation_error_mentions_openrc_and_path() {
let msg = config_dir_creation_error(Path::new("/etc/zeroclaw"));
@ -8335,7 +8440,7 @@ mod tests {
async fn heartbeat_config_default() {
let h = HeartbeatConfig::default();
assert!(!h.enabled);
assert_eq!(h.interval_minutes, 30);
assert_eq!(h.interval_minutes, 5);
assert!(h.message.is_none());
assert!(h.target.is_none());
assert!(h.to.is_none());
@ -8369,11 +8474,13 @@ recipient = "42"
async fn cron_config_serde_roundtrip() {
let c = CronConfig {
enabled: false,
catch_up_on_startup: false,
max_run_history: 100,
};
let json = serde_json::to_string(&c).unwrap();
let parsed: CronConfig = serde_json::from_str(&json).unwrap();
assert!(!parsed.enabled);
assert!(!parsed.catch_up_on_startup);
assert_eq!(parsed.max_run_history, 100);
}
@ -8385,8 +8492,9 @@ config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(toml_str).unwrap();
let parsed = parse_test_config(toml_str);
assert!(parsed.cron.enabled);
assert!(parsed.cron.catch_up_on_startup);
assert_eq!(parsed.cron.max_run_history, 50);
}
@ -8563,7 +8671,7 @@ default_temperature = 0.7
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
let parsed = parse_test_config(&toml_str);
assert_eq!(parsed.api_key, config.api_key);
assert_eq!(parsed.default_provider, config.default_provider);
@ -8596,7 +8704,7 @@ workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
let parsed = parse_test_config(minimal);
assert!(parsed.api_key.is_none());
assert!(parsed.default_provider.is_none());
assert_eq!(parsed.observability.backend, "none");
@ -8619,7 +8727,7 @@ default_temperature = 0.7
default_temperature = 0.7
provider_timeout_secs = 300
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert_eq!(parsed.provider_timeout_secs, 300);
}
@ -8699,7 +8807,7 @@ default_temperature = 0.7
User-Agent = "MyApp/1.0"
X-Title = "zeroclaw"
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert_eq!(parsed.extra_headers.len(), 2);
assert_eq!(parsed.extra_headers.get("User-Agent").unwrap(), "MyApp/1.0");
assert_eq!(parsed.extra_headers.get("X-Title").unwrap(), "zeroclaw");
@ -8710,7 +8818,7 @@ X-Title = "zeroclaw"
let raw = r#"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert!(parsed.extra_headers.is_empty());
}
@ -8727,7 +8835,7 @@ table = "memories"
connect_timeout_secs = 12
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert_eq!(parsed.storage.provider.config.provider, "postgres");
assert_eq!(
parsed.storage.provider.config.db_url.as_deref(),
@ -8750,7 +8858,7 @@ default_temperature = 0.7
reasoning_enabled = false
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
}
@ -8783,7 +8891,7 @@ reasoning_effort = "turbo"
#[test]
async fn agent_config_defaults() {
let cfg = AgentConfig::default();
assert!(!cfg.compact_context);
assert!(cfg.compact_context);
assert_eq!(cfg.max_tool_iterations, 10);
assert_eq!(cfg.max_history_messages, 50);
assert!(!cfg.parallel_tools);
@ -8801,7 +8909,7 @@ max_history_messages = 80
parallel_tools = true
tool_dispatcher = "xml"
"#;
let parsed: Config = toml::from_str(raw).unwrap();
let parsed = parse_test_config(raw);
assert!(parsed.agent.compact_context);
assert_eq!(parsed.agent.max_tool_iterations, 20);
assert_eq!(parsed.agent.max_history_messages, 80);
@ -9342,6 +9450,7 @@ allowed_users = ["@ops:matrix.org"]
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
}
@ -9351,6 +9460,7 @@ allowed_users = ["@ops:matrix.org"]
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["U111"]);
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
}
@ -9360,6 +9470,7 @@ allowed_users = ["@ops:matrix.org"]
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.mention_only);
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
}
#[test]
@ -9367,6 +9478,16 @@ allowed_users = ["@ops:matrix.org"]
let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
}
#[test]
async fn slack_config_deserializes_thread_replies() {
let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.thread_replies, Some(false));
assert!(!parsed.interrupt_on_new_message);
assert!(!parsed.mention_only);
}
@ -9390,6 +9511,7 @@ channel_id = "C123"
let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.thread_replies, None);
assert!(!parsed.mention_only);
assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
}
@ -9660,7 +9782,7 @@ workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
let parsed = parse_test_config(minimal);
assert!(
parsed.gateway.require_pairing,
"Missing [gateway] must default to require_pairing=true"
@ -9722,7 +9844,7 @@ workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
let parsed = parse_test_config(minimal);
assert!(
!parsed.composio.enabled,
"Missing [composio] must default to disabled"
@ -9777,7 +9899,7 @@ workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
let parsed = parse_test_config(minimal);
assert!(
parsed.secrets.encrypt,
"Missing [secrets] must default to encrypt=true"
@ -9862,7 +9984,7 @@ workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
let parsed = parse_test_config(minimal);
assert!(!parsed.browser.enabled);
assert!(parsed.browser.allowed_domains.is_empty());
}
@ -9961,7 +10083,7 @@ wire_api = "responses"
requires_openai_auth = true
"#;
let parsed: Config = toml::from_str(raw).expect("config should parse");
let parsed = parse_test_config(raw);
assert_eq!(parsed.default_provider.as_deref(), Some("sub2api"));
assert_eq!(parsed.default_model.as_deref(), Some("gpt-5.3-codex"));
let profile = parsed
@ -10199,7 +10321,7 @@ requires_openai_auth = true
let saved = tokio::fs::read_to_string(&resolved_config_path)
.await
.unwrap();
let parsed: Config = toml::from_str(&saved).unwrap();
let parsed = parse_test_config(&saved);
assert_eq!(parsed.default_temperature, 0.5);
std::env::remove_var("ZEROCLAW_WORKSPACE");
@ -10644,6 +10766,59 @@ default_model = "legacy-model"
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
async fn load_or_init_logs_existing_config_as_initialized() {
let _env_guard = env_override_lock().await;
let temp_home =
std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
let workspace_dir = temp_home.join("profile-a");
let config_path = workspace_dir.join("config.toml");
fs::create_dir_all(&workspace_dir).await.unwrap();
fs::write(
&config_path,
r#"default_temperature = 0.7
default_model = "persisted-profile"
"#,
)
.await
.unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", &temp_home);
std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir);
let capture = SharedLogBuffer::default();
let subscriber = tracing_subscriber::fmt()
.with_ansi(false)
.without_time()
.with_target(false)
.with_writer(capture.clone())
.finish();
let dispatch = tracing::Dispatch::new(subscriber);
let guard = tracing::dispatcher::set_default(&dispatch);
let config = Config::load_or_init().await.unwrap();
drop(guard);
let logs = capture.captured();
assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
assert_eq!(config.config_path, config_path);
assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
assert!(logs.contains("Config loaded"), "{logs}");
assert!(logs.contains("initialized=true"), "{logs}");
assert!(!logs.contains("initialized=false"), "{logs}");
std::env::remove_var("ZEROCLAW_WORKSPACE");
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
async fn env_override_empty_values_ignored() {
let _env_guard = env_override_lock().await;
@ -11296,7 +11471,7 @@ default_model = "legacy-model"
config.transcription.language = Some("en".into());
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
let parsed = parse_test_config(&toml_str);
assert!(parsed.transcription.enabled);
assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
@ -11310,21 +11485,20 @@ default_model = "legacy-model"
default_model = "test-model"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(toml_str).unwrap();
let parsed = parse_test_config(toml_str);
assert!(!parsed.transcription.enabled);
assert_eq!(parsed.transcription.max_duration_secs, 120);
}
#[test]
async fn security_defaults_are_backward_compatible() {
let parsed: Config = toml::from_str(
let parsed = parse_test_config(
r#"
default_provider = "openrouter"
default_model = "anthropic/claude-sonnet-4.6"
default_temperature = 0.7
"#,
)
.unwrap();
);
assert!(!parsed.security.otp.enabled);
assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
@ -11334,7 +11508,7 @@ default_temperature = 0.7
#[test]
async fn security_toml_parses_otp_and_estop_sections() {
let parsed: Config = toml::from_str(
let parsed = parse_test_config(
r#"
default_provider = "openrouter"
default_model = "anthropic/claude-sonnet-4.6"
@ -11354,8 +11528,7 @@ enabled = true
state_file = "~/.zeroclaw/estop-state.json"
require_otp_to_resume = true
"#,
)
.unwrap();
);
assert!(parsed.security.otp.enabled);
assert!(parsed.security.estop.enabled);
@ -11761,7 +11934,7 @@ require_otp_to_resume = true
agents = ["researcher", "writer"]
strategy = "sequential"
"#;
let config: Config = toml::from_str(toml_str).expect("deserialize");
let config = parse_test_config(toml_str);
assert_eq!(config.agents.len(), 2);
assert_eq!(config.swarms.len(), 1);
assert!(config.swarms.contains_key("pipeline"));

View File

@ -14,8 +14,8 @@ pub use schedule::{
};
#[allow(unused_imports)]
pub use store::{
add_agent_job, due_jobs, get_job, list_jobs, list_runs, record_last_run, record_run,
remove_job, reschedule_after_run, update_job,
add_agent_job, all_overdue_jobs, due_jobs, get_job, list_jobs, list_runs, record_last_run,
record_run, remove_job, reschedule_after_run, update_job,
};
pub use types::{
deserialize_maybe_stringified, CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType,
@ -156,6 +156,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
expression,
tz,
agent,
allowed_tools,
command,
} => {
let schedule = Schedule::Cron {
@ -172,12 +173,20 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
false,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added agent cron job {}", job.id);
println!(" Expr : {}", job.expression);
println!(" Next : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added cron job {}", job.id);
println!(" Expr: {}", job.expression);
@ -186,7 +195,12 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
}
Ok(())
}
crate::CronCommands::AddAt { at, agent, command } => {
crate::CronCommands::AddAt {
at,
agent,
allowed_tools,
command,
} => {
let at = chrono::DateTime::parse_from_rfc3339(&at)
.map_err(|e| anyhow::anyhow!("Invalid RFC3339 timestamp for --at: {e}"))?
.with_timezone(&chrono::Utc);
@ -201,11 +215,19 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
true,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added one-shot agent cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added one-shot cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
@ -216,6 +238,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
crate::CronCommands::AddEvery {
every_ms,
agent,
allowed_tools,
command,
} => {
let schedule = Schedule::Every { every_ms };
@ -229,12 +252,20 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
false,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added interval agent cron job {}", job.id);
println!(" Every(ms): {every_ms}");
println!(" Next : {}", job.next_run.to_rfc3339());
println!(" Prompt : {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_shell_job(config, None, schedule, &command)?;
println!("✅ Added interval cron job {}", job.id);
println!(" Every(ms): {every_ms}");
@ -246,6 +277,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
crate::CronCommands::Once {
delay,
agent,
allowed_tools,
command,
} => {
if agent {
@ -261,11 +293,19 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None,
None,
true,
if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
)?;
println!("✅ Added one-shot agent cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
println!(" Prompt: {}", job.prompt.as_deref().unwrap_or_default());
} else {
if !allowed_tools.is_empty() {
bail!("--allowed-tool is only supported with --agent cron jobs");
}
let job = add_once(config, &delay, &command)?;
println!("✅ Added one-shot cron job {}", job.id);
println!(" At : {}", job.next_run.to_rfc3339());
@ -279,21 +319,37 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
tz,
command,
name,
allowed_tools,
} => {
if expression.is_none() && tz.is_none() && command.is_none() && name.is_none() {
bail!("At least one of --expression, --tz, --command, or --name must be provided");
if expression.is_none()
&& tz.is_none()
&& command.is_none()
&& name.is_none()
&& allowed_tools.is_empty()
{
bail!(
"At least one of --expression, --tz, --command, --name, or --allowed-tool must be provided"
);
}
let existing = if expression.is_some() || tz.is_some() || !allowed_tools.is_empty() {
Some(get_job(config, &id)?)
} else {
None
};
// Merge expression/tz with the existing schedule so that
// --tz alone updates the timezone and --expression alone
// preserves the existing timezone.
let schedule = if expression.is_some() || tz.is_some() {
let existing = get_job(config, &id)?;
let (existing_expr, existing_tz) = match existing.schedule {
let existing = existing
.as_ref()
.expect("existing job must be loaded when updating schedule");
let (existing_expr, existing_tz) = match &existing.schedule {
Schedule::Cron {
expr,
tz: existing_tz,
} => (expr, existing_tz),
} => (expr.clone(), existing_tz.clone()),
_ => bail!("Cannot update expression/tz on a non-cron schedule"),
};
Some(Schedule::Cron {
@ -304,10 +360,24 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
None
};
if !allowed_tools.is_empty() {
let existing = existing
.as_ref()
.expect("existing job must be loaded when updating allowed tools");
if existing.job_type != JobType::Agent {
bail!("--allowed-tool is only supported for agent cron jobs");
}
}
let patch = CronJobPatch {
schedule,
command,
name,
allowed_tools: if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
},
..CronJobPatch::default()
};
@ -430,6 +500,7 @@ mod tests {
tz: tz.map(Into::into),
command: command.map(Into::into),
name: name.map(Into::into),
allowed_tools: vec![],
},
config,
)
@ -778,6 +849,7 @@ mod tests {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec![],
command: "Check server health: disk space, memory, CPU load".into(),
},
&config,
@ -808,6 +880,7 @@ mod tests {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec![],
command: "Check server health: disk space, memory, CPU load".into(),
},
&config,
@ -819,6 +892,68 @@ mod tests {
assert_eq!(jobs[0].job_type, JobType::Agent);
}
#[test]
fn cli_agent_allowed_tools_persist() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
handle_command(
crate::CronCommands::Add {
expression: "*/15 * * * *".into(),
tz: None,
agent: true,
allowed_tools: vec!["file_read".into(), "web_search".into()],
command: "Check server health".into(),
},
&config,
)
.unwrap();
let jobs = list_jobs(&config).unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(
jobs[0].allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
}
#[test]
fn cli_update_agent_allowed_tools_persist() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_agent_job(
&config,
Some("agent".into()),
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"original prompt",
SessionTarget::Isolated,
None,
None,
false,
None,
)
.unwrap();
handle_command(
crate::CronCommands::Update {
id: job.id.clone(),
expression: None,
tz: None,
command: None,
name: None,
allowed_tools: vec!["shell".into()],
},
&config,
)
.unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.allowed_tools, Some(vec!["shell".into()]));
}
#[test]
fn cli_without_agent_flag_defaults_to_shell_job() {
let tmp = TempDir::new().unwrap();
@ -829,6 +964,7 @@ mod tests {
expression: "*/5 * * * *".into(),
tz: None,
agent: false,
allowed_tools: vec![],
command: "echo ok".into(),
},
&config,

View File

@ -6,8 +6,9 @@ use crate::channels::{
};
use crate::config::Config;
use crate::cron::{
due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job, reschedule_after_run,
update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule, SessionTarget,
all_overdue_jobs, due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job,
reschedule_after_run, update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule,
SessionTarget,
};
use crate::security::SecurityPolicy;
use anyhow::Result;
@ -33,6 +34,18 @@ pub async fn run(config: Config) -> Result<()> {
crate::health::mark_component_ok(SCHEDULER_COMPONENT);
// ── Startup catch-up: run ALL overdue jobs before entering the
// normal polling loop. The regular loop is capped by `max_tasks`,
// which could leave some overdue jobs waiting across many cycles
// if the machine was off for a while. The catch-up phase fetches
// without the `max_tasks` limit so every missed job fires once.
// Controlled by `[cron] catch_up_on_startup` (default: true).
if config.cron.catch_up_on_startup {
catch_up_overdue_jobs(&config, &security).await;
} else {
tracing::info!("Scheduler startup: catch-up disabled by config");
}
loop {
interval.tick().await;
// Keep scheduler liveness fresh even when there are no due jobs.
@ -51,6 +64,35 @@ pub async fn run(config: Config) -> Result<()> {
}
}
/// Fetch **all** overdue jobs (ignoring `max_tasks`) and execute them.
///
/// Called once at scheduler startup so that jobs missed during downtime
/// (e.g. late boot, daemon restart) are caught up immediately.
async fn catch_up_overdue_jobs(config: &Config, security: &Arc<SecurityPolicy>) {
let now = Utc::now();
let jobs = match all_overdue_jobs(config, now) {
Ok(jobs) => jobs,
Err(e) => {
tracing::warn!("Startup catch-up query failed: {e}");
return;
}
};
if jobs.is_empty() {
tracing::info!("Scheduler startup: no overdue jobs to catch up");
return;
}
tracing::info!(
count = jobs.len(),
"Scheduler startup: catching up overdue jobs"
);
process_due_jobs(config, security, jobs, SCHEDULER_COMPONENT).await;
tracing::info!("Scheduler startup: catch-up complete");
}
pub async fn execute_job_now(config: &Config, job: &CronJob) -> (bool, String) {
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
Box::pin(execute_job_with_retry(config, &security, job)).await
@ -506,18 +548,12 @@ async fn run_job_command_with_timeout(
);
}
let child = match Command::new("sh")
.arg("-lc")
.arg(&job.command)
.current_dir(&config.workspace_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
{
Ok(child) => child,
Err(e) => return (false, format!("spawn error: {e}")),
let child = match build_cron_shell_command(&job.command, &config.workspace_dir) {
Ok(mut cmd) => match cmd.spawn() {
Ok(child) => child,
Err(e) => return (false, format!("spawn error: {e}")),
},
Err(e) => return (false, format!("shell setup error: {e}")),
};
match time::timeout(timeout, child.wait_with_output()).await {
@ -540,6 +576,35 @@ async fn run_job_command_with_timeout(
}
}
/// Build a shell `Command` for cron job execution.
///
/// Uses `sh -c <command>` (non-login shell). On Windows, ZeroClaw users
/// typically have Git Bash installed which provides `sh` in PATH, and
/// cron commands are written with Unix shell syntax. The previous `-lc`
/// (login shell) flag was dropped: login shells load the full user
/// profile on every invocation which is slow and may cause side effects.
///
/// The command is configured with:
/// - `current_dir` set to the workspace
/// - `stdin` piped to `/dev/null` (no interactive input)
/// - `stdout` and `stderr` piped for capture
/// - `kill_on_drop(true)` for safe timeout handling
fn build_cron_shell_command(
command: &str,
workspace_dir: &std::path::Path,
) -> anyhow::Result<Command> {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(command)
.current_dir(workspace_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
Ok(cmd)
}
#[cfg(test)]
mod tests {
use super::*;
@ -900,6 +965,7 @@ mod tests {
None,
None,
true,
None,
)
.unwrap();
let started = Utc::now();
@ -925,6 +991,7 @@ mod tests {
None,
None,
true,
None,
)
.unwrap();
let started = Utc::now();
@ -991,6 +1058,7 @@ mod tests {
best_effort: false,
}),
false,
None,
)
.unwrap();
let started = Utc::now();
@ -1029,6 +1097,7 @@ mod tests {
best_effort: true,
}),
false,
None,
)
.unwrap();
let started = Utc::now();
@ -1060,6 +1129,7 @@ mod tests {
None,
None,
false,
None,
)
.unwrap();
assert!(!job.delete_after_run);
@ -1152,4 +1222,50 @@ mod tests {
.to_string()
.contains("matrix delivery channel requires `channel-matrix` feature"));
}
#[test]
fn build_cron_shell_command_uses_sh_non_login() {
let workspace = std::env::temp_dir();
let cmd = build_cron_shell_command("echo cron-test", &workspace).unwrap();
let debug = format!("{cmd:?}");
assert!(debug.contains("echo cron-test"));
assert!(debug.contains("\"sh\""), "should use sh: {debug}");
// Must NOT use login shell (-l) — login shells load full profile
// and are slow/unpredictable for cron jobs.
assert!(
!debug.contains("\"-lc\""),
"must not use login shell: {debug}"
);
}
#[tokio::test]
async fn build_cron_shell_command_executes_successfully() {
let workspace = std::env::temp_dir();
let mut cmd = build_cron_shell_command("echo cron-ok", &workspace).unwrap();
let output = cmd.output().await.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("cron-ok"));
}
#[tokio::test]
async fn catch_up_queries_all_overdue_jobs_ignoring_max_tasks() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp).await;
config.scheduler.max_tasks = 1; // limit normal polling to 1
// Create 3 jobs with "every minute" schedule
for i in 0..3 {
let _ = cron::add_job(&config, "* * * * *", &format!("echo catchup-{i}")).unwrap();
}
// Verify normal due_jobs is limited to max_tasks=1
let far_future = Utc::now() + ChronoDuration::days(1);
let due = cron::due_jobs(&config, far_future).unwrap();
assert_eq!(due.len(), 1, "due_jobs must respect max_tasks");
// all_overdue_jobs ignores the limit
let overdue = cron::all_overdue_jobs(&config, far_future).unwrap();
assert_eq!(overdue.len(), 3, "all_overdue_jobs must return all");
}
}

View File

@ -77,6 +77,7 @@ pub fn add_agent_job(
model: Option<String>,
delivery: Option<DeliveryConfig>,
delete_after_run: bool,
allowed_tools: Option<Vec<String>>,
) -> Result<CronJob> {
let now = Utc::now();
validate_schedule(&schedule, now)?;
@ -90,8 +91,8 @@ pub fn add_agent_job(
conn.execute(
"INSERT INTO cron_jobs (
id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run
) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, 1, ?8, ?9, ?10, ?11)",
enabled, delivery, delete_after_run, allowed_tools, created_at, next_run
) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, 1, ?8, ?9, ?10, ?11, ?12)",
params![
id,
expression,
@ -102,6 +103,7 @@ pub fn add_agent_job(
model,
serde_json::to_string(&delivery)?,
if delete_after_run { 1 } else { 0 },
encode_allowed_tools(allowed_tools.as_ref())?,
now.to_rfc3339(),
next_run.to_rfc3339(),
],
@ -117,7 +119,8 @@ pub fn list_jobs(config: &Config) -> Result<Vec<CronJob>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output,
allowed_tools
FROM cron_jobs ORDER BY next_run ASC",
)?;
@ -135,7 +138,8 @@ pub fn get_job(config: &Config, job_id: &str) -> Result<CronJob> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output,
allowed_tools
FROM cron_jobs WHERE id = ?1",
)?;
@ -168,7 +172,8 @@ pub fn due_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output,
allowed_tools
FROM cron_jobs
WHERE enabled = 1 AND next_run <= ?1
ORDER BY next_run ASC
@ -188,6 +193,34 @@ pub fn due_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
})
}
/// Return **all** enabled overdue jobs without the `max_tasks` limit.
///
/// Used by the scheduler startup catch-up to ensure every missed job is
/// executed at least once after a period of downtime (late boot, daemon
/// restart, etc.).
pub fn all_overdue_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output, allowed_tools
FROM cron_jobs
WHERE enabled = 1 AND next_run <= ?1
ORDER BY next_run ASC",
)?;
let rows = stmt.query_map(params![now.to_rfc3339()], map_cron_job_row)?;
let mut jobs = Vec::new();
for row in rows {
match row {
Ok(job) => jobs.push(job),
Err(e) => tracing::warn!("Skipping cron job with unparseable row data: {e}"),
}
}
Ok(jobs)
})
}
pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<CronJob> {
let mut job = get_job(config, job_id)?;
let mut schedule_changed = false;
@ -222,6 +255,9 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<
if let Some(delete_after_run) = patch.delete_after_run {
job.delete_after_run = delete_after_run;
}
if let Some(allowed_tools) = patch.allowed_tools {
job.allowed_tools = Some(allowed_tools);
}
if schedule_changed {
job.next_run = next_run_for_schedule(&job.schedule, Utc::now())?;
@ -232,8 +268,8 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<
"UPDATE cron_jobs
SET expression = ?1, command = ?2, schedule = ?3, job_type = ?4, prompt = ?5, name = ?6,
session_target = ?7, model = ?8, enabled = ?9, delivery = ?10, delete_after_run = ?11,
next_run = ?12
WHERE id = ?13",
allowed_tools = ?12, next_run = ?13
WHERE id = ?14",
params![
job.expression,
job.command,
@ -246,6 +282,7 @@ pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<
if job.enabled { 1 } else { 0 },
serde_json::to_string(&job.delivery)?,
if job.delete_after_run { 1 } else { 0 },
encode_allowed_tools(job.allowed_tools.as_ref())?,
job.next_run.to_rfc3339(),
job.id,
],
@ -446,6 +483,7 @@ fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CronJob> {
let next_run_raw: String = row.get(13)?;
let last_run_raw: Option<String> = row.get(14)?;
let created_at_raw: String = row.get(12)?;
let allowed_tools_raw: Option<String> = row.get(17)?;
Ok(CronJob {
id: row.get(0)?,
@ -468,7 +506,8 @@ fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CronJob> {
},
last_status: row.get(15)?,
last_output: row.get(16)?,
allowed_tools: None,
allowed_tools: decode_allowed_tools(allowed_tools_raw.as_deref())
.map_err(sql_conversion_error)?,
})
}
@ -502,6 +541,25 @@ fn decode_delivery(delivery_raw: Option<&str>) -> Result<DeliveryConfig> {
Ok(DeliveryConfig::default())
}
fn encode_allowed_tools(allowed_tools: Option<&Vec<String>>) -> Result<Option<String>> {
allowed_tools
.map(serde_json::to_string)
.transpose()
.context("Failed to serialize cron allowed_tools")
}
fn decode_allowed_tools(raw: Option<&str>) -> Result<Option<Vec<String>>> {
if let Some(raw) = raw {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return serde_json::from_str(trimmed)
.map(Some)
.with_context(|| format!("Failed to parse cron allowed_tools JSON: {trimmed}"));
}
}
Ok(None)
}
fn add_column_if_missing(conn: &Connection, name: &str, sql_type: &str) -> Result<()> {
let mut stmt = conn.prepare("PRAGMA table_info(cron_jobs)")?;
let mut rows = stmt.query([])?;
@ -557,6 +615,7 @@ fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>)
enabled INTEGER NOT NULL DEFAULT 1,
delivery TEXT,
delete_after_run INTEGER NOT NULL DEFAULT 0,
allowed_tools TEXT,
created_at TEXT NOT NULL,
next_run TEXT NOT NULL,
last_run TEXT,
@ -590,6 +649,7 @@ fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>)
add_column_if_missing(&conn, "enabled", "INTEGER NOT NULL DEFAULT 1")?;
add_column_if_missing(&conn, "delivery", "TEXT")?;
add_column_if_missing(&conn, "delete_after_run", "INTEGER NOT NULL DEFAULT 0")?;
add_column_if_missing(&conn, "allowed_tools", "TEXT")?;
f(&conn)
}
@ -704,6 +764,108 @@ mod tests {
assert_eq!(due.len(), 2);
}
#[test]
fn all_overdue_jobs_ignores_max_tasks_limit() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.scheduler.max_tasks = 2;
let _ = add_job(&config, "* * * * *", "echo ov-1").unwrap();
let _ = add_job(&config, "* * * * *", "echo ov-2").unwrap();
let _ = add_job(&config, "* * * * *", "echo ov-3").unwrap();
let far_future = Utc::now() + ChronoDuration::days(365);
// due_jobs respects the limit
let due = due_jobs(&config, far_future).unwrap();
assert_eq!(due.len(), 2);
// all_overdue_jobs returns everything
let overdue = all_overdue_jobs(&config, far_future).unwrap();
assert_eq!(overdue.len(), 3);
}
#[test]
fn all_overdue_jobs_excludes_disabled_jobs() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_job(&config, "* * * * *", "echo disabled").unwrap();
let _ = update_job(
&config,
&job.id,
CronJobPatch {
enabled: Some(false),
..CronJobPatch::default()
},
)
.unwrap();
let far_future = Utc::now() + ChronoDuration::days(365);
let overdue = all_overdue_jobs(&config, far_future).unwrap();
assert!(overdue.is_empty());
}
#[test]
fn add_agent_job_persists_allowed_tools() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_agent_job(
&config,
Some("agent".into()),
Schedule::Every { every_ms: 60_000 },
"do work",
SessionTarget::Isolated,
None,
None,
false,
Some(vec!["file_read".into(), "web_search".into()]),
)
.unwrap();
assert_eq!(
job.allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
let stored = get_job(&config, &job.id).unwrap();
assert_eq!(stored.allowed_tools, job.allowed_tools);
}
#[test]
fn update_job_persists_allowed_tools_patch() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_agent_job(
&config,
Some("agent".into()),
Schedule::Every { every_ms: 60_000 },
"do work",
SessionTarget::Isolated,
None,
None,
false,
None,
)
.unwrap();
let updated = update_job(
&config,
&job.id,
CronJobPatch {
allowed_tools: Some(vec!["shell".into()]),
..CronJobPatch::default()
},
)
.unwrap();
assert_eq!(updated.allowed_tools, Some(vec!["shell".into()]));
assert_eq!(
get_job(&config, &job.id).unwrap().allowed_tools,
Some(vec!["shell".into()])
);
}
#[test]
fn reschedule_after_run_persists_last_status_and_last_run() {
let tmp = TempDir::new().unwrap();

View File

@ -315,7 +315,10 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
// ── Phase 1: LLM decision (two-phase mode) ──────────────
let tasks_to_run = if two_phase {
let decision_prompt = HeartbeatEngine::build_decision_prompt(&tasks);
let decision_prompt = format!(
"[Heartbeat Task | decision] {}",
HeartbeatEngine::build_decision_prompt(&tasks),
);
match Box::pin(crate::agent::run(
config.clone(),
Some(decision_prompt),

View File

@ -357,6 +357,65 @@ pub async fn handle_api_cron_delete(
}
}
/// GET /api/cron/settings — return cron subsystem settings
pub async fn handle_api_cron_settings_get(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let config = state.config.lock().clone();
Json(serde_json::json!({
"enabled": config.cron.enabled,
"catch_up_on_startup": config.cron.catch_up_on_startup,
"max_run_history": config.cron.max_run_history,
}))
.into_response()
}
/// PATCH /api/cron/settings — update cron subsystem settings
pub async fn handle_api_cron_settings_patch(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let mut config = state.config.lock().clone();
if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) {
config.cron.enabled = v;
}
if let Some(v) = body.get("catch_up_on_startup").and_then(|v| v.as_bool()) {
config.cron.catch_up_on_startup = v;
}
if let Some(v) = body.get("max_run_history").and_then(|v| v.as_u64()) {
config.cron.max_run_history = u32::try_from(v).unwrap_or(u32::MAX);
}
if let Err(e) = config.save().await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
)
.into_response();
}
*state.config.lock() = config.clone();
Json(serde_json::json!({
"status": "ok",
"enabled": config.cron.enabled,
"catch_up_on_startup": config.cron.catch_up_on_startup,
"max_run_history": config.cron.max_run_history,
}))
.into_response()
}
/// GET /api/integrations — list all integrations with status
pub async fn handle_api_integrations(
State(state): State<AppState>,

View File

@ -766,6 +766,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
.route("/api/tools", get(api::handle_api_tools))
.route("/api/cron", get(api::handle_api_cron_list))
.route("/api/cron", post(api::handle_api_cron_add))
.route(
"/api/cron/settings",
get(api::handle_api_cron_settings_get).patch(api::handle_api_cron_settings_patch),
)
.route("/api/cron/{id}", delete(api::handle_api_cron_delete))
.route("/api/cron/{id}/runs", get(api::handle_api_cron_runs))
.route("/api/integrations", get(api::handle_api_integrations))

View File

@ -299,6 +299,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@ -317,6 +320,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@ -335,6 +341,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@ -355,6 +364,9 @@ Examples:
/// Treat the argument as an agent prompt instead of a shell command
#[arg(long)]
agent: bool,
/// Restrict agent cron jobs to the specified tool names (repeatable, agent-only)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
/// Command (shell) or prompt (agent) to run
command: String,
},
@ -388,6 +400,9 @@ Examples:
/// New job name
#[arg(long)]
name: Option<String>,
/// Replace the agent job allowlist with the specified tool names (repeatable)
#[arg(long = "allowed-tool")]
allowed_tools: Vec<String>,
},
/// Pause a scheduled task
Pause {

View File

@ -101,6 +101,7 @@ pub fn should_skip_autosave_content(content: &str) -> bool {
let lowered = normalized.to_ascii_lowercase();
lowered.starts_with("[cron:")
|| lowered.starts_with("[heartbeat task")
|| lowered.starts_with("[distilled_")
|| lowered.contains("distilled_index_sig:")
}
@ -471,6 +472,12 @@ mod tests {
assert!(should_skip_autosave_content(
"[DISTILLED_MEMORY_CHUNK 1/2] DISTILLED_INDEX_SIG:abc123"
));
assert!(should_skip_autosave_content(
"[Heartbeat Task | decision] Should I run tasks?"
));
assert!(should_skip_autosave_content(
"[Heartbeat Task | high] Execute scheduled patrol"
));
assert!(!should_skip_autosave_content(
"User prefers concise answers."
));

View File

@ -463,6 +463,47 @@ fn resolve_quick_setup_dirs_with_home(home: &Path) -> (PathBuf, PathBuf) {
(config_dir.clone(), config_dir.join("workspace"))
}
fn homebrew_prefix_for_exe(exe: &Path) -> Option<&'static str> {
let exe = exe.to_string_lossy();
if exe == "/opt/homebrew/bin/zeroclaw"
|| exe.starts_with("/opt/homebrew/Cellar/zeroclaw/")
|| exe.starts_with("/opt/homebrew/opt/zeroclaw/")
{
return Some("/opt/homebrew");
}
if exe == "/usr/local/bin/zeroclaw"
|| exe.starts_with("/usr/local/Cellar/zeroclaw/")
|| exe.starts_with("/usr/local/opt/zeroclaw/")
{
return Some("/usr/local");
}
None
}
fn quick_setup_homebrew_service_note(
config_path: &Path,
workspace_dir: &Path,
exe: &Path,
) -> Option<String> {
let prefix = homebrew_prefix_for_exe(exe)?;
let service_root = Path::new(prefix).join("var").join("zeroclaw");
let service_config = service_root.join("config.toml");
let service_workspace = service_root.join("workspace");
if config_path == service_config || workspace_dir == service_workspace {
return None;
}
Some(format!(
"Homebrew service note: `brew services` uses {} (config {}) by default. Your onboarding just wrote {}. If you plan to run ZeroClaw as a service, copy or link this workspace first.",
service_workspace.display(),
service_config.display(),
config_path.display(),
))
}
#[allow(clippy::too_many_lines)]
async fn run_quick_setup_with_home(
credential_override: Option<&str>,
@ -650,6 +691,16 @@ async fn run_quick_setup_with_home(
style("Config saved:").white().bold(),
style(config_path.display()).green()
);
if cfg!(target_os = "macos") {
if let Ok(exe) = std::env::current_exe() {
if let Some(note) =
quick_setup_homebrew_service_note(&config_path, &workspace_dir, &exe)
{
println!();
println!(" {}", style(note).yellow());
}
}
}
println!();
println!(" {}", style("Next steps:").white().bold());
if credential_override.is_none() {
@ -3913,6 +3964,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
},
allowed_users,
interrupt_on_new_message: false,
thread_replies: None,
mention_only: false,
});
}
@ -5367,7 +5419,7 @@ async fn scaffold_workspace(workspace_dir: &Path, ctx: &ProjectContext) -> Resul
Participate, don't dominate. Respond when mentioned or when you add genuine value.\n\
Stay silent when it's casual banter or someone already answered.\n\n\
## Tools & Skills\n\n\
Skills are listed in the system prompt. Use `read` on a skill's SKILL.md for details.\n\
Skills are listed in the system prompt. Use `read_skill` when available, or `file_read` on a skill file, for full details.\n\
Keep local notes (SSH hosts, device names, etc.) in `TOOLS.md`.\n\n\
## Crash Recovery\n\n\
- If a run stops unexpectedly, recover context before acting.\n\
@ -6066,6 +6118,52 @@ mod tests {
assert_eq!(config.config_path, expected_config_path);
}
#[test]
fn homebrew_prefix_for_exe_detects_supported_layouts() {
assert_eq!(
homebrew_prefix_for_exe(Path::new("/opt/homebrew/bin/zeroclaw")),
Some("/opt/homebrew")
);
assert_eq!(
homebrew_prefix_for_exe(Path::new(
"/opt/homebrew/Cellar/zeroclaw/0.5.0/bin/zeroclaw",
)),
Some("/opt/homebrew")
);
assert_eq!(
homebrew_prefix_for_exe(Path::new("/usr/local/bin/zeroclaw")),
Some("/usr/local")
);
assert_eq!(homebrew_prefix_for_exe(Path::new("/tmp/zeroclaw")), None);
}
#[test]
fn quick_setup_homebrew_service_note_mentions_service_workspace() {
let note = quick_setup_homebrew_service_note(
Path::new("/Users/alix/.zeroclaw/config.toml"),
Path::new("/Users/alix/.zeroclaw/workspace"),
Path::new("/opt/homebrew/bin/zeroclaw"),
)
.expect("homebrew installs should emit a service workspace note");
assert!(note.contains("/opt/homebrew/var/zeroclaw/workspace"));
assert!(note.contains("/opt/homebrew/var/zeroclaw/config.toml"));
assert!(note.contains("/Users/alix/.zeroclaw/config.toml"));
}
#[test]
fn quick_setup_homebrew_service_note_skips_matching_service_layout() {
let service_config = Path::new("/opt/homebrew/var/zeroclaw/config.toml");
let service_workspace = Path::new("/opt/homebrew/var/zeroclaw/workspace");
assert!(quick_setup_homebrew_service_note(
service_config,
service_workspace,
Path::new("/opt/homebrew/bin/zeroclaw"),
)
.is_none());
}
// ── scaffold_workspace: basic file creation ─────────────────
#[tokio::test]

View File

@ -211,9 +211,9 @@ impl AnthropicProvider {
text.len() > 3072
}
/// Cache conversations with more than 4 messages (excluding system)
/// Cache conversations with more than 1 non-system message (i.e. after first exchange)
fn should_cache_conversation(messages: &[ChatMessage]) -> bool {
messages.iter().filter(|m| m.role != "system").count() > 4
messages.iter().filter(|m| m.role != "system").count() > 1
}
/// Apply cache control to the last message content block
@ -447,17 +447,13 @@ impl AnthropicProvider {
}
}
// Convert system text to SystemPrompt with cache control if large
// Always use Blocks format with cache_control for system prompts
let system_prompt = system_text.map(|text| {
if Self::should_cache_system(&text) {
SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text,
cache_control: Some(CacheControl::ephemeral()),
}])
} else {
SystemPrompt::String(text)
}
SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text,
cache_control: Some(CacheControl::ephemeral()),
}])
});
(system_prompt, native_messages)
@ -1063,12 +1059,8 @@ mod tests {
role: "user".to_string(),
content: "Hello".to_string(),
},
ChatMessage {
role: "assistant".to_string(),
content: "Hi".to_string(),
},
];
// Only 2 non-system messages
// Only 1 non-system message — should not cache
assert!(!AnthropicProvider::should_cache_conversation(&messages));
}
@ -1078,8 +1070,8 @@ mod tests {
role: "system".to_string(),
content: "System prompt".to_string(),
}];
// Add 5 non-system messages
for i in 0..5 {
// Add 3 non-system messages
for i in 0..3 {
messages.push(ChatMessage {
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: format!("Message {i}"),
@ -1090,21 +1082,24 @@ mod tests {
#[test]
fn should_cache_conversation_boundary() {
let mut messages = vec![];
// Add exactly 4 non-system messages
for i in 0..4 {
messages.push(ChatMessage {
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: format!("Message {i}"),
});
}
let messages = vec![ChatMessage {
role: "user".to_string(),
content: "Hello".to_string(),
}];
// Exactly 1 non-system message — should not cache
assert!(!AnthropicProvider::should_cache_conversation(&messages));
// Add one more to cross boundary
messages.push(ChatMessage {
role: "user".to_string(),
content: "One more".to_string(),
});
// Add one more to cross boundary (>1)
let messages = vec![
ChatMessage {
role: "user".to_string(),
content: "Hello".to_string(),
},
ChatMessage {
role: "assistant".to_string(),
content: "Hi".to_string(),
},
];
assert!(AnthropicProvider::should_cache_conversation(&messages));
}
@ -1217,7 +1212,7 @@ mod tests {
}
#[test]
fn convert_messages_small_system_prompt() {
fn convert_messages_small_system_prompt_uses_blocks_with_cache() {
let messages = vec![ChatMessage {
role: "system".to_string(),
content: "Short system prompt".to_string(),
@ -1226,10 +1221,17 @@ mod tests {
let (system_prompt, _) = AnthropicProvider::convert_messages(&messages);
match system_prompt.unwrap() {
SystemPrompt::String(s) => {
assert_eq!(s, "Short system prompt");
SystemPrompt::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "Short system prompt");
assert!(
blocks[0].cache_control.is_some(),
"Small system prompts should have cache_control"
);
}
SystemPrompt::String(_) => {
panic!("Expected Blocks variant with cache_control for small prompt")
}
SystemPrompt::Blocks(_) => panic!("Expected String variant for small prompt"),
}
}
@ -1254,12 +1256,16 @@ mod tests {
}
#[test]
fn backward_compatibility_native_chat_request() {
// Test that requests without cache_control serialize identically to old format
fn native_chat_request_with_blocks_system() {
// System prompts now always use Blocks format with cache_control
let req = NativeChatRequest {
model: "claude-3-opus".to_string(),
max_tokens: 4096,
system: Some(SystemPrompt::String("System".to_string())),
system: Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: "System".to_string(),
cache_control: Some(CacheControl::ephemeral()),
}])),
messages: vec![NativeMessage {
role: "user".to_string(),
content: vec![NativeContentOut::Text {
@ -1272,8 +1278,11 @@ mod tests {
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("cache_control"));
assert!(json.contains(r#""system":"System""#));
assert!(json.contains("System"));
assert!(
json.contains(r#""cache_control":{"type":"ephemeral"}"#),
"System prompt should include cache_control"
);
}
#[tokio::test]

View File

@ -279,6 +279,7 @@ impl Provider for ClaudeCodeProvider {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
@ -375,32 +376,35 @@ mod tests {
/// Helper: create a provider that uses a shell script echoing stdin back.
/// The script ignores CLI flags (`--print`, `--model`, `-`) and just cats stdin.
///
/// Uses `OnceLock` to write the script file exactly once, avoiding
/// "Text file busy" (ETXTBSY) races when parallel tests try to
/// overwrite a script that another test is currently executing.
fn echo_provider() -> ClaudeCodeProvider {
use std::sync::OnceLock;
use std::io::Write;
static SCRIPT_PATH: OnceLock<PathBuf> = OnceLock::new();
let script = SCRIPT_PATH.get_or_init(|| {
use std::io::Write;
let dir = std::env::temp_dir().join("zeroclaw_test_claude_code");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("fake_claude_{}.sh", std::process::id()));
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "#!/bin/sh\ncat /dev/stdin").unwrap();
drop(f);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
path
});
ClaudeCodeProvider {
binary_path: script.clone(),
static SCRIPT_ID: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join("zeroclaw_test_claude_code");
std::fs::create_dir_all(&dir).unwrap();
let script_id = SCRIPT_ID.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!(
"fake_claude_{}_{}.sh",
std::process::id(),
script_id
));
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "#!/bin/sh\ncat /dev/stdin").unwrap();
drop(f);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
ClaudeCodeProvider { binary_path: path }
}
#[test]
fn echo_provider_uses_unique_script_paths() {
let first = echo_provider();
let second = echo_provider();
assert_ne!(first.binary_path, second.binary_path);
}
#[tokio::test]

View File

@ -335,6 +335,23 @@ impl OpenAiCompatibleProvider {
!path.is_empty() && path != "/"
}
fn requires_tool_stream(&self) -> bool {
let host_requires_tool_stream = reqwest::Url::parse(&self.base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_ascii_lowercase))
.is_some_and(|host| host == "api.z.ai" || host.ends_with(".z.ai"));
host_requires_tool_stream || matches!(self.name.as_str(), "zai" | "z.ai")
}
fn tool_stream_for_tools(&self, has_tools: bool) -> Option<bool> {
if has_tools && self.requires_tool_stream() {
Some(true)
} else {
None
}
}
/// Build the full URL for responses API, detecting if base_url already includes the path.
fn responses_url(&self) -> String {
if self.path_ends_with("/responses") {
@ -392,6 +409,8 @@ struct ApiChatRequest {
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
@ -590,6 +609,8 @@ struct NativeChatRequest {
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
@ -1264,6 +1285,7 @@ impl Provider for OpenAiCompatibleProvider {
temperature,
stream: Some(false),
reasoning_effort: self.reasoning_effort_for_model(model),
tool_stream: None,
tools: None,
tool_choice: None,
};
@ -1387,6 +1409,7 @@ impl Provider for OpenAiCompatibleProvider {
temperature,
stream: Some(false),
reasoning_effort: self.reasoning_effort_for_model(model),
tool_stream: None,
tools: None,
tool_choice: None,
};
@ -1498,6 +1521,7 @@ impl Provider for OpenAiCompatibleProvider {
temperature,
stream: Some(false),
reasoning_effort: self.reasoning_effort_for_model(model),
tool_stream: self.tool_stream_for_tools(!tools.is_empty()),
tools: if tools.is_empty() {
None
} else {
@ -1604,6 +1628,8 @@ impl Provider for OpenAiCompatibleProvider {
temperature,
stream: Some(false),
reasoning_effort: self.reasoning_effort_for_model(model),
tool_stream: self
.tool_stream_for_tools(tools.as_ref().is_some_and(|tools| !tools.is_empty())),
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
tools,
};
@ -1748,6 +1774,7 @@ impl Provider for OpenAiCompatibleProvider {
temperature,
stream: Some(options.enabled),
reasoning_effort: self.reasoning_effort_for_model(model),
tool_stream: None,
tools: None,
tool_choice: None,
};
@ -1890,6 +1917,7 @@ mod tests {
temperature: 0.4,
stream: Some(false),
reasoning_effort: None,
tool_stream: None,
tools: None,
tool_choice: None,
};
@ -2671,6 +2699,7 @@ mod tests {
temperature: 0.7,
stream: Some(false),
reasoning_effort: None,
tool_stream: None,
tools: Some(tools),
tool_choice: Some("auto".to_string()),
};
@ -2680,6 +2709,78 @@ mod tests {
assert!(json.contains("\"tool_choice\":\"auto\""));
}
#[test]
fn zai_tool_requests_enable_tool_stream() {
let provider = make_provider("zai", "https://api.z.ai/api/paas/v4", None);
let req = ApiChatRequest {
model: "glm-5".to_string(),
messages: vec![Message {
role: "user".to_string(),
content: MessageContent::Text("List /tmp".to_string()),
}],
temperature: 0.7,
stream: Some(false),
reasoning_effort: None,
tool_stream: provider.tool_stream_for_tools(true),
tools: Some(vec![serde_json::json!({
"type": "function",
"function": {
"name": "shell",
"description": "Run a shell command",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string"}
}
}
}
})]),
tool_choice: Some("auto".to_string()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"tool_stream\":true"));
}
#[test]
fn non_zai_tool_requests_omit_tool_stream() {
let provider = make_provider("test", "https://api.example.com/v1", None);
let req = ApiChatRequest {
model: "test-model".to_string(),
messages: vec![Message {
role: "user".to_string(),
content: MessageContent::Text("List /tmp".to_string()),
}],
temperature: 0.7,
stream: Some(false),
reasoning_effort: None,
tool_stream: provider.tool_stream_for_tools(true),
tools: Some(vec![serde_json::json!({
"type": "function",
"function": {
"name": "shell",
"description": "Run a shell command",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string"}
}
}
}
})]),
tool_choice: Some("auto".to_string()),
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("\"tool_stream\""));
}
#[test]
fn z_ai_host_enables_tool_stream_for_custom_profiles() {
let provider = make_provider("custom", "https://api.z.ai/api/coding/paas/v4", None);
assert_eq!(provider.tool_stream_for_tools(true), Some(true));
}
#[test]
fn response_with_tool_calls_deserializes() {
let json = r#"{

View File

@ -1119,7 +1119,13 @@ fn create_provider_with_url_and_options(
)?))
}
// ── Primary providers (custom implementations) ───────
"openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(key))),
"openrouter" => {
let mut p = openrouter::OpenRouterProvider::new(key);
if let Some(t) = options.provider_timeout_secs {
p = p.with_timeout_secs(t);
}
Ok(Box::new(p))
}
"anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(key))),
"openai" => Ok(Box::new(openai::OpenAiProvider::with_base_url(api_url, key))),
// Ollama uses api_url for custom base URL (e.g. remote Ollama instance)

View File

@ -4,12 +4,14 @@ use crate::providers::traits::{
Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall,
};
use crate::tools::ToolSpec;
use anyhow::Context as _;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
pub struct OpenRouterProvider {
credential: Option<String>,
timeout_secs: u64,
}
#[derive(Debug, Serialize)]
@ -149,9 +151,16 @@ impl OpenRouterProvider {
pub fn new(credential: Option<&str>) -> Self {
Self {
credential: credential.map(ToString::to_string),
timeout_secs: 120,
}
}
/// Override the HTTP request timeout for LLM API calls.
pub fn with_timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
let items = tools?;
if items.is_empty() {
@ -296,7 +305,11 @@ impl OpenRouterProvider {
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.openrouter", 120, 10)
crate::config::build_runtime_proxy_client_with_timeouts(
"provider.openrouter",
self.timeout_secs,
10,
)
}
}
@ -368,7 +381,13 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let chat_response: ApiChatResponse = response.json().await?;
let text = response.text().await?;
let chat_response: ApiChatResponse = serde_json::from_str(&text).with_context(|| {
format!(
"OpenRouter: failed to decode response body: {}",
&text[..text.len().min(500)]
)
})?;
chat_response
.choices
@ -415,7 +434,13 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let chat_response: ApiChatResponse = response.json().await?;
let text = response.text().await?;
let chat_response: ApiChatResponse = serde_json::from_str(&text).with_context(|| {
format!(
"OpenRouter: failed to decode response body: {}",
&text[..text.len().min(500)]
)
})?;
chat_response
.choices
@ -460,7 +485,14 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let native_response: NativeChatResponse = response.json().await?;
let text = response.text().await?;
let native_response: NativeChatResponse =
serde_json::from_str(&text).with_context(|| {
format!(
"OpenRouter: failed to decode response body: {}",
&text[..text.len().min(500)]
)
})?;
let usage = native_response.usage.map(|u| TokenUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
@ -552,7 +584,14 @@ impl Provider for OpenRouterProvider {
return Err(super::api_error("OpenRouter", response).await);
}
let native_response: NativeChatResponse = response.json().await?;
let text = response.text().await?;
let native_response: NativeChatResponse =
serde_json::from_str(&text).with_context(|| {
format!(
"OpenRouter: failed to decode response body: {}",
&text[..text.len().min(500)]
)
})?;
let usage = native_response.usage.map(|u| TokenUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
@ -1017,4 +1056,20 @@ mod tests {
assert!(json.contains("reasoning_content"));
assert!(json.contains("thinking..."));
}
// ═══════════════════════════════════════════════════════════════════════
// timeout_secs configuration tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn default_timeout_is_120() {
let provider = OpenRouterProvider::new(Some("key"));
assert_eq!(provider.timeout_secs, 120);
}
#[test]
fn with_timeout_secs_overrides_default() {
let provider = OpenRouterProvider::new(Some("key")).with_timeout_secs(300);
assert_eq!(provider.timeout_secs, 300);
}
}

View File

@ -22,6 +22,13 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool {
return false;
}
// Tool schema validation errors are NOT non-retryable — the provider's
// built-in fallback in compatible.rs can recover by switching to
// prompt-guided tool instructions.
if is_tool_schema_error(err) {
return false;
}
// 4xx errors are generally non-retryable (bad request, auth failure, etc.),
// except 429 (rate-limit — transient) and 408 (timeout — worth retrying).
if let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() {
@ -73,6 +80,22 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool {
|| msg_lower.contains("invalid"))
}
/// Check if an error is a tool schema validation failure (e.g. Groq returning
/// "tool call validation failed: attempted to call tool '...' which was not in request").
/// These errors should NOT be classified as non-retryable because the provider's
/// built-in fallback logic (`compatible.rs::is_native_tool_schema_unsupported`)
/// can recover by switching to prompt-guided tool instructions.
pub fn is_tool_schema_error(err: &anyhow::Error) -> bool {
let lower = err.to_string().to_lowercase();
let hints = [
"tool call validation failed",
"was not in request",
"not found in tool list",
"invalid_tool_call",
];
hints.iter().any(|hint| lower.contains(hint))
}
fn is_context_window_exceeded(err: &anyhow::Error) -> bool {
let lower = err.to_string().to_lowercase();
let hints = [
@ -2189,4 +2212,55 @@ mod tests {
// Should have been called twice: once with full messages, once with truncated
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
// ── Tool schema error detection tests ───────────────────────────────
#[test]
fn tool_schema_error_detects_groq_validation_failure() {
let msg = r#"Groq API error (400 Bad Request): {"error":{"message":"tool call validation failed: attempted to call tool 'memory_recall' which was not in request"}}"#;
let err = anyhow::anyhow!("{}", msg);
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_detects_not_in_request() {
let err = anyhow::anyhow!("tool 'search' was not in request");
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_detects_not_found_in_tool_list() {
let err = anyhow::anyhow!("function 'foo' not found in tool list");
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_detects_invalid_tool_call() {
let err = anyhow::anyhow!("invalid_tool_call: no matching function");
assert!(is_tool_schema_error(&err));
}
#[test]
fn tool_schema_error_ignores_unrelated_errors() {
let err = anyhow::anyhow!("invalid api key");
assert!(!is_tool_schema_error(&err));
let err = anyhow::anyhow!("model not found");
assert!(!is_tool_schema_error(&err));
}
#[test]
fn non_retryable_returns_false_for_tool_schema_400() {
// A 400 error with tool schema validation text should NOT be non-retryable.
let msg = "400 Bad Request: tool call validation failed: attempted to call tool 'x' which was not in request";
let err = anyhow::anyhow!("{}", msg);
assert!(!is_non_retryable(&err));
}
#[test]
fn non_retryable_returns_true_for_other_400_errors() {
// A regular 400 error (e.g. invalid API key) should still be non-retryable.
let err = anyhow::anyhow!("400 Bad Request: invalid api key provided");
assert!(is_non_retryable(&err));
}
}

View File

@ -234,6 +234,26 @@ fn expand_user_path(path: &str) -> PathBuf {
PathBuf::from(path)
}
fn rootless_path(path: &Path) -> Option<PathBuf> {
let mut relative = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::Prefix(_)
| std::path::Component::RootDir
| std::path::Component::CurDir => {}
std::path::Component::ParentDir => return None,
std::path::Component::Normal(part) => relative.push(part),
}
}
if relative.as_os_str().is_empty() {
None
} else {
Some(relative)
}
}
// ── Shell Command Parsing Utilities ───────────────────────────────────────
// These helpers implement a minimal quote-aware shell lexer. They exist
// because security validation must reason about the *structure* of a
@ -1173,6 +1193,44 @@ impl SecurityPolicy {
false
}
fn runtime_config_dir(&self) -> Option<PathBuf> {
let parent = self.workspace_dir.parent()?;
Some(
parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf()),
)
}
pub fn is_runtime_config_path(&self, resolved: &Path) -> bool {
let Some(config_dir) = self.runtime_config_dir() else {
return false;
};
if !resolved.starts_with(&config_dir) {
return false;
}
if resolved.parent() != Some(config_dir.as_path()) {
return false;
}
let Some(file_name) = resolved.file_name().and_then(|value| value.to_str()) else {
return false;
};
file_name == "config.toml"
|| file_name == "config.toml.bak"
|| file_name == "active_workspace.toml"
|| file_name.starts_with(".config.toml.tmp-")
|| file_name.starts_with(".active_workspace.toml.tmp-")
}
pub fn runtime_config_violation_message(&self, resolved: &Path) -> String {
format!(
"Refusing to modify ZeroClaw runtime config/state file: {}. Use dedicated config tools or edit it manually outside the agent loop.",
resolved.display()
)
}
pub fn resolved_path_violation_message(&self, resolved: &Path) -> String {
let guidance = if self.allowed_roots.is_empty() {
"Add the directory to [autonomy].allowed_roots (for example: allowed_roots = [\"/absolute/path\"]), or move the file into the workspace."
@ -1245,6 +1303,16 @@ impl SecurityPolicy {
let expanded = expand_user_path(path);
if expanded.is_absolute() {
expanded
} else if let Some(workspace_hint) = rootless_path(&self.workspace_dir) {
if let Ok(stripped) = expanded.strip_prefix(&workspace_hint) {
if stripped.as_os_str().is_empty() {
self.workspace_dir.clone()
} else {
self.workspace_dir.join(stripped)
}
} else {
self.workspace_dir.join(expanded)
}
} else {
self.workspace_dir.join(expanded)
}
@ -2720,6 +2788,19 @@ mod tests {
assert_eq!(resolved, PathBuf::from("/workspace/relative/path.txt"));
}
#[test]
fn resolve_tool_path_normalizes_workspace_prefixed_relative_paths() {
let p = SecurityPolicy {
workspace_dir: PathBuf::from("/zeroclaw-data/workspace"),
..SecurityPolicy::default()
};
let resolved = p.resolve_tool_path("zeroclaw-data/workspace/scripts/daily.py");
assert_eq!(
resolved,
PathBuf::from("/zeroclaw-data/workspace/scripts/daily.py")
);
}
#[test]
fn is_under_allowed_root_matches_allowed_roots() {
let p = SecurityPolicy {
@ -2744,4 +2825,33 @@ mod tests {
};
assert!(!p.is_under_allowed_root("/any/path"));
}
#[test]
fn runtime_config_paths_are_protected() {
let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace");
let policy = SecurityPolicy {
workspace_dir: workspace.clone(),
..SecurityPolicy::default()
};
let config_dir = workspace.parent().unwrap();
assert!(policy.is_runtime_config_path(&config_dir.join("config.toml")));
assert!(policy.is_runtime_config_path(&config_dir.join("config.toml.bak")));
assert!(policy.is_runtime_config_path(&config_dir.join(".config.toml.tmp-1234")));
assert!(policy.is_runtime_config_path(&config_dir.join("active_workspace.toml")));
assert!(policy.is_runtime_config_path(&config_dir.join(".active_workspace.toml.tmp-1234")));
}
#[test]
fn workspace_files_are_not_runtime_config_paths() {
let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace");
let policy = SecurityPolicy {
workspace_dir: workspace.clone(),
..SecurityPolicy::default()
};
let nested_dir = workspace.join("notes");
assert!(!policy.is_runtime_config_path(&workspace.join("notes.txt")));
assert!(!policy.is_runtime_config_path(&nested_dir.join("config.toml")));
}
}

View File

@ -409,13 +409,43 @@ fn has_shell_shebang(path: &Path) -> bool {
return false;
};
let prefix = &content[..content.len().min(128)];
let shebang = String::from_utf8_lossy(prefix).to_ascii_lowercase();
shebang.starts_with("#!")
&& (shebang.contains("sh")
|| shebang.contains("bash")
|| shebang.contains("zsh")
|| shebang.contains("pwsh")
|| shebang.contains("powershell"))
let shebang_line = String::from_utf8_lossy(prefix)
.lines()
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let Some(interpreter) = shebang_interpreter(&shebang_line) else {
return false;
};
matches!(
interpreter,
"sh" | "bash" | "zsh" | "ksh" | "fish" | "pwsh" | "powershell"
)
}
fn shebang_interpreter(line: &str) -> Option<&str> {
let shebang = line.strip_prefix("#!")?.trim();
if shebang.is_empty() {
return None;
}
let mut parts = shebang.split_whitespace();
let first = parts.next()?;
let first_basename = Path::new(first).file_name()?.to_str()?;
if first_basename == "env" {
for part in parts {
if part.starts_with('-') {
continue;
}
return Path::new(part).file_name()?.to_str();
}
return None;
}
Some(first_basename)
}
fn extract_markdown_links(content: &str) -> Vec<String> {
@ -586,6 +616,30 @@ mod tests {
);
}
#[test]
fn audit_allows_python_shebang_file_when_early_text_contains_sh() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("python-helper");
let scripts_dir = skill_dir.join("scripts");
std::fs::create_dir_all(&scripts_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap();
std::fs::write(
scripts_dir.join("helper.py"),
"#!/usr/bin/env python3\n\"\"\"Refresh report cache.\"\"\"\n\nprint(\"ok\")\n",
)
.unwrap();
let report = audit_skill_directory(&skill_dir).unwrap();
assert!(
!report
.findings
.iter()
.any(|finding| finding.contains("script-like files are blocked")),
"{:#?}",
report.findings
);
}
#[test]
fn audit_rejects_markdown_escape_links() {
let dir = tempfile::tempdir().unwrap();

View File

@ -97,6 +97,15 @@ pub fn load_skills_with_config(workspace_dir: &Path, config: &crate::config::Con
)
}
/// Load skills using explicit open-skills settings.
pub fn load_skills_with_open_skills_settings(
workspace_dir: &Path,
open_skills_enabled: bool,
open_skills_dir: Option<&str>,
) -> Vec<Skill> {
load_skills_with_open_skills_config(workspace_dir, Some(open_skills_enabled), open_skills_dir)
}
fn load_skills_with_open_skills_config(
workspace_dir: &Path,
config_open_skills_enabled: Option<bool>,
@ -674,7 +683,8 @@ pub fn skills_to_prompt_with_mode(
crate::config::SkillsPromptInjectionMode::Compact => String::from(
"## Available Skills\n\n\
Skill summaries are preloaded below to keep context compact.\n\
Skill instructions are loaded on demand: read the skill file in `location` only when needed.\n\n\
Skill instructions are loaded on demand: call `read_skill(name)` with the skill's `<name>` when you need the full skill file.\n\
The `location` field is included for reference.\n\n\
<available_skills>\n",
),
};
@ -1267,6 +1277,7 @@ command = "echo hello"
assert!(prompt.contains("<name>test</name>"));
assert!(prompt.contains("<location>skills/test/SKILL.md</location>"));
assert!(prompt.contains("loaded on demand"));
assert!(prompt.contains("read_skill(name)"));
assert!(!prompt.contains("<instructions>"));
assert!(!prompt.contains("<instruction>Do the thing.</instruction>"));
assert!(!prompt.contains("<tools>"));

View File

@ -130,6 +130,11 @@ impl Tool for CronAddTool {
"type": "string",
"description": "Optional model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
},
"allowed_tools": {
"type": "array",
"items": { "type": "string" },
"description": "Optional allowlist of tool names for agent jobs. When omitted, all tools remain available."
},
"delivery": {
"type": "object",
"description": "Optional delivery config to send job output to a channel after each run. When provided, all three of mode, channel, and to are expected.",
@ -288,6 +293,19 @@ impl Tool for CronAddTool {
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let allowed_tools = match args.get("allowed_tools") {
Some(v) => match serde_json::from_value::<Vec<String>>(v.clone()) {
Ok(v) => Some(v),
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Invalid allowed_tools: {e}")),
});
}
},
None => None,
};
let delivery = match args.get("delivery") {
Some(v) => match serde_json::from_value::<DeliveryConfig>(v.clone()) {
@ -316,6 +334,7 @@ impl Tool for CronAddTool {
model,
delivery,
delete_after_run,
allowed_tools,
)
}
};
@ -329,7 +348,8 @@ impl Tool for CronAddTool {
"job_type": job.job_type,
"schedule": job.schedule,
"next_run": job.next_run,
"enabled": job.enabled
"enabled": job.enabled,
"allowed_tools": job.allowed_tools
}))?,
error: None,
}),
@ -612,6 +632,32 @@ mod tests {
.contains("Missing 'prompt'"));
}
#[tokio::test]
async fn agent_job_persists_allowed_tools() {
let tmp = TempDir::new().unwrap();
let cfg = test_config(&tmp).await;
let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
let result = tool
.execute(json!({
"schedule": { "kind": "cron", "expr": "*/5 * * * *" },
"job_type": "agent",
"prompt": "check status",
"allowed_tools": ["file_read", "web_search"]
}))
.await
.unwrap();
assert!(result.success, "{:?}", result.error);
let jobs = cron::list_jobs(&cfg).unwrap();
assert_eq!(jobs.len(), 1);
assert_eq!(
jobs[0].allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
}
#[tokio::test]
async fn delivery_schema_includes_matrix_channel() {
let tmp = TempDir::new().unwrap();

View File

@ -89,6 +89,11 @@ impl Tool for CronUpdateTool {
"type": "string",
"description": "Model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
},
"allowed_tools": {
"type": "array",
"items": { "type": "string" },
"description": "Optional replacement allowlist of tool names for agent jobs"
},
"session_target": {
"type": "string",
"enum": ["isolated", "main"],
@ -403,6 +408,7 @@ mod tests {
"command",
"prompt",
"model",
"allowed_tools",
"session_target",
"delete_after_run",
"schedule",
@ -501,4 +507,40 @@ mod tests {
.contains("Rate limit exceeded"));
assert!(cron::get_job(&cfg, &job.id).unwrap().enabled);
}
#[tokio::test]
async fn updates_agent_allowed_tools() {
let tmp = TempDir::new().unwrap();
let cfg = test_config(&tmp).await;
let job = cron::add_agent_job(
&cfg,
None,
crate::cron::Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"check status",
crate::cron::SessionTarget::Isolated,
None,
None,
false,
None,
)
.unwrap();
let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg));
let result = tool
.execute(json!({
"job_id": job.id,
"patch": { "allowed_tools": ["file_read", "web_search"] }
}))
.await
.unwrap();
assert!(result.success, "{:?}", result.error);
assert_eq!(
cron::get_job(&cfg, &job.id).unwrap().allowed_tools,
Some(vec!["file_read".into(), "web_search".into()])
);
}
}

View File

@ -418,6 +418,7 @@ impl DelegateTool {
true,
None,
"delegate",
None,
&self.multimodal_config,
agent_config.max_iterations,
None,

View File

@ -147,6 +147,17 @@ impl Tool for FileEditTool {
let resolved_target = resolved_parent.join(file_name);
if self.security.is_runtime_config_path(&resolved_target) {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
self.security
.runtime_config_violation_message(&resolved_target),
),
});
}
// ── 7. Symlink check ───────────────────────────────────────
if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await {
if meta.file_type().is_symlink() {
@ -495,6 +506,42 @@ mod tests {
assert!(result.error.as_ref().unwrap().contains("not allowed"));
}
#[tokio::test]
async fn file_edit_normalizes_workspace_prefixed_relative_path() {
let root = std::env::temp_dir().join("zeroclaw_test_file_edit_workspace_prefixed");
let workspace = root.join("workspace");
let _ = tokio::fs::remove_dir_all(&root).await;
tokio::fs::create_dir_all(workspace.join("nested"))
.await
.unwrap();
tokio::fs::write(workspace.join("nested/target.txt"), "hello world")
.await
.unwrap();
let tool = FileEditTool::new(test_security(workspace.clone()));
let workspace_prefixed = workspace
.strip_prefix(std::path::Path::new("/"))
.unwrap()
.join("nested/target.txt");
let result = tool
.execute(json!({
"path": workspace_prefixed.to_string_lossy(),
"old_string": "world",
"new_string": "zeroclaw"
}))
.await
.unwrap();
assert!(result.success);
let content = tokio::fs::read_to_string(workspace.join("nested/target.txt"))
.await
.unwrap();
assert_eq!(content, "hello zeroclaw");
assert!(!workspace.join(workspace_prefixed).exists());
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[cfg(unix)]
#[tokio::test]
async fn file_edit_blocks_symlink_escape() {
@ -726,4 +773,42 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_edit_blocks_runtime_config_path() {
let root = std::env::temp_dir().join("zeroclaw_test_file_edit_runtime_config");
let workspace = root.join("workspace");
let config_path = root.join("config.toml");
let _ = tokio::fs::remove_dir_all(&root).await;
tokio::fs::create_dir_all(&workspace).await.unwrap();
tokio::fs::write(&config_path, "always_ask = [\"cron_add\"]")
.await
.unwrap();
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
workspace_dir: workspace.clone(),
workspace_only: false,
allowed_roots: vec![root.clone()],
forbidden_paths: vec![],
..SecurityPolicy::default()
});
let tool = FileEditTool::new(security);
let result = tool
.execute(json!({
"path": config_path.to_string_lossy(),
"old_string": "always_ask",
"new_string": "auto_approve"
}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.unwrap_or_default()
.contains("runtime config/state file"));
let _ = tokio::fs::remove_dir_all(&root).await;
}
}

View File

@ -124,6 +124,17 @@ impl Tool for FileWriteTool {
let resolved_target = resolved_parent.join(file_name);
if self.security.is_runtime_config_path(&resolved_target) {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
self.security
.runtime_config_violation_message(&resolved_target),
),
});
}
// If the target already exists and is a symlink, refuse to follow it
if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await {
if meta.file_type().is_symlink() {
@ -247,6 +258,36 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_write_normalizes_workspace_prefixed_relative_path() {
let root = std::env::temp_dir().join("zeroclaw_test_file_write_workspace_prefixed");
let workspace = root.join("workspace");
let _ = tokio::fs::remove_dir_all(&root).await;
tokio::fs::create_dir_all(&workspace).await.unwrap();
let tool = FileWriteTool::new(test_security(workspace.clone()));
let workspace_prefixed = workspace
.strip_prefix(std::path::Path::new("/"))
.unwrap()
.join("nested/out.txt");
let result = tool
.execute(json!({
"path": workspace_prefixed.to_string_lossy(),
"content": "written!"
}))
.await
.unwrap();
assert!(result.success);
let content = tokio::fs::read_to_string(workspace.join("nested/out.txt"))
.await
.unwrap();
assert_eq!(content, "written!");
assert!(!workspace.join(workspace_prefixed).exists());
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test]
async fn file_write_overwrites_existing() {
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_overwrite");
@ -499,4 +540,38 @@ mod tests {
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn file_write_blocks_runtime_config_path() {
let root = std::env::temp_dir().join("zeroclaw_test_file_write_runtime_config");
let workspace = root.join("workspace");
let config_path = root.join("config.toml");
let _ = tokio::fs::remove_dir_all(&root).await;
tokio::fs::create_dir_all(&workspace).await.unwrap();
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
workspace_dir: workspace.clone(),
workspace_only: false,
allowed_roots: vec![root.clone()],
forbidden_paths: vec![],
..SecurityPolicy::default()
});
let tool = FileWriteTool::new(security);
let result = tool
.execute(json!({
"path": config_path.to_string_lossy(),
"content": "auto_approve = [\"cron_add\"]"
}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.unwrap_or_default()
.contains("runtime config/state file"));
let _ = tokio::fs::remove_dir_all(&root).await;
}
}

View File

@ -66,6 +66,7 @@ pub mod pdf_read;
pub mod project_intel;
pub mod proxy_config;
pub mod pushover;
pub mod read_skill;
pub mod report_templates;
pub mod schedule;
pub mod schema;
@ -128,6 +129,7 @@ pub use pdf_read::PdfReadTool;
pub use project_intel::ProjectIntelTool;
pub use proxy_config::ProxyConfigTool;
pub use pushover::PushoverTool;
pub use read_skill::ReadSkillTool;
pub use schedule::ScheduleTool;
#[allow(unused_imports)]
pub use schema::{CleaningStrategy, SchemaCleanr};
@ -146,7 +148,7 @@ pub use workspace_tool::WorkspaceTool;
use crate::config::{Config, DelegateAgentConfig};
use crate::memory::Memory;
use crate::runtime::{NativeRuntime, RuntimeAdapter};
use crate::security::SecurityPolicy;
use crate::security::{create_sandbox, SecurityPolicy};
use async_trait::async_trait;
use parking_lot::RwLock;
use std::collections::HashMap;
@ -283,8 +285,13 @@ pub fn all_tools_with_runtime(
root_config: &crate::config::Config,
) -> (Vec<Box<dyn Tool>>, Option<DelegateParentToolsHandle>) {
let has_shell_access = runtime.has_shell_access();
let sandbox = create_sandbox(&root_config.security);
let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
Arc::new(ShellTool::new(security.clone(), runtime)),
Arc::new(ShellTool::new_with_sandbox(
security.clone(),
runtime,
sandbox,
)),
Arc::new(FileReadTool::new(security.clone())),
Arc::new(FileWriteTool::new(security.clone())),
Arc::new(FileEditTool::new(security.clone())),
@ -316,6 +323,17 @@ pub fn all_tools_with_runtime(
)),
];
if matches!(
root_config.skills.prompt_injection_mode,
crate::config::SkillsPromptInjectionMode::Compact
) {
tool_arcs.push(Arc::new(ReadSkillTool::new(
workspace_dir.to_path_buf(),
root_config.skills.open_skills_enabled,
root_config.skills.open_skills_dir.clone(),
)));
}
if browser_config.enabled {
// Add legacy browser_open tool for simple URL opening
tool_arcs.push(Arc::new(BrowserOpenTool::new(
@ -972,4 +990,72 @@ mod tests {
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"delegate"));
}
#[test]
fn all_tools_includes_read_skill_in_compact_mode() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let mut cfg = test_config(&tmp);
cfg.skills.prompt_injection_mode = crate::config::SkillsPromptInjectionMode::Compact;
let (tools, _) = all_tools(
Arc::new(cfg.clone()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"read_skill"));
}
#[test]
fn all_tools_excludes_read_skill_in_full_mode() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let mut cfg = test_config(&tmp);
cfg.skills.prompt_injection_mode = crate::config::SkillsPromptInjectionMode::Full;
let (tools, _) = all_tools(
Arc::new(cfg.clone()),
&security,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"read_skill"));
}
}

187
src/tools/read_skill.rs Normal file
View File

@ -0,0 +1,187 @@
use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::path::PathBuf;
/// Compact-mode helper for loading a skill's source file on demand.
pub struct ReadSkillTool {
workspace_dir: PathBuf,
open_skills_enabled: bool,
open_skills_dir: Option<String>,
}
impl ReadSkillTool {
pub fn new(
workspace_dir: PathBuf,
open_skills_enabled: bool,
open_skills_dir: Option<String>,
) -> Self {
Self {
workspace_dir,
open_skills_enabled,
open_skills_dir,
}
}
}
#[async_trait]
impl Tool for ReadSkillTool {
fn name(&self) -> &str {
"read_skill"
}
fn description(&self) -> &str {
"Read the full source file for an available skill by name. Use this in compact skills mode when you need the complete skill instructions without remembering file paths."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The skill name exactly as listed in <available_skills>."
}
},
"required": ["name"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let requested = args
.get("name")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?;
let skills = crate::skills::load_skills_with_open_skills_settings(
&self.workspace_dir,
self.open_skills_enabled,
self.open_skills_dir.as_deref(),
);
let Some(skill) = skills
.iter()
.find(|skill| skill.name.eq_ignore_ascii_case(requested))
else {
let mut names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect();
names.sort_unstable();
let available = if names.is_empty() {
"none".to_string()
} else {
names.join(", ")
};
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Unknown skill '{requested}'. Available skills: {available}"
)),
});
};
let Some(location) = skill.location.as_ref() else {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Skill '{}' has no readable source location.",
skill.name
)),
});
};
match tokio::fs::read_to_string(location).await {
Ok(output) => Ok(ToolResult {
success: true,
output,
error: None,
}),
Err(err) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Failed to read skill '{}' from {}: {err}",
skill.name,
location.display()
)),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_tool(tmp: &TempDir) -> ReadSkillTool {
ReadSkillTool::new(tmp.path().join("workspace"), false, None)
}
#[tokio::test]
async fn reads_markdown_skill_by_name() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("workspace/skills/weather");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"# Weather\n\nUse this skill for forecast lookups.\n",
)
.unwrap();
let result = make_tool(&tmp)
.execute(json!({ "name": "weather" }))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("# Weather"));
assert!(result.output.contains("forecast lookups"));
}
#[tokio::test]
async fn reads_toml_skill_manifest_by_name() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("workspace/skills/deploy");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.toml"),
r#"[skill]
name = "deploy"
description = "Ship safely"
"#,
)
.unwrap();
let result = make_tool(&tmp)
.execute(json!({ "name": "deploy" }))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("[skill]"));
assert!(result.output.contains("Ship safely"));
}
#[tokio::test]
async fn unknown_skill_lists_available_names() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("workspace/skills/weather");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# Weather\n").unwrap();
let result = make_tool(&tmp)
.execute(json!({ "name": "calendar" }))
.await
.unwrap();
assert!(!result.success);
assert_eq!(
result.error.as_deref(),
Some("Unknown skill 'calendar'. Available skills: weather")
);
}
}

View File

@ -1,5 +1,6 @@
use super::traits::{Tool, ToolResult};
use crate::runtime::RuntimeAdapter;
use crate::security::traits::Sandbox;
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
@ -44,11 +45,28 @@ const SAFE_ENV_VARS: &[&str] = &[
pub struct ShellTool {
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
sandbox: Arc<dyn Sandbox>,
}
impl ShellTool {
pub fn new(security: Arc<SecurityPolicy>, runtime: Arc<dyn RuntimeAdapter>) -> Self {
Self { security, runtime }
Self {
security,
runtime,
sandbox: Arc::new(crate::security::NoopSandbox),
}
}
pub fn new_with_sandbox(
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
sandbox: Arc<dyn Sandbox>,
) -> Self {
Self {
security,
runtime,
sandbox,
}
}
}
@ -169,6 +187,14 @@ impl Tool for ShellTool {
});
}
};
// Apply sandbox wrapping before execution.
// The Sandbox trait operates on std::process::Command, so use as_std_mut()
// to get a mutable reference to the underlying command.
self.sandbox
.wrap_command(cmd.as_std_mut())
.map_err(|e| anyhow::anyhow!("Sandbox error: {}", e))?;
cmd.env_clear();
for var in collect_allowed_shell_env_vars(&self.security) {
@ -690,4 +716,59 @@ mod tests {
|| r2.error.as_deref().unwrap_or("").contains("budget")
);
}
// ── Sandbox integration tests ────────────────────────
#[test]
fn shell_tool_can_be_constructed_with_sandbox() {
use crate::security::NoopSandbox;
let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
let tool = ShellTool::new_with_sandbox(
test_security(AutonomyLevel::Supervised),
test_runtime(),
sandbox,
);
assert_eq!(tool.name(), "shell");
}
#[test]
fn noop_sandbox_does_not_modify_command() {
use crate::security::NoopSandbox;
let sandbox = NoopSandbox;
let mut cmd = std::process::Command::new("echo");
cmd.arg("hello");
let program_before = cmd.get_program().to_os_string();
let args_before: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect();
sandbox
.wrap_command(&mut cmd)
.expect("wrap_command should succeed");
assert_eq!(cmd.get_program(), program_before);
assert_eq!(
cmd.get_args().map(|a| a.to_os_string()).collect::<Vec<_>>(),
args_before
);
}
#[tokio::test]
async fn shell_executes_with_sandbox() {
use crate::security::NoopSandbox;
let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
let tool = ShellTool::new_with_sandbox(
test_security(AutonomyLevel::Supervised),
test_runtime(),
sandbox,
);
let result = tool
.execute(json!({"command": "echo sandbox_test"}))
.await
.expect("command with sandbox should succeed");
assert!(result.success);
assert!(result.output.contains("sandbox_test"));
}
}

View File

@ -72,11 +72,11 @@ fn agent_config_default_tool_dispatcher() {
}
#[test]
fn agent_config_default_compact_context_off() {
fn agent_config_default_compact_context_on() {
let agent = AgentConfig::default();
assert!(
!agent.compact_context,
"compact_context should default to false"
agent.compact_context,
"compact_context should default to true"
);
}
@ -204,7 +204,7 @@ default_temperature = 0.7
// Agent config should use defaults
assert_eq!(parsed.agent.max_tool_iterations, 10);
assert_eq!(parsed.agent.max_history_messages, 50);
assert!(!parsed.agent.compact_context);
assert!(parsed.agent.compact_context);
}
#[test]

BIN
web/dist/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -193,6 +193,25 @@ export function getCronRuns(
).then((data) => unwrapField(data, 'runs'));
}
export interface CronSettings {
enabled: boolean;
catch_up_on_startup: boolean;
max_run_history: number;
}
export function getCronSettings(): Promise<CronSettings> {
return apiFetch<CronSettings>('/api/cron/settings');
}
export function patchCronSettings(
patch: Partial<CronSettings>,
): Promise<CronSettings> {
return apiFetch<CronSettings & { status: string }>('/api/cron/settings', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------

View File

@ -12,7 +12,15 @@ import {
RefreshCw,
} from 'lucide-react';
import type { CronJob, CronRun } from '@/types/api';
import { getCronJobs, addCronJob, deleteCronJob, getCronRuns } from '@/lib/api';
import {
getCronJobs,
addCronJob,
deleteCronJob,
getCronRuns,
getCronSettings,
patchCronSettings,
} from '@/lib/api';
import type { CronSettings } from '@/lib/api';
import { t } from '@/lib/i18n';
function formatDate(iso: string | null): string {
@ -143,6 +151,8 @@ export default function Cron() {
const [showForm, setShowForm] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [expandedJob, setExpandedJob] = useState<string | null>(null);
const [settings, setSettings] = useState<CronSettings | null>(null);
const [togglingCatchUp, setTogglingCatchUp] = useState(false);
// Form state
const [formName, setFormName] = useState('');
@ -159,8 +169,28 @@ export default function Cron() {
.finally(() => setLoading(false));
};
const fetchSettings = () => {
getCronSettings().then(setSettings).catch(() => {});
};
const toggleCatchUp = async () => {
if (!settings) return;
setTogglingCatchUp(true);
try {
const updated = await patchCronSettings({
catch_up_on_startup: !settings.catch_up_on_startup,
});
setSettings(updated);
} catch {
// silently fail — user can retry
} finally {
setTogglingCatchUp(false);
}
};
useEffect(() => {
fetchJobs();
fetchSettings();
}, []);
const handleAdd = async () => {
@ -250,6 +280,37 @@ export default function Cron() {
</button>
</div>
{/* Catch-up toggle */}
{settings && (
<div className="glass-card px-4 py-3 flex items-center justify-between">
<div>
<span className="text-sm font-medium text-white">
Catch up missed jobs on startup
</span>
<p className="text-xs text-[#556080] mt-0.5">
Run all overdue jobs when ZeroClaw starts after downtime
</p>
</div>
<button
onClick={toggleCatchUp}
disabled={togglingCatchUp}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-300 focus:outline-none ${
settings.catch_up_on_startup
? 'bg-[#0080ff]'
: 'bg-[#1a1a3e]'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform duration-300 ${
settings.catch_up_on_startup
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
)}
{/* Add Job Form Modal */}
{showForm && (
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">